python-discovery 1.2.2__tar.gz → 1.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. {python_discovery-1.2.2 → python_discovery-1.3.0}/.github/workflows/check.yaml +1 -1
  2. {python_discovery-1.2.2 → python_discovery-1.3.0}/.github/workflows/release.yaml +3 -3
  3. {python_discovery-1.2.2 → python_discovery-1.3.0}/.pre-commit-config.yaml +5 -5
  4. {python_discovery-1.2.2 → python_discovery-1.3.0}/PKG-INFO +3 -2
  5. {python_discovery-1.2.2 → python_discovery-1.3.0}/README.md +2 -1
  6. python_discovery-1.3.0/docs/changelog/65.bugfix.rst +3 -0
  7. python_discovery-1.3.0/docs/changelog/65.feature.rst +3 -0
  8. {python_discovery-1.2.2 → python_discovery-1.3.0}/docs/explanation.rst +107 -4
  9. {python_discovery-1.2.2 → python_discovery-1.3.0}/docs/how-to/standalone-usage.rst +54 -0
  10. {python_discovery-1.2.2 → python_discovery-1.3.0}/docs/tutorial/getting-started.rst +45 -1
  11. {python_discovery-1.2.2 → python_discovery-1.3.0}/pyproject.toml +1 -0
  12. {python_discovery-1.2.2 → python_discovery-1.3.0}/src/python_discovery/__init__.py +4 -2
  13. {python_discovery-1.2.2 → python_discovery-1.3.0}/src/python_discovery/_discovery.py +99 -11
  14. {python_discovery-1.2.2 → python_discovery-1.3.0}/src/python_discovery/_py_spec.py +16 -4
  15. python_discovery-1.3.0/tests/test_iter_interpreters.py +218 -0
  16. {python_discovery-1.2.2 → python_discovery-1.3.0}/.github/FUNDING.yaml +0 -0
  17. {python_discovery-1.2.2 → python_discovery-1.3.0}/.github/SECURITY.md +0 -0
  18. {python_discovery-1.2.2 → python_discovery-1.3.0}/.github/dependabot.yaml +0 -0
  19. {python_discovery-1.2.2 → python_discovery-1.3.0}/.github/release.yaml +0 -0
  20. {python_discovery-1.2.2 → python_discovery-1.3.0}/.gitignore +0 -0
  21. {python_discovery-1.2.2 → python_discovery-1.3.0}/.readthedocs.yaml +0 -0
  22. {python_discovery-1.2.2 → python_discovery-1.3.0}/.readthedocs.yml +0 -0
  23. {python_discovery-1.2.2 → python_discovery-1.3.0}/CODE_OF_CONDUCT.md +0 -0
  24. {python_discovery-1.2.2 → python_discovery-1.3.0}/LICENSE +0 -0
  25. {python_discovery-1.2.2 → python_discovery-1.3.0}/docs/_static/custom.css +0 -0
  26. {python_discovery-1.2.2 → python_discovery-1.3.0}/docs/_static/logo.svg +0 -0
  27. {python_discovery-1.2.2 → python_discovery-1.3.0}/docs/changelog/59.bugfix.rst +0 -0
  28. {python_discovery-1.2.2 → python_discovery-1.3.0}/docs/conf.py +0 -0
  29. {python_discovery-1.2.2 → python_discovery-1.3.0}/docs/index.rst +0 -0
  30. {python_discovery-1.2.2 → python_discovery-1.3.0}/docs/reference/api.rst +0 -0
  31. {python_discovery-1.2.2 → python_discovery-1.3.0}/docs/reference/environment-variables.rst +0 -0
  32. {python_discovery-1.2.2 → python_discovery-1.3.0}/src/python_discovery/_cache.py +0 -0
  33. {python_discovery-1.2.2 → python_discovery-1.3.0}/src/python_discovery/_cached_py_info.py +0 -0
  34. {python_discovery-1.2.2 → python_discovery-1.3.0}/src/python_discovery/_compat.py +0 -0
  35. {python_discovery-1.2.2 → python_discovery-1.3.0}/src/python_discovery/_py_info.py +0 -0
  36. {python_discovery-1.2.2 → python_discovery-1.3.0}/src/python_discovery/_specifier.py +0 -0
  37. {python_discovery-1.2.2 → python_discovery-1.3.0}/src/python_discovery/_windows/__init__.py +0 -0
  38. {python_discovery-1.2.2 → python_discovery-1.3.0}/src/python_discovery/_windows/_pep514.py +0 -0
  39. {python_discovery-1.2.2 → python_discovery-1.3.0}/src/python_discovery/_windows/_propose.py +0 -0
  40. {python_discovery-1.2.2 → python_discovery-1.3.0}/src/python_discovery/py.typed +0 -0
  41. {python_discovery-1.2.2 → python_discovery-1.3.0}/tests/conftest.py +0 -0
  42. {python_discovery-1.2.2 → python_discovery-1.3.0}/tests/py_info/test_py_info.py +0 -0
  43. {python_discovery-1.2.2 → python_discovery-1.3.0}/tests/py_info/test_py_info_exe_based_of.py +0 -0
  44. {python_discovery-1.2.2 → python_discovery-1.3.0}/tests/test_cache.py +0 -0
  45. {python_discovery-1.2.2 → python_discovery-1.3.0}/tests/test_cached_py_info.py +0 -0
  46. {python_discovery-1.2.2 → python_discovery-1.3.0}/tests/test_discovery.py +0 -0
  47. {python_discovery-1.2.2 → python_discovery-1.3.0}/tests/test_discovery_extra.py +0 -0
  48. {python_discovery-1.2.2 → python_discovery-1.3.0}/tests/test_py_info_extra.py +0 -0
  49. {python_discovery-1.2.2 → python_discovery-1.3.0}/tests/test_py_spec.py +0 -0
  50. {python_discovery-1.2.2 → python_discovery-1.3.0}/tests/test_py_spec_extra.py +0 -0
  51. {python_discovery-1.2.2 → python_discovery-1.3.0}/tests/test_specifier.py +0 -0
  52. {python_discovery-1.2.2 → python_discovery-1.3.0}/tests/windows/conftest.py +0 -0
  53. {python_discovery-1.2.2 → python_discovery-1.3.0}/tests/windows/test_windows.py +0 -0
  54. {python_discovery-1.2.2 → python_discovery-1.3.0}/tests/windows/test_windows_pep514.py +0 -0
  55. {python_discovery-1.2.2 → python_discovery-1.3.0}/tests/windows/winreg_mock_values.py +0 -0
  56. {python_discovery-1.2.2 → python_discovery-1.3.0}/tox.toml +0 -0
@@ -39,7 +39,7 @@ jobs:
39
39
  fetch-depth: 0
40
40
  persist-credentials: false
41
41
  - name: Install the latest version of uv
42
- uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
42
+ uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
43
43
  with:
44
44
  enable-cache: false
45
45
  cache-dependency-glob: "pyproject.toml"
@@ -18,7 +18,7 @@ jobs:
18
18
  fetch-depth: 0
19
19
  persist-credentials: false
20
20
  - name: Install the latest version of uv
21
- uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
21
+ uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
22
22
  with:
23
23
  enable-cache: false
24
24
  cache-dependency-glob: "pyproject.toml"
@@ -26,7 +26,7 @@ jobs:
26
26
  - name: Build package
27
27
  run: uv build --python 3.14 --python-preference only-managed --sdist --wheel . --out-dir dist
28
28
  - name: Store the distribution packages
29
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
29
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
30
30
  with:
31
31
  name: ${{ env.dists-artifact-name }}
32
32
  path: dist/*
@@ -47,6 +47,6 @@ jobs:
47
47
  name: ${{ env.dists-artifact-name }}
48
48
  path: dist/
49
49
  - name: Publish to PyPI
50
- uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0
50
+ uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
51
51
  with:
52
52
  attestations: true
@@ -5,7 +5,7 @@ repos:
5
5
  - id: end-of-file-fixer
6
6
  - id: trailing-whitespace
7
7
  - repo: https://github.com/python-jsonschema/check-jsonschema
8
- rev: 0.37.1
8
+ rev: 0.37.2
9
9
  hooks:
10
10
  - id: check-github-workflows
11
11
  args: ["--verbose"]
@@ -15,24 +15,24 @@ repos:
15
15
  - id: codespell
16
16
  additional_dependencies: ["tomli>=2.4"]
17
17
  - repo: https://github.com/tox-dev/pyproject-fmt
18
- rev: "v2.21.0"
18
+ rev: "v2.21.1"
19
19
  hooks:
20
20
  - id: pyproject-fmt
21
21
  - repo: https://github.com/astral-sh/ruff-pre-commit
22
- rev: "v0.15.9"
22
+ rev: "v0.15.12"
23
23
  hooks:
24
24
  - id: ruff-format
25
25
  - id: ruff
26
26
  args: ["--fix", "--unsafe-fixes", "--exit-non-zero-on-fix"]
27
27
  - repo: https://github.com/rbubley/mirrors-prettier
28
- rev: "v3.8.1"
28
+ rev: "v3.8.3"
29
29
  hooks:
30
30
  - id: prettier
31
31
  additional_dependencies:
32
32
  - prettier@3.8.1
33
33
  - "@prettier/plugin-xml@3.4.2"
34
34
  - repo: https://github.com/zizmorcore/zizmor-pre-commit
35
- rev: v1.23.1
35
+ rev: v1.24.1
36
36
  hooks:
37
37
  - id: zizmor
38
38
  - repo: meta
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-discovery
3
- Version: 1.2.2
3
+ Version: 1.3.0
4
4
  Summary: Python interpreter discovery
5
5
  Project-URL: Changelog, https://github.com/tox-dev/python-discovery/releases
6
6
  Project-URL: Documentation, https://python-discovery.readthedocs.io
@@ -99,7 +99,8 @@ The `get_interpreter()` function accepts various specification formats:
99
99
  - Absolute path: `/usr/bin/python3.12`
100
100
  - Version: `3.12`
101
101
  - Implementation prefix: `cpython3.12`
102
- - PEP 440 specifier: `>=3.10`, `>=3.11,<3.13`
102
+ - [Version specifier](https://packaging.python.org/en/latest/specifications/version-specifiers/): `>=3.10`,
103
+ `>=3.11,<3.13`
103
104
 
104
105
  ## Documentation
105
106
 
@@ -36,7 +36,8 @@ The `get_interpreter()` function accepts various specification formats:
36
36
  - Absolute path: `/usr/bin/python3.12`
37
37
  - Version: `3.12`
38
38
  - 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`
40
41
 
41
42
  ## Documentation
42
43
 
@@ -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`.
@@ -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`.
@@ -65,6 +65,106 @@ detects these shims and resolves them to the actual binary.
65
65
  `mise <https://mise.jdx.dev/>`_ and `asdf <https://asdf-vm.com/>`_ work similarly, using the
66
66
  ``MISE_DATA_DIR`` and ``ASDF_DATA_DIR`` environment variables to locate their installations.
67
67
 
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
+
68
168
  How caching works
69
169
  -------------------
70
170
 
@@ -165,9 +265,12 @@ A spec string follows the pattern ``[impl][version][t][-arch][-machine]``. Every
165
265
  * - ``/usr/bin/python3``
166
266
  - Absolute path, used directly (no search)
167
267
  * - ``>=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)
169
270
  * - ``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
171
273
 
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``.
@@ -62,6 +62,60 @@ the ``PY_DISCOVERY_TIMEOUT`` environment variable.
62
62
  The timeout value should be a number in seconds. Each interpreter candidate is given this much time
63
63
  to respond. If a timeout occurs, the candidate is skipped and the search continues with the next one.
64
64
 
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
+
65
119
  Read interpreter metadata
66
120
  ---------------------------
67
121
 
@@ -87,6 +87,49 @@ You can pass multiple specs as a list -- the library tries each one in order and
87
87
 
88
88
  result = get_interpreter(["python3.12", "python3.11"], cache=cache)
89
89
 
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
+
90
133
  Writing specs
91
134
  -------------
92
135
 
@@ -131,7 +174,8 @@ Common examples:
131
174
  * - ``/usr/bin/python3``
132
175
  - An absolute path, used directly without searching
133
176
  * - ``>=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)
135
179
 
136
180
  See the :doc:`full spec reference </explanation>` for all options.
137
181
 
@@ -105,6 +105,7 @@ lint.per-file-ignores."tests/**/*.py" = [
105
105
  "PLC0415", # imports inside test functions (conditional on mocking)
106
106
  "PLC2701", # private imports needed to test internal APIs
107
107
  "PLR0913", # too many arguments (pytest fixtures)
108
+ "PLR0917", # too many positional arguments (pytest fixtures)
108
109
  "PLR2004", # Magic value used in comparison
109
110
  "S101", # asserts allowed in tests
110
111
  "S404", # subprocess import
@@ -5,15 +5,16 @@ from __future__ import annotations
5
5
  from importlib.metadata import version
6
6
 
7
7
  from ._cache import ContentStore, DiskCache, PyInfoCache
8
- from ._discovery import get_interpreter
8
+ from ._discovery import get_interpreter, iter_interpreters
9
9
  from ._py_info import KNOWN_ARCHITECTURES, PythonInfo, normalize_isa
10
- from ._py_spec import PythonSpec
10
+ from ._py_spec import KNOWN_IMPLEMENTATIONS, PythonSpec
11
11
  from ._specifier import SimpleSpecifier, SimpleSpecifierSet, SimpleVersion
12
12
 
13
13
  __version__ = version("python-discovery")
14
14
 
15
15
  __all__ = [
16
16
  "KNOWN_ARCHITECTURES",
17
+ "KNOWN_IMPLEMENTATIONS",
17
18
  "ContentStore",
18
19
  "DiskCache",
19
20
  "PyInfoCache",
@@ -24,5 +25,6 @@ __all__ = [
24
25
  "SimpleVersion",
25
26
  "__version__",
26
27
  "get_interpreter",
28
+ "iter_interpreters",
27
29
  "normalize_isa",
28
30
  ]
@@ -14,7 +14,7 @@ from ._py_info import PythonInfo
14
14
  from ._py_spec import PythonSpec
15
15
 
16
16
  if TYPE_CHECKING:
17
- from collections.abc import Callable, Generator, Iterable, Mapping, Sequence
17
+ from collections.abc import Callable, Generator, Iterable, Iterator, Mapping, Sequence
18
18
 
19
19
  from ._cache import PyInfoCache
20
20
 
@@ -36,7 +36,9 @@ def get_interpreter(
36
36
  the optional *predicate*.
37
37
 
38
38
  :param key: interpreter specification string(s) — an absolute path, a version (``3.12``), an implementation prefix
39
- (``cpython3.12``), or a PEP 440 specifier (``>=3.10``). When a sequence is given each entry is tried in order.
39
+ (``cpython3.12``), or a
40
+ `version specifier <https://packaging.python.org/en/latest/specifications/version-specifiers/>`_
41
+ (``>=3.10``). When a sequence is given each entry is tried in order.
40
42
  :param try_first_with: executables to probe before the normal discovery search.
41
43
  :param cache: interpreter metadata cache; when ``None`` results are not cached.
42
44
  :param env: environment mapping for ``PATH`` lookup; defaults to :data:`os.environ`.
@@ -51,6 +53,73 @@ def get_interpreter(
51
53
  return None
52
54
 
53
55
 
56
+ def iter_interpreters(
57
+ key: str | Sequence[str] | None = None,
58
+ try_first_with: Iterable[str] | None = None,
59
+ cache: PyInfoCache | None = None,
60
+ env: Mapping[str, str] | None = None,
61
+ predicate: Callable[[PythonInfo], bool] | None = None,
62
+ ) -> Iterator[PythonInfo]:
63
+ """
64
+ Yield every interpreter on the system that satisfies *key*.
65
+
66
+ Iteration order is discovery order: ``try_first_with`` paths first, then the running interpreter, then ``PATH``
67
+ (left to right), then UV-managed installs. Results are deduplicated by the resolved real path of the underlying
68
+ system interpreter, so symlinked aliases (``/bin`` vs ``/usr/bin``) and venvs that symlink to a base interpreter
69
+ collapse to a single entry. Callers that want a different ordering should sort the result.
70
+
71
+ :param key: interpreter specification — same syntax as :func:`get_interpreter`. ``None`` enumerates every Python
72
+ implementation python-discovery knows about (see :data:`KNOWN_IMPLEMENTATIONS`).
73
+ :param try_first_with: executables to probe before the normal discovery search.
74
+ :param cache: interpreter metadata cache; when ``None`` results are not cached. Strongly recommended for
75
+ enumeration, which interrogates every candidate as a subprocess on a cold cache.
76
+ :param env: environment mapping for ``PATH`` lookup; defaults to :data:`os.environ`.
77
+ :param predicate: optional filter applied after the spec match; return ``True`` to include the interpreter.
78
+ """
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
+ first_with = tuple(try_first_with or ())
86
+ env_map = os.environ if env is None else env
87
+ seen: set[str] = set()
88
+ for spec_str in keys:
89
+ yield from _iter_for_spec(spec_str, first_with, cache, env_map, predicate, seen)
90
+
91
+
92
+ def _iter_for_spec( # noqa: PLR0913, PLR0917
93
+ spec_str: str | None,
94
+ try_first_with: tuple[str, ...],
95
+ cache: PyInfoCache | None,
96
+ env: Mapping[str, str],
97
+ predicate: Callable[[PythonInfo], bool] | None,
98
+ seen: set[str],
99
+ ) -> Iterator[PythonInfo]:
100
+ if spec_str is None:
101
+ spec = PythonSpec("", None, None, None, None, None, None)
102
+ wide = True
103
+ else:
104
+ spec = PythonSpec.from_string_spec(spec_str)
105
+ wide = False
106
+ for interpreter, impl_must_match in propose_interpreters(
107
+ spec, try_first_with, cache, env, all_implementations=wide
108
+ ):
109
+ if interpreter is None:
110
+ continue
111
+ if (anchor := interpreter.system_executable or interpreter.executable) is None:
112
+ continue
113
+ if (real_path := os.path.realpath(anchor)) in seen:
114
+ continue
115
+ if not interpreter.satisfies(spec, impl_must_match=impl_must_match):
116
+ continue
117
+ if predicate is not None and not predicate(interpreter):
118
+ continue
119
+ seen.add(real_path)
120
+ yield interpreter
121
+
122
+
54
123
  def _find_interpreter(
55
124
  key: str,
56
125
  try_first_with: Iterable[str],
@@ -106,6 +175,8 @@ def propose_interpreters(
106
175
  try_first_with: Iterable[str],
107
176
  cache: PyInfoCache | None = None,
108
177
  env: Mapping[str, str] | None = None,
178
+ *,
179
+ all_implementations: bool = False,
109
180
  ) -> Generator[tuple[PythonInfo | None, bool], None, None]:
110
181
  """
111
182
  Yield ``(interpreter, impl_must_match)`` candidates for *spec*.
@@ -114,6 +185,8 @@ def propose_interpreters(
114
185
  :param try_first_with: executable paths to probe before the standard search.
115
186
  :param cache: interpreter metadata cache; when ``None`` results are not cached.
116
187
  :param env: environment mapping for ``PATH`` lookup; defaults to :data:`os.environ`.
188
+ :param all_implementations: when ``True`` and *spec* does not constrain the implementation, also surface
189
+ non-CPython binaries on ``PATH`` and under UV's install directory. Used by enumeration APIs.
117
190
  """
118
191
  env = os.environ if env is None else env
119
192
  tested_exes: set[str] = set()
@@ -125,8 +198,8 @@ def propose_interpreters(
125
198
  yield from _propose_explicit(spec, try_first_with, cache, env, tested_exes)
126
199
  if spec.path is not None and spec.is_abs: # pragma: no cover # relative spec.path is never abs
127
200
  return
128
- yield from _propose_from_path(spec, cache, env, tested_exes)
129
- yield from _propose_from_uv(cache, env)
201
+ yield from _propose_from_path(spec, cache, env, tested_exes, all_implementations=all_implementations)
202
+ yield from _propose_from_uv(cache, env, all_implementations=all_implementations)
130
203
 
131
204
 
132
205
  def _propose_explicit(
@@ -170,8 +243,10 @@ def _propose_from_path(
170
243
  cache: PyInfoCache | None,
171
244
  env: Mapping[str, str],
172
245
  tested_exes: set[str],
246
+ *,
247
+ all_implementations: bool = False,
173
248
  ) -> Generator[tuple[PythonInfo | None, bool], None, None]:
174
- find_candidates = path_exe_finder(spec)
249
+ find_candidates = path_exe_finder(spec, all_implementations=all_implementations)
175
250
  for pos, path in enumerate(get_paths(env)):
176
251
  _LOGGER.debug(LazyPathDump(pos, path, env))
177
252
  for exe, impl_must_match in find_candidates(path):
@@ -189,6 +264,8 @@ def _propose_from_path(
189
264
  def _propose_from_uv(
190
265
  cache: PyInfoCache | None,
191
266
  env: Mapping[str, str],
267
+ *,
268
+ all_implementations: bool = False,
192
269
  ) -> Generator[tuple[PythonInfo | None, bool], None, None]:
193
270
  if uv_python_dir := os.getenv("UV_PYTHON_INSTALL_DIR"):
194
271
  uv_python_path = Path(uv_python_dir).expanduser()
@@ -197,10 +274,18 @@ def _propose_from_uv(
197
274
  else:
198
275
  uv_python_path = user_data_path("uv") / "python"
199
276
 
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
277
+ patterns: list[str] = ["*/bin/python", "*/python.exe"]
278
+ if all_implementations:
279
+ patterns.extend(("*/bin/pypy*", "*/bin/graalpy", "*/pypy*.exe", "*/bin/graalpy.exe"))
280
+ seen_uv_paths: set[str] = set()
281
+ for pattern in patterns:
282
+ for exe_path in uv_python_path.glob(pattern):
283
+ resolved = str(Path(exe_path).resolve())
284
+ if resolved in seen_uv_paths:
285
+ continue
286
+ seen_uv_paths.add(resolved)
287
+ if interpreter := PathPythonInfo.from_exe(str(exe_path), cache, raise_on_error=False, env=env):
288
+ yield interpreter, True
204
289
 
205
290
 
206
291
  def get_paths(env: Mapping[str, str]) -> Generator[Path, None, None]:
@@ -244,9 +329,11 @@ class LazyPathDump:
244
329
  return content
245
330
 
246
331
 
247
- def path_exe_finder(spec: PythonSpec) -> Callable[[Path], Generator[tuple[Path, bool], None, None]]:
332
+ def path_exe_finder(
333
+ spec: PythonSpec, *, all_implementations: bool = False
334
+ ) -> Callable[[Path], Generator[tuple[Path, bool], None, None]]:
248
335
  """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")
336
+ pat = spec.generate_re(windows=sys.platform == "win32", all_implementations=all_implementations)
250
337
  direct = spec.str_spec
251
338
  if sys.platform == "win32": # pragma: win32 cover
252
339
  direct = f"{direct}.exe"
@@ -331,5 +418,6 @@ __all__ = [
331
418
  "PathPythonInfo",
332
419
  "get_interpreter",
333
420
  "get_paths",
421
+ "iter_interpreters",
334
422
  "propose_interpreters",
335
423
  ]
@@ -35,6 +35,8 @@ SPECIFIER_PATTERN = re.compile(
35
35
  _MAX_VERSION_PARTS: Final[int] = 3
36
36
  _SINGLE_DIGIT_MAX: Final[int] = 9
37
37
 
38
+ KNOWN_IMPLEMENTATIONS: Final[tuple[str, ...]] = ("python", "cpython", "pypy", "graalpy")
39
+
38
40
  SpecifierSet = SimpleSpecifierSet
39
41
  Version = SimpleVersion
40
42
  InvalidSpecifier = ValueError
@@ -111,7 +113,9 @@ class PythonSpec:
111
113
  :param path: filesystem path to a specific interpreter, or ``None``.
112
114
  :param free_threaded: whether a free-threaded build is required, or ``None`` for any.
113
115
  :param machine: required ISA (e.g. ``"arm64"``), or ``None`` for any.
114
- :param version_specifier: PEP 440 version constraints, or ``None``.
116
+ :param version_specifier:
117
+ `version specifier <https://packaging.python.org/en/latest/specifications/version-specifiers/>`_
118
+ constraints, or ``None``.
115
119
  """
116
120
 
117
121
  def __init__( # noqa: PLR0913, PLR0917
@@ -145,7 +149,7 @@ class PythonSpec:
145
149
  Parse a string specification into a :class:`PythonSpec`.
146
150
 
147
151
  :param string_spec: an interpreter spec — an absolute path, a version string, an implementation prefix,
148
- or a PEP 440 specifier.
152
+ or a `version specifier <https://packaging.python.org/en/latest/specifications/version-specifiers/>`_.
149
153
  """
150
154
  if pathlib.Path(string_spec).is_absolute():
151
155
  return cls(string_spec, None, None, None, None, None, string_spec)
@@ -155,16 +159,23 @@ class PythonSpec:
155
159
  return result
156
160
  return cls(string_spec, None, None, None, None, None, string_spec)
157
161
 
158
- def generate_re(self, *, windows: bool) -> re.Pattern:
162
+ def generate_re(self, *, windows: bool, all_implementations: bool = False) -> re.Pattern:
159
163
  """
160
164
  Generate a regular expression for matching interpreter filenames.
161
165
 
162
166
  :param windows: if ``True``, require a ``.exe`` suffix.
167
+ :param all_implementations: when ``True`` and the spec does not constrain the implementation, match every
168
+ filename in :data:`KNOWN_IMPLEMENTATIONS` instead of only ``python``. Used by enumeration APIs.
163
169
  """
164
170
  version = r"{}(\.{}(\.{})?)?".format(
165
171
  *(r"\d+" if v is None else v for v in (self.major, self.minor, self.micro)),
166
172
  )
167
- impl = "python" if self.implementation is None else f"python|{re.escape(self.implementation)}"
173
+ if self.implementation is not None:
174
+ impl = f"python|{re.escape(self.implementation)}"
175
+ elif all_implementations:
176
+ impl = "|".join(re.escape(i) for i in KNOWN_IMPLEMENTATIONS)
177
+ else:
178
+ impl = "python"
168
179
  mod = "t?" if self.free_threaded else ""
169
180
  suffix = r"\.exe" if windows else ""
170
181
  version_conditional = "?" if windows or self.major is None else ""
@@ -254,6 +265,7 @@ class PythonSpec:
254
265
 
255
266
 
256
267
  __all__ = [
268
+ "KNOWN_IMPLEMENTATIONS",
257
269
  "InvalidSpecifier",
258
270
  "InvalidVersion",
259
271
  "PythonSpec",
@@ -0,0 +1,218 @@
1
+ from __future__ import annotations
2
+
3
+ import contextlib
4
+ import os
5
+ import sys
6
+ from pathlib import Path
7
+ from typing import TYPE_CHECKING
8
+ from uuid import uuid4
9
+
10
+ import pytest
11
+
12
+ from python_discovery import DiskCache, PythonInfo, iter_interpreters
13
+ from python_discovery._discovery import IS_WIN
14
+
15
+ if TYPE_CHECKING:
16
+ from collections.abc import Callable, Iterator
17
+
18
+ from pytest_mock import MockerFixture
19
+
20
+
21
+ @pytest.fixture
22
+ def uv_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
23
+ """Empty uv install root with environment variables pointing at it."""
24
+ monkeypatch.delenv("XDG_DATA_HOME", raising=False)
25
+ root = tmp_path / "uv-python"
26
+ root.mkdir()
27
+ monkeypatch.setenv("UV_PYTHON_INSTALL_DIR", str(root))
28
+ return root
29
+
30
+
31
+ def test_iter_interpreters_returns_iterator(session_cache: DiskCache) -> None:
32
+ result: Iterator[PythonInfo] = iter_interpreters(sys.executable, cache=session_cache)
33
+ assert iter(result) is result
34
+
35
+
36
+ def test_iter_interpreters_with_abs_path_yields_one(session_cache: DiskCache) -> None:
37
+ results = list(iter_interpreters(sys.executable, cache=session_cache))
38
+ assert len(results) == 1
39
+ assert results[0].executable == sys.executable
40
+
41
+
42
+ def test_iter_interpreters_sequence_dedups_across_keys(session_cache: DiskCache) -> None:
43
+ results = list(iter_interpreters([sys.executable, sys.executable], cache=session_cache))
44
+ assert len(results) == 1
45
+
46
+
47
+ @pytest.mark.parametrize(
48
+ ("predicate", "expected_count"),
49
+ [
50
+ pytest.param(None, 1, id="none-is-noop"),
51
+ pytest.param(lambda _: True, 1, id="accepts-all"),
52
+ pytest.param(lambda _: False, 0, id="rejects-all"),
53
+ ],
54
+ )
55
+ def test_iter_interpreters_predicate(
56
+ session_cache: DiskCache,
57
+ predicate: Callable[[PythonInfo], bool] | None,
58
+ expected_count: int,
59
+ ) -> None:
60
+ results = list(iter_interpreters(sys.executable, cache=session_cache, predicate=predicate))
61
+ assert len(results) == expected_count
62
+
63
+
64
+ @pytest.mark.parametrize(
65
+ "key_factory",
66
+ [
67
+ pytest.param(lambda: uuid4().hex, id="filename-cannot-match"),
68
+ pytest.param(lambda: ">=999", id="version-cannot-match"),
69
+ ],
70
+ )
71
+ def test_iter_interpreters_filters_to_empty(
72
+ session_cache: DiskCache,
73
+ key_factory: Callable[[], str],
74
+ ) -> None:
75
+ assert list(iter_interpreters(key_factory(), cache=session_cache)) == []
76
+
77
+
78
+ @pytest.mark.usefixtures("uv_dir")
79
+ def test_iter_interpreters_no_key_includes_running_interpreter(session_cache: DiskCache) -> None:
80
+ real_self = os.path.realpath(sys.executable)
81
+ results = list(iter_interpreters(cache=session_cache))
82
+ assert any(os.path.realpath(info.executable) == real_self for info in results)
83
+
84
+
85
+ @pytest.mark.usefixtures("uv_dir")
86
+ def test_iter_interpreters_dedups_symlinks(
87
+ tmp_path: Path,
88
+ monkeypatch: pytest.MonkeyPatch,
89
+ session_cache: DiskCache,
90
+ ) -> None:
91
+ suffix = ".exe" if IS_WIN else ""
92
+ for name in ("python3", "python3.99"):
93
+ Path(str(tmp_path / f"{name}{suffix}")).symlink_to(sys.executable)
94
+ pyvenv_cfg = Path(sys.executable).parents[1] / "pyvenv.cfg"
95
+ with contextlib.suppress(FileNotFoundError):
96
+ (tmp_path / pyvenv_cfg.name).write_bytes(pyvenv_cfg.read_bytes())
97
+ monkeypatch.setenv("PATH", os.pathsep.join([str(tmp_path), os.environ.get("PATH", "")]))
98
+
99
+ real = os.path.realpath(sys.executable)
100
+ matches = [info for info in iter_interpreters(cache=session_cache) if os.path.realpath(info.executable) == real]
101
+ assert len(matches) == 1
102
+
103
+
104
+ @pytest.mark.usefixtures("uv_dir")
105
+ def test_iter_interpreters_try_first_with_yields_first(
106
+ tmp_path: Path,
107
+ monkeypatch: pytest.MonkeyPatch,
108
+ session_cache: DiskCache,
109
+ ) -> None:
110
+ monkeypatch.setenv("PATH", str(tmp_path))
111
+ results = list(iter_interpreters(try_first_with=[sys.executable], cache=session_cache))
112
+ assert results
113
+ assert os.path.realpath(results[0].executable) == os.path.realpath(sys.executable)
114
+
115
+
116
+ def test_iter_interpreters_skips_none_from_propose(
117
+ session_cache: DiskCache,
118
+ mocker: MockerFixture,
119
+ ) -> None:
120
+ real_info = PythonInfo.current(session_cache)
121
+ mocker.patch(
122
+ "python_discovery._discovery.propose_interpreters",
123
+ return_value=iter([(None, True), (real_info, True)]),
124
+ )
125
+ results = list(iter_interpreters(cache=session_cache))
126
+ assert results == [real_info]
127
+
128
+
129
+ def test_iter_interpreters_skips_when_no_executable(
130
+ session_cache: DiskCache,
131
+ mocker: MockerFixture,
132
+ ) -> None:
133
+ bogus = mocker.MagicMock(spec=PythonInfo)
134
+ bogus.system_executable = None
135
+ bogus.executable = None
136
+ mocker.patch(
137
+ "python_discovery._discovery.propose_interpreters",
138
+ return_value=iter([(bogus, True)]),
139
+ )
140
+ assert list(iter_interpreters(cache=session_cache)) == []
141
+
142
+
143
+ def test_iter_interpreters_uv_yields_when_interrogation_succeeds(
144
+ uv_dir: Path,
145
+ session_cache: DiskCache,
146
+ monkeypatch: pytest.MonkeyPatch,
147
+ mocker: MockerFixture,
148
+ ) -> None:
149
+ monkeypatch.setenv("PATH", "")
150
+ bin_path = uv_dir / "cpython-3.99-fake/bin"
151
+ bin_path.mkdir(parents=True)
152
+ (bin_path / "python").touch()
153
+
154
+ fake_info = mocker.MagicMock(spec=PythonInfo)
155
+ fake_info.system_executable = str(uv_dir / "fake-unique" / "python")
156
+ fake_info.executable = fake_info.system_executable
157
+ fake_info.satisfies.return_value = True
158
+ mocker.patch("python_discovery._discovery.PathPythonInfo.from_exe", return_value=fake_info)
159
+
160
+ assert fake_info in list(iter_interpreters(cache=session_cache))
161
+
162
+
163
+ @pytest.mark.parametrize(
164
+ ("real_files", "symlinks", "expected_real"),
165
+ [
166
+ pytest.param(
167
+ ("pypy-3.10-linux-x86_64-gnu/bin/pypy3.10",),
168
+ (
169
+ ("pypy-3.10-linux-x86_64-gnu/bin/python", "pypy-3.10-linux-x86_64-gnu/bin/pypy3.10"),
170
+ ("pypy-3.10-linux-x86_64-gnu/bin/pypy", "pypy-3.10-linux-x86_64-gnu/bin/pypy3.10"),
171
+ ),
172
+ ("pypy-3.10-linux-x86_64-gnu/bin/pypy3.10",),
173
+ id="pypy-unix-with-python-symlink",
174
+ ),
175
+ pytest.param(
176
+ ("graalpy-24.1.1-linux-x86_64-gnu/bin/graalpy",),
177
+ (),
178
+ ("graalpy-24.1.1-linux-x86_64-gnu/bin/graalpy",),
179
+ id="graalpy-unix",
180
+ ),
181
+ pytest.param(
182
+ (
183
+ "cpython-3.14.4-windows-x86_64-none/python.exe",
184
+ "pypy-3.11.15-windows-x86_64-none/pypy3.11.exe",
185
+ "graalpy-3.11-windows-x86_64-none/bin/graalpy.exe",
186
+ ),
187
+ (),
188
+ (
189
+ "cpython-3.14.4-windows-x86_64-none/python.exe",
190
+ "pypy-3.11.15-windows-x86_64-none/pypy3.11.exe",
191
+ "graalpy-3.11-windows-x86_64-none/bin/graalpy.exe",
192
+ ),
193
+ id="windows-all-impls",
194
+ ),
195
+ ],
196
+ )
197
+ def test_iter_interpreters_uv_layout(
198
+ uv_dir: Path,
199
+ session_cache: DiskCache,
200
+ monkeypatch: pytest.MonkeyPatch,
201
+ mocker: MockerFixture,
202
+ real_files: tuple[str, ...],
203
+ symlinks: tuple[tuple[str, str], ...],
204
+ expected_real: tuple[str, ...],
205
+ ) -> None:
206
+ monkeypatch.setenv("PATH", "")
207
+ for rel in real_files:
208
+ path = uv_dir / rel
209
+ path.parent.mkdir(parents=True, exist_ok=True)
210
+ path.touch()
211
+ for link_rel, target_rel in symlinks:
212
+ Path(str(uv_dir / link_rel)).symlink_to(uv_dir / target_rel)
213
+
214
+ mock_from_exe = mocker.patch("python_discovery._discovery.PathPythonInfo.from_exe", return_value=None)
215
+ list(iter_interpreters(cache=session_cache))
216
+
217
+ interrogated_real = {os.path.realpath(call.args[0]) for call in mock_from_exe.call_args_list}
218
+ assert interrogated_real == {os.path.realpath(uv_dir / r) for r in expected_real}