python-discovery 1.2.1__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.
- {python_discovery-1.2.1 → python_discovery-1.3.0}/.github/workflows/check.yaml +1 -1
- {python_discovery-1.2.1 → python_discovery-1.3.0}/.github/workflows/release.yaml +3 -3
- {python_discovery-1.2.1 → python_discovery-1.3.0}/.pre-commit-config.yaml +5 -5
- {python_discovery-1.2.1 → python_discovery-1.3.0}/PKG-INFO +3 -2
- {python_discovery-1.2.1 → python_discovery-1.3.0}/README.md +2 -1
- python_discovery-1.3.0/docs/changelog/59.bugfix.rst +1 -0
- python_discovery-1.3.0/docs/changelog/65.bugfix.rst +3 -0
- python_discovery-1.3.0/docs/changelog/65.feature.rst +3 -0
- {python_discovery-1.2.1 → python_discovery-1.3.0}/docs/conf.py +7 -1
- {python_discovery-1.2.1 → python_discovery-1.3.0}/docs/explanation.rst +107 -4
- {python_discovery-1.2.1 → python_discovery-1.3.0}/docs/how-to/standalone-usage.rst +54 -0
- {python_discovery-1.2.1 → python_discovery-1.3.0}/docs/tutorial/getting-started.rst +45 -1
- {python_discovery-1.2.1 → python_discovery-1.3.0}/pyproject.toml +1 -0
- {python_discovery-1.2.1 → python_discovery-1.3.0}/src/python_discovery/__init__.py +6 -3
- {python_discovery-1.2.1 → python_discovery-1.3.0}/src/python_discovery/_discovery.py +99 -11
- {python_discovery-1.2.1 → python_discovery-1.3.0}/src/python_discovery/_py_info.py +25 -3
- {python_discovery-1.2.1 → python_discovery-1.3.0}/src/python_discovery/_py_spec.py +16 -4
- {python_discovery-1.2.1 → python_discovery-1.3.0}/tests/py_info/test_py_info.py +2 -2
- python_discovery-1.3.0/tests/test_iter_interpreters.py +218 -0
- {python_discovery-1.2.1 → python_discovery-1.3.0}/tests/test_py_spec.py +10 -0
- {python_discovery-1.2.1 → python_discovery-1.3.0}/.github/FUNDING.yaml +0 -0
- {python_discovery-1.2.1 → python_discovery-1.3.0}/.github/SECURITY.md +0 -0
- {python_discovery-1.2.1 → python_discovery-1.3.0}/.github/dependabot.yaml +0 -0
- {python_discovery-1.2.1 → python_discovery-1.3.0}/.github/release.yaml +0 -0
- {python_discovery-1.2.1 → python_discovery-1.3.0}/.gitignore +0 -0
- {python_discovery-1.2.1 → python_discovery-1.3.0}/.readthedocs.yaml +0 -0
- {python_discovery-1.2.1 → python_discovery-1.3.0}/.readthedocs.yml +0 -0
- {python_discovery-1.2.1 → python_discovery-1.3.0}/CODE_OF_CONDUCT.md +0 -0
- {python_discovery-1.2.1 → python_discovery-1.3.0}/LICENSE +0 -0
- {python_discovery-1.2.1 → python_discovery-1.3.0}/docs/_static/custom.css +0 -0
- {python_discovery-1.2.1 → python_discovery-1.3.0}/docs/_static/logo.svg +0 -0
- {python_discovery-1.2.1 → python_discovery-1.3.0}/docs/index.rst +0 -0
- {python_discovery-1.2.1 → python_discovery-1.3.0}/docs/reference/api.rst +0 -0
- {python_discovery-1.2.1 → python_discovery-1.3.0}/docs/reference/environment-variables.rst +0 -0
- {python_discovery-1.2.1 → python_discovery-1.3.0}/src/python_discovery/_cache.py +0 -0
- {python_discovery-1.2.1 → python_discovery-1.3.0}/src/python_discovery/_cached_py_info.py +0 -0
- {python_discovery-1.2.1 → python_discovery-1.3.0}/src/python_discovery/_compat.py +0 -0
- {python_discovery-1.2.1 → python_discovery-1.3.0}/src/python_discovery/_specifier.py +0 -0
- {python_discovery-1.2.1 → python_discovery-1.3.0}/src/python_discovery/_windows/__init__.py +0 -0
- {python_discovery-1.2.1 → python_discovery-1.3.0}/src/python_discovery/_windows/_pep514.py +0 -0
- {python_discovery-1.2.1 → python_discovery-1.3.0}/src/python_discovery/_windows/_propose.py +0 -0
- {python_discovery-1.2.1 → python_discovery-1.3.0}/src/python_discovery/py.typed +0 -0
- {python_discovery-1.2.1 → python_discovery-1.3.0}/tests/conftest.py +0 -0
- {python_discovery-1.2.1 → python_discovery-1.3.0}/tests/py_info/test_py_info_exe_based_of.py +0 -0
- {python_discovery-1.2.1 → python_discovery-1.3.0}/tests/test_cache.py +0 -0
- {python_discovery-1.2.1 → python_discovery-1.3.0}/tests/test_cached_py_info.py +0 -0
- {python_discovery-1.2.1 → python_discovery-1.3.0}/tests/test_discovery.py +0 -0
- {python_discovery-1.2.1 → python_discovery-1.3.0}/tests/test_discovery_extra.py +0 -0
- {python_discovery-1.2.1 → python_discovery-1.3.0}/tests/test_py_info_extra.py +0 -0
- {python_discovery-1.2.1 → python_discovery-1.3.0}/tests/test_py_spec_extra.py +0 -0
- {python_discovery-1.2.1 → python_discovery-1.3.0}/tests/test_specifier.py +0 -0
- {python_discovery-1.2.1 → python_discovery-1.3.0}/tests/windows/conftest.py +0 -0
- {python_discovery-1.2.1 → python_discovery-1.3.0}/tests/windows/test_windows.py +0 -0
- {python_discovery-1.2.1 → python_discovery-1.3.0}/tests/windows/test_windows_pep514.py +0 -0
- {python_discovery-1.2.1 → python_discovery-1.3.0}/tests/windows/winreg_mock_values.py +0 -0
- {python_discovery-1.2.1 → 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@
|
|
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@
|
|
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@
|
|
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@
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
-
|
|
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
|
-
-
|
|
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 @@
|
|
|
1
|
+
export normalize_isa and deprecate KNOWN_ARCHITECTURES - by :user:`rahuldevikar`.
|
|
@@ -21,13 +21,19 @@ extensions = [
|
|
|
21
21
|
"sphinxcontrib.mermaid",
|
|
22
22
|
]
|
|
23
23
|
|
|
24
|
+
extlinks = {
|
|
25
|
+
"issue": ("https://github.com/tox-dev/python-discovery/issues/%s", "#%s"),
|
|
26
|
+
"pull": ("https://github.com/tox-dev/python-discovery/pull/%s", "PR #%s"),
|
|
27
|
+
"user": ("https://github.com/%s", "@%s"),
|
|
28
|
+
}
|
|
29
|
+
|
|
24
30
|
intersphinx_mapping = {
|
|
25
31
|
"python": ("https://docs.python.org/3", None),
|
|
26
32
|
}
|
|
27
33
|
|
|
28
34
|
templates_path = []
|
|
29
35
|
source_suffix = ".rst"
|
|
30
|
-
exclude_patterns = ["_build"]
|
|
36
|
+
exclude_patterns = ["_build", "changelog/*.rst"]
|
|
31
37
|
|
|
32
38
|
main_doc = "index"
|
|
33
39
|
pygments_style = "default"
|
|
@@ -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
|
-
-
|
|
268
|
+
- `Version specifier <https://packaging.python.org/en/latest/specifications/version-specifiers/>`_
|
|
269
|
+
(any Python in range)
|
|
169
270
|
* - ``cpython>=3.11``
|
|
170
|
-
-
|
|
271
|
+
- `Version specifier <https://packaging.python.org/en/latest/specifications/version-specifiers/>`_
|
|
272
|
+
restricted to CPython
|
|
171
273
|
|
|
172
|
-
|
|
173
|
-
specifiers can be comma-separated,
|
|
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
|
|
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
|
|
9
|
-
from ._py_info import KNOWN_ARCHITECTURES, PythonInfo
|
|
10
|
-
from ._py_spec import PythonSpec
|
|
8
|
+
from ._discovery import get_interpreter, iter_interpreters
|
|
9
|
+
from ._py_info import KNOWN_ARCHITECTURES, PythonInfo, normalize_isa
|
|
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,4 +25,6 @@ __all__ = [
|
|
|
24
25
|
"SimpleVersion",
|
|
25
26
|
"__version__",
|
|
26
27
|
"get_interpreter",
|
|
28
|
+
"iter_interpreters",
|
|
29
|
+
"normalize_isa",
|
|
27
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
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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(
|
|
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
|
]
|
|
@@ -770,21 +770,43 @@ class PythonInfo: # noqa: PLR0904
|
|
|
770
770
|
|
|
771
771
|
KNOWN_ARCHITECTURES: frozenset[str] = frozenset({
|
|
772
772
|
"arm64",
|
|
773
|
-
"i686",
|
|
774
773
|
"loongarch64",
|
|
774
|
+
"ppc",
|
|
775
775
|
"ppc64",
|
|
776
776
|
"ppc64le",
|
|
777
777
|
"riscv64",
|
|
778
778
|
"s390x",
|
|
779
|
+
"sparc64",
|
|
779
780
|
"x86",
|
|
780
781
|
"x86_64",
|
|
781
782
|
})
|
|
782
|
-
"""Known CPU architecture (ISA) values after normalization.
|
|
783
|
+
"""Known CPU architecture (ISA) values after normalization.
|
|
784
|
+
|
|
785
|
+
.. deprecated::
|
|
786
|
+
Use :func:`normalize_isa` instead, which handles both known and unknown architectures.
|
|
787
|
+
"""
|
|
783
788
|
|
|
784
789
|
|
|
785
790
|
def normalize_isa(isa: str) -> str:
|
|
791
|
+
"""
|
|
792
|
+
Normalize an ISA (instruction set architecture) string to a canonical form.
|
|
793
|
+
|
|
794
|
+
Known aliases are mapped (e.g. ``amd64`` → ``x86_64``, ``aarch64`` → ``arm64``).
|
|
795
|
+
Unrecognized values are lowercased and returned as-is.
|
|
796
|
+
"""
|
|
786
797
|
low = isa.lower()
|
|
787
|
-
return {
|
|
798
|
+
return {
|
|
799
|
+
"amd64": "x86_64",
|
|
800
|
+
"aarch64": "arm64",
|
|
801
|
+
"i386": "x86",
|
|
802
|
+
"i486": "x86",
|
|
803
|
+
"i586": "x86",
|
|
804
|
+
"i686": "x86",
|
|
805
|
+
"powerpc": "ppc",
|
|
806
|
+
"powerpc64": "ppc64",
|
|
807
|
+
"powerpc64le": "ppc64le",
|
|
808
|
+
"sparcv9": "sparc64",
|
|
809
|
+
}.get(low, low)
|
|
788
810
|
|
|
789
811
|
|
|
790
812
|
def _main() -> None: # pragma: no cover
|
|
@@ -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:
|
|
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
|
|
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
|
-
|
|
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",
|
|
@@ -14,7 +14,7 @@ from typing import TYPE_CHECKING, NamedTuple
|
|
|
14
14
|
import pytest
|
|
15
15
|
from setuptools.dist import Distribution
|
|
16
16
|
|
|
17
|
-
from python_discovery import
|
|
17
|
+
from python_discovery import DiskCache, PythonInfo, PythonSpec
|
|
18
18
|
from python_discovery import _cached_py_info as cached_py_info
|
|
19
19
|
from python_discovery._py_info import VersionInfo
|
|
20
20
|
|
|
@@ -338,7 +338,7 @@ def test_py_info_machine_property() -> None:
|
|
|
338
338
|
assert machine is not None
|
|
339
339
|
assert isinstance(machine, str)
|
|
340
340
|
assert len(machine) > 0
|
|
341
|
-
assert machine
|
|
341
|
+
assert machine == machine.lower(), f"machine value should be lowercase: {machine}"
|
|
342
342
|
|
|
343
343
|
|
|
344
344
|
def test_py_info_machine_in_spec() -> None:
|
|
@@ -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}
|
|
@@ -217,9 +217,19 @@ def test_spec_satisfies_machine(left: str, right: str, expected: bool) -> None:
|
|
|
217
217
|
pytest.param("x86_64", "x86_64", id="x86_64"),
|
|
218
218
|
pytest.param("arm64", "arm64", id="arm64"),
|
|
219
219
|
pytest.param("x86", "x86", id="x86"),
|
|
220
|
+
pytest.param("i386", "x86", id="i386"),
|
|
221
|
+
pytest.param("i486", "x86", id="i486"),
|
|
222
|
+
pytest.param("i586", "x86", id="i586"),
|
|
223
|
+
pytest.param("i686", "x86", id="i686"),
|
|
220
224
|
pytest.param("ppc64le", "ppc64le", id="ppc64le"),
|
|
225
|
+
pytest.param("powerpc", "ppc", id="powerpc"),
|
|
226
|
+
pytest.param("powerpc64", "ppc64", id="powerpc64"),
|
|
227
|
+
pytest.param("powerpc64le", "ppc64le", id="powerpc64le"),
|
|
221
228
|
pytest.param("riscv64", "riscv64", id="riscv64"),
|
|
222
229
|
pytest.param("s390x", "s390x", id="s390x"),
|
|
230
|
+
pytest.param("sparcv9", "sparc64", id="sparcv9"),
|
|
231
|
+
pytest.param("sparc64", "sparc64", id="sparc64"),
|
|
232
|
+
pytest.param("alpha", "alpha", id="alpha-passthrough"),
|
|
223
233
|
],
|
|
224
234
|
)
|
|
225
235
|
def test_normalize_isa(isa: str, normalized: str) -> None:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_discovery-1.2.1 → python_discovery-1.3.0}/tests/py_info/test_py_info_exe_based_of.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|