diff --git a/docs/changelog/96.bugfix.rst b/docs/changelog/96.bugfix.rst
new file mode 100644
index 0000000..1c6020a
--- /dev/null
+++ b/docs/changelog/96.bugfix.rst
@@ -0,0 +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``.
+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/docs/explanation.rst b/docs/explanation.rst
index e35300c..4dd47bb 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 / -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
@@ -237,9 +239,17 @@ 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: ``-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::
@@ -258,6 +268,10 @@ 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.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 893bc02..1098934 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(
@@ -717,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:
@@ -772,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", ""] 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/src/python_discovery/_py_spec.py b/src/python_discovery/_py_spec.py
index f0fb53e..32beb6d 100644
--- a/src/python_discovery/_py_spec.py
+++ b/src/python_discovery/_py_spec.py
@@ -16,6 +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
+ (?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)
$
@@ -68,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)
@@ -83,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:
@@ -117,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``.
@@ -134,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
@@ -144,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
@@ -181,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,
)
@@ -263,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 162a757..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]
@@ -287,6 +312,18 @@ 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_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
+
+
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
@@ -431,7 +468,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:
diff --git a/tests/test_py_spec.py b/tests/test_py_spec.py
index a616622..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,6 +203,31 @@ def test_spec_parse_arch_and_machine_together(spec_str: str, expected_arch: int,
assert spec.machine == expected_machine
+@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.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(
("left", "right", "expected"),
[
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