diff --git a/docs/changelog/207.security.rst b/docs/changelog/207.security.rst
new file mode 100644
index 0000000..eeba0c0
--- /dev/null
+++ b/docs/changelog/207.security.rst
@@ -0,0 +1,2 @@
+Bare interpreter names now search ``PATH`` without probing a matching file in the working directory. Use ``./name``
+when the working directory contains the intended interpreter.
diff --git a/docs/explanation.rst b/docs/explanation.rst
index 14ab839..2d455d8 100644
--- a/docs/explanation.rst
+++ b/docs/explanation.rst
@@ -13,9 +13,11 @@ order. It stops as soon as it finds an interpreter that matches your spec.
Start["get_interpreter()"] --> AbsPath{"Is spec an
absolute path?"}
AbsPath -->|Yes| TryAbs["Use path directly"]
AbsPath -->|No| TryFirst["try_first_with paths"]
- TryFirst --> RelPath{"Is spec a
relative path?"}
+ TryFirst --> RelPath{"Does spec name a
relative directory?"}
RelPath -->|Yes| TryRel["Resolve relative to cwd"]
- RelPath -->|No| Current["Current interpreter"]
+ RelPath -->|No| Bare{"Bare executable name?"}
+ Bare -->|Yes| PATH
+ Bare -->|No| Current["Current interpreter"]
Current --> Win{"Windows?"}
Win -->|Yes| PEP514["PEP 514 registry"]
Win -->|No| PATH
@@ -329,7 +331,8 @@ A spec string follows the pattern ``[impl][version][t][d][-arch][-machine]``. Ev
Order matters: the parts appear in the sequence shown above. Combine the ABI flags as ``python3.13td``
(free-threaded then debug, the order CPython uses in ``sys.abiflags``), and keep ``-arch`` before
``-machine`` as in ``python3.13d-64-arm64``. Mixing ``d`` with ``-dbg`` in one spec, or swapping the arch
-and machine segments, leaves the string unrecognized, so it falls back to a literal executable-name match.
+and machine segments, leaves the string unrecognized, so it falls back to a literal executable-name match on ``PATH``.
+Only a spec with a directory component, such as ``./python3`` or ``venv/bin/python``, searches the working directory.
**Full examples:**
diff --git a/src/python_discovery/_discovery.py b/src/python_discovery/_discovery.py
index 99fbb59..9c37bb7 100644
--- a/src/python_discovery/_discovery.py
+++ b/src/python_discovery/_discovery.py
@@ -216,7 +216,8 @@ def _propose_explicit(
yield PythonInfo.from_exe(exe_raw, cache, env=env), True
if spec.path is not None:
- if exe_raw := _check_exe(spec.path, tested_exes): # pragma: no branch
+ # Unparsed bare names reach spec.path; only an explicit directory should search the working directory.
+ if os.path.dirname(spec.path) and (exe_raw := _check_exe(spec.path, tested_exes)):
yield PythonInfo.from_exe(exe_raw, cache, env=env), True
else:
yield from _propose_current_and_windows(spec, cache, env, tested_exes)
diff --git a/tests/test_discovery.py b/tests/test_discovery.py
index 73d6f84..65fe44e 100644
--- a/tests/test_discovery.py
+++ b/tests/test_discovery.py
@@ -94,6 +94,25 @@ def test_relative_path(session_cache: DiskCache, monkeypatch: pytest.MonkeyPatch
assert result is not None
+@pytest.mark.parametrize(
+ "name",
+ [
+ pytest.param("3.14.0rc1", id="prerelease"),
+ pytest.param("python3.13-arm64-64", id="swapped-platform"),
+ pytest.param("python_d", id="executable-name"),
+ ],
+)
+def test_bare_name_spec_does_not_probe_cwd(
+ name: str, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, mocker: MockerFixture
+) -> None:
+ (tmp_path / name).touch(mode=0o755)
+ monkeypatch.chdir(tmp_path)
+ from_exe = mocker.patch("python_discovery._discovery.PythonInfo.from_exe", return_value=None)
+
+ assert get_interpreter(name, [], env={"PATH": "", "UV_PYTHON_INSTALL_DIR": str(tmp_path / "uv")}) is None
+ from_exe.assert_not_called()
+
+
@pytest.mark.parametrize(
"sep",
[