From 85cc9441ecc3fe2c078522bbc29b4ca1bf922fd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Mon, 2 Nov 2026 18:41:34 -0800 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=90=9B=20fix(uv):=20limit=20home=20lo?= =?UTF-8?q?okup=20to=20supplied=20env?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An explicit environment mapping must bound interpreter discovery. The process-home fallback could probe and execute a uv-managed interpreter outside that mapping. Use mapped home and data-directory values for default uv roots. Correct the Windows order test to plant an interpreter in the Windows uv store. Fixes #200. --- docs/changelog/200.security.rst | 2 ++ src/python_discovery/_discovery.py | 26 ++++++++-------- tests/test_discovery.py | 48 +++++++++++++++++++++++++++++- 3 files changed, 62 insertions(+), 14 deletions(-) create mode 100644 docs/changelog/200.security.rst diff --git a/docs/changelog/200.security.rst b/docs/changelog/200.security.rst new file mode 100644 index 0000000..9ceb7d0 --- /dev/null +++ b/docs/changelog/200.security.rst @@ -0,0 +1,2 @@ +When an explicit ``env`` mapping omits ``HOME`` or ``USERPROFILE``, uv-managed interpreter discovery no longer searches +the process home directory. diff --git a/src/python_discovery/_discovery.py b/src/python_discovery/_discovery.py index 5f82233..ba756b6 100644 --- a/src/python_discovery/_discovery.py +++ b/src/python_discovery/_discovery.py @@ -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. @@ -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: @@ -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. """ @@ -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" diff --git a/tests/test_discovery.py b/tests/test_discovery.py index 0b69e59..b3c9c79 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -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() == [] @@ -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)) From 9e93616148f579ccaace0955e5ae4f7b1baa6a1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Mon, 2 Nov 2026 18:48:17 -0800 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=93=9D=20docs(uv):=20explain=20missin?= =?UTF-8?q?g=20home=20in=20env?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/explanation.rst | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/explanation.rst b/docs/explanation.rst index 355ab93..6114d32 100644 --- a/docs/explanation.rst +++ b/docs/explanation.rst @@ -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: