From 78dda6537c89bf50b6b959b359361d216899250a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Fri, 19 Jun 2026 10:37:15 -0700 Subject: [PATCH 1/3] Tolerate uv truncated pyvenv.cfg version_info uv 0.11.22 (astral-sh/uv#19890) writes only the major.minor into a seeded venv's pyvenv.cfg (e.g. "3.14") instead of the full "3.14.6". pre-commit's health_check then fails the exact-match comparison against our _version_info override, raising "expected environment to be healthy immediately after install". Override health_check with a prefix-aware comparison that checks only the version components uv actually recorded, so it passes whether uv writes two or three components. Drop the now-redundant _version_info monkeypatch since health_check was its only consumer. Fixes #152 --- src/pre_commit_uv/__init__.py | 71 +++++++++++++++++++++++++-------- tests/test_health_check.py | 75 +++++++++++++++++++++++++++++++++++ 2 files changed, 129 insertions(+), 17 deletions(-) create mode 100644 tests/test_health_check.py diff --git a/src/pre_commit_uv/__init__.py b/src/pre_commit_uv/__init__.py index a84581c..247ec9b 100644 --- a/src/pre_commit_uv/__init__.py +++ b/src/pre_commit_uv/__init__.py @@ -4,11 +4,20 @@ # only import built-ins at top level to avoid interpreter startup overhead import os +import pathlib import sys -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, cast if TYPE_CHECKING: - from collections.abc import Sequence + from collections.abc import Callable, Sequence + from typing import Protocol + + from pre_commit.prefix import Prefix + + class _PatchablePython(Protocol): + install_environment: Callable[[Prefix, str, Sequence[str]], None] + health_check: Callable[[Prefix, str], str | None] + _original_main = None @@ -57,9 +66,6 @@ def _new_main(argv: Sequence[str] | None = None) -> int: from pre_commit.languages import python # noqa: PLC0415 - if TYPE_CHECKING: - from pre_commit.prefix import Prefix # noqa: PLC0415 - def _install_environment( prefix: Prefix, version: str, @@ -121,17 +127,48 @@ def uv_version() -> str: return _metadata_version("uv") - @cache - def _version_info(exe: str) -> str: - from pre_commit.util import CalledProcessError, cmd_output # noqa: PLC0415 - - prog = 'import sys;print(".".join(str(p) for p in sys.version_info[0:3]))' - try: - return cmd_output(exe, "-S", "-c", prog)[1].strip() - except CalledProcessError: - return f"<>" - - python.install_environment = _install_environment # ty: ignore[invalid-assignment] - python._version_info = _version_info # noqa: SLF001 + patched = cast("_PatchablePython", python) + patched.install_environment = _install_environment + patched.health_check = _health_check assert _original_main is not None # noqa: S101 return _original_main(argv) + + +def _version_info(exe: str) -> str: + from pre_commit.util import CalledProcessError, cmd_output # noqa: PLC0415 + + prog = 'import sys;print(".".join(str(p) for p in sys.version_info[0:3]))' + try: + return cmd_output(exe, "-S", "-c", prog)[1].strip() + except CalledProcessError: + return f"<>" + + +def _health_check(prefix: Prefix, version: str) -> str | None: + # uv may record fewer version components in pyvenv.cfg than pre-commit expects (e.g. "3.14" vs "3.14.6"), + # so compare only the components uv actually wrote rather than requiring an exact string match + from pre_commit.lang_base import environment_dir # noqa: PLC0415 + from pre_commit.languages import python # noqa: PLC0415 + from pre_commit.util import win_exe # noqa: PLC0415 + + pyvenv_cfg = pathlib.Path(environment_dir(prefix, python.ENVIRONMENT_DIR, version)) / "pyvenv.cfg" + if not pyvenv_cfg.exists(): + return "pyvenv.cfg does not exist (old virtualenv?)" + + cfg = python._read_pyvenv_cfg(str(pyvenv_cfg)) # noqa: SLF001 + if "version_info" not in cfg: + return "created virtualenv's pyvenv.cfg is missing `version_info`" + expected = cfg["version_info"].split(".") + + py_exe = prefix.path(python.bin_dir(str(pyvenv_cfg.parent)), win_exe("python")) + targets = [("virtualenv python", py_exe)] + if "base-executable" in cfg: + targets.append(("base executable", cfg["base-executable"])) + for label, exe in targets: + if (actual := _version_info(exe)).split(".")[: len(expected)] != expected: + return ( + f"{label} version did not match created version:\n" + f"- actual version: {actual}\n" + f"- expected version: {cfg['version_info']}\n" + ) + return None diff --git a/tests/test_health_check.py b/tests/test_health_check.py new file mode 100644 index 0000000..0d50bc2 --- /dev/null +++ b/tests/test_health_check.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +import sys +from typing import TYPE_CHECKING + +from pre_commit.languages import python +from pre_commit.prefix import Prefix + +from pre_commit_uv import _health_check + +if TYPE_CHECKING: + from pathlib import Path + +version = "3.14" +actual_version = ".".join(str(p) for p in sys.version_info[0:3]) + + +def _make_env(tmp_path: Path, pyvenv_cfg: str | None) -> Prefix: + envdir = tmp_path / f"py_env-{version}" + bin_dir = python.bin_dir(str(envdir)) + (tmp_path / bin_dir).mkdir(parents=True) + (tmp_path / bin_dir / python.win_exe("python")).symlink_to(sys.executable) + if pyvenv_cfg is not None: + (envdir / "pyvenv.cfg").write_text(pyvenv_cfg) + return Prefix(str(tmp_path)) + + +def test_health_check_truncated_version_passes(tmp_path: Path) -> None: + """uv >=0.11.22 writes only the major.minor into pyvenv.cfg (see issue #152).""" + prefix = _make_env(tmp_path, f"version_info = 3.14\nbase-executable = {sys.executable}\n") + + assert _health_check(prefix, version) is None + + +def test_health_check_full_version_passes(tmp_path: Path) -> None: + prefix = _make_env(tmp_path, f"version_info = {actual_version}\nbase-executable = {sys.executable}\n") + + assert _health_check(prefix, version) is None + + +def test_health_check_no_base_executable_passes(tmp_path: Path) -> None: + prefix = _make_env(tmp_path, "version_info = 3.14\n") + + assert _health_check(prefix, version) is None + + +def test_health_check_version_mismatch_fails(tmp_path: Path) -> None: + prefix = _make_env(tmp_path, "version_info = 2.7\n") + + result = _health_check(prefix, version) + + assert result is not None + assert "virtualenv python version did not match created version" in result + assert "- expected version: 2.7" in result + + +def test_health_check_base_executable_mismatch_fails(tmp_path: Path) -> None: + prefix = _make_env(tmp_path, "version_info = 3.14\nbase-executable = /does/not/exist/python\n") + + result = _health_check(prefix, version) + + assert result is not None + assert "base executable version did not match created version" in result + + +def test_health_check_missing_pyvenv_cfg(tmp_path: Path) -> None: + prefix = _make_env(tmp_path, None) + + assert _health_check(prefix, version) == "pyvenv.cfg does not exist (old virtualenv?)" + + +def test_health_check_missing_version_info(tmp_path: Path) -> None: + prefix = _make_env(tmp_path, "home = /usr\n") + + assert _health_check(prefix, version) == "created virtualenv's pyvenv.cfg is missing `version_info`" From 00e5630f5bf9b39c908d186cdddf13bff02082fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Fri, 19 Jun 2026 10:44:37 -0700 Subject: [PATCH 2/3] Derive test versions from running interpreter --- tests/test_health_check.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/tests/test_health_check.py b/tests/test_health_check.py index 0d50bc2..64b8a3c 100644 --- a/tests/test_health_check.py +++ b/tests/test_health_check.py @@ -11,8 +11,10 @@ if TYPE_CHECKING: from pathlib import Path -version = "3.14" -actual_version = ".".join(str(p) for p in sys.version_info[0:3]) +version = "default" +major_minor = f"{sys.version_info[0]}.{sys.version_info[1]}" +full_version = ".".join(str(p) for p in sys.version_info[0:3]) +wrong_version = f"{sys.version_info[0]}.{sys.version_info[1] + 1}" def _make_env(tmp_path: Path, pyvenv_cfg: str | None) -> Prefix: @@ -27,35 +29,35 @@ def _make_env(tmp_path: Path, pyvenv_cfg: str | None) -> Prefix: def test_health_check_truncated_version_passes(tmp_path: Path) -> None: """uv >=0.11.22 writes only the major.minor into pyvenv.cfg (see issue #152).""" - prefix = _make_env(tmp_path, f"version_info = 3.14\nbase-executable = {sys.executable}\n") + prefix = _make_env(tmp_path, f"version_info = {major_minor}\nbase-executable = {sys.executable}\n") assert _health_check(prefix, version) is None def test_health_check_full_version_passes(tmp_path: Path) -> None: - prefix = _make_env(tmp_path, f"version_info = {actual_version}\nbase-executable = {sys.executable}\n") + prefix = _make_env(tmp_path, f"version_info = {full_version}\nbase-executable = {sys.executable}\n") assert _health_check(prefix, version) is None def test_health_check_no_base_executable_passes(tmp_path: Path) -> None: - prefix = _make_env(tmp_path, "version_info = 3.14\n") + prefix = _make_env(tmp_path, f"version_info = {major_minor}\n") assert _health_check(prefix, version) is None def test_health_check_version_mismatch_fails(tmp_path: Path) -> None: - prefix = _make_env(tmp_path, "version_info = 2.7\n") + prefix = _make_env(tmp_path, f"version_info = {wrong_version}\n") result = _health_check(prefix, version) assert result is not None assert "virtualenv python version did not match created version" in result - assert "- expected version: 2.7" in result + assert f"- expected version: {wrong_version}" in result def test_health_check_base_executable_mismatch_fails(tmp_path: Path) -> None: - prefix = _make_env(tmp_path, "version_info = 3.14\nbase-executable = /does/not/exist/python\n") + prefix = _make_env(tmp_path, f"version_info = {major_minor}\nbase-executable = /does/not/exist/python\n") result = _health_check(prefix, version) From 6bfeca660c8f219d070ea770f809dedd1ef340ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Fri, 19 Jun 2026 10:47:36 -0700 Subject: [PATCH 3/3] Drop removed ANN101 from ruff ignore list --- pyproject.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index f26b15f..a48ef63 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -89,7 +89,6 @@ lint.select = [ "ALL", ] lint.ignore = [ - "ANN101", # no type annotation for self "ANN401", # allow Any as type annotation "COM812", # Conflict with formatter "CPY", # No copyright statements