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/210.security.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Discovery uses the profile default for empty or relative ``APPDATA`` and skips relative profile paths when finding
uv-managed Pythons.
3 changes: 2 additions & 1 deletion docs/explanation.rst
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,8 @@ 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. Every one of these variables comes from the ``env`` mapping passed to
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.

Each install lives in its own subdirectory, but the actual binary location varies by OS and implementation:
Expand Down
8 changes: 6 additions & 2 deletions src/python_discovery/_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -288,15 +288,19 @@ def _uv_python_roots(env: Mapping[str, str]) -> Generator[Path, None, None]:
yield Path(install_dir).expanduser()
return
for state_dir in _uv_state_dirs(env):
yield state_dir / "python"
if state_dir.is_absolute(): # a relative profile path would search the caller's working directory
yield state_dir / "python"


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()
if windows:
app_data = Path(env["APPDATA"]) if "APPDATA" in env else home / "AppData" / "Roaming"
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"
return
Expand Down
51 changes: 51 additions & 0 deletions tests/test_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -638,6 +638,57 @@ def test_discovery_uv_store_roots_per_platform(
assert uv_probed() == executables


@pytest.mark.parametrize(
("appdata", "store"),
[
pytest.param("", "uv/data", id="empty"),
pytest.param("relative", "relative/uv/data", id="relative"),
],
)
def test_discovery_uv_store_invalid_appdata_uses_profile_default(
uv_home: Path,
monkeypatch: pytest.MonkeyPatch,
uv_plant: Callable[[Path], str],
uv_probed: Callable[..., list[str]],
appdata: str,
store: str,
) -> None:
monkeypatch.setattr(sys, "platform", "win32")
monkeypatch.setenv("APPDATA", appdata)
(checkout := uv_home / "checkout").mkdir()
monkeypatch.chdir(checkout)
uv_plant(checkout / store / "python")
profile_executable = uv_plant(uv_home / "AppData" / "Roaming" / "uv" / "data" / "python")

assert uv_probed() == [profile_executable]


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

assert uv_probed() == []


def test_discovery_uv_store_windows_ignores_xdg_data_home(
uv_home: Path,
monkeypatch: pytest.MonkeyPatch,
Expand Down