python-discovery 1.5.1__tar.gz → 1.5.3__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 (61) hide show
  1. {python_discovery-1.5.1 → python_discovery-1.5.3}/.github/dependabot.yaml +6 -1
  2. {python_discovery-1.5.1 → python_discovery-1.5.3}/.github/workflows/check.yaml +24 -1
  3. {python_discovery-1.5.1 → python_discovery-1.5.3}/.github/workflows/prepare-release.yaml +1 -1
  4. {python_discovery-1.5.1 → python_discovery-1.5.3}/.github/workflows/release.yaml +2 -2
  5. {python_discovery-1.5.1 → python_discovery-1.5.3}/.pre-commit-config.yaml +4 -4
  6. {python_discovery-1.5.1 → python_discovery-1.5.3}/PKG-INFO +5 -2
  7. {python_discovery-1.5.1 → python_discovery-1.5.3}/README.md +3 -0
  8. {python_discovery-1.5.1 → python_discovery-1.5.3}/docs/changelog.rst +17 -0
  9. {python_discovery-1.5.1 → python_discovery-1.5.3}/docs/conf.py +4 -0
  10. {python_discovery-1.5.1 → python_discovery-1.5.3}/docs/explanation.rst +12 -0
  11. {python_discovery-1.5.1 → python_discovery-1.5.3}/pyproject.toml +46 -33
  12. {python_discovery-1.5.1 → python_discovery-1.5.3}/src/python_discovery/_cached_py_info.py +65 -29
  13. {python_discovery-1.5.1 → python_discovery-1.5.3}/src/python_discovery/_py_info.py +51 -298
  14. python_discovery-1.5.3/src/python_discovery/_py_info_collect.py +370 -0
  15. python_discovery-1.5.3/tasks/old_target_check.py +45 -0
  16. python_discovery-1.5.3/tasks/old_target_check.sh +8 -0
  17. {python_discovery-1.5.1 → python_discovery-1.5.3}/tests/py_info/test_py_info.py +17 -21
  18. {python_discovery-1.5.1 → python_discovery-1.5.3}/tests/test_cached_py_info.py +97 -1
  19. python_discovery-1.5.3/tests/test_py_info_collect.py +333 -0
  20. {python_discovery-1.5.1 → python_discovery-1.5.3}/tests/test_py_info_extra.py +0 -221
  21. {python_discovery-1.5.1 → python_discovery-1.5.3}/.github/FUNDING.yaml +0 -0
  22. {python_discovery-1.5.1 → python_discovery-1.5.3}/.github/SECURITY.md +0 -0
  23. {python_discovery-1.5.1 → python_discovery-1.5.3}/.github/release.yaml +0 -0
  24. {python_discovery-1.5.1 → python_discovery-1.5.3}/.gitignore +0 -0
  25. {python_discovery-1.5.1 → python_discovery-1.5.3}/.readthedocs.yaml +0 -0
  26. {python_discovery-1.5.1 → python_discovery-1.5.3}/.readthedocs.yml +0 -0
  27. {python_discovery-1.5.1 → python_discovery-1.5.3}/CODE_OF_CONDUCT.md +0 -0
  28. {python_discovery-1.5.1 → python_discovery-1.5.3}/LICENSE +0 -0
  29. {python_discovery-1.5.1 → python_discovery-1.5.3}/docs/_static/custom.css +0 -0
  30. {python_discovery-1.5.1 → python_discovery-1.5.3}/docs/_static/logo.svg +0 -0
  31. {python_discovery-1.5.1 → python_discovery-1.5.3}/docs/changelog/template.jinja2 +0 -0
  32. {python_discovery-1.5.1 → python_discovery-1.5.3}/docs/how-to/standalone-usage.rst +0 -0
  33. {python_discovery-1.5.1 → python_discovery-1.5.3}/docs/index.rst +0 -0
  34. {python_discovery-1.5.1 → python_discovery-1.5.3}/docs/reference/api.rst +0 -0
  35. {python_discovery-1.5.1 → python_discovery-1.5.3}/docs/reference/environment-variables.rst +0 -0
  36. {python_discovery-1.5.1 → python_discovery-1.5.3}/docs/tutorial/getting-started.rst +0 -0
  37. {python_discovery-1.5.1 → python_discovery-1.5.3}/src/python_discovery/__init__.py +0 -0
  38. {python_discovery-1.5.1 → python_discovery-1.5.3}/src/python_discovery/_cache.py +0 -0
  39. {python_discovery-1.5.1 → python_discovery-1.5.3}/src/python_discovery/_compat.py +0 -0
  40. {python_discovery-1.5.1 → python_discovery-1.5.3}/src/python_discovery/_discovery.py +0 -0
  41. {python_discovery-1.5.1 → python_discovery-1.5.3}/src/python_discovery/_py_spec.py +0 -0
  42. {python_discovery-1.5.1 → python_discovery-1.5.3}/src/python_discovery/_specifier.py +0 -0
  43. {python_discovery-1.5.1 → python_discovery-1.5.3}/src/python_discovery/_windows/__init__.py +0 -0
  44. {python_discovery-1.5.1 → python_discovery-1.5.3}/src/python_discovery/_windows/_pep514.py +0 -0
  45. {python_discovery-1.5.1 → python_discovery-1.5.3}/src/python_discovery/_windows/_propose.py +0 -0
  46. {python_discovery-1.5.1 → python_discovery-1.5.3}/src/python_discovery/py.typed +0 -0
  47. {python_discovery-1.5.1 → python_discovery-1.5.3}/tasks/release.py +0 -0
  48. {python_discovery-1.5.1 → python_discovery-1.5.3}/tests/conftest.py +0 -0
  49. {python_discovery-1.5.1 → python_discovery-1.5.3}/tests/py_info/test_py_info_exe_based_of.py +0 -0
  50. {python_discovery-1.5.1 → python_discovery-1.5.3}/tests/test_cache.py +0 -0
  51. {python_discovery-1.5.1 → python_discovery-1.5.3}/tests/test_discovery.py +0 -0
  52. {python_discovery-1.5.1 → python_discovery-1.5.3}/tests/test_discovery_extra.py +0 -0
  53. {python_discovery-1.5.1 → python_discovery-1.5.3}/tests/test_iter_interpreters.py +0 -0
  54. {python_discovery-1.5.1 → python_discovery-1.5.3}/tests/test_py_spec.py +0 -0
  55. {python_discovery-1.5.1 → python_discovery-1.5.3}/tests/test_py_spec_extra.py +0 -0
  56. {python_discovery-1.5.1 → python_discovery-1.5.3}/tests/test_specifier.py +0 -0
  57. {python_discovery-1.5.1 → python_discovery-1.5.3}/tests/windows/conftest.py +0 -0
  58. {python_discovery-1.5.1 → python_discovery-1.5.3}/tests/windows/test_windows.py +0 -0
  59. {python_discovery-1.5.1 → python_discovery-1.5.3}/tests/windows/test_windows_pep514.py +0 -0
  60. {python_discovery-1.5.1 → python_discovery-1.5.3}/tests/windows/winreg_mock_values.py +0 -0
  61. {python_discovery-1.5.1 → python_discovery-1.5.3}/tox.toml +0 -0
@@ -3,6 +3,11 @@ updates:
3
3
  - package-ecosystem: "github-actions"
4
4
  directory: "/"
5
5
  schedule:
6
- interval: "daily"
6
+ interval: "weekly"
7
+ day: "tuesday"
8
+ groups:
9
+ github-actions:
10
+ patterns:
11
+ - "*"
7
12
  cooldown:
8
13
  default-days: 7
@@ -38,7 +38,7 @@ jobs:
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@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
41
+ uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
42
42
  with:
43
43
  enable-cache: false
44
44
  cache-dependency-glob: "pyproject.toml"
@@ -55,3 +55,26 @@ jobs:
55
55
  env:
56
56
  PYTEST_ADDOPTS: "-vv --durations=20"
57
57
  DIFF_AGAINST: HEAD
58
+ old-target:
59
+ runs-on: ubuntu-latest
60
+ steps:
61
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
62
+ with:
63
+ fetch-depth: 0
64
+ persist-credentials: false
65
+ - name: Install the latest version of uv
66
+ uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
67
+ with:
68
+ enable-cache: false
69
+ cache-dependency-glob: "pyproject.toml"
70
+ github-token: ${{ secrets.GITHUB_TOKEN }}
71
+ - name: Build the wheel
72
+ run: uv build --wheel --out-dir dist .
73
+ - name: Discover Python 3.6
74
+ run: tasks/old_target_check.sh 3.6 discover
75
+ - name: Discover Python 3.7
76
+ run: tasks/old_target_check.sh 3.7 discover
77
+ - name: Reject Python 3.5
78
+ run: tasks/old_target_check.sh 3.5 rejected
79
+ - name: Reject Python 2.7
80
+ run: tasks/old_target_check.sh 2.7 rejected
@@ -29,7 +29,7 @@ jobs:
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@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
32
+ uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
33
33
  with:
34
34
  enable-cache: true
35
35
  cache-dependency-glob: "pyproject.toml"
@@ -15,7 +15,7 @@ jobs:
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@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
18
+ uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
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@ba38be9e461d3875417946c167d0b5f3d385a247 # v1.14.1
46
+ uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
47
47
  with:
48
48
  attestations: true
@@ -5,7 +5,7 @@ repos:
5
5
  - id: end-of-file-fixer
6
6
  - id: trailing-whitespace
7
7
  - repo: https://github.com/python-jsonschema/check-jsonschema
8
- rev: 0.37.4
8
+ rev: 0.38.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.26.0"
18
+ rev: "v2.28.0"
19
19
  hooks:
20
20
  - id: pyproject-fmt
21
21
  - repo: https://github.com/astral-sh/ruff-pre-commit
22
- rev: "v0.16.0"
22
+ rev: "v0.16.3"
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.28.0
42
+ rev: v1.29.0
43
43
  hooks:
44
44
  - id: zizmor
45
45
  - repo: meta
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: python-discovery
3
- Version: 1.5.1
3
+ Version: 1.5.3
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
@@ -68,6 +68,9 @@ the right one for you.
68
68
  Give it a requirement like `python3.12` or `>=3.11,<3.13`, and it searches all known locations, verifies each candidate,
69
69
  and returns detailed metadata about the match. Results are cached to disk so repeated lookups are fast.
70
70
 
71
+ The library runs on Python 3.8+ and discovers interpreters as old as Python 3.6; anything older is skipped with a
72
+ warning naming the version it found.
73
+
71
74
  ## Usage
72
75
 
73
76
  ```python
@@ -16,6 +16,9 @@ the right one for you.
16
16
  Give it a requirement like `python3.12` or `>=3.11,<3.13`, and it searches all known locations, verifies each candidate,
17
17
  and returns detailed metadata about the match. Results are cached to disk so repeated lookups are fast.
18
18
 
19
+ The library runs on Python 3.8+ and discovers interpreters as old as Python 3.6; anything older is skipped with a
20
+ warning naming the version it found.
21
+
19
22
  ## Usage
20
23
 
21
24
  ```python
@@ -6,6 +6,23 @@
6
6
 
7
7
  .. towncrier release notes start
8
8
 
9
+ ********************
10
+ v1.5.3 (2026-08-24)
11
+ ********************
12
+
13
+ Bug fixes - 1.5.3
14
+ =================
15
+ - Restore discovery of Python 3.6 and 3.7 interpreters: the interrogation script had grown 3.8-only syntax and is now
16
+ kept to Python 3.6. Candidates below 3.6 log one warning naming the version found instead of two debug-level query
17
+ attempts, and the verdict is cached so they are queried only once; an absolute-path spec for one raises
18
+ ``RuntimeError`` with the same message - by :user:`gaborbernat`. (:issue:`116`)
19
+ - Prefer the version-matched system executable when CPython reports an existing generic executable for a copied POSIX
20
+ virtual environment. (:issue:`124`)
21
+
22
+ Improved documentation - 1.5.3
23
+ ==============================
24
+ - Document the version floors: runs on Python 3.8+, discovers interpreters down to 3.6 - by :user:`gaborbernat`. (:issue:`116`)
25
+
9
26
  ********************
10
27
  v1.5.1 (2026-07-31)
11
28
  ********************
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import os
5
6
  from datetime import datetime, timezone
6
7
  from pathlib import Path
7
8
 
@@ -14,6 +15,7 @@ release = __version__
14
15
  copyright = f"2026-{datetime.now(tz=timezone.utc).year}, {company}" # ruff:ignore[builtin-variable-shadowing]
15
16
 
16
17
  extensions = [
18
+ "sphinx_llm.txt",
17
19
  "sphinx.ext.autodoc",
18
20
  "sphinx.ext.autosectionlabel",
19
21
  "sphinx.ext.extlinks",
@@ -58,3 +60,5 @@ html_theme_options = {
58
60
  "sidebar_hide_name": True,
59
61
  }
60
62
  html_css_files = ["custom.css"]
63
+
64
+ markdown_http_base = os.environ.get("READTHEDOCS_CANONICAL_URL", "")
@@ -243,6 +243,18 @@ The timeout applies to each individual interpreter being queried. If you set a v
243
243
  legitimate interpreters may be skipped; if too high, the discovery process may take longer to fail
244
244
  when encountering problematic interpreters.
245
245
 
246
+ Supported Python versions
247
+ -------------------------
248
+
249
+ Two floors apply: python-discovery itself runs on Python 3.8+ (``requires-python``), while discovery
250
+ reaches down to Python 3.6 - old enough to cover RHEL 8's system Python.
251
+
252
+ python-discovery skips candidates below 3.6 with one warning naming the executable, the version
253
+ found, and the floor; the verdict is cached, so the interpreter is not queried again. Candidates too
254
+ old to run the version check at all (before Python 2.7) fail as a generic query error. An
255
+ absolute-path spec for a below-floor interpreter raises :class:`RuntimeError` with the same message.
256
+ The floor moves only in a major release.
257
+
246
258
  Spec format reference
247
259
  -----------------------
248
260
 
@@ -59,11 +59,13 @@ test = [
59
59
  "pytest>=8.3.5",
60
60
  "pytest-mock>=3.14",
61
61
  "setuptools>=75.1",
62
+ "vermin>=1.6",
62
63
  ]
63
64
  docs = [
64
65
  "furo>=2025.12.19",
65
66
  "sphinx>=9.1",
66
67
  "sphinx-autodoc-typehints>=3.6.3",
68
+ "sphinx-llm>=0.4.1",
67
69
  "sphinxcontrib-mermaid>=2",
68
70
  "sphinxcontrib-towncrier>=0.4",
69
71
  "towncrier>=25.8",
@@ -87,50 +89,57 @@ lint.select = [
87
89
  "ALL",
88
90
  ]
89
91
  lint.ignore = [
90
- "COM812", # Conflict with formatter
91
- "CPY", # No copyright statements
92
- "D203", # `one-blank-line-before-class` and `no-blank-line-before-class` are incompatible
93
- "D212", # `multi-line-summary-first-line` and `multi-line-summary-second-line` are incompatible
94
- "DOC201", # `return` is not documented in docstring
95
- "DOC402", # `yield` is not documented in docstring
96
- "DOC501", # `raises` is not documented in docstring
97
- "ISC001", # Conflict with formatter
98
- "S104", # Possible binding to all interface
92
+ "CPY", # No copyright statements
93
+ "docstring-missing-exception", # `raises` is not documented in docstring
94
+ "docstring-missing-returns", # `return` is not documented in docstring
95
+ "docstring-missing-yields", # `yield` is not documented in docstring
96
+ "hardcoded-bind-all-interfaces", # Possible binding to all interface
97
+ "incorrect-blank-line-before-class", # `one-blank-line-before-class` and `no-blank-line-before-class` are incompatible
98
+ "missing-trailing-comma", # Conflict with formatter
99
+ "multi-line-summary-first-line", # `multi-line-summary-first-line` and `multi-line-summary-second-line` are incompatible
100
+ "single-line-implicit-string-concatenation", # Conflict with formatter
99
101
  ]
100
102
  lint.per-file-ignores."docs/**/*.py" = [
101
- "INP001", # no __init__.py in docs directory
103
+ "implicit-namespace-package", # no __init__.py in docs directory
102
104
  ]
103
105
  lint.per-file-ignores."src/python_discovery/_discovery.py" = [
104
106
  "PTH", # shim resolution uses string-based os.path for consistency with env variables
105
107
  ]
106
108
  lint.per-file-ignores."src/python_discovery/_py_info.py" = [
107
- "PTH", # must use os.path — file runs as subprocess script with only stdlib
109
+ "PTH", # string-based os.path kept for parity with the interrogation script data
110
+ ]
111
+ lint.per-file-ignores."src/python_discovery/_py_info_collect.py" = [
112
+ "ANN", # file must parse on Python 2.7 for the version gate: no annotations
113
+ "missing-required-import", # `from __future__ import annotations` is a SyntaxError below 3.7
114
+ "module-import-not-at-top-of-file", # the version gate must run before importing sysconfig, absent on 3.0/3.1
115
+ "PTH", # must use os.path — file runs as subprocess script with only stdlib
116
+ "UP", # pyupgrade pushes syntax newer than the 2.7 parse floor, e.g. f-strings
108
117
  ]
109
118
  lint.per-file-ignores."src/python_discovery/_windows/_pep514.py" = [
110
119
  "PTH", # os.path.exists is monkeypatched in tests; pathlib.Path.exists bypasses the mock
111
120
  ]
112
121
  lint.per-file-ignores."tasks/**/*.py" = [
113
- "D", # release helper scripts, not API
114
- "INP001", # no __init__.py in tasks directory
115
- "S404", # subprocess import (release tooling)
116
- "S603", # `subprocess` call: argv built from trusted inputs (version, gh CLI)
122
+ "D", # release helper scripts, not API
123
+ "implicit-namespace-package", # no __init__.py in tasks directory
124
+ "subprocess-without-shell-equals-true", # `subprocess` call: argv built from trusted inputs (version, gh CLI)
125
+ "suspicious-subprocess-import", # subprocess import (release tooling)
117
126
  ]
118
127
  lint.per-file-ignores."tests/**/*.py" = [
119
- "D", # don't care about documentation in tests
120
- "FBT", # don't care about booleans as positional arguments in tests
121
- "INP001", # no implicit namespace
122
- "PLC0415", # imports inside test functions (conditional on mocking)
123
- "PLC2701", # private imports needed to test internal APIs
124
- "PLR0913", # too many arguments (pytest fixtures)
125
- "PLR0917", # too many positional arguments (pytest fixtures)
126
- "PLR2004", # Magic value used in comparison
127
- "S101", # asserts allowed in tests
128
- "S404", # subprocess import
129
- "S603", # `subprocess` call: check for execution of untrusted input
130
- "SLF001", # private member access needed to test internals
128
+ "assert", # asserts allowed in tests
129
+ "D", # don't care about documentation in tests
130
+ "FBT", # don't care about booleans as positional arguments in tests
131
+ "implicit-namespace-package", # no implicit namespace
132
+ "import-outside-top-level", # imports inside test functions (conditional on mocking)
133
+ "import-private-name", # private imports needed to test internal APIs
134
+ "magic-value-comparison", # Magic value used in comparison
135
+ "private-member-access", # private member access needed to test internals
136
+ "subprocess-without-shell-equals-true", # `subprocess` call: check for execution of untrusted input
137
+ "suspicious-subprocess-import", # subprocess import
138
+ "too-many-arguments", # too many arguments (pytest fixtures)
139
+ "too-many-positional-arguments", # too many positional arguments (pytest fixtures)
131
140
  ]
132
141
  lint.per-file-ignores."tests/windows/winreg_mock_values.py" = [
133
- "F821", # undefined name (winreg available only on Windows)
142
+ "undefined-name", # undefined name (winreg available only on Windows)
134
143
  ]
135
144
  lint.isort = { known-first-party = [
136
145
  "python_discovery",
@@ -151,7 +160,11 @@ max_supported_python = "3.15"
151
160
  src.exclude = [ "tests/windows/winreg_mock_values.py" ]
152
161
 
153
162
  [[tool.ty.overrides]]
154
- include = [ "src/python_discovery/_py_info.py", "src/python_discovery/_py_spec.py" ]
163
+ include = [
164
+ "src/python_discovery/_py_info.py",
165
+ "src/python_discovery/_py_info_collect.py",
166
+ "src/python_discovery/_py_spec.py",
167
+ ]
155
168
  rules.unused-ignore-comment = "ignore"
156
169
  rules.invalid-argument-type = "ignore"
157
170
  rules.invalid-return-type = "ignore"
@@ -168,16 +181,16 @@ rules.unresolved-attribute = "ignore"
168
181
  ini_options.markers = [ "graalpy" ]
169
182
 
170
183
  [tool.coverage]
184
+ run.source = [
185
+ "python_discovery",
186
+ "tests",
187
+ ]
171
188
  run.branch = true
172
189
  run.dynamic_context = "test_function"
173
190
  run.parallel = true
174
191
  run.plugins = [
175
192
  "covdefaults",
176
193
  ]
177
- run.source = [
178
- "python_discovery",
179
- "tests",
180
- ]
181
194
  paths.source = [
182
195
  "src",
183
196
  ".tox*/*/lib/python*/site-packages",
@@ -13,6 +13,7 @@ import sys
13
13
  import tempfile
14
14
  from collections import OrderedDict
15
15
  from contextlib import contextmanager
16
+ from functools import lru_cache
16
17
  from pathlib import Path
17
18
  from shlex import quote
18
19
  from subprocess import Popen, TimeoutExpired # ruff:ignore[suspicious-subprocess-import]
@@ -20,16 +21,30 @@ from typing import TYPE_CHECKING, Final
20
21
 
21
22
  from ._cache import NoOpCache
22
23
  from ._py_info import PythonInfo
24
+ from ._py_info_collect import MIN_INTERROGATE_VERSION, UNSUPPORTED_EXIT_CODE, UNSUPPORTED_MARKER
23
25
 
24
26
  if TYPE_CHECKING:
25
27
  from collections.abc import Generator, Mapping
28
+ from typing import TypedDict
26
29
 
27
30
  from ._cache import ContentStore, PyInfoCache
28
31
 
32
+ class CacheEntryMeta(TypedDict):
33
+ """Identity of a cached interrogation result: the queried binary and the script that produced it."""
34
+
35
+ st_mtime: float
36
+ path: str
37
+ hash: str | None
38
+
29
39
 
30
40
  _CACHE: OrderedDict[Path, PythonInfo | Exception] = OrderedDict()
31
41
  _CACHE[Path(sys.executable)] = PythonInfo()
32
42
  _LOGGER: Final[logging.Logger] = logging.getLogger(__name__)
43
+ _PY_INFO_SCRIPT: Final[Path] = Path(__file__).resolve().parent / "_py_info_collect.py"
44
+
45
+
46
+ class _UnsupportedInterpreterError(RuntimeError):
47
+ """The interpreter is older than the interrogation script supports; retrying cannot help."""
33
48
 
34
49
 
35
50
  def from_exe( # ruff:ignore[too-many-arguments]
@@ -82,48 +97,54 @@ def _get_via_file_cache(
82
97
  path_modified = path.stat().st_mtime
83
98
  except OSError:
84
99
  path_modified = -1
85
- py_info_script = Path(Path(__file__).resolve()).parent / "_py_info.py"
86
100
  try:
87
- py_info_hash: str | None = hashlib.sha256(py_info_script.read_bytes()).hexdigest()
101
+ py_info_hash: str | None = _script_hash()
88
102
  except OSError:
89
103
  py_info_hash = None
90
104
 
91
105
  resolved_cache = cache if cache is not None else NoOpCache()
92
- py_info: PythonInfo | None = None
93
106
  py_info_store = resolved_cache.py_info(path)
107
+ entry_meta: CacheEntryMeta = {"st_mtime": path_modified, "path": path_text, "hash": py_info_hash}
94
108
  with py_info_store.locked():
95
- if py_info_store.exists() and (data := py_info_store.read()) is not None:
96
- of_path, of_st_mtime = data.get("path"), data.get("st_mtime")
97
- of_content, of_hash = data.get("content"), data.get("hash")
98
- if (
99
- of_path == path_text
100
- and of_st_mtime == path_modified
101
- and of_hash == py_info_hash
102
- and isinstance(of_content, dict)
103
- ):
104
- py_info = _load_cached_py_info(cls, py_info_store, of_content)
105
- else:
106
- py_info_store.remove()
109
+ cached = _read_cache_entry(cls, py_info_store, entry_meta)
110
+ if isinstance(cached, _UnsupportedInterpreterError):
111
+ return cached
112
+ py_info = cached
107
113
  if py_info is None:
108
114
  failure, py_info = _run_subprocess(cls, exe, env)
109
- if failure is not None:
115
+ if failure is not None and not isinstance(failure, _UnsupportedInterpreterError):
110
116
  _LOGGER.debug("first subprocess attempt failed for %s (%s), retrying", exe, failure)
111
117
  failure, py_info = _run_subprocess(cls, exe, env)
118
+ if isinstance(failure, _UnsupportedInterpreterError):
119
+ _LOGGER.warning("%s", failure) # the verdict is permanent, warn once and remember it
120
+ py_info_store.write({**entry_meta, "unsupported": str(failure)})
121
+ return failure
112
122
  if failure is not None:
113
123
  return failure
114
124
  if py_info is not None:
115
- py_info_store.write({
116
- "st_mtime": path_modified,
117
- "path": path_text,
118
- "content": py_info.to_dict(),
119
- "hash": py_info_hash,
120
- })
125
+ py_info_store.write({**entry_meta, "content": py_info.to_dict()})
121
126
  if py_info is None:
122
127
  msg = f"{exe} failed to produce interpreter info"
123
128
  return RuntimeError(msg)
124
129
  return py_info
125
130
 
126
131
 
132
+ def _read_cache_entry(
133
+ cls: type[PythonInfo],
134
+ py_info_store: ContentStore,
135
+ entry_meta: CacheEntryMeta,
136
+ ) -> PythonInfo | _UnsupportedInterpreterError | None:
137
+ if not py_info_store.exists() or (data := py_info_store.read()) is None:
138
+ return None
139
+ if all(data.get(key) == value for key, value in entry_meta.items()):
140
+ if isinstance(unsupported := data.get("unsupported"), str):
141
+ return _UnsupportedInterpreterError(unsupported)
142
+ if isinstance(content := data.get("content"), dict):
143
+ return _load_cached_py_info(cls, py_info_store, content)
144
+ py_info_store.remove()
145
+ return None
146
+
147
+
127
148
  def _load_cached_py_info(
128
149
  cls: type[PythonInfo],
129
150
  py_info_store: ContentStore,
@@ -140,6 +161,11 @@ def _load_cached_py_info(
140
161
  return py_info
141
162
 
142
163
 
164
+ @lru_cache(maxsize=1)
165
+ def _script_hash() -> str:
166
+ return hashlib.sha256(_PY_INFO_SCRIPT.read_bytes()).hexdigest()
167
+
168
+
143
169
  COOKIE_LENGTH: Final[int] = 32
144
170
 
145
171
 
@@ -149,13 +175,12 @@ def gen_cookie() -> str:
149
175
 
150
176
  @contextmanager
151
177
  def _resolve_py_info_script() -> Generator[Path]:
152
- py_info_script = Path(Path(__file__).resolve()).parent / "_py_info.py"
153
- if py_info_script.is_file():
154
- yield py_info_script
178
+ if _PY_INFO_SCRIPT.is_file():
179
+ yield _PY_INFO_SCRIPT
155
180
  else:
156
- data = pkgutil.get_data(__package__ or __name__, "_py_info.py")
181
+ data = pkgutil.get_data(__package__ or __name__, _PY_INFO_SCRIPT.name)
157
182
  if data is None:
158
- msg = "cannot locate _py_info.py for subprocess interrogation"
183
+ msg = f"cannot locate {_PY_INFO_SCRIPT.name} for subprocess interrogation"
159
184
  raise FileNotFoundError(msg)
160
185
  fd, tmp = tempfile.mkstemp(suffix=".py")
161
186
  try:
@@ -216,8 +241,7 @@ def _run_subprocess(
216
241
  except OSError as os_error:
217
242
  out, err, code = "", os_error.strerror, os_error.errno
218
243
  if code != 0:
219
- msg = f"{exe} with code {code}{f' out: {out!r}' if out else ''}{f' err: {err!r}' if err else ''}"
220
- return RuntimeError(f"failed to query {msg}"), None
244
+ return _query_failure(exe, out, err, code), None
221
245
  out, raw_out, out_starts, out_ends = _extract_between_cookies(out, start_cookie, end_cookie)
222
246
  try:
223
247
  result = cls.from_json(out)
@@ -240,6 +264,18 @@ def _run_subprocess(
240
264
  return None, result
241
265
 
242
266
 
267
+ def _query_failure(exe: str, out: str, err: str | None, code: int | None) -> RuntimeError:
268
+ # the gate's exit code and stderr marker must both be present: either alone can collide, an errno of 79 from a
269
+ # failed exec or a shim that echoes the marker phrase, but only the gate produces the combination
270
+ if code == UNSUPPORTED_EXIT_CODE and err is not None and (at := err.find(UNSUPPORTED_MARKER)) != -1:
271
+ version = err[at + len(UNSUPPORTED_MARKER) :].split() or ["unknown"]
272
+ floor = ".".join(str(i) for i in MIN_INTERROGATE_VERSION)
273
+ msg = f"{exe} is Python {version[0]}, older than the minimum {floor} python-discovery can query"
274
+ return _UnsupportedInterpreterError(msg)
275
+ msg = f"{exe} with code {code}{f' out: {out!r}' if out else ''}{f' err: {err!r}' if err else ''}"
276
+ return RuntimeError(f"failed to query {msg}")
277
+
278
+
243
279
  class LogCmd:
244
280
  def __init__(self, cmd: list[str], env: Mapping[str, str] | None = None) -> None:
245
281
  self.cmd = cmd