Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/changelog/207.security.rst
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 6 additions & 3 deletions docs/explanation.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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<br>absolute path?"}
AbsPath -->|Yes| TryAbs["Use path directly"]
AbsPath -->|No| TryFirst["try_first_with paths"]
TryFirst --> RelPath{"Is spec a<br>relative path?"}
TryFirst --> RelPath{"Does spec name a<br>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
Expand Down Expand Up @@ -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:**

Expand Down
3 changes: 2 additions & 1 deletion src/python_discovery/_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
19 changes: 19 additions & 0 deletions tests/test_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
[
Expand Down