diff --git a/tests/tools/test_lazy_deps.py b/tests/tools/test_lazy_deps.py index b77034e8cf..cdf21943c9 100644 --- a/tests/tools/test_lazy_deps.py +++ b/tests/tools/test_lazy_deps.py @@ -613,7 +613,7 @@ class TestPipConfIndexBridge: """Run _venv_pip_install against a stubbed uv (with *env* set) and return the env it was spawned with.""" conf = tmp_path / "pip.conf" conf.write_text(f"[global]\nindex-url = {self.MIRROR}\n", encoding="utf-8") - # PIP_CONFIG_FILE keeps the host's own /etc, sys.prefix and ~ pip files out of the read. + # PIP_CONFIG_FILE is pip's highest file tier, so it wins over any host /etc file. monkeypatch.setenv("PIP_CONFIG_FILE", str(conf)) monkeypatch.setattr(ld.Path, "home", staticmethod(lambda: tmp_path)) monkeypatch.setattr(ld.sys, "prefix", str(tmp_path / "venv")) @@ -639,9 +639,11 @@ class TestPipConfIndexBridge: env = self._run_with_fake_uv(monkeypatch, tmp_path) assert env["UV_INDEX_URL"] == self.MIRROR - def test_pip_index_url_env_beats_pip_conf_and_explicit_uv_config_is_left_alone(self, monkeypatch, tmp_path): - monkeypatch.setenv("PIP_INDEX_URL", "https://env.example/simple") - assert self._run_with_fake_uv(monkeypatch, tmp_path)["UV_INDEX_URL"] == "https://env.example/simple" + def test_pip_index_url_env_beats_pip_conf(self, monkeypatch, tmp_path): + env = self._run_with_fake_uv(monkeypatch, tmp_path, PIP_INDEX_URL="https://env.example/simple") + assert env["UV_INDEX_URL"] == "https://env.example/simple" + def test_explicit_uv_index_knob_is_not_overridden(self, monkeypatch, tmp_path): + monkeypatch.delenv("PIP_INDEX_URL", raising=False) env = self._run_with_fake_uv(monkeypatch, tmp_path, UV_DEFAULT_INDEX="https://custom.example/simple") - assert "UV_INDEX_URL" not in env, "an explicit uv index knob must not be overridden" + assert "UV_INDEX_URL" not in env diff --git a/tools/lazy_deps.py b/tools/lazy_deps.py index 4d64ab4ac1..0811ab302b 100644 --- a/tools/lazy_deps.py +++ b/tools/lazy_deps.py @@ -11,6 +11,7 @@ package can only add modules, never shadow core; PyPI-by-name specs only (``_spe from __future__ import annotations +import configparser import contextlib import logging import os @@ -423,10 +424,10 @@ def _core_constraints_file() -> Optional[Path]: def _pip_config_candidates(env: dict[str, str]) -> list[Path]: - """pip's config files, lowest precedence first (mirrors ``pip._internal.configuration`` - ``get_configuration_files``): ``PIP_CONFIG_FILE``, then global, the venv's ``sys.prefix`` - site file, then user (legacy ``~/.pip`` before the new location, so the new one wins). - ``PIP_CONFIG_FILE=os.devnull`` disables every file, as in pip.""" + """pip's config files, lowest precedence first, as ``pip._internal.configuration`` ranks them: + global, then user (skipped entirely when ``PIP_CONFIG_FILE`` names an existing file), then the + venv's ``sys.prefix`` site file, then ``PIP_CONFIG_FILE`` itself on top. ``RawConfigParser.read`` + applies them in order, so the last file wins. ``PIP_CONFIG_FILE=os.devnull`` disables all of them.""" explicit = env.get("PIP_CONFIG_FILE", "") if explicit == os.devnull: return [] @@ -435,14 +436,20 @@ def _pip_config_candidates(env: dict[str, str]) -> list[Path]: name = "pip.ini" global_files = [Path(env.get("ProgramData") or r"C:\ProgramData") / "pip" / name] user_files = [home / "pip" / name, Path(env.get("APPDATA") or home / "AppData" / "Roaming") / "pip" / name] + elif sys.platform == "darwin": + name = "pip.conf" + global_files = [Path("/Library/Application Support/pip") / name] + app_support = home / "Library" / "Application Support" / "pip" + user_files = [home / ".pip" / name, (app_support if app_support.is_dir() else home / ".config" / "pip") / name] else: name = "pip.conf" - global_files = [Path("/etc") / name, Path("/etc/pip") / name] - xdg = Path(env.get("XDG_CONFIG_HOME") or home / ".config") - user_files = [home / ".pip" / name, xdg / "pip" / name] - if sys.platform == "darwin" and (home / "Library" / "Application Support" / "pip").is_dir(): - user_files.append(home / "Library" / "Application Support" / "pip" / name) - return ([Path(explicit)] if explicit else []) + global_files + [Path(sys.prefix) / name] + user_files + xdg_dirs = (env.get("XDG_CONFIG_DIRS") or "/etc/xdg").split(os.pathsep) + global_files = [Path(d) / "pip" / name for d in xdg_dirs if d] + [Path("/etc") / name] + user_files = [home / ".pip" / name, Path(env.get("XDG_CONFIG_HOME") or home / ".config") / "pip" / name] + explicit_files = [Path(explicit)] if explicit else [] + if explicit_files and explicit_files[0].is_file(): + user_files = [] + return global_files + user_files + [Path(sys.prefix) / name] + explicit_files def _pip_conf_index_url(env: dict[str, str]) -> Optional[str]: @@ -452,17 +459,14 @@ def _pip_conf_index_url(env: dict[str, str]) -> Optional[str]: mirrored (common behind restricted networks) watches every lazy install hit the default pypi.org and time out (#95608). """ + # Raw: pip does not interpolate, and mirror URLs carry percent-encoded credentials. + parser = configparser.RawConfigParser() try: - import configparser - - # Raw: pip does not interpolate, and mirror URLs carry percent-encoded credentials. - parser = configparser.RawConfigParser() - found = parser.read(str(p) for p in _pip_config_candidates(env)) - if not found or not parser.has_section("global"): + parser.read(str(p) for p in _pip_config_candidates(env)) + if not parser.has_section("global"): return None - value = parser.get("global", "index-url", fallback="").strip() - return value or None - except Exception as e: # noqa: BLE001 (config reading is best-effort) + return parser.get("global", "index-url", fallback="").strip() or None + except configparser.Error as e: logger.debug("Could not read pip.conf for index-url: %s", e) return None @@ -584,21 +588,16 @@ def _venv_pip_install(specs: tuple[str, ...], *, timeout: int = 300, constraint_ from tools.environments.local import hermes_subprocess_env uv_env = hermes_subprocess_env(inherit_credentials=False) uv_env["VIRTUAL_ENV"] = str(Path(sys.executable).parent.parent) - # uv never reads pip.conf, so a mirrored-pip user would see every - # lazy install stall against the default pypi.org for the full - # timeout (#95608). Bridge pip's index-url unless uv is configured - # explicitly (any of its index knobs). PIP_INDEX_URL follows pip's own - # precedence (env var beats pip.conf). - if not any(uv_env.get(k) for k in ("UV_INDEX_URL", "UV_DEFAULT_INDEX", "UV_INDEX")): - pip_index_url = (uv_env.get("PIP_INDEX_URL") or "").strip() or ( - _pip_conf_index_url(uv_env) - ) - if pip_index_url: - uv_env["UV_INDEX_URL"] = pip_index_url # Tier 1: uv. --compile-bytecode because uv writes no __pycache__ by default, so the first # import would recompile the backend AND its transitives (_warm_installed_bytecode is the # belt-and-braces pass for the spec's own roots on any tier). if uv_bin := _uv_binary(): + # Bridge pip's index unless any uv index knob is set; PIP_INDEX_URL beats pip.conf, as in + # pip (see _pip_conf_index_url for why uv needs this at all). + if not any(uv_env.get(k) for k in ("UV_INDEX_URL", "UV_DEFAULT_INDEX", "UV_INDEX")): + pip_index_url = (uv_env.get("PIP_INDEX_URL") or "").strip() or _pip_conf_index_url(uv_env) + if pip_index_url: + uv_env["UV_INDEX_URL"] = pip_index_url try: r = _run_installer([uv_bin, "pip", "install", "--compile-bytecode", *extra_args, *specs], timeout=timeout, env=uv_env) if r.returncode != 0: @@ -612,11 +611,8 @@ def _venv_pip_install(specs: tuple[str, ...], *, timeout: int = 300, constraint_ # no feedback, then silence. The failure string flows into # FeatureUnavailable, which callers surface as warnings. hint = ( - f"uv pip install timed out after {timeout}s: {e}. " - "If your network needs a package mirror, set index-url in " - "pip.conf (bridged to uv automatically) or UV_INDEX_URL " - f"directly; or install manually with: " - f"{sys.executable} -m pip install {' '.join(specs)}" + f"uv pip install timed out after {timeout}s. If your network needs a package " + "mirror, set index-url in pip.conf (bridged to uv automatically) or UV_INDEX_URL." ) return _InstallResult(False, "", hint) except FileNotFoundError as e: # uv vanished between lookup and spawn; it never evaluated the requirements