python-discovery 1.6.1__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.1 → python_discovery-1.6.2}/.github/workflows/check.yaml +19 -17
  2. {python_discovery-1.6.1 → python_discovery-1.6.2}/.github/workflows/prepare-release.yaml +3 -2
  3. {python_discovery-1.6.1 → python_discovery-1.6.2}/.github/workflows/release.yaml +7 -6
  4. {python_discovery-1.6.1 → python_discovery-1.6.2}/.pre-commit-config.yaml +5 -4
  5. {python_discovery-1.6.1 → python_discovery-1.6.2}/.readthedocs.yaml +1 -1
  6. python_discovery-1.6.2/.readthedocs.yml +8 -0
  7. {python_discovery-1.6.1 → python_discovery-1.6.2}/PKG-INFO +4 -5
  8. {python_discovery-1.6.1 → python_discovery-1.6.2}/README.md +1 -1
  9. {python_discovery-1.6.1 → python_discovery-1.6.2}/docs/changelog.rst +11 -0
  10. {python_discovery-1.6.1 → python_discovery-1.6.2}/docs/explanation.rst +3 -2
  11. {python_discovery-1.6.1 → python_discovery-1.6.2}/docs/reference/environment-variables.rst +4 -0
  12. {python_discovery-1.6.1 → python_discovery-1.6.2}/pyproject.toml +11 -10
  13. {python_discovery-1.6.1 → python_discovery-1.6.2}/src/python_discovery/_cached_py_info.py +25 -6
  14. {python_discovery-1.6.1 → python_discovery-1.6.2}/src/python_discovery/_py_info_collect.py +1 -1
  15. python_discovery-1.6.2/tests/test_cache_recovery.py +74 -0
  16. {python_discovery-1.6.1 → python_discovery-1.6.2}/tests/test_cached_py_info.py +0 -11
  17. {python_discovery-1.6.1 → python_discovery-1.6.2}/tests/test_py_info_collect.py +2 -2
  18. {python_discovery-1.6.1 → python_discovery-1.6.2}/tests/test_py_info_extra.py +1 -1
  19. python_discovery-1.6.2/tests/test_query_timeout.py +88 -0
  20. {python_discovery-1.6.1 → python_discovery-1.6.2}/tests/windows/conftest.py +2 -2
  21. python_discovery-1.6.2/tox.toml +89 -0
  22. python_discovery-1.6.1/.readthedocs.yml +0 -8
  23. python_discovery-1.6.1/tox.toml +0 -85
  24. {python_discovery-1.6.1 → python_discovery-1.6.2}/.github/FUNDING.yaml +0 -0
  25. {python_discovery-1.6.1 → python_discovery-1.6.2}/.github/SECURITY.md +0 -0
  26. {python_discovery-1.6.1 → python_discovery-1.6.2}/.github/dependabot.yaml +0 -0
  27. {python_discovery-1.6.1 → python_discovery-1.6.2}/.github/release.yaml +0 -0
  28. {python_discovery-1.6.1 → python_discovery-1.6.2}/.gitignore +0 -0
  29. {python_discovery-1.6.1 → python_discovery-1.6.2}/CODE_OF_CONDUCT.md +0 -0
  30. {python_discovery-1.6.1 → python_discovery-1.6.2}/LICENSE +0 -0
  31. {python_discovery-1.6.1 → python_discovery-1.6.2}/docs/_static/custom.css +0 -0
  32. {python_discovery-1.6.1 → python_discovery-1.6.2}/docs/_static/logo.svg +0 -0
  33. {python_discovery-1.6.1 → python_discovery-1.6.2}/docs/changelog/template.jinja2 +0 -0
  34. {python_discovery-1.6.1 → python_discovery-1.6.2}/docs/conf.py +0 -0
  35. {python_discovery-1.6.1 → python_discovery-1.6.2}/docs/how-to/standalone-usage.rst +0 -0
  36. {python_discovery-1.6.1 → python_discovery-1.6.2}/docs/index.rst +0 -0
  37. {python_discovery-1.6.1 → python_discovery-1.6.2}/docs/reference/api.rst +0 -0
  38. {python_discovery-1.6.1 → python_discovery-1.6.2}/docs/tutorial/getting-started.rst +0 -0
  39. {python_discovery-1.6.1 → python_discovery-1.6.2}/src/python_discovery/__init__.py +0 -0
  40. {python_discovery-1.6.1 → python_discovery-1.6.2}/src/python_discovery/_cache.py +0 -0
  41. {python_discovery-1.6.1 → python_discovery-1.6.2}/src/python_discovery/_compat.py +0 -0
  42. {python_discovery-1.6.1 → python_discovery-1.6.2}/src/python_discovery/_discovery.py +0 -0
  43. {python_discovery-1.6.1 → python_discovery-1.6.2}/src/python_discovery/_py_info.py +0 -0
  44. {python_discovery-1.6.1 → python_discovery-1.6.2}/src/python_discovery/_py_spec.py +0 -0
  45. {python_discovery-1.6.1 → python_discovery-1.6.2}/src/python_discovery/_specifier.py +0 -0
  46. {python_discovery-1.6.1 → python_discovery-1.6.2}/src/python_discovery/_windows/__init__.py +0 -0
  47. {python_discovery-1.6.1 → python_discovery-1.6.2}/src/python_discovery/_windows/_pep514.py +0 -0
  48. {python_discovery-1.6.1 → python_discovery-1.6.2}/src/python_discovery/_windows/_propose.py +0 -0
  49. {python_discovery-1.6.1 → python_discovery-1.6.2}/src/python_discovery/py.typed +0 -0
  50. {python_discovery-1.6.1 → python_discovery-1.6.2}/tasks/old_target_check.py +0 -0
  51. {python_discovery-1.6.1 → python_discovery-1.6.2}/tasks/old_target_check.sh +0 -0
  52. {python_discovery-1.6.1 → python_discovery-1.6.2}/tasks/release.py +0 -0
  53. {python_discovery-1.6.1 → python_discovery-1.6.2}/tests/conftest.py +0 -0
  54. {python_discovery-1.6.1 → python_discovery-1.6.2}/tests/py_info/test_py_info.py +0 -0
  55. {python_discovery-1.6.1 → python_discovery-1.6.2}/tests/py_info/test_py_info_exe_based_of.py +0 -0
  56. {python_discovery-1.6.1 → python_discovery-1.6.2}/tests/test_cache.py +0 -0
  57. {python_discovery-1.6.1 → python_discovery-1.6.2}/tests/test_discovery.py +0 -0
  58. {python_discovery-1.6.1 → python_discovery-1.6.2}/tests/test_discovery_extra.py +0 -0
  59. {python_discovery-1.6.1 → python_discovery-1.6.2}/tests/test_iter_interpreters.py +0 -0
  60. {python_discovery-1.6.1 → python_discovery-1.6.2}/tests/test_py_spec.py +0 -0
  61. {python_discovery-1.6.1 → python_discovery-1.6.2}/tests/test_py_spec_extra.py +0 -0
  62. {python_discovery-1.6.1 → python_discovery-1.6.2}/tests/test_specifier.py +0 -0
  63. {python_discovery-1.6.1 → python_discovery-1.6.2}/tests/windows/test_windows.py +0 -0
  64. {python_discovery-1.6.1 → python_discovery-1.6.2}/tests/windows/test_windows_pep514.py +0 -0
  65. {python_discovery-1.6.1 → 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.29.4"
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.7"
23
+ rev: "v0.16.10"
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.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,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
@@ -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
@@ -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")
@@ -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)"]]