python-discovery 1.5.3__tar.gz → 1.6.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 (61) hide show
  1. {python_discovery-1.5.3 → python_discovery-1.6.1}/.pre-commit-config.yaml +3 -3
  2. {python_discovery-1.5.3 → python_discovery-1.6.1}/PKG-INFO +1 -1
  3. {python_discovery-1.5.3 → python_discovery-1.6.1}/docs/changelog.rst +27 -0
  4. {python_discovery-1.5.3 → python_discovery-1.6.1}/docs/explanation.rst +35 -1
  5. {python_discovery-1.5.3 → python_discovery-1.6.1}/docs/how-to/standalone-usage.rst +10 -2
  6. {python_discovery-1.5.3 → python_discovery-1.6.1}/docs/tutorial/getting-started.rst +7 -0
  7. {python_discovery-1.5.3 → python_discovery-1.6.1}/pyproject.toml +1 -0
  8. {python_discovery-1.5.3 → python_discovery-1.6.1}/src/python_discovery/_discovery.py +6 -1
  9. {python_discovery-1.5.3 → python_discovery-1.6.1}/src/python_discovery/_py_info.py +11 -0
  10. {python_discovery-1.5.3 → python_discovery-1.6.1}/tests/py_info/test_py_info.py +14 -0
  11. {python_discovery-1.5.3 → python_discovery-1.6.1}/tests/test_discovery.py +32 -0
  12. {python_discovery-1.5.3 → python_discovery-1.6.1}/.github/FUNDING.yaml +0 -0
  13. {python_discovery-1.5.3 → python_discovery-1.6.1}/.github/SECURITY.md +0 -0
  14. {python_discovery-1.5.3 → python_discovery-1.6.1}/.github/dependabot.yaml +0 -0
  15. {python_discovery-1.5.3 → python_discovery-1.6.1}/.github/release.yaml +0 -0
  16. {python_discovery-1.5.3 → python_discovery-1.6.1}/.github/workflows/check.yaml +0 -0
  17. {python_discovery-1.5.3 → python_discovery-1.6.1}/.github/workflows/prepare-release.yaml +0 -0
  18. {python_discovery-1.5.3 → python_discovery-1.6.1}/.github/workflows/release.yaml +0 -0
  19. {python_discovery-1.5.3 → python_discovery-1.6.1}/.gitignore +0 -0
  20. {python_discovery-1.5.3 → python_discovery-1.6.1}/.readthedocs.yaml +0 -0
  21. {python_discovery-1.5.3 → python_discovery-1.6.1}/.readthedocs.yml +0 -0
  22. {python_discovery-1.5.3 → python_discovery-1.6.1}/CODE_OF_CONDUCT.md +0 -0
  23. {python_discovery-1.5.3 → python_discovery-1.6.1}/LICENSE +0 -0
  24. {python_discovery-1.5.3 → python_discovery-1.6.1}/README.md +0 -0
  25. {python_discovery-1.5.3 → python_discovery-1.6.1}/docs/_static/custom.css +0 -0
  26. {python_discovery-1.5.3 → python_discovery-1.6.1}/docs/_static/logo.svg +0 -0
  27. {python_discovery-1.5.3 → python_discovery-1.6.1}/docs/changelog/template.jinja2 +0 -0
  28. {python_discovery-1.5.3 → python_discovery-1.6.1}/docs/conf.py +0 -0
  29. {python_discovery-1.5.3 → python_discovery-1.6.1}/docs/index.rst +0 -0
  30. {python_discovery-1.5.3 → python_discovery-1.6.1}/docs/reference/api.rst +0 -0
  31. {python_discovery-1.5.3 → python_discovery-1.6.1}/docs/reference/environment-variables.rst +0 -0
  32. {python_discovery-1.5.3 → python_discovery-1.6.1}/src/python_discovery/__init__.py +0 -0
  33. {python_discovery-1.5.3 → python_discovery-1.6.1}/src/python_discovery/_cache.py +0 -0
  34. {python_discovery-1.5.3 → python_discovery-1.6.1}/src/python_discovery/_cached_py_info.py +0 -0
  35. {python_discovery-1.5.3 → python_discovery-1.6.1}/src/python_discovery/_compat.py +0 -0
  36. {python_discovery-1.5.3 → python_discovery-1.6.1}/src/python_discovery/_py_info_collect.py +0 -0
  37. {python_discovery-1.5.3 → python_discovery-1.6.1}/src/python_discovery/_py_spec.py +0 -0
  38. {python_discovery-1.5.3 → python_discovery-1.6.1}/src/python_discovery/_specifier.py +0 -0
  39. {python_discovery-1.5.3 → python_discovery-1.6.1}/src/python_discovery/_windows/__init__.py +0 -0
  40. {python_discovery-1.5.3 → python_discovery-1.6.1}/src/python_discovery/_windows/_pep514.py +0 -0
  41. {python_discovery-1.5.3 → python_discovery-1.6.1}/src/python_discovery/_windows/_propose.py +0 -0
  42. {python_discovery-1.5.3 → python_discovery-1.6.1}/src/python_discovery/py.typed +0 -0
  43. {python_discovery-1.5.3 → python_discovery-1.6.1}/tasks/old_target_check.py +0 -0
  44. {python_discovery-1.5.3 → python_discovery-1.6.1}/tasks/old_target_check.sh +0 -0
  45. {python_discovery-1.5.3 → python_discovery-1.6.1}/tasks/release.py +0 -0
  46. {python_discovery-1.5.3 → python_discovery-1.6.1}/tests/conftest.py +0 -0
  47. {python_discovery-1.5.3 → python_discovery-1.6.1}/tests/py_info/test_py_info_exe_based_of.py +0 -0
  48. {python_discovery-1.5.3 → python_discovery-1.6.1}/tests/test_cache.py +0 -0
  49. {python_discovery-1.5.3 → python_discovery-1.6.1}/tests/test_cached_py_info.py +0 -0
  50. {python_discovery-1.5.3 → python_discovery-1.6.1}/tests/test_discovery_extra.py +0 -0
  51. {python_discovery-1.5.3 → python_discovery-1.6.1}/tests/test_iter_interpreters.py +0 -0
  52. {python_discovery-1.5.3 → python_discovery-1.6.1}/tests/test_py_info_collect.py +0 -0
  53. {python_discovery-1.5.3 → python_discovery-1.6.1}/tests/test_py_info_extra.py +0 -0
  54. {python_discovery-1.5.3 → python_discovery-1.6.1}/tests/test_py_spec.py +0 -0
  55. {python_discovery-1.5.3 → python_discovery-1.6.1}/tests/test_py_spec_extra.py +0 -0
  56. {python_discovery-1.5.3 → python_discovery-1.6.1}/tests/test_specifier.py +0 -0
  57. {python_discovery-1.5.3 → python_discovery-1.6.1}/tests/windows/conftest.py +0 -0
  58. {python_discovery-1.5.3 → python_discovery-1.6.1}/tests/windows/test_windows.py +0 -0
  59. {python_discovery-1.5.3 → python_discovery-1.6.1}/tests/windows/test_windows_pep514.py +0 -0
  60. {python_discovery-1.5.3 → python_discovery-1.6.1}/tests/windows/winreg_mock_values.py +0 -0
  61. {python_discovery-1.5.3 → python_discovery-1.6.1}/tox.toml +0 -0
@@ -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.28.0"
18
+ rev: "v2.29.4"
19
19
  hooks:
20
20
  - id: pyproject-fmt
21
21
  - repo: https://github.com/astral-sh/ruff-pre-commit
22
- rev: "v0.16.3"
22
+ rev: "v0.16.7"
23
23
  hooks:
24
24
  - id: ruff-format
25
25
  - id: ruff
@@ -39,7 +39,7 @@ repos:
39
39
  - mdformat-gfm>=1
40
40
  - mdformat-ruff>=0.1.3
41
41
  - repo: https://github.com/zizmorcore/zizmor-pre-commit
42
- rev: v1.29.0
42
+ rev: v1.30.1
43
43
  hooks:
44
44
  - id: zizmor
45
45
  - repo: meta
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: python-discovery
3
- Version: 1.5.3
3
+ Version: 1.6.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,33 @@
6
6
 
7
7
  .. towncrier release notes start
8
8
 
9
+ ********************
10
+ v1.6.1 (2026-09-18)
11
+ ********************
12
+
13
+ Bug fixes - 1.6.1
14
+ =================
15
+ - Skip empty ``PATH`` entries during interpreter discovery - by :user:`gaborbernat`. (:issue:`129`)
16
+
17
+ Improved documentation - 1.6.1
18
+ ==============================
19
+ - Document :attr:`~python_discovery.PythonInfo.system_exe` across the tutorial, the how-to guide and the explanation of
20
+ how resolution reaches a base interpreter. The class diagram in the how-to guide had
21
+ :attr:`~python_discovery.PythonInfo.system_executable` typed ``str`` rather than ``str | None`` - by
22
+ :user:`gaborbernat`. (:issue:`128`)
23
+
24
+ ********************
25
+ v1.6.0 (2026-08-28)
26
+ ********************
27
+
28
+ Features - 1.6.0
29
+ ================
30
+ - :attr:`~python_discovery.PythonInfo.system_exe` returns the executable of the system Python an interpreter is based on,
31
+ falling back to :attr:`~python_discovery.PythonInfo.executable` the way
32
+ :meth:`~python_discovery.PythonInfo.resolve_to_system` does. :attr:`~python_discovery.PythonInfo.system_executable` is
33
+ ``None`` until resolution runs, so every consumer had to narrow a value discovery has already settled - by
34
+ :user:`gaborbernat`. (:issue:`127`)
35
+
9
36
  ********************
10
37
  v1.5.3 (2026-08-24)
11
38
  ********************
@@ -186,7 +186,7 @@ APIs whenever a spec is given.
186
186
 
187
187
  **Deduplication.** :func:`~python_discovery.get_interpreter` deduplicates per call so it does not interrogate the
188
188
  same binary twice while searching, and stops as soon as a match is found. :func:`~python_discovery.iter_interpreters`
189
- deduplicates by the resolved real path of each candidate's ``system_executable`` (falling back to ``executable``).
189
+ deduplicates by the resolved real path of each candidate's :attr:`~python_discovery.PythonInfo.system_exe`.
190
190
  That means symlinked aliases like ``/bin/python3`` and ``/usr/bin/python3``, or a virtualenv whose ``python``
191
191
  symlinks to its base interpreter, collapse to a single yield. The semantic is "one entry per distinct install,"
192
192
  which is what callers building choosers or version-range pickers usually want.
@@ -198,6 +198,40 @@ version first, smallest install root, etc.), wrap the call in :func:`sorted` --
198
198
  include a ``sort_by`` parameter because keeping discovery order preserves the priority signal for callers who
199
199
  want it.
200
200
 
201
+ Resolving a virtual environment to its base
202
+ --------------------------------------------
203
+
204
+ A :class:`~python_discovery.PythonInfo` reaches you in one of two states, and the difference shows up in a single
205
+ field.
206
+
207
+ Collection fills in :attr:`~python_discovery.PythonInfo.system_executable` by inspection alone, which works whenever
208
+ the interpreter is not in a virtual environment or names its base outright. When inspection comes up short the field
209
+ holds ``None``, and :meth:`~python_discovery.PythonInfo.resolve_to_system` walks the prefix chain a layer at a time,
210
+ interrogating each prefix until it reaches a real install. Every discovery entry point runs that walk, so an
211
+ interpreter you got from :func:`~python_discovery.get_interpreter`,
212
+ :func:`~python_discovery.iter_interpreters` or :meth:`~python_discovery.PythonInfo.from_exe` is resolved before you
213
+ see it.
214
+
215
+ .. mermaid::
216
+
217
+ flowchart TD
218
+ Collect["collection<br>system_executable = None"] --> Walk["resolve_to_system()<br>walk prefix chain"]
219
+ Walk --> Done["system_executable set"]
220
+ Collect -- "base named outright" --> Done
221
+ Done --> Read["system_exe"]
222
+
223
+ style Collect fill:#ffe3a3,stroke:#d29200,color:#3a2c00
224
+ style Walk fill:#4a90d9,stroke:#2a5f8f,color:#fff
225
+ style Done fill:#4a9f4a,stroke:#2a6f2a,color:#fff
226
+ style Read fill:#4a9f4a,stroke:#2a6f2a,color:#fff
227
+
228
+ The annotation cannot describe that ordering, so ``system_executable`` stays ``str | None`` and a type checker asks
229
+ every reader to narrow a value resolution has settled.
230
+ :attr:`~python_discovery.PythonInfo.system_exe` closes that gap by falling back to
231
+ :attr:`~python_discovery.PythonInfo.executable`, which is the value ``resolve_to_system`` writes anyway when a prefix
232
+ links back to its own interpreter. Reading the raw field still tells the two states apart, which is what
233
+ :meth:`~python_discovery.PythonInfo.from_exe` with ``resolve_to_host=False`` leaves you holding.
234
+
201
235
  How caching works
202
236
  -------------------
203
237
 
@@ -145,7 +145,8 @@ Once you have a :class:`~python_discovery.PythonInfo`, you can inspect everythin
145
145
  classDiagram
146
146
  class PythonInfo {
147
147
  +executable: str
148
- +system_executable: str
148
+ +system_exe: str
149
+ +system_executable: str | None
149
150
  +implementation: str
150
151
  +version_info: VersionInfo
151
152
  +architecture: int
@@ -167,7 +168,7 @@ Once you have a :class:`~python_discovery.PythonInfo`, you can inspect everythin
167
168
  info = get_interpreter("python3.12", cache=cache)
168
169
 
169
170
  info.executable # Resolved path to the binary.
170
- info.system_executable # The underlying system interpreter (outside any venv).
171
+ info.system_exe # The underlying system interpreter (outside any venv).
171
172
  info.implementation # "CPython", "PyPy", "GraalPy", etc.
172
173
  info.version_info # VersionInfo(major, minor, micro, releaselevel, serial).
173
174
  info.architecture # 64 or 32.
@@ -178,6 +179,13 @@ Once you have a :class:`~python_discovery.PythonInfo`, you can inspect everythin
178
179
  info.sysconfig_vars # All sysconfig.get_config_vars() values.
179
180
  info.sysconfig_paths # All sysconfig.get_paths() values.
180
181
 
182
+ Prefer :attr:`~python_discovery.PythonInfo.system_exe` over the raw
183
+ :attr:`~python_discovery.PythonInfo.system_executable` field it reads. The field is typed ``str | None`` because it
184
+ holds nothing until resolution runs, so a type checker makes you narrow it at every use even though discovery has
185
+ filled it in by the time you hold a :class:`~python_discovery.PythonInfo`. Read the raw field only when you build a
186
+ :class:`~python_discovery.PythonInfo` yourself, or call
187
+ :meth:`~python_discovery.PythonInfo.from_exe` with ``resolve_to_host=False``, and want to tell the two states apart.
188
+
181
189
  Implement a custom cache backend
182
190
  -----------------------------------
183
191
 
@@ -17,6 +17,7 @@ Before diving into code, here are the key ideas:
17
17
  - **Spec** -- a short string describing what you are looking for (e.g., ``python3.12``, ``pypy3.9``, ``>=3.11``).
18
18
  - **Discovery** -- the process of searching your system for an interpreter that matches a spec.
19
19
  - **Cache** -- a disk store that remembers previously discovered interpreters so the next lookup is instant.
20
+ - **Resolution** -- following a virtual environment back to the base interpreter it was built from.
20
21
 
21
22
  Inspecting the current interpreter
22
23
  ------------------------------------
@@ -45,6 +46,7 @@ The simplest use case: get information about the Python that is running right no
45
46
  info = PythonInfo.current_system(cache)
46
47
 
47
48
  print(info.executable) # /usr/bin/python3.12
49
+ print(info.system_exe) # /usr/bin/python3.12
48
50
  print(info.version_info[:3]) # (3, 12, 1)
49
51
  print(info.implementation) # CPython (or PyPy, GraalPy, etc.)
50
52
  print(info.architecture) # 64 (or 32)
@@ -52,6 +54,11 @@ The simplest use case: get information about the Python that is running right no
52
54
  The returned :class:`~python_discovery.PythonInfo` object contains everything the library knows about that interpreter:
53
55
  paths, version numbers, sysconfig variables, platform details, and more.
54
56
 
57
+ Run the same snippet from inside a virtual environment and the two paths part company.
58
+ :attr:`~python_discovery.PythonInfo.executable` stays the environment's own ``python``, while
59
+ :attr:`~python_discovery.PythonInfo.system_exe` names the base interpreter that environment was built from. Reach for
60
+ ``system_exe`` whenever you want the real install rather than a link to it.
61
+
55
62
  Finding a different interpreter
56
63
  --------------------------------
57
64
 
@@ -133,6 +133,7 @@ lint.per-file-ignores."tests/**/*.py" = [
133
133
  "import-private-name", # private imports needed to test internal APIs
134
134
  "magic-value-comparison", # Magic value used in comparison
135
135
  "private-member-access", # private member access needed to test internals
136
+ "pytest-fixture-autouse", # shared setup fixtures are meant to apply to every test
136
137
  "subprocess-without-shell-equals-true", # `subprocess` call: check for execution of untrusted input
137
138
  "suspicious-subprocess-import", # subprocess import
138
139
  "too-many-arguments", # too many arguments (pytest fixtures)
@@ -423,7 +423,12 @@ def get_paths(env: Mapping[str, str]) -> Generator[Path, None, None]:
423
423
  except (AttributeError, ValueError): # pragma: no cover # Windows only (no confstr)
424
424
  path = os.defpath
425
425
  if path:
426
- for entry in map(Path, path.split(os.pathsep)):
426
+ # An empty component (a leading/trailing/doubled separator) means "the current directory", the
427
+ # same footgun a shell has when PATH is misconfigured - Path("") resolves to it. Since every
428
+ # yielded path here is later searched for interpreters to execute and interrogate, silently
429
+ # including the caller's CWD would let whatever directory virtualenv happens to run from smuggle
430
+ # in a candidate binary.
431
+ for entry in map(Path, filter(None, path.split(os.pathsep))):
427
432
  with suppress(OSError):
428
433
  if entry.is_dir() and next(entry.iterdir(), None):
429
434
  yield entry
@@ -167,6 +167,17 @@ class PythonInfo: # ruff:ignore[too-many-public-methods]
167
167
  """The exec prefix of the system Python this interpreter is based on."""
168
168
  return self.real_prefix or self.base_exec_prefix or self.exec_prefix
169
169
 
170
+ @property
171
+ def system_exe(self) -> str:
172
+ """
173
+ The executable of the system Python this interpreter is based on.
174
+
175
+ :attr:`system_executable` stays ``None`` until :meth:`resolve_to_system` walks the prefix chain, so reading it
176
+ forces every caller to narrow a value discovery has already settled. The fallback here matches what resolution
177
+ itself writes when a prefix links back to its own interpreter.
178
+ """
179
+ return self.system_executable or self.executable
180
+
170
181
  def __repr__(self) -> str:
171
182
  return "{}({!r})".format(
172
183
  self.__class__.__name__,
@@ -511,3 +511,17 @@ def test_select_most_likely_prefers_machine_match(
511
511
  d.sysconfig_platform = plat
512
512
  result = PythonInfo._select_most_likely(discovered, target)
513
513
  assert result.sysconfig_platform == discovered_platforms[expected_idx]
514
+
515
+
516
+ @pytest.mark.parametrize(
517
+ ("system_executable", "expected"),
518
+ [
519
+ pytest.param("/usr/bin/python3", "/usr/bin/python3", id="resolved"),
520
+ pytest.param(None, "/venv/bin/python", id="unresolved falls back to executable"),
521
+ ],
522
+ )
523
+ def test_py_info_system_exe(system_executable: str | None, expected: str) -> None:
524
+ info = copy.deepcopy(CURRENT)
525
+ info.executable = "/venv/bin/python"
526
+ info.system_executable = system_executable
527
+ assert info.system_exe == expected
@@ -161,6 +161,38 @@ def test_get_paths_no_path_env(monkeypatch: pytest.MonkeyPatch) -> None:
161
161
  assert paths
162
162
 
163
163
 
164
+ @pytest.mark.parametrize(
165
+ ("raw_path", "expected"),
166
+ [
167
+ pytest.param("{first}{sep}", ["first"], id="trailing"),
168
+ pytest.param("{sep}{first}", ["first"], id="leading"),
169
+ pytest.param("{first}{sep}{sep}{second}", ["first", "second"], id="doubled"),
170
+ ],
171
+ )
172
+ def test_get_paths_skips_empty_entries(tmp_path: Path, raw_path: str, expected: list[str]) -> None:
173
+ """An empty PATH entry means "current directory" - it must never be searched for interpreters."""
174
+ dirs = {}
175
+ for name in ("first", "second"):
176
+ dirs[name] = tmp_path / name
177
+ dirs[name].mkdir()
178
+ (dirs[name] / "dummy").touch()
179
+
180
+ paths = list(get_paths({"PATH": raw_path.format(first=dirs["first"], second=dirs["second"], sep=os.pathsep)}))
181
+
182
+ assert paths == [dirs[name] for name in expected]
183
+
184
+
185
+ def test_get_paths_empty_entry_does_not_yield_cwd(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
186
+ """Reproduces the exploit: an empty PATH entry used to resolve to the caller's CWD."""
187
+ cwd_marker = tmp_path / "python3.11"
188
+ cwd_marker.touch(mode=0o755)
189
+ monkeypatch.chdir(tmp_path)
190
+
191
+ paths = list(get_paths({"PATH": f"{os.pathsep}"}))
192
+
193
+ assert not any(path.resolve() == tmp_path.resolve() for path in paths)
194
+
195
+
164
196
  def test_lazy_path_dump_debug(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
165
197
  monkeypatch.setenv("_VIRTUALENV_DEBUG", "1")
166
198
  a_dir = tmp_path