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
2 changes: 2 additions & 0 deletions docs/changelog/+no-spec-variant-names.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
List free-threaded and debug builds on ``PATH``, such as ``python3.13t``, ``python3.13d`` and ``python3.13-dbg``, when
``iter_interpreters()`` runs with no spec, as it already did for uv's ``+freethreaded`` and ``+debug`` installs.
7 changes: 4 additions & 3 deletions docs/explanation.rst
Original file line number Diff line number Diff line change
Expand Up @@ -180,9 +180,10 @@ PATH unless the spec names another implementation explicitly (``pypy3.12``, ``gr
compatibility with tools that have always read "no implementation in the spec" as "give me CPython."
:func:`~python_discovery.iter_interpreters` with no spec broadens the search to every name in
:data:`~python_discovery.KNOWN_IMPLEMENTATIONS` -- otherwise an "all interpreters" call would silently miss every
PyPy and GraalPy on the system. When you pass a spec to :func:`~python_discovery.iter_interpreters`, it falls back
to the same narrow regex as :func:`~python_discovery.get_interpreter`, so behavior is consistent across the two
APIs whenever a spec is given.
PyPy and GraalPy on the system. It also matches the free-threaded and debug names (``python3.13t``, ``python3.13d``,
``python3.13-dbg``), so PATH lists those builds just as uv's ``+freethreaded`` and ``+debug`` installs are listed.
When you pass a spec to :func:`~python_discovery.iter_interpreters`, it falls back to the same narrow regex as
:func:`~python_discovery.get_interpreter`, so behavior is consistent across the two APIs whenever a spec is given.

**Deduplication.** :func:`~python_discovery.get_interpreter` deduplicates per call so it does not interrogate the
same binary twice while searching, and stops as soon as a match is found. :func:`~python_discovery.iter_interpreters`
Expand Down
9 changes: 6 additions & 3 deletions src/python_discovery/_py_spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,9 @@ def generate_re(self, *, windows: bool, all_implementations: bool = False) -> re

:param windows: if ``True``, require a ``.exe`` suffix.
:param all_implementations: when ``True`` and the spec does not constrain the implementation, match every
filename in :data:`KNOWN_IMPLEMENTATIONS` instead of only ``python``. Used by enumeration APIs.
filename in :data:`KNOWN_IMPLEMENTATIONS` instead of only ``python``. A free-threaded or debug build the
spec leaves open also matches its marked name, such as ``python3.13t`` or ``python3.13-dbg``. Used by
enumeration APIs.
"""
version = r"{}(\.{}(\.{})?)?".format(
*(r"\d+" if v is None else v for v in (self.major, self.minor, self.micro)),
Expand All @@ -208,8 +210,9 @@ def generate_re(self, *, windows: bool, all_implementations: bool = False) -> re
impl = "|".join(re.escape(i) for i in KNOWN_IMPLEMENTATIONS)
else:
impl = "python"
mod = "t?" if self.free_threaded else ""
dbg = "(?:d|-dbg|-debug)?" if self.debug else ""
# An unconstrained single lookup must not depend on directory order to choose a variant.
mod = "t?" if self.free_threaded or (all_implementations and self.free_threaded is None) else ""
dbg = "(?:d|-dbg|-debug)?" if self.debug or (all_implementations and self.debug is None) else ""
suffix = r"\.exe" if windows else ""
version_conditional = "?" if windows or self.major is None else ""
return re.compile(
Expand Down
23 changes: 23 additions & 0 deletions tests/test_iter_interpreters.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,29 @@ def test_iter_interpreters_no_key_includes_running_interpreter(session_cache: Di
assert any(os.path.realpath(info.executable) == real_self for info in results)


@pytest.mark.usefixtures("uv_dir")
@pytest.mark.parametrize(
"name",
[
pytest.param("python3.99t", id="free-threaded"),
pytest.param("python3.99d", id="debug-abi"),
pytest.param("python3.99-dbg", id="debug-debian"),
],
)
def test_iter_interpreters_no_key_probes_variant_builds_on_path(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, mocker: MockerFixture, name: str
) -> None:
exe = tmp_path / "bin" / f"{name}{'.exe' if IS_WIN else ''}"
exe.parent.mkdir()
exe.touch()
monkeypatch.setenv("PATH", str(exe.parent))
from_exe = mocker.patch("python_discovery._discovery.PathPythonInfo.from_exe", return_value=None)

list(iter_interpreters())

assert str(exe) in [call.args[0] for call in from_exe.call_args_list]


@pytest.mark.usefixtures("uv_dir")
def test_iter_interpreters_dedups_symlinks(
tmp_path: Path,
Expand Down
46 changes: 46 additions & 0 deletions tests/test_py_spec_extra.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,52 @@ def test_generate_re_with_debug(name: str) -> None:
assert pat.fullmatch(name) is not None


@pytest.mark.parametrize(
"name",
[
pytest.param("python3.99t", id="free-threaded"),
pytest.param("python3.99d", id="debug-abi"),
pytest.param("python3.99-dbg", id="debug-short"),
pytest.param("python3.99-debug", id="debug-long"),
pytest.param("python3.99td", id="free-threaded-debug-abi"),
pytest.param("python3.99t-dbg", id="free-threaded-debug-short"),
],
)
@pytest.mark.parametrize("windows", [pytest.param(False, id="posix"), pytest.param(True, id="windows")])
def test_generate_re_all_implementations_matches_variant_names(name: str, windows: bool) -> None:
spec = PythonSpec("", None, None, None, None, None, None)
pat = spec.generate_re(windows=windows, all_implementations=True)
assert pat.fullmatch(f"{name}.exe" if windows else name) is not None


@pytest.mark.parametrize(
"name",
[
pytest.param("python3.99t", id="free-threaded"),
pytest.param("python3.99d", id="debug-abi"),
pytest.param("python3.99-dbg", id="debug-debian"),
],
)
@pytest.mark.parametrize("key", [pytest.param("python", id="name"), pytest.param("3.99", id="version")])
def test_generate_re_single_lookup_skips_variant_names(key: str, name: str) -> None:
pat = PythonSpec.from_string_spec(key).generate_re(windows=False)
assert pat.fullmatch(name) is None


@pytest.mark.parametrize(
"name",
[
pytest.param("python3.99t", id="free-threaded"),
pytest.param("python3.99d", id="debug-abi"),
pytest.param("python3.99-dbg", id="debug-debian"),
],
)
def test_generate_re_all_implementations_keeps_ruled_out_variants_out(name: str) -> None:
spec = PythonSpec("", None, 3, 99, None, None, None, free_threaded=False, debug=False)
pat = spec.generate_re(windows=False, all_implementations=True)
assert pat.fullmatch(name) is None


def test_single_digit_version() -> None:
spec = PythonSpec.from_string_spec("python3")
assert spec.major == 3
Expand Down