python-discovery 1.5.1__tar.gz → 1.5.2__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 (63) hide show
  1. {python_discovery-1.5.1 → python_discovery-1.5.2}/.github/workflows/check.yaml +23 -0
  2. {python_discovery-1.5.1 → python_discovery-1.5.2}/.github/workflows/release.yaml +1 -1
  3. {python_discovery-1.5.1 → python_discovery-1.5.2}/.pre-commit-config.yaml +4 -4
  4. {python_discovery-1.5.1 → python_discovery-1.5.2}/PKG-INFO +5 -2
  5. {python_discovery-1.5.1 → python_discovery-1.5.2}/README.md +3 -0
  6. python_discovery-1.5.2/docs/changelog/116.bugfix.rst +4 -0
  7. python_discovery-1.5.2/docs/changelog/116.doc.rst +1 -0
  8. {python_discovery-1.5.1 → python_discovery-1.5.2}/docs/explanation.rst +12 -0
  9. {python_discovery-1.5.1 → python_discovery-1.5.2}/pyproject.toml +41 -29
  10. {python_discovery-1.5.1 → python_discovery-1.5.2}/src/python_discovery/_cached_py_info.py +65 -29
  11. {python_discovery-1.5.1 → python_discovery-1.5.2}/src/python_discovery/_py_info.py +51 -298
  12. python_discovery-1.5.2/src/python_discovery/_py_info_collect.py +345 -0
  13. python_discovery-1.5.2/tasks/old_target_check.py +45 -0
  14. python_discovery-1.5.2/tasks/old_target_check.sh +8 -0
  15. {python_discovery-1.5.1 → python_discovery-1.5.2}/tests/py_info/test_py_info.py +4 -2
  16. {python_discovery-1.5.1 → python_discovery-1.5.2}/tests/test_cached_py_info.py +97 -1
  17. python_discovery-1.5.2/tests/test_py_info_collect.py +305 -0
  18. {python_discovery-1.5.1 → python_discovery-1.5.2}/tests/test_py_info_extra.py +0 -221
  19. {python_discovery-1.5.1 → python_discovery-1.5.2}/.github/FUNDING.yaml +0 -0
  20. {python_discovery-1.5.1 → python_discovery-1.5.2}/.github/SECURITY.md +0 -0
  21. {python_discovery-1.5.1 → python_discovery-1.5.2}/.github/dependabot.yaml +0 -0
  22. {python_discovery-1.5.1 → python_discovery-1.5.2}/.github/release.yaml +0 -0
  23. {python_discovery-1.5.1 → python_discovery-1.5.2}/.github/workflows/prepare-release.yaml +0 -0
  24. {python_discovery-1.5.1 → python_discovery-1.5.2}/.gitignore +0 -0
  25. {python_discovery-1.5.1 → python_discovery-1.5.2}/.readthedocs.yaml +0 -0
  26. {python_discovery-1.5.1 → python_discovery-1.5.2}/.readthedocs.yml +0 -0
  27. {python_discovery-1.5.1 → python_discovery-1.5.2}/CODE_OF_CONDUCT.md +0 -0
  28. {python_discovery-1.5.1 → python_discovery-1.5.2}/LICENSE +0 -0
  29. {python_discovery-1.5.1 → python_discovery-1.5.2}/docs/_static/custom.css +0 -0
  30. {python_discovery-1.5.1 → python_discovery-1.5.2}/docs/_static/logo.svg +0 -0
  31. {python_discovery-1.5.1 → python_discovery-1.5.2}/docs/changelog/template.jinja2 +0 -0
  32. {python_discovery-1.5.1 → python_discovery-1.5.2}/docs/changelog.rst +0 -0
  33. {python_discovery-1.5.1 → python_discovery-1.5.2}/docs/conf.py +0 -0
  34. {python_discovery-1.5.1 → python_discovery-1.5.2}/docs/how-to/standalone-usage.rst +0 -0
  35. {python_discovery-1.5.1 → python_discovery-1.5.2}/docs/index.rst +0 -0
  36. {python_discovery-1.5.1 → python_discovery-1.5.2}/docs/reference/api.rst +0 -0
  37. {python_discovery-1.5.1 → python_discovery-1.5.2}/docs/reference/environment-variables.rst +0 -0
  38. {python_discovery-1.5.1 → python_discovery-1.5.2}/docs/tutorial/getting-started.rst +0 -0
  39. {python_discovery-1.5.1 → python_discovery-1.5.2}/src/python_discovery/__init__.py +0 -0
  40. {python_discovery-1.5.1 → python_discovery-1.5.2}/src/python_discovery/_cache.py +0 -0
  41. {python_discovery-1.5.1 → python_discovery-1.5.2}/src/python_discovery/_compat.py +0 -0
  42. {python_discovery-1.5.1 → python_discovery-1.5.2}/src/python_discovery/_discovery.py +0 -0
  43. {python_discovery-1.5.1 → python_discovery-1.5.2}/src/python_discovery/_py_spec.py +0 -0
  44. {python_discovery-1.5.1 → python_discovery-1.5.2}/src/python_discovery/_specifier.py +0 -0
  45. {python_discovery-1.5.1 → python_discovery-1.5.2}/src/python_discovery/_windows/__init__.py +0 -0
  46. {python_discovery-1.5.1 → python_discovery-1.5.2}/src/python_discovery/_windows/_pep514.py +0 -0
  47. {python_discovery-1.5.1 → python_discovery-1.5.2}/src/python_discovery/_windows/_propose.py +0 -0
  48. {python_discovery-1.5.1 → python_discovery-1.5.2}/src/python_discovery/py.typed +0 -0
  49. {python_discovery-1.5.1 → python_discovery-1.5.2}/tasks/release.py +0 -0
  50. {python_discovery-1.5.1 → python_discovery-1.5.2}/tests/conftest.py +0 -0
  51. {python_discovery-1.5.1 → python_discovery-1.5.2}/tests/py_info/test_py_info_exe_based_of.py +0 -0
  52. {python_discovery-1.5.1 → python_discovery-1.5.2}/tests/test_cache.py +0 -0
  53. {python_discovery-1.5.1 → python_discovery-1.5.2}/tests/test_discovery.py +0 -0
  54. {python_discovery-1.5.1 → python_discovery-1.5.2}/tests/test_discovery_extra.py +0 -0
  55. {python_discovery-1.5.1 → python_discovery-1.5.2}/tests/test_iter_interpreters.py +0 -0
  56. {python_discovery-1.5.1 → python_discovery-1.5.2}/tests/test_py_spec.py +0 -0
  57. {python_discovery-1.5.1 → python_discovery-1.5.2}/tests/test_py_spec_extra.py +0 -0
  58. {python_discovery-1.5.1 → python_discovery-1.5.2}/tests/test_specifier.py +0 -0
  59. {python_discovery-1.5.1 → python_discovery-1.5.2}/tests/windows/conftest.py +0 -0
  60. {python_discovery-1.5.1 → python_discovery-1.5.2}/tests/windows/test_windows.py +0 -0
  61. {python_discovery-1.5.1 → python_discovery-1.5.2}/tests/windows/test_windows_pep514.py +0 -0
  62. {python_discovery-1.5.1 → python_discovery-1.5.2}/tests/windows/winreg_mock_values.py +0 -0
  63. {python_discovery-1.5.1 → python_discovery-1.5.2}/tox.toml +0 -0
@@ -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@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
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
@@ -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.27.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.2"
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.2
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
@@ -0,0 +1,4 @@
1
+ Restore discovery of Python 3.6 and 3.7 interpreters: the interrogation script had grown 3.8-only syntax and is now
2
+ kept to Python 3.6. Candidates below 3.6 log one warning naming the version found instead of two debug-level query
3
+ attempts, and the verdict is cached so they are queried only once; an absolute-path spec for one raises
4
+ ``RuntimeError`` with the same message - by :user:`gaborbernat`.
@@ -0,0 +1 @@
1
+ Document the version floors: runs on Python 3.8+, discovers interpreters down to 3.6 - by :user:`gaborbernat`.
@@ -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,6 +59,7 @@ 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",
@@ -87,50 +88,57 @@ lint.select = [
87
88
  "ALL",
88
89
  ]
89
90
  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
91
+ "CPY", # No copyright statements
92
+ "docstring-missing-exception", # `raises` is not documented in docstring
93
+ "docstring-missing-returns", # `return` is not documented in docstring
94
+ "docstring-missing-yields", # `yield` is not documented in docstring
95
+ "hardcoded-bind-all-interfaces", # Possible binding to all interface
96
+ "incorrect-blank-line-before-class", # `one-blank-line-before-class` and `no-blank-line-before-class` are incompatible
97
+ "missing-trailing-comma", # Conflict with formatter
98
+ "multi-line-summary-first-line", # `multi-line-summary-first-line` and `multi-line-summary-second-line` are incompatible
99
+ "single-line-implicit-string-concatenation", # Conflict with formatter
99
100
  ]
100
101
  lint.per-file-ignores."docs/**/*.py" = [
101
- "INP001", # no __init__.py in docs directory
102
+ "implicit-namespace-package", # no __init__.py in docs directory
102
103
  ]
103
104
  lint.per-file-ignores."src/python_discovery/_discovery.py" = [
104
105
  "PTH", # shim resolution uses string-based os.path for consistency with env variables
105
106
  ]
106
107
  lint.per-file-ignores."src/python_discovery/_py_info.py" = [
107
- "PTH", # must use os.path — file runs as subprocess script with only stdlib
108
+ "PTH", # string-based os.path kept for parity with the interrogation script data
109
+ ]
110
+ lint.per-file-ignores."src/python_discovery/_py_info_collect.py" = [
111
+ "ANN", # file must parse on Python 2.7 for the version gate: no annotations
112
+ "missing-required-import", # `from __future__ import annotations` is a SyntaxError below 3.7
113
+ "module-import-not-at-top-of-file", # the version gate must run before importing sysconfig, absent on 3.0/3.1
114
+ "PTH", # must use os.path — file runs as subprocess script with only stdlib
115
+ "UP", # pyupgrade pushes syntax newer than the 2.7 parse floor, e.g. f-strings
108
116
  ]
109
117
  lint.per-file-ignores."src/python_discovery/_windows/_pep514.py" = [
110
118
  "PTH", # os.path.exists is monkeypatched in tests; pathlib.Path.exists bypasses the mock
111
119
  ]
112
120
  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)
121
+ "D", # release helper scripts, not API
122
+ "implicit-namespace-package", # no __init__.py in tasks directory
123
+ "subprocess-without-shell-equals-true", # `subprocess` call: argv built from trusted inputs (version, gh CLI)
124
+ "suspicious-subprocess-import", # subprocess import (release tooling)
117
125
  ]
118
126
  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
127
+ "assert", # asserts allowed in tests
128
+ "D", # don't care about documentation in tests
129
+ "FBT", # don't care about booleans as positional arguments in tests
130
+ "implicit-namespace-package", # no implicit namespace
131
+ "import-outside-top-level", # imports inside test functions (conditional on mocking)
132
+ "import-private-name", # private imports needed to test internal APIs
133
+ "magic-value-comparison", # Magic value used in comparison
134
+ "private-member-access", # private member access needed to test internals
135
+ "subprocess-without-shell-equals-true", # `subprocess` call: check for execution of untrusted input
136
+ "suspicious-subprocess-import", # subprocess import
137
+ "too-many-arguments", # too many arguments (pytest fixtures)
138
+ "too-many-positional-arguments", # too many positional arguments (pytest fixtures)
131
139
  ]
132
140
  lint.per-file-ignores."tests/windows/winreg_mock_values.py" = [
133
- "F821", # undefined name (winreg available only on Windows)
141
+ "undefined-name", # undefined name (winreg available only on Windows)
134
142
  ]
135
143
  lint.isort = { known-first-party = [
136
144
  "python_discovery",
@@ -151,7 +159,11 @@ max_supported_python = "3.15"
151
159
  src.exclude = [ "tests/windows/winreg_mock_values.py" ]
152
160
 
153
161
  [[tool.ty.overrides]]
154
- include = [ "src/python_discovery/_py_info.py", "src/python_discovery/_py_spec.py" ]
162
+ include = [
163
+ "src/python_discovery/_py_info.py",
164
+ "src/python_discovery/_py_info_collect.py",
165
+ "src/python_discovery/_py_spec.py",
166
+ ]
155
167
  rules.unused-ignore-comment = "ignore"
156
168
  rules.invalid-argument-type = "ignore"
157
169
  rules.invalid-return-type = "ignore"
@@ -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