python-discovery 1.3.0__tar.gz → 1.3.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 (60) hide show
  1. python_discovery-1.3.2/.github/workflows/prepare-release.yaml +100 -0
  2. {python_discovery-1.3.0 → python_discovery-1.3.2}/.pre-commit-config.yaml +3 -3
  3. {python_discovery-1.3.0 → python_discovery-1.3.2}/PKG-INFO +3 -1
  4. python_discovery-1.3.2/docs/changelog/template.jinja2 +38 -0
  5. python_discovery-1.3.2/docs/changelog.rst +132 -0
  6. {python_discovery-1.3.0 → python_discovery-1.3.2}/docs/conf.py +7 -1
  7. {python_discovery-1.3.0 → python_discovery-1.3.2}/docs/index.rst +6 -0
  8. {python_discovery-1.3.0 → python_discovery-1.3.2}/pyproject.toml +34 -0
  9. {python_discovery-1.3.0 → python_discovery-1.3.2}/src/python_discovery/_discovery.py +9 -7
  10. {python_discovery-1.3.0 → python_discovery-1.3.2}/src/python_discovery/_py_info.py +29 -31
  11. {python_discovery-1.3.0 → python_discovery-1.3.2}/src/python_discovery/_py_spec.py +5 -1
  12. {python_discovery-1.3.0 → python_discovery-1.3.2}/src/python_discovery/_windows/_pep514.py +11 -11
  13. python_discovery-1.3.2/tasks/release.py +173 -0
  14. {python_discovery-1.3.0 → python_discovery-1.3.2}/tests/test_py_info_extra.py +6 -0
  15. {python_discovery-1.3.0 → python_discovery-1.3.2}/tests/test_py_spec.py +14 -0
  16. {python_discovery-1.3.0 → python_discovery-1.3.2}/tox.toml +9 -2
  17. python_discovery-1.3.0/docs/changelog/59.bugfix.rst +0 -1
  18. python_discovery-1.3.0/docs/changelog/65.bugfix.rst +0 -3
  19. python_discovery-1.3.0/docs/changelog/65.feature.rst +0 -3
  20. {python_discovery-1.3.0 → python_discovery-1.3.2}/.github/FUNDING.yaml +0 -0
  21. {python_discovery-1.3.0 → python_discovery-1.3.2}/.github/SECURITY.md +0 -0
  22. {python_discovery-1.3.0 → python_discovery-1.3.2}/.github/dependabot.yaml +0 -0
  23. {python_discovery-1.3.0 → python_discovery-1.3.2}/.github/release.yaml +0 -0
  24. {python_discovery-1.3.0 → python_discovery-1.3.2}/.github/workflows/check.yaml +0 -0
  25. {python_discovery-1.3.0 → python_discovery-1.3.2}/.github/workflows/release.yaml +0 -0
  26. {python_discovery-1.3.0 → python_discovery-1.3.2}/.gitignore +0 -0
  27. {python_discovery-1.3.0 → python_discovery-1.3.2}/.readthedocs.yaml +0 -0
  28. {python_discovery-1.3.0 → python_discovery-1.3.2}/.readthedocs.yml +0 -0
  29. {python_discovery-1.3.0 → python_discovery-1.3.2}/CODE_OF_CONDUCT.md +0 -0
  30. {python_discovery-1.3.0 → python_discovery-1.3.2}/LICENSE +0 -0
  31. {python_discovery-1.3.0 → python_discovery-1.3.2}/README.md +0 -0
  32. {python_discovery-1.3.0 → python_discovery-1.3.2}/docs/_static/custom.css +0 -0
  33. {python_discovery-1.3.0 → python_discovery-1.3.2}/docs/_static/logo.svg +0 -0
  34. {python_discovery-1.3.0 → python_discovery-1.3.2}/docs/explanation.rst +0 -0
  35. {python_discovery-1.3.0 → python_discovery-1.3.2}/docs/how-to/standalone-usage.rst +0 -0
  36. {python_discovery-1.3.0 → python_discovery-1.3.2}/docs/reference/api.rst +0 -0
  37. {python_discovery-1.3.0 → python_discovery-1.3.2}/docs/reference/environment-variables.rst +0 -0
  38. {python_discovery-1.3.0 → python_discovery-1.3.2}/docs/tutorial/getting-started.rst +0 -0
  39. {python_discovery-1.3.0 → python_discovery-1.3.2}/src/python_discovery/__init__.py +0 -0
  40. {python_discovery-1.3.0 → python_discovery-1.3.2}/src/python_discovery/_cache.py +0 -0
  41. {python_discovery-1.3.0 → python_discovery-1.3.2}/src/python_discovery/_cached_py_info.py +0 -0
  42. {python_discovery-1.3.0 → python_discovery-1.3.2}/src/python_discovery/_compat.py +0 -0
  43. {python_discovery-1.3.0 → python_discovery-1.3.2}/src/python_discovery/_specifier.py +0 -0
  44. {python_discovery-1.3.0 → python_discovery-1.3.2}/src/python_discovery/_windows/__init__.py +0 -0
  45. {python_discovery-1.3.0 → python_discovery-1.3.2}/src/python_discovery/_windows/_propose.py +0 -0
  46. {python_discovery-1.3.0 → python_discovery-1.3.2}/src/python_discovery/py.typed +0 -0
  47. {python_discovery-1.3.0 → python_discovery-1.3.2}/tests/conftest.py +0 -0
  48. {python_discovery-1.3.0 → python_discovery-1.3.2}/tests/py_info/test_py_info.py +0 -0
  49. {python_discovery-1.3.0 → python_discovery-1.3.2}/tests/py_info/test_py_info_exe_based_of.py +0 -0
  50. {python_discovery-1.3.0 → python_discovery-1.3.2}/tests/test_cache.py +0 -0
  51. {python_discovery-1.3.0 → python_discovery-1.3.2}/tests/test_cached_py_info.py +0 -0
  52. {python_discovery-1.3.0 → python_discovery-1.3.2}/tests/test_discovery.py +0 -0
  53. {python_discovery-1.3.0 → python_discovery-1.3.2}/tests/test_discovery_extra.py +0 -0
  54. {python_discovery-1.3.0 → python_discovery-1.3.2}/tests/test_iter_interpreters.py +0 -0
  55. {python_discovery-1.3.0 → python_discovery-1.3.2}/tests/test_py_spec_extra.py +0 -0
  56. {python_discovery-1.3.0 → python_discovery-1.3.2}/tests/test_specifier.py +0 -0
  57. {python_discovery-1.3.0 → python_discovery-1.3.2}/tests/windows/conftest.py +0 -0
  58. {python_discovery-1.3.0 → python_discovery-1.3.2}/tests/windows/test_windows.py +0 -0
  59. {python_discovery-1.3.0 → python_discovery-1.3.2}/tests/windows/test_windows_pep514.py +0 -0
  60. {python_discovery-1.3.0 → python_discovery-1.3.2}/tests/windows/winreg_mock_values.py +0 -0
@@ -0,0 +1,100 @@
1
+ name: Prepare Release
2
+ on:
3
+ workflow_dispatch:
4
+ inputs:
5
+ bump:
6
+ description: "Version bump type"
7
+ required: true
8
+ type: choice
9
+ options:
10
+ - auto
11
+ - major
12
+ - minor
13
+ - patch
14
+ default: auto
15
+
16
+ permissions:
17
+ contents: read
18
+
19
+ env:
20
+ FORCE_COLOR: 1
21
+
22
+ jobs:
23
+ prepare-release:
24
+ runs-on: ubuntu-24.04
25
+ environment: release-auth
26
+ permissions:
27
+ contents: write
28
+ steps:
29
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
30
+ with:
31
+ fetch-depth: 0
32
+ token: ${{ secrets.RELEASE_PAT }}
33
+ persist-credentials: true
34
+
35
+ - name: Install the latest version of uv
36
+ uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
37
+ with:
38
+ enable-cache: true
39
+ cache-dependency-glob: "pyproject.toml"
40
+ github-token: ${{ secrets.GITHUB_TOKEN }}
41
+
42
+ - name: Configure git
43
+ run: |
44
+ git config user.name "${GITHUB_ACTOR}"
45
+ git config user.email "${GITHUB_ACTOR}@users.noreply.github.com"
46
+
47
+ - name: Calculate next version
48
+ id: version
49
+ env:
50
+ BUMP: ${{ inputs.bump }}
51
+ run: |
52
+ current=$(git describe --tags --abbrev=0 2>/dev/null || echo "0.0.0")
53
+ echo "Current version: $current"
54
+
55
+ IFS='.' read -r major minor patch <<< "$current"
56
+
57
+ bump_type="$BUMP"
58
+ if [ "$bump_type" = "auto" ]; then
59
+ feature_count=$(find docs/changelog -name "*.feature.rst" 2>/dev/null | wc -l)
60
+ breaking_count=$(find docs/changelog -name "*.breaking.rst" 2>/dev/null | wc -l)
61
+ if [ "$breaking_count" -gt 0 ]; then
62
+ bump_type="major"
63
+ echo "Auto-detected: major bump (found $breaking_count breaking changelog(s))"
64
+ elif [ "$feature_count" -gt 0 ]; then
65
+ bump_type="minor"
66
+ echo "Auto-detected: minor bump (found $feature_count feature changelog(s))"
67
+ else
68
+ bump_type="patch"
69
+ echo "Auto-detected: patch bump (no feature changelogs)"
70
+ fi
71
+ fi
72
+
73
+ case "$bump_type" in
74
+ major)
75
+ next="$((major + 1)).0.0"
76
+ ;;
77
+ minor)
78
+ next="$major.$((minor + 1)).0"
79
+ ;;
80
+ patch)
81
+ next="$major.$minor.$((patch + 1))"
82
+ ;;
83
+ esac
84
+
85
+ echo "Next version: $next"
86
+ echo "version=$next" >> $GITHUB_OUTPUT
87
+
88
+ - name: Install tox
89
+ run: uv tool install --python-preference only-managed --python 3.14 tox@latest --with tox-uv
90
+
91
+ - name: Run release process
92
+ run: tox run -e release -- ${STEPS_VERSION_OUTPUTS_VERSION}
93
+ env:
94
+ GH_TOKEN: ${{ secrets.RELEASE_PAT }}
95
+ STEPS_VERSION_OUTPUTS_VERSION: ${{ steps.version.outputs.version }}
96
+
97
+ - name: Display completion message
98
+ run: echo "Release ${STEPS_VERSION_OUTPUTS_VERSION} prepared and pushed successfully!"
99
+ env:
100
+ STEPS_VERSION_OUTPUTS_VERSION: ${{ steps.version.outputs.version }}
@@ -15,11 +15,11 @@ repos:
15
15
  - id: codespell
16
16
  additional_dependencies: ["tomli>=2.4"]
17
17
  - repo: https://github.com/tox-dev/pyproject-fmt
18
- rev: "v2.21.1"
18
+ rev: "v2.21.2"
19
19
  hooks:
20
20
  - id: pyproject-fmt
21
21
  - repo: https://github.com/astral-sh/ruff-pre-commit
22
- rev: "v0.15.12"
22
+ rev: "v0.15.14"
23
23
  hooks:
24
24
  - id: ruff-format
25
25
  - id: ruff
@@ -32,7 +32,7 @@ repos:
32
32
  - prettier@3.8.1
33
33
  - "@prettier/plugin-xml@3.4.2"
34
34
  - repo: https://github.com/zizmorcore/zizmor-pre-commit
35
- rev: v1.24.1
35
+ rev: v1.25.2
36
36
  hooks:
37
37
  - id: zizmor
38
38
  - repo: meta
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-discovery
3
- Version: 1.3.0
3
+ Version: 1.3.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
@@ -53,6 +53,8 @@ Requires-Dist: furo>=2025.12.19; extra == 'docs'
53
53
  Requires-Dist: sphinx-autodoc-typehints>=3.6.3; extra == 'docs'
54
54
  Requires-Dist: sphinx>=9.1; extra == 'docs'
55
55
  Requires-Dist: sphinxcontrib-mermaid>=2; extra == 'docs'
56
+ Requires-Dist: sphinxcontrib-towncrier>=0.4; extra == 'docs'
57
+ Requires-Dist: towncrier>=25.8; extra == 'docs'
56
58
  Provides-Extra: testing
57
59
  Requires-Dist: covdefaults>=2.3; extra == 'testing'
58
60
  Requires-Dist: coverage>=7.5.4; extra == 'testing'
@@ -0,0 +1,38 @@
1
+ {% set top_underline = "*" %}
2
+ {% set underline = "=" %}
3
+ {% if versiondata.name %}
4
+ {{ top_underline * ((versiondata.version + versiondata.date)|length + 5)}}
5
+ v{{ versiondata.version }} ({{ versiondata.date }})
6
+ {{ top_underline * ((versiondata.version + versiondata.date)|length + 5)}}
7
+ {% else %}
8
+ {{ top_underline * ((versiondata.version + versiondata.date)|length + 4)}}
9
+ {{ versiondata.version }} ({{ versiondata.date }})
10
+ {{ top_underline * ((versiondata.version + versiondata.date)|length + 4)}}
11
+ {% endif %}
12
+
13
+ {% for section, _ in sections.items() %}
14
+ {% if sections[section] %}
15
+ {% for category, val in definitions.items() if category in sections[section]%}
16
+ {{ definitions[category]['name'] }} - {{ versiondata.version }}
17
+ {{ underline * ((definitions[category]['name'] + versiondata.version)|length + 3)}}
18
+ {% if definitions[category]['showcontent'] %}
19
+ {% for text, values in sections[section][category].items() %}
20
+ - {{ text }} ({{ values|join(', ') }})
21
+ {% endfor %}
22
+
23
+ {% else %}
24
+ - {{ sections[section][category]['']|join(', ') }}
25
+
26
+ {% endif %}
27
+ {% if sections[section][category]|length == 0 %}
28
+ No significant changes.
29
+
30
+ {% else %}
31
+ {% endif %}
32
+ {% endfor %}
33
+ {% else %}
34
+ No significant changes.
35
+
36
+
37
+ {% endif %}
38
+ {% endfor %}
@@ -0,0 +1,132 @@
1
+ #################
2
+ Release History
3
+ #################
4
+
5
+ .. towncrier-draft-entries::
6
+
7
+ .. towncrier release notes start
8
+
9
+ ********************
10
+ v1.3.2 (2026-05-27)
11
+ ********************
12
+
13
+ No significant changes.
14
+
15
+
16
+ ********************
17
+ v1.3.1 (2026-05-12)
18
+ ********************
19
+
20
+ Bug fixes - 1.3.1
21
+ =================
22
+ - export normalize_isa and deprecate KNOWN_ARCHITECTURES - by :user:`rahuldevikar`. (:issue:`59`)
23
+ - discover uv-managed Pythons on Windows. Previously the glob assumed Unix layout (``<root>/<key>/bin/python``) and
24
+ silently found nothing on Windows, where uv places ``python.exe`` directly under the install root - by
25
+ :user:`gaborbernat`. (:issue:`65`)
26
+ - Canonicalize GraalVM to match GraalPy Python interpreter in PythonSpec and PythonInfo. - by :user:`timfel`. (:issue:`73`)
27
+
28
+ ********************
29
+ v1.3.0 (2026-05-05)
30
+ ********************
31
+
32
+ Features - 1.3.0
33
+ ================
34
+
35
+ - Add :func:`~python_discovery.iter_interpreters` for enumerating every discovered interpreter, with PATH and
36
+ UV-install support for non-CPython implementations listed in :data:`~python_discovery.KNOWN_IMPLEMENTATIONS`
37
+ (:pull:`71`)
38
+
39
+ ********************
40
+ v1.2.2 (2026-04-06)
41
+ ********************
42
+
43
+ Features - 1.2.2
44
+ ================
45
+
46
+ - Export ``normalize_isa`` and deprecate ``KNOWN_ARCHITECTURES`` (:pull:`62`)
47
+
48
+ ********************
49
+ v1.2.1 (2026-03-26)
50
+ ********************
51
+
52
+ Features - 1.2.1
53
+ ================
54
+
55
+ - Expose ``KNOWN_ARCHITECTURES`` as public API (:pull:`56`)
56
+
57
+ Contributor-facing changes - 1.2.1
58
+ ==================================
59
+
60
+ - Add zizmor security auditing for workflows (:pull:`55`)
61
+
62
+ ********************
63
+ v1.2.0 (2026-03-18)
64
+ ********************
65
+
66
+ Features - 1.2.0
67
+ ================
68
+
69
+ - Increase interpreter query timeout to 15s, with an override (:pull:`53`)
70
+
71
+ ********************
72
+ v1.1.3 (2026-03-10)
73
+ ********************
74
+
75
+ Bug fixes - 1.1.3
76
+ =================
77
+
78
+ - Add ``loongarch64`` to known ISAs (:pull:`50`)
79
+
80
+ ********************
81
+ v1.1.2 (2026-03-09)
82
+ ********************
83
+
84
+ Bug fixes - 1.1.2
85
+ =================
86
+
87
+ - Match prerelease versions against ``major.minor`` specs (:pull:`48`)
88
+ - Drain pipes after killing a timed-out interpreter probe (:pull:`49`)
89
+
90
+ Improved documentation - 1.1.2
91
+ ==============================
92
+
93
+ - Add package description and usage to the README (:pull:`46`)
94
+
95
+ ********************
96
+ v1.1.1 (2026-03-06)
97
+ ********************
98
+
99
+ Bug fixes - 1.1.1
100
+ =================
101
+
102
+ - Add a timeout to interpreter probing (:pull:`42`)
103
+ - Add ``i686`` to known ISAs (:pull:`43`)
104
+
105
+ Contributor-facing changes - 1.1.1
106
+ ==================================
107
+
108
+ - Add a security policy and workflow permissions hardening (:pull:`33`)
109
+
110
+ ********************
111
+ v1.1.0 (2026-02-26)
112
+ ********************
113
+
114
+ Features - 1.1.0
115
+ ================
116
+
117
+ - Add a ``predicate`` parameter to :func:`~python_discovery.get_interpreter` (:pull:`31`)
118
+
119
+ Improved documentation - 1.1.0
120
+ ==============================
121
+
122
+ - Fix the ReadTheDocs build (:pull:`29`)
123
+ - Add ``:param:`` descriptions to all public APIs (:pull:`32`)
124
+
125
+ ********************
126
+ v1.0.0 (2026-02-25)
127
+ ********************
128
+
129
+ Features - 1.0.0
130
+ ================
131
+
132
+ - Initial release as a standalone package, extracted from ``virtualenv`` (:pull:`28`)
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from datetime import datetime, timezone
6
+ from pathlib import Path
6
7
 
7
8
  from python_discovery import __version__
8
9
 
@@ -19,8 +20,13 @@ extensions = [
19
20
  "sphinx.ext.intersphinx",
20
21
  "sphinx_autodoc_typehints",
21
22
  "sphinxcontrib.mermaid",
23
+ "sphinxcontrib.towncrier.ext",
22
24
  ]
23
25
 
26
+ towncrier_draft_autoversion_mode = "draft"
27
+ towncrier_draft_include_empty = True
28
+ towncrier_draft_working_directory = Path(__file__).parent.parent
29
+
24
30
  extlinks = {
25
31
  "issue": ("https://github.com/tox-dev/python-discovery/issues/%s", "#%s"),
26
32
  "pull": ("https://github.com/tox-dev/python-discovery/pull/%s", "PR #%s"),
@@ -33,7 +39,7 @@ intersphinx_mapping = {
33
39
 
34
40
  templates_path = []
35
41
  source_suffix = ".rst"
36
- exclude_patterns = ["_build", "changelog/*.rst"]
42
+ exclude_patterns = ["_build", "changelog/*"]
37
43
 
38
44
  main_doc = "index"
39
45
  pygments_style = "default"
@@ -47,3 +47,9 @@ repeated lookups are fast.
47
47
  :hidden:
48
48
 
49
49
  explanation
50
+
51
+ .. toctree::
52
+ :caption: Project
53
+ :hidden:
54
+
55
+ changelog
@@ -50,6 +50,8 @@ optional-dependencies.docs = [
50
50
  "sphinx>=9.1",
51
51
  "sphinx-autodoc-typehints>=3.6.3",
52
52
  "sphinxcontrib-mermaid>=2",
53
+ "sphinxcontrib-towncrier>=0.4",
54
+ "towncrier>=25.8",
53
55
  ]
54
56
  optional-dependencies.testing = [
55
57
  "covdefaults>=2.3",
@@ -64,6 +66,14 @@ urls.Homepage = "https://github.com/tox-dev/python-discovery"
64
66
  urls.Source = "https://github.com/tox-dev/python-discovery"
65
67
  urls.Tracker = "https://github.com/tox-dev/python-discovery/issues"
66
68
 
69
+ [dependency-groups]
70
+ release = [
71
+ "gitpython>=3.1.46",
72
+ "packaging>=26",
73
+ "pre-commit>=4.5.1",
74
+ "towncrier>=25.8",
75
+ ]
76
+
67
77
  [tool.hatch]
68
78
  version.source = "vcs"
69
79
 
@@ -98,6 +108,12 @@ lint.per-file-ignores."src/python_discovery/_py_info.py" = [
98
108
  lint.per-file-ignores."src/python_discovery/_windows/_pep514.py" = [
99
109
  "PTH", # os.path.exists is monkeypatched in tests; pathlib.Path.exists bypasses the mock
100
110
  ]
111
+ lint.per-file-ignores."tasks/**/*.py" = [
112
+ "D", # release helper scripts, not API
113
+ "INP001", # no __init__.py in tasks directory
114
+ "S404", # subprocess import (release tooling)
115
+ "S603", # `subprocess` call: argv built from trusted inputs (version, gh CLI)
116
+ ]
101
117
  lint.per-file-ignores."tests/**/*.py" = [
102
118
  "D", # don't care about documentation in tests
103
119
  "FBT", # don't care about booleans as positional arguments in tests
@@ -180,3 +196,21 @@ report.partial_branches = [
180
196
  report.show_missing = true
181
197
  html.show_contexts = true
182
198
  html.skip_covered = false
199
+
200
+ [tool.towncrier]
201
+ name = "python-discovery"
202
+ filename = "docs/changelog.rst"
203
+ directory = "docs/changelog"
204
+ title_format = false
205
+ issue_format = ":issue:`{issue}`"
206
+ template = "docs/changelog/template.jinja2"
207
+ type = [
208
+ { directory = "breaking", name = "Backward incompatible changes", showcontent = true },
209
+ { directory = "deprecation", name = "Deprecations (removal in next major release)", showcontent = true },
210
+ { directory = "feature", name = "Features", showcontent = true },
211
+ { directory = "bugfix", name = "Bug fixes", showcontent = true },
212
+ { directory = "doc", name = "Improved documentation", showcontent = true },
213
+ { directory = "packaging", name = "Packaging updates and notes for downstreams", showcontent = true },
214
+ { directory = "contrib", name = "Contributor-facing changes", showcontent = true },
215
+ { directory = "misc", name = "Miscellaneous internal changes", showcontent = true },
216
+ ]
@@ -314,13 +314,7 @@ class LazyPathDump:
314
314
  content += " with =>"
315
315
  for file_path in self.path.iterdir():
316
316
  try:
317
- if file_path.is_dir():
318
- continue
319
- if IS_WIN: # pragma: win32 cover
320
- pathext = self.env.get("PATHEXT", ".COM;.EXE;.BAT;.CMD").split(";")
321
- if not any(file_path.name.upper().endswith(ext) for ext in pathext):
322
- continue
323
- elif not (file_path.stat().st_mode & os.X_OK):
317
+ if not self._is_executable(file_path):
324
318
  continue
325
319
  except OSError:
326
320
  pass
@@ -328,6 +322,14 @@ class LazyPathDump:
328
322
  content += file_path.name
329
323
  return content
330
324
 
325
+ def _is_executable(self, file_path: Path) -> bool:
326
+ if file_path.is_dir():
327
+ return False
328
+ if IS_WIN: # pragma: win32 cover
329
+ pathext = self.env.get("PATHEXT", ".COM;.EXE;.BAT;.CMD").split(";")
330
+ return any(file_path.name.upper().endswith(ext) for ext in pathext)
331
+ return bool(file_path.stat().st_mode & os.X_OK)
332
+
331
333
 
332
334
  def path_exe_finder(
333
335
  spec: PythonSpec, *, all_implementations: bool = False
@@ -16,6 +16,7 @@ from string import digits
16
16
  from typing import TYPE_CHECKING, ClassVar, Final, NamedTuple
17
17
 
18
18
  if TYPE_CHECKING:
19
+ import tkinter as tk
19
20
  from collections.abc import Generator, Mapping
20
21
 
21
22
  from ._cache import PyInfoCache
@@ -59,6 +60,8 @@ class PythonInfo: # noqa: PLR0904
59
60
  def _init_identity(self) -> None:
60
61
  self.platform = sys.platform
61
62
  self.implementation = platform.python_implementation()
63
+ if self.implementation == "GraalVM":
64
+ self.implementation = "GraalPy"
62
65
  if self.implementation == "PyPy":
63
66
  self.pypy_version_info = tuple(sys.pypy_version_info) # ty: ignore[unresolved-attribute] # pypy only
64
67
 
@@ -163,42 +166,37 @@ class PythonInfo: # noqa: PLR0904
163
166
  try:
164
167
  tcl = tk.Tcl()
165
168
  tcl_lib = tcl.eval("info library")
166
-
167
- # Try to get TK library path directly first
168
- try:
169
- tk_lib = tcl.eval("set tk_library")
170
- if tk_lib and os.path.isdir(tk_lib):
171
- pass # We found it directly
172
- else:
173
- tk_lib = None # Reset if invalid
174
- except tk.TclError:
175
- tk_lib = None
176
-
177
- # If direct query failed, try constructing the path
178
- if tk_lib is None:
179
- tk_version = tcl.eval("package require Tk")
180
- tcl_parent = os.path.dirname(tcl_lib)
181
-
182
- # Try different version formats
183
- version_variants = [
184
- tk_version, # Full version like "8.6.12"
185
- ".".join(tk_version.split(".")[:2]), # Major.minor like "8.6"
186
- tk_version.split(".")[0], # Just major like "8"
187
- ]
188
-
189
- for version in version_variants:
190
- tk_lib_path = os.path.join(tcl_parent, f"tk{version}")
191
- if not os.path.isdir(tk_lib_path):
192
- continue
193
- if os.path.exists(os.path.join(tk_lib_path, "tk.tcl")):
194
- tk_lib = tk_lib_path
195
- break
196
-
169
+ tk_lib = PythonInfo._resolve_tk_lib(tcl, tcl_lib)
197
170
  except tk.TclError:
198
171
  pass
199
172
 
200
173
  return tcl_lib, tk_lib
201
174
 
175
+ @staticmethod
176
+ def _query_tk_library(tcl: tk.Tk) -> str | None: # pragma: no cover
177
+ """Try to get the TK library path directly from Tcl."""
178
+ import tkinter as tk # noqa: PLC0415
179
+
180
+ try:
181
+ if (tk_lib := tcl.eval("set tk_library")) and os.path.isdir(tk_lib):
182
+ return tk_lib
183
+ except tk.TclError:
184
+ pass
185
+ return None
186
+
187
+ @staticmethod
188
+ def _resolve_tk_lib(tcl: tk.Tk, tcl_lib: str) -> str | None: # pragma: no cover
189
+ """Resolve the TK library path by direct query or path construction."""
190
+ if (tk_lib := PythonInfo._query_tk_library(tcl)) is not None:
191
+ return tk_lib
192
+ tk_version = tcl.eval("package require Tk")
193
+ tcl_parent = os.path.dirname(tcl_lib)
194
+ for version in (tk_version, ".".join(tk_version.split(".")[:2]), tk_version.split(".")[0]):
195
+ tk_lib_path = os.path.join(tcl_parent, f"tk{version}")
196
+ if os.path.isdir(tk_lib_path) and os.path.exists(os.path.join(tk_lib_path, "tk.tcl")):
197
+ return tk_lib_path
198
+ return None
199
+
202
200
  def _fast_get_system_executable(self) -> str | None:
203
201
  """Try to get the system executable by just looking at properties."""
204
202
  # if we're not in a virtual environment, this is already a system python, so return the original executable
@@ -17,7 +17,7 @@ PATTERN = re.compile(
17
17
  (?P<version>[0-9.]+)? # version (e.g. 3.12, 3.12.1)
18
18
  (?P<threaded>t)? # free-threaded flag
19
19
  (?:-(?P<arch>32|64))? # architecture bitness
20
- (?:-(?P<machine>[a-zA-Z0-9_]+))? # ISA (e.g. arm64, x86_64)
20
+ (?:-(?P<machine>[a-zA-Z0-9_.]+))? # ISA (e.g. arm64, x86_64, i86pc.64bit)
21
21
  $
22
22
  """,
23
23
  re.VERBOSE,
@@ -77,6 +77,8 @@ def _parse_spec_pattern(string_spec: str) -> PythonSpec | None:
77
77
  impl = groups["impl"]
78
78
  if impl in {"py", "python"}:
79
79
  impl = None
80
+ if impl == "graalvm":
81
+ impl = "graalpy"
80
82
  arch = _int_or_none(groups["arch"])
81
83
  machine = groups.get("machine")
82
84
  if machine is not None:
@@ -97,6 +99,8 @@ def _parse_specifier(string_spec: str) -> PythonSpec | None:
97
99
  return None
98
100
  if impl in {"py", "python"}:
99
101
  impl = None
102
+ if impl == "graalvm":
103
+ impl = "graalpy"
100
104
  return PythonSpec(string_spec, impl, None, None, None, None, None, version_specifier=version_specifier)
101
105
 
102
106
 
@@ -124,23 +124,23 @@ def load_exe(hive_name: str, company: str, company_key: Any, tag: str) -> tuple[
124
124
  key_path = f"{hive_name}/{company}/{tag}"
125
125
  try:
126
126
  with winreg.OpenKeyEx(company_key, rf"{tag}\InstallPath") as ip_key, ip_key: # ty: ignore[unresolved-attribute]
127
- exe = get_value(ip_key, "ExecutablePath")
128
- if exe is None:
129
- ip = get_value(ip_key, None)
130
- if ip is None:
131
- msg(key_path, "no ExecutablePath or default for it")
132
-
133
- else:
134
- exe = os.path.join(ip, "python.exe")
135
- if exe is not None and os.path.exists(exe):
136
- args = get_value(ip_key, "ExecutableArguments")
137
- return exe, args
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")
138
129
  msg(key_path, f"could not load exe with value {exe}")
139
130
  except OSError:
140
131
  msg(f"{key_path}/InstallPath", "missing")
141
132
  return None
142
133
 
143
134
 
135
+ def _resolve_exe(ip_key: Any, key_path: str) -> str | None: # noqa: ANN401
136
+ if (exe := get_value(ip_key, "ExecutablePath")) is not None:
137
+ return exe
138
+ if (ip := get_value(ip_key, None)) is None:
139
+ msg(key_path, "no ExecutablePath or default for it")
140
+ return None
141
+ return os.path.join(ip, "python.exe")
142
+
143
+
144
144
  def load_arch_data(hive_name: str, company: str, tag: str, tag_key: Any, default_arch: int) -> int | None: # noqa: ANN401
145
145
  arch_str = get_value(tag_key, "SysArchitecture")
146
146
  if arch_str is not None:
@@ -0,0 +1,173 @@
1
+ """Handles creating a release commit and tag, then publishes the GitHub release."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from subprocess import CalledProcessError, check_call, run
7
+
8
+ from git import Commit, Head, Remote, Repo, TagReference
9
+ from packaging.version import Version
10
+
11
+ ROOT_SRC_DIR = Path(__file__).parents[1]
12
+
13
+
14
+ def main(version_str: str) -> None:
15
+ version = Version(version_str)
16
+ repo = Repo(str(ROOT_SRC_DIR))
17
+
18
+ if repo.is_dirty():
19
+ msg = "Current repository is dirty. Please commit any changes and try again."
20
+ raise RuntimeError(msg)
21
+ upstream, release_branch = create_release_branch(repo, version)
22
+ original_main_sha = upstream.refs.main.commit.hexsha
23
+ main_pushed = False
24
+ tag_pushed = False
25
+ release_created = False
26
+ try:
27
+ main_pushed, tag_pushed, release_created = push_release(repo, upstream, release_branch, version)
28
+ finalize_release(repo, upstream, release_branch)
29
+ except Exception:
30
+ cleanup_failed_release(
31
+ repo,
32
+ upstream,
33
+ version,
34
+ release_branch,
35
+ original_main_sha,
36
+ release_created=release_created,
37
+ tag_pushed=tag_pushed,
38
+ main_pushed=main_pushed,
39
+ )
40
+ raise
41
+
42
+
43
+ def create_release_branch(repo: Repo, version: Version) -> tuple[Remote, Head]:
44
+ print("create release branch from upstream main") # noqa: T201
45
+ upstream = get_upstream(repo)
46
+ upstream.fetch()
47
+ branch_name = f"release-{version}"
48
+ release_branch = repo.create_head(branch_name, upstream.refs.main, force=True)
49
+ upstream.push(refspec=f"{branch_name}:{branch_name}", force=True)
50
+ release_branch.set_tracking_branch(upstream.refs[branch_name])
51
+ release_branch.checkout()
52
+ return upstream, release_branch
53
+
54
+
55
+ def get_upstream(repo: Repo) -> Remote:
56
+ for remote in repo.remotes:
57
+ if any("tox-dev/python-discovery" in url for url in remote.urls):
58
+ return remote
59
+ msg = "could not find tox-dev/python-discovery remote"
60
+ raise RuntimeError(msg)
61
+
62
+
63
+ def push_release(repo: Repo, upstream: Remote, release_branch: Head, version: Version) -> tuple[bool, bool, bool]:
64
+ release_commit = release_changelog(repo, version)
65
+ tag = tag_release_commit(release_commit, repo, version)
66
+ print("push release commit") # noqa: T201
67
+ repo.git.push(upstream.name, f"{release_branch}:main", "-f")
68
+ print("push release tag") # noqa: T201
69
+ repo.git.push(upstream.name, tag, "-f")
70
+ create_github_release(version)
71
+ return True, True, True
72
+
73
+
74
+ def release_changelog(repo: Repo, version: Version) -> Commit:
75
+ print("generate release commit") # noqa: T201
76
+ check_call(["towncrier", "build", "--yes", "--version", version.public], cwd=str(ROOT_SRC_DIR)) # noqa: S607
77
+ print("format changelog with pre-commit") # noqa: T201
78
+ changelog_path = ROOT_SRC_DIR / "docs" / "changelog.rst"
79
+ try:
80
+ check_call(["pre-commit", "run", "--files", str(changelog_path)], cwd=str(ROOT_SRC_DIR)) # noqa: S607
81
+ except CalledProcessError:
82
+ print("pre-commit made formatting changes, staging them") # noqa: T201
83
+ repo.index.add([str(changelog_path)])
84
+ return repo.index.commit(f"release {version}")
85
+
86
+
87
+ def tag_release_commit(release_commit: Commit, repo: Repo, version: Version) -> TagReference:
88
+ print("tag release commit") # noqa: T201
89
+ version_str = str(version)
90
+ if version_str in {tag.name for tag in repo.tags}:
91
+ print(f"delete existing tag {version_str}") # noqa: T201
92
+ repo.delete_tag(repo.tags[version_str])
93
+ print(f"create tag {version_str}") # noqa: T201
94
+ return repo.create_tag(version_str, ref=release_commit.hexsha, force=True)
95
+
96
+
97
+ def create_github_release(version: Version) -> None:
98
+ print("create github release") # noqa: T201
99
+ version_str = str(version)
100
+ try:
101
+ result = run(
102
+ ["gh", "release", "create", version_str, "--title", f"v{version_str}", "--generate-notes"], # noqa: S607
103
+ cwd=str(ROOT_SRC_DIR),
104
+ capture_output=True,
105
+ text=True,
106
+ check=True,
107
+ )
108
+ if result.stdout:
109
+ print(result.stdout) # noqa: T201
110
+ except CalledProcessError as e:
111
+ print(f"gh release create failed with exit code {e.returncode}") # noqa: T201
112
+ if e.stdout:
113
+ print(f"stdout: {e.stdout}") # noqa: T201
114
+ if e.stderr:
115
+ print(f"stderr: {e.stderr}") # noqa: T201
116
+ raise
117
+
118
+
119
+ def finalize_release(repo: Repo, upstream: Remote, release_branch: Head) -> None:
120
+ print("checkout main to new release and delete release branch") # noqa: T201
121
+ repo.heads.main.checkout()
122
+ repo.delete_head(release_branch, force=True)
123
+ print("delete remote release branch") # noqa: T201
124
+ repo.git.push(upstream.name, f":{release_branch}", "--no-verify")
125
+ upstream.fetch()
126
+ repo.git.reset("--hard", f"{upstream.name}/main")
127
+ print("All done!") # noqa: T201
128
+
129
+
130
+ def cleanup_failed_release( # noqa: PLR0913
131
+ repo: Repo,
132
+ upstream: Remote,
133
+ version: Version,
134
+ release_branch: Head,
135
+ original_main_sha: str,
136
+ *,
137
+ release_created: bool,
138
+ tag_pushed: bool,
139
+ main_pushed: bool,
140
+ ) -> None:
141
+ print("Release failed! Cleaning up...") # noqa: T201
142
+ if release_created:
143
+ print(f"Deleting GitHub release {version}") # noqa: T201
144
+ try:
145
+ check_call(["gh", "release", "delete", str(version), "--yes"], cwd=str(ROOT_SRC_DIR)) # noqa: S607
146
+ except Exception as cleanup_error: # noqa: BLE001
147
+ print(f"Warning: Failed to delete GitHub release: {cleanup_error}") # noqa: T201
148
+ if tag_pushed:
149
+ print(f"Deleting remote tag {version}") # noqa: T201
150
+ try:
151
+ repo.git.push(upstream.name, f":refs/tags/{version}", "--no-verify")
152
+ except Exception as cleanup_error: # noqa: BLE001
153
+ print(f"Warning: Failed to delete remote tag: {cleanup_error}") # noqa: T201
154
+ if main_pushed:
155
+ print(f"Reverting main to {original_main_sha[:8]}") # noqa: T201
156
+ try:
157
+ repo.git.push(upstream.name, f"{original_main_sha}:main", "-f", "--no-verify")
158
+ except Exception as cleanup_error: # noqa: BLE001
159
+ print(f"Warning: Failed to revert main: {cleanup_error}") # noqa: T201
160
+ print("Deleting remote release branch") # noqa: T201
161
+ try:
162
+ repo.git.push(upstream.name, f":{release_branch}", "--no-verify")
163
+ except Exception as cleanup_error: # noqa: BLE001
164
+ print(f"Warning: Failed to delete remote branch: {cleanup_error}") # noqa: T201
165
+
166
+
167
+ if __name__ == "__main__":
168
+ import argparse
169
+
170
+ parser = argparse.ArgumentParser(prog="release")
171
+ parser.add_argument("--version", required=True)
172
+ options = parser.parse_args()
173
+ main(options.version)
@@ -32,6 +32,12 @@ def test_py_info_pypy_version(mocker: MockerFixture) -> None:
32
32
  assert info.pypy_version_info == (7, 3, 11, "final", 0)
33
33
 
34
34
 
35
+ def test_py_info_canonicalizes_graalvm(mocker: MockerFixture) -> None:
36
+ mocker.patch("platform.python_implementation", return_value="GraalVM")
37
+ info = PythonInfo()
38
+ assert info.implementation == "GraalPy"
39
+
40
+
35
41
  def test_has_venv_attribute() -> None:
36
42
  info = PythonInfo()
37
43
  assert isinstance(info.has_venv, bool)
@@ -155,6 +155,12 @@ def test_specifier_with_implementation() -> None:
155
155
  assert spec.version_specifier == SpecifierSet.from_string(">=3.12")
156
156
 
157
157
 
158
+ def test_specifier_with_graalpy_implementation_alias() -> None:
159
+ spec = PythonSpec.from_string_spec("graalvm>=3.12")
160
+ assert spec.implementation == "graalpy"
161
+ assert spec.version_specifier == SpecifierSet.from_string(">=3.12")
162
+
163
+
158
164
  def test_specifier_satisfies_with_partial_information() -> None:
159
165
  spec = PythonSpec.from_string_spec(">=3.12")
160
166
  candidate = PythonSpec.from_string_spec("python3.12")
@@ -174,6 +180,7 @@ def test_specifier_satisfies_with_partial_information() -> None:
174
180
  pytest.param("cpython3.12-64", None, id="no-machine"),
175
181
  pytest.param("cpython3.12", None, id="no-arch-no-machine"),
176
182
  pytest.param("python3.12-64-arm64", "arm64", id="python-impl"),
183
+ pytest.param("cpython3.14-64-i86pc.64bit", "i86pc.64bit", id="dotted-machine"),
177
184
  ],
178
185
  )
179
186
  def test_spec_parse_machine(spec_str: str, expected_machine: str | None) -> None:
@@ -245,3 +252,10 @@ def test_normalize_isa(isa: str, normalized: str) -> None:
245
252
  )
246
253
  def test_spec_repr_machine(spec_str: str, in_repr: str) -> None:
247
254
  assert in_repr in repr(PythonSpec.from_string_spec(spec_str))
255
+
256
+
257
+ @pytest.mark.parametrize(("left", "right"), [("graalpy", "graalvm"), ("graalvm", "graalpy")])
258
+ def test_spec_satisfies_graalpy_implementation_aliases(left: str, right: str) -> None:
259
+ spec_1 = PythonSpec.from_string_spec(left)
260
+ spec_2 = PythonSpec.from_string_spec(right)
261
+ assert spec_1.satisfies(spec_2) is True
@@ -36,12 +36,12 @@ commands = [["pre-commit", "run", "--all-files", "--show-diff-on-failure"]]
36
36
 
37
37
  [env."type-3.8"]
38
38
  description = "run type check on code base (3.8)"
39
- deps = ["ty==0.0.17"]
39
+ deps = ["gitpython>=3.1.46", "packaging>=26", "ty==0.0.17"]
40
40
  commands = [["ty", "check", "--output-format", "concise", "--error-on-warning", "--python-version", "3.8", "."]]
41
41
 
42
42
  [env."type-3.14"]
43
43
  description = "run type check on code base (3.14)"
44
- deps = ["ty==0.0.17"]
44
+ deps = ["gitpython>=3.1.46", "packaging>=26", "ty==0.0.17"]
45
45
  commands = [["ty", "check", "--output-format", "concise", "--error-on-warning", "--python-version", "3.14", "."]]
46
46
 
47
47
  [env.docs]
@@ -71,6 +71,13 @@ commands = [
71
71
  ["check-wheel-contents", "--no-config", "{env_tmp_dir}"],
72
72
  ]
73
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
+
74
81
  [env.dev]
75
82
  description = "generate a DEV environment"
76
83
  package = "editable"
@@ -1 +0,0 @@
1
- export normalize_isa and deprecate KNOWN_ARCHITECTURES - by :user:`rahuldevikar`.
@@ -1,3 +0,0 @@
1
- discover uv-managed Pythons on Windows. Previously the glob assumed Unix layout (``<root>/<key>/bin/python``) and
2
- silently found nothing on Windows, where uv places ``python.exe`` directly under the install root - by
3
- :user:`gaborbernat`.
@@ -1,3 +0,0 @@
1
- add :func:`~python_discovery.iter_interpreters` for enumerating every discovered interpreter, with PATH and
2
- UV-install support for non-CPython implementations listed in :data:`~python_discovery.KNOWN_IMPLEMENTATIONS`
3
- - by :user:`gaborbernat`.