Skip to content

Commit f3f99f7

Browse files
committed
fix(tests): isolate rendered projects from the harness interpreter pin
The generation tests render a project and run copier's copy-time `uv lock`/`uv sync` _tasks. `just test` runs under `uv run` (which exports VIRTUAL_ENV) and the CI matrix's astral-sh/setup-uv exports UV_PYTHON=<matrix python>; both leak into those subprocesses and pin the rendered project to the maintainer's interpreter instead of its own requires-python. Every matrix Python older than the rendered python_version (default 3.13) then aborts `uv lock` with "incompatible with the project's Python requirement". The first CI run failed all 3.11/3.12 jobs this way; the local 3.13-only run masked it. Add a without_interpreter_pins() context manager and apply it at every boundary that builds or drives a rendered project: the render fixture's copier.run_copy, run_in's subprocess env, and an autouse fixture for the update-roundtrip module (its own copier runs plus `just ci`). A real `copier copy` runs in no such environment, so this also makes the tests faithful to it. Add a regression test that renders with a deliberately incompatible UV_PYTHON and asserts the lock still succeeds.
1 parent 70e25e5 commit f3f99f7

3 files changed

Lines changed: 66 additions & 10 deletions

File tree

‎tests/conftest.py‎

Lines changed: 41 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@
22

33
from __future__ import annotations
44

5+
import contextlib
6+
import os
57
import shutil
68
import subprocess
7-
from collections.abc import Callable, Mapping
9+
from collections.abc import Callable, Generator, Mapping
810
from pathlib import Path
911
from typing import TypeAlias
1012

@@ -15,6 +17,29 @@
1517

1618
RenderFn: TypeAlias = Callable[[Mapping[str, object], Path], Path]
1719

20+
# Interpreter pins the maintainer harness must never leak into a rendered project.
21+
# `just test` runs under `uv run` (which exports VIRTUAL_ENV) and the CI matrix's
22+
# astral-sh/setup-uv exports UV_PYTHON=<matrix python>. Either, inherited by copier's
23+
# copy-time `uv lock`/`uv sync` _tasks, resolves the generated project against the
24+
# maintainer's interpreter instead of its own requires-python -- aborting every matrix
25+
# Python older than the rendered python_version (default 3.13). A real `copier copy` runs
26+
# in no such environment; strip them wherever the harness builds or drives a project.
27+
_INTERPRETER_PINS = ("UV_PYTHON", "VIRTUAL_ENV")
28+
29+
30+
@contextlib.contextmanager
31+
def without_interpreter_pins() -> Generator[None]:
32+
"""Run the body with the maintainer's interpreter pins absent from os.environ."""
33+
saved = {name: os.environ.pop(name, None) for name in _INTERPRETER_PINS}
34+
try:
35+
yield
36+
finally:
37+
os.environ.update({name: value for name, value in saved.items() if value is not None})
38+
39+
40+
def _clean_env() -> dict[str, str]:
41+
return {name: value for name, value in os.environ.items() if name not in _INTERPRETER_PINS}
42+
1843

1944
def _missing_tools() -> list[str]:
2045
return [t for t in REQUIRED_TOOLS if shutil.which(t) is None]
@@ -36,26 +61,32 @@ def _render(data: Mapping[str, object], dst: Path) -> Path:
3661
# Generation renders skip the slow pre-commit hook-install task (the config
3762
# does not exist until that layer is added). A dedicated test in Task 7
3863
# exercises the install path with the flag left at its default.
39-
_ = copier.run_copy(
40-
str(template_root),
41-
str(dst),
42-
data={"enable_precommit_install": False, **data},
43-
defaults=True,
44-
unsafe=True,
45-
overwrite=True,
46-
quiet=True,
47-
)
64+
# The copy-time `uv lock`/`uv sync` _tasks must resolve against the generated
65+
# project's requires-python, not a leaked UV_PYTHON/VIRTUAL_ENV (see the pins note).
66+
with without_interpreter_pins():
67+
_ = copier.run_copy(
68+
str(template_root),
69+
str(dst),
70+
data={"enable_precommit_install": False, **data},
71+
defaults=True,
72+
unsafe=True,
73+
overwrite=True,
74+
quiet=True,
75+
)
4876
return dst
4977

5078
return _render
5179

5280

5381
def run_in(project: Path, *args: str, check: bool = True) -> subprocess.CompletedProcess[str]:
5482
"""Run a command inside a rendered project; capture output for assertions."""
83+
# A rendered project's own tooling (`just ci`, `uv run ...`) must not inherit the
84+
# maintainer's interpreter pins, or uv rebuilds its venv against the wrong Python.
5585
return subprocess.run(
5686
list(args),
5787
cwd=project,
5888
check=check,
5989
capture_output=True,
6090
text=True,
91+
env=_clean_env(),
6192
)

‎tests/test_generation.py‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,22 @@ def test_minimal_renders(render: RenderFn, tmp_path: Path) -> None:
114114
assert not (project / "{{ _copier_conf.answers_file }}.jinja").exists()
115115

116116

117+
def test_copy_tasks_ignore_a_leaked_interpreter_pin(
118+
render: RenderFn, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
119+
) -> None:
120+
"""A leaked UV_PYTHON must not pin the rendered project's copy-time `uv lock`.
121+
122+
`just test` runs under `uv run` and the CI matrix's setup-uv exports UV_PYTHON=<matrix
123+
python>; inherited by copier's `uv lock`/`uv sync` _tasks it resolves the generated
124+
project against the maintainer's interpreter instead of its own requires-python, aborting
125+
every matrix Python below the rendered python_version (default 3.13). The render fixture
126+
strips it; prove a deliberately-leaked, incompatible pin no longer fails the render.
127+
"""
128+
monkeypatch.setenv("UV_PYTHON", "3.11") # older than MINIMAL's python_version (3.13)
129+
project = render(MINIMAL, tmp_path / "out")
130+
assert (project / "uv.lock").is_file()
131+
132+
117133
def test_readme_renders(render: RenderFn, tmp_path: Path) -> None:
118134
"""README interpolates project_name/description; the Run block is gated on project_type."""
119135
app = render({**MINIMAL, "project_type": "application"}, tmp_path / "app")

‎tests/test_update_roundtrip.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,23 @@
99
import copier
1010
import pytest
1111

12+
from tests.conftest import without_interpreter_pins
1213
from tests.test_generation import FULL, MINIMAL
1314

1415
if TYPE_CHECKING:
16+
from collections.abc import Iterator
1517
from pathlib import Path
1618

1719
DATA = {**MINIMAL, "enable_precommit_install": False}
1820

1921

22+
@pytest.fixture(autouse=True)
23+
def isolate_interpreter() -> Iterator[None]:
24+
"""Keep UV_PYTHON/VIRTUAL_ENV out of this module's copier runs and rendered `just ci`."""
25+
with without_interpreter_pins():
26+
yield
27+
28+
2029
def _git(repo: Path, *args: str) -> None:
2130
# commit.gpgsign=false / tag.*sign=false: a global `commit.gpgsign=true` or
2231
# `tag.forceSignAnnotated=true` would otherwise make these commits/tags hang

0 commit comments

Comments
 (0)