python-discovery 1.6.0__tar.gz → 1.6.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.
- {python_discovery-1.6.0 → python_discovery-1.6.2}/.github/workflows/check.yaml +19 -17
- {python_discovery-1.6.0 → python_discovery-1.6.2}/.github/workflows/prepare-release.yaml +3 -2
- {python_discovery-1.6.0 → python_discovery-1.6.2}/.github/workflows/release.yaml +7 -6
- {python_discovery-1.6.0 → python_discovery-1.6.2}/.pre-commit-config.yaml +6 -5
- {python_discovery-1.6.0 → python_discovery-1.6.2}/.readthedocs.yaml +1 -1
- python_discovery-1.6.2/.readthedocs.yml +8 -0
- {python_discovery-1.6.0 → python_discovery-1.6.2}/PKG-INFO +4 -5
- {python_discovery-1.6.0 → python_discovery-1.6.2}/README.md +1 -1
- {python_discovery-1.6.0 → python_discovery-1.6.2}/docs/changelog.rst +26 -0
- {python_discovery-1.6.0 → python_discovery-1.6.2}/docs/explanation.rst +38 -3
- {python_discovery-1.6.0 → python_discovery-1.6.2}/docs/how-to/standalone-usage.rst +10 -2
- {python_discovery-1.6.0 → python_discovery-1.6.2}/docs/reference/environment-variables.rst +4 -0
- {python_discovery-1.6.0 → python_discovery-1.6.2}/docs/tutorial/getting-started.rst +7 -0
- {python_discovery-1.6.0 → python_discovery-1.6.2}/pyproject.toml +12 -10
- {python_discovery-1.6.0 → python_discovery-1.6.2}/src/python_discovery/_cached_py_info.py +25 -6
- {python_discovery-1.6.0 → python_discovery-1.6.2}/src/python_discovery/_discovery.py +6 -1
- {python_discovery-1.6.0 → python_discovery-1.6.2}/src/python_discovery/_py_info_collect.py +1 -1
- python_discovery-1.6.2/tests/test_cache_recovery.py +74 -0
- {python_discovery-1.6.0 → python_discovery-1.6.2}/tests/test_cached_py_info.py +0 -11
- {python_discovery-1.6.0 → python_discovery-1.6.2}/tests/test_discovery.py +32 -0
- {python_discovery-1.6.0 → python_discovery-1.6.2}/tests/test_py_info_collect.py +2 -2
- {python_discovery-1.6.0 → python_discovery-1.6.2}/tests/test_py_info_extra.py +1 -1
- python_discovery-1.6.2/tests/test_query_timeout.py +88 -0
- {python_discovery-1.6.0 → python_discovery-1.6.2}/tests/windows/conftest.py +2 -2
- python_discovery-1.6.2/tox.toml +89 -0
- python_discovery-1.6.0/.readthedocs.yml +0 -8
- python_discovery-1.6.0/tox.toml +0 -85
- {python_discovery-1.6.0 → python_discovery-1.6.2}/.github/FUNDING.yaml +0 -0
- {python_discovery-1.6.0 → python_discovery-1.6.2}/.github/SECURITY.md +0 -0
- {python_discovery-1.6.0 → python_discovery-1.6.2}/.github/dependabot.yaml +0 -0
- {python_discovery-1.6.0 → python_discovery-1.6.2}/.github/release.yaml +0 -0
- {python_discovery-1.6.0 → python_discovery-1.6.2}/.gitignore +0 -0
- {python_discovery-1.6.0 → python_discovery-1.6.2}/CODE_OF_CONDUCT.md +0 -0
- {python_discovery-1.6.0 → python_discovery-1.6.2}/LICENSE +0 -0
- {python_discovery-1.6.0 → python_discovery-1.6.2}/docs/_static/custom.css +0 -0
- {python_discovery-1.6.0 → python_discovery-1.6.2}/docs/_static/logo.svg +0 -0
- {python_discovery-1.6.0 → python_discovery-1.6.2}/docs/changelog/template.jinja2 +0 -0
- {python_discovery-1.6.0 → python_discovery-1.6.2}/docs/conf.py +0 -0
- {python_discovery-1.6.0 → python_discovery-1.6.2}/docs/index.rst +0 -0
- {python_discovery-1.6.0 → python_discovery-1.6.2}/docs/reference/api.rst +0 -0
- {python_discovery-1.6.0 → python_discovery-1.6.2}/src/python_discovery/__init__.py +0 -0
- {python_discovery-1.6.0 → python_discovery-1.6.2}/src/python_discovery/_cache.py +0 -0
- {python_discovery-1.6.0 → python_discovery-1.6.2}/src/python_discovery/_compat.py +0 -0
- {python_discovery-1.6.0 → python_discovery-1.6.2}/src/python_discovery/_py_info.py +0 -0
- {python_discovery-1.6.0 → python_discovery-1.6.2}/src/python_discovery/_py_spec.py +0 -0
- {python_discovery-1.6.0 → python_discovery-1.6.2}/src/python_discovery/_specifier.py +0 -0
- {python_discovery-1.6.0 → python_discovery-1.6.2}/src/python_discovery/_windows/__init__.py +0 -0
- {python_discovery-1.6.0 → python_discovery-1.6.2}/src/python_discovery/_windows/_pep514.py +0 -0
- {python_discovery-1.6.0 → python_discovery-1.6.2}/src/python_discovery/_windows/_propose.py +0 -0
- {python_discovery-1.6.0 → python_discovery-1.6.2}/src/python_discovery/py.typed +0 -0
- {python_discovery-1.6.0 → python_discovery-1.6.2}/tasks/old_target_check.py +0 -0
- {python_discovery-1.6.0 → python_discovery-1.6.2}/tasks/old_target_check.sh +0 -0
- {python_discovery-1.6.0 → python_discovery-1.6.2}/tasks/release.py +0 -0
- {python_discovery-1.6.0 → python_discovery-1.6.2}/tests/conftest.py +0 -0
- {python_discovery-1.6.0 → python_discovery-1.6.2}/tests/py_info/test_py_info.py +0 -0
- {python_discovery-1.6.0 → python_discovery-1.6.2}/tests/py_info/test_py_info_exe_based_of.py +0 -0
- {python_discovery-1.6.0 → python_discovery-1.6.2}/tests/test_cache.py +0 -0
- {python_discovery-1.6.0 → python_discovery-1.6.2}/tests/test_discovery_extra.py +0 -0
- {python_discovery-1.6.0 → python_discovery-1.6.2}/tests/test_iter_interpreters.py +0 -0
- {python_discovery-1.6.0 → python_discovery-1.6.2}/tests/test_py_spec.py +0 -0
- {python_discovery-1.6.0 → python_discovery-1.6.2}/tests/test_py_spec_extra.py +0 -0
- {python_discovery-1.6.0 → python_discovery-1.6.2}/tests/test_specifier.py +0 -0
- {python_discovery-1.6.0 → python_discovery-1.6.2}/tests/windows/test_windows.py +0 -0
- {python_discovery-1.6.0 → python_discovery-1.6.2}/tests/windows/test_windows_pep514.py +0 -0
- {python_discovery-1.6.0 → python_discovery-1.6.2}/tests/windows/winreg_mock_values.py +0 -0
|
@@ -14,7 +14,7 @@ permissions:
|
|
|
14
14
|
contents: read
|
|
15
15
|
jobs:
|
|
16
16
|
test:
|
|
17
|
-
runs-on: ubuntu-
|
|
17
|
+
runs-on: ubuntu-26.04
|
|
18
18
|
strategy:
|
|
19
19
|
fail-fast: false
|
|
20
20
|
matrix:
|
|
@@ -27,9 +27,8 @@ jobs:
|
|
|
27
27
|
- "3.11"
|
|
28
28
|
- "3.10"
|
|
29
29
|
- "3.9"
|
|
30
|
-
-
|
|
31
|
-
- type-3.
|
|
32
|
-
- type-3.14
|
|
30
|
+
- type-3.9
|
|
31
|
+
- type-3.15
|
|
33
32
|
- dev
|
|
34
33
|
- pkg_meta
|
|
35
34
|
steps:
|
|
@@ -38,15 +37,16 @@ jobs:
|
|
|
38
37
|
fetch-depth: 0
|
|
39
38
|
persist-credentials: false
|
|
40
39
|
- name: Install the latest version of uv
|
|
41
|
-
uses: astral-sh/setup-uv@
|
|
40
|
+
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
|
42
41
|
with:
|
|
42
|
+
python-version: "3.15"
|
|
43
43
|
enable-cache: false
|
|
44
44
|
cache-dependency-glob: "pyproject.toml"
|
|
45
45
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
|
46
46
|
- name: Install tox
|
|
47
|
-
run: uv tool install --python-preference only-managed --python 3.
|
|
47
|
+
run: uv tool install --python-preference only-managed --python 3.15 tox --with tox-uv
|
|
48
48
|
- name: Install Python
|
|
49
|
-
if: startsWith(matrix.env, '3.') && matrix.env != '3.
|
|
49
|
+
if: startsWith(matrix.env, '3.') && matrix.env != '3.15'
|
|
50
50
|
run: uv python install --python-preference only-managed ${{ matrix.env }}
|
|
51
51
|
- name: Setup test suite
|
|
52
52
|
run: tox run -vv --notest --skip-missing-interpreters false -e ${{ matrix.env }}
|
|
@@ -56,25 +56,27 @@ jobs:
|
|
|
56
56
|
PYTEST_ADDOPTS: "-vv --durations=20"
|
|
57
57
|
DIFF_AGAINST: HEAD
|
|
58
58
|
old-target:
|
|
59
|
-
runs-on: ubuntu-
|
|
59
|
+
runs-on: ubuntu-26.04
|
|
60
60
|
steps:
|
|
61
61
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
62
62
|
with:
|
|
63
63
|
fetch-depth: 0
|
|
64
64
|
persist-credentials: false
|
|
65
65
|
- name: Install the latest version of uv
|
|
66
|
-
uses: astral-sh/setup-uv@
|
|
66
|
+
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
|
67
67
|
with:
|
|
68
|
+
python-version: "3.15"
|
|
68
69
|
enable-cache: false
|
|
69
70
|
cache-dependency-glob: "pyproject.toml"
|
|
70
71
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
|
71
72
|
- name: Build the wheel
|
|
72
73
|
run: uv build --wheel --out-dir dist .
|
|
73
|
-
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
74
|
+
- parallel:
|
|
75
|
+
- name: Discover Python 3.6
|
|
76
|
+
run: tasks/old_target_check.sh 3.6 discover
|
|
77
|
+
- name: Discover Python 3.7
|
|
78
|
+
run: tasks/old_target_check.sh 3.7 discover
|
|
79
|
+
- name: Reject Python 3.5
|
|
80
|
+
run: tasks/old_target_check.sh 3.5 rejected
|
|
81
|
+
- name: Reject Python 2.7
|
|
82
|
+
run: tasks/old_target_check.sh 2.7 rejected
|
|
@@ -29,8 +29,9 @@ 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@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
|
33
33
|
with:
|
|
34
|
+
python-version: "3.15"
|
|
34
35
|
enable-cache: true
|
|
35
36
|
cache-dependency-glob: "pyproject.toml"
|
|
36
37
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
|
@@ -79,7 +80,7 @@ jobs:
|
|
|
79
80
|
echo "Next version: $next"
|
|
80
81
|
echo "version=$next" >> $GITHUB_OUTPUT
|
|
81
82
|
- name: Install tox
|
|
82
|
-
run: uv tool install --python-preference only-managed --python 3.
|
|
83
|
+
run: uv tool install --python-preference only-managed --python 3.15 tox@latest --with tox-uv
|
|
83
84
|
- name: Run release process
|
|
84
85
|
run: tox run -e release -- ${STEPS_VERSION_OUTPUTS_VERSION}
|
|
85
86
|
env:
|
|
@@ -8,29 +8,30 @@ env:
|
|
|
8
8
|
dists-artifact-name: python-package-distributions
|
|
9
9
|
jobs:
|
|
10
10
|
build:
|
|
11
|
-
runs-on: ubuntu-
|
|
11
|
+
runs-on: ubuntu-26.04
|
|
12
12
|
steps:
|
|
13
13
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
14
14
|
with:
|
|
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@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
|
19
19
|
with:
|
|
20
|
+
python-version: "3.15"
|
|
20
21
|
enable-cache: false
|
|
21
22
|
cache-dependency-glob: "pyproject.toml"
|
|
22
23
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
|
23
24
|
- name: Build package
|
|
24
|
-
run: uv build --python 3.
|
|
25
|
+
run: uv build --python 3.15 --python-preference only-managed --sdist --wheel . --out-dir dist
|
|
25
26
|
- name: Store the distribution packages
|
|
26
|
-
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
|
27
|
+
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
27
28
|
with:
|
|
28
29
|
name: ${{ env.dists-artifact-name }}
|
|
29
30
|
path: dist/*
|
|
30
31
|
release:
|
|
31
32
|
needs:
|
|
32
33
|
- build
|
|
33
|
-
runs-on: ubuntu-
|
|
34
|
+
runs-on: ubuntu-26.04
|
|
34
35
|
environment:
|
|
35
36
|
name: release
|
|
36
37
|
url: https://pypi.org/project/python-discovery/${{ github.ref_name }}
|
|
@@ -38,7 +39,7 @@ jobs:
|
|
|
38
39
|
id-token: write
|
|
39
40
|
steps:
|
|
40
41
|
- name: Download all the dists
|
|
41
|
-
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
|
42
|
+
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
|
42
43
|
with:
|
|
43
44
|
name: ${{ env.dists-artifact-name }}
|
|
44
45
|
path: dist/
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
default_language_version:
|
|
2
|
+
python: python3.15
|
|
1
3
|
repos:
|
|
2
4
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
|
3
5
|
rev: v6.0.0
|
|
@@ -5,7 +7,7 @@ repos:
|
|
|
5
7
|
- id: end-of-file-fixer
|
|
6
8
|
- id: trailing-whitespace
|
|
7
9
|
- repo: https://github.com/python-jsonschema/check-jsonschema
|
|
8
|
-
rev: 0.38.
|
|
10
|
+
rev: 0.38.2
|
|
9
11
|
hooks:
|
|
10
12
|
- id: check-github-workflows
|
|
11
13
|
args: ["--verbose"]
|
|
@@ -13,13 +15,12 @@ repos:
|
|
|
13
15
|
rev: v2.4.3
|
|
14
16
|
hooks:
|
|
15
17
|
- id: codespell
|
|
16
|
-
additional_dependencies: ["tomli>=2.4"]
|
|
17
18
|
- repo: https://github.com/tox-dev/pyproject-fmt
|
|
18
|
-
rev: "v2.
|
|
19
|
+
rev: "v2.30.1"
|
|
19
20
|
hooks:
|
|
20
21
|
- id: pyproject-fmt
|
|
21
22
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
|
22
|
-
rev: "v0.16.
|
|
23
|
+
rev: "v0.16.10"
|
|
23
24
|
hooks:
|
|
24
25
|
- id: ruff-format
|
|
25
26
|
- id: ruff
|
|
@@ -39,7 +40,7 @@ repos:
|
|
|
39
40
|
- mdformat-gfm>=1
|
|
40
41
|
- mdformat-ruff>=0.1.3
|
|
41
42
|
- repo: https://github.com/zizmorcore/zizmor-pre-commit
|
|
42
|
-
rev: v1.
|
|
43
|
+
rev: v1.30.1
|
|
43
44
|
hooks:
|
|
44
45
|
- id: zizmor
|
|
45
46
|
- repo: meta
|
|
@@ -4,5 +4,5 @@ build:
|
|
|
4
4
|
tools: {}
|
|
5
5
|
commands:
|
|
6
6
|
- curl -LsSf https://astral.sh/uv/install.sh | sh
|
|
7
|
-
- ~/.local/bin/uv tool install tox --with tox-uv -p 3.
|
|
7
|
+
- ~/.local/bin/uv tool install tox --with tox-uv -p 3.15 --managed-python
|
|
8
8
|
- ~/.local/bin/tox run -e docs -- "$READTHEDOCS_OUTPUT/html"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: python-discovery
|
|
3
|
-
Version: 1.6.
|
|
3
|
+
Version: 1.6.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
|
|
@@ -35,7 +35,6 @@ Classifier: Operating System :: MacOS :: MacOS X
|
|
|
35
35
|
Classifier: Operating System :: Microsoft :: Windows
|
|
36
36
|
Classifier: Operating System :: POSIX
|
|
37
37
|
Classifier: Programming Language :: Python :: 3 :: Only
|
|
38
|
-
Classifier: Programming Language :: Python :: 3.8
|
|
39
38
|
Classifier: Programming Language :: Python :: 3.9
|
|
40
39
|
Classifier: Programming Language :: Python :: 3.10
|
|
41
40
|
Classifier: Programming Language :: Python :: 3.11
|
|
@@ -46,8 +45,8 @@ Classifier: Programming Language :: Python :: 3.15
|
|
|
46
45
|
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
47
46
|
Classifier: Topic :: Software Development :: Libraries
|
|
48
47
|
Classifier: Topic :: Utilities
|
|
49
|
-
Requires-Python: >=3.
|
|
50
|
-
Requires-Dist: filelock>=3.
|
|
48
|
+
Requires-Python: >=3.9
|
|
49
|
+
Requires-Dist: filelock>=3.16.1
|
|
51
50
|
Description-Content-Type: text/markdown
|
|
52
51
|
|
|
53
52
|
# [`python-discovery`](https://python-discovery.readthedocs.io/en/latest/)
|
|
@@ -68,7 +67,7 @@ the right one for you.
|
|
|
68
67
|
Give it a requirement like `python3.12` or `>=3.11,<3.13`, and it searches all known locations, verifies each candidate,
|
|
69
68
|
and returns detailed metadata about the match. Results are cached to disk so repeated lookups are fast.
|
|
70
69
|
|
|
71
|
-
The library runs on Python 3.
|
|
70
|
+
The library runs on Python 3.9+ and discovers interpreters as old as Python 3.6; anything older is skipped with a
|
|
72
71
|
warning naming the version it found.
|
|
73
72
|
|
|
74
73
|
## Usage
|
|
@@ -16,7 +16,7 @@ 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.
|
|
19
|
+
The library runs on Python 3.9+ and discovers interpreters as old as Python 3.6; anything older is skipped with a
|
|
20
20
|
warning naming the version it found.
|
|
21
21
|
|
|
22
22
|
## Usage
|
|
@@ -6,6 +6,32 @@
|
|
|
6
6
|
|
|
7
7
|
.. towncrier release notes start
|
|
8
8
|
|
|
9
|
+
********************
|
|
10
|
+
v1.6.2 (2026-10-10)
|
|
11
|
+
********************
|
|
12
|
+
|
|
13
|
+
Bug fixes - 1.6.2
|
|
14
|
+
=================
|
|
15
|
+
- Discard malformed interpreter cache entries and query the interpreter again, including entries with missing,
|
|
16
|
+
non-string or unusable ``system_executable`` paths - by :user:`darrenhuai`. (:issue:`141`)
|
|
17
|
+
- Use the default interpreter query timeout and warn for invalid ``PY_DISCOVERY_TIMEOUT`` values. Accept ``inf``
|
|
18
|
+
or values above the subprocess wait limit to disable the timeout - by :user:`darrenhuai`. (:issue:`143`)
|
|
19
|
+
|
|
20
|
+
********************
|
|
21
|
+
v1.6.1 (2026-09-18)
|
|
22
|
+
********************
|
|
23
|
+
|
|
24
|
+
Bug fixes - 1.6.1
|
|
25
|
+
=================
|
|
26
|
+
- Skip empty ``PATH`` entries during interpreter discovery - by :user:`gaborbernat`. (:issue:`129`)
|
|
27
|
+
|
|
28
|
+
Improved documentation - 1.6.1
|
|
29
|
+
==============================
|
|
30
|
+
- Document :attr:`~python_discovery.PythonInfo.system_exe` across the tutorial, the how-to guide and the explanation of
|
|
31
|
+
how resolution reaches a base interpreter. The class diagram in the how-to guide had
|
|
32
|
+
:attr:`~python_discovery.PythonInfo.system_executable` typed ``str`` rather than ``str | None`` - by
|
|
33
|
+
:user:`gaborbernat`. (:issue:`128`)
|
|
34
|
+
|
|
9
35
|
********************
|
|
10
36
|
v1.6.0 (2026-08-28)
|
|
11
37
|
********************
|
|
@@ -186,7 +186,7 @@ APIs whenever a spec is given.
|
|
|
186
186
|
|
|
187
187
|
**Deduplication.** :func:`~python_discovery.get_interpreter` deduplicates per call so it does not interrogate the
|
|
188
188
|
same binary twice while searching, and stops as soon as a match is found. :func:`~python_discovery.iter_interpreters`
|
|
189
|
-
deduplicates by the resolved real path of each candidate's
|
|
189
|
+
deduplicates by the resolved real path of each candidate's :attr:`~python_discovery.PythonInfo.system_exe`.
|
|
190
190
|
That means symlinked aliases like ``/bin/python3`` and ``/usr/bin/python3``, or a virtualenv whose ``python``
|
|
191
191
|
symlinks to its base interpreter, collapse to a single yield. The semantic is "one entry per distinct install,"
|
|
192
192
|
which is what callers building choosers or version-range pickers usually want.
|
|
@@ -198,6 +198,40 @@ version first, smallest install root, etc.), wrap the call in :func:`sorted` --
|
|
|
198
198
|
include a ``sort_by`` parameter because keeping discovery order preserves the priority signal for callers who
|
|
199
199
|
want it.
|
|
200
200
|
|
|
201
|
+
Resolving a virtual environment to its base
|
|
202
|
+
--------------------------------------------
|
|
203
|
+
|
|
204
|
+
A :class:`~python_discovery.PythonInfo` reaches you in one of two states, and the difference shows up in a single
|
|
205
|
+
field.
|
|
206
|
+
|
|
207
|
+
Collection fills in :attr:`~python_discovery.PythonInfo.system_executable` by inspection alone, which works whenever
|
|
208
|
+
the interpreter is not in a virtual environment or names its base outright. When inspection comes up short the field
|
|
209
|
+
holds ``None``, and :meth:`~python_discovery.PythonInfo.resolve_to_system` walks the prefix chain a layer at a time,
|
|
210
|
+
interrogating each prefix until it reaches a real install. Every discovery entry point runs that walk, so an
|
|
211
|
+
interpreter you got from :func:`~python_discovery.get_interpreter`,
|
|
212
|
+
:func:`~python_discovery.iter_interpreters` or :meth:`~python_discovery.PythonInfo.from_exe` is resolved before you
|
|
213
|
+
see it.
|
|
214
|
+
|
|
215
|
+
.. mermaid::
|
|
216
|
+
|
|
217
|
+
flowchart TD
|
|
218
|
+
Collect["collection<br>system_executable = None"] --> Walk["resolve_to_system()<br>walk prefix chain"]
|
|
219
|
+
Walk --> Done["system_executable set"]
|
|
220
|
+
Collect -- "base named outright" --> Done
|
|
221
|
+
Done --> Read["system_exe"]
|
|
222
|
+
|
|
223
|
+
style Collect fill:#ffe3a3,stroke:#d29200,color:#3a2c00
|
|
224
|
+
style Walk fill:#4a90d9,stroke:#2a5f8f,color:#fff
|
|
225
|
+
style Done fill:#4a9f4a,stroke:#2a6f2a,color:#fff
|
|
226
|
+
style Read fill:#4a9f4a,stroke:#2a6f2a,color:#fff
|
|
227
|
+
|
|
228
|
+
The annotation cannot describe that ordering, so ``system_executable`` stays ``str | None`` and a type checker asks
|
|
229
|
+
every reader to narrow a value resolution has settled.
|
|
230
|
+
:attr:`~python_discovery.PythonInfo.system_exe` closes that gap by falling back to
|
|
231
|
+
:attr:`~python_discovery.PythonInfo.executable`, which is the value ``resolve_to_system`` writes anyway when a prefix
|
|
232
|
+
links back to its own interpreter. Reading the raw field still tells the two states apart, which is what
|
|
233
|
+
:meth:`~python_discovery.PythonInfo.from_exe` with ``resolve_to_host=False`` leaves you holding.
|
|
234
|
+
|
|
201
235
|
How caching works
|
|
202
236
|
-------------------
|
|
203
237
|
|
|
@@ -241,12 +275,13 @@ If your system consistently hits timeouts, you can customize the timeout via the
|
|
|
241
275
|
|
|
242
276
|
The timeout applies to each individual interpreter being queried. If you set a value that is too low,
|
|
243
277
|
legitimate interpreters may be skipped; if too high, the discovery process may take longer to fail
|
|
244
|
-
when encountering problematic interpreters.
|
|
278
|
+
when encountering problematic interpreters. For invalid values, python-discovery logs a warning and uses
|
|
279
|
+
the default of 15 seconds. Set ``inf`` to disable the timeout.
|
|
245
280
|
|
|
246
281
|
Supported Python versions
|
|
247
282
|
-------------------------
|
|
248
283
|
|
|
249
|
-
Two floors apply: python-discovery itself runs on Python 3.
|
|
284
|
+
Two floors apply: python-discovery itself runs on Python 3.9+ (``requires-python``), while discovery
|
|
250
285
|
reaches down to Python 3.6 - old enough to cover RHEL 8's system Python.
|
|
251
286
|
|
|
252
287
|
python-discovery skips candidates below 3.6 with one warning naming the executable, the version
|
|
@@ -145,7 +145,8 @@ Once you have a :class:`~python_discovery.PythonInfo`, you can inspect everythin
|
|
|
145
145
|
classDiagram
|
|
146
146
|
class PythonInfo {
|
|
147
147
|
+executable: str
|
|
148
|
-
+
|
|
148
|
+
+system_exe: str
|
|
149
|
+
+system_executable: str | None
|
|
149
150
|
+implementation: str
|
|
150
151
|
+version_info: VersionInfo
|
|
151
152
|
+architecture: int
|
|
@@ -167,7 +168,7 @@ Once you have a :class:`~python_discovery.PythonInfo`, you can inspect everythin
|
|
|
167
168
|
info = get_interpreter("python3.12", cache=cache)
|
|
168
169
|
|
|
169
170
|
info.executable # Resolved path to the binary.
|
|
170
|
-
info.
|
|
171
|
+
info.system_exe # The underlying system interpreter (outside any venv).
|
|
171
172
|
info.implementation # "CPython", "PyPy", "GraalPy", etc.
|
|
172
173
|
info.version_info # VersionInfo(major, minor, micro, releaselevel, serial).
|
|
173
174
|
info.architecture # 64 or 32.
|
|
@@ -178,6 +179,13 @@ Once you have a :class:`~python_discovery.PythonInfo`, you can inspect everythin
|
|
|
178
179
|
info.sysconfig_vars # All sysconfig.get_config_vars() values.
|
|
179
180
|
info.sysconfig_paths # All sysconfig.get_paths() values.
|
|
180
181
|
|
|
182
|
+
Prefer :attr:`~python_discovery.PythonInfo.system_exe` over the raw
|
|
183
|
+
:attr:`~python_discovery.PythonInfo.system_executable` field it reads. The field is typed ``str | None`` because it
|
|
184
|
+
holds nothing until resolution runs, so a type checker makes you narrow it at every use even though discovery has
|
|
185
|
+
filled it in by the time you hold a :class:`~python_discovery.PythonInfo`. Read the raw field only when you build a
|
|
186
|
+
:class:`~python_discovery.PythonInfo` yourself, or call
|
|
187
|
+
:meth:`~python_discovery.PythonInfo.from_exe` with ``resolve_to_host=False``, and want to tell the two states apart.
|
|
188
|
+
|
|
181
189
|
Implement a custom cache backend
|
|
182
190
|
-----------------------------------
|
|
183
191
|
|
|
@@ -36,3 +36,7 @@ Setting this variable extends the allowed time for each interpreter query.
|
|
|
36
36
|
- Setting the value too low may skip legitimate interpreters
|
|
37
37
|
- Setting it too high increases discovery time when encountering problematic interpreters
|
|
38
38
|
- The value is read from the environment dict passed to :func:`~python_discovery.get_interpreter`
|
|
39
|
+
- For invalid values, including empty strings, ``nan``, ``0`` and negative numbers, python-discovery logs a warning
|
|
40
|
+
and uses the default of 15 seconds
|
|
41
|
+
- ``inf`` or a value above 2,147,483.647 seconds (about 24.9 days) disables the timeout. This limit applies on all
|
|
42
|
+
platforms because some subprocess waits accept a signed 32-bit count of milliseconds
|
|
@@ -17,6 +17,7 @@ Before diving into code, here are the key ideas:
|
|
|
17
17
|
- **Spec** -- a short string describing what you are looking for (e.g., ``python3.12``, ``pypy3.9``, ``>=3.11``).
|
|
18
18
|
- **Discovery** -- the process of searching your system for an interpreter that matches a spec.
|
|
19
19
|
- **Cache** -- a disk store that remembers previously discovered interpreters so the next lookup is instant.
|
|
20
|
+
- **Resolution** -- following a virtual environment back to the base interpreter it was built from.
|
|
20
21
|
|
|
21
22
|
Inspecting the current interpreter
|
|
22
23
|
------------------------------------
|
|
@@ -45,6 +46,7 @@ The simplest use case: get information about the Python that is running right no
|
|
|
45
46
|
info = PythonInfo.current_system(cache)
|
|
46
47
|
|
|
47
48
|
print(info.executable) # /usr/bin/python3.12
|
|
49
|
+
print(info.system_exe) # /usr/bin/python3.12
|
|
48
50
|
print(info.version_info[:3]) # (3, 12, 1)
|
|
49
51
|
print(info.implementation) # CPython (or PyPy, GraalPy, etc.)
|
|
50
52
|
print(info.architecture) # 64 (or 32)
|
|
@@ -52,6 +54,11 @@ The simplest use case: get information about the Python that is running right no
|
|
|
52
54
|
The returned :class:`~python_discovery.PythonInfo` object contains everything the library knows about that interpreter:
|
|
53
55
|
paths, version numbers, sysconfig variables, platform details, and more.
|
|
54
56
|
|
|
57
|
+
Run the same snippet from inside a virtual environment and the two paths part company.
|
|
58
|
+
:attr:`~python_discovery.PythonInfo.executable` stays the environment's own ``python``, while
|
|
59
|
+
:attr:`~python_discovery.PythonInfo.system_exe` names the base interpreter that environment was built from. Reach for
|
|
60
|
+
``system_exe`` whenever you want the real install rather than a link to it.
|
|
61
|
+
|
|
55
62
|
Finding a different interpreter
|
|
56
63
|
--------------------------------
|
|
57
64
|
|
|
@@ -19,7 +19,7 @@ license.file = "LICENSE"
|
|
|
19
19
|
maintainers = [
|
|
20
20
|
{ name = "Bernát Gábor", email = "gaborjbernat@gmail.com" },
|
|
21
21
|
]
|
|
22
|
-
requires-python = ">=3.
|
|
22
|
+
requires-python = ">=3.9"
|
|
23
23
|
classifiers = [
|
|
24
24
|
"Development Status :: 5 - Production/Stable",
|
|
25
25
|
"Intended Audience :: Developers",
|
|
@@ -28,7 +28,6 @@ classifiers = [
|
|
|
28
28
|
"Operating System :: Microsoft :: Windows",
|
|
29
29
|
"Operating System :: POSIX",
|
|
30
30
|
"Programming Language :: Python :: 3 :: Only",
|
|
31
|
-
"Programming Language :: Python :: 3.8",
|
|
32
31
|
"Programming Language :: Python :: 3.9",
|
|
33
32
|
"Programming Language :: Python :: 3.10",
|
|
34
33
|
"Programming Language :: Python :: 3.11",
|
|
@@ -44,7 +43,7 @@ dynamic = [
|
|
|
44
43
|
"version",
|
|
45
44
|
]
|
|
46
45
|
dependencies = [
|
|
47
|
-
"filelock>=3.
|
|
46
|
+
"filelock>=3.16.1",
|
|
48
47
|
]
|
|
49
48
|
urls.Changelog = "https://github.com/tox-dev/python-discovery/releases"
|
|
50
49
|
urls.Documentation = "https://python-discovery.readthedocs.io"
|
|
@@ -55,24 +54,25 @@ urls.Tracker = "https://github.com/tox-dev/python-discovery/issues"
|
|
|
55
54
|
[dependency-groups]
|
|
56
55
|
test = [
|
|
57
56
|
"covdefaults>=2.3",
|
|
58
|
-
"coverage>=7.
|
|
57
|
+
"coverage>=7.6.1",
|
|
59
58
|
"pytest>=8.3.5",
|
|
60
|
-
"pytest-mock>=3.14",
|
|
61
|
-
"setuptools>=75.
|
|
62
|
-
"
|
|
59
|
+
"pytest-mock>=3.14.1",
|
|
60
|
+
"setuptools>=75.3.4",
|
|
61
|
+
"typing-extensions>=4.12",
|
|
62
|
+
"vermin>=1.8",
|
|
63
63
|
]
|
|
64
64
|
docs = [
|
|
65
65
|
"furo>=2025.12.19",
|
|
66
66
|
"sphinx>=9.1",
|
|
67
67
|
"sphinx-autodoc-typehints>=3.6.3",
|
|
68
|
-
"sphinx-llm>=0.4.1",
|
|
68
|
+
"sphinx-llm>=0.4.1,!=1.1",
|
|
69
69
|
"sphinxcontrib-mermaid>=2",
|
|
70
70
|
"sphinxcontrib-towncrier>=0.4",
|
|
71
71
|
"towncrier>=25.8",
|
|
72
72
|
]
|
|
73
73
|
release = [
|
|
74
|
-
"gitpython>=3.
|
|
75
|
-
"packaging>=26",
|
|
74
|
+
"gitpython>=3.2",
|
|
75
|
+
"packaging>=26.2",
|
|
76
76
|
"pre-commit>=4.5.1",
|
|
77
77
|
"towncrier>=25.8",
|
|
78
78
|
]
|
|
@@ -81,6 +81,7 @@ release = [
|
|
|
81
81
|
version.source = "vcs"
|
|
82
82
|
|
|
83
83
|
[tool.ruff]
|
|
84
|
+
target-version = "py39"
|
|
84
85
|
line-length = 120
|
|
85
86
|
format.preview = true
|
|
86
87
|
format.docstring-code-line-length = 100
|
|
@@ -133,6 +134,7 @@ lint.per-file-ignores."tests/**/*.py" = [
|
|
|
133
134
|
"import-private-name", # private imports needed to test internal APIs
|
|
134
135
|
"magic-value-comparison", # Magic value used in comparison
|
|
135
136
|
"private-member-access", # private member access needed to test internals
|
|
137
|
+
"pytest-fixture-autouse", # shared setup fixtures are meant to apply to every test
|
|
136
138
|
"subprocess-without-shell-equals-true", # `subprocess` call: check for execution of untrusted input
|
|
137
139
|
"suspicious-subprocess-import", # subprocess import
|
|
138
140
|
"too-many-arguments", # too many arguments (pytest fixtures)
|
|
@@ -41,6 +41,9 @@ _CACHE: OrderedDict[Path, PythonInfo | Exception] = OrderedDict()
|
|
|
41
41
|
_CACHE[Path(sys.executable)] = PythonInfo()
|
|
42
42
|
_LOGGER: Final[logging.Logger] = logging.getLogger(__name__)
|
|
43
43
|
_PY_INFO_SCRIPT: Final[Path] = Path(__file__).resolve().parent / "_py_info_collect.py"
|
|
44
|
+
_DEFAULT_QUERY_TIMEOUT: Final[float] = 15.0
|
|
45
|
+
# poll(2) limits milliseconds to a signed C int.
|
|
46
|
+
_MAX_QUERY_TIMEOUT: Final[float] = (2**31 - 1) / 1000
|
|
44
47
|
|
|
45
48
|
|
|
46
49
|
class _UnsupportedInterpreterError(RuntimeError):
|
|
@@ -136,7 +139,7 @@ def _read_cache_entry(
|
|
|
136
139
|
) -> PythonInfo | _UnsupportedInterpreterError | None:
|
|
137
140
|
if not py_info_store.exists() or (data := py_info_store.read()) is None:
|
|
138
141
|
return None
|
|
139
|
-
if all(data.get(key) == value for key, value in entry_meta.items()):
|
|
142
|
+
if isinstance(data, dict) and all(data.get(key) == value for key, value in entry_meta.items()):
|
|
140
143
|
if isinstance(unsupported := data.get("unsupported"), str):
|
|
141
144
|
return _UnsupportedInterpreterError(unsupported)
|
|
142
145
|
if isinstance(content := data.get("content"), dict):
|
|
@@ -152,10 +155,10 @@ def _load_cached_py_info(
|
|
|
152
155
|
) -> PythonInfo | None:
|
|
153
156
|
try:
|
|
154
157
|
py_info = cls.from_dict(content.copy())
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
if
|
|
158
|
+
stale = (sys_exe := py_info.system_executable) is not None and not Path(sys_exe).is_file()
|
|
159
|
+
except (AttributeError, KeyError, OSError, TypeError):
|
|
160
|
+
stale = True
|
|
161
|
+
if stale:
|
|
159
162
|
py_info_store.remove()
|
|
160
163
|
return None
|
|
161
164
|
return py_info
|
|
@@ -214,7 +217,7 @@ def _run_subprocess(
|
|
|
214
217
|
) -> tuple[Exception | None, PythonInfo | None]:
|
|
215
218
|
start_cookie = gen_cookie()
|
|
216
219
|
end_cookie = gen_cookie()
|
|
217
|
-
timeout =
|
|
220
|
+
timeout = _query_timeout(env.get("PY_DISCOVERY_TIMEOUT"))
|
|
218
221
|
with _resolve_py_info_script() as py_info_script:
|
|
219
222
|
cmd = [exe, str(py_info_script), start_cookie, end_cookie]
|
|
220
223
|
env = dict(env)
|
|
@@ -264,6 +267,22 @@ def _run_subprocess(
|
|
|
264
267
|
return None, result
|
|
265
268
|
|
|
266
269
|
|
|
270
|
+
@lru_cache(maxsize=128)
|
|
271
|
+
def _query_timeout(raw: str | None) -> float | None:
|
|
272
|
+
if raw is None:
|
|
273
|
+
return _DEFAULT_QUERY_TIMEOUT
|
|
274
|
+
try:
|
|
275
|
+
timeout = float(raw)
|
|
276
|
+
except ValueError:
|
|
277
|
+
timeout = 0.0
|
|
278
|
+
if timeout > 0:
|
|
279
|
+
return timeout if timeout <= _MAX_QUERY_TIMEOUT else None
|
|
280
|
+
_LOGGER.warning(
|
|
281
|
+
"ignoring PY_DISCOVERY_TIMEOUT=%r, not a positive number of seconds; using %s", raw, _DEFAULT_QUERY_TIMEOUT
|
|
282
|
+
)
|
|
283
|
+
return _DEFAULT_QUERY_TIMEOUT
|
|
284
|
+
|
|
285
|
+
|
|
267
286
|
def _query_failure(exe: str, out: str, err: str | None, code: int | None) -> RuntimeError:
|
|
268
287
|
# the gate's exit code and stderr marker must both be present: either alone can collide, an errno of 79 from a
|
|
269
288
|
# failed exec or a shim that echoes the marker phrase, but only the gate produces the combination
|
|
@@ -423,7 +423,12 @@ def get_paths(env: Mapping[str, str]) -> Generator[Path, None, None]:
|
|
|
423
423
|
except (AttributeError, ValueError): # pragma: no cover # Windows only (no confstr)
|
|
424
424
|
path = os.defpath
|
|
425
425
|
if path:
|
|
426
|
-
|
|
426
|
+
# An empty component (a leading/trailing/doubled separator) means "the current directory", the
|
|
427
|
+
# same footgun a shell has when PATH is misconfigured - Path("") resolves to it. Since every
|
|
428
|
+
# yielded path here is later searched for interpreters to execute and interrogate, silently
|
|
429
|
+
# including the caller's CWD would let whatever directory virtualenv happens to run from smuggle
|
|
430
|
+
# in a candidate binary.
|
|
431
|
+
for entry in map(Path, filter(None, path.split(os.pathsep))):
|
|
427
432
|
with suppress(OSError):
|
|
428
433
|
if entry.is_dir() and next(entry.iterdir(), None):
|
|
429
434
|
yield entry
|
|
@@ -321,7 +321,7 @@ class PythonInfoCollector:
|
|
|
321
321
|
"script_args": "--no-user-cfg",
|
|
322
322
|
}) # conf files not parsed so they do not hijack paths
|
|
323
323
|
if hasattr(sys, "_framework"): # pragma: no cover # macOS framework builds only
|
|
324
|
-
sys._framework = None # ruff:ignore[private-member-access] # disable macOS static paths for framework
|
|
324
|
+
sys._framework = None # ty: ignore[invalid-assignment] # ruff:ignore[private-member-access] # disable macOS static paths for framework
|
|
325
325
|
|
|
326
326
|
with warnings.catch_warnings(): # disable warning for PEP-632
|
|
327
327
|
warnings.simplefilter("ignore")
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from python_discovery import DiskCache, PythonInfo
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@pytest.mark.parametrize(
|
|
16
|
+
"payload",
|
|
17
|
+
[
|
|
18
|
+
pytest.param("[]", id="list"),
|
|
19
|
+
pytest.param('"python"', id="string"),
|
|
20
|
+
pytest.param("3", id="number"),
|
|
21
|
+
pytest.param("true", id="bool"),
|
|
22
|
+
pytest.param("null", id="null"),
|
|
23
|
+
],
|
|
24
|
+
)
|
|
25
|
+
def test_from_exe_recovers_non_object_cache(cached_interpreter: tuple[DiskCache, Path, dict], payload: str) -> None:
|
|
26
|
+
cache, cache_file, expected = cached_interpreter
|
|
27
|
+
cache_file.write_text(payload, encoding="utf-8")
|
|
28
|
+
result = PythonInfo.from_exe(sys.executable, cache, ignore_cache=True, resolve_to_host=False)
|
|
29
|
+
assert result is not None
|
|
30
|
+
assert (json.loads(result.to_json()), json.loads(cache_file.read_text(encoding="utf-8"))) == (
|
|
31
|
+
expected["content"],
|
|
32
|
+
expected,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@pytest.mark.parametrize(
|
|
37
|
+
"payload",
|
|
38
|
+
[
|
|
39
|
+
pytest.param(None, id="missing"),
|
|
40
|
+
pytest.param("5", id="int"),
|
|
41
|
+
pytest.param('["python"]', id="list"),
|
|
42
|
+
pytest.param("{}", id="dict"),
|
|
43
|
+
pytest.param("true", id="bool"),
|
|
44
|
+
pytest.param('""', id="empty"),
|
|
45
|
+
pytest.param('"."', id="directory"),
|
|
46
|
+
pytest.param('"missing-python"', id="nonexistent"),
|
|
47
|
+
pytest.param('"python\\u0000"', id="nul"),
|
|
48
|
+
pytest.param(json.dumps("a" * 1024), id="path-too-long"),
|
|
49
|
+
],
|
|
50
|
+
)
|
|
51
|
+
def test_from_exe_recovers_invalid_system_executable(
|
|
52
|
+
cached_interpreter: tuple[DiskCache, Path, dict], payload: str | None
|
|
53
|
+
) -> None:
|
|
54
|
+
cache, cache_file, expected = cached_interpreter
|
|
55
|
+
data = json.loads(cache_file.read_text(encoding="utf-8"))
|
|
56
|
+
if payload is None:
|
|
57
|
+
del data["content"]["system_executable"]
|
|
58
|
+
else:
|
|
59
|
+
data["content"]["system_executable"] = json.loads(payload)
|
|
60
|
+
cache_file.write_text(json.dumps(data), encoding="utf-8")
|
|
61
|
+
result = PythonInfo.from_exe(sys.executable, cache, ignore_cache=True, resolve_to_host=False)
|
|
62
|
+
assert result is not None
|
|
63
|
+
assert (json.loads(result.to_json()), json.loads(cache_file.read_text(encoding="utf-8"))) == (
|
|
64
|
+
expected["content"],
|
|
65
|
+
expected,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@pytest.fixture
|
|
70
|
+
def cached_interpreter(tmp_path: Path) -> tuple[DiskCache, Path, dict]:
|
|
71
|
+
cache = DiskCache(tmp_path)
|
|
72
|
+
assert PythonInfo.from_exe(sys.executable, cache, ignore_cache=True, resolve_to_host=False) is not None
|
|
73
|
+
cache_file = next(tmp_path.rglob("*.json"))
|
|
74
|
+
return cache, cache_file, json.loads(cache_file.read_text(encoding="utf-8"))
|
|
@@ -124,17 +124,6 @@ def test_run_subprocess_timeout(mocker: MockerFixture) -> None:
|
|
|
124
124
|
assert mock_process.communicate.call_count == 2
|
|
125
125
|
|
|
126
126
|
|
|
127
|
-
def test_run_subprocess_custom_timeout(mocker: MockerFixture) -> None:
|
|
128
|
-
mock_process = MagicMock()
|
|
129
|
-
mock_process.communicate.return_value = (json.dumps(PythonInfo().to_dict()), "")
|
|
130
|
-
mock_process.returncode = 0
|
|
131
|
-
mocker.patch("python_discovery._cached_py_info.Popen", return_value=mock_process)
|
|
132
|
-
env = dict(os.environ)
|
|
133
|
-
env["PY_DISCOVERY_TIMEOUT"] = "30"
|
|
134
|
-
_run_subprocess(PythonInfo, sys.executable, env)
|
|
135
|
-
mock_process.communicate.assert_called_once_with(timeout=30.0)
|
|
136
|
-
|
|
137
|
-
|
|
138
127
|
def test_run_subprocess_nonzero_exit(mocker: MockerFixture) -> None:
|
|
139
128
|
mock_process = MagicMock()
|
|
140
129
|
mock_process.communicate.return_value = ("some output", "some error")
|
|
@@ -161,6 +161,38 @@ def test_get_paths_no_path_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
|
161
161
|
assert paths
|
|
162
162
|
|
|
163
163
|
|
|
164
|
+
@pytest.mark.parametrize(
|
|
165
|
+
("raw_path", "expected"),
|
|
166
|
+
[
|
|
167
|
+
pytest.param("{first}{sep}", ["first"], id="trailing"),
|
|
168
|
+
pytest.param("{sep}{first}", ["first"], id="leading"),
|
|
169
|
+
pytest.param("{first}{sep}{sep}{second}", ["first", "second"], id="doubled"),
|
|
170
|
+
],
|
|
171
|
+
)
|
|
172
|
+
def test_get_paths_skips_empty_entries(tmp_path: Path, raw_path: str, expected: list[str]) -> None:
|
|
173
|
+
"""An empty PATH entry means "current directory" - it must never be searched for interpreters."""
|
|
174
|
+
dirs = {}
|
|
175
|
+
for name in ("first", "second"):
|
|
176
|
+
dirs[name] = tmp_path / name
|
|
177
|
+
dirs[name].mkdir()
|
|
178
|
+
(dirs[name] / "dummy").touch()
|
|
179
|
+
|
|
180
|
+
paths = list(get_paths({"PATH": raw_path.format(first=dirs["first"], second=dirs["second"], sep=os.pathsep)}))
|
|
181
|
+
|
|
182
|
+
assert paths == [dirs[name] for name in expected]
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def test_get_paths_empty_entry_does_not_yield_cwd(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
186
|
+
"""Reproduces the exploit: an empty PATH entry used to resolve to the caller's CWD."""
|
|
187
|
+
cwd_marker = tmp_path / "python3.11"
|
|
188
|
+
cwd_marker.touch(mode=0o755)
|
|
189
|
+
monkeypatch.chdir(tmp_path)
|
|
190
|
+
|
|
191
|
+
paths = list(get_paths({"PATH": f"{os.pathsep}"}))
|
|
192
|
+
|
|
193
|
+
assert not any(path.resolve() == tmp_path.resolve() for path in paths)
|
|
194
|
+
|
|
195
|
+
|
|
164
196
|
def test_lazy_path_dump_debug(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
165
197
|
monkeypatch.setenv("_VIRTUALENV_DEBUG", "1")
|
|
166
198
|
a_dir = tmp_path
|
|
@@ -79,7 +79,7 @@ def no_framework(mocker: MockerFixture) -> None:
|
|
|
79
79
|
mocker.patch.object(
|
|
80
80
|
sysconfig,
|
|
81
81
|
"get_config_var",
|
|
82
|
-
side_effect=lambda name: "" if name == "PYTHONFRAMEWORK" else get_config_var(name),
|
|
82
|
+
side_effect=lambda name: "" if name == "PYTHONFRAMEWORK" else get_config_var(name), # ty: ignore[deprecated] # resolves the deprecated Literal["SO"] overload even though name is a generic str
|
|
83
83
|
)
|
|
84
84
|
|
|
85
85
|
|
|
@@ -187,7 +187,7 @@ def test_system_executable_framework_symlink_kept(tmp_path: Path, mocker: Mocker
|
|
|
187
187
|
mocker.patch.object(
|
|
188
188
|
sysconfig,
|
|
189
189
|
"get_config_var",
|
|
190
|
-
side_effect=lambda name: "Python" if name == "PYTHONFRAMEWORK" else get_config_var(name),
|
|
190
|
+
side_effect=lambda name: "Python" if name == "PYTHONFRAMEWORK" else get_config_var(name), # ty: ignore[deprecated] # resolves the deprecated Literal["SO"] overload even though name is a generic str
|
|
191
191
|
)
|
|
192
192
|
mocker.patch.object(sys, "executable", str(link))
|
|
193
193
|
assert PythonInfo().system_executable == str(link)
|
|
@@ -225,7 +225,7 @@ def test_satisfies_path_not_abs_basename_match() -> None:
|
|
|
225
225
|
# stem drops everything from the first dot, so python3.15t arrives as python3 and stops naming a free-threaded
|
|
226
226
|
# build; only the Windows .exe belongs off the end.
|
|
227
227
|
name = Path(info.original_executable).name
|
|
228
|
-
basename = name
|
|
228
|
+
basename = name.removesuffix(".exe")
|
|
229
229
|
spec = PythonSpec.from_string_spec(basename)
|
|
230
230
|
assert info.satisfies(spec, impl_must_match=False) is True
|
|
231
231
|
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
from subprocess import Popen
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from python_discovery import PythonInfo
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from pytest_mock import MockerFixture
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@pytest.mark.parametrize(
|
|
18
|
+
("raw", "expected"),
|
|
19
|
+
[
|
|
20
|
+
pytest.param(None, 15.0, id="default"),
|
|
21
|
+
pytest.param("30", 30.0, id="seconds"),
|
|
22
|
+
pytest.param(" 0.5 ", 0.5, id="padded-fraction"),
|
|
23
|
+
pytest.param("inf", None, id="inf"),
|
|
24
|
+
pytest.param("1e30", None, id="beyond-platform-wait"),
|
|
25
|
+
pytest.param("2147483.647", 2147483.647, id="wait-limit"),
|
|
26
|
+
pytest.param("2147483.648", None, id="above-wait-limit"),
|
|
27
|
+
],
|
|
28
|
+
)
|
|
29
|
+
def test_from_exe_query_timeout(
|
|
30
|
+
mocker: MockerFixture, caplog: pytest.LogCaptureFixture, raw: str | None, expected: float | None
|
|
31
|
+
) -> None:
|
|
32
|
+
communicate = mocker.spy(Popen, "communicate")
|
|
33
|
+
env = {key: value for key, value in os.environ.items() if key != "PY_DISCOVERY_TIMEOUT"}
|
|
34
|
+
if raw is not None:
|
|
35
|
+
env["PY_DISCOVERY_TIMEOUT"] = raw
|
|
36
|
+
result = PythonInfo.from_exe(sys.executable, env=env, ignore_cache=True, resolve_to_host=False)
|
|
37
|
+
assert result is not None
|
|
38
|
+
assert result.version_info == sys.version_info
|
|
39
|
+
assert communicate.call_args_list[0].kwargs == {"timeout": expected}
|
|
40
|
+
assert not caplog.records
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@pytest.mark.parametrize(
|
|
44
|
+
"raw",
|
|
45
|
+
[
|
|
46
|
+
pytest.param("", id="empty"),
|
|
47
|
+
pytest.param("abc", id="words"),
|
|
48
|
+
pytest.param("0x10", id="hex"),
|
|
49
|
+
pytest.param("nan", id="nan"),
|
|
50
|
+
pytest.param("0", id="zero"),
|
|
51
|
+
pytest.param("-1", id="negative"),
|
|
52
|
+
pytest.param("-inf", id="negative-inf"),
|
|
53
|
+
pytest.param("1e-999", id="underflow"),
|
|
54
|
+
],
|
|
55
|
+
)
|
|
56
|
+
def test_from_exe_invalid_timeout_warns_once(mocker: MockerFixture, caplog: pytest.LogCaptureFixture, raw: str) -> None:
|
|
57
|
+
communicate = mocker.spy(Popen, "communicate")
|
|
58
|
+
env = {**os.environ, "PY_DISCOVERY_TIMEOUT": raw}
|
|
59
|
+
for _ in range(2):
|
|
60
|
+
result = PythonInfo.from_exe(sys.executable, env=env, ignore_cache=True, resolve_to_host=False)
|
|
61
|
+
assert result is not None
|
|
62
|
+
assert result.version_info == sys.version_info
|
|
63
|
+
assert [call.kwargs for call in communicate.call_args_list] == [{"timeout": 15.0}] * 2
|
|
64
|
+
assert [(record.levelno, record.getMessage()) for record in caplog.records] == [
|
|
65
|
+
(logging.WARNING, f"ignoring PY_DISCOVERY_TIMEOUT={raw!r}, not a positive number of seconds; using 15.0")
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_from_exe_timeout_warning_cache_is_bounded(caplog: pytest.LogCaptureFixture) -> None:
|
|
70
|
+
for index in range(129):
|
|
71
|
+
result = PythonInfo.from_exe(
|
|
72
|
+
sys.executable,
|
|
73
|
+
env={**os.environ, "PY_DISCOVERY_TIMEOUT": f"invalid-timeout-{index}"},
|
|
74
|
+
ignore_cache=True,
|
|
75
|
+
resolve_to_host=False,
|
|
76
|
+
)
|
|
77
|
+
assert result is not None
|
|
78
|
+
assert result.version_info == sys.version_info
|
|
79
|
+
caplog.clear()
|
|
80
|
+
for raw in ("invalid-timeout-128", "invalid-timeout-0"):
|
|
81
|
+
result = PythonInfo.from_exe(
|
|
82
|
+
sys.executable, env={**os.environ, "PY_DISCOVERY_TIMEOUT": raw}, ignore_cache=True, resolve_to_host=False
|
|
83
|
+
)
|
|
84
|
+
assert result is not None
|
|
85
|
+
assert result.version_info == sys.version_info
|
|
86
|
+
assert [record.getMessage() for record in caplog.records] == [
|
|
87
|
+
"ignoring PY_DISCOVERY_TIMEOUT='invalid-timeout-0', not a positive number of seconds; using 15.0"
|
|
88
|
+
]
|
|
@@ -43,7 +43,7 @@ def _load_registry_data(
|
|
|
43
43
|
glob: dict[str, object] = {"winreg": winreg}
|
|
44
44
|
mock_value_str = (Path(__file__).parent / "winreg_mock_values.py").read_text(encoding="utf-8")
|
|
45
45
|
exec(mock_value_str, glob, loc) # ruff:ignore[exec-builtin]
|
|
46
|
-
return loc["enum_collect"], loc["value_collect"], loc["key_open"], loc["hive_open"] #
|
|
46
|
+
return loc["enum_collect"], loc["value_collect"], loc["key_open"], loc["hive_open"] # ty: ignore[invalid-return-type]
|
|
47
47
|
|
|
48
48
|
|
|
49
49
|
class _Key:
|
|
@@ -65,7 +65,7 @@ def _make_enum_key(enum_collect: dict[object, dict[int, object]]) -> object:
|
|
|
65
65
|
result = enum_collect[key_id][at]
|
|
66
66
|
if isinstance(result, OSError):
|
|
67
67
|
raise result
|
|
68
|
-
return result #
|
|
68
|
+
return result # ty: ignore[invalid-return-type]
|
|
69
69
|
|
|
70
70
|
return _enum_key
|
|
71
71
|
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
requires = [ "tox>=4.64.7", "tox-uv>=1.29" ]
|
|
2
|
+
env_list = [ "3.15", "type-3.15", "3.14", "3.13", "3.12", "3.11", "3.10", "3.9", "type-3.9", "3.15t", "docs", "fix", "pkg_meta" ]
|
|
3
|
+
skip_missing_interpreters = true
|
|
4
|
+
|
|
5
|
+
[env_run_base]
|
|
6
|
+
description = "run the tests with pytest"
|
|
7
|
+
default_base_python = [ "3.15" ]
|
|
8
|
+
package = "wheel"
|
|
9
|
+
wheel_build_env = ".pkg"
|
|
10
|
+
dependency_groups = [ "test" ]
|
|
11
|
+
pass_env = [ "DIFF_AGAINST", "PYTEST_*" ]
|
|
12
|
+
set_env.COVERAGE_FILE = "{work_dir}{/}.coverage.{env_name}"
|
|
13
|
+
commands = [
|
|
14
|
+
[ "coverage", "erase" ],
|
|
15
|
+
[
|
|
16
|
+
"coverage",
|
|
17
|
+
"run",
|
|
18
|
+
"-m",
|
|
19
|
+
"pytest",
|
|
20
|
+
{ replace = "posargs", default = [
|
|
21
|
+
"--junitxml",
|
|
22
|
+
"{work_dir}{/}junit.{env_name}.xml",
|
|
23
|
+
"tests",
|
|
24
|
+
], extend = true },
|
|
25
|
+
],
|
|
26
|
+
[ "coverage", "combine" ],
|
|
27
|
+
[ "coverage", "report" ],
|
|
28
|
+
[ "coverage", "html", "-d", "{env_tmp_dir}{/}htmlcov" ],
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[env_pkg_base]
|
|
32
|
+
default_base_python = [ "3.15" ]
|
|
33
|
+
|
|
34
|
+
[env."type-3.15"]
|
|
35
|
+
description = "run type check on code base (3.15)"
|
|
36
|
+
deps = [ "gitpython>=3.2", "packaging>=26.2", "ty==0.0.84" ]
|
|
37
|
+
commands = [ [ "ty", "check", "--output-format", "concise", "--error-on-warning", "--python-version", "3.15", "." ] ]
|
|
38
|
+
|
|
39
|
+
[env."type-3.9"]
|
|
40
|
+
description = "run type check on code base (3.9)"
|
|
41
|
+
deps = [ "gitpython>=3.2", "packaging>=26.2", "ty==0.0.84" ]
|
|
42
|
+
commands = [ [ "ty", "check", "--output-format", "concise", "--error-on-warning", "--python-version", "3.9", "." ] ]
|
|
43
|
+
|
|
44
|
+
[env.docs]
|
|
45
|
+
description = "build documentation"
|
|
46
|
+
dependency_groups = [ "docs" ]
|
|
47
|
+
commands = [
|
|
48
|
+
[
|
|
49
|
+
"sphinx-build",
|
|
50
|
+
"-d",
|
|
51
|
+
"{env_tmp_dir}{/}doctree",
|
|
52
|
+
"docs",
|
|
53
|
+
"--color",
|
|
54
|
+
"-b",
|
|
55
|
+
"html",
|
|
56
|
+
"-W",
|
|
57
|
+
{ replace = "posargs", default = [ "{work_dir}{/}docs_out" ], extend = true },
|
|
58
|
+
],
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
[env.fix]
|
|
62
|
+
description = "run static analysis and style check using flake8"
|
|
63
|
+
skip_install = true
|
|
64
|
+
deps = [ "pre-commit-uv>=4.2.1" ]
|
|
65
|
+
pass_env = [ "HOMEPATH", "PROGRAMDATA" ]
|
|
66
|
+
commands = [ [ "pre-commit", "run", "--all-files", "--show-diff-on-failure" ] ]
|
|
67
|
+
|
|
68
|
+
[env.pkg_meta]
|
|
69
|
+
description = "check that the long description is valid"
|
|
70
|
+
skip_install = true
|
|
71
|
+
deps = [ "check-wheel-contents>=0.6.3", "twine>=6.2", "uv>=0.12.22" ]
|
|
72
|
+
commands = [
|
|
73
|
+
[ "uv", "build", "--sdist", "--wheel", "--out-dir", "{env_tmp_dir}", "." ],
|
|
74
|
+
[ "twine", "check", "{env_tmp_dir}{/}*" ],
|
|
75
|
+
[ "check-wheel-contents", "--no-config", "{env_tmp_dir}" ],
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
[env.dev]
|
|
79
|
+
description = "generate a DEV environment"
|
|
80
|
+
package = "editable"
|
|
81
|
+
dependency_groups = [ "test" ]
|
|
82
|
+
commands = [ [ "uv", "pip", "tree" ], [ "python", "-c", "import sys; print(sys.executable)" ] ]
|
|
83
|
+
|
|
84
|
+
[env.release]
|
|
85
|
+
description = "do a release, required posargs of the version number"
|
|
86
|
+
skip_install = true
|
|
87
|
+
dependency_groups = [ "release" ]
|
|
88
|
+
pass_env = [ "GH_TOKEN" ]
|
|
89
|
+
commands = [ [ "python", "{tox_root}/tasks/release.py", "--version", "{posargs}" ] ]
|
python_discovery-1.6.0/tox.toml
DELETED
|
@@ -1,85 +0,0 @@
|
|
|
1
|
-
requires = ["tox>=4.38", "tox-uv>=1.29"]
|
|
2
|
-
env_list = ["fix", "3.15", "3.15t", "3.14", "3.13", "3.12", "3.11", "3.10", "3.9", "3.8", "type-3.8", "type-3.14", "docs", "pkg_meta"]
|
|
3
|
-
skip_missing_interpreters = true
|
|
4
|
-
|
|
5
|
-
[env_run_base]
|
|
6
|
-
description = "run the tests with pytest"
|
|
7
|
-
package = "wheel"
|
|
8
|
-
wheel_build_env = ".pkg"
|
|
9
|
-
dependency_groups = ["test"]
|
|
10
|
-
pass_env = ["DIFF_AGAINST", "PYTEST_*"]
|
|
11
|
-
set_env.COVERAGE_FILE = "{work_dir}{/}.coverage.{env_name}"
|
|
12
|
-
commands = [
|
|
13
|
-
["coverage", "erase"],
|
|
14
|
-
[
|
|
15
|
-
"coverage",
|
|
16
|
-
"run",
|
|
17
|
-
"-m",
|
|
18
|
-
"pytest",
|
|
19
|
-
{ replace = "posargs", extend = true, default = [
|
|
20
|
-
"--junitxml",
|
|
21
|
-
"{work_dir}{/}junit.{env_name}.xml",
|
|
22
|
-
"tests",
|
|
23
|
-
] },
|
|
24
|
-
],
|
|
25
|
-
["coverage", "combine"],
|
|
26
|
-
["coverage", "report"],
|
|
27
|
-
["coverage", "html", "-d", "{env_tmp_dir}{/}htmlcov"],
|
|
28
|
-
]
|
|
29
|
-
|
|
30
|
-
[env.fix]
|
|
31
|
-
description = "run static analysis and style check using flake8"
|
|
32
|
-
skip_install = true
|
|
33
|
-
deps = ["pre-commit-uv>=4.2.1"]
|
|
34
|
-
pass_env = ["HOMEPATH", "PROGRAMDATA"]
|
|
35
|
-
commands = [["pre-commit", "run", "--all-files", "--show-diff-on-failure"]]
|
|
36
|
-
|
|
37
|
-
[env."type-3.8"]
|
|
38
|
-
description = "run type check on code base (3.8)"
|
|
39
|
-
deps = ["gitpython>=3.1.46", "packaging>=26", "ty==0.0.17"]
|
|
40
|
-
commands = [["ty", "check", "--output-format", "concise", "--error-on-warning", "--python-version", "3.8", "."]]
|
|
41
|
-
|
|
42
|
-
[env."type-3.14"]
|
|
43
|
-
description = "run type check on code base (3.14)"
|
|
44
|
-
deps = ["gitpython>=3.1.46", "packaging>=26", "ty==0.0.17"]
|
|
45
|
-
commands = [["ty", "check", "--output-format", "concise", "--error-on-warning", "--python-version", "3.14", "."]]
|
|
46
|
-
|
|
47
|
-
[env.docs]
|
|
48
|
-
description = "build documentation"
|
|
49
|
-
dependency_groups = ["docs"]
|
|
50
|
-
commands = [
|
|
51
|
-
[
|
|
52
|
-
"sphinx-build",
|
|
53
|
-
"-d",
|
|
54
|
-
"{env_tmp_dir}{/}doctree",
|
|
55
|
-
"docs",
|
|
56
|
-
"--color",
|
|
57
|
-
"-b",
|
|
58
|
-
"html",
|
|
59
|
-
"-W",
|
|
60
|
-
{ replace = "posargs", extend = true, default = ["{work_dir}{/}docs_out"] },
|
|
61
|
-
],
|
|
62
|
-
]
|
|
63
|
-
|
|
64
|
-
[env.pkg_meta]
|
|
65
|
-
description = "check that the long description is valid"
|
|
66
|
-
skip_install = true
|
|
67
|
-
deps = ["check-wheel-contents>=0.6.3", "twine>=6.2", "uv>=0.10.4"]
|
|
68
|
-
commands = [
|
|
69
|
-
["uv", "build", "--sdist", "--wheel", "--out-dir", "{env_tmp_dir}", "."],
|
|
70
|
-
["twine", "check", "{env_tmp_dir}{/}*"],
|
|
71
|
-
["check-wheel-contents", "--no-config", "{env_tmp_dir}"],
|
|
72
|
-
]
|
|
73
|
-
|
|
74
|
-
[env.release]
|
|
75
|
-
description = "do a release, required posargs of the version number"
|
|
76
|
-
skip_install = true
|
|
77
|
-
dependency_groups = ["release"]
|
|
78
|
-
pass_env = ["GH_TOKEN"]
|
|
79
|
-
commands = [["python", "{tox_root}/tasks/release.py", "--version", "{posargs}"]]
|
|
80
|
-
|
|
81
|
-
[env.dev]
|
|
82
|
-
description = "generate a DEV environment"
|
|
83
|
-
package = "editable"
|
|
84
|
-
dependency_groups = ["test"]
|
|
85
|
-
commands = [["uv", "pip", "tree"], ["python", "-c", "import sys; print(sys.executable)"]]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_discovery-1.6.0 → python_discovery-1.6.2}/tests/py_info/test_py_info_exe_based_of.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|