diff --git a/docs/changelog/+no-spec-variant-names.bugfix.rst b/docs/changelog/+no-spec-variant-names.bugfix.rst new file mode 100644 index 0000000..790a415 --- /dev/null +++ b/docs/changelog/+no-spec-variant-names.bugfix.rst @@ -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. diff --git a/docs/explanation.rst b/docs/explanation.rst index 2307281..14ab839 100644 --- a/docs/explanation.rst +++ b/docs/explanation.rst @@ -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` diff --git a/src/python_discovery/_py_spec.py b/src/python_discovery/_py_spec.py index 8e79c94..f38c129 100644 --- a/src/python_discovery/_py_spec.py +++ b/src/python_discovery/_py_spec.py @@ -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)), @@ -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( diff --git a/tests/test_iter_interpreters.py b/tests/test_iter_interpreters.py index 2ce5fb4..f81b2ec 100644 --- a/tests/test_iter_interpreters.py +++ b/tests/test_iter_interpreters.py @@ -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, diff --git a/tests/test_py_spec_extra.py b/tests/test_py_spec_extra.py index 571639a..3adc020 100644 --- a/tests/test_py_spec_extra.py +++ b/tests/test_py_spec_extra.py @@ -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