diff --git a/docs/changelog/80.feature.rst b/docs/changelog/80.feature.rst new file mode 100644 index 0000000..47e39a3 --- /dev/null +++ b/docs/changelog/80.feature.rst @@ -0,0 +1,2 @@ +Add ``debug_build`` attribute to :class:`PythonInfo` exposing whether the interpreter is a debug build +(``Py_DEBUG``) - by :user:`gaborbernat`. diff --git a/src/python_discovery/_py_info.py b/src/python_discovery/_py_info.py index 2679ae4..5022b60 100644 --- a/src/python_discovery/_py_info.py +++ b/src/python_discovery/_py_info.py @@ -12,6 +12,7 @@ import sysconfig import warnings from collections import OrderedDict +from itertools import product from string import digits from typing import TYPE_CHECKING, ClassVar, Final, NamedTuple @@ -73,6 +74,7 @@ def _init_identity(self) -> None: self.version = sys.version self.os = os.name self.free_threaded = sysconfig.get_config_var("Py_GIL_DISABLED") == 1 + self.debug_build = bool(sysconfig.get_config_var("Py_DEBUG")) def _init_prefixes(self) -> None: def abs_path(value: str | None) -> str | None: @@ -404,10 +406,11 @@ def machine(self) -> str: @property def spec(self) -> str: """A specification string identifying this interpreter (e.g. ``CPython3.13.2-64-arm64``).""" - return "{}{}{}-{}-{}".format( + return "{}{}{}{}-{}-{}".format( self.implementation, ".".join(str(i) for i in self.version_info), "t" if self.free_threaded else "", + "d" if self.debug_build else "", self.architecture, self.machine, ) @@ -731,17 +734,14 @@ def _find_possible_folders(self, inside_folder: str) -> list[str]: def _find_possible_exe_names(self) -> list[str]: name_candidate = OrderedDict() + mods = ["", "t"] if self.free_threaded else [""] + debug_suffixes = ["_d", ""] if self.debug_build else [""] + archs = [f"-{self.architecture}", ""] for name in self._possible_base(): for at in (3, 2, 1, 0): version = ".".join(str(i) for i in self.version_info[:at]) - mods = [""] - if self.free_threaded: - mods.append("t") - for mod in mods: - for arch in [f"-{self.architecture}", ""]: - for ext in EXTENSIONS: - candidate = f"{name}{version}{mod}{arch}{ext}" - name_candidate[candidate] = None + for mod, debug, arch, ext in product(mods, debug_suffixes, archs, EXTENSIONS): + name_candidate[f"{name}{version}{mod}{debug}{arch}{ext}"] = None return list(name_candidate.keys()) def _possible_base(self) -> Generator[str, None, None]: diff --git a/tests/py_info/test_py_info.py b/tests/py_info/test_py_info.py index 92c23d8..162a757 100644 --- a/tests/py_info/test_py_info.py +++ b/tests/py_info/test_py_info.py @@ -40,6 +40,7 @@ def test_current_as_json() -> None: "serial": serial, } assert parsed["free_threaded"] is free_threaded + assert parsed["debug_build"] is bool(sysconfig.get_config_var("Py_DEBUG")) 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 assert info.satisfies(spec, impl_must_match=True) is True +@pytest.mark.parametrize("debug", [True, False], ids=["debug", "release"]) +def test_py_info_debug_build_in_spec(*, debug: bool) -> None: + info = copy.deepcopy(CURRENT) + info.debug_build = debug + assert ("d-" in info.spec) is debug + + +@pytest.mark.parametrize("debug", [True, False], ids=["debug", "release"]) +def test_py_info_debug_build_exe_names(*, debug: bool) -> None: + info = copy.deepcopy(CURRENT) + info.debug_build = debug + names = info._find_possible_exe_names() + assert any("_d" in n for n in names) is debug + + +def test_py_info_debug_build_json_round_trip() -> None: + info = copy.deepcopy(CURRENT) + info.debug_build = True + restored = PythonInfo.from_json(info.to_json()) + assert restored.debug_build is True + + def test_py_info_to_dict_includes_sysconfig_platform() -> None: data = CURRENT.to_dict() assert "sysconfig_platform" in data