Skip to content

Commit dfe7d0c

Browse files
authored
Tolerate uv truncated pyvenv.cfg version_info (#153)
## What uv `0.11.22` ([astral-sh/uv#19890](astral-sh/uv#19890)) changed seeded venvs to record only `major.minor` in `pyvenv.cfg` (e.g. `version_info = 3.14`) instead of the full `3.14.6`. pre-commit's `health_check` does an exact-string comparison against our `_version_info` override, so the second hook run failed with: ``` AssertionError: BUG: expected environment for python to be healthy immediately after install virtualenv python version did not match created version: - actual version: 3.14.6 - expected version: 3.14 ``` ## Fix Override `python.health_check` with a prefix-aware comparison that checks only the version components uv actually wrote, so it passes whether uv records two or three components. The now-redundant `_version_info` monkeypatch is dropped — `health_check` was its only consumer. Verified by reproducing the issue scenario end-to-end with uv `0.11.22` (fails on `main`, passes here) and added unit tests covering truncated/full/mismatched/missing-config cases. Fixes #152
1 parent 516cd58 commit dfe7d0c

3 files changed

Lines changed: 131 additions & 18 deletions

File tree

pyproject.toml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,6 @@ lint.select = [
8989
"ALL",
9090
]
9191
lint.ignore = [
92-
"ANN101", # no type annotation for self
9392
"ANN401", # allow Any as type annotation
9493
"COM812", # Conflict with formatter
9594
"CPY", # No copyright statements

src/pre_commit_uv/__init__.py

Lines changed: 54 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,20 @@
44

55
# only import built-ins at top level to avoid interpreter startup overhead
66
import os
7+
import pathlib
78
import sys
8-
from typing import TYPE_CHECKING
9+
from typing import TYPE_CHECKING, cast
910

1011
if TYPE_CHECKING:
11-
from collections.abc import Sequence
12+
from collections.abc import Callable, Sequence
13+
from typing import Protocol
14+
15+
from pre_commit.prefix import Prefix
16+
17+
class _PatchablePython(Protocol):
18+
install_environment: Callable[[Prefix, str, Sequence[str]], None]
19+
health_check: Callable[[Prefix, str], str | None]
20+
1221

1322
_original_main = None
1423

@@ -57,9 +66,6 @@ def _new_main(argv: Sequence[str] | None = None) -> int:
5766

5867
from pre_commit.languages import python # noqa: PLC0415
5968

60-
if TYPE_CHECKING:
61-
from pre_commit.prefix import Prefix # noqa: PLC0415
62-
6369
def _install_environment(
6470
prefix: Prefix,
6571
version: str,
@@ -121,17 +127,48 @@ def uv_version() -> str:
121127

122128
return _metadata_version("uv")
123129

124-
@cache
125-
def _version_info(exe: str) -> str:
126-
from pre_commit.util import CalledProcessError, cmd_output # noqa: PLC0415
127-
128-
prog = 'import sys;print(".".join(str(p) for p in sys.version_info[0:3]))'
129-
try:
130-
return cmd_output(exe, "-S", "-c", prog)[1].strip()
131-
except CalledProcessError:
132-
return f"<<error retrieving version from {exe}>>"
133-
134-
python.install_environment = _install_environment # ty: ignore[invalid-assignment]
135-
python._version_info = _version_info # noqa: SLF001
130+
patched = cast("_PatchablePython", python)
131+
patched.install_environment = _install_environment
132+
patched.health_check = _health_check
136133
assert _original_main is not None # noqa: S101
137134
return _original_main(argv)
135+
136+
137+
def _version_info(exe: str) -> str:
138+
from pre_commit.util import CalledProcessError, cmd_output # noqa: PLC0415
139+
140+
prog = 'import sys;print(".".join(str(p) for p in sys.version_info[0:3]))'
141+
try:
142+
return cmd_output(exe, "-S", "-c", prog)[1].strip()
143+
except CalledProcessError:
144+
return f"<<error retrieving version from {exe}>>"
145+
146+
147+
def _health_check(prefix: Prefix, version: str) -> str | None:
148+
# uv may record fewer version components in pyvenv.cfg than pre-commit expects (e.g. "3.14" vs "3.14.6"),
149+
# so compare only the components uv actually wrote rather than requiring an exact string match
150+
from pre_commit.lang_base import environment_dir # noqa: PLC0415
151+
from pre_commit.languages import python # noqa: PLC0415
152+
from pre_commit.util import win_exe # noqa: PLC0415
153+
154+
pyvenv_cfg = pathlib.Path(environment_dir(prefix, python.ENVIRONMENT_DIR, version)) / "pyvenv.cfg"
155+
if not pyvenv_cfg.exists():
156+
return "pyvenv.cfg does not exist (old virtualenv?)"
157+
158+
cfg = python._read_pyvenv_cfg(str(pyvenv_cfg)) # noqa: SLF001
159+
if "version_info" not in cfg:
160+
return "created virtualenv's pyvenv.cfg is missing `version_info`"
161+
expected = cfg["version_info"].split(".")
162+
163+
py_exe = prefix.path(python.bin_dir(str(pyvenv_cfg.parent)), win_exe("python"))
164+
targets = [("virtualenv python", py_exe)]
165+
if "base-executable" in cfg:
166+
targets.append(("base executable", cfg["base-executable"]))
167+
for label, exe in targets:
168+
if (actual := _version_info(exe)).split(".")[: len(expected)] != expected:
169+
return (
170+
f"{label} version did not match created version:\n"
171+
f"- actual version: {actual}\n"
172+
f"- expected version: {cfg['version_info']}\n"
173+
)
174+
return None

tests/test_health_check.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
from __future__ import annotations
2+
3+
import sys
4+
from typing import TYPE_CHECKING
5+
6+
from pre_commit.languages import python
7+
from pre_commit.prefix import Prefix
8+
9+
from pre_commit_uv import _health_check
10+
11+
if TYPE_CHECKING:
12+
from pathlib import Path
13+
14+
version = "default"
15+
major_minor = f"{sys.version_info[0]}.{sys.version_info[1]}"
16+
full_version = ".".join(str(p) for p in sys.version_info[0:3])
17+
wrong_version = f"{sys.version_info[0]}.{sys.version_info[1] + 1}"
18+
19+
20+
def _make_env(tmp_path: Path, pyvenv_cfg: str | None) -> Prefix:
21+
envdir = tmp_path / f"py_env-{version}"
22+
bin_dir = python.bin_dir(str(envdir))
23+
(tmp_path / bin_dir).mkdir(parents=True)
24+
(tmp_path / bin_dir / python.win_exe("python")).symlink_to(sys.executable)
25+
if pyvenv_cfg is not None:
26+
(envdir / "pyvenv.cfg").write_text(pyvenv_cfg)
27+
return Prefix(str(tmp_path))
28+
29+
30+
def test_health_check_truncated_version_passes(tmp_path: Path) -> None:
31+
"""uv >=0.11.22 writes only the major.minor into pyvenv.cfg (see issue #152)."""
32+
prefix = _make_env(tmp_path, f"version_info = {major_minor}\nbase-executable = {sys.executable}\n")
33+
34+
assert _health_check(prefix, version) is None
35+
36+
37+
def test_health_check_full_version_passes(tmp_path: Path) -> None:
38+
prefix = _make_env(tmp_path, f"version_info = {full_version}\nbase-executable = {sys.executable}\n")
39+
40+
assert _health_check(prefix, version) is None
41+
42+
43+
def test_health_check_no_base_executable_passes(tmp_path: Path) -> None:
44+
prefix = _make_env(tmp_path, f"version_info = {major_minor}\n")
45+
46+
assert _health_check(prefix, version) is None
47+
48+
49+
def test_health_check_version_mismatch_fails(tmp_path: Path) -> None:
50+
prefix = _make_env(tmp_path, f"version_info = {wrong_version}\n")
51+
52+
result = _health_check(prefix, version)
53+
54+
assert result is not None
55+
assert "virtualenv python version did not match created version" in result
56+
assert f"- expected version: {wrong_version}" in result
57+
58+
59+
def test_health_check_base_executable_mismatch_fails(tmp_path: Path) -> None:
60+
prefix = _make_env(tmp_path, f"version_info = {major_minor}\nbase-executable = /does/not/exist/python\n")
61+
62+
result = _health_check(prefix, version)
63+
64+
assert result is not None
65+
assert "base executable version did not match created version" in result
66+
67+
68+
def test_health_check_missing_pyvenv_cfg(tmp_path: Path) -> None:
69+
prefix = _make_env(tmp_path, None)
70+
71+
assert _health_check(prefix, version) == "pyvenv.cfg does not exist (old virtualenv?)"
72+
73+
74+
def test_health_check_missing_version_info(tmp_path: Path) -> None:
75+
prefix = _make_env(tmp_path, "home = /usr\n")
76+
77+
assert _health_check(prefix, version) == "created virtualenv's pyvenv.cfg is missing `version_info`"

0 commit comments

Comments
 (0)