python-discovery 1.6.1__tar.gz → 1.6.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. {python_discovery-1.6.1 → python_discovery-1.6.3}/.github/workflows/check.yaml +19 -17
  2. {python_discovery-1.6.1 → python_discovery-1.6.3}/.github/workflows/prepare-release.yaml +3 -2
  3. {python_discovery-1.6.1 → python_discovery-1.6.3}/.github/workflows/release.yaml +7 -6
  4. {python_discovery-1.6.1 → python_discovery-1.6.3}/.pre-commit-config.yaml +5 -4
  5. {python_discovery-1.6.1 → python_discovery-1.6.3}/.readthedocs.yaml +1 -1
  6. python_discovery-1.6.3/.readthedocs.yml +8 -0
  7. {python_discovery-1.6.1 → python_discovery-1.6.3}/PKG-INFO +4 -5
  8. {python_discovery-1.6.1 → python_discovery-1.6.3}/README.md +1 -1
  9. python_discovery-1.6.3/docs/changelog/147.bugfix.rst +2 -0
  10. python_discovery-1.6.3/docs/changelog/148.bugfix.rst +2 -0
  11. {python_discovery-1.6.1 → python_discovery-1.6.3}/docs/changelog.rst +11 -0
  12. {python_discovery-1.6.1 → python_discovery-1.6.3}/docs/explanation.rst +3 -2
  13. {python_discovery-1.6.1 → python_discovery-1.6.3}/docs/reference/environment-variables.rst +4 -0
  14. {python_discovery-1.6.1 → python_discovery-1.6.3}/pyproject.toml +11 -10
  15. {python_discovery-1.6.1 → python_discovery-1.6.3}/src/python_discovery/_cached_py_info.py +58 -24
  16. {python_discovery-1.6.1 → python_discovery-1.6.3}/src/python_discovery/_py_info.py +32 -1
  17. {python_discovery-1.6.1 → python_discovery-1.6.3}/src/python_discovery/_py_info_collect.py +1 -1
  18. {python_discovery-1.6.1 → python_discovery-1.6.3}/src/python_discovery/_py_spec.py +3 -0
  19. {python_discovery-1.6.1 → python_discovery-1.6.3}/src/python_discovery/_windows/_pep514.py +4 -1
  20. {python_discovery-1.6.1 → python_discovery-1.6.3}/src/python_discovery/_windows/_propose.py +8 -2
  21. python_discovery-1.6.3/tests/test_cache_recovery.py +74 -0
  22. {python_discovery-1.6.1 → python_discovery-1.6.3}/tests/test_cached_py_info.py +67 -13
  23. {python_discovery-1.6.1 → python_discovery-1.6.3}/tests/test_discovery.py +3 -2
  24. {python_discovery-1.6.1 → python_discovery-1.6.3}/tests/test_py_info_collect.py +2 -2
  25. {python_discovery-1.6.1 → python_discovery-1.6.3}/tests/test_py_info_extra.py +1 -1
  26. {python_discovery-1.6.1 → python_discovery-1.6.3}/tests/test_py_spec.py +2 -2
  27. python_discovery-1.6.3/tests/test_query_timeout.py +88 -0
  28. {python_discovery-1.6.1 → python_discovery-1.6.3}/tests/windows/conftest.py +3 -3
  29. {python_discovery-1.6.1 → python_discovery-1.6.3}/tests/windows/test_windows_pep514.py +75 -0
  30. python_discovery-1.6.3/tox.toml +89 -0
  31. python_discovery-1.6.1/.readthedocs.yml +0 -8
  32. python_discovery-1.6.1/tox.toml +0 -85
  33. {python_discovery-1.6.1 → python_discovery-1.6.3}/.github/FUNDING.yaml +0 -0
  34. {python_discovery-1.6.1 → python_discovery-1.6.3}/.github/SECURITY.md +0 -0
  35. {python_discovery-1.6.1 → python_discovery-1.6.3}/.github/dependabot.yaml +0 -0
  36. {python_discovery-1.6.1 → python_discovery-1.6.3}/.github/release.yaml +0 -0
  37. {python_discovery-1.6.1 → python_discovery-1.6.3}/.gitignore +0 -0
  38. {python_discovery-1.6.1 → python_discovery-1.6.3}/CODE_OF_CONDUCT.md +0 -0
  39. {python_discovery-1.6.1 → python_discovery-1.6.3}/LICENSE +0 -0
  40. {python_discovery-1.6.1 → python_discovery-1.6.3}/docs/_static/custom.css +0 -0
  41. {python_discovery-1.6.1 → python_discovery-1.6.3}/docs/_static/logo.svg +0 -0
  42. {python_discovery-1.6.1 → python_discovery-1.6.3}/docs/changelog/template.jinja2 +0 -0
  43. {python_discovery-1.6.1 → python_discovery-1.6.3}/docs/conf.py +0 -0
  44. {python_discovery-1.6.1 → python_discovery-1.6.3}/docs/how-to/standalone-usage.rst +0 -0
  45. {python_discovery-1.6.1 → python_discovery-1.6.3}/docs/index.rst +0 -0
  46. {python_discovery-1.6.1 → python_discovery-1.6.3}/docs/reference/api.rst +0 -0
  47. {python_discovery-1.6.1 → python_discovery-1.6.3}/docs/tutorial/getting-started.rst +0 -0
  48. {python_discovery-1.6.1 → python_discovery-1.6.3}/src/python_discovery/__init__.py +0 -0
  49. {python_discovery-1.6.1 → python_discovery-1.6.3}/src/python_discovery/_cache.py +0 -0
  50. {python_discovery-1.6.1 → python_discovery-1.6.3}/src/python_discovery/_compat.py +0 -0
  51. {python_discovery-1.6.1 → python_discovery-1.6.3}/src/python_discovery/_discovery.py +0 -0
  52. {python_discovery-1.6.1 → python_discovery-1.6.3}/src/python_discovery/_specifier.py +0 -0
  53. {python_discovery-1.6.1 → python_discovery-1.6.3}/src/python_discovery/_windows/__init__.py +0 -0
  54. {python_discovery-1.6.1 → python_discovery-1.6.3}/src/python_discovery/py.typed +0 -0
  55. {python_discovery-1.6.1 → python_discovery-1.6.3}/tasks/old_target_check.py +0 -0
  56. {python_discovery-1.6.1 → python_discovery-1.6.3}/tasks/old_target_check.sh +0 -0
  57. {python_discovery-1.6.1 → python_discovery-1.6.3}/tasks/release.py +0 -0
  58. {python_discovery-1.6.1 → python_discovery-1.6.3}/tests/conftest.py +0 -0
  59. {python_discovery-1.6.1 → python_discovery-1.6.3}/tests/py_info/test_py_info.py +0 -0
  60. {python_discovery-1.6.1 → python_discovery-1.6.3}/tests/py_info/test_py_info_exe_based_of.py +0 -0
  61. {python_discovery-1.6.1 → python_discovery-1.6.3}/tests/test_cache.py +0 -0
  62. {python_discovery-1.6.1 → python_discovery-1.6.3}/tests/test_discovery_extra.py +0 -0
  63. {python_discovery-1.6.1 → python_discovery-1.6.3}/tests/test_iter_interpreters.py +0 -0
  64. {python_discovery-1.6.1 → python_discovery-1.6.3}/tests/test_py_spec_extra.py +0 -0
  65. {python_discovery-1.6.1 → python_discovery-1.6.3}/tests/test_specifier.py +0 -0
  66. {python_discovery-1.6.1 → python_discovery-1.6.3}/tests/windows/test_windows.py +0 -0
  67. {python_discovery-1.6.1 → python_discovery-1.6.3}/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@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
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@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
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.29.4"
19
+ rev: "v2.30.3"
19
20
  hooks:
20
21
  - id: pyproject-fmt
21
22
  - repo: https://github.com/astral-sh/ruff-pre-commit
22
- rev: "v0.16.7"
23
+ rev: "v0.17.0"
23
24
  hooks:
24
25
  - id: ruff-format
25
26
  - id: ruff
@@ -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.1
3
+ Version: 1.6.3
4
4
  Summary: Python interpreter discovery
5
5
  Project-URL: Changelog, https://github.com/tox-dev/python-discovery/releases
6
6
  Project-URL: Documentation, https://python-discovery.readthedocs.io
@@ -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
@@ -0,0 +1,2 @@
1
+ Treat specs with dot-only version components, such as ``python.``, as literal executable paths instead of raising
2
+ ``IndexError`` - by :user:`harbinresearcher`.
@@ -0,0 +1,2 @@
1
+ Pass PEP 514 ``ExecutableArguments`` to registered Windows interpreters when querying them, so a registration that
2
+ points at a launcher such as ``py.exe -3.11`` reports the interpreter it selects - by :user:`blouirts123`.
@@ -6,6 +6,17 @@
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
+
9
20
  ********************
10
21
  v1.6.1 (2026-09-18)
11
22
  ********************
@@ -275,12 +275,13 @@ If your system consistently hits timeouts, you can customize the timeout via the
275
275
 
276
276
  The timeout applies to each individual interpreter being queried. If you set a value that is too low,
277
277
  legitimate interpreters may be skipped; if too high, the discovery process may take longer to fail
278
- 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.
279
280
 
280
281
  Supported Python versions
281
282
  -------------------------
282
283
 
283
- 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
284
285
  reaches down to Python 3.6 - old enough to cover RHEL 8's system Python.
285
286
 
286
287
  python-discovery skips candidates below 3.6 with one warning naming the executable, the version
@@ -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
@@ -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
@@ -30,17 +30,21 @@ if TYPE_CHECKING:
30
30
  from ._cache import ContentStore, PyInfoCache
31
31
 
32
32
  class CacheEntryMeta(TypedDict):
33
- """Identity of a cached interrogation result: the queried binary and the script that produced it."""
33
+ """Identity of a cached interrogation result: the queried binary, its arguments and the script."""
34
34
 
35
35
  st_mtime: float
36
36
  path: str
37
37
  hash: str | None
38
+ arguments: str | None
38
39
 
39
40
 
40
- _CACHE: OrderedDict[Path, PythonInfo | Exception] = OrderedDict()
41
- _CACHE[Path(sys.executable)] = PythonInfo()
41
+ _CACHE: OrderedDict[tuple[Path, str | None], PythonInfo | Exception] = OrderedDict()
42
+ _CACHE[Path(sys.executable), None] = PythonInfo()
42
43
  _LOGGER: Final[logging.Logger] = logging.getLogger(__name__)
43
44
  _PY_INFO_SCRIPT: Final[Path] = Path(__file__).resolve().parent / "_py_info_collect.py"
45
+ _DEFAULT_QUERY_TIMEOUT: Final[float] = 15.0
46
+ # poll(2) limits milliseconds to a signed C int.
47
+ _MAX_QUERY_TIMEOUT: Final[float] = (2**31 - 1) / 1000
44
48
 
45
49
 
46
50
  class _UnsupportedInterpreterError(RuntimeError):
@@ -55,9 +59,10 @@ def from_exe( # ruff:ignore[too-many-arguments]
55
59
  *,
56
60
  raise_on_error: bool = True,
57
61
  ignore_cache: bool = False,
62
+ executable_args: str | None = None,
58
63
  ) -> PythonInfo | None:
59
64
  env = os.environ if env is None else env
60
- result = _get_from_cache(cls, cache, exe, env, ignore_cache=ignore_cache)
65
+ result = _get_from_cache(cls, cache, exe, env, ignore_cache=ignore_cache, executable_args=executable_args)
61
66
  if isinstance(result, Exception):
62
67
  if raise_on_error:
63
68
  raise result
@@ -66,31 +71,34 @@ def from_exe( # ruff:ignore[too-many-arguments]
66
71
  return result
67
72
 
68
73
 
69
- def _get_from_cache(
74
+ def _get_from_cache( # ruff:ignore[too-many-arguments]
70
75
  cls: type[PythonInfo],
71
76
  cache: PyInfoCache | None,
72
77
  exe: str,
73
78
  env: Mapping[str, str],
74
79
  *,
75
80
  ignore_cache: bool = True,
81
+ executable_args: str | None = None,
76
82
  ) -> PythonInfo | Exception:
77
- exe_path = Path(exe)
78
- if not ignore_cache and exe_path in _CACHE:
79
- result = _CACHE[exe_path]
83
+ key = Path(exe), executable_args
84
+ if not ignore_cache and key in _CACHE:
85
+ result = _CACHE[key]
80
86
  else:
81
- py_info = _get_via_file_cache(cls, cache, exe_path, exe, env)
82
- result = _CACHE[exe_path] = py_info
83
- if isinstance(result, PythonInfo):
87
+ result = _CACHE[key] = _get_via_file_cache(cls, cache, key[0], exe, env, executable_args=executable_args)
88
+ # with launcher arguments the queried binary is not the interpreter, so keep the one the query reported
89
+ if isinstance(result, PythonInfo) and executable_args is None:
84
90
  result.executable = exe
85
91
  return result
86
92
 
87
93
 
88
- def _get_via_file_cache(
94
+ def _get_via_file_cache( # ruff:ignore[too-many-arguments]
89
95
  cls: type[PythonInfo],
90
96
  cache: PyInfoCache | None,
91
97
  path: Path,
92
98
  exe: str,
93
99
  env: Mapping[str, str],
100
+ *,
101
+ executable_args: str | None = None,
94
102
  ) -> PythonInfo | Exception:
95
103
  path_text = str(path)
96
104
  try:
@@ -104,17 +112,22 @@ def _get_via_file_cache(
104
112
 
105
113
  resolved_cache = cache if cache is not None else NoOpCache()
106
114
  py_info_store = resolved_cache.py_info(path)
107
- entry_meta: CacheEntryMeta = {"st_mtime": path_modified, "path": path_text, "hash": py_info_hash}
115
+ entry_meta: CacheEntryMeta = {
116
+ "st_mtime": path_modified,
117
+ "path": path_text,
118
+ "hash": py_info_hash,
119
+ "arguments": executable_args,
120
+ }
108
121
  with py_info_store.locked():
109
122
  cached = _read_cache_entry(cls, py_info_store, entry_meta)
110
123
  if isinstance(cached, _UnsupportedInterpreterError):
111
124
  return cached
112
125
  py_info = cached
113
126
  if py_info is None:
114
- failure, py_info = _run_subprocess(cls, exe, env)
127
+ failure, py_info = _run_subprocess(cls, exe, env, executable_args)
115
128
  if failure is not None and not isinstance(failure, _UnsupportedInterpreterError):
116
129
  _LOGGER.debug("first subprocess attempt failed for %s (%s), retrying", exe, failure)
117
- failure, py_info = _run_subprocess(cls, exe, env)
130
+ failure, py_info = _run_subprocess(cls, exe, env, executable_args)
118
131
  if isinstance(failure, _UnsupportedInterpreterError):
119
132
  _LOGGER.warning("%s", failure) # the verdict is permanent, warn once and remember it
120
133
  py_info_store.write({**entry_meta, "unsupported": str(failure)})
@@ -136,7 +149,7 @@ def _read_cache_entry(
136
149
  ) -> PythonInfo | _UnsupportedInterpreterError | None:
137
150
  if not py_info_store.exists() or (data := py_info_store.read()) is None:
138
151
  return None
139
- if all(data.get(key) == value for key, value in entry_meta.items()):
152
+ if isinstance(data, dict) and all(data.get(key) == value for key, value in entry_meta.items()):
140
153
  if isinstance(unsupported := data.get("unsupported"), str):
141
154
  return _UnsupportedInterpreterError(unsupported)
142
155
  if isinstance(content := data.get("content"), dict):
@@ -152,10 +165,10 @@ def _load_cached_py_info(
152
165
  ) -> PythonInfo | None:
153
166
  try:
154
167
  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():
168
+ stale = (sys_exe := py_info.system_executable) is not None and not Path(sys_exe).is_file()
169
+ except (AttributeError, KeyError, OSError, TypeError):
170
+ stale = True
171
+ if stale:
159
172
  py_info_store.remove()
160
173
  return None
161
174
  return py_info
@@ -211,16 +224,20 @@ def _run_subprocess(
211
224
  cls: type[PythonInfo],
212
225
  exe: str,
213
226
  env: Mapping[str, str],
227
+ executable_args: str | None = None,
214
228
  ) -> tuple[Exception | None, PythonInfo | None]:
215
229
  start_cookie = gen_cookie()
216
230
  end_cookie = gen_cookie()
217
- timeout = float(env.get("PY_DISCOVERY_TIMEOUT", "15"))
231
+ timeout = _query_timeout(env.get("PY_DISCOVERY_TIMEOUT"))
218
232
  with _resolve_py_info_script() as py_info_script:
219
- cmd = [exe, str(py_info_script), start_cookie, end_cookie]
233
+ cmd: list[str] | str = [exe, str(py_info_script), start_cookie, end_cookie]
234
+ if executable_args is not None:
235
+ # PEP 514 ExecutableArguments is raw command line text, placed before the script per the PEP
236
+ cmd = f"{subprocess.list2cmdline(cmd[:1])} {executable_args} {subprocess.list2cmdline(cmd[1:])}"
220
237
  env = dict(env)
221
238
  env.pop("__PYVENV_LAUNCHER__", None)
222
239
  env["PYTHONUTF8"] = "1"
223
- _LOGGER.debug("get interpreter info via cmd: %s", LogCmd(cmd))
240
+ _LOGGER.debug("get interpreter info via cmd: %s", cmd if isinstance(cmd, str) else LogCmd(cmd))
224
241
  try:
225
242
  process = Popen( # ruff:ignore[subprocess-without-shell-equals-true]
226
243
  cmd,
@@ -245,7 +262,8 @@ def _run_subprocess(
245
262
  out, raw_out, out_starts, out_ends = _extract_between_cookies(out, start_cookie, end_cookie)
246
263
  try:
247
264
  result = cls.from_json(out)
248
- result.executable = exe
265
+ if executable_args is None:
266
+ result.executable = exe
249
267
  except json.JSONDecodeError as exc:
250
268
  _LOGGER.warning(
251
269
  "subprocess %s returned invalid JSON; raw stdout %d chars, start cookie %s, end cookie %s, "
@@ -264,6 +282,22 @@ def _run_subprocess(
264
282
  return None, result
265
283
 
266
284
 
285
+ @lru_cache(maxsize=128)
286
+ def _query_timeout(raw: str | None) -> float | None:
287
+ if raw is None:
288
+ return _DEFAULT_QUERY_TIMEOUT
289
+ try:
290
+ timeout = float(raw)
291
+ except ValueError:
292
+ timeout = 0.0
293
+ if timeout > 0:
294
+ return timeout if timeout <= _MAX_QUERY_TIMEOUT else None
295
+ _LOGGER.warning(
296
+ "ignoring PY_DISCOVERY_TIMEOUT=%r, not a positive number of seconds; using %s", raw, _DEFAULT_QUERY_TIMEOUT
297
+ )
298
+ return _DEFAULT_QUERY_TIMEOUT
299
+
300
+
267
301
  def _query_failure(exe: str, out: str, err: str | None, code: int | None) -> RuntimeError:
268
302
  # the gate's exit code and stderr marker must both be present: either alone can collide, an errno of 79 from a
269
303
  # failed exec or a shim that echoes the marker phrase, but only the gate produces the combination
@@ -378,10 +378,41 @@ class PythonInfo: # ruff:ignore[too-many-public-methods]
378
378
  :param resolve_to_host: resolve through virtualenv layers to the system interpreter.
379
379
  :param env: environment mapping; defaults to :data:`os.environ`.
380
380
  """
381
+ return cls._from_exe_with_args(
382
+ exe,
383
+ cache,
384
+ executable_args=None,
385
+ raise_on_error=raise_on_error,
386
+ ignore_cache=ignore_cache,
387
+ resolve_to_host=resolve_to_host,
388
+ env=env,
389
+ )
390
+
391
+ @classmethod
392
+ def _from_exe_with_args( # ruff:ignore[too-many-arguments]
393
+ cls,
394
+ exe: str,
395
+ cache: PyInfoCache | None = None,
396
+ *,
397
+ executable_args: str | None,
398
+ raise_on_error: bool = True,
399
+ ignore_cache: bool = False,
400
+ resolve_to_host: bool = True,
401
+ env: Mapping[str, str] | None = None,
402
+ ) -> PythonInfo | None:
403
+ """Interrogate an executable with optional platform-specific leading arguments."""
381
404
  from ._cached_py_info import from_exe # ruff:ignore[import-outside-top-level]
382
405
 
383
406
  env = os.environ if env is None else env
384
- proposed = from_exe(cls, cache, exe, env=env, raise_on_error=raise_on_error, ignore_cache=ignore_cache)
407
+ proposed = from_exe(
408
+ cls,
409
+ cache,
410
+ exe,
411
+ env=env,
412
+ raise_on_error=raise_on_error,
413
+ ignore_cache=ignore_cache,
414
+ executable_args=executable_args,
415
+ )
385
416
 
386
417
  if isinstance(proposed, PythonInfo) and resolve_to_host:
387
418
  try:
@@ -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")
@@ -50,6 +50,9 @@ def _int_or_none(val: str | None) -> int | None:
50
50
 
51
51
  def _parse_version_parts(version: str) -> tuple[int | None, int | None, int | None]:
52
52
  versions = tuple(int(i) for i in version.split(".") if i)
53
+ if not versions:
54
+ msg = "version must contain a numeric component"
55
+ raise ValueError(msg)
53
56
  if len(versions) > _MAX_VERSION_PARTS:
54
57
  msg = "too many version parts"
55
58
  raise ValueError(msg)
@@ -125,7 +125,10 @@ def load_exe(hive_name: str, company: str, company_key: Any, tag: str) -> tuple[
125
125
  try:
126
126
  with winreg.OpenKeyEx(company_key, rf"{tag}\InstallPath") as ip_key, ip_key: # ty: ignore[unresolved-attribute]
127
127
  if (exe := _resolve_exe(ip_key, key_path)) is not None and os.path.exists(exe):
128
- return exe, get_value(ip_key, "ExecutableArguments")
128
+ if (args := get_value(ip_key, "ExecutableArguments")) is None or isinstance(args, str):
129
+ return exe, args if args and args.strip() else None
130
+ msg(f"{key_path}/ExecutableArguments", f"expected a string, got {type(args).__name__}")
131
+ return None
129
132
  msg(key_path, f"could not load exe with value {exe}")
130
133
  except OSError:
131
134
  msg(f"{key_path}/InstallPath", "missing")
@@ -36,13 +36,19 @@ def propose_interpreters(
36
36
  reverse=True,
37
37
  )
38
38
 
39
- for name, major, minor, arch, threaded, exe, _ in existing:
39
+ for name, major, minor, arch, threaded, exe, executable_args in existing:
40
40
  implementation = _IMPLEMENTATION_BY_ORG.get(name, name)
41
41
 
42
42
  skip_pre_filter = implementation.lower() != "cpython"
43
43
  registry_spec = PythonSpec("", implementation, major, minor, None, arch, exe, free_threaded=threaded)
44
44
  if skip_pre_filter or registry_spec.satisfies(spec):
45
- interpreter = Pep514PythonInfo.from_exe(exe, cache, env=env, raise_on_error=False)
45
+ interpreter = Pep514PythonInfo._from_exe_with_args( # ruff:ignore[private-member-access]
46
+ exe,
47
+ cache,
48
+ env=env,
49
+ executable_args=executable_args,
50
+ raise_on_error=False,
51
+ )
46
52
  if interpreter is not None and interpreter.satisfies(spec, impl_must_match=True):
47
53
  yield interpreter
48
54