Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/changelog/96.bugfix.rst
Original file line number Diff line number Diff line change
@@ -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`.
18 changes: 16 additions & 2 deletions docs/explanation.rst
Original file line number Diff line number Diff line change
Expand Up @@ -213,20 +213,22 @@ 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::

flowchart TD
Spec["Spec string"] --> Impl["impl<br>(optional)"]
Impl --> Version["version<br>(optional)"]
Version --> T["t<br>(optional)"]
T --> Arch["-arch<br>(optional)"]
T --> D["d / -dbg / -debug<br>(optional)"]
D --> Arch["-arch<br>(optional)"]
Arch --> Machine["-machine<br>(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

Expand All @@ -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::
Expand All @@ -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``
Expand Down
21 changes: 21 additions & 0 deletions docs/how-to/standalone-usage.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
---------------------------------

Expand Down Expand Up @@ -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
Expand All @@ -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.

Expand Down
16 changes: 11 additions & 5 deletions docs/tutorial/getting-started.rst
Original file line number Diff line number Diff line change
Expand Up @@ -142,12 +142,14 @@ You can add more constraints to narrow the search.
Spec["Spec string"] --> Impl["impl<br>(optional)"]
Impl --> Version["version<br>(optional)"]
Version --> T["t<br>(optional)"]
T --> Arch["-arch<br>(optional)"]
T --> D["d / -dbg / -debug<br>(optional)"]
D --> Arch["-arch<br>(optional)"]
Arch --> Machine["-machine<br>(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

Expand All @@ -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``
Expand All @@ -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"]

Expand All @@ -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"

Expand Down
8 changes: 6 additions & 2 deletions src/python_discovery/_py_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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):
Expand Down
13 changes: 11 additions & 2 deletions src/python_discovery/_py_spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
(?P<impl>[a-zA-Z]+)? # implementation (e.g. cpython, pypy)
(?P<version>[0-9.]+)? # version (e.g. 3.12, 3.12.1)
(?P<threaded>t)? # free-threaded flag
(?P<debug>d|(?:-dbg|-debug)(?=-|$))? # debug build flag (d, -dbg or -debug)
(?:-(?P<arch>32|64))? # architecture bitness
(?:-(?P<machine>[a-zA-Z0-9_.]+))? # ISA (e.g. arm64, x86_64, i86pc.64bit)
$
Expand Down Expand Up @@ -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)
Expand All @@ -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:
Expand Down Expand Up @@ -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 <https://packaging.python.org/en/latest/specifications/version-specifiers/>`_
constraints, or ``None``.
Expand All @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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>{impl})(?P<v>{version}{mod}){version_conditional}{suffix}$",
rf"(?P<impl>{impl})(?P<v>{version}{mod}){version_conditional}{dbg}{suffix}$",
flags=re.IGNORECASE,
)

Expand Down Expand Up @@ -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)})"
Expand Down
40 changes: 40 additions & 0 deletions tests/py_info/test_py_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
26 changes: 26 additions & 0 deletions tests/test_py_spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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"),
[
Expand Down
9 changes: 9 additions & 0 deletions tests/test_py_spec_extra.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

from unittest.mock import MagicMock

import pytest

from python_discovery import PythonSpec


Expand Down Expand Up @@ -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
Expand Down