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.
Files changed (65) hide show
  1. {python_discovery-1.6.0 → python_discovery-1.6.2}/.github/workflows/check.yaml +19 -17
  2. {python_discovery-1.6.0 → python_discovery-1.6.2}/.github/workflows/prepare-release.yaml +3 -2
  3. {python_discovery-1.6.0 → python_discovery-1.6.2}/.github/workflows/release.yaml +7 -6
  4. {python_discovery-1.6.0 → python_discovery-1.6.2}/.pre-commit-config.yaml +6 -5
  5. {python_discovery-1.6.0 → python_discovery-1.6.2}/.readthedocs.yaml +1 -1
  6. python_discovery-1.6.2/.readthedocs.yml +8 -0
  7. {python_discovery-1.6.0 → python_discovery-1.6.2}/PKG-INFO +4 -5
  8. {python_discovery-1.6.0 → python_discovery-1.6.2}/README.md +1 -1
  9. {python_discovery-1.6.0 → python_discovery-1.6.2}/docs/changelog.rst +26 -0
  10. {python_discovery-1.6.0 → python_discovery-1.6.2}/docs/explanation.rst +38 -3
  11. {python_discovery-1.6.0 → python_discovery-1.6.2}/docs/how-to/standalone-usage.rst +10 -2
  12. {python_discovery-1.6.0 → python_discovery-1.6.2}/docs/reference/environment-variables.rst +4 -0
  13. {python_discovery-1.6.0 → python_discovery-1.6.2}/docs/tutorial/getting-started.rst +7 -0
  14. {python_discovery-1.6.0 → python_discovery-1.6.2}/pyproject.toml +12 -10
  15. {python_discovery-1.6.0 → python_discovery-1.6.2}/src/python_discovery/_cached_py_info.py +25 -6
  16. {python_discovery-1.6.0 → python_discovery-1.6.2}/src/python_discovery/_discovery.py +6 -1
  17. {python_discovery-1.6.0 → python_discovery-1.6.2}/src/python_discovery/_py_info_collect.py +1 -1
  18. python_discovery-1.6.2/tests/test_cache_recovery.py +74 -0
  19. {python_discovery-1.6.0 → python_discovery-1.6.2}/tests/test_cached_py_info.py +0 -11
  20. {python_discovery-1.6.0 → python_discovery-1.6.2}/tests/test_discovery.py +32 -0
  21. {python_discovery-1.6.0 → python_discovery-1.6.2}/tests/test_py_info_collect.py +2 -2
  22. {python_discovery-1.6.0 → python_discovery-1.6.2}/tests/test_py_info_extra.py +1 -1
  23. python_discovery-1.6.2/tests/test_query_timeout.py +88 -0
  24. {python_discovery-1.6.0 → python_discovery-1.6.2}/tests/windows/conftest.py +2 -2
  25. python_discovery-1.6.2/tox.toml +89 -0
  26. python_discovery-1.6.0/.readthedocs.yml +0 -8
  27. python_discovery-1.6.0/tox.toml +0 -85
  28. {python_discovery-1.6.0 → python_discovery-1.6.2}/.github/FUNDING.yaml +0 -0
  29. {python_discovery-1.6.0 → python_discovery-1.6.2}/.github/SECURITY.md +0 -0
  30. {python_discovery-1.6.0 → python_discovery-1.6.2}/.github/dependabot.yaml +0 -0
  31. {python_discovery-1.6.0 → python_discovery-1.6.2}/.github/release.yaml +0 -0
  32. {python_discovery-1.6.0 → python_discovery-1.6.2}/.gitignore +0 -0
  33. {python_discovery-1.6.0 → python_discovery-1.6.2}/CODE_OF_CONDUCT.md +0 -0
  34. {python_discovery-1.6.0 → python_discovery-1.6.2}/LICENSE +0 -0
  35. {python_discovery-1.6.0 → python_discovery-1.6.2}/docs/_static/custom.css +0 -0
  36. {python_discovery-1.6.0 → python_discovery-1.6.2}/docs/_static/logo.svg +0 -0
  37. {python_discovery-1.6.0 → python_discovery-1.6.2}/docs/changelog/template.jinja2 +0 -0
  38. {python_discovery-1.6.0 → python_discovery-1.6.2}/docs/conf.py +0 -0
  39. {python_discovery-1.6.0 → python_discovery-1.6.2}/docs/index.rst +0 -0
  40. {python_discovery-1.6.0 → python_discovery-1.6.2}/docs/reference/api.rst +0 -0
  41. {python_discovery-1.6.0 → python_discovery-1.6.2}/src/python_discovery/__init__.py +0 -0
  42. {python_discovery-1.6.0 → python_discovery-1.6.2}/src/python_discovery/_cache.py +0 -0
  43. {python_discovery-1.6.0 → python_discovery-1.6.2}/src/python_discovery/_compat.py +0 -0
  44. {python_discovery-1.6.0 → python_discovery-1.6.2}/src/python_discovery/_py_info.py +0 -0
  45. {python_discovery-1.6.0 → python_discovery-1.6.2}/src/python_discovery/_py_spec.py +0 -0
  46. {python_discovery-1.6.0 → python_discovery-1.6.2}/src/python_discovery/_specifier.py +0 -0
  47. {python_discovery-1.6.0 → python_discovery-1.6.2}/src/python_discovery/_windows/__init__.py +0 -0
  48. {python_discovery-1.6.0 → python_discovery-1.6.2}/src/python_discovery/_windows/_pep514.py +0 -0
  49. {python_discovery-1.6.0 → python_discovery-1.6.2}/src/python_discovery/_windows/_propose.py +0 -0
  50. {python_discovery-1.6.0 → python_discovery-1.6.2}/src/python_discovery/py.typed +0 -0
  51. {python_discovery-1.6.0 → python_discovery-1.6.2}/tasks/old_target_check.py +0 -0
  52. {python_discovery-1.6.0 → python_discovery-1.6.2}/tasks/old_target_check.sh +0 -0
  53. {python_discovery-1.6.0 → python_discovery-1.6.2}/tasks/release.py +0 -0
  54. {python_discovery-1.6.0 → python_discovery-1.6.2}/tests/conftest.py +0 -0
  55. {python_discovery-1.6.0 → python_discovery-1.6.2}/tests/py_info/test_py_info.py +0 -0
  56. {python_discovery-1.6.0 → python_discovery-1.6.2}/tests/py_info/test_py_info_exe_based_of.py +0 -0
  57. {python_discovery-1.6.0 → python_discovery-1.6.2}/tests/test_cache.py +0 -0
  58. {python_discovery-1.6.0 → python_discovery-1.6.2}/tests/test_discovery_extra.py +0 -0
  59. {python_discovery-1.6.0 → python_discovery-1.6.2}/tests/test_iter_interpreters.py +0 -0
  60. {python_discovery-1.6.0 → python_discovery-1.6.2}/tests/test_py_spec.py +0 -0
  61. {python_discovery-1.6.0 → python_discovery-1.6.2}/tests/test_py_spec_extra.py +0 -0
  62. {python_discovery-1.6.0 → python_discovery-1.6.2}/tests/test_specifier.py +0 -0
  63. {python_discovery-1.6.0 → python_discovery-1.6.2}/tests/windows/test_windows.py +0 -0
  64. {python_discovery-1.6.0 → python_discovery-1.6.2}/tests/windows/test_windows_pep514.py +0 -0
  65. {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-latest
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
- - "3.8"
31
- - type-3.8
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@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
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.14 tox --with tox-uv
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.14'
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-latest
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@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
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
- - 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
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@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
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.14 tox@latest --with tox-uv
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-latest
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@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
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.14 --python-preference only-managed --sdist --wheel . --out-dir dist
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-latest
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.0
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.28.1"
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.4"
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.29.0
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.14 --managed-python
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"
@@ -0,0 +1,8 @@
1
+ version: 2
2
+ build:
3
+ os: ubuntu-22.04
4
+ tools: {}
5
+ commands:
6
+ - curl -LsSf https://astral.sh/uv/install.sh | sh
7
+ - ~/.local/bin/uv tool install tox --with tox-uv -p 3.15 --managed-python
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.0
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.8
50
- Requires-Dist: filelock>=3.15.4
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.8+ and discovers interpreters as old as Python 3.6; anything older is skipped with a
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.8+ and discovers interpreters as old as Python 3.6; anything older is skipped with a
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 ``system_executable`` (falling back to ``executable``).
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.8+ (``requires-python``), while discovery
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
- +system_executable: str
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.system_executable # The underlying system interpreter (outside any venv).
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.8"
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.15.4",
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.5.4",
57
+ "coverage>=7.6.1",
59
58
  "pytest>=8.3.5",
60
- "pytest-mock>=3.14",
61
- "setuptools>=75.1",
62
- "vermin>=1.6",
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.1.46",
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
- except (KeyError, TypeError):
156
- py_info_store.remove()
157
- return None
158
- if (sys_exe := py_info.system_executable) is not None and not Path(sys_exe).exists():
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 = float(env.get("PY_DISCOVERY_TIMEOUT", "15"))
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
- for entry in map(Path, path.split(os.pathsep)):
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[: -len(".exe")] if name.endswith(".exe") else 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"] # type: ignore[return-value]
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 # type: ignore[return-value]
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}" ] ]
@@ -1,8 +0,0 @@
1
- version: 2
2
- build:
3
- os: ubuntu-22.04
4
- tools:
5
- python: "3.12"
6
- commands:
7
- - pip install tox
8
- - tox r -e docs -- "${READTHEDOCS_OUTPUT}"/html
@@ -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)"]]