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.
- {python_discovery-1.5.1 → python_discovery-1.5.3}/.github/dependabot.yaml +6 -1
- {python_discovery-1.5.1 → python_discovery-1.5.3}/.github/workflows/check.yaml +24 -1
- {python_discovery-1.5.1 → python_discovery-1.5.3}/.github/workflows/prepare-release.yaml +1 -1
- {python_discovery-1.5.1 → python_discovery-1.5.3}/.github/workflows/release.yaml +2 -2
- {python_discovery-1.5.1 → python_discovery-1.5.3}/.pre-commit-config.yaml +4 -4
- {python_discovery-1.5.1 → python_discovery-1.5.3}/PKG-INFO +5 -2
- {python_discovery-1.5.1 → python_discovery-1.5.3}/README.md +3 -0
- {python_discovery-1.5.1 → python_discovery-1.5.3}/docs/changelog.rst +17 -0
- {python_discovery-1.5.1 → python_discovery-1.5.3}/docs/conf.py +4 -0
- {python_discovery-1.5.1 → python_discovery-1.5.3}/docs/explanation.rst +12 -0
- {python_discovery-1.5.1 → python_discovery-1.5.3}/pyproject.toml +46 -33
- {python_discovery-1.5.1 → python_discovery-1.5.3}/src/python_discovery/_cached_py_info.py +65 -29
- {python_discovery-1.5.1 → python_discovery-1.5.3}/src/python_discovery/_py_info.py +51 -298
- python_discovery-1.5.3/src/python_discovery/_py_info_collect.py +370 -0
- python_discovery-1.5.3/tasks/old_target_check.py +45 -0
- python_discovery-1.5.3/tasks/old_target_check.sh +8 -0
- {python_discovery-1.5.1 → python_discovery-1.5.3}/tests/py_info/test_py_info.py +17 -21
- {python_discovery-1.5.1 → python_discovery-1.5.3}/tests/test_cached_py_info.py +97 -1
- python_discovery-1.5.3/tests/test_py_info_collect.py +333 -0
- {python_discovery-1.5.1 → python_discovery-1.5.3}/tests/test_py_info_extra.py +0 -221
- {python_discovery-1.5.1 → python_discovery-1.5.3}/.github/FUNDING.yaml +0 -0
- {python_discovery-1.5.1 → python_discovery-1.5.3}/.github/SECURITY.md +0 -0
- {python_discovery-1.5.1 → python_discovery-1.5.3}/.github/release.yaml +0 -0
- {python_discovery-1.5.1 → python_discovery-1.5.3}/.gitignore +0 -0
- {python_discovery-1.5.1 → python_discovery-1.5.3}/.readthedocs.yaml +0 -0
- {python_discovery-1.5.1 → python_discovery-1.5.3}/.readthedocs.yml +0 -0
- {python_discovery-1.5.1 → python_discovery-1.5.3}/CODE_OF_CONDUCT.md +0 -0
- {python_discovery-1.5.1 → python_discovery-1.5.3}/LICENSE +0 -0
- {python_discovery-1.5.1 → python_discovery-1.5.3}/docs/_static/custom.css +0 -0
- {python_discovery-1.5.1 → python_discovery-1.5.3}/docs/_static/logo.svg +0 -0
- {python_discovery-1.5.1 → python_discovery-1.5.3}/docs/changelog/template.jinja2 +0 -0
- {python_discovery-1.5.1 → python_discovery-1.5.3}/docs/how-to/standalone-usage.rst +0 -0
- {python_discovery-1.5.1 → python_discovery-1.5.3}/docs/index.rst +0 -0
- {python_discovery-1.5.1 → python_discovery-1.5.3}/docs/reference/api.rst +0 -0
- {python_discovery-1.5.1 → python_discovery-1.5.3}/docs/reference/environment-variables.rst +0 -0
- {python_discovery-1.5.1 → python_discovery-1.5.3}/docs/tutorial/getting-started.rst +0 -0
- {python_discovery-1.5.1 → python_discovery-1.5.3}/src/python_discovery/__init__.py +0 -0
- {python_discovery-1.5.1 → python_discovery-1.5.3}/src/python_discovery/_cache.py +0 -0
- {python_discovery-1.5.1 → python_discovery-1.5.3}/src/python_discovery/_compat.py +0 -0
- {python_discovery-1.5.1 → python_discovery-1.5.3}/src/python_discovery/_discovery.py +0 -0
- {python_discovery-1.5.1 → python_discovery-1.5.3}/src/python_discovery/_py_spec.py +0 -0
- {python_discovery-1.5.1 → python_discovery-1.5.3}/src/python_discovery/_specifier.py +0 -0
- {python_discovery-1.5.1 → python_discovery-1.5.3}/src/python_discovery/_windows/__init__.py +0 -0
- {python_discovery-1.5.1 → python_discovery-1.5.3}/src/python_discovery/_windows/_pep514.py +0 -0
- {python_discovery-1.5.1 → python_discovery-1.5.3}/src/python_discovery/_windows/_propose.py +0 -0
- {python_discovery-1.5.1 → python_discovery-1.5.3}/src/python_discovery/py.typed +0 -0
- {python_discovery-1.5.1 → python_discovery-1.5.3}/tasks/release.py +0 -0
- {python_discovery-1.5.1 → python_discovery-1.5.3}/tests/conftest.py +0 -0
- {python_discovery-1.5.1 → python_discovery-1.5.3}/tests/py_info/test_py_info_exe_based_of.py +0 -0
- {python_discovery-1.5.1 → python_discovery-1.5.3}/tests/test_cache.py +0 -0
- {python_discovery-1.5.1 → python_discovery-1.5.3}/tests/test_discovery.py +0 -0
- {python_discovery-1.5.1 → python_discovery-1.5.3}/tests/test_discovery_extra.py +0 -0
- {python_discovery-1.5.1 → python_discovery-1.5.3}/tests/test_iter_interpreters.py +0 -0
- {python_discovery-1.5.1 → python_discovery-1.5.3}/tests/test_py_spec.py +0 -0
- {python_discovery-1.5.1 → python_discovery-1.5.3}/tests/test_py_spec_extra.py +0 -0
- {python_discovery-1.5.1 → python_discovery-1.5.3}/tests/test_specifier.py +0 -0
- {python_discovery-1.5.1 → python_discovery-1.5.3}/tests/windows/conftest.py +0 -0
- {python_discovery-1.5.1 → python_discovery-1.5.3}/tests/windows/test_windows.py +0 -0
- {python_discovery-1.5.1 → python_discovery-1.5.3}/tests/windows/test_windows_pep514.py +0 -0
- {python_discovery-1.5.1 → python_discovery-1.5.3}/tests/windows/winreg_mock_values.py +0 -0
- {python_discovery-1.5.1 → python_discovery-1.5.3}/tox.toml +0 -0
|
@@ -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@
|
|
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@
|
|
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@
|
|
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@
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
42
|
+
rev: v1.29.0
|
|
43
43
|
hooks:
|
|
44
44
|
- id: zizmor
|
|
45
45
|
- repo: meta
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: python-discovery
|
|
3
|
-
Version: 1.5.
|
|
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
|
-
"
|
|
91
|
-
"
|
|
92
|
-
"
|
|
93
|
-
"
|
|
94
|
-
"
|
|
95
|
-
"
|
|
96
|
-
"
|
|
97
|
-
"
|
|
98
|
-
"
|
|
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
|
-
"
|
|
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", #
|
|
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",
|
|
114
|
-
"
|
|
115
|
-
"
|
|
116
|
-
"
|
|
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
|
-
"
|
|
120
|
-
"
|
|
121
|
-
"
|
|
122
|
-
"
|
|
123
|
-
"
|
|
124
|
-
"
|
|
125
|
-
"
|
|
126
|
-
"
|
|
127
|
-
"
|
|
128
|
-
"
|
|
129
|
-
"
|
|
130
|
-
"
|
|
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
|
-
"
|
|
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 = [
|
|
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 =
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
153
|
-
|
|
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__,
|
|
181
|
+
data = pkgutil.get_data(__package__ or __name__, _PY_INFO_SCRIPT.name)
|
|
157
182
|
if data is None:
|
|
158
|
-
msg = "cannot locate
|
|
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
|
-
|
|
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
|