Skip to content

Commit 97e27b7

Browse files
committed
✨ feat(discovery): add iter_interpreters for enumeration
Callers wanting every interpreter on the system, rather than the first match, had to abuse get_interpreter's predicate as a side-channel: return False for every candidate so the search never stops, accumulate into a dict keyed by realpath. The workaround missed any interpreter whose binary on PATH is named pypy or graalpy, and surfaced /bin/python and /usr/bin/python as separate entries even though they resolve to the same file. iter_interpreters yields every match in discovery order, deduplicated by the resolved real path of system_executable so symlinked aliases and venvs that symlink to a base interpreter collapse to a single entry. With no spec it broadens the PATH and uv-install regex to every name in KNOWN_IMPLEMENTATIONS, because an "all interpreters" call that quietly drops PyPy and GraalPy would just shift the gotcha. get_interpreter's narrow regex stays as it was so tools that have always read "no implementation in the spec" as "give me CPython" keep that behaviour. Closes #65.
1 parent 791d139 commit 97e27b7

8 files changed

Lines changed: 396 additions & 15 deletions

File tree

docs/changelog/65.feature.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
add :func:`~python_discovery.iter_interpreters` for enumerating every discovered interpreter, with PATH and
2+
UV-install support for non-CPython implementations listed in :data:`~python_discovery.KNOWN_IMPLEMENTATIONS`
3+
- by :user:`gaborbernat`.

docs/explanation.rst

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,45 @@ detects these shims and resolves them to the actual binary.
6565
`mise <https://mise.jdx.dev/>`_ and `asdf <https://asdf-vm.com/>`_ work similarly, using the
6666
``MISE_DATA_DIR`` and ``ASDF_DATA_DIR`` environment variables to locate their installations.
6767

68+
Selecting one interpreter vs. enumerating all of them
69+
-------------------------------------------------------
70+
71+
:func:`~python_discovery.get_interpreter` and :func:`~python_discovery.iter_interpreters` walk the same candidate
72+
sources, but they answer different questions and behave differently in three ways.
73+
74+
.. mermaid::
75+
76+
flowchart LR
77+
Sources["candidate sources<br>(try_first_with → current →<br>PEP 514 → PATH → uv)"]
78+
Sources --> Get["get_interpreter()<br>first match wins, returns one"]
79+
Sources --> Iter["iter_interpreters()<br>yields every match"]
80+
81+
style Get fill:#4a9f4a,stroke:#2a6f2a,color:#fff
82+
style Iter fill:#4a90d9,stroke:#2a5f8f,color:#fff
83+
84+
**Implementation coverage on PATH.** :func:`~python_discovery.get_interpreter` matches only ``python*`` filenames on
85+
PATH unless the spec names another implementation explicitly (``pypy3.12``, ``graalpy3.11``). This keeps backwards
86+
compatibility with tools that have always read "no implementation in the spec" as "give me CPython."
87+
:func:`~python_discovery.iter_interpreters` with no spec broadens the search to every name in
88+
:data:`~python_discovery.KNOWN_IMPLEMENTATIONS` -- otherwise an "all interpreters" call would silently miss every
89+
PyPy and GraalPy on the system. When you pass a spec to :func:`~python_discovery.iter_interpreters`, it falls back
90+
to the same narrow regex as :func:`~python_discovery.get_interpreter`, so behaviour is consistent across the two
91+
APIs whenever a spec is given.
92+
93+
**Deduplication.** :func:`~python_discovery.get_interpreter` deduplicates per call so it does not interrogate the
94+
same binary twice while searching, and stops as soon as a match is found. :func:`~python_discovery.iter_interpreters`
95+
deduplicates by the resolved real path of each candidate's ``system_executable`` (falling back to ``executable``).
96+
That means symlinked aliases like ``/bin/python3`` and ``/usr/bin/python3``, or a virtualenv whose ``python``
97+
symlinks to its base interpreter, collapse to a single yield. The semantic is "one entry per distinct install,"
98+
which is what callers building choosers or version-range pickers usually want.
99+
100+
**Iteration order.** Yields come back in *priority order*: ``try_first_with`` first, then the running interpreter,
101+
then PEP 514 entries on Windows, then PATH left-to-right, then UV-managed installs. This matches what
102+
:func:`~python_discovery.get_interpreter` would have returned at each step. If your ordering differs (newest
103+
version first, smallest install root, etc.), wrap the call in :func:`sorted` -- the API deliberately does not
104+
include a ``sort_by`` parameter because keeping discovery order preserves the priority signal for callers who
105+
want it.
106+
68107
How caching works
69108
-------------------
70109

docs/how-to/standalone-usage.rst

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,60 @@ the ``PY_DISCOVERY_TIMEOUT`` environment variable.
6262
The timeout value should be a number in seconds. Each interpreter candidate is given this much time
6363
to respond. If a timeout occurs, the candidate is skipped and the search continues with the next one.
6464

65+
List every interpreter on the system
66+
--------------------------------------
67+
68+
Use :func:`~python_discovery.iter_interpreters` to enumerate every Python python-discovery can find. With no spec
69+
it yields all known implementations (CPython, PyPy, GraalPy -- see
70+
:data:`~python_discovery.KNOWN_IMPLEMENTATIONS`). Pass a spec to filter, exactly like
71+
:func:`~python_discovery.get_interpreter`.
72+
73+
.. code-block:: python
74+
75+
from pathlib import Path
76+
77+
from python_discovery import DiskCache, iter_interpreters
78+
79+
cache = DiskCache(root=Path("~/.cache/python-discovery").expanduser())
80+
81+
# Every interpreter, no filter
82+
for info in iter_interpreters(cache=cache):
83+
print(info.executable, info.version_str, info.implementation)
84+
85+
# Every CPython 3.10 or newer, newest first
86+
newest_first = sorted(
87+
iter_interpreters("cpython>=3.10", cache=cache),
88+
key=lambda info: info.version_info,
89+
reverse=True,
90+
)
91+
92+
Enumeration interrogates every candidate as a subprocess on a cold cache. Always pass a
93+
:class:`~python_discovery.DiskCache` if you call this more than once.
94+
95+
Pick an interpreter from a range, preferring newer
96+
----------------------------------------------------
97+
98+
A common need: search a version range and prefer newer interpreters when more than one matches. Sort the result of
99+
:func:`~python_discovery.iter_interpreters` and take the first.
100+
101+
.. code-block:: python
102+
103+
from pathlib import Path
104+
105+
from python_discovery import DiskCache, iter_interpreters
106+
107+
cache = DiskCache(root=Path("~/.cache/python-discovery").expanduser())
108+
109+
matches = sorted(
110+
iter_interpreters("cpython>=3.10,<3.15", cache=cache),
111+
key=lambda info: info.version_info,
112+
reverse=True,
113+
)
114+
info = matches[0] if matches else None
115+
116+
Use :func:`get_interpreter` instead when you only need the first PATH-priority hit; use the sort-and-take pattern
117+
when *your* ordering differs from PATH order (newest version, smallest install size, preferred install root, etc.).
118+
65119
Read interpreter metadata
66120
---------------------------
67121

docs/tutorial/getting-started.rst

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,49 @@ You can pass multiple specs as a list -- the library tries each one in order and
8787
8888
result = get_interpreter(["python3.12", "python3.11"], cache=cache)
8989
90+
Listing every interpreter
91+
---------------------------
92+
93+
When you need *every* interpreter rather than just the first match -- for example, to show the user a chooser, or
94+
to apply your own ranking -- use :func:`~python_discovery.iter_interpreters`. Pass no arguments to enumerate every
95+
implementation python-discovery knows about, or pass a spec to filter.
96+
97+
.. mermaid::
98+
99+
flowchart TD
100+
Call["iter_interpreters(spec, cache)"] --> Yield["yields PythonInfo"]
101+
Yield --> A["1. try_first_with paths"]
102+
Yield --> B["2. running interpreter"]
103+
Yield --> C["3. PATH (left to right)"]
104+
Yield --> D["4. uv-managed installs"]
105+
106+
style Call fill:#4a90d9,stroke:#2a5f8f,color:#fff
107+
style Yield fill:#4a9f4a,stroke:#2a6f2a,color:#fff
108+
109+
.. code-block:: python
110+
111+
from pathlib import Path
112+
113+
from python_discovery import DiskCache, iter_interpreters
114+
115+
cache = DiskCache(root=Path("~/.cache/python-discovery").expanduser())
116+
for info in iter_interpreters(cache=cache):
117+
print(info.executable, info.version_str, info.implementation)
118+
119+
The result is an iterator, so :func:`list`, :func:`sorted`, generator expressions and early ``break`` all work as you
120+
would expect. Symlinked aliases (``/bin/python3`` and ``/usr/bin/python3``, or a virtualenv and the base it points at)
121+
collapse to a single entry, so you do not see the same install twice.
122+
123+
To prefer newer interpreters in a range, sort the result by ``version_info`` after filtering:
124+
125+
.. code-block:: python
126+
127+
newest_first = sorted(
128+
iter_interpreters(">=3.10,<3.15", cache=cache),
129+
key=lambda info: info.version_info,
130+
reverse=True,
131+
)
132+
90133
Writing specs
91134
-------------
92135

src/python_discovery/__init__.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,16 @@
55
from importlib.metadata import version
66

77
from ._cache import ContentStore, DiskCache, PyInfoCache
8-
from ._discovery import get_interpreter
8+
from ._discovery import get_interpreter, iter_interpreters
99
from ._py_info import KNOWN_ARCHITECTURES, PythonInfo, normalize_isa
10-
from ._py_spec import PythonSpec
10+
from ._py_spec import KNOWN_IMPLEMENTATIONS, PythonSpec
1111
from ._specifier import SimpleSpecifier, SimpleSpecifierSet, SimpleVersion
1212

1313
__version__ = version("python-discovery")
1414

1515
__all__ = [
1616
"KNOWN_ARCHITECTURES",
17+
"KNOWN_IMPLEMENTATIONS",
1718
"ContentStore",
1819
"DiskCache",
1920
"PyInfoCache",
@@ -24,5 +25,6 @@
2425
"SimpleVersion",
2526
"__version__",
2627
"get_interpreter",
28+
"iter_interpreters",
2729
"normalize_isa",
2830
]

src/python_discovery/_discovery.py

Lines changed: 89 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,10 @@
1111

1212
from ._compat import fs_path_id
1313
from ._py_info import PythonInfo
14-
from ._py_spec import PythonSpec
14+
from ._py_spec import KNOWN_IMPLEMENTATIONS, PythonSpec
1515

1616
if TYPE_CHECKING:
17-
from collections.abc import Callable, Generator, Iterable, Mapping, Sequence
17+
from collections.abc import Callable, Generator, Iterable, Iterator, Mapping, Sequence
1818

1919
from ._cache import PyInfoCache
2020

@@ -51,6 +51,64 @@ def get_interpreter(
5151
return None
5252

5353

54+
def iter_interpreters(
55+
key: str | Sequence[str] | None = None,
56+
try_first_with: Iterable[str] | None = None,
57+
cache: PyInfoCache | None = None,
58+
env: Mapping[str, str] | None = None,
59+
predicate: Callable[[PythonInfo], bool] | None = None,
60+
) -> Iterator[PythonInfo]:
61+
"""
62+
Yield every interpreter on the system that satisfies *key*.
63+
64+
Iteration order is discovery order: ``try_first_with`` paths first, then the running interpreter, then ``PATH``
65+
(left to right), then UV-managed installs. Results are deduplicated by the resolved real path of the underlying
66+
system interpreter, so symlinked aliases (``/bin`` vs ``/usr/bin``) and venvs that symlink to a base interpreter
67+
collapse to a single entry. Callers that want a different ordering should sort the result.
68+
69+
:param key: interpreter specification — same syntax as :func:`get_interpreter`. ``None`` enumerates every Python
70+
implementation python-discovery knows about (see :data:`KNOWN_IMPLEMENTATIONS`).
71+
:param try_first_with: executables to probe before the normal discovery search.
72+
:param cache: interpreter metadata cache; when ``None`` results are not cached. Strongly recommended for
73+
enumeration, which interrogates every candidate as a subprocess on a cold cache.
74+
:param env: environment mapping for ``PATH`` lookup; defaults to :data:`os.environ`.
75+
:param predicate: optional filter applied after the spec match; return ``True`` to include the interpreter.
76+
"""
77+
env_map = os.environ if env is None else env
78+
try_first_tuple = tuple(try_first_with or ())
79+
if key is None:
80+
keys: tuple[str | None, ...] = (None,)
81+
elif isinstance(key, str):
82+
keys = (key,)
83+
else:
84+
keys = tuple(key)
85+
seen_real_paths: set[str] = set()
86+
for spec_str in keys:
87+
if spec_str is None:
88+
spec = PythonSpec("", None, None, None, None, None, None)
89+
wide = True
90+
else:
91+
spec = PythonSpec.from_string_spec(spec_str)
92+
wide = False
93+
for interpreter, impl_must_match in propose_interpreters(
94+
spec, try_first_tuple, cache, env_map, all_implementations=wide
95+
):
96+
if interpreter is None:
97+
continue
98+
anchor = interpreter.system_executable or interpreter.executable
99+
if anchor is None:
100+
continue
101+
real_path = os.path.realpath(anchor)
102+
if real_path in seen_real_paths:
103+
continue
104+
if not interpreter.satisfies(spec, impl_must_match=impl_must_match):
105+
continue
106+
if predicate is not None and not predicate(interpreter):
107+
continue
108+
seen_real_paths.add(real_path)
109+
yield interpreter
110+
111+
54112
def _find_interpreter(
55113
key: str,
56114
try_first_with: Iterable[str],
@@ -106,6 +164,8 @@ def propose_interpreters(
106164
try_first_with: Iterable[str],
107165
cache: PyInfoCache | None = None,
108166
env: Mapping[str, str] | None = None,
167+
*,
168+
all_implementations: bool = False,
109169
) -> Generator[tuple[PythonInfo | None, bool], None, None]:
110170
"""
111171
Yield ``(interpreter, impl_must_match)`` candidates for *spec*.
@@ -114,6 +174,8 @@ def propose_interpreters(
114174
:param try_first_with: executable paths to probe before the standard search.
115175
:param cache: interpreter metadata cache; when ``None`` results are not cached.
116176
:param env: environment mapping for ``PATH`` lookup; defaults to :data:`os.environ`.
177+
:param all_implementations: when ``True`` and *spec* does not constrain the implementation, also surface
178+
non-CPython binaries on ``PATH`` and under UV's install directory. Used by enumeration APIs.
117179
"""
118180
env = os.environ if env is None else env
119181
tested_exes: set[str] = set()
@@ -125,8 +187,8 @@ def propose_interpreters(
125187
yield from _propose_explicit(spec, try_first_with, cache, env, tested_exes)
126188
if spec.path is not None and spec.is_abs: # pragma: no cover # relative spec.path is never abs
127189
return
128-
yield from _propose_from_path(spec, cache, env, tested_exes)
129-
yield from _propose_from_uv(cache, env)
190+
yield from _propose_from_path(spec, cache, env, tested_exes, all_implementations=all_implementations)
191+
yield from _propose_from_uv(cache, env, all_implementations=all_implementations)
130192

131193

132194
def _propose_explicit(
@@ -170,8 +232,10 @@ def _propose_from_path(
170232
cache: PyInfoCache | None,
171233
env: Mapping[str, str],
172234
tested_exes: set[str],
235+
*,
236+
all_implementations: bool = False,
173237
) -> Generator[tuple[PythonInfo | None, bool], None, None]:
174-
find_candidates = path_exe_finder(spec)
238+
find_candidates = path_exe_finder(spec, all_implementations=all_implementations)
175239
for pos, path in enumerate(get_paths(env)):
176240
_LOGGER.debug(LazyPathDump(pos, path, env))
177241
for exe, impl_must_match in find_candidates(path):
@@ -189,6 +253,8 @@ def _propose_from_path(
189253
def _propose_from_uv(
190254
cache: PyInfoCache | None,
191255
env: Mapping[str, str],
256+
*,
257+
all_implementations: bool = False,
192258
) -> Generator[tuple[PythonInfo | None, bool], None, None]:
193259
if uv_python_dir := os.getenv("UV_PYTHON_INSTALL_DIR"):
194260
uv_python_path = Path(uv_python_dir).expanduser()
@@ -197,10 +263,19 @@ def _propose_from_uv(
197263
else:
198264
uv_python_path = user_data_path("uv") / "python"
199265

200-
for exe_path in uv_python_path.glob("*/bin/python"): # pragma: no branch
201-
interpreter = PathPythonInfo.from_exe(str(exe_path), cache, raise_on_error=False, env=env)
202-
if interpreter is not None: # pragma: no branch
203-
yield interpreter, True
266+
patterns: list[str] = ["*/bin/python"]
267+
if all_implementations:
268+
patterns.extend(f"*/bin/{impl}" for impl in KNOWN_IMPLEMENTATIONS if impl != "python")
269+
seen_uv_paths: set[str] = set()
270+
for pattern in patterns:
271+
for exe_path in uv_python_path.glob(pattern):
272+
resolved = str(Path(exe_path).resolve())
273+
if resolved in seen_uv_paths:
274+
continue
275+
seen_uv_paths.add(resolved)
276+
interpreter = PathPythonInfo.from_exe(str(exe_path), cache, raise_on_error=False, env=env)
277+
if interpreter is not None: # pragma: no branch
278+
yield interpreter, True
204279

205280

206281
def get_paths(env: Mapping[str, str]) -> Generator[Path, None, None]:
@@ -244,9 +319,11 @@ def __repr__(self) -> str:
244319
return content
245320

246321

247-
def path_exe_finder(spec: PythonSpec) -> Callable[[Path], Generator[tuple[Path, bool], None, None]]:
322+
def path_exe_finder(
323+
spec: PythonSpec, *, all_implementations: bool = False
324+
) -> Callable[[Path], Generator[tuple[Path, bool], None, None]]:
248325
"""Given a spec, return a function that can be called on a path to find all matching files in it."""
249-
pat = spec.generate_re(windows=sys.platform == "win32")
326+
pat = spec.generate_re(windows=sys.platform == "win32", all_implementations=all_implementations)
250327
direct = spec.str_spec
251328
if sys.platform == "win32": # pragma: win32 cover
252329
direct = f"{direct}.exe"
@@ -331,5 +408,6 @@ class PathPythonInfo(PythonInfo):
331408
"PathPythonInfo",
332409
"get_interpreter",
333410
"get_paths",
411+
"iter_interpreters",
334412
"propose_interpreters",
335413
]

0 commit comments

Comments
 (0)