python-discovery 1.3.1__tar.gz → 1.4.0__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.3.1 → python_discovery-1.4.0}/.pre-commit-config.yaml +2 -2
  2. {python_discovery-1.3.1 → python_discovery-1.4.0}/PKG-INFO +1 -1
  3. {python_discovery-1.3.1 → python_discovery-1.4.0}/docs/changelog.rst +16 -0
  4. {python_discovery-1.3.1 → python_discovery-1.4.0}/src/python_discovery/_discovery.py +9 -7
  5. {python_discovery-1.3.1 → python_discovery-1.4.0}/src/python_discovery/_py_info.py +36 -40
  6. {python_discovery-1.3.1 → python_discovery-1.4.0}/src/python_discovery/_py_spec.py +1 -1
  7. {python_discovery-1.3.1 → python_discovery-1.4.0}/src/python_discovery/_windows/_pep514.py +11 -11
  8. {python_discovery-1.3.1 → python_discovery-1.4.0}/tasks/release.py +25 -19
  9. {python_discovery-1.3.1 → python_discovery-1.4.0}/tests/py_info/test_py_info.py +23 -0
  10. {python_discovery-1.3.1 → python_discovery-1.4.0}/tests/test_py_spec.py +1 -0
  11. {python_discovery-1.3.1 → python_discovery-1.4.0}/.github/FUNDING.yaml +0 -0
  12. {python_discovery-1.3.1 → python_discovery-1.4.0}/.github/SECURITY.md +0 -0
  13. {python_discovery-1.3.1 → python_discovery-1.4.0}/.github/dependabot.yaml +0 -0
  14. {python_discovery-1.3.1 → python_discovery-1.4.0}/.github/release.yaml +0 -0
  15. {python_discovery-1.3.1 → python_discovery-1.4.0}/.github/workflows/check.yaml +0 -0
  16. {python_discovery-1.3.1 → python_discovery-1.4.0}/.github/workflows/prepare-release.yaml +0 -0
  17. {python_discovery-1.3.1 → python_discovery-1.4.0}/.github/workflows/release.yaml +0 -0
  18. {python_discovery-1.3.1 → python_discovery-1.4.0}/.gitignore +0 -0
  19. {python_discovery-1.3.1 → python_discovery-1.4.0}/.readthedocs.yaml +0 -0
  20. {python_discovery-1.3.1 → python_discovery-1.4.0}/.readthedocs.yml +0 -0
  21. {python_discovery-1.3.1 → python_discovery-1.4.0}/CODE_OF_CONDUCT.md +0 -0
  22. {python_discovery-1.3.1 → python_discovery-1.4.0}/LICENSE +0 -0
  23. {python_discovery-1.3.1 → python_discovery-1.4.0}/README.md +0 -0
  24. {python_discovery-1.3.1 → python_discovery-1.4.0}/docs/_static/custom.css +0 -0
  25. {python_discovery-1.3.1 → python_discovery-1.4.0}/docs/_static/logo.svg +0 -0
  26. {python_discovery-1.3.1 → python_discovery-1.4.0}/docs/changelog/template.jinja2 +0 -0
  27. {python_discovery-1.3.1 → python_discovery-1.4.0}/docs/conf.py +0 -0
  28. {python_discovery-1.3.1 → python_discovery-1.4.0}/docs/explanation.rst +0 -0
  29. {python_discovery-1.3.1 → python_discovery-1.4.0}/docs/how-to/standalone-usage.rst +0 -0
  30. {python_discovery-1.3.1 → python_discovery-1.4.0}/docs/index.rst +0 -0
  31. {python_discovery-1.3.1 → python_discovery-1.4.0}/docs/reference/api.rst +0 -0
  32. {python_discovery-1.3.1 → python_discovery-1.4.0}/docs/reference/environment-variables.rst +0 -0
  33. {python_discovery-1.3.1 → python_discovery-1.4.0}/docs/tutorial/getting-started.rst +0 -0
  34. {python_discovery-1.3.1 → python_discovery-1.4.0}/pyproject.toml +0 -0
  35. {python_discovery-1.3.1 → python_discovery-1.4.0}/src/python_discovery/__init__.py +0 -0
  36. {python_discovery-1.3.1 → python_discovery-1.4.0}/src/python_discovery/_cache.py +0 -0
  37. {python_discovery-1.3.1 → python_discovery-1.4.0}/src/python_discovery/_cached_py_info.py +0 -0
  38. {python_discovery-1.3.1 → python_discovery-1.4.0}/src/python_discovery/_compat.py +0 -0
  39. {python_discovery-1.3.1 → python_discovery-1.4.0}/src/python_discovery/_specifier.py +0 -0
  40. {python_discovery-1.3.1 → python_discovery-1.4.0}/src/python_discovery/_windows/__init__.py +0 -0
  41. {python_discovery-1.3.1 → python_discovery-1.4.0}/src/python_discovery/_windows/_propose.py +0 -0
  42. {python_discovery-1.3.1 → python_discovery-1.4.0}/src/python_discovery/py.typed +0 -0
  43. {python_discovery-1.3.1 → python_discovery-1.4.0}/tests/conftest.py +0 -0
  44. {python_discovery-1.3.1 → python_discovery-1.4.0}/tests/py_info/test_py_info_exe_based_of.py +0 -0
  45. {python_discovery-1.3.1 → python_discovery-1.4.0}/tests/test_cache.py +0 -0
  46. {python_discovery-1.3.1 → python_discovery-1.4.0}/tests/test_cached_py_info.py +0 -0
  47. {python_discovery-1.3.1 → python_discovery-1.4.0}/tests/test_discovery.py +0 -0
  48. {python_discovery-1.3.1 → python_discovery-1.4.0}/tests/test_discovery_extra.py +0 -0
  49. {python_discovery-1.3.1 → python_discovery-1.4.0}/tests/test_iter_interpreters.py +0 -0
  50. {python_discovery-1.3.1 → python_discovery-1.4.0}/tests/test_py_info_extra.py +0 -0
  51. {python_discovery-1.3.1 → python_discovery-1.4.0}/tests/test_py_spec_extra.py +0 -0
  52. {python_discovery-1.3.1 → python_discovery-1.4.0}/tests/test_specifier.py +0 -0
  53. {python_discovery-1.3.1 → python_discovery-1.4.0}/tests/windows/conftest.py +0 -0
  54. {python_discovery-1.3.1 → python_discovery-1.4.0}/tests/windows/test_windows.py +0 -0
  55. {python_discovery-1.3.1 → python_discovery-1.4.0}/tests/windows/test_windows_pep514.py +0 -0
  56. {python_discovery-1.3.1 → python_discovery-1.4.0}/tests/windows/winreg_mock_values.py +0 -0
  57. {python_discovery-1.3.1 → python_discovery-1.4.0}/tox.toml +0 -0
@@ -19,7 +19,7 @@ repos:
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.1
3
+ Version: 1.4.0
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,22 @@
6
6
 
7
7
  .. towncrier release notes start
8
8
 
9
+ ********************
10
+ v1.4.0 (2026-05-28)
11
+ ********************
12
+
13
+ Features - 1.4.0
14
+ ================
15
+ - Add ``debug_build`` attribute to :class:`PythonInfo` exposing whether the interpreter is a debug build
16
+ (``Py_DEBUG``) - by :user:`gaborbernat`. (:issue:`80`)
17
+
18
+ ********************
19
+ v1.3.2 (2026-05-27)
20
+ ********************
21
+
22
+ No significant changes.
23
+
24
+
9
25
  ********************
10
26
  v1.3.1 (2026-05-12)
11
27
  ********************
@@ -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
@@ -12,10 +12,12 @@ import sys
12
12
  import sysconfig
13
13
  import warnings
14
14
  from collections import OrderedDict
15
+ from itertools import product
15
16
  from string import digits
16
17
  from typing import TYPE_CHECKING, ClassVar, Final, NamedTuple
17
18
 
18
19
  if TYPE_CHECKING:
20
+ import tkinter as tk
19
21
  from collections.abc import Generator, Mapping
20
22
 
21
23
  from ._cache import PyInfoCache
@@ -72,6 +74,7 @@ class PythonInfo: # noqa: PLR0904
72
74
  self.version = sys.version
73
75
  self.os = os.name
74
76
  self.free_threaded = sysconfig.get_config_var("Py_GIL_DISABLED") == 1
77
+ self.debug_build = bool(sysconfig.get_config_var("Py_DEBUG"))
75
78
 
76
79
  def _init_prefixes(self) -> None:
77
80
  def abs_path(value: str | None) -> str | None:
@@ -165,42 +168,37 @@ class PythonInfo: # noqa: PLR0904
165
168
  try:
166
169
  tcl = tk.Tcl()
167
170
  tcl_lib = tcl.eval("info library")
168
-
169
- # Try to get TK library path directly first
170
- try:
171
- tk_lib = tcl.eval("set tk_library")
172
- if tk_lib and os.path.isdir(tk_lib):
173
- pass # We found it directly
174
- else:
175
- tk_lib = None # Reset if invalid
176
- except tk.TclError:
177
- tk_lib = None
178
-
179
- # If direct query failed, try constructing the path
180
- if tk_lib is None:
181
- tk_version = tcl.eval("package require Tk")
182
- tcl_parent = os.path.dirname(tcl_lib)
183
-
184
- # Try different version formats
185
- version_variants = [
186
- tk_version, # Full version like "8.6.12"
187
- ".".join(tk_version.split(".")[:2]), # Major.minor like "8.6"
188
- tk_version.split(".")[0], # Just major like "8"
189
- ]
190
-
191
- for version in version_variants:
192
- tk_lib_path = os.path.join(tcl_parent, f"tk{version}")
193
- if not os.path.isdir(tk_lib_path):
194
- continue
195
- if os.path.exists(os.path.join(tk_lib_path, "tk.tcl")):
196
- tk_lib = tk_lib_path
197
- break
198
-
171
+ tk_lib = PythonInfo._resolve_tk_lib(tcl, tcl_lib)
199
172
  except tk.TclError:
200
173
  pass
201
174
 
202
175
  return tcl_lib, tk_lib
203
176
 
177
+ @staticmethod
178
+ def _query_tk_library(tcl: tk.Tk) -> str | None: # pragma: no cover
179
+ """Try to get the TK library path directly from Tcl."""
180
+ import tkinter as tk # noqa: PLC0415
181
+
182
+ try:
183
+ if (tk_lib := tcl.eval("set tk_library")) and os.path.isdir(tk_lib):
184
+ return tk_lib
185
+ except tk.TclError:
186
+ pass
187
+ return None
188
+
189
+ @staticmethod
190
+ def _resolve_tk_lib(tcl: tk.Tk, tcl_lib: str) -> str | None: # pragma: no cover
191
+ """Resolve the TK library path by direct query or path construction."""
192
+ if (tk_lib := PythonInfo._query_tk_library(tcl)) is not None:
193
+ return tk_lib
194
+ tk_version = tcl.eval("package require Tk")
195
+ tcl_parent = os.path.dirname(tcl_lib)
196
+ for version in (tk_version, ".".join(tk_version.split(".")[:2]), tk_version.split(".")[0]):
197
+ tk_lib_path = os.path.join(tcl_parent, f"tk{version}")
198
+ if os.path.isdir(tk_lib_path) and os.path.exists(os.path.join(tk_lib_path, "tk.tcl")):
199
+ return tk_lib_path
200
+ return None
201
+
204
202
  def _fast_get_system_executable(self) -> str | None:
205
203
  """Try to get the system executable by just looking at properties."""
206
204
  # if we're not in a virtual environment, this is already a system python, so return the original executable
@@ -408,10 +406,11 @@ class PythonInfo: # noqa: PLR0904
408
406
  @property
409
407
  def spec(self) -> str:
410
408
  """A specification string identifying this interpreter (e.g. ``CPython3.13.2-64-arm64``)."""
411
- return "{}{}{}-{}-{}".format(
409
+ return "{}{}{}{}-{}-{}".format(
412
410
  self.implementation,
413
411
  ".".join(str(i) for i in self.version_info),
414
412
  "t" if self.free_threaded else "",
413
+ "d" if self.debug_build else "",
415
414
  self.architecture,
416
415
  self.machine,
417
416
  )
@@ -735,17 +734,14 @@ class PythonInfo: # noqa: PLR0904
735
734
 
736
735
  def _find_possible_exe_names(self) -> list[str]:
737
736
  name_candidate = OrderedDict()
737
+ mods = ["", "t"] if self.free_threaded else [""]
738
+ debug_suffixes = ["_d", ""] if self.debug_build else [""]
739
+ archs = [f"-{self.architecture}", ""]
738
740
  for name in self._possible_base():
739
741
  for at in (3, 2, 1, 0):
740
742
  version = ".".join(str(i) for i in self.version_info[:at])
741
- mods = [""]
742
- if self.free_threaded:
743
- mods.append("t")
744
- for mod in mods:
745
- for arch in [f"-{self.architecture}", ""]:
746
- for ext in EXTENSIONS:
747
- candidate = f"{name}{version}{mod}{arch}{ext}"
748
- name_candidate[candidate] = None
743
+ for mod, debug, arch, ext in product(mods, debug_suffixes, archs, EXTENSIONS):
744
+ name_candidate[f"{name}{version}{mod}{debug}{arch}{ext}"] = None
749
745
  return list(name_candidate.keys())
750
746
 
751
747
  def _possible_base(self) -> Generator[str, None, None]:
@@ -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,
@@ -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:
@@ -19,29 +19,13 @@ def main(version_str: str) -> None:
19
19
  msg = "Current repository is dirty. Please commit any changes and try again."
20
20
  raise RuntimeError(msg)
21
21
  upstream, release_branch = create_release_branch(repo, version)
22
+ original_main_sha = upstream.refs.main.commit.hexsha
22
23
  main_pushed = False
23
24
  tag_pushed = False
24
25
  release_created = False
25
- original_main_sha = upstream.refs.main.commit.hexsha
26
26
  try:
27
- release_commit = release_changelog(repo, version)
28
- tag = tag_release_commit(release_commit, repo, version)
29
- print("push release commit") # noqa: T201
30
- repo.git.push(upstream.name, f"{release_branch}:main", "-f")
31
- main_pushed = True
32
- print("push release tag") # noqa: T201
33
- repo.git.push(upstream.name, tag, "-f")
34
- tag_pushed = True
35
- create_github_release(version)
36
- release_created = True
37
- print("checkout main to new release and delete release branch") # noqa: T201
38
- repo.heads.main.checkout()
39
- repo.delete_head(release_branch, force=True)
40
- print("delete remote release branch") # noqa: T201
41
- repo.git.push(upstream.name, f":{release_branch}", "--no-verify")
42
- upstream.fetch()
43
- repo.git.reset("--hard", f"{upstream.name}/main")
44
- print("All done!") # noqa: T201
27
+ main_pushed, tag_pushed, release_created = push_release(repo, upstream, release_branch, version)
28
+ finalize_release(repo, upstream, release_branch)
45
29
  except Exception:
46
30
  cleanup_failed_release(
47
31
  repo,
@@ -76,6 +60,17 @@ def get_upstream(repo: Repo) -> Remote:
76
60
  raise RuntimeError(msg)
77
61
 
78
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
+
79
74
  def release_changelog(repo: Repo, version: Version) -> Commit:
80
75
  print("generate release commit") # noqa: T201
81
76
  check_call(["towncrier", "build", "--yes", "--version", version.public], cwd=str(ROOT_SRC_DIR)) # noqa: S607
@@ -121,6 +116,17 @@ def create_github_release(version: Version) -> None:
121
116
  raise
122
117
 
123
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
+
124
130
  def cleanup_failed_release( # noqa: PLR0913
125
131
  repo: Repo,
126
132
  upstream: Remote,
@@ -40,6 +40,7 @@ def test_current_as_json() -> None:
40
40
  "serial": serial,
41
41
  }
42
42
  assert parsed["free_threaded"] is free_threaded
43
+ assert parsed["debug_build"] is bool(sysconfig.get_config_var("Py_DEBUG"))
43
44
 
44
45
 
45
46
  def test_bad_exe_py_info_raise(tmp_path: Path, session_cache: DiskCache) -> None:
@@ -418,6 +419,28 @@ def test_py_info_satisfies_machine_cross_os_normalization(platform: str, spec_ma
418
419
  assert info.satisfies(spec, impl_must_match=True) is True
419
420
 
420
421
 
422
+ @pytest.mark.parametrize("debug", [True, False], ids=["debug", "release"])
423
+ def test_py_info_debug_build_in_spec(*, debug: bool) -> None:
424
+ info = copy.deepcopy(CURRENT)
425
+ info.debug_build = debug
426
+ assert ("d-" in info.spec) is debug
427
+
428
+
429
+ @pytest.mark.parametrize("debug", [True, False], ids=["debug", "release"])
430
+ def test_py_info_debug_build_exe_names(*, debug: bool) -> None:
431
+ info = copy.deepcopy(CURRENT)
432
+ info.debug_build = debug
433
+ names = info._find_possible_exe_names()
434
+ assert any("_d" in n for n in names) is debug
435
+
436
+
437
+ def test_py_info_debug_build_json_round_trip() -> None:
438
+ info = copy.deepcopy(CURRENT)
439
+ info.debug_build = True
440
+ restored = PythonInfo.from_json(info.to_json())
441
+ assert restored.debug_build is True
442
+
443
+
421
444
  def test_py_info_to_dict_includes_sysconfig_platform() -> None:
422
445
  data = CURRENT.to_dict()
423
446
  assert "sysconfig_platform" in data
@@ -180,6 +180,7 @@ def test_specifier_satisfies_with_partial_information() -> None:
180
180
  pytest.param("cpython3.12-64", None, id="no-machine"),
181
181
  pytest.param("cpython3.12", None, id="no-arch-no-machine"),
182
182
  pytest.param("python3.12-64-arm64", "arm64", id="python-impl"),
183
+ pytest.param("cpython3.14-64-i86pc.64bit", "i86pc.64bit", id="dotted-machine"),
183
184
  ],
184
185
  )
185
186
  def test_spec_parse_machine(spec_str: str, expected_machine: str | None) -> None: