python-discovery 1.4.0__tar.gz → 1.4.1__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 (57) hide show
  1. {python_discovery-1.4.0 → python_discovery-1.4.1}/.github/workflows/check.yaml +2 -2
  2. {python_discovery-1.4.0 → python_discovery-1.4.1}/.github/workflows/prepare-release.yaml +2 -2
  3. {python_discovery-1.4.0 → python_discovery-1.4.1}/.github/workflows/release.yaml +2 -2
  4. {python_discovery-1.4.0 → python_discovery-1.4.1}/.pre-commit-config.yaml +2 -2
  5. {python_discovery-1.4.0 → python_discovery-1.4.1}/PKG-INFO +1 -1
  6. {python_discovery-1.4.0 → python_discovery-1.4.1}/docs/changelog.rst +10 -0
  7. {python_discovery-1.4.0 → python_discovery-1.4.1}/src/python_discovery/_py_info.py +25 -2
  8. {python_discovery-1.4.0 → python_discovery-1.4.1}/tests/test_discovery.py +7 -8
  9. {python_discovery-1.4.0 → python_discovery-1.4.1}/tests/test_py_info_extra.py +104 -1
  10. {python_discovery-1.4.0 → python_discovery-1.4.1}/.github/FUNDING.yaml +0 -0
  11. {python_discovery-1.4.0 → python_discovery-1.4.1}/.github/SECURITY.md +0 -0
  12. {python_discovery-1.4.0 → python_discovery-1.4.1}/.github/dependabot.yaml +0 -0
  13. {python_discovery-1.4.0 → python_discovery-1.4.1}/.github/release.yaml +0 -0
  14. {python_discovery-1.4.0 → python_discovery-1.4.1}/.gitignore +0 -0
  15. {python_discovery-1.4.0 → python_discovery-1.4.1}/.readthedocs.yaml +0 -0
  16. {python_discovery-1.4.0 → python_discovery-1.4.1}/.readthedocs.yml +0 -0
  17. {python_discovery-1.4.0 → python_discovery-1.4.1}/CODE_OF_CONDUCT.md +0 -0
  18. {python_discovery-1.4.0 → python_discovery-1.4.1}/LICENSE +0 -0
  19. {python_discovery-1.4.0 → python_discovery-1.4.1}/README.md +0 -0
  20. {python_discovery-1.4.0 → python_discovery-1.4.1}/docs/_static/custom.css +0 -0
  21. {python_discovery-1.4.0 → python_discovery-1.4.1}/docs/_static/logo.svg +0 -0
  22. {python_discovery-1.4.0 → python_discovery-1.4.1}/docs/changelog/template.jinja2 +0 -0
  23. {python_discovery-1.4.0 → python_discovery-1.4.1}/docs/conf.py +0 -0
  24. {python_discovery-1.4.0 → python_discovery-1.4.1}/docs/explanation.rst +0 -0
  25. {python_discovery-1.4.0 → python_discovery-1.4.1}/docs/how-to/standalone-usage.rst +0 -0
  26. {python_discovery-1.4.0 → python_discovery-1.4.1}/docs/index.rst +0 -0
  27. {python_discovery-1.4.0 → python_discovery-1.4.1}/docs/reference/api.rst +0 -0
  28. {python_discovery-1.4.0 → python_discovery-1.4.1}/docs/reference/environment-variables.rst +0 -0
  29. {python_discovery-1.4.0 → python_discovery-1.4.1}/docs/tutorial/getting-started.rst +0 -0
  30. {python_discovery-1.4.0 → python_discovery-1.4.1}/pyproject.toml +2 -2
  31. {python_discovery-1.4.0 → python_discovery-1.4.1}/src/python_discovery/__init__.py +0 -0
  32. {python_discovery-1.4.0 → python_discovery-1.4.1}/src/python_discovery/_cache.py +0 -0
  33. {python_discovery-1.4.0 → python_discovery-1.4.1}/src/python_discovery/_cached_py_info.py +0 -0
  34. {python_discovery-1.4.0 → python_discovery-1.4.1}/src/python_discovery/_compat.py +0 -0
  35. {python_discovery-1.4.0 → python_discovery-1.4.1}/src/python_discovery/_discovery.py +0 -0
  36. {python_discovery-1.4.0 → python_discovery-1.4.1}/src/python_discovery/_py_spec.py +0 -0
  37. {python_discovery-1.4.0 → python_discovery-1.4.1}/src/python_discovery/_specifier.py +0 -0
  38. {python_discovery-1.4.0 → python_discovery-1.4.1}/src/python_discovery/_windows/__init__.py +0 -0
  39. {python_discovery-1.4.0 → python_discovery-1.4.1}/src/python_discovery/_windows/_pep514.py +0 -0
  40. {python_discovery-1.4.0 → python_discovery-1.4.1}/src/python_discovery/_windows/_propose.py +0 -0
  41. {python_discovery-1.4.0 → python_discovery-1.4.1}/src/python_discovery/py.typed +0 -0
  42. {python_discovery-1.4.0 → python_discovery-1.4.1}/tasks/release.py +0 -0
  43. {python_discovery-1.4.0 → python_discovery-1.4.1}/tests/conftest.py +0 -0
  44. {python_discovery-1.4.0 → python_discovery-1.4.1}/tests/py_info/test_py_info.py +0 -0
  45. {python_discovery-1.4.0 → python_discovery-1.4.1}/tests/py_info/test_py_info_exe_based_of.py +0 -0
  46. {python_discovery-1.4.0 → python_discovery-1.4.1}/tests/test_cache.py +0 -0
  47. {python_discovery-1.4.0 → python_discovery-1.4.1}/tests/test_cached_py_info.py +0 -0
  48. {python_discovery-1.4.0 → python_discovery-1.4.1}/tests/test_discovery_extra.py +0 -0
  49. {python_discovery-1.4.0 → python_discovery-1.4.1}/tests/test_iter_interpreters.py +0 -0
  50. {python_discovery-1.4.0 → python_discovery-1.4.1}/tests/test_py_spec.py +0 -0
  51. {python_discovery-1.4.0 → python_discovery-1.4.1}/tests/test_py_spec_extra.py +0 -0
  52. {python_discovery-1.4.0 → python_discovery-1.4.1}/tests/test_specifier.py +0 -0
  53. {python_discovery-1.4.0 → python_discovery-1.4.1}/tests/windows/conftest.py +0 -0
  54. {python_discovery-1.4.0 → python_discovery-1.4.1}/tests/windows/test_windows.py +0 -0
  55. {python_discovery-1.4.0 → python_discovery-1.4.1}/tests/windows/test_windows_pep514.py +0 -0
  56. {python_discovery-1.4.0 → python_discovery-1.4.1}/tests/windows/winreg_mock_values.py +0 -0
  57. {python_discovery-1.4.0 → python_discovery-1.4.1}/tox.toml +0 -0
@@ -34,12 +34,12 @@ jobs:
34
34
  - dev
35
35
  - pkg_meta
36
36
  steps:
37
- - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
37
+ - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
38
38
  with:
39
39
  fetch-depth: 0
40
40
  persist-credentials: false
41
41
  - name: Install the latest version of uv
42
- uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
42
+ uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
43
43
  with:
44
44
  enable-cache: false
45
45
  cache-dependency-glob: "pyproject.toml"
@@ -26,14 +26,14 @@ jobs:
26
26
  permissions:
27
27
  contents: write
28
28
  steps:
29
- - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
29
+ - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
30
30
  with:
31
31
  fetch-depth: 0
32
32
  token: ${{ secrets.RELEASE_PAT }}
33
33
  persist-credentials: true
34
34
 
35
35
  - name: Install the latest version of uv
36
- uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
36
+ uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
37
37
  with:
38
38
  enable-cache: true
39
39
  cache-dependency-glob: "pyproject.toml"
@@ -13,12 +13,12 @@ jobs:
13
13
  build:
14
14
  runs-on: ubuntu-latest
15
15
  steps:
16
- - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
16
+ - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
17
17
  with:
18
18
  fetch-depth: 0
19
19
  persist-credentials: false
20
20
  - name: Install the latest version of uv
21
- uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
21
+ uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
22
22
  with:
23
23
  enable-cache: false
24
24
  cache-dependency-glob: "pyproject.toml"
@@ -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.2"
18
+ rev: "v2.23.0"
19
19
  hooks:
20
20
  - id: pyproject-fmt
21
21
  - repo: https://github.com/astral-sh/ruff-pre-commit
22
- rev: "v0.15.14"
22
+ rev: "v0.15.16"
23
23
  hooks:
24
24
  - id: ruff-format
25
25
  - id: ruff
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-discovery
3
- Version: 1.4.0
3
+ Version: 1.4.1
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
@@ -6,6 +6,16 @@
6
6
 
7
7
  .. towncrier release notes start
8
8
 
9
+ ********************
10
+ v1.4.1 (2026-06-11)
11
+ ********************
12
+
13
+ Bug fixes - 1.4.1
14
+ =================
15
+ - Resolve executable-only symlinks when computing ``system_executable``, mirroring CPython's ``getpath.realpath``
16
+ (python/cpython#115237): a symlink to the interpreter binary now resolves to the real interpreter, while a fully
17
+ symlinked interpreter tree is kept as-is - by :user:`gaborbernat`. (:issue:`84`)
18
+
9
19
  ********************
10
20
  v1.4.0 (2026-05-28)
11
21
  ********************
@@ -204,7 +204,7 @@ class PythonInfo: # noqa: PLR0904
204
204
  # if we're not in a virtual environment, this is already a system python, so return the original executable
205
205
  # note we must choose the original and not the pure executable as shim scripts might throw us off
206
206
  if not (self.real_prefix or (self.base_prefix is not None and self.base_prefix != self.prefix)):
207
- return self.original_executable
207
+ return self._resolve_executable_symlink(self.original_executable)
208
208
 
209
209
  # if this is NOT a virtual environment, can't determine easily, bail out
210
210
  if self.real_prefix is not None:
@@ -220,11 +220,34 @@ class PythonInfo: # noqa: PLR0904
220
220
 
221
221
  # We're not in a venv and base_executable exists; use it directly
222
222
  if os.path.exists(base_executable): # pragma: >=3.11 cover
223
- return base_executable
223
+ return self._resolve_executable_symlink(base_executable)
224
224
 
225
225
  # Try fallback for POSIX virtual environments
226
226
  return self._try_posix_fallback_executable(base_executable) # pragma: >=3.11 cover
227
227
 
228
+ def _resolve_executable_symlink(self, path: str) -> str:
229
+ """
230
+ Resolve symlinks of the executable itself, but never of its parent directories.
231
+
232
+ Mirrors CPython's ``getpath.realpath`` (and ``venv`` in python/cpython#115237): an executable-only symlink
233
+ resolves to the real interpreter so its home can be located, while a fully symlinked interpreter tree is
234
+ kept as-is.
235
+ """
236
+ result = os.path.abspath(path)
237
+ if self.os != "posix": # CPython only does this where HAVE_READLINK
238
+ return result
239
+ real_path = os.path.realpath(result)
240
+ if not os.path.exists(real_path): # symlink loop or broken symlink
241
+ return result
242
+ while os.path.islink(result):
243
+ link = os.readlink(result)
244
+ candidate = link if os.path.isabs(link) else os.path.normpath(os.path.join(os.path.dirname(result), link))
245
+ # normpath through a symlinked directory may point at a different file - stop resolving there
246
+ if not (os.path.exists(candidate) and os.path.samefile(real_path, candidate)):
247
+ return result
248
+ result = candidate
249
+ return result
250
+
228
251
  def _try_posix_fallback_executable(self, base_executable: str) -> str | None:
229
252
  """Find a versioned Python binary as fallback for POSIX virtual environments."""
230
253
  major, minor = self.version_info.major, self.version_info.minor
@@ -431,16 +431,15 @@ def test_predicate_none_is_noop(session_cache: DiskCache) -> None:
431
431
  def test_predicate_with_fallback_specs(session_cache: DiskCache) -> None:
432
432
  current = PythonInfo.current_system(session_cache)
433
433
  major, minor = current.version_info.major, current.version_info.minor
434
- accepted_exe: str | None = None
434
+ proposed: list[str] = []
435
435
 
436
436
  def reject_first(info: PythonInfo) -> bool:
437
- nonlocal accepted_exe
438
- if accepted_exe is None:
439
- accepted_exe = str(info.executable)
440
- return False
441
- return True
437
+ proposed.append(str(info.executable))
438
+ return len(proposed) > 1
442
439
 
443
440
  result = get_interpreter([f"{major}.{minor}", sys.executable], [], session_cache, predicate=reject_first)
444
- assert accepted_exe is not None
441
+ # rejecting the first proposal must not abort discovery; a later proposal (same or fallback spec) is accepted -
442
+ # on hosts where every alias resolves to one interpreter the fallback re-proposes it, so executables may match
445
443
  assert result is not None
446
- assert str(result.executable) != accepted_exe
444
+ assert len(proposed) > 1
445
+ assert str(result.executable) == proposed[-1]
@@ -19,6 +19,8 @@ except ImportError: # pragma: no cover
19
19
  tk = None # type: ignore[assignment]
20
20
 
21
21
  if TYPE_CHECKING:
22
+ from collections.abc import Callable
23
+
22
24
  from pytest_mock import MockerFixture
23
25
 
24
26
  CURRENT = PythonInfo.current_system()
@@ -71,7 +73,9 @@ def test_fast_get_system_executable_not_venv() -> None:
71
73
  info = PythonInfo()
72
74
  info.real_prefix = None
73
75
  info.base_prefix = info.prefix
74
- assert info._fast_get_system_executable() == info.original_executable
76
+ result = info._fast_get_system_executable()
77
+ assert result is not None
78
+ assert Path(result).samefile(info.original_executable)
75
79
 
76
80
 
77
81
  def test_fast_get_system_executable_real_prefix() -> None:
@@ -96,6 +100,105 @@ def test_fast_get_system_executable_same_as_current(mocker: MockerFixture) -> No
96
100
  assert info._fast_get_system_executable() is None
97
101
 
98
102
 
103
+ @pytest.fixture
104
+ def posix_info() -> PythonInfo:
105
+ info = PythonInfo()
106
+ info.os = "posix"
107
+ return info
108
+
109
+
110
+ def test_resolve_executable_symlink_not_posix() -> None:
111
+ info = PythonInfo()
112
+ info.os = "nt"
113
+ assert info._resolve_executable_symlink("/some/python") == str(Path("/some/python").resolve())
114
+
115
+
116
+ def _layout_regular_file(tmp_path: Path) -> tuple[Path, Path]:
117
+ exe = tmp_path / "python"
118
+ exe.touch()
119
+ return exe, exe
120
+
121
+
122
+ def _layout_broken_symlink(tmp_path: Path) -> tuple[Path, Path]:
123
+ link = tmp_path / "python"
124
+ link.symlink_to(tmp_path / "missing")
125
+ return link, link
126
+
127
+
128
+ def _layout_absolute_symlink(tmp_path: Path) -> tuple[Path, Path]:
129
+ exe = tmp_path / "install" / "bin" / "python3.12"
130
+ exe.parent.mkdir(parents=True)
131
+ exe.touch()
132
+ link = tmp_path / "symdir" / "python3"
133
+ link.parent.mkdir()
134
+ link.symlink_to(exe)
135
+ return link, exe
136
+
137
+
138
+ def _layout_relative_chain(tmp_path: Path) -> tuple[Path, Path]:
139
+ exe = tmp_path / "python3.12"
140
+ exe.touch()
141
+ (tmp_path / "python3").symlink_to("python3.12")
142
+ link = tmp_path / "python"
143
+ link.symlink_to("python3")
144
+ return link, exe
145
+
146
+
147
+ def _layout_tree_symlink(tmp_path: Path) -> tuple[Path, Path]:
148
+ real_bin = tmp_path / "install" / "bin"
149
+ real_bin.mkdir(parents=True)
150
+ (real_bin / "python3").touch()
151
+ tree_link = tmp_path / "tree"
152
+ tree_link.symlink_to(tmp_path / "install")
153
+ via_tree = tree_link / "bin" / "python3"
154
+ return via_tree, via_tree
155
+
156
+
157
+ def _layout_normpath_mismatch(tmp_path: Path) -> tuple[Path, Path]:
158
+ real_dir = tmp_path / "deep" / "real"
159
+ real_dir.mkdir(parents=True)
160
+ (tmp_path / "deep" / "exe").touch()
161
+ (real_dir / "python").symlink_to("../exe")
162
+ dir_link = tmp_path / "link"
163
+ dir_link.symlink_to(real_dir)
164
+ via_link = dir_link / "python"
165
+ return via_link, via_link
166
+
167
+
168
+ @pytest.mark.skipif(sys.platform == "win32", reason="POSIX only")
169
+ @pytest.mark.parametrize(
170
+ "layout",
171
+ [
172
+ pytest.param(_layout_regular_file, id="regular-file"),
173
+ pytest.param(_layout_broken_symlink, id="broken-symlink"),
174
+ pytest.param(_layout_absolute_symlink, id="absolute-symlink"),
175
+ pytest.param(_layout_relative_chain, id="relative-chain"),
176
+ pytest.param(_layout_tree_symlink, id="tree-preserved"),
177
+ pytest.param(_layout_normpath_mismatch, id="normpath-mismatch"),
178
+ ],
179
+ )
180
+ def test_resolve_executable_symlink(
181
+ tmp_path: Path,
182
+ posix_info: PythonInfo,
183
+ layout: Callable[[Path], tuple[Path, Path]],
184
+ ) -> None:
185
+ path, expected = layout(tmp_path)
186
+ assert posix_info._resolve_executable_symlink(str(path)) == str(expected)
187
+
188
+
189
+ @pytest.mark.skipif(sys.platform == "win32", reason="POSIX only")
190
+ def test_from_exe_resolves_executable_only_symlink(tmp_path: Path, session_cache: DiskCache) -> None:
191
+ system_exe = CURRENT.system_executable
192
+ assert system_exe is not None
193
+ link = tmp_path / "python3"
194
+ link.symlink_to(system_exe)
195
+ info = PythonInfo.from_exe(str(link), session_cache, ignore_cache=True)
196
+ assert info is not None
197
+ assert info.system_executable is not None
198
+ assert Path(info.system_executable).samefile(system_exe)
199
+ assert not Path(info.system_executable).is_symlink()
200
+
201
+
99
202
  def test_try_posix_fallback_not_posix() -> None:
100
203
  info = PythonInfo()
101
204
  info.os = "nt"
@@ -199,11 +199,11 @@ html.skip_covered = false
199
199
 
200
200
  [tool.towncrier]
201
201
  name = "python-discovery"
202
- filename = "docs/changelog.rst"
203
202
  directory = "docs/changelog"
203
+ filename = "docs/changelog.rst"
204
+ template = "docs/changelog/template.jinja2"
204
205
  title_format = false
205
206
  issue_format = ":issue:`{issue}`"
206
- template = "docs/changelog/template.jinja2"
207
207
  type = [
208
208
  { directory = "breaking", name = "Backward incompatible changes", showcontent = true },
209
209
  { directory = "deprecation", name = "Deprecations (removal in next major release)", showcontent = true },