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/200.security.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
When an explicit ``env`` mapping omits ``HOME`` or ``USERPROFILE``, uv-managed interpreter discovery no longer searches
the process home directory.
8 changes: 5 additions & 3 deletions docs/explanation.rst
Original file line number Diff line number Diff line change
Expand Up @@ -104,9 +104,11 @@ overrides it; otherwise the store is the ``python`` bucket of uv's state directo
uv follows the XDG convention on macOS too, and falls back to the legacy directory only when that directory already
exists. python-discovery searches both, in the same order, so a leftover legacy directory cannot hide the store uv
installs into. A relative ``XDG_DATA_HOME`` does not count, as the XDG specification requires, and Windows ignores it
outright. An empty or relative ``APPDATA`` uses the profile default, and discovery skips any relative store root.
Every one of these variables comes from the ``env`` mapping passed to
:func:`~python_discovery.get_interpreter`, rather than from the process environment.
outright. An empty or relative ``APPDATA`` uses the profile default when ``USERPROFILE`` is present. Discovery skips
relative store roots. Every one of these variables comes from the ``env`` mapping passed to
:func:`~python_discovery.get_interpreter`, rather than from the process environment. If the mapping omits ``HOME`` or
``USERPROFILE``, discovery skips the corresponding home-derived roots but still searches an absolute mapped ``APPDATA``
or ``XDG_DATA_HOME``.

Each install lives in its own subdirectory, but the actual binary location varies by OS and implementation:

Expand Down
26 changes: 13 additions & 13 deletions src/python_discovery/_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ def get_interpreter(
(``>=3.10``). When a sequence is given each entry is tried in order.
:param try_first_with: executables to probe before the normal discovery search.
:param cache: interpreter metadata cache; when ``None`` results are not cached.
:param env: environment mapping for ``PATH`` lookup; defaults to :data:`os.environ`.
:param env: environment mapping for ``PATH`` and uv store lookup; defaults to :data:`os.environ`.
:param predicate: optional callback applied after an interpreter matches the spec. Return ``True`` to accept the
interpreter, ``False`` to skip it and continue searching.
:return: the first matching interpreter, or ``None`` if no match is found.
Expand Down Expand Up @@ -75,7 +75,7 @@ def iter_interpreters(
:param try_first_with: executables to probe before the normal discovery search.
:param cache: interpreter metadata cache; when ``None`` results are not cached. Strongly recommended for
enumeration, which interrogates every candidate as a subprocess on a cold cache.
:param env: environment mapping for ``PATH`` lookup; defaults to :data:`os.environ`.
:param env: environment mapping for ``PATH`` and uv store lookup; defaults to :data:`os.environ`.
:param predicate: optional filter applied after the spec match; return ``True`` to include the interpreter.
"""
if key is None:
Expand Down Expand Up @@ -187,7 +187,7 @@ def propose_interpreters(
:param spec: the parsed interpreter specification to match against.
:param try_first_with: executable paths to probe before the standard search.
:param cache: interpreter metadata cache; when ``None`` results are not cached.
:param env: environment mapping for ``PATH`` lookup; defaults to :data:`os.environ`.
:param env: environment mapping for ``PATH`` and uv store lookup; defaults to :data:`os.environ`.
:param all_implementations: when ``True`` and *spec* does not constrain the implementation, also surface
non-CPython binaries on ``PATH`` and under UV's install directory. Used by enumeration APIs.
"""
Expand Down Expand Up @@ -296,22 +296,22 @@ def _uv_python_roots(env: Mapping[str, str]) -> Generator[Path, None, None]:

def _uv_state_dirs(env: Mapping[str, str]) -> Generator[Path, None, None]:
"""Yield uv's legacy state directory then its current one, the order uv prefers them in."""
windows = sys.platform == "win32"
home = Path(env.get("USERPROFILE" if windows else "HOME") or "~").expanduser()
windows: Final = sys.platform == "win32"
home: Final = Path(profile) if (profile := env.get("USERPROFILE" if windows else "HOME")) else None
if windows:
default_app_data = home / "AppData" / "Roaming"
app_data = Path(env.get("APPDATA") or default_app_data)
if not app_data.is_absolute():
app_data = default_app_data
yield app_data / "uv" / "data"
yield app_data / "uv"
app_data = Path(roaming) if (roaming := env.get("APPDATA")) else None
if app_data is None or not app_data.is_absolute():
app_data = home / "AppData" / "Roaming" if home is not None else None
if app_data is not None:
yield app_data / "uv" / "data"
yield app_data / "uv"
return
if sys.platform == "darwin":
if sys.platform == "darwin" and home is not None:
yield home / "Library" / "Application Support" / "uv"
# uv ignores a relative XDG_DATA_HOME, as the XDG specification requires
if (xdg_data_home := env.get("XDG_DATA_HOME")) and (xdg := Path(xdg_data_home)).is_absolute():
yield xdg / "uv"
else:
elif home is not None:
yield home / ".local" / "share" / "uv"


Expand Down
48 changes: 47 additions & 1 deletion tests/test_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -859,6 +859,52 @@ def test_discovery_uv_store_reads_the_env_argument(
assert uv_probed(env={"PATH": "", "UV_PYTHON_INSTALL_DIR": str(uv_home / "store")}) == [executable]


@pytest.mark.parametrize(
("platform", "store"),
[
pytest.param("win32", "AppData/Roaming/uv/data", id="windows"),
pytest.param("darwin", "Library/Application Support/uv", id="macos"),
pytest.param("linux", ".local/share/uv", id="linux"),
],
)
def test_discovery_uv_store_ignores_process_home_without_mapped_home(
uv_home: Path,
monkeypatch: pytest.MonkeyPatch,
uv_plant: Callable[[Path], str],
uv_probed: Callable[..., list[str]],
platform: str,
store: str,
) -> None:
monkeypatch.setattr(sys, "platform", platform)
uv_plant(uv_home / store / "python")

assert uv_probed(env={"PATH": ""}) == []


@pytest.mark.parametrize(
("platform", "variable", "store"),
[
pytest.param("win32", "APPDATA", "uv/data", id="windows-appdata"),
pytest.param("darwin", "XDG_DATA_HOME", "uv", id="macos-xdg"),
pytest.param("linux", "XDG_DATA_HOME", "uv", id="linux-xdg"),
],
)
def test_discovery_uv_store_uses_mapped_directory_without_home(
uv_home: Path,
monkeypatch: pytest.MonkeyPatch,
uv_plant: Callable[[Path], str],
uv_probed: Callable[..., list[str]],
platform: str,
variable: str,
store: str,
) -> None:
monkeypatch.setattr(sys, "platform", platform)
mapped = uv_home / "mapped"
executable = uv_plant(mapped / store / "python")

assert uv_probed(env={"PATH": "", variable: str(mapped)}) == [executable]


@pytest.mark.usefixtures("uv_home")
def test_discovery_uv_store_missing_root_is_not_an_error(uv_probed: Callable[..., list[str]]) -> None:
assert uv_probed() == []
Expand All @@ -870,7 +916,7 @@ def test_discovery_uv_store_is_searched_after_path(
uv_plant: Callable[[Path], str],
uv_probed: Callable[..., list[str]],
) -> None:
store_executable = uv_plant(uv_home / ".local" / "share" / "uv" / "python")
store_executable = uv_plant(uv_home / ("AppData/Roaming/uv/data" if IS_WIN else ".local/share/uv") / "python")
(path_dir := uv_home / "path-bin").mkdir()
(path_executable := path_dir / f"python{_NO_UV_MATCH}{'.exe' if IS_WIN else ''}").touch()
monkeypatch.setenv("PATH", str(path_dir))
Expand Down