From 2576aac6fb10cd0c6285ed040cdc679902b46486 Mon Sep 17 00:00:00 2001 From: darrenhuai <60621295+darrenhuai@users.noreply.github.com> Date: Mon, 2 Nov 2026 21:54:36 -0800 Subject: [PATCH 1/3] =?UTF-8?q?=F0=9F=90=9B=20fix(discovery):=20find=20.ex?= =?UTF-8?q?e=20names=20on=20Windows=20PATH?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Windows, get_interpreter("python3.exe") or get_interpreter("pythonw.exe") returned None even with that file on PATH, while "pythonw" found the same pythonw.exe. A name ending in .exe does not parse as a version spec, so it is looked up on PATH by name, and path_exe_finder appended .exe to it again. The direct candidate became python3.exe.exe, which never exists. The regex fallback only matches python[version].exe, so pythonw.exe was never proposed. In a venv it does find python3.exe, but only after python.exe fails the name check, and then python3.exe is skipped because both share one base interpreter. The suffix is now added only when the name does not already end in .exe, ignoring case. --- docs/changelog/+exe-suffixed-names.bugfix.rst | 2 ++ src/python_discovery/_discovery.py | 2 +- tests/test_discovery_extra.py | 20 +++++++++++++++++++ tests/windows/test_windows.py | 16 +++++++++++++++ 4 files changed, 39 insertions(+), 1 deletion(-) create mode 100644 docs/changelog/+exe-suffixed-names.bugfix.rst diff --git a/docs/changelog/+exe-suffixed-names.bugfix.rst b/docs/changelog/+exe-suffixed-names.bugfix.rst new file mode 100644 index 0000000..d9650eb --- /dev/null +++ b/docs/changelog/+exe-suffixed-names.bugfix.rst @@ -0,0 +1,2 @@ +Find interpreter names that already end in ``.exe``, such as ``python3.exe`` or ``pythonw.exe``, on ``PATH`` on Windows +instead of looking for ``python3.exe.exe``. diff --git a/src/python_discovery/_discovery.py b/src/python_discovery/_discovery.py index a2667ac..7e33867 100644 --- a/src/python_discovery/_discovery.py +++ b/src/python_discovery/_discovery.py @@ -196,7 +196,7 @@ def path_exe_finder( direct = spec.str_spec implementation = spec.implementation.lower() if spec.implementation is not None else None impl_must_match = implementation in KNOWN_IMPLEMENTATIONS and implementation != "python" - if sys.platform == "win32": # pragma: win32 cover + if sys.platform == "win32" and not direct.lower().endswith(".exe"): # pragma: win32 cover direct = f"{direct}.exe" def path_exes(path: Path) -> Generator[tuple[Path, bool], None, None]: diff --git a/tests/test_discovery_extra.py b/tests/test_discovery_extra.py index d42d64f..8d0552b 100644 --- a/tests/test_discovery_extra.py +++ b/tests/test_discovery_extra.py @@ -14,6 +14,7 @@ _active_versions, _read_python_version_file, _resolve_shim, + path_exe_finder, propose_interpreters, ) from python_discovery._py_spec import PythonSpec @@ -163,6 +164,25 @@ def test_path_exe_finder_returns_callable(tmp_path: Path) -> None: assert results == [] +@pytest.mark.parametrize( + ("spec", "name"), + [ + pytest.param("python_custom", "python_custom.exe", id="bare-name"), + pytest.param("pythonw.exe", "pythonw.exe", id="exe-suffix"), + pytest.param("python3.13t.EXE", "python3.13t.EXE", id="upper-exe-suffix"), + ], +) +def test_path_exe_finder_win32_direct_name( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, spec: str, name: str +) -> None: + monkeypatch.setattr(sys, "platform", "win32") + (tmp_path / name).touch() + + finder = path_exe_finder(PythonSpec.from_string_spec(spec)) + + assert list(finder(tmp_path)) == [(tmp_path / name, False)] + + def test_get_paths_no_path_env() -> None: from python_discovery._discovery import get_paths diff --git a/tests/windows/test_windows.py b/tests/windows/test_windows.py index 15dd58a..d3cd55a 100644 --- a/tests/windows/test_windows.py +++ b/tests/windows/test_windows.py @@ -1,7 +1,9 @@ from __future__ import annotations +import shutil import sys from pathlib import Path +from subprocess import check_call import pytest @@ -19,6 +21,20 @@ def test_discovery_via_path_literal_symlink(tmp_path: Path) -> None: assert interpreter.executable == str(link) +@pytest.mark.skipif(sys.platform != "win32", reason="Windows executable names") +@pytest.mark.parametrize("name", ["python3.exe", "PYTHON3.EXE", "pythonw.exe"]) +def test_discovery_via_path_exe_suffixed_name(tmp_path: Path, name: str) -> None: + venv = tmp_path / "venv" + check_call([sys.executable, "-m", "venv", "--without-pip", str(venv)]) + scripts = venv / "Scripts" + shutil.copyfile(scripts / "python.exe", scripts / "python3.exe") + + interpreter = get_interpreter(name, [], env={"PATH": str(scripts)}) + + assert interpreter is not None + assert interpreter.executable == str(scripts / name) + + @pytest.mark.skipif(sys.platform != "win32", reason="propose_interpreters calls from_exe with Windows paths") @pytest.mark.usefixtures("_mock_registry") @pytest.mark.usefixtures("_populate_pyinfo_cache") From 77633cc81ea516e20e69990afdfac575b1a35e4d Mon Sep 17 00:00:00 2001 From: darrenhuai <60621295+darrenhuai@users.noreply.github.com> Date: Tue, 3 Nov 2026 19:52:53 -0800 Subject: [PATCH 2/3] Test a mixed-case .exe suffix and shorten the changelog entry Move the Windows test to the end of test_windows.py so it does not sit in the same spot as other new tests there. --- docs/changelog/+exe-suffixed-names.bugfix.rst | 3 +- tests/test_discovery_extra.py | 2 +- tests/windows/test_windows.py | 28 +++++++++---------- 3 files changed, 16 insertions(+), 17 deletions(-) diff --git a/docs/changelog/+exe-suffixed-names.bugfix.rst b/docs/changelog/+exe-suffixed-names.bugfix.rst index d9650eb..5e6eac9 100644 --- a/docs/changelog/+exe-suffixed-names.bugfix.rst +++ b/docs/changelog/+exe-suffixed-names.bugfix.rst @@ -1,2 +1 @@ -Find interpreter names that already end in ``.exe``, such as ``python3.exe`` or ``pythonw.exe``, on ``PATH`` on Windows -instead of looking for ``python3.exe.exe``. +On Windows, find interpreter names that already end in ``.exe``, such as ``pythonw.exe``, on ``PATH``. diff --git a/tests/test_discovery_extra.py b/tests/test_discovery_extra.py index 8d0552b..8ceffc0 100644 --- a/tests/test_discovery_extra.py +++ b/tests/test_discovery_extra.py @@ -169,7 +169,7 @@ def test_path_exe_finder_returns_callable(tmp_path: Path) -> None: [ pytest.param("python_custom", "python_custom.exe", id="bare-name"), pytest.param("pythonw.exe", "pythonw.exe", id="exe-suffix"), - pytest.param("python3.13t.EXE", "python3.13t.EXE", id="upper-exe-suffix"), + pytest.param("python3.13t.Exe", "python3.13t.Exe", id="mixed-case-exe-suffix"), ], ) def test_path_exe_finder_win32_direct_name( diff --git a/tests/windows/test_windows.py b/tests/windows/test_windows.py index d3cd55a..d7c7fad 100644 --- a/tests/windows/test_windows.py +++ b/tests/windows/test_windows.py @@ -21,20 +21,6 @@ def test_discovery_via_path_literal_symlink(tmp_path: Path) -> None: assert interpreter.executable == str(link) -@pytest.mark.skipif(sys.platform != "win32", reason="Windows executable names") -@pytest.mark.parametrize("name", ["python3.exe", "PYTHON3.EXE", "pythonw.exe"]) -def test_discovery_via_path_exe_suffixed_name(tmp_path: Path, name: str) -> None: - venv = tmp_path / "venv" - check_call([sys.executable, "-m", "venv", "--without-pip", str(venv)]) - scripts = venv / "Scripts" - shutil.copyfile(scripts / "python.exe", scripts / "python3.exe") - - interpreter = get_interpreter(name, [], env={"PATH": str(scripts)}) - - assert interpreter is not None - assert interpreter.executable == str(scripts / name) - - @pytest.mark.skipif(sys.platform != "win32", reason="propose_interpreters calls from_exe with Windows paths") @pytest.mark.usefixtures("_mock_registry") @pytest.mark.usefixtures("_populate_pyinfo_cache") @@ -76,3 +62,17 @@ def test_propose_interpreters(string_spec: str, expected_exe: str) -> None: interpreter = next(propose_interpreters(spec=PythonSpec.from_string_spec(string_spec), cache=None, env={})) assert interpreter.executable == expected_exe + + +@pytest.mark.skipif(sys.platform != "win32", reason="Windows executable names") +@pytest.mark.parametrize("name", ["python3.exe", "PYTHON3.EXE", "pythonw.exe"]) +def test_discovery_via_path_exe_suffixed_name(tmp_path: Path, name: str) -> None: + venv = tmp_path / "venv" + check_call([sys.executable, "-m", "venv", "--without-pip", str(venv)]) + scripts = venv / "Scripts" + shutil.copyfile(scripts / "python.exe", scripts / "python3.exe") + + interpreter = get_interpreter(name, [], env={"PATH": str(scripts)}) + + assert interpreter is not None + assert interpreter.executable == str(scripts / name) From 11930ae7b789f8d1de51bf7868368f62e85d92e8 Mon Sep 17 00:00:00 2001 From: darrenhuai <60621295+darrenhuai@users.noreply.github.com> Date: Wed, 4 Nov 2026 17:59:16 -0800 Subject: [PATCH 3/3] Pass SYSTEMROOT in the Windows .exe name test so Python 3.9 can start --- tests/windows/test_windows.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/windows/test_windows.py b/tests/windows/test_windows.py index d7c7fad..931c7cd 100644 --- a/tests/windows/test_windows.py +++ b/tests/windows/test_windows.py @@ -1,5 +1,6 @@ from __future__ import annotations +import os import shutil import sys from pathlib import Path @@ -72,7 +73,7 @@ def test_discovery_via_path_exe_suffixed_name(tmp_path: Path, name: str) -> None scripts = venv / "Scripts" shutil.copyfile(scripts / "python.exe", scripts / "python3.exe") - interpreter = get_interpreter(name, [], env={"PATH": str(scripts)}) + interpreter = get_interpreter(name, [], env={"PATH": str(scripts), "SYSTEMROOT": os.environ["SYSTEMROOT"]}) assert interpreter is not None assert interpreter.executable == str(scripts / name)