python-discovery 1.0.0__tar.gz → 1.1.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 (52) hide show
  1. python_discovery-1.1.1/.github/FUNDING.yaml +1 -0
  2. python_discovery-1.1.1/.github/SECURITY.md +9 -0
  3. python_discovery-1.1.1/.github/release.yaml +5 -0
  4. {python_discovery-1.0.0 → python_discovery-1.1.1}/.github/workflows/check.yaml +3 -0
  5. {python_discovery-1.0.0 → python_discovery-1.1.1}/.github/workflows/release.yaml +5 -2
  6. {python_discovery-1.0.0 → python_discovery-1.1.1}/.pre-commit-config.yaml +3 -3
  7. {python_discovery-1.0.0 → python_discovery-1.1.1}/.readthedocs.yaml +1 -1
  8. {python_discovery-1.0.0 → python_discovery-1.1.1}/PKG-INFO +2 -2
  9. {python_discovery-1.0.0 → python_discovery-1.1.1}/README.md +1 -1
  10. {python_discovery-1.0.0 → python_discovery-1.1.1}/docs/explanation.rst +1 -1
  11. {python_discovery-1.0.0 → python_discovery-1.1.1}/docs/index.rst +1 -1
  12. {python_discovery-1.0.0 → python_discovery-1.1.1}/pyproject.toml +17 -17
  13. {python_discovery-1.0.0 → python_discovery-1.1.1}/src/python_discovery/__init__.py +4 -0
  14. {python_discovery-1.0.0 → python_discovery-1.1.1}/src/python_discovery/_cache.py +40 -8
  15. {python_discovery-1.0.0 → python_discovery-1.1.1}/src/python_discovery/_cached_py_info.py +5 -2
  16. {python_discovery-1.0.0 → python_discovery-1.1.1}/src/python_discovery/_discovery.py +29 -2
  17. {python_discovery-1.0.0 → python_discovery-1.1.1}/src/python_discovery/_py_info.py +69 -8
  18. {python_discovery-1.0.0 → python_discovery-1.1.1}/src/python_discovery/_py_spec.py +31 -4
  19. {python_discovery-1.0.0 → python_discovery-1.1.1}/src/python_discovery/_specifier.py +52 -5
  20. {python_discovery-1.0.0 → python_discovery-1.1.1}/tests/conftest.py +2 -1
  21. {python_discovery-1.0.0 → python_discovery-1.1.1}/tests/py_info/test_py_info.py +1 -1
  22. {python_discovery-1.0.0 → python_discovery-1.1.1}/tests/test_cached_py_info.py +12 -0
  23. {python_discovery-1.0.0 → python_discovery-1.1.1}/tests/test_discovery.py +35 -0
  24. python_discovery-1.0.0/.github/release.yml +0 -5
  25. /python_discovery-1.0.0/.github/dependabot.yml → /python_discovery-1.1.1/.github/dependabot.yaml +0 -0
  26. {python_discovery-1.0.0 → python_discovery-1.1.1}/.gitignore +0 -0
  27. {python_discovery-1.0.0 → python_discovery-1.1.1}/.readthedocs.yml +0 -0
  28. {python_discovery-1.0.0 → python_discovery-1.1.1}/CODE_OF_CONDUCT.md +0 -0
  29. {python_discovery-1.0.0 → python_discovery-1.1.1}/LICENSE +0 -0
  30. {python_discovery-1.0.0 → python_discovery-1.1.1}/docs/_static/custom.css +0 -0
  31. {python_discovery-1.0.0 → python_discovery-1.1.1}/docs/_static/logo.svg +0 -0
  32. {python_discovery-1.0.0 → python_discovery-1.1.1}/docs/conf.py +0 -0
  33. {python_discovery-1.0.0 → python_discovery-1.1.1}/docs/how-to/standalone-usage.rst +0 -0
  34. {python_discovery-1.0.0 → python_discovery-1.1.1}/docs/reference/api.rst +0 -0
  35. {python_discovery-1.0.0 → python_discovery-1.1.1}/docs/tutorial/getting-started.rst +0 -0
  36. {python_discovery-1.0.0 → python_discovery-1.1.1}/src/python_discovery/_compat.py +0 -0
  37. {python_discovery-1.0.0 → python_discovery-1.1.1}/src/python_discovery/_windows/__init__.py +0 -0
  38. {python_discovery-1.0.0 → python_discovery-1.1.1}/src/python_discovery/_windows/_pep514.py +0 -0
  39. {python_discovery-1.0.0 → python_discovery-1.1.1}/src/python_discovery/_windows/_propose.py +0 -0
  40. {python_discovery-1.0.0 → python_discovery-1.1.1}/src/python_discovery/py.typed +0 -0
  41. {python_discovery-1.0.0 → python_discovery-1.1.1}/tests/py_info/test_py_info_exe_based_of.py +0 -0
  42. {python_discovery-1.0.0 → python_discovery-1.1.1}/tests/test_cache.py +0 -0
  43. {python_discovery-1.0.0 → python_discovery-1.1.1}/tests/test_discovery_extra.py +0 -0
  44. {python_discovery-1.0.0 → python_discovery-1.1.1}/tests/test_py_info_extra.py +0 -0
  45. {python_discovery-1.0.0 → python_discovery-1.1.1}/tests/test_py_spec.py +0 -0
  46. {python_discovery-1.0.0 → python_discovery-1.1.1}/tests/test_py_spec_extra.py +0 -0
  47. {python_discovery-1.0.0 → python_discovery-1.1.1}/tests/test_specifier.py +0 -0
  48. {python_discovery-1.0.0 → python_discovery-1.1.1}/tests/windows/conftest.py +0 -0
  49. {python_discovery-1.0.0 → python_discovery-1.1.1}/tests/windows/test_windows.py +0 -0
  50. {python_discovery-1.0.0 → python_discovery-1.1.1}/tests/windows/test_windows_pep514.py +0 -0
  51. {python_discovery-1.0.0 → python_discovery-1.1.1}/tests/windows/winreg_mock_values.py +0 -0
  52. {python_discovery-1.0.0 → python_discovery-1.1.1}/tox.toml +0 -0
@@ -0,0 +1 @@
1
+ github: gaborbernat
@@ -0,0 +1,9 @@
1
+ # Security Policy
2
+
3
+ ## Supported Versions
4
+
5
+ Only the latest released version is supported with security updates. Older versions will not receive patches.
6
+
7
+ ## Reporting a Vulnerability
8
+
9
+ Please report security vulnerabilities via [GitHub's private vulnerability reporting](../../security/advisories/new).
@@ -0,0 +1,5 @@
1
+ changelog:
2
+ exclude:
3
+ authors:
4
+ - dependabot[bot]
5
+ - pre-commit-ci[bot]
@@ -12,6 +12,9 @@ concurrency:
12
12
  group: check-${{ github.ref }}
13
13
  cancel-in-progress: true
14
14
 
15
+ permissions:
16
+ contents: read
17
+
15
18
  jobs:
16
19
  test:
17
20
  runs-on: ubuntu-latest
@@ -3,6 +3,9 @@ on:
3
3
  push:
4
4
  tags: ["*"]
5
5
 
6
+ permissions:
7
+ contents: read
8
+
6
9
  env:
7
10
  dists-artifact-name: python-package-distributions
8
11
 
@@ -22,7 +25,7 @@ jobs:
22
25
  - name: Build package
23
26
  run: uv build --python 3.14 --python-preference only-managed --sdist --wheel . --out-dir dist
24
27
  - name: Store the distribution packages
25
- uses: actions/upload-artifact@v6
28
+ uses: actions/upload-artifact@v7
26
29
  with:
27
30
  name: ${{ env.dists-artifact-name }}
28
31
  path: dist/*
@@ -38,7 +41,7 @@ jobs:
38
41
  id-token: write
39
42
  steps:
40
43
  - name: Download all the dists
41
- uses: actions/download-artifact@v7
44
+ uses: actions/download-artifact@v8
42
45
  with:
43
46
  name: ${{ env.dists-artifact-name }}
44
47
  path: dist/
@@ -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.36.1
8
+ rev: 0.37.0
9
9
  hooks:
10
10
  - id: check-github-workflows
11
11
  args: ["--verbose"]
@@ -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.11.1"
18
+ rev: "v2.16.2"
19
19
  hooks:
20
20
  - id: pyproject-fmt
21
21
  - repo: https://github.com/astral-sh/ruff-pre-commit
22
- rev: "v0.14.14"
22
+ rev: "v0.15.4"
23
23
  hooks:
24
24
  - id: ruff-format
25
25
  - id: ruff
@@ -5,4 +5,4 @@ build:
5
5
  commands:
6
6
  - curl -LsSf https://astral.sh/uv/install.sh | sh
7
7
  - ~/.local/bin/uv tool install tox --with tox-uv -p 3.14 --managed-python
8
- - ~/.local/bin/tox run -e docs --
8
+ - ~/.local/bin/tox run -e docs -- "$READTHEDOCS_OUTPUT/html"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-discovery
3
- Version: 1.0.0
3
+ Version: 1.1.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
@@ -67,5 +67,5 @@ Description-Content-Type: text/markdown
67
67
  [![Supported Python
68
68
  versions](https://img.shields.io/pypi/pyversions/python-discovery.svg)](https://pypi.org/project/python-discovery/)
69
69
  [![Downloads](https://static.pepy.tech/badge/python-discovery/month)](https://pepy.tech/project/python-discovery)
70
- [![check](https://github.com/tox-dev/python-discovery/actions/workflows/check.yml/badge.svg)](https://github.com/tox-dev/python-discovery/actions/workflows/check.yml)
70
+ [![check](https://github.com/tox-dev/python-discovery/actions/workflows/check.yaml/badge.svg)](https://github.com/tox-dev/python-discovery/actions/workflows/check.yaml)
71
71
  [![Documentation Status](https://readthedocs.org/projects/python-discovery/badge/?version=latest)](https://python-discovery.readthedocs.io/en/latest/?badge=latest)
@@ -4,5 +4,5 @@
4
4
  [![Supported Python
5
5
  versions](https://img.shields.io/pypi/pyversions/python-discovery.svg)](https://pypi.org/project/python-discovery/)
6
6
  [![Downloads](https://static.pepy.tech/badge/python-discovery/month)](https://pepy.tech/project/python-discovery)
7
- [![check](https://github.com/tox-dev/python-discovery/actions/workflows/check.yml/badge.svg)](https://github.com/tox-dev/python-discovery/actions/workflows/check.yml)
7
+ [![check](https://github.com/tox-dev/python-discovery/actions/workflows/check.yaml/badge.svg)](https://github.com/tox-dev/python-discovery/actions/workflows/check.yaml)
8
8
  [![Documentation Status](https://readthedocs.org/projects/python-discovery/badge/?version=latest)](https://python-discovery.readthedocs.io/en/latest/?badge=latest)
@@ -2,7 +2,7 @@ How it works
2
2
  ============
3
3
 
4
4
  Where does python-discovery look?
5
- -------------------------------
5
+ ---------------------------------
6
6
 
7
7
  When you call :func:`~python_discovery.get_interpreter`, the library checks several locations in
8
8
  order. It stops as soon as it finds an interpreter that matches your spec.
@@ -1,5 +1,5 @@
1
1
  python-discovery
2
- ============
2
+ ================
3
3
 
4
4
  You may have multiple Python versions installed on your machine -- system Python, versions from
5
5
  `pyenv <https://github.com/pyenv/pyenv>`_, `mise <https://mise.jdx.dev/>`_,
@@ -129,31 +129,20 @@ count = true
129
129
  [tool.pyproject-fmt]
130
130
  max_supported_python = "3.14"
131
131
 
132
- [tool.pytest.ini_options]
133
- markers = [ "graalpy" ]
132
+ [tool.pytest]
133
+ ini_options.markers = [ "graalpy" ]
134
134
 
135
135
  [tool.coverage]
136
- run.source = [
137
- "python_discovery",
138
- "tests",
139
- ]
140
- run.dynamic_context = "test_function"
141
136
  run.branch = true
137
+ run.dynamic_context = "test_function"
142
138
  run.parallel = true
143
139
  run.plugins = [
144
140
  "covdefaults",
145
141
  ]
146
- report.fail_under = 100
147
- report.show_missing = true
148
- report.partial_branches = [
149
- "assert any\\(",
150
- ]
151
- report.omit = [
152
- "src/python_discovery/_windows/*",
153
- "tests/windows/*",
142
+ run.source = [
143
+ "python_discovery",
144
+ "tests",
154
145
  ]
155
- html.show_contexts = true
156
- html.skip_covered = false
157
146
  paths.source = [
158
147
  "src",
159
148
  ".tox*/*/lib/python*/site-packages",
@@ -162,6 +151,17 @@ paths.source = [
162
151
  "*/src",
163
152
  "*\\src",
164
153
  ]
154
+ report.fail_under = 100
155
+ report.omit = [
156
+ "src/python_discovery/_windows/*",
157
+ "tests/windows/*",
158
+ ]
159
+ report.partial_branches = [
160
+ "assert any\\(",
161
+ ]
162
+ report.show_missing = true
163
+ html.show_contexts = true
164
+ html.skip_covered = false
165
165
 
166
166
  [tool.ty]
167
167
  src.exclude = [ "tests/windows/winreg_mock_values.py" ]
@@ -8,6 +8,7 @@ from ._cache import ContentStore, DiskCache, PyInfoCache
8
8
  from ._discovery import get_interpreter
9
9
  from ._py_info import PythonInfo
10
10
  from ._py_spec import PythonSpec
11
+ from ._specifier import SimpleSpecifier, SimpleSpecifierSet, SimpleVersion
11
12
 
12
13
  __version__ = version("python-discovery")
13
14
 
@@ -17,6 +18,9 @@ __all__ = [
17
18
  "PyInfoCache",
18
19
  "PythonInfo",
19
20
  "PythonSpec",
21
+ "SimpleSpecifier",
22
+ "SimpleSpecifierSet",
23
+ "SimpleVersion",
20
24
  "__version__",
21
25
  "get_interpreter",
22
26
  ]
@@ -19,25 +19,47 @@ _LOGGER: Final[logging.Logger] = logging.getLogger(__name__)
19
19
  class ContentStore(Protocol):
20
20
  """A store for reading and writing cached content."""
21
21
 
22
- def exists(self) -> bool: ...
22
+ def exists(self) -> bool:
23
+ """Return whether the cached content exists."""
24
+ ...
23
25
 
24
- def read(self) -> dict | None: ...
26
+ def read(self) -> dict | None:
27
+ """Read the cached content, or ``None`` if unavailable or corrupt."""
28
+ ...
25
29
 
26
- def write(self, content: dict) -> None: ...
30
+ def write(self, content: dict) -> None:
31
+ """
32
+ Persist *content* to the store.
27
33
 
28
- def remove(self) -> None: ...
34
+ :param content: interpreter metadata to cache.
35
+ """
36
+ ...
37
+
38
+ def remove(self) -> None:
39
+ """Delete the cached content."""
40
+ ...
29
41
 
30
42
  @contextmanager
31
- def locked(self) -> Generator[None]: ...
43
+ def locked(self) -> Generator[None]:
44
+ """Context manager that acquires an exclusive lock on this store."""
45
+ ...
32
46
 
33
47
 
34
48
  @runtime_checkable
35
49
  class PyInfoCache(Protocol):
36
50
  """Cache interface for Python interpreter information."""
37
51
 
38
- def py_info(self, path: Path) -> ContentStore: ...
52
+ def py_info(self, path: Path) -> ContentStore:
53
+ """
54
+ Return the content store for the interpreter at *path*.
55
+
56
+ :param path: absolute path to a Python executable.
57
+ """
58
+ ...
39
59
 
40
- def py_info_clear(self) -> None: ...
60
+ def py_info_clear(self) -> None:
61
+ """Remove all cached interpreter information."""
62
+ ...
41
63
 
42
64
 
43
65
  class DiskContentStore:
@@ -91,7 +113,11 @@ class DiskContentStore:
91
113
 
92
114
 
93
115
  class DiskCache:
94
- """File-system based Python interpreter info cache (``<root>/py_info/4/<sha256>.json``)."""
116
+ """
117
+ File-system based Python interpreter info cache (``<root>/py_info/4/<sha256>.json``).
118
+
119
+ :param root: root directory for the on-disk cache.
120
+ """
95
121
 
96
122
  def __init__(self, root: Path) -> None:
97
123
  self._root = root
@@ -101,10 +127,16 @@ class DiskCache:
101
127
  return self._root / "py_info" / "4"
102
128
 
103
129
  def py_info(self, path: Path) -> DiskContentStore:
130
+ """
131
+ Return the content store for the interpreter at *path*.
132
+
133
+ :param path: absolute path to a Python executable.
134
+ """
104
135
  key = sha256(str(path).encode("utf-8")).hexdigest()
105
136
  return DiskContentStore(self._py_info_dir, key)
106
137
 
107
138
  def py_info_clear(self) -> None:
139
+ """Remove all cached interpreter information."""
108
140
  folder = self._py_info_dir
109
141
  if folder.exists():
110
142
  for entry in folder.iterdir():
@@ -15,7 +15,7 @@ from collections import OrderedDict
15
15
  from contextlib import contextmanager
16
16
  from pathlib import Path
17
17
  from shlex import quote
18
- from subprocess import Popen # noqa: S404
18
+ from subprocess import Popen, TimeoutExpired # noqa: S404
19
19
  from typing import TYPE_CHECKING, Final
20
20
 
21
21
  from ._cache import NoOpCache
@@ -206,8 +206,11 @@ def _run_subprocess(
206
206
  encoding="utf-8",
207
207
  errors="backslashreplace",
208
208
  )
209
- out, err = process.communicate()
209
+ out, err = process.communicate(timeout=5)
210
210
  code = process.returncode
211
+ except TimeoutExpired:
212
+ process.kill()
213
+ out, err, code = "", "timed out", -1
211
214
  except OSError as os_error:
212
215
  out, err, code = "", os_error.strerror, os_error.errno
213
216
  if code != 0:
@@ -27,10 +27,26 @@ def get_interpreter(
27
27
  try_first_with: Iterable[str] | None = None,
28
28
  cache: PyInfoCache | None = None,
29
29
  env: Mapping[str, str] | None = None,
30
+ predicate: Callable[[PythonInfo], bool] | None = None,
30
31
  ) -> PythonInfo | None:
32
+ """
33
+ Find a Python interpreter matching *key*.
34
+
35
+ Iterates over one or more specification strings and returns the first interpreter that satisfies the spec and passes
36
+ the optional *predicate*.
37
+
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.
40
+ :param try_first_with: executables to probe before the normal discovery search.
41
+ :param cache: interpreter metadata cache; when ``None`` results are not cached.
42
+ :param env: environment mapping for ``PATH`` lookup; defaults to :data:`os.environ`.
43
+ :param predicate: optional callback applied after an interpreter matches the spec. Return ``True`` to accept the
44
+ interpreter, ``False`` to skip it and continue searching.
45
+ :return: the first matching interpreter, or ``None`` if no match is found.
46
+ """
31
47
  specs = [key] if isinstance(key, str) else key
32
48
  for spec_str in specs:
33
- if result := _find_interpreter(spec_str, try_first_with or (), cache, env):
49
+ if result := _find_interpreter(spec_str, try_first_with or (), cache, env, predicate):
34
50
  return result
35
51
  return None
36
52
 
@@ -40,6 +56,7 @@ def _find_interpreter(
40
56
  try_first_with: Iterable[str],
41
57
  cache: PyInfoCache | None = None,
42
58
  env: Mapping[str, str] | None = None,
59
+ predicate: Callable[[PythonInfo], bool] | None = None,
43
60
  ) -> PythonInfo | None:
44
61
  spec = PythonSpec.from_string_spec(key)
45
62
  _LOGGER.info("find interpreter for spec %r", spec)
@@ -52,7 +69,9 @@ def _find_interpreter(
52
69
  if proposed_key in proposed_paths:
53
70
  continue
54
71
  _LOGGER.info("proposed %s", interpreter)
55
- if interpreter.satisfies(spec, impl_must_match=impl_must_match):
72
+ if interpreter.satisfies(spec, impl_must_match=impl_must_match) and (
73
+ predicate is None or predicate(interpreter)
74
+ ):
56
75
  _LOGGER.debug("accepted %s", interpreter)
57
76
  return interpreter
58
77
  proposed_paths.add(proposed_key)
@@ -88,6 +107,14 @@ def propose_interpreters(
88
107
  cache: PyInfoCache | None = None,
89
108
  env: Mapping[str, str] | None = None,
90
109
  ) -> Generator[tuple[PythonInfo | None, bool], None, None]:
110
+ """
111
+ Yield ``(interpreter, impl_must_match)`` candidates for *spec*.
112
+
113
+ :param spec: the parsed interpreter specification to match against.
114
+ :param try_first_with: executable paths to probe before the standard search.
115
+ :param cache: interpreter metadata cache; when ``None`` results are not cached.
116
+ :param env: environment mapping for ``PATH`` lookup; defaults to :data:`os.environ`.
117
+ """
91
118
  env = os.environ if env is None else env
92
119
  tested_exes: set[str] = set()
93
120
  if spec.is_abs and spec.path is not None:
@@ -245,7 +245,11 @@ class PythonInfo: # noqa: PLR0904
245
245
  return None # in this case we just can't tell easily without poking around FS and calling them, bail
246
246
 
247
247
  def install_path(self, key: str) -> str:
248
- """Return the relative installation path for a given installation scheme *key*."""
248
+ """
249
+ Return the relative installation path for a given installation scheme *key*.
250
+
251
+ :param key: sysconfig installation scheme key (e.g. ``"scripts"``, ``"purelib"``).
252
+ """
249
253
  result = self.distutils_install.get(key)
250
254
  if result is None: # pragma: >=3.11 cover # distutils is empty when "venv" scheme is available
251
255
  # set prefixes to empty => result is relative from cwd
@@ -308,7 +312,13 @@ class PythonInfo: # noqa: PLR0904
308
312
  return self.base_prefix is not None
309
313
 
310
314
  def sysconfig_path(self, key: str, config_var: dict[str, str] | None = None, sep: str = os.sep) -> str:
311
- """Return the sysconfig install path for a scheme *key*, optionally substituting config variables."""
315
+ """
316
+ Return the sysconfig install path for a scheme *key*, optionally substituting config variables.
317
+
318
+ :param key: sysconfig path key (e.g. ``"purelib"``, ``"include"``).
319
+ :param config_var: replacement mapping for sysconfig variables; when ``None`` uses the interpreter's own values.
320
+ :param sep: path separator to use in the result.
321
+ """
312
322
  pattern = self.sysconfig_paths.get(key)
313
323
  if pattern is None:
314
324
  return ""
@@ -406,14 +416,23 @@ class PythonInfo: # noqa: PLR0904
406
416
 
407
417
  @classmethod
408
418
  def clear_cache(cls, cache: PyInfoCache) -> None:
409
- """Clear all cached interpreter information from *cache*."""
419
+ """
420
+ Clear all cached interpreter information from *cache*.
421
+
422
+ :param cache: the cache store to clear.
423
+ """
410
424
  from ._cached_py_info import clear # noqa: PLC0415
411
425
 
412
426
  clear(cache)
413
427
  cls._cache_exe_discovery.clear()
414
428
 
415
429
  def satisfies(self, spec: PythonSpec, *, impl_must_match: bool) -> bool: # noqa: PLR0911
416
- """Check if a given specification can be satisfied by this python interpreter instance."""
430
+ """
431
+ Check if a given specification can be satisfied by this python interpreter instance.
432
+
433
+ :param spec: the specification to check against.
434
+ :param impl_must_match: when ``True``, the implementation name must match exactly.
435
+ """
417
436
  if spec.path and not self._satisfies_path(spec):
418
437
  return False
419
438
  if impl_must_match and not self._satisfies_implementation(spec):
@@ -462,7 +481,11 @@ class PythonInfo: # noqa: PLR0904
462
481
 
463
482
  @classmethod
464
483
  def current(cls, cache: PyInfoCache | None = None) -> PythonInfo:
465
- """Locate the current host interpreter information."""
484
+ """
485
+ Locate the current host interpreter information.
486
+
487
+ :param cache: interpreter metadata cache; when ``None`` results are not cached.
488
+ """
466
489
  if cls._current is None:
467
490
  result = cls.from_exe(sys.executable, cache, raise_on_error=True, resolve_to_host=False)
468
491
  if result is None:
@@ -473,7 +496,11 @@ class PythonInfo: # noqa: PLR0904
473
496
 
474
497
  @classmethod
475
498
  def current_system(cls, cache: PyInfoCache | None = None) -> PythonInfo:
476
- """Locate the current system interpreter information, resolving through any virtualenv layers."""
499
+ """
500
+ Locate the current system interpreter information, resolving through any virtualenv layers.
501
+
502
+ :param cache: interpreter metadata cache; when ``None`` results are not cached.
503
+ """
477
504
  if cls._current_system is None:
478
505
  result = cls.from_exe(sys.executable, cache, raise_on_error=True, resolve_to_host=True)
479
506
  if result is None:
@@ -483,9 +510,11 @@ class PythonInfo: # noqa: PLR0904
483
510
  return cls._current_system
484
511
 
485
512
  def to_json(self) -> str:
513
+ """Serialize this interpreter information to a JSON string."""
486
514
  return json.dumps(self.to_dict(), indent=2)
487
515
 
488
516
  def to_dict(self) -> dict[str, object]:
517
+ """Convert this interpreter information to a plain dictionary."""
489
518
  data = {var: (getattr(self, var) if var != "_creators" else None) for var in vars(self)}
490
519
  version_info = data["version_info"]
491
520
  data["version_info"] = version_info._asdict() if hasattr(version_info, "_asdict") else version_info
@@ -502,7 +531,16 @@ class PythonInfo: # noqa: PLR0904
502
531
  resolve_to_host: bool = True,
503
532
  env: Mapping[str, str] | None = None,
504
533
  ) -> PythonInfo | None:
505
- """Get the python information for a given executable path."""
534
+ """
535
+ Get the python information for a given executable path.
536
+
537
+ :param exe: path to the Python executable.
538
+ :param cache: interpreter metadata cache; when ``None`` results are not cached.
539
+ :param raise_on_error: raise on failure instead of returning ``None``.
540
+ :param ignore_cache: bypass the cache and re-query the interpreter.
541
+ :param resolve_to_host: resolve through virtualenv layers to the system interpreter.
542
+ :param env: environment mapping; defaults to :data:`os.environ`.
543
+ """
506
544
  from ._cached_py_info import from_exe # noqa: PLC0415
507
545
 
508
546
  env = os.environ if env is None else env
@@ -520,11 +558,21 @@ class PythonInfo: # noqa: PLR0904
520
558
 
521
559
  @classmethod
522
560
  def from_json(cls, payload: str) -> PythonInfo:
561
+ """
562
+ Deserialize interpreter information from a JSON string.
563
+
564
+ :param payload: JSON produced by :meth:`to_json`.
565
+ """
523
566
  raw = json.loads(payload)
524
567
  return cls.from_dict(raw.copy())
525
568
 
526
569
  @classmethod
527
570
  def from_dict(cls, data: dict[str, object]) -> PythonInfo:
571
+ """
572
+ Reconstruct a :class:`PythonInfo` from a plain dictionary.
573
+
574
+ :param data: dictionary produced by :meth:`to_dict`.
575
+ """
528
576
  data["version_info"] = VersionInfo(**data["version_info"]) # restore this to a named tuple structure
529
577
  result = cls()
530
578
  result.__dict__ = data.copy()
@@ -532,6 +580,12 @@ class PythonInfo: # noqa: PLR0904
532
580
 
533
581
  @classmethod
534
582
  def resolve_to_system(cls, cache: PyInfoCache | None, target: PythonInfo) -> PythonInfo:
583
+ """
584
+ Walk virtualenv/venv prefix chains to find the underlying system interpreter.
585
+
586
+ :param cache: interpreter metadata cache; when ``None`` results are not cached.
587
+ :param target: the interpreter to resolve.
588
+ """
535
589
  start_executable = target.executable
536
590
  prefixes = OrderedDict()
537
591
  while target.system_executable is None:
@@ -565,7 +619,14 @@ class PythonInfo: # noqa: PLR0904
565
619
  exact: bool = True,
566
620
  env: Mapping[str, str] | None = None,
567
621
  ) -> PythonInfo:
568
- """Discover a matching Python executable under a given *prefix* directory."""
622
+ """
623
+ Discover a matching Python executable under a given *prefix* directory.
624
+
625
+ :param cache: interpreter metadata cache.
626
+ :param prefix: directory prefix to search under.
627
+ :param exact: when ``True``, require an exact version match.
628
+ :param env: environment mapping; defaults to :data:`os.environ`.
629
+ """
569
630
  key = prefix, exact
570
631
  if key in self._cache_exe_discovery and prefix:
571
632
  _LOGGER.debug("discover exe from cache %s - exact %s: %r", prefix, exact, self._cache_exe_discovery[key])
@@ -99,7 +99,20 @@ def _parse_specifier(string_spec: str) -> PythonSpec | None:
99
99
 
100
100
 
101
101
  class PythonSpec:
102
- """Contains specification about a Python Interpreter."""
102
+ """
103
+ Contains specification about a Python Interpreter.
104
+
105
+ :param str_spec: the raw specification string as provided by the caller.
106
+ :param implementation: interpreter implementation name (e.g. ``"cpython"``, ``"pypy"``), or ``None`` for any.
107
+ :param major: required major version, or ``None`` for any.
108
+ :param minor: required minor version, or ``None`` for any.
109
+ :param micro: required micro (patch) version, or ``None`` for any.
110
+ :param architecture: required pointer-size bitness (``32`` or ``64``), or ``None`` for any.
111
+ :param path: filesystem path to a specific interpreter, or ``None``.
112
+ :param free_threaded: whether a free-threaded build is required, or ``None`` for any.
113
+ :param machine: required ISA (e.g. ``"arm64"``), or ``None`` for any.
114
+ :param version_specifier: PEP 440 version constraints, or ``None``.
115
+ """
103
116
 
104
117
  def __init__( # noqa: PLR0913, PLR0917
105
118
  self,
@@ -128,7 +141,12 @@ class PythonSpec:
128
141
 
129
142
  @classmethod
130
143
  def from_string_spec(cls, string_spec: str) -> PythonSpec:
131
- """Parse a string specification into a PythonSpec."""
144
+ """
145
+ Parse a string specification into a :class:`PythonSpec`.
146
+
147
+ :param string_spec: an interpreter spec — an absolute path, a version string, an implementation prefix,
148
+ or a PEP 440 specifier.
149
+ """
132
150
  if pathlib.Path(string_spec).is_absolute():
133
151
  return cls(string_spec, None, None, None, None, None, string_spec)
134
152
  if result := _parse_spec_pattern(string_spec):
@@ -138,7 +156,11 @@ class PythonSpec:
138
156
  return cls(string_spec, None, None, None, None, None, string_spec)
139
157
 
140
158
  def generate_re(self, *, windows: bool) -> re.Pattern:
141
- """Generate a regular expression for matching against a filename."""
159
+ """
160
+ Generate a regular expression for matching interpreter filenames.
161
+
162
+ :param windows: if ``True``, require a ``.exe`` suffix.
163
+ """
142
164
  version = r"{}(\.{}(\.{})?)?".format(
143
165
  *(r"\d+" if v is None else v for v in (self.major, self.minor, self.micro)),
144
166
  )
@@ -153,6 +175,7 @@ class PythonSpec:
153
175
 
154
176
  @property
155
177
  def is_abs(self) -> bool:
178
+ """``True`` if the spec refers to an absolute filesystem path."""
156
179
  return self.path is not None and pathlib.Path(self.path).is_absolute()
157
180
 
158
181
  def _check_version_specifier(self, spec: PythonSpec) -> bool:
@@ -188,7 +211,11 @@ class PythonSpec:
188
211
  return None
189
212
 
190
213
  def satisfies(self, spec: PythonSpec) -> bool: # noqa: PLR0911
191
- """Check if this spec is compatible with the given *spec* (e.g. PEP-514 on Windows)."""
214
+ """
215
+ Check if this spec is compatible with the given *spec* (e.g. PEP-514 on Windows).
216
+
217
+ :param spec: the requirement to check against.
218
+ """
192
219
  if spec.is_abs and self.is_abs and self.path != spec.path:
193
220
  return False
194
221
  if (
@@ -40,7 +40,17 @@ _PRE_ORDER: Final[dict[str, int]] = {"a": 1, "b": 2, "rc": 3}
40
40
 
41
41
  @dataclass(**_DC_KW)
42
42
  class SimpleVersion:
43
- """Simple PEP 440-like version parser using only standard library."""
43
+ """
44
+ Simple PEP 440-like version parser using only standard library.
45
+
46
+ :param version_str: the original version string.
47
+ :param major: major version number.
48
+ :param minor: minor version number.
49
+ :param micro: micro (patch) version number.
50
+ :param pre_type: pre-release label (``"a"``, ``"b"``, or ``"rc"``), or ``None``.
51
+ :param pre_num: pre-release sequence number, or ``None``.
52
+ :param release: the ``(major, minor, micro)`` tuple.
53
+ """
44
54
 
45
55
  version_str: str
46
56
  major: int
@@ -52,6 +62,11 @@ class SimpleVersion:
52
62
 
53
63
  @classmethod
54
64
  def from_string(cls, version_str: str) -> SimpleVersion:
65
+ """
66
+ Parse a PEP 440 version string (e.g. ``3.12.1``).
67
+
68
+ :param version_str: the version string to parse.
69
+ """
55
70
  stripped = version_str.strip()
56
71
  if not (match := _VERSION_RE.match(stripped)):
57
72
  msg = f"Invalid version: {version_str}"
@@ -112,7 +127,16 @@ class SimpleVersion:
112
127
 
113
128
  @dataclass(**_DC_KW)
114
129
  class SimpleSpecifier:
115
- """Simple PEP 440-like version specifier using only standard library."""
130
+ """
131
+ Simple PEP 440-like version specifier using only standard library.
132
+
133
+ :param spec_str: the original specifier string (e.g. ``>=3.10``).
134
+ :param operator: the comparison operator (``==``, ``>=``, ``<``, etc.).
135
+ :param version_str: the version portion of the specifier, without the operator.
136
+ :param is_wildcard: ``True`` if the specifier uses a wildcard suffix (``.*``).
137
+ :param wildcard_precision: number of version components before the wildcard, or ``None``.
138
+ :param version: the parsed version, or ``None`` if parsing failed.
139
+ """
116
140
 
117
141
  spec_str: str
118
142
  operator: str
@@ -123,6 +147,11 @@ class SimpleSpecifier:
123
147
 
124
148
  @classmethod
125
149
  def from_string(cls, spec_str: str) -> SimpleSpecifier:
150
+ """
151
+ Parse a single PEP 440 specifier (e.g. ``>=3.10``).
152
+
153
+ :param spec_str: the specifier string to parse.
154
+ """
126
155
  stripped = spec_str.strip()
127
156
  if not (match := _SPECIFIER_RE.match(stripped)):
128
157
  msg = f"Invalid specifier: {spec_str}"
@@ -148,7 +177,11 @@ class SimpleSpecifier:
148
177
  )
149
178
 
150
179
  def contains(self, version_str: str) -> bool:
151
- """Check if a version string satisfies this specifier."""
180
+ """
181
+ Check if a version string satisfies this specifier.
182
+
183
+ :param version_str: the version string to test.
184
+ """
152
185
  try:
153
186
  candidate = SimpleVersion.from_string(version_str) if isinstance(version_str, str) else version_str
154
187
  except ValueError:
@@ -216,13 +249,23 @@ class SimpleSpecifier:
216
249
 
217
250
  @dataclass(**_DC_KW)
218
251
  class SimpleSpecifierSet:
219
- """Simple PEP 440-like specifier set using only standard library."""
252
+ """
253
+ Simple PEP 440-like specifier set using only standard library.
254
+
255
+ :param specifiers_str: the original comma-separated specifier string.
256
+ :param specifiers: the parsed individual specifiers.
257
+ """
220
258
 
221
259
  specifiers_str: str
222
260
  specifiers: tuple[SimpleSpecifier, ...]
223
261
 
224
262
  @classmethod
225
263
  def from_string(cls, specifiers_str: str = "") -> SimpleSpecifierSet:
264
+ """
265
+ Parse a comma-separated PEP 440 specifier string (e.g. ``>=3.10,<4``).
266
+
267
+ :param specifiers_str: the specifier string to parse.
268
+ """
226
269
  stripped = specifiers_str.strip()
227
270
  specs: list[SimpleSpecifier] = []
228
271
  if stripped:
@@ -234,7 +277,11 @@ class SimpleSpecifierSet:
234
277
  return cls(specifiers_str=stripped, specifiers=tuple(specs))
235
278
 
236
279
  def contains(self, version_str: str) -> bool:
237
- """Check if a version satisfies all specifiers in the set."""
280
+ """
281
+ Check if a version satisfies all specifiers in the set.
282
+
283
+ :param version_str: the version string to test.
284
+ """
238
285
  if not self.specifiers:
239
286
  return True
240
287
  return all(spec.contains(version_str) for spec in self.specifiers)
@@ -26,4 +26,5 @@ def _ensure_py_info_cache_empty(session_cache: DiskCache) -> Generator[None]:
26
26
  def _skip_if_test_in_system(session_cache: DiskCache) -> None:
27
27
  current = PythonInfo.current(session_cache)
28
28
  if current.system_executable is not None: # pragma: no cover
29
- pytest.skip("test not valid if run under system")
29
+ msg = "test not valid if run under system"
30
+ raise pytest.skip.Exception(msg)
@@ -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
- known_isas = {"arm64", "x86_64", "x86", "ppc64le", "ppc64", "s390x", "riscv64"}
341
+ known_isas = {"arm64", "i686", "ppc64", "ppc64le", "riscv64", "s390x", "x86", "x86_64"}
342
342
  assert machine in known_isas, f"unexpected machine value: {machine}"
343
343
 
344
344
 
@@ -5,6 +5,7 @@ import logging
5
5
  import os
6
6
  import sys
7
7
  from pathlib import Path
8
+ from subprocess import TimeoutExpired
8
9
  from typing import TYPE_CHECKING
9
10
  from unittest.mock import MagicMock, patch
10
11
 
@@ -110,6 +111,17 @@ def test_run_subprocess_with_cookies(mocker: MockerFixture) -> None:
110
111
  assert mock_stdout.write.call_count == 2
111
112
 
112
113
 
114
+ def test_run_subprocess_timeout(mocker: MockerFixture) -> None:
115
+ mock_process = MagicMock()
116
+ mock_process.communicate.side_effect = TimeoutExpired(cmd="python", timeout=30)
117
+ mocker.patch("python_discovery._cached_py_info.Popen", return_value=mock_process)
118
+ failure, result = _run_subprocess(PythonInfo, sys.executable, dict(os.environ))
119
+ assert failure is not None
120
+ assert "timed out" in str(failure)
121
+ assert result is None
122
+ mock_process.kill.assert_called_once()
123
+
124
+
113
125
  def test_run_subprocess_nonzero_exit(mocker: MockerFixture) -> None:
114
126
  mock_process = MagicMock()
115
127
  mock_process.communicate.return_value = ("some output", "some error")
@@ -409,3 +409,38 @@ def test_shim_colon_separated_pyenv_version_picks_first_match(
409
409
  mock_from_exe.return_value = None
410
410
  get_interpreter("python2.7", [])
411
411
  assert mock_from_exe.call_args_list[0][0][0] == str(second_binary)
412
+
413
+
414
+ def test_predicate_filters_interpreters(session_cache: DiskCache) -> None:
415
+ result = get_interpreter(sys.executable, [], session_cache, predicate=lambda _: False)
416
+ assert result is None
417
+
418
+
419
+ def test_predicate_accepts_interpreter(session_cache: DiskCache) -> None:
420
+ result = get_interpreter(sys.executable, [], session_cache, predicate=lambda _: True)
421
+ assert result is not None
422
+ assert result.executable == sys.executable
423
+
424
+
425
+ def test_predicate_none_is_noop(session_cache: DiskCache) -> None:
426
+ result = get_interpreter(sys.executable, [], session_cache, predicate=None)
427
+ assert result is not None
428
+ assert result.executable == sys.executable
429
+
430
+
431
+ def test_predicate_with_fallback_specs(session_cache: DiskCache) -> None:
432
+ current = PythonInfo.current_system(session_cache)
433
+ major, minor = current.version_info.major, current.version_info.minor
434
+ accepted_exe: str | None = None
435
+
436
+ def reject_first(info: PythonInfo) -> bool:
437
+ nonlocal accepted_exe
438
+ if accepted_exe is None:
439
+ accepted_exe = str(info.executable)
440
+ return False
441
+ return True
442
+
443
+ result = get_interpreter([f"{major}.{minor}", sys.executable], [], session_cache, predicate=reject_first)
444
+ assert accepted_exe is not None
445
+ assert result is not None
446
+ assert str(result.executable) != accepted_exe
@@ -1,5 +0,0 @@
1
- changelog:
2
- exclude:
3
- authors:
4
- - dependabot
5
- - pre-commit-ci