From 5a1ccbd8bc36e88fd43b6c553d69b5236f600816 Mon Sep 17 00:00:00 2001 From: darrenhuai <60621295+darrenhuai@users.noreply.github.com> Date: Thu, 29 Oct 2026 19:16:37 -0700 Subject: [PATCH 1/3] =?UTF-8?q?=F0=9F=90=9B=20fix(spec):=20enumerate=20fre?= =?UTF-8?q?e-threaded=20and=20debug=20builds=20on=20PATH?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit iter_interpreters() with no spec skipped PATH entries such as python3.13t, python3.13d and python3.13-dbg. generate_re only added the t and debug suffixes when the spec asked for that build, and the empty spec used for enumeration leaves both flags unset, so the wide pattern matched python3.13 alone. On Linux a distribution's free-threaded or debug build is usually found only through PATH, so an "all interpreters" call missed it, while the same call did list uv's +freethreaded and +debug installs. In wide mode a flag the spec leaves unset now makes its suffix optional, and the spec match still decides what is yielded. get_interpreter and a spec passed to iter_interpreters keep the narrow pattern, so a lookup for 3.13 does not start picking python3.13d over python3.13. --- .../+no-spec-variant-names.bugfix.rst | 2 ++ docs/explanation.rst | 7 +++--- src/python_discovery/_py_spec.py | 10 +++++--- tests/test_iter_interpreters.py | 17 +++++++++++++ tests/test_py_spec_extra.py | 24 +++++++++++++++++++ 5 files changed, 54 insertions(+), 6 deletions(-) create mode 100644 docs/changelog/+no-spec-variant-names.bugfix.rst 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 125532b..a70c0c1 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 c263650..4a50aa0 100644 --- a/src/python_discovery/_py_spec.py +++ b/src/python_discovery/_py_spec.py @@ -184,7 +184,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)), @@ -195,8 +197,10 @@ 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 "" + # enumeration wants every build the spec leaves open, as it already gets from the uv store; a single lookup + # keeps to the plain names, so it cannot land on a variant build ahead of the default one + 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..78ff7ce 100644 --- a/tests/test_iter_interpreters.py +++ b/tests/test_iter_interpreters.py @@ -121,6 +121,23 @@ 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", ["python3.99t", "python3.99d", "python3.99-dbg"]) +def test_iter_interpreters_no_key_probes_variant_builds_on_path( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, mocker: MockerFixture, name: str +) -> None: + """Unlike a uv install directory, a PATH entry tells free-threaded and debug builds apart only by file name.""" + 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 2250f07..87d3dc3 100644 --- a/tests/test_py_spec_extra.py +++ b/tests/test_py_spec_extra.py @@ -95,6 +95,30 @@ def test_generate_re_with_debug(name: str) -> None: assert pat.fullmatch(name) is not None +@pytest.mark.parametrize("name", ["python3.99t", "python3.99d", "python3.99-dbg", "python3.99td", "python3.99t-dbg"]) +@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", ["python3.99t", "python3.99d", "python3.99-dbg"]) +@pytest.mark.parametrize("key", ["python", "3.99"]) +def test_generate_re_single_lookup_skips_variant_names(key: str, name: str) -> None: + """A spec silent about a variant must not let get_interpreter pick that build ahead of the default one.""" + pat = PythonSpec.from_string_spec(key).generate_re(windows=False) + assert pat.fullmatch(name) is None + + +@pytest.mark.parametrize("name", ["python3.99t", "python3.99d", "python3.99-dbg"]) +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("python3.99") is not None + assert pat.fullmatch(name) is None + + def test_single_digit_version() -> None: spec = PythonSpec.from_string_spec("python3") assert spec.major == 3 From 7060ce8d6eb66df1b6c188f27b5a4306bd4c5906 Mon Sep 17 00:00:00 2001 From: darrenhuai <60621295+darrenhuai@users.noreply.github.com> Date: Thu, 29 Oct 2026 20:20:41 -0700 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=A7=AA=20test(spec):=20cover=20the=20?= =?UTF-8?q?-debug=20name=20and=20reword=20the=20narrow-pattern=20docstring?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_py_spec_extra.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/test_py_spec_extra.py b/tests/test_py_spec_extra.py index 87d3dc3..d929029 100644 --- a/tests/test_py_spec_extra.py +++ b/tests/test_py_spec_extra.py @@ -95,7 +95,9 @@ def test_generate_re_with_debug(name: str) -> None: assert pat.fullmatch(name) is not None -@pytest.mark.parametrize("name", ["python3.99t", "python3.99d", "python3.99-dbg", "python3.99td", "python3.99t-dbg"]) +@pytest.mark.parametrize( + "name", ["python3.99t", "python3.99d", "python3.99-dbg", "python3.99-debug", "python3.99td", "python3.99t-dbg"] +) @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) @@ -106,7 +108,7 @@ def test_generate_re_all_implementations_matches_variant_names(name: str, window @pytest.mark.parametrize("name", ["python3.99t", "python3.99d", "python3.99-dbg"]) @pytest.mark.parametrize("key", ["python", "3.99"]) def test_generate_re_single_lookup_skips_variant_names(key: str, name: str) -> None: - """A spec silent about a variant must not let get_interpreter pick that build ahead of the default one.""" + """A single lookup must not let get_interpreter pick a variant build ahead of the default one.""" pat = PythonSpec.from_string_spec(key).generate_re(windows=False) assert pat.fullmatch(name) is None From d5280c4b625be080193ec8ca1e55250e0d5bec24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Mon, 2 Nov 2026 07:46:04 -0800 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=92=84=20style(spec):=20clarify=20var?= =?UTF-8?q?iant=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/python_discovery/_py_spec.py | 3 +-- tests/test_iter_interpreters.py | 10 ++++++++-- tests/test_py_spec_extra.py | 32 ++++++++++++++++++++++++++------ 3 files changed, 35 insertions(+), 10 deletions(-) diff --git a/src/python_discovery/_py_spec.py b/src/python_discovery/_py_spec.py index d6336bb..f38c129 100644 --- a/src/python_discovery/_py_spec.py +++ b/src/python_discovery/_py_spec.py @@ -210,8 +210,7 @@ def generate_re(self, *, windows: bool, all_implementations: bool = False) -> re impl = "|".join(re.escape(i) for i in KNOWN_IMPLEMENTATIONS) else: impl = "python" - # enumeration wants every build the spec leaves open, as it already gets from the uv store; a single lookup - # keeps to the plain names, so it cannot land on a variant build ahead of the default one + # 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 "" diff --git a/tests/test_iter_interpreters.py b/tests/test_iter_interpreters.py index 78ff7ce..f81b2ec 100644 --- a/tests/test_iter_interpreters.py +++ b/tests/test_iter_interpreters.py @@ -122,11 +122,17 @@ def test_iter_interpreters_no_key_includes_running_interpreter(session_cache: Di @pytest.mark.usefixtures("uv_dir") -@pytest.mark.parametrize("name", ["python3.99t", "python3.99d", "python3.99-dbg"]) +@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: - """Unlike a uv install directory, a PATH entry tells free-threaded and debug builds apart only by file name.""" exe = tmp_path / "bin" / f"{name}{'.exe' if IS_WIN else ''}" exe.parent.mkdir() exe.touch() diff --git a/tests/test_py_spec_extra.py b/tests/test_py_spec_extra.py index 5f17c6e..3adc020 100644 --- a/tests/test_py_spec_extra.py +++ b/tests/test_py_spec_extra.py @@ -96,7 +96,15 @@ def test_generate_re_with_debug(name: str) -> None: @pytest.mark.parametrize( - "name", ["python3.99t", "python3.99d", "python3.99-dbg", "python3.99-debug", "python3.99td", "python3.99t-dbg"] + "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: @@ -105,19 +113,31 @@ def test_generate_re_all_implementations_matches_variant_names(name: str, window assert pat.fullmatch(f"{name}.exe" if windows else name) is not None -@pytest.mark.parametrize("name", ["python3.99t", "python3.99d", "python3.99-dbg"]) -@pytest.mark.parametrize("key", ["python", "3.99"]) +@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: - """A single lookup must not let get_interpreter pick a variant build ahead of the default one.""" pat = PythonSpec.from_string_spec(key).generate_re(windows=False) assert pat.fullmatch(name) is None -@pytest.mark.parametrize("name", ["python3.99t", "python3.99d", "python3.99-dbg"]) +@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("python3.99") is not None assert pat.fullmatch(name) is None