From 460111604a7037dc018f9619d3d67ad5ec93ddc6 Mon Sep 17 00:00:00 2001 From: ychampion Date: Wed, 8 Jul 2026 20:19:25 +0000 Subject: [PATCH 1/4] Fix debug-suffixed Python specs --- docs/explanation.rst | 3 ++- src/python_discovery/_py_spec.py | 3 +-- tests/py_info/test_py_info.py | 9 +++++++++ tests/test_py_spec.py | 7 +++++++ 4 files changed, 19 insertions(+), 3 deletions(-) diff --git a/docs/explanation.rst b/docs/explanation.rst index e35300c..5355788 100644 --- a/docs/explanation.rst +++ b/docs/explanation.rst @@ -238,7 +238,8 @@ A spec string follows the pattern ``[impl][version][t][-arch][-machine]``. Every ``312`` as shorthand for ``3.12``. - **t** -- appended directly after the version. Matches free-threaded (no-GIL) builds only. - **-arch** -- ``-32`` or ``-64`` for 32-bit or 64-bit interpreters. -- **-machine** -- the CPU instruction set: ``-arm64``, ``-x86_64``, ``-aarch64``, ``-riscv64``, etc. +- **-machine** -- the CPU instruction set after ``-arch``: + ``-64-arm64``, ``-64-x86_64``, ``-64-aarch64``, ``-64-riscv64``, etc. **Full examples:** diff --git a/src/python_discovery/_py_spec.py b/src/python_discovery/_py_spec.py index f0fb53e..625d7c5 100644 --- a/src/python_discovery/_py_spec.py +++ b/src/python_discovery/_py_spec.py @@ -16,8 +16,7 @@ (?P[a-zA-Z]+)? # implementation (e.g. cpython, pypy) (?P[0-9.]+)? # version (e.g. 3.12, 3.12.1) (?Pt)? # free-threaded flag - (?:-(?P32|64))? # architecture bitness - (?:-(?P[a-zA-Z0-9_.]+))? # ISA (e.g. arm64, x86_64, i86pc.64bit) + (?:-(?P32|64)(?:-(?P[a-zA-Z0-9_.]+))?)? # architecture bitness and optional ISA $ """, re.VERBOSE, diff --git a/tests/py_info/test_py_info.py b/tests/py_info/test_py_info.py index 162a757..8066880 100644 --- a/tests/py_info/test_py_info.py +++ b/tests/py_info/test_py_info.py @@ -287,6 +287,15 @@ def test_discover_exe_on_path_non_spec_name_match(mocker: MockerFixture) -> None assert CURRENT.satisfies(spec, impl_must_match=True) is True +def test_discover_exe_on_path_debug_suffix_match(mocker: MockerFixture) -> None: + suffixed_name = f"python{CURRENT.version_info.major}.{CURRENT.version_info.minor}-dbg" + if sys.platform == "win32": # pragma: win32 cover + suffixed_name += Path(CURRENT.original_executable).suffix + spec = PythonSpec.from_string_spec(suffixed_name) + mocker.patch.object(CURRENT, "original_executable", str(Path(CURRENT.executable).parent / suffixed_name)) + assert CURRENT.satisfies(spec, impl_must_match=True) is True + + def test_discover_exe_on_path_non_spec_name_not_match(mocker: MockerFixture) -> None: suffixed_name = f"python{CURRENT.version_info.major}.{CURRENT.version_info.minor}m" if sys.platform == "win32": # pragma: win32 cover diff --git a/tests/test_py_spec.py b/tests/test_py_spec.py index a616622..f44fc44 100644 --- a/tests/test_py_spec.py +++ b/tests/test_py_spec.py @@ -202,6 +202,13 @@ def test_spec_parse_arch_and_machine_together(spec_str: str, expected_arch: int, assert spec.machine == expected_machine +def test_spec_does_not_parse_machine_without_architecture() -> None: + spec = PythonSpec.from_string_spec("python3.13-dbg") + + assert spec.path == "python3.13-dbg" + assert spec.machine is None + + @pytest.mark.parametrize( ("left", "right", "expected"), [ From 6a1458606c97bfa8a3e6037cb4d9682755a67a7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Wed, 8 Jul 2026 14:39:15 -0700 Subject: [PATCH 2/4] Parse debug build flag in interpreter specs python3.13-dbg parsed as version 3.13 with machine="dbg", so a real Py_DEBUG interpreter was rejected during discovery. Recognize d, -dbg and -debug as a debug-build marker instead, matching against PythonInfo.debug_build. Absent a marker the build type stays unconstrained, so release specs behave as before. Fixes tox-dev/tox#3977. --- docs/changelog/96.bugfix.rst | 3 +++ docs/explanation.rst | 14 ++++++++++---- src/python_discovery/_py_info.py | 4 +++- src/python_discovery/_py_spec.py | 16 +++++++++++++--- tests/py_info/test_py_info.py | 16 ++++++++++------ tests/test_py_spec.py | 25 ++++++++++++++++++++++--- tests/test_py_spec_extra.py | 9 +++++++++ 7 files changed, 70 insertions(+), 17 deletions(-) create mode 100644 docs/changelog/96.bugfix.rst diff --git a/docs/changelog/96.bugfix.rst b/docs/changelog/96.bugfix.rst new file mode 100644 index 0000000..3c43433 --- /dev/null +++ b/docs/changelog/96.bugfix.rst @@ -0,0 +1,3 @@ +Parse the debug build flag in interpreter specs - ``python3.13d`` and Debian's ``python3.13-dbg`` / +``python3.13-debug`` now select a ``Py_DEBUG`` interpreter instead of being misread as an ISA named ``dbg`` - +by :user:`gaborbernat`. diff --git a/docs/explanation.rst b/docs/explanation.rst index 5355788..827060a 100644 --- a/docs/explanation.rst +++ b/docs/explanation.rst @@ -213,7 +213,7 @@ when encountering problematic interpreters. Spec format reference ----------------------- -A spec string follows the pattern ``[impl][version][t][-arch][-machine]``. Every part is optional. +A spec string follows the pattern ``[impl][version][t][d][-arch][-machine]``. Every part is optional. .. mermaid:: @@ -221,12 +221,14 @@ A spec string follows the pattern ``[impl][version][t][-arch][-machine]``. Every Spec["Spec string"] --> Impl["impl
(optional)"] Impl --> Version["version
(optional)"] Version --> T["t
(optional)"] - T --> Arch["-arch
(optional)"] + T --> D["d
(optional)"] + D --> Arch["-arch
(optional)"] Arch --> Machine["-machine
(optional)"] style Impl fill:#4a90d9,stroke:#2a5f8f,color:#fff style Version fill:#4a9f4a,stroke:#2a6f2a,color:#fff style T fill:#d9904a,stroke:#8f5f2a,color:#fff + style D fill:#4ad9c4,stroke:#2a8f7f,color:#fff style Arch fill:#d94a4a,stroke:#8f2a2a,color:#fff style Machine fill:#904ad9,stroke:#5f2a8f,color:#fff @@ -237,9 +239,11 @@ A spec string follows the pattern ``[impl][version][t][-arch][-machine]``. Every - **version** -- dotted version number (``3``, ``3.12``, or ``3.12.1``). You can also write ``312`` as shorthand for ``3.12``. - **t** -- appended directly after the version. Matches free-threaded (no-GIL) builds only. +- **d** -- require a debug (``Py_DEBUG``) build. Written as ``d`` right after the version (the ABI flag + form, as in ``python3.13d``) or as Debian's ``-dbg`` / ``-debug`` suffix (``python3.13-dbg``). When + omitted the build type is unconstrained. - **-arch** -- ``-32`` or ``-64`` for 32-bit or 64-bit interpreters. -- **-machine** -- the CPU instruction set after ``-arch``: - ``-64-arm64``, ``-64-x86_64``, ``-64-aarch64``, ``-64-riscv64``, etc. +- **-machine** -- the CPU instruction set: ``-arm64``, ``-x86_64``, ``-aarch64``, ``-riscv64``, etc. **Full examples:** @@ -259,6 +263,8 @@ A spec string follows the pattern ``[impl][version][t][-arch][-machine]``. Every - PyPy 3.9 * - ``python3.13t`` - Free-threaded (no-GIL) CPython 3.13 + * - ``python3.13-dbg`` + - Debug (``Py_DEBUG``) CPython 3.13 (``python3.13d`` also works) * - ``python3.12-64`` - 64-bit CPython 3.12 * - ``python3.12-64-arm64`` diff --git a/src/python_discovery/_py_info.py b/src/python_discovery/_py_info.py index 893bc02..c4089a1 100644 --- a/src/python_discovery/_py_info.py +++ b/src/python_discovery/_py_info.py @@ -481,6 +481,8 @@ def satisfies(self, spec: PythonSpec, *, impl_must_match: bool) -> bool: # noqa return False if spec.free_threaded is not None and spec.free_threaded != self.free_threaded: return False + if spec.debug is not None and spec.debug != self.debug_build: + return False if spec.version_specifier is not None and not self._satisfies_version_specifier(spec): return False return all( @@ -772,7 +774,7 @@ 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 [""] + debug_suffixes = ["_d", "-dbg", ""] if self.debug_build else [""] archs = [f"-{self.architecture}", ""] for name in self._possible_base(): for at in (3, 2, 1, 0): diff --git a/src/python_discovery/_py_spec.py b/src/python_discovery/_py_spec.py index 625d7c5..32beb6d 100644 --- a/src/python_discovery/_py_spec.py +++ b/src/python_discovery/_py_spec.py @@ -16,7 +16,9 @@ (?P[a-zA-Z]+)? # implementation (e.g. cpython, pypy) (?P[0-9.]+)? # version (e.g. 3.12, 3.12.1) (?Pt)? # free-threaded flag - (?:-(?P32|64)(?:-(?P[a-zA-Z0-9_.]+))?)? # architecture bitness and optional ISA + (?Pd|(?:-dbg|-debug)(?=-|$))? # debug build flag (d, -dbg or -debug) + (?:-(?P32|64))? # architecture bitness + (?:-(?P[a-zA-Z0-9_.]+))? # ISA (e.g. arm64, x86_64, i86pc.64bit) $ """, re.VERBOSE, @@ -67,6 +69,7 @@ def _parse_spec_pattern(string_spec: str) -> PythonSpec | None: groups = match.groupdict() version = groups["version"] major, minor, micro, threaded = None, None, None, None + debug = True if groups["debug"] else None # unconstrained unless an explicit d/-dbg/-debug marker is present if version is not None: try: major, minor, micro = _parse_version_parts(version) @@ -82,7 +85,9 @@ def _parse_spec_pattern(string_spec: str) -> PythonSpec | None: machine = groups.get("machine") if machine is not None: machine = normalize_isa(machine) - return PythonSpec(string_spec, impl, major, minor, micro, arch, None, free_threaded=threaded, machine=machine) + return PythonSpec( + string_spec, impl, major, minor, micro, arch, None, free_threaded=threaded, machine=machine, debug=debug + ) def _parse_specifier(string_spec: str) -> PythonSpec | None: @@ -116,6 +121,7 @@ class PythonSpec: :param path: filesystem path to a specific interpreter, or ``None``. :param free_threaded: whether a free-threaded build is required, or ``None`` for any. :param machine: required ISA (e.g. ``"arm64"``), or ``None`` for any. + :param debug: whether a debug (``Py_DEBUG``) build is required, or ``None`` for any. :param version_specifier: `version specifier `_ constraints, or ``None``. @@ -133,6 +139,7 @@ def __init__( # noqa: PLR0913, PLR0917 *, free_threaded: bool | None = None, machine: str | None = None, + debug: bool | None = None, version_specifier: SpecifierSet | None = None, ) -> None: self.str_spec = str_spec @@ -143,6 +150,7 @@ def __init__( # noqa: PLR0913, PLR0917 self.free_threaded = free_threaded self.architecture = architecture self.machine = machine + self.debug = debug self.path = path self.version_specifier = version_specifier @@ -180,10 +188,11 @@ def generate_re(self, *, windows: bool, all_implementations: bool = False) -> re else: impl = "python" mod = "t?" if self.free_threaded else "" + dbg = "(?:d|-dbg|-debug)?" if self.debug else "" suffix = r"\.exe" if windows else "" version_conditional = "?" if windows or self.major is None else "" return re.compile( - rf"(?P{impl})(?P{version}{mod}){version_conditional}{suffix}$", + rf"(?P{impl})(?P{version}{mod}){version_conditional}{dbg}{suffix}$", flags=re.IGNORECASE, ) @@ -262,6 +271,7 @@ def __repr__(self) -> str: "machine", "path", "free_threaded", + "debug", "version_specifier", ) return f"{name}({', '.join(f'{k}={getattr(self, k)}' for k in params if getattr(self, k) is not None)})" diff --git a/tests/py_info/test_py_info.py b/tests/py_info/test_py_info.py index 8066880..053a9ae 100644 --- a/tests/py_info/test_py_info.py +++ b/tests/py_info/test_py_info.py @@ -287,12 +287,15 @@ def test_discover_exe_on_path_non_spec_name_match(mocker: MockerFixture) -> None assert CURRENT.satisfies(spec, impl_must_match=True) is True -def test_discover_exe_on_path_debug_suffix_match(mocker: MockerFixture) -> None: - suffixed_name = f"python{CURRENT.version_info.major}.{CURRENT.version_info.minor}-dbg" - if sys.platform == "win32": # pragma: win32 cover - suffixed_name += Path(CURRENT.original_executable).suffix - spec = PythonSpec.from_string_spec(suffixed_name) - mocker.patch.object(CURRENT, "original_executable", str(Path(CURRENT.executable).parent / suffixed_name)) +def test_satisfies_debug_spec_requires_debug_build(mocker: MockerFixture) -> None: + spec = PythonSpec.from_string_spec(f"python{CURRENT.version_info.major}.{CURRENT.version_info.minor}-dbg") + assert spec.debug is True + mocker.patch.object(CURRENT, "free_threaded", spec.free_threaded) + + mocker.patch.object(CURRENT, "debug_build", False) + assert CURRENT.satisfies(spec, impl_must_match=True) is False + + mocker.patch.object(CURRENT, "debug_build", True) assert CURRENT.satisfies(spec, impl_must_match=True) is True @@ -441,6 +444,7 @@ def test_py_info_debug_build_exe_names(*, debug: bool) -> None: info.debug_build = debug names = info._find_possible_exe_names() assert any("_d" in n for n in names) is debug + assert any(n.endswith("-dbg") for n in names) is debug def test_py_info_debug_build_json_round_trip() -> None: diff --git a/tests/test_py_spec.py b/tests/test_py_spec.py index f44fc44..d4e6b1f 100644 --- a/tests/test_py_spec.py +++ b/tests/test_py_spec.py @@ -186,6 +186,7 @@ def test_specifier_satisfies_with_partial_information() -> None: def test_spec_parse_machine(spec_str: str, expected_machine: str | None) -> None: spec = PythonSpec.from_string_spec(spec_str) assert spec.machine == expected_machine + assert spec.debug is None # the debug marker sits before -arch/-machine and must not swallow the ISA @pytest.mark.parametrize( @@ -202,11 +203,29 @@ def test_spec_parse_arch_and_machine_together(spec_str: str, expected_arch: int, assert spec.machine == expected_machine -def test_spec_does_not_parse_machine_without_architecture() -> None: - spec = PythonSpec.from_string_spec("python3.13-dbg") +@pytest.mark.parametrize( + "spec_str", + [ + pytest.param("python3.13-dbg", id="debian-dbg"), + pytest.param("python3.13-debug", id="debian-debug"), + pytest.param("python3.13d", id="abi-flag"), + pytest.param("cpython3.13d", id="impl-abi-flag"), + pytest.param("python3.13td", id="free-threaded-debug"), + ], +) +def test_spec_parse_debug(spec_str: str) -> None: + spec = PythonSpec.from_string_spec(spec_str) - assert spec.path == "python3.13-dbg" + assert spec.debug is True + assert spec.major == 3 + assert spec.minor == 13 assert spec.machine is None + assert spec.path is None + + +@pytest.mark.parametrize("spec_str", ["python3.13", "python"]) +def test_spec_parse_debug_unconstrained_without_marker(spec_str: str) -> None: + assert PythonSpec.from_string_spec(spec_str).debug is None @pytest.mark.parametrize( diff --git a/tests/test_py_spec_extra.py b/tests/test_py_spec_extra.py index d779e23..2250f07 100644 --- a/tests/test_py_spec_extra.py +++ b/tests/test_py_spec_extra.py @@ -2,6 +2,8 @@ from unittest.mock import MagicMock +import pytest + from python_discovery import PythonSpec @@ -86,6 +88,13 @@ def test_generate_re_with_threaded() -> None: assert pat.fullmatch("python3.12t") is not None +@pytest.mark.parametrize("name", ["python3.12-dbg", "python3.12d", "python3.12"]) +def test_generate_re_with_debug(name: str) -> None: + spec = PythonSpec.from_string_spec("python3.12-dbg") + pat = spec.generate_re(windows=False) + assert pat.fullmatch(name) is not None + + def test_single_digit_version() -> None: spec = PythonSpec.from_string_spec("python3") assert spec.major == 3 From 8216c134a267673f30d543f546266726a434e530 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Wed, 8 Jul 2026 15:10:38 -0700 Subject: [PATCH 3/4] Cover abiflags debug names and sync docs Debian, Fedora and upstream all install the abiflags-suffixed debug binary (pythonX.Yd, pythonX.Ytd), but _find_possible_exe_names only generated the Windows (_d) and Debian (-dbg) forms, so venv-to-system resolution missed it. Add the d suffix. Sync the spec-format docs across all Diataxis sections: add the debug node to both grammar diagrams, document the t-before-d and arch-before- machine ordering, list debug_build in the metadata reference, and add a how-to for selecting a debug build. --- docs/explanation.rst | 9 ++++++++- docs/how-to/standalone-usage.rst | 21 +++++++++++++++++++++ docs/tutorial/getting-started.rst | 16 +++++++++++----- src/python_discovery/_py_info.py | 4 +++- tests/py_info/test_py_info.py | 2 ++ 5 files changed, 45 insertions(+), 7 deletions(-) diff --git a/docs/explanation.rst b/docs/explanation.rst index 827060a..4dd47bb 100644 --- a/docs/explanation.rst +++ b/docs/explanation.rst @@ -221,7 +221,7 @@ A spec string follows the pattern ``[impl][version][t][d][-arch][-machine]``. Ev Spec["Spec string"] --> Impl["impl
(optional)"] Impl --> Version["version
(optional)"] Version --> T["t
(optional)"] - T --> D["d
(optional)"] + T --> D["d / -dbg / -debug
(optional)"] D --> Arch["-arch
(optional)"] Arch --> Machine["-machine
(optional)"] @@ -245,6 +245,11 @@ A spec string follows the pattern ``[impl][version][t][d][-arch][-machine]``. Ev - **-arch** -- ``-32`` or ``-64`` for 32-bit or 64-bit interpreters. - **-machine** -- the CPU instruction set: ``-arm64``, ``-x86_64``, ``-aarch64``, ``-riscv64``, etc. +Order matters: the parts appear in the sequence shown above. Combine the ABI flags as ``python3.13td`` +(free-threaded then debug, the order CPython uses in ``sys.abiflags``), and keep ``-arch`` before +``-machine`` as in ``python3.13d-64-arm64``. Mixing ``d`` with ``-dbg`` in one spec, or swapping the arch +and machine segments, leaves the string unrecognized, so it falls back to a literal executable-name match. + **Full examples:** .. list-table:: @@ -265,6 +270,8 @@ A spec string follows the pattern ``[impl][version][t][d][-arch][-machine]``. Ev - Free-threaded (no-GIL) CPython 3.13 * - ``python3.13-dbg`` - Debug (``Py_DEBUG``) CPython 3.13 (``python3.13d`` also works) + * - ``python3.13td`` + - Free-threaded debug CPython 3.13 * - ``python3.12-64`` - 64-bit CPython 3.12 * - ``python3.12-64-arm64`` diff --git a/docs/how-to/standalone-usage.rst b/docs/how-to/standalone-usage.rst index 507cdd2..0715d24 100644 --- a/docs/how-to/standalone-usage.rst +++ b/docs/how-to/standalone-usage.rst @@ -16,6 +16,25 @@ standard locations. if info is not None: print(info.executable) +Select a debug build +---------------------- + +To require a debug (``Py_DEBUG``) interpreter, add the debug marker to the spec. Both the ABI-flag form +``python3.13d`` and the Debian package name ``python3.13-dbg`` work, and each resolves to the same +interpreter (Debian ships ``/usr/bin/python3.13d`` and ``/usr/bin/python3.13-dbg`` side by side). A spec with no +marker keeps matching either build, so reach for the marker only when you need the debug build. + +.. code-block:: python + + from python_discovery import get_interpreter + + info = get_interpreter("python3.13-dbg") + if info is not None: + assert info.debug_build + +Combine the marker with the free-threaded ``t`` flag to select a free-threaded debug build, written +``python3.13td`` (ABI-flag order is ``t`` then ``d``) or ``python3.13t-dbg``. + Restrict the search environment --------------------------------- @@ -135,6 +154,7 @@ Once you have a :class:`~python_discovery.PythonInfo`, you can inspect everythin +sysconfig_paths: dict +machine: str +free_threaded: bool + +debug_build: bool } .. code-block:: python @@ -154,6 +174,7 @@ Once you have a :class:`~python_discovery.PythonInfo`, you can inspect everythin info.platform # sys.platform value ("linux", "darwin", "win32"). info.machine # ISA: "arm64", "x86_64", etc. info.free_threaded # True if this is a no-GIL build. + info.debug_build # True if this is a Py_DEBUG build. info.sysconfig_vars # All sysconfig.get_config_vars() values. info.sysconfig_paths # All sysconfig.get_paths() values. diff --git a/docs/tutorial/getting-started.rst b/docs/tutorial/getting-started.rst index 1c10bc4..3ced1d4 100644 --- a/docs/tutorial/getting-started.rst +++ b/docs/tutorial/getting-started.rst @@ -142,12 +142,14 @@ You can add more constraints to narrow the search. Spec["Spec string"] --> Impl["impl
(optional)"] Impl --> Version["version
(optional)"] Version --> T["t
(optional)"] - T --> Arch["-arch
(optional)"] + T --> D["d / -dbg / -debug
(optional)"] + D --> Arch["-arch
(optional)"] Arch --> Machine["-machine
(optional)"] style Impl fill:#4a90d9,stroke:#2a5f8f,color:#fff style Version fill:#4a9f4a,stroke:#2a6f2a,color:#fff style T fill:#d9904a,stroke:#8f5f2a,color:#fff + style D fill:#4ad9c4,stroke:#2a8f7f,color:#fff style Arch fill:#d94a4a,stroke:#8f2a2a,color:#fff style Machine fill:#904ad9,stroke:#5f2a8f,color:#fff @@ -167,6 +169,8 @@ Common examples: - PyPy 3.9 * - ``python3.13t`` - Free-threaded (no-GIL) CPython 3.13 + * - ``python3.13-dbg`` + - Debug (``Py_DEBUG``) CPython 3.13 (``python3.13d`` also works) * - ``python3.12-64`` - 64-bit CPython 3.12 * - ``python3.12-64-arm64`` @@ -188,11 +192,12 @@ inspecting what a spec means or for building tools on top of python-discovery. .. mermaid:: flowchart TD - Input["cpython3.12t-64-arm64"] --> Parse["PythonSpec.from_string_spec()"] + Input["cpython3.13td-64-arm64"] --> Parse["PythonSpec.from_string_spec()"] Parse --> Spec["PythonSpec"] Spec --> impl["implementation: cpython"] - Spec --> ver["major: 3, minor: 12"] + Spec --> ver["major: 3, minor: 13"] Spec --> ft["free_threaded: True"] + Spec --> dbg["debug: True"] Spec --> arch["architecture: 64"] Spec --> mach["machine: arm64"] @@ -203,11 +208,12 @@ inspecting what a spec means or for building tools on top of python-discovery. from python_discovery import PythonSpec - spec = PythonSpec.from_string_spec("cpython3.12t-64-arm64") + spec = PythonSpec.from_string_spec("cpython3.13td-64-arm64") spec.implementation # "cpython" spec.major # 3 - spec.minor # 12 + spec.minor # 13 spec.free_threaded # True + spec.debug # True spec.architecture # 64 spec.machine # "arm64" diff --git a/src/python_discovery/_py_info.py b/src/python_discovery/_py_info.py index c4089a1..3dbdf09 100644 --- a/src/python_discovery/_py_info.py +++ b/src/python_discovery/_py_info.py @@ -774,7 +774,9 @@ 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", "-dbg", ""] if self.debug_build else [""] + # _d (Windows python_d.exe), -dbg (Debian's own name), d (the abiflags form used by + # upstream/Fedora and also shipped by Debian, so pythonX.Yd / pythonX.Ytd resolve too) + debug_suffixes = ["_d", "-dbg", "d", ""] if self.debug_build else [""] archs = [f"-{self.architecture}", ""] for name in self._possible_base(): for at in (3, 2, 1, 0): diff --git a/tests/py_info/test_py_info.py b/tests/py_info/test_py_info.py index 053a9ae..d638318 100644 --- a/tests/py_info/test_py_info.py +++ b/tests/py_info/test_py_info.py @@ -443,8 +443,10 @@ 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() + abiflag_name = f"python{info.version_info.major}.{info.version_info.minor}d" assert any("_d" in n for n in names) is debug assert any(n.endswith("-dbg") for n in names) is debug + assert (abiflag_name in names) is debug def test_py_info_debug_build_json_round_trip() -> None: From e49a81f8afe0327a41513657636c7cffaf8375bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Wed, 8 Jul 2026 15:16:33 -0700 Subject: [PATCH 4/4] Validate debug and free-threaded flags on resolve discover_exe matched a base interpreter by name, version, implementation and architecture, but not by its ABI flags. A debug or free-threaded virtualenv could therefore resolve to a release build of the same version. Compare free_threaded and debug_build too, so the runtime value confirms the name-level match. --- docs/changelog/96.bugfix.rst | 5 +++-- src/python_discovery/_py_info.py | 2 +- tests/py_info/test_py_info.py | 25 +++++++++++++++++++++++++ 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/docs/changelog/96.bugfix.rst b/docs/changelog/96.bugfix.rst index 3c43433..1c6020a 100644 --- a/docs/changelog/96.bugfix.rst +++ b/docs/changelog/96.bugfix.rst @@ -1,3 +1,4 @@ Parse the debug build flag in interpreter specs - ``python3.13d`` and Debian's ``python3.13-dbg`` / -``python3.13-debug`` now select a ``Py_DEBUG`` interpreter instead of being misread as an ISA named ``dbg`` - -by :user:`gaborbernat`. +``python3.13-debug`` now select a ``Py_DEBUG`` interpreter instead of being misread as an ISA named ``dbg``. +Resolving a virtualenv to its base interpreter also checks the free-threaded and debug ABI flags, so a debug or +free-threaded environment no longer resolves to a release build of the same version - by :user:`gaborbernat`. diff --git a/src/python_discovery/_py_info.py b/src/python_discovery/_py_info.py index 3dbdf09..1098934 100644 --- a/src/python_discovery/_py_info.py +++ b/src/python_discovery/_py_info.py @@ -719,7 +719,7 @@ def _check_exe( # noqa: PLR0913 info = self.from_exe(exe_path, cache, resolve_to_host=False, raise_on_error=False, env=env) if info is None: # ignore if for some reason we can't query return None - for item in ["implementation", "architecture", "machine", "version_info"]: + for item in ["implementation", "architecture", "machine", "version_info", "free_threaded", "debug_build"]: found = getattr(info, item) searched = getattr(self, item) if found != searched: diff --git a/tests/py_info/test_py_info.py b/tests/py_info/test_py_info.py index d638318..c333248 100644 --- a/tests/py_info/test_py_info.py +++ b/tests/py_info/test_py_info.py @@ -259,6 +259,31 @@ def func(exe_path: str, _cache: object = None, **_kwargs: object) -> PythonInfo: assert warn_similar.msg.startswith("no exact match found, chosen most similar") +@pytest.mark.parametrize("attr", ["free_threaded", "debug_build"]) +def test_discover_exe_validates_abi_flag( + attr: str, tmp_path: Path, mocker: MockerFixture, session_cache: DiskCache +) -> None: + def candidate(*, flag: bool, name: str) -> PythonInfo: + info = copy.deepcopy(CURRENT) + setattr(info, attr, flag) + exe = tmp_path / name + exe.write_text("", encoding="utf-8") + info.executable = str(exe) + return info + + target = candidate(flag=True, name="target") + mismatch = candidate(flag=False, name="release") + match = candidate(flag=True, name="debug") + by_path = {info.executable: info for info in (mismatch, match)} + + mocker.patch.object(target, "_find_possible_exe_names", return_value=["release", "debug"]) + mocker.patch.object(target, "_find_possible_folders", return_value=[str(tmp_path)]) + mocker.patch.object(target, "from_exe", side_effect=lambda exe, *_a, **_k: by_path[exe]) + + found = target.discover_exe(session_cache, prefix=str(tmp_path), exact=True) + assert found is match # the release build is refused even though version, impl and arch match + + def test_py_info_ignores_distutils_config(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: raw = f""" [install]