python-discovery 1.5.0__py3-none-any.whl → 1.5.1__py3-none-any.whl

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.
@@ -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",
@@ -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/)
@@ -2,15 +2,15 @@ python_discovery/__init__.py,sha256=Hi4j3pMvC8Fo8KoNL-wOHsPSCUHwY4c0qvv5sjnFzME,
2
2
  python_discovery/_cache.py,sha256=svmG2GzUQzd2xTjAbm2eX1CKxxGAglfv4kxaPuxwPIA,5147
3
3
  python_discovery/_cached_py_info.py,sha256=l9Y9UJGjKjp4M7iR9HfXYzW3hWdQ3KyNOD5IbSEphLw,8659
4
4
  python_discovery/_compat.py,sha256=iqxGuhK9rPaiui80G1H1KrweoMJ16gyLYEmUrSXrp5c,709
5
- python_discovery/_discovery.py,sha256=HF_TIuH6WLpvHU1KQiK9_yWfJ-np1eB9OSJwI1xRgdc,16894
5
+ python_discovery/_discovery.py,sha256=ABPD8MRweUO6B7cG3U3ZBfUIYs08rh6dYPA225EURyY,22032
6
6
  python_discovery/_py_info.py,sha256=sXsOX4epGDRhrLvEcpDThh3r1yKMf5KZVaF2ZegoSOA,37311
7
7
  python_discovery/_py_spec.py,sha256=O1RMC9JQ7m5SRzF6cEELxnIWZpadXYWO4bWDD7TemUs,10965
8
- python_discovery/_specifier.py,sha256=Mu9Wp0tgY3WhXNffsG8Je8K3uc-vxqadEdvvQzWIanA,10416
8
+ python_discovery/_specifier.py,sha256=o7MHb0z_mFSLz9gXqMwJoNkHYjgi2biq3Ibvd0_slvc,10457
9
9
  python_discovery/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
10
  python_discovery/_windows/__init__.py,sha256=qthcyGh36b3WkeSbZ4ECRRYLbCOCaQu5X_DRZ98tXfk,315
11
11
  python_discovery/_windows/_pep514.py,sha256=1TqLTdofSCPu3VFE7D4u2fXweyaqhKJR7b48VmJ9AQQ,7787
12
12
  python_discovery/_windows/_propose.py,sha256=iMJf6l0TU4SvuV8UsCHHINAe9L5dUT949xyeyMVdong,1565
13
- python_discovery-1.5.0.dist-info/METADATA,sha256=aDIMeMkRg8kO2fVDwL41lD5DgT_u9Iu8tokFsq-0UPY,5018
14
- python_discovery-1.5.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
15
- python_discovery-1.5.0.dist-info/licenses/LICENSE,sha256=kOJeH68qSq6o2V7o5_VziwUqGswMnFgGgQH5Mahr1Yg,1023
16
- python_discovery-1.5.0.dist-info/RECORD,,
13
+ python_discovery-1.5.1.dist-info/METADATA,sha256=DWitu_0XNhVQusKTT4aIXioAlImTpg-XH4Yp5ZhHbRc,4980
14
+ python_discovery-1.5.1.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
15
+ python_discovery-1.5.1.dist-info/licenses/LICENSE,sha256=kOJeH68qSq6o2V7o5_VziwUqGswMnFgGgQH5Mahr1Yg,1023
16
+ python_discovery-1.5.1.dist-info/RECORD,,