diff --git a/docs/changelog/+exe-suffixed-names.bugfix.rst b/docs/changelog/+exe-suffixed-names.bugfix.rst new file mode 100644 index 0000000..5e6eac9 --- /dev/null +++ b/docs/changelog/+exe-suffixed-names.bugfix.rst @@ -0,0 +1 @@ +On Windows, find interpreter names that already end in ``.exe``, such as ``pythonw.exe``, on ``PATH``. 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..8ceffc0 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="mixed-case-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..931c7cd 100644 --- a/tests/windows/test_windows.py +++ b/tests/windows/test_windows.py @@ -1,7 +1,10 @@ from __future__ import annotations +import os +import shutil import sys from pathlib import Path +from subprocess import check_call import pytest @@ -60,3 +63,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), "SYSTEMROOT": os.environ["SYSTEMROOT"]}) + + assert interpreter is not None + assert interpreter.executable == str(scripts / name)