Skip to content

Commit 00052bd

Browse files
✨ feat(discovery): add iter_interpreters for enumeration (#71)
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 791d139 commit 00052bd

11 files changed

Lines changed: 552 additions & 23 deletions

File tree

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,8 @@ The `get_interpreter()` function accepts various specification formats:
3636
- Absolute path: `/usr/bin/python3.12`
3737
- Version: `3.12`
3838
- Implementation prefix: `cpython3.12`
39-
- PEP 440 specifier: `>=3.10`, `>=3.11,<3.13`
39+
- [Version specifier](https://packaging.python.org/en/latest/specifications/version-specifiers/): `>=3.10`,
40+
`>=3.11,<3.13`
4041

4142
## Documentation
4243

docs/changelog/65.bugfix.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
discover uv-managed Pythons on Windows. Previously the glob assumed Unix layout (``<root>/<key>/bin/python``) and
2+
silently found nothing on Windows, where uv places ``python.exe`` directly under the install root - by
3+
:user:`gaborbernat`.

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: 107 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,106 @@ 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+
How uv-managed Pythons are discovered
69+
---------------------------------------
70+
71+
`uv <https://docs.astral.sh/uv/>`_ installs Python interpreters under a single root directory (configurable via
72+
``UV_PYTHON_INSTALL_DIR``, otherwise defaulting under ``XDG_DATA_HOME`` or the platform user-data path). Each
73+
install lives in its own subdirectory, but the actual binary location varies by OS and implementation:
74+
75+
.. list-table::
76+
:header-rows: 1
77+
:widths: 25 35 40
78+
79+
* - Implementation
80+
- Unix layout
81+
- Windows layout
82+
* - CPython
83+
- ``<root>/<key>/bin/python``
84+
- ``<root>/<key>/python.exe``
85+
* - PyPy
86+
- ``<root>/<key>/bin/pypy*``
87+
- ``<root>/<key>/pypy*.exe``
88+
* - GraalPy
89+
- ``<root>/<key>/bin/graalpy``
90+
- ``<root>/<key>/bin/graalpy.exe``
91+
92+
.. mermaid::
93+
94+
flowchart LR
95+
Call(["iter_interpreters(key)"]) --> Mode{"key is None?"}
96+
Mode -->|"narrow"| N1["*/bin/python"]
97+
Mode -->|"narrow"| N2["*/python.exe"]
98+
Mode -->|"wide"| W1["*/bin/pypy*"]
99+
Mode -->|"wide"| W2["*/bin/graalpy"]
100+
Mode -->|"wide"| W3["*/pypy*.exe"]
101+
Mode -->|"wide"| W4["*/bin/graalpy.exe"]
102+
103+
N1 --> Dedup[/"realpath dedup"/]
104+
N2 --> Dedup
105+
W1 --> Dedup
106+
W2 --> Dedup
107+
W3 --> Dedup
108+
W4 --> Dedup
109+
110+
Dedup --> Interrogate(["subprocess interrogation"])
111+
112+
style Call fill:#4a90d9,stroke:#2a5f8f,color:#fff
113+
style Mode fill:#d9904a,stroke:#8f5f2a,color:#fff
114+
style N1 fill:#3a7fc2,stroke:#1f4d7a,color:#fff
115+
style N2 fill:#3a7fc2,stroke:#1f4d7a,color:#fff
116+
style W1 fill:#9f4ad9,stroke:#5f2a8f,color:#fff
117+
style W2 fill:#9f4ad9,stroke:#5f2a8f,color:#fff
118+
style W3 fill:#9f4ad9,stroke:#5f2a8f,color:#fff
119+
style W4 fill:#9f4ad9,stroke:#5f2a8f,color:#fff
120+
style Dedup fill:#c2873a,stroke:#7a4c1f,color:#fff
121+
style Interrogate fill:#4a9f4a,stroke:#2a6f2a,color:#fff
122+
123+
GraalPy keeps its ``bin/`` segment on Windows (an upstream choice in uv); PyPy and CPython do not. python-discovery
124+
globs all of these patterns regardless of the host OS, because globs that do not match anything are essentially
125+
free, and the cross-platform list is short. Symlinked aliases inside an install (``bin/python``,
126+
``bin/python3``, ``bin/python3.14`` all pointing at the same real file) are deduplicated by resolved path before
127+
the subprocess interrogation, so each install is interrogated once.
128+
129+
Selecting one interpreter vs. enumerating all of them
130+
-------------------------------------------------------
131+
132+
:func:`~python_discovery.get_interpreter` and :func:`~python_discovery.iter_interpreters` walk the same candidate
133+
sources, but they answer different questions and behave differently in three ways.
134+
135+
.. mermaid::
136+
137+
flowchart LR
138+
Sources["candidate sources<br>(try_first_with → current →<br>PEP 514 → PATH → uv)"]
139+
Sources --> Get["get_interpreter()<br>first match wins, returns one"]
140+
Sources --> Iter["iter_interpreters()<br>yields every match"]
141+
142+
style Get fill:#4a9f4a,stroke:#2a6f2a,color:#fff
143+
style Iter fill:#4a90d9,stroke:#2a5f8f,color:#fff
144+
145+
**Implementation coverage on PATH.** :func:`~python_discovery.get_interpreter` matches only ``python*`` filenames on
146+
PATH unless the spec names another implementation explicitly (``pypy3.12``, ``graalpy3.11``). This keeps backwards
147+
compatibility with tools that have always read "no implementation in the spec" as "give me CPython."
148+
:func:`~python_discovery.iter_interpreters` with no spec broadens the search to every name in
149+
:data:`~python_discovery.KNOWN_IMPLEMENTATIONS` -- otherwise an "all interpreters" call would silently miss every
150+
PyPy and GraalPy on the system. When you pass a spec to :func:`~python_discovery.iter_interpreters`, it falls back
151+
to the same narrow regex as :func:`~python_discovery.get_interpreter`, so behavior is consistent across the two
152+
APIs whenever a spec is given.
153+
154+
**Deduplication.** :func:`~python_discovery.get_interpreter` deduplicates per call so it does not interrogate the
155+
same binary twice while searching, and stops as soon as a match is found. :func:`~python_discovery.iter_interpreters`
156+
deduplicates by the resolved real path of each candidate's ``system_executable`` (falling back to ``executable``).
157+
That means symlinked aliases like ``/bin/python3`` and ``/usr/bin/python3``, or a virtualenv whose ``python``
158+
symlinks to its base interpreter, collapse to a single yield. The semantic is "one entry per distinct install,"
159+
which is what callers building choosers or version-range pickers usually want.
160+
161+
**Iteration order.** Yields come back in *priority order*: ``try_first_with`` first, then the running interpreter,
162+
then :pep:`514` entries on Windows, then PATH left-to-right, then UV-managed installs. This matches what
163+
:func:`~python_discovery.get_interpreter` would have returned at each step. If your ordering differs (newest
164+
version first, smallest install root, etc.), wrap the call in :func:`sorted` -- the API deliberately does not
165+
include a ``sort_by`` parameter because keeping discovery order preserves the priority signal for callers who
166+
want it.
167+
68168
How caching works
69169
-------------------
70170

@@ -165,9 +265,12 @@ A spec string follows the pattern ``[impl][version][t][-arch][-machine]``. Every
165265
* - ``/usr/bin/python3``
166266
- Absolute path, used directly (no search)
167267
* - ``>=3.11,<3.13``
168-
- :pep:`440` version specifier (any Python in range)
268+
- `Version specifier <https://packaging.python.org/en/latest/specifications/version-specifiers/>`_
269+
(any Python in range)
169270
* - ``cpython>=3.11``
170-
- :pep:`440` specifier restricted to CPython
271+
- `Version specifier <https://packaging.python.org/en/latest/specifications/version-specifiers/>`_
272+
restricted to CPython
171273

172-
:pep:`440` specifiers (``>=``, ``<=``, ``~=``, ``!=``, ``==``, ``===``) are supported. Multiple
173-
specifiers can be comma-separated, for example ``>=3.11,<3.13``.
274+
`Version specifiers <https://packaging.python.org/en/latest/specifications/version-specifiers/>`_
275+
(``>=``, ``<=``, ``~=``, ``!=``, ``==``, ``===``) are supported. Multiple specifiers can be comma-separated,
276+
for example ``>=3.11,<3.13``.

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: 45 additions & 1 deletion
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

@@ -131,7 +174,8 @@ Common examples:
131174
* - ``/usr/bin/python3``
132175
- An absolute path, used directly without searching
133176
* - ``>=3.11,<3.13``
134-
- Any Python in the 3.11--3.12 range (:pep:`440` syntax)
177+
- Any Python in the 3.11--3.12 range
178+
(`version specifier <https://packaging.python.org/en/latest/specifications/version-specifiers/>`_ syntax)
135179

136180
See the :doc:`full spec reference </explanation>` for all options.
137181

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ lint.per-file-ignores."tests/**/*.py" = [
105105
"PLC0415", # imports inside test functions (conditional on mocking)
106106
"PLC2701", # private imports needed to test internal APIs
107107
"PLR0913", # too many arguments (pytest fixtures)
108+
"PLR0917", # too many positional arguments (pytest fixtures)
108109
"PLR2004", # Magic value used in comparison
109110
"S101", # asserts allowed in tests
110111
"S404", # subprocess import

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
]

0 commit comments

Comments
 (0)