From a0b490a25db285d2e7ce3cfa5541804617d95b95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Mon, 2 Nov 2026 15:42:43 -0800 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=90=9B=20fix(uv):=20ignore=20relative?= =?UTF-8?q?=20store=20roots?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An empty or relative profile path could make uv discovery search the caller's working directory for an interpreter. Use the profile default for invalid APPDATA and skip any remaining relative state roots. --- docs/explanation.rst | 3 +- src/python_discovery/_discovery.py | 8 +++-- tests/test_discovery.py | 51 ++++++++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 3 deletions(-) diff --git a/docs/explanation.rst b/docs/explanation.rst index 14ab839..56eaa78 100644 --- a/docs/explanation.rst +++ b/docs/explanation.rst @@ -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: diff --git a/src/python_discovery/_discovery.py b/src/python_discovery/_discovery.py index 99fbb59..61abd70 100644 --- a/src/python_discovery/_discovery.py +++ b/src/python_discovery/_discovery.py @@ -288,7 +288,8 @@ 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]: @@ -296,7 +297,10 @@ def _uv_state_dirs(env: Mapping[str, str]) -> Generator[Path, None, None]: 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 diff --git a/tests/test_discovery.py b/tests/test_discovery.py index 73d6f84..cfcb51b 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -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, From 2f618859977c43ab0d04b2abe6a7aca300bd7486 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Mon, 2 Nov 2026 15:43:55 -0800 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=93=9D=20docs(news):=20note=20uv=20st?= =?UTF-8?q?ore=20root=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/changelog/210.security.rst | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 docs/changelog/210.security.rst diff --git a/docs/changelog/210.security.rst b/docs/changelog/210.security.rst new file mode 100644 index 0000000..bce145b --- /dev/null +++ b/docs/changelog/210.security.rst @@ -0,0 +1,2 @@ +Discovery uses the profile default for empty or relative ``APPDATA`` and skips relative profile paths when finding +uv-managed Pythons.