fix(lazy_deps): pip's file precedence, not its enumeration order
Gate review: pip ranks PIP_CONFIG_FILE highest (and skips the user tier when it exists), then the venv site file, then user, then global; the list was built in enumeration order so the user file beat both. Global tier now matches pip per platform (XDG_CONFIG_DIRS on Linux, /Library/Application Support on macOS). configparser.Error only; the timeout hint no longer repeats the manual-install command FeatureUnavailable already appends; the bridge runs only when uv is the tier in use.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user