python-discovery 1.5.0__tar.gz → 1.5.1__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 (58) hide show
  1. {python_discovery-1.5.0 → python_discovery-1.5.1}/.github/workflows/check.yaml +2 -2
  2. {python_discovery-1.5.0 → python_discovery-1.5.1}/.github/workflows/prepare-release.yaml +2 -2
  3. {python_discovery-1.5.0 → python_discovery-1.5.1}/.github/workflows/release.yaml +3 -3
  4. {python_discovery-1.5.0 → python_discovery-1.5.1}/.pre-commit-config.yaml +3 -3
  5. {python_discovery-1.5.0 → python_discovery-1.5.1}/PKG-INFO +1 -2
  6. {python_discovery-1.5.0 → python_discovery-1.5.1}/docs/changelog.rst +18 -0
  7. {python_discovery-1.5.0 → python_discovery-1.5.1}/docs/explanation.rst +67 -34
  8. {python_discovery-1.5.0 → python_discovery-1.5.1}/pyproject.toml +0 -1
  9. {python_discovery-1.5.0 → python_discovery-1.5.1}/src/python_discovery/_discovery.py +147 -20
  10. {python_discovery-1.5.0 → python_discovery-1.5.1}/src/python_discovery/_specifier.py +7 -4
  11. {python_discovery-1.5.0 → python_discovery-1.5.1}/tests/test_discovery.py +132 -61
  12. python_discovery-1.5.1/tests/test_iter_interpreters.py +423 -0
  13. python_discovery-1.5.0/tests/test_iter_interpreters.py +0 -218
  14. {python_discovery-1.5.0 → python_discovery-1.5.1}/.github/FUNDING.yaml +0 -0
  15. {python_discovery-1.5.0 → python_discovery-1.5.1}/.github/SECURITY.md +0 -0
  16. {python_discovery-1.5.0 → python_discovery-1.5.1}/.github/dependabot.yaml +0 -0
  17. {python_discovery-1.5.0 → python_discovery-1.5.1}/.github/release.yaml +0 -0
  18. {python_discovery-1.5.0 → python_discovery-1.5.1}/.gitignore +0 -0
  19. {python_discovery-1.5.0 → python_discovery-1.5.1}/.readthedocs.yaml +0 -0
  20. {python_discovery-1.5.0 → python_discovery-1.5.1}/.readthedocs.yml +0 -0
  21. {python_discovery-1.5.0 → python_discovery-1.5.1}/CODE_OF_CONDUCT.md +0 -0
  22. {python_discovery-1.5.0 → python_discovery-1.5.1}/LICENSE +0 -0
  23. {python_discovery-1.5.0 → python_discovery-1.5.1}/README.md +0 -0
  24. {python_discovery-1.5.0 → python_discovery-1.5.1}/docs/_static/custom.css +0 -0
  25. {python_discovery-1.5.0 → python_discovery-1.5.1}/docs/_static/logo.svg +0 -0
  26. {python_discovery-1.5.0 → python_discovery-1.5.1}/docs/changelog/template.jinja2 +0 -0
  27. {python_discovery-1.5.0 → python_discovery-1.5.1}/docs/conf.py +0 -0
  28. {python_discovery-1.5.0 → python_discovery-1.5.1}/docs/how-to/standalone-usage.rst +0 -0
  29. {python_discovery-1.5.0 → python_discovery-1.5.1}/docs/index.rst +0 -0
  30. {python_discovery-1.5.0 → python_discovery-1.5.1}/docs/reference/api.rst +0 -0
  31. {python_discovery-1.5.0 → python_discovery-1.5.1}/docs/reference/environment-variables.rst +0 -0
  32. {python_discovery-1.5.0 → python_discovery-1.5.1}/docs/tutorial/getting-started.rst +0 -0
  33. {python_discovery-1.5.0 → python_discovery-1.5.1}/src/python_discovery/__init__.py +0 -0
  34. {python_discovery-1.5.0 → python_discovery-1.5.1}/src/python_discovery/_cache.py +0 -0
  35. {python_discovery-1.5.0 → python_discovery-1.5.1}/src/python_discovery/_cached_py_info.py +0 -0
  36. {python_discovery-1.5.0 → python_discovery-1.5.1}/src/python_discovery/_compat.py +0 -0
  37. {python_discovery-1.5.0 → python_discovery-1.5.1}/src/python_discovery/_py_info.py +0 -0
  38. {python_discovery-1.5.0 → python_discovery-1.5.1}/src/python_discovery/_py_spec.py +0 -0
  39. {python_discovery-1.5.0 → python_discovery-1.5.1}/src/python_discovery/_windows/__init__.py +0 -0
  40. {python_discovery-1.5.0 → python_discovery-1.5.1}/src/python_discovery/_windows/_pep514.py +0 -0
  41. {python_discovery-1.5.0 → python_discovery-1.5.1}/src/python_discovery/_windows/_propose.py +0 -0
  42. {python_discovery-1.5.0 → python_discovery-1.5.1}/src/python_discovery/py.typed +0 -0
  43. {python_discovery-1.5.0 → python_discovery-1.5.1}/tasks/release.py +0 -0
  44. {python_discovery-1.5.0 → python_discovery-1.5.1}/tests/conftest.py +0 -0
  45. {python_discovery-1.5.0 → python_discovery-1.5.1}/tests/py_info/test_py_info.py +0 -0
  46. {python_discovery-1.5.0 → python_discovery-1.5.1}/tests/py_info/test_py_info_exe_based_of.py +0 -0
  47. {python_discovery-1.5.0 → python_discovery-1.5.1}/tests/test_cache.py +0 -0
  48. {python_discovery-1.5.0 → python_discovery-1.5.1}/tests/test_cached_py_info.py +0 -0
  49. {python_discovery-1.5.0 → python_discovery-1.5.1}/tests/test_discovery_extra.py +0 -0
  50. {python_discovery-1.5.0 → python_discovery-1.5.1}/tests/test_py_info_extra.py +0 -0
  51. {python_discovery-1.5.0 → python_discovery-1.5.1}/tests/test_py_spec.py +0 -0
  52. {python_discovery-1.5.0 → python_discovery-1.5.1}/tests/test_py_spec_extra.py +0 -0
  53. {python_discovery-1.5.0 → python_discovery-1.5.1}/tests/test_specifier.py +0 -0
  54. {python_discovery-1.5.0 → python_discovery-1.5.1}/tests/windows/conftest.py +0 -0
  55. {python_discovery-1.5.0 → python_discovery-1.5.1}/tests/windows/test_windows.py +0 -0
  56. {python_discovery-1.5.0 → python_discovery-1.5.1}/tests/windows/test_windows_pep514.py +0 -0
  57. {python_discovery-1.5.0 → python_discovery-1.5.1}/tests/windows/winreg_mock_values.py +0 -0
  58. {python_discovery-1.5.0 → python_discovery-1.5.1}/tox.toml +0 -0
@@ -33,12 +33,12 @@ jobs:
33
33
  - dev
34
34
  - pkg_meta
35
35
  steps:
36
- - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
36
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
37
37
  with:
38
38
  fetch-depth: 0
39
39
  persist-credentials: false
40
40
  - name: Install the latest version of uv
41
- uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
41
+ uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
42
42
  with:
43
43
  enable-cache: false
44
44
  cache-dependency-glob: "pyproject.toml"
@@ -23,13 +23,13 @@ jobs:
23
23
  permissions:
24
24
  contents: write
25
25
  steps:
26
- - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
26
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
27
27
  with:
28
28
  fetch-depth: 0
29
29
  token: ${{ secrets.RELEASE_PAT }}
30
30
  persist-credentials: true
31
31
  - name: Install the latest version of uv
32
- uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
32
+ uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
33
33
  with:
34
34
  enable-cache: true
35
35
  cache-dependency-glob: "pyproject.toml"
@@ -10,12 +10,12 @@ jobs:
10
10
  build:
11
11
  runs-on: ubuntu-latest
12
12
  steps:
13
- - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
13
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
14
14
  with:
15
15
  fetch-depth: 0
16
16
  persist-credentials: false
17
17
  - name: Install the latest version of uv
18
- uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
18
+ uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
19
19
  with:
20
20
  enable-cache: false
21
21
  cache-dependency-glob: "pyproject.toml"
@@ -43,6 +43,6 @@ jobs:
43
43
  name: ${{ env.dists-artifact-name }}
44
44
  path: dist/
45
45
  - name: Publish to PyPI
46
- uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
46
+ uses: pypa/gh-action-pypi-publish@ba38be9e461d3875417946c167d0b5f3d385a247 # v1.14.1
47
47
  with:
48
48
  attestations: true
@@ -15,11 +15,11 @@ 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.25.3"
18
+ rev: "v2.26.0"
19
19
  hooks:
20
20
  - id: pyproject-fmt
21
21
  - repo: https://github.com/astral-sh/ruff-pre-commit
22
- rev: "v0.15.22"
22
+ rev: "v0.16.0"
23
23
  hooks:
24
24
  - id: ruff-format
25
25
  - id: ruff
@@ -39,7 +39,7 @@ repos:
39
39
  - mdformat-gfm>=1
40
40
  - mdformat-ruff>=0.1.3
41
41
  - repo: https://github.com/zizmorcore/zizmor-pre-commit
42
- rev: v1.27.0
42
+ rev: v1.28.0
43
43
  hooks:
44
44
  - id: zizmor
45
45
  - repo: meta
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-discovery
3
- Version: 1.5.0
3
+ Version: 1.5.1
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
@@ -48,7 +48,6 @@ Classifier: Topic :: Software Development :: Libraries
48
48
  Classifier: Topic :: Utilities
49
49
  Requires-Python: >=3.8
50
50
  Requires-Dist: filelock>=3.15.4
51
- Requires-Dist: platformdirs<5,>=4.3.6
52
51
  Description-Content-Type: text/markdown
53
52
 
54
53
  # [`python-discovery`](https://python-discovery.readthedocs.io/en/latest/)
@@ -6,6 +6,24 @@
6
6
 
7
7
  .. towncrier release notes start
8
8
 
9
+ ********************
10
+ v1.5.1 (2026-07-31)
11
+ ********************
12
+
13
+ Bug fixes - 1.5.1
14
+ =================
15
+ - Locate uv's interpreter store the way uv does, using XDG on macOS (``~/.local/share/uv/python``) and roaming
16
+ ``%APPDATA%\uv\python`` on Windows, neither of which ``platformdirs`` reported. The uv variables now come from the
17
+ ``env`` mapping rather than ``os.environ`` - by :user:`gaborbernat`. (:issue:`109`)
18
+ - Pick uv-managed interpreters by install directory name instead of globbing the store. A bare ``3.8`` now means
19
+ CPython, the way uv resolves it, rather than whichever implementation the filesystem happened to list first, and
20
+ probing runs newest version first - by :user:`gaborbernat`. (:issue:`110`)
21
+
22
+ Packaging updates and notes for downstreams - 1.5.1
23
+ ===================================================
24
+ - Drop the ``platformdirs`` runtime dependency; locating uv's store was the only thing it did - by
25
+ :user:`gaborbernat`. (:issue:`109`)
26
+
9
27
  ********************
10
28
  v1.5.0 (2026-07-21)
11
29
  ********************
@@ -68,9 +68,33 @@ detects these shims and resolves them to the actual binary.
68
68
  How uv-managed Pythons are discovered
69
69
  ---------------------------------------
70
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:
71
+ `uv <https://docs.astral.sh/uv/>`_ installs Python interpreters under a single root directory. ``UV_PYTHON_INSTALL_DIR``
72
+ overrides it; otherwise the store is the ``python`` bucket of uv's state directory, which uv derives as follows:
73
+
74
+ .. list-table::
75
+ :header-rows: 1
76
+ :widths: 20 40 40
77
+
78
+ * - Platform
79
+ - Legacy state directory
80
+ - Current state directory
81
+ * - Windows
82
+ - ``%APPDATA%\uv\data``
83
+ - ``%APPDATA%\uv`` (roaming, not local)
84
+ * - macOS
85
+ - ``~/Library/Application Support/uv``
86
+ - ``$XDG_DATA_HOME/uv`` or ``~/.local/share/uv``
87
+ * - Other
88
+ - same as current
89
+ - ``$XDG_DATA_HOME/uv`` or ``~/.local/share/uv``
90
+
91
+ uv follows the XDG convention on macOS too, and falls back to the legacy directory only when that directory already
92
+ exists. python-discovery searches both, in the same order, so a leftover legacy directory cannot hide the store uv
93
+ installs into. A relative ``XDG_DATA_HOME`` does not count, as the XDG specification requires, and Windows ignores it
94
+ outright. Every one of these variables comes from the ``env`` mapping passed to
95
+ :func:`~python_discovery.get_interpreter`, rather than from the process environment.
96
+
97
+ Each install lives in its own subdirectory, but the actual binary location varies by OS and implementation:
74
98
 
75
99
  .. list-table::
76
100
  :header-rows: 1
@@ -89,42 +113,51 @@ install lives in its own subdirectory, but the actual binary location varies by
89
113
  - ``<root>/<key>/bin/graalpy``
90
114
  - ``<root>/<key>/bin/graalpy.exe``
91
115
 
116
+ GraalPy keeps its ``bin/`` segment on Windows (an upstream choice in uv); PyPy and CPython do not. python-discovery
117
+ globs all of these patterns regardless of the host OS, because globs that do not match anything are essentially
118
+ free, and the cross-platform list is short. Symlinked aliases inside an install (``bin/python``,
119
+ ``bin/python3``, ``bin/python3.14`` all pointing at the same real file) are deduplicated by resolved path before
120
+ the subprocess interrogation, so each install is interrogated once. python-discovery also follows the ``install``
121
+ subdirectory some distributions unpack into, as uv does.
122
+
123
+ Which uv installs get interrogated
124
+ ------------------------------------
125
+
126
+ Interrogating an interpreter costs a subprocess, and a uv install directory announces what it holds in its own name:
127
+ ``<implementation>-<version>[+<variant>]-<os>-<arch>-<libc>``, for example
128
+ ``cpython-3.14.6+freethreaded-macos-aarch64-none``. python-discovery reads that name and skips the installs the spec
129
+ already rules out, then probes what is left in uv's preference order:
130
+
92
131
  .. mermaid::
93
132
 
94
133
  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
134
+ Dirs[/"store subdirectories"/] --> Parse{"name is a uv key?"}
135
+ Parse -->|"no"| Last["probe last"]
136
+ Parse -->|"yes"| Filter["drop what the spec rules out:<br>implementation, version, free-threaded, debug"]
137
+ Filter --> Sort["CPython → PyPy → GraalPy,<br>newest version first,<br>default build before variants"]
138
+ Sort --> Interrogate(["subprocess interrogation"])
139
+ Last --> Interrogate
140
+
141
+ style Dirs fill:#4a90d9,stroke:#2a5f8f,color:#fff
142
+ style Parse fill:#d9904a,stroke:#8f5f2a,color:#fff
143
+ style Filter fill:#9f4ad9,stroke:#5f2a8f,color:#fff
144
+ style Sort fill:#c2873a,stroke:#7a4c1f,color:#fff
121
145
  style Interrogate fill:#4a9f4a,stroke:#2a6f2a,color:#fff
122
146
 
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.
147
+ python-discovery reads the variant suffix the same way :class:`~python_discovery.PythonSpec` does: ``3.14t`` wants
148
+ ``+freethreaded``, ``3.14d`` wants ``+debug``. A spec that says nothing about a variant leaves it unconstrained, so
149
+ ``3.14`` still reaches a ``+debug`` install, after the default build rather than instead of it.
150
+
151
+ A bare version spec such as ``3.8`` matches CPython only, which is what uv resolves it to and what the equivalent
152
+ ``python3.8`` probe on ``PATH`` finds in practice. Ask for ``pypy3.8`` or enumerate with
153
+ :func:`~python_discovery.iter_interpreters` to reach the other implementations - PyPy ships a ``bin/python`` symlink,
154
+ so a store-wide glob would otherwise hand back PyPy for ``3.8`` whenever the filesystem listed it first.
155
+
156
+ Two names carry less information than they appear to. GraalPy keys named after the GraalVM release
157
+ (``graalpy-24.1.1``, from older uv versions) do not constrain the version, and uv's minor version links
158
+ (``cpython-3.15`` pointing at ``cpython-3.15.0b3``) move on the next patch release, so the concrete install ranks
159
+ ahead of the link. Anything that is not a uv key - a hand-made ``UV_PYTHON_INSTALL_DIR`` layout, say - stays in
160
+ the running, at the back of the queue.
128
161
 
129
162
  Selecting one interpreter vs. enumerating all of them
130
163
  -------------------------------------------------------
@@ -45,7 +45,6 @@ dynamic = [
45
45
  ]
46
46
  dependencies = [
47
47
  "filelock>=3.15.4",
48
- "platformdirs<5,>=4.3.6",
49
48
  ]
50
49
  urls.Changelog = "https://github.com/tox-dev/python-discovery/releases"
51
50
  urls.Documentation = "https://python-discovery.readthedocs.io"
@@ -4,14 +4,14 @@ import logging
4
4
  import os
5
5
  import sys
6
6
  from contextlib import suppress
7
+ from dataclasses import dataclass
7
8
  from pathlib import Path
8
9
  from typing import TYPE_CHECKING, Final
9
10
 
10
- from platformdirs import user_data_path
11
-
12
11
  from ._compat import fs_path_id
13
12
  from ._py_info import PythonInfo
14
13
  from ._py_spec import PythonSpec
14
+ from ._specifier import DC_KW, SimpleVersion
15
15
 
16
16
  if TYPE_CHECKING:
17
17
  from collections.abc import Callable, Generator, Iterable, Iterator, Mapping, Sequence
@@ -199,7 +199,7 @@ def propose_interpreters(
199
199
  if spec.path is not None and spec.is_abs: # pragma: no cover # relative spec.path is never abs
200
200
  return
201
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)
202
+ yield from _propose_from_uv(spec, cache, env, all_implementations=all_implementations)
203
203
 
204
204
 
205
205
  def _propose_explicit(
@@ -262,30 +262,157 @@ def _propose_from_path(
262
262
 
263
263
 
264
264
  def _propose_from_uv(
265
+ spec: PythonSpec,
265
266
  cache: PyInfoCache | None,
266
267
  env: Mapping[str, str],
267
268
  *,
268
269
  all_implementations: bool = False,
269
270
  ) -> Generator[tuple[PythonInfo | None, bool], None, None]:
270
- if uv_python_dir := os.getenv("UV_PYTHON_INSTALL_DIR"):
271
- uv_python_path = Path(uv_python_dir).expanduser()
272
- elif xdg_data_home := os.getenv("XDG_DATA_HOME"):
273
- uv_python_path = Path(xdg_data_home).expanduser() / "uv" / "python"
271
+ seen_uv_paths: set[str] = set()
272
+ for root in _uv_python_roots(env):
273
+ for install in _uv_installs(root, spec, all_implementations=all_implementations):
274
+ for exe_path in _uv_executables(install):
275
+ if (resolved := str(exe_path.resolve())) in seen_uv_paths:
276
+ continue
277
+ seen_uv_paths.add(resolved)
278
+ if interpreter := PathPythonInfo.from_exe(str(exe_path), cache, raise_on_error=False, env=env):
279
+ yield interpreter, True
280
+ break # one install directory holds one interpreter
281
+
282
+
283
+ def _uv_python_roots(env: Mapping[str, str]) -> Generator[Path, None, None]:
284
+ """Yield uv's store roots, legacy first; searching both keeps a stale legacy directory from hiding the live one."""
285
+ if install_dir := env.get("UV_PYTHON_INSTALL_DIR"):
286
+ yield Path(install_dir).expanduser()
287
+ return
288
+ for state_dir in _uv_state_dirs(env):
289
+ yield state_dir / "python"
290
+
291
+
292
+ def _uv_state_dirs(env: Mapping[str, str]) -> Generator[Path, None, None]:
293
+ """Yield uv's legacy state directory then its current one, the order uv prefers them in."""
294
+ windows = sys.platform == "win32"
295
+ home = Path(env.get("USERPROFILE" if windows else "HOME") or "~").expanduser()
296
+ if windows:
297
+ app_data = Path(env["APPDATA"]) if "APPDATA" in env else home / "AppData" / "Roaming"
298
+ yield app_data / "uv" / "data"
299
+ yield app_data / "uv"
300
+ return
301
+ if sys.platform == "darwin":
302
+ yield home / "Library" / "Application Support" / "uv"
303
+ # uv ignores a relative XDG_DATA_HOME, as the XDG specification requires
304
+ if (xdg_data_home := env.get("XDG_DATA_HOME")) and (xdg := Path(xdg_data_home)).is_absolute():
305
+ yield xdg / "uv"
274
306
  else:
275
- uv_python_path = user_data_path("uv") / "python"
307
+ yield home / ".local" / "share" / "uv"
276
308
 
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
309
+
310
+ def _uv_installs(root: Path, spec: PythonSpec, *, all_implementations: bool) -> list[_UvInstall]:
311
+ try:
312
+ matching = [
313
+ install
314
+ for entry in sorted(root.iterdir())
315
+ if not entry.name.startswith(".")
316
+ and entry.is_dir()
317
+ and _uv_install_matches(install := _parse_uv_install(entry), spec, all_implementations=all_implementations)
318
+ ]
319
+ except OSError:
320
+ return []
321
+ matching.sort(key=_uv_sort_key) # stable, so equally ranked installs stay in directory name order
322
+ return matching
323
+
324
+
325
+ def _parse_uv_install(path: Path) -> _UvInstall:
326
+ implementation, _, remainder = path.name.partition("-")
327
+ version_str, _, variant = remainder.partition("-")[0].partition("+")
328
+ try:
329
+ version = SimpleVersion.from_string(version_str)
330
+ except ValueError: # not a uv key, but the directory may still hold an interpreter worth probing
331
+ return _UvInstall(path=path, key=None)
332
+ variants = variant.split("+")
333
+ key = _UvKey(
334
+ implementation=implementation.lower(),
335
+ version=version,
336
+ free_threaded="freethreaded" in variants,
337
+ debug="debug" in variants,
338
+ )
339
+ return _UvInstall(path=path, key=key)
340
+
341
+
342
+ @dataclass(**DC_KW)
343
+ class _UvKey:
344
+ """The ``<implementation>-<version>[+<variant>]-<os>-<arch>-<libc>`` name uv gives an install directory."""
345
+
346
+ implementation: str
347
+ version: SimpleVersion
348
+ free_threaded: bool
349
+ debug: bool
350
+
351
+
352
+ @dataclass(**DC_KW)
353
+ class _UvInstall:
354
+ path: Path
355
+ key: _UvKey | None
356
+
357
+
358
+ def _uv_install_matches(install: _UvInstall, spec: PythonSpec, *, all_implementations: bool) -> bool:
359
+ if (key := install.key) is None:
360
+ return True
361
+ if spec.implementation is not None:
362
+ if spec.implementation.lower() != key.implementation:
363
+ return False
364
+ elif not all_implementations and key.implementation != "cpython":
365
+ # uv resolves a bare `3.8` to `cpython-3.8`; on PATH the equivalent probe finds CPython too
366
+ return False
367
+ if spec.free_threaded is not None and spec.free_threaded != key.free_threaded:
368
+ return False
369
+ if spec.debug is not None and spec.debug != key.debug:
370
+ return False
371
+ return _uv_version_matches(key, spec)
372
+
373
+
374
+ def _uv_version_matches(key: _UvKey, spec: PythonSpec) -> bool:
375
+ # GraalPy keys carry the GraalVM version in older uv releases, so only CPython and PyPy state a Python version
376
+ if key.implementation not in {"cpython", "pypy"}:
377
+ return True
378
+ if spec.version_specifier is not None and not spec.version_specifier.contains(str(key.version)):
379
+ return False
380
+ return all(req is None or our == req for our, req in zip(key.version.release, (spec.major, spec.minor, spec.micro)))
381
+
382
+
383
+ def _uv_sort_key(install: _UvInstall) -> tuple[int, ...]:
384
+ """Rank like uv does: CPython first, newest version first, default build ahead of free-threaded and debug."""
385
+ impl_order: Final[tuple[str, ...]] = ("cpython", "pypy", "graalpy", "pyodide")
386
+ if (key := install.key) is None:
387
+ return (len(impl_order) + 1,)
388
+ version = key.version
389
+ return (
390
+ impl_order.index(key.implementation) if key.implementation in impl_order else len(impl_order),
391
+ # uv's minor version links point at a concrete install that moves on upgrade, so report the target instead
392
+ int(install.path.is_symlink()),
393
+ -version.major,
394
+ -version.minor,
395
+ -version.micro,
396
+ -{"a": 0, "b": 1, "rc": 2, None: 3}[version.pre_type],
397
+ -(version.pre_num or 0),
398
+ int(key.free_threaded),
399
+ int(key.debug),
400
+ )
401
+
402
+
403
+ def _uv_executables(install: _UvInstall) -> Generator[Path, None, None]:
404
+ # some distributions unpack into an `install` subdirectory, which uv resolves the same way
405
+ base = nested if (nested := install.path / "install").is_dir() else install.path
406
+ for pattern in (
407
+ "bin/python",
408
+ "python.exe",
409
+ "bin/pypy*",
410
+ "pypy*.exe",
411
+ "bin/graalpy",
412
+ "bin/graalpy.exe",
413
+ "python*t.exe", # free-threaded Windows builds that ship no python.exe (astral-sh/uv#8298)
414
+ ):
415
+ yield from sorted(base.glob(pattern))
289
416
 
290
417
 
291
418
  def get_paths(env: Mapping[str, str]) -> Generator[Path, None, None]:
@@ -9,7 +9,9 @@ import sys
9
9
  from dataclasses import dataclass
10
10
  from typing import TYPE_CHECKING, Final
11
11
 
12
- _DC_KW = {"frozen": True, "kw_only": True, "slots": True} if sys.version_info >= (3, 10) else {"frozen": True}
12
+ DC_KW: Final[dict[str, bool]] = (
13
+ {"frozen": True, "kw_only": True, "slots": True} if sys.version_info >= (3, 10) else {"frozen": True}
14
+ )
13
15
 
14
16
  if TYPE_CHECKING:
15
17
  from collections.abc import Iterator
@@ -38,7 +40,7 @@ _SPECIFIER_RE: Final[re.Pattern[str]] = re.compile(
38
40
  _PRE_ORDER: Final[dict[str, int]] = {"a": 1, "b": 2, "rc": 3}
39
41
 
40
42
 
41
- @dataclass(**_DC_KW)
43
+ @dataclass(**DC_KW)
42
44
  class SimpleVersion:
43
45
  """
44
46
  Simple PEP 440-like version parser using only standard library.
@@ -125,7 +127,7 @@ class SimpleVersion:
125
127
  return f"SimpleVersion('{self.version_str}')"
126
128
 
127
129
 
128
- @dataclass(**_DC_KW)
130
+ @dataclass(**DC_KW)
129
131
  class SimpleSpecifier:
130
132
  """
131
133
  Simple PEP 440-like version specifier using only standard library.
@@ -247,7 +249,7 @@ class SimpleSpecifier:
247
249
  return f"SimpleSpecifier('{self.spec_str}')"
248
250
 
249
251
 
250
- @dataclass(**_DC_KW)
252
+ @dataclass(**DC_KW)
251
253
  class SimpleSpecifierSet:
252
254
  """
253
255
  Simple PEP 440-like specifier set using only standard library.
@@ -305,6 +307,7 @@ class SimpleSpecifierSet:
305
307
 
306
308
 
307
309
  __all__ = [
310
+ "DC_KW",
308
311
  "SimpleSpecifier",
309
312
  "SimpleSpecifierSet",
310
313
  "SimpleVersion",