Skip to content

Commit 3f0771b

Browse files
committed
test: Use existing golden test for script validation
This commit uses the exiting generated API golden tests (cookiecutter generate templates) to validate the script works on the current template. This ensures that things continue to work even if the template pyproject.toml file is changed and uses a different format to specify the grpc/protobuf dependencies. Signed-off-by: Leandro Lucarella <luca-frequenz@llucax.com>
1 parent 8a5659a commit 3f0771b

1 file changed

Lines changed: 175 additions & 0 deletions

File tree

tests/cookiecutter/scripts/test_dependabot-grpc-fixer.py

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
"""Tests for the dependabot_gprc_fixer package."""
22

33
import importlib.util
4+
import json
5+
import re
6+
import shutil
7+
import sys
48
from pathlib import Path
59
from typing import Protocol, cast
610

@@ -69,3 +73,174 @@ def test_replace_range_uses_dependency_specific_upper_bounds(
6973

7074
assert count == 1
7175
assert updated == expected
76+
77+
78+
# Integration: run the fixer against pyproject.toml files generated by the
79+
# cookiecutter template (as captured in tests_golden/). The fixer only applies
80+
# to API repositories, so we discover every `api*` golden case automatically.
81+
# This catches drift between the template's runtime range formatting and the
82+
# regexes in cookiecutter/scripts/dependabot-grpc-fixer.py.
83+
GOLDEN_API_PYPROJECTS = sorted(
84+
(Path(__file__).resolve().parents[3] / "tests_golden").glob(
85+
"integration/test_cookiecutter_generation/api*/*/pyproject.toml"
86+
)
87+
)
88+
89+
# Upper-bound offsets the fixer applies per runtime dependency (mirrors
90+
# `UPPER_BOUND_OFFSETS` in dependabot-grpc-fixer.py). Kept here independently so
91+
# the test fails loudly if the offsets diverge from the documented contract.
92+
EXPECTED_UPPER_BOUND_OFFSETS = {"protobuf": 2, "grpcio": 1}
93+
94+
95+
def _golden_id(path: Path) -> str:
96+
"""Return a short pytest id derived from the golden case directory."""
97+
# .../test_cookiecutter_generation/<case>/<repo>/pyproject.toml
98+
return path.parent.parent.name
99+
100+
101+
def _extract_runtime_bound(text: str, name: str) -> tuple[str, int]:
102+
"""Return ``(floor, upper_major)`` for ``name`` from a pyproject text.
103+
104+
Uses the same shape the fixer's regex expects so that a template format
105+
change makes this helper raise with a clear message before the test
106+
fabricates a bogus dependabot payload.
107+
"""
108+
pattern = rf'"{re.escape(name)}\s*>=\s*([^,"]+)\s*,\s*<\s*([^"]+)"'
109+
matches = re.findall(pattern, text)
110+
if len(matches) != 1:
111+
raise AssertionError(
112+
f"expected exactly one {name} runtime range in the golden "
113+
f"pyproject.toml; found {len(matches)}: {matches!r}. The api "
114+
"template format may have drifted from the fixer's contract."
115+
)
116+
floor, upper = matches[0]
117+
return floor.strip(), int(upper.strip())
118+
119+
120+
def _bump_major(version: str) -> str:
121+
"""Return ``version`` with its major component incremented by one."""
122+
match = re.match(r"v?(\d+)(.*)", version.strip())
123+
if match is None:
124+
raise AssertionError(f"could not parse version {version!r}")
125+
return f"{int(match.group(1)) + 1}{match.group(2)}"
126+
127+
128+
def _major_number(version: str) -> int:
129+
"""Return the major number from a version string."""
130+
match = re.match(r"\d+", version)
131+
if match is None:
132+
raise AssertionError(f"could not parse major number from {version!r}")
133+
return int(match.group(0))
134+
135+
136+
def _make_dependabot_metadata(*, protobuf_version: str, grpcio_version: str) -> str:
137+
"""Build a realistic dependabot ``UPDATED_DEPENDENCIES_JSON`` payload."""
138+
return json.dumps(
139+
[
140+
{
141+
"dependencyName": "grpcio",
142+
"dependencyType": "direct:production",
143+
"updateType": "version-update:semver-major",
144+
"directory": "/",
145+
"packageEcosystem": "pip",
146+
"newVersion": grpcio_version,
147+
},
148+
{
149+
"dependencyName": "grpcio-tools",
150+
"dependencyType": "direct:development",
151+
"updateType": "version-update:semver-major",
152+
"directory": "/",
153+
"packageEcosystem": "pip",
154+
"newVersion": grpcio_version,
155+
},
156+
{
157+
"dependencyName": "protobuf",
158+
"dependencyType": "direct:production",
159+
"updateType": "version-update:semver-major",
160+
"directory": "/",
161+
"packageEcosystem": "pip",
162+
"newVersion": protobuf_version,
163+
},
164+
]
165+
)
166+
167+
168+
@pytest.mark.parametrize("golden_pyproject", GOLDEN_API_PYPROJECTS, ids=_golden_id)
169+
def test_fixer_against_generated_api_pyproject(
170+
golden_pyproject: Path,
171+
tmp_path: Path,
172+
monkeypatch: pytest.MonkeyPatch,
173+
capsys: pytest.CaptureFixture[str],
174+
) -> None:
175+
"""Apply the fixer to a freshly generated API repo's ``pyproject.toml``.
176+
177+
This guards against template drift: if the API template stops emitting the
178+
exact ``"<dep> >= X, < Y", # Do not widen beyond Y!`` shape that the fixer
179+
relies on, the regex-based replacement will fail to find a match and this
180+
test will surface the breakage.
181+
182+
The "new" versions injected via dependabot metadata are derived from the
183+
golden file's current bounds (next major) so the test always exercises a
184+
real bump even after the template's pinned versions get bumped.
185+
"""
186+
assert GOLDEN_API_PYPROJECTS, (
187+
"no api golden pyproject.toml fixtures discovered; refresh the golden "
188+
"tree with `UPDATE_GOLDEN=1 pytest tests/integration/"
189+
"test_cookiecutter_generation.py::test_golden`"
190+
)
191+
192+
original = golden_pyproject.read_text(encoding="utf-8")
193+
194+
# Derive realistic "new" versions from the golden's current bounds. Bumping
195+
# the major guarantees the fixer rewrites both the floor and the upper
196+
# bound, even if the template later raises its pinned versions.
197+
protobuf_floor, protobuf_upper = _extract_runtime_bound(original, "protobuf")
198+
grpcio_floor, grpcio_upper = _extract_runtime_bound(original, "grpcio")
199+
new_protobuf = _bump_major(protobuf_floor)
200+
new_grpcio = _bump_major(grpcio_floor)
201+
expected_protobuf_upper = (
202+
_major_number(new_protobuf) + EXPECTED_UPPER_BOUND_OFFSETS["protobuf"]
203+
)
204+
expected_grpcio_upper = (
205+
_major_number(new_grpcio) + EXPECTED_UPPER_BOUND_OFFSETS["grpcio"]
206+
)
207+
# Sanity: the derived "new" bounds must actually differ from the originals,
208+
# otherwise the test would silently no-op.
209+
assert new_protobuf != protobuf_floor
210+
assert new_grpcio != grpcio_floor
211+
assert expected_protobuf_upper != protobuf_upper
212+
assert expected_grpcio_upper != grpcio_upper
213+
214+
workspace = tmp_path / fixer.PYPROJECT.name
215+
shutil.copyfile(golden_pyproject, workspace)
216+
monkeypatch.chdir(tmp_path)
217+
monkeypatch.setattr(sys, "argv", [str(FIXER_PATH)])
218+
monkeypatch.setenv(
219+
"UPDATED_DEPENDENCIES_JSON",
220+
_make_dependabot_metadata(
221+
protobuf_version=new_protobuf, grpcio_version=new_grpcio
222+
),
223+
)
224+
225+
fixer.main()
226+
227+
captured = capsys.readouterr()
228+
assert (
229+
captured.out == "Updated pyproject.toml with 2 grpc/protobuf constraint(s).\n"
230+
)
231+
assert captured.err == ""
232+
233+
updated = workspace.read_text(encoding="utf-8")
234+
expected_protobuf_line = (
235+
f'"protobuf >= {new_protobuf}, < {expected_protobuf_upper}", '
236+
f"# Do not widen beyond {expected_protobuf_upper}!"
237+
)
238+
expected_grpcio_line = (
239+
f'"grpcio >= {new_grpcio}, < {expected_grpcio_upper}", '
240+
f"# Do not widen beyond {expected_grpcio_upper}!"
241+
)
242+
assert expected_protobuf_line in updated
243+
assert expected_grpcio_line in updated
244+
# The original bounds must be gone (proves the rewrite actually happened).
245+
assert f'"protobuf >= {protobuf_floor}, < {protobuf_upper}"' not in updated
246+
assert f'"grpcio >= {grpcio_floor}, < {grpcio_upper}"' not in updated

0 commit comments

Comments
 (0)