python-discovery 1.2.1__py3-none-any.whl → 1.3.0__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.
@@ -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 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
  ]
@@ -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 {"amd64": "x86_64", "aarch64": "arm64", "i386": "x86", "i586": "x86"}.get(low, low)
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: 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",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-discovery
3
- Version: 1.2.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
- - 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
 
@@ -1,16 +1,16 @@
1
- python_discovery/__init__.py,sha256=_-cpcN1ZKP1uRuQDpxsSbiqd036yyMOAJXe13PzVyqc,676
1
+ python_discovery/__init__.py,sha256=Hi4j3pMvC8Fo8KoNL-wOHsPSCUHwY4c0qvv5sjnFzME,808
2
2
  python_discovery/_cache.py,sha256=bG4fD-h2pdPmb4v9BldbXmJ5Tlt1boiH1Qrz8kp9Dds,5063
3
3
  python_discovery/_cached_py_info.py,sha256=1AFEoc_-OaCi1PXmaFz9Iy_bEuqOhJdFSDFHljzD-EE,8540
4
4
  python_discovery/_compat.py,sha256=iqxGuhK9rPaiui80G1H1KrweoMJ16gyLYEmUrSXrp5c,709
5
- python_discovery/_discovery.py,sha256=yccO7GNtNuxHtmDHsyqtaASmNHFgKIeEX6Me3jX-GFs,12822
6
- python_discovery/_py_info.py,sha256=-b47WzGdzVlBr7xQxavrKKjmUpIJ2RSV7wfwxkXKi5I,33873
7
- python_discovery/_py_spec.py,sha256=rhm9uahWSQL3JLyyMmM2HQVhrTMVnHnZEAhArkjeNtM,9601
5
+ python_discovery/_discovery.py,sha256=tlcmGDsOL8zitSSQSws9cXYt6MKT6ENiVtfAjQBzBtw,16820
6
+ python_discovery/_py_info.py,sha256=MLDQJbqamOdVev9_OH1b-DcQqTJR0h3RA3dHyB-bAgM,34446
7
+ python_discovery/_py_spec.py,sha256=rmABAbCh0oZ48uLT-2TfOj8GDQu5rmNBIa5dq5vaNCc,10305
8
8
  python_discovery/_specifier.py,sha256=PTh6suNTC0hMnFSxFYF_vggdciFLnwxoh1YflSbgCXA,10368
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=JYSQ5tOYbSMOKtVVfOEkyYskBhEoTsImNr3DJcv7XNE,7658
12
12
  python_discovery/_windows/_propose.py,sha256=iMJf6l0TU4SvuV8UsCHHINAe9L5dUT949xyeyMVdong,1565
13
- python_discovery-1.2.1.dist-info/METADATA,sha256=IakgM5Ss1sLajW-_p4TQRscNlvVmB2oP_X0mdUvDD-M,5404
14
- python_discovery-1.2.1.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
15
- python_discovery-1.2.1.dist-info/licenses/LICENSE,sha256=kOJeH68qSq6o2V7o5_VziwUqGswMnFgGgQH5Mahr1Yg,1023
16
- python_discovery-1.2.1.dist-info/RECORD,,
13
+ python_discovery-1.3.0.dist-info/METADATA,sha256=0an9XeuI3H011i75sO0571tS0rmyOG28Q66WajKbIJQ,5483
14
+ python_discovery-1.3.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
15
+ python_discovery-1.3.0.dist-info/licenses/LICENSE,sha256=kOJeH68qSq6o2V7o5_VziwUqGswMnFgGgQH5Mahr1Yg,1023
16
+ python_discovery-1.3.0.dist-info/RECORD,,