From 96a8a90033851765ad9cbaa9828bf6f1706c3d83 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 3 Oct 2026 22:41:19 -0400 Subject: [PATCH 1/3] Keep uv.lock's own version in step with pyproject on release bump_version.py also rewrites this package's entry in uv.lock, so CI can install with uv sync --locked. The versioning job runs uv lock --check before committing. Tests cover the rewrite with Hypothesis properties, examples and a differential check against the real lock. Part of #2128. Co-Authored-By: Claude Opus 5.5 --- .github/bump_version.py | 39 ++++++ .github/workflows/versioning.yaml | 5 + changelog.d/uv-lock-version-in-step.fixed.md | 1 + .../tests/code_health/test_version_bump.py | 124 ++++++++++++++++++ 4 files changed, 169 insertions(+) create mode 100644 changelog.d/uv-lock-version-in-step.fixed.md create mode 100644 policyengine_uk/tests/code_health/test_version_bump.py diff --git a/.github/bump_version.py b/.github/bump_version.py index 779a82e385..38fabc642d 100644 --- a/.github/bump_version.py +++ b/.github/bump_version.py @@ -2,6 +2,7 @@ import re import sys +import tomllib from pathlib import Path @@ -59,6 +60,43 @@ def update_file(path: Path, old_version: str, new_version: str): print(f" Updated {path}") +def get_project_name(pyproject_path: Path) -> str: + """The project name, normalised the way uv writes it in uv.lock.""" + name = tomllib.loads(pyproject_path.read_text())["project"]["name"] + return re.sub(r"[-_.]+", "-", name).lower() + + +def set_lock_version(text: str, package: str, new_version: str) -> str: + """Set the version of the project's own entry in uv.lock text. + + CI installs with `uv sync --locked`, which refuses a lock whose entry for + the project disagrees with pyproject.toml, so the two must move together. + Only that entry changes: a dependency can share the old version string. + """ + pattern = re.compile( + rf'^(\[\[package\]\]\nname = "{re.escape(package)}"\nversion = ")[^"\n]*(")$', + re.MULTILINE, + ) + updated, count = pattern.subn(rf"\g<1>{new_version}\g<2>", text) + if count != 1: + raise ValueError(f"Expected one {package} entry in uv.lock, found {count}") + return updated + + +def update_lock(path: Path, package: str, new_version: str): + if not path.exists(): + return + text = path.read_text() + try: + updated = set_lock_version(text, package, new_version) + except ValueError as error: + print(error, file=sys.stderr) + sys.exit(1) + if updated != text: + path.write_text(updated) + print(f" Updated {path}") + + def main(): root = Path(__file__).resolve().parent.parent pyproject = root / "pyproject.toml" @@ -71,6 +109,7 @@ def main(): print(f"Version: {current} -> {new} ({bump})") update_file(pyproject, current, new) + update_lock(root / "uv.lock", get_project_name(pyproject), new) if __name__ == "__main__": diff --git a/.github/workflows/versioning.yaml b/.github/workflows/versioning.yaml index 4b9a3462c1..54baa32268 100644 --- a/.github/workflows/versioning.yaml +++ b/.github/workflows/versioning.yaml @@ -41,6 +41,11 @@ jobs: run: | python .github/bump_version.py towncrier build --yes --version $(python -c "import re; print(re.search(r'version = \"(.+?)\"', open('pyproject.toml').read()).group(1))") + # bump_version.py moves uv.lock's entry for this package with + # pyproject.toml. Refuse to commit a lock that `uv sync --locked` would + # reject, since that would fail every later pull request. + - name: Check uv.lock matches pyproject.toml + run: uv lock --check - name: Update changelog id: commit uses: EndBug/add-and-commit@v10 diff --git a/changelog.d/uv-lock-version-in-step.fixed.md b/changelog.d/uv-lock-version-in-step.fixed.md new file mode 100644 index 0000000000..514e00b6e9 --- /dev/null +++ b/changelog.d/uv-lock-version-in-step.fixed.md @@ -0,0 +1 @@ +- The release version bump now updates uv.lock's entry for policyengine-uk along with pyproject.toml, and checks the lock before committing it. diff --git a/policyengine_uk/tests/code_health/test_version_bump.py b/policyengine_uk/tests/code_health/test_version_bump.py new file mode 100644 index 0000000000..5e36b00cc2 --- /dev/null +++ b/policyengine_uk/tests/code_health/test_version_bump.py @@ -0,0 +1,124 @@ +"""The version bump must keep uv.lock's entry for this package in step. + +CI installs with ``uv sync --locked``, which fails when uv.lock disagrees with +pyproject.toml. The lock records this package's own version, so a release that +bumps pyproject.toml without the lock would fail every later pull request. +.github/bump_version.py therefore rewrites that one entry. These tests check +that the rewrite changes the package's own version and nothing else, including +dependencies that happen to share the old version string, both on the real +uv.lock and on generated ones. + +They read files from the repository checkout, so they skip when the package is +installed without one. +""" + +import importlib.util +import tomllib +from pathlib import Path + +import pytest +from hypothesis import given +from hypothesis import strategies as st + +ROOT = Path(__file__).resolve().parents[3] +SCRIPT = ROOT / ".github" / "bump_version.py" +LOCK = ROOT / "uv.lock" +PYPROJECT = ROOT / "pyproject.toml" + +if not (SCRIPT.exists() and LOCK.exists() and PYPROJECT.exists()): + pytest.skip("needs the repository checkout", allow_module_level=True) + +spec = importlib.util.spec_from_file_location("bump_version", SCRIPT) +bump_version = importlib.util.module_from_spec(spec) +spec.loader.exec_module(bump_version) + +PACKAGE = bump_version.get_project_name(PYPROJECT) + +versions = st.tuples(*[st.integers(0, 999)] * 3).map(lambda v: ".".join(map(str, v))) +# Names that share a prefix with this package, plus arbitrary ones. +other_names = st.one_of( + st.sampled_from([f"{PACKAGE}-data", f"{PACKAGE}2", "policyengine-core"]), + st.text("abcdefghijklmnopqrstuvwxyz0123456789-", min_size=1, max_size=12), +).filter(lambda name: name != PACKAGE) + + +def lock_versions(text: str) -> dict: + return { + package["name"]: package.get("version") + for package in tomllib.loads(text)["package"] + } + + +def make_lock(entries) -> str: + blocks = ['version = 1\nrevision = 3\nrequires-python = ">=3.11"\n'] + for name, version, editable in entries: + source = ( + '{ editable = "." }' + if editable + else '{ registry = "https://pypi.org/simple" }' + ) + blocks.append( + f'[[package]]\nname = "{name}"\nversion = "{version}"\nsource = {source}\n' + ) + return "\n".join(blocks) + + +def test_rewriting_the_real_lock_changes_only_this_package(): + text = LOCK.read_text() + updated = bump_version.set_lock_version(text, PACKAGE, "9999.0.0") + before, after = lock_versions(text), lock_versions(updated) + assert after[PACKAGE] == "9999.0.0" + assert {k: v for k, v in after.items() if k != PACKAGE} == { + k: v for k, v in before.items() if k != PACKAGE + } + changed = [ + (old, new) + for old, new in zip(text.splitlines(), updated.splitlines()) + if old != new + ] + assert len(changed) == 1 + assert len(text.splitlines()) == len(updated.splitlines()) + + +def test_a_dependency_sharing_the_old_version_is_left_alone(): + text = make_lock( + [ + ("numpy", "2.113.0", False), + (PACKAGE, "2.113.0", True), + ("pandas", "2.113.0", False), + ] + ) + updated = bump_version.set_lock_version(text, PACKAGE, "2.113.1") + assert lock_versions(updated) == { + "numpy": "2.113.0", + PACKAGE: "2.113.1", + "pandas": "2.113.0", + } + + +@pytest.mark.parametrize("copies", [0, 2]) +def test_a_missing_or_repeated_entry_is_an_error(copies): + text = make_lock([("numpy", "2.1.3", False)] + [(PACKAGE, "1.0.0", True)] * copies) + with pytest.raises(ValueError): + bump_version.set_lock_version(text, PACKAGE, "1.0.1") + + +@given( + st.lists(st.tuples(other_names, versions), max_size=6, unique_by=lambda e: e[0]), + st.integers(0, 6), + versions, + versions, +) +def test_rewrite_sets_only_this_package_and_is_idempotent( + others, position, old_version, new_version +): + entries = [(name, version, False) for name, version in others] + entries.insert(min(position, len(entries)), (PACKAGE, old_version, True)) + text = make_lock(entries) + + updated = bump_version.set_lock_version(text, PACKAGE, new_version) + + expected = dict(lock_versions(text), **{PACKAGE: new_version}) + assert lock_versions(updated) == expected + assert bump_version.set_lock_version(updated, PACKAGE, new_version) == updated + assert bump_version.set_lock_version(updated, PACKAGE, old_version) == text From 16e9eeefd8f790debafe76e23c63581d82f20aa4 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 10 Oct 2026 17:28:46 -0400 Subject: [PATCH 2/3] Don't fail the lock-rewrite property test on slow input generation On a loaded runner Hypothesis can take over a second to draw the first set of unique package names and fails the too_slow health check, which says nothing about the rewrite under test. Co-Authored-By: Claude Opus 5.5 --- policyengine_uk/tests/code_health/test_version_bump.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/policyengine_uk/tests/code_health/test_version_bump.py b/policyengine_uk/tests/code_health/test_version_bump.py index 5e36b00cc2..c5e2a4d071 100644 --- a/policyengine_uk/tests/code_health/test_version_bump.py +++ b/policyengine_uk/tests/code_health/test_version_bump.py @@ -17,7 +17,7 @@ from pathlib import Path import pytest -from hypothesis import given +from hypothesis import HealthCheck, given, settings from hypothesis import strategies as st ROOT = Path(__file__).resolve().parents[3] @@ -103,6 +103,9 @@ def test_a_missing_or_repeated_entry_is_an_error(copies): bump_version.set_lock_version(text, PACKAGE, "1.0.1") +# Generating unique names with a filter can be slow on a loaded runner; that +# says nothing about the rewrite, so don't fail on it. +@settings(suppress_health_check=[HealthCheck.too_slow], deadline=None) @given( st.lists(st.tuples(other_names, versions), max_size=6, unique_by=lambda e: e[0]), st.integers(0, 6), From d75ce69da3662edda1e253c16bf0ccdd6c732216 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 10 Oct 2026 18:16:26 -0400 Subject: [PATCH 3/3] Stop the version bump before any write when uv.lock is missing or wrong The lock is now updated before pyproject.toml, a missing lock is an error, and only the editable project entry is rewritten. New tests run the real script on a scratch repository: both files move together, and a missing lock or a lock without this package leaves both unchanged. Co-Authored-By: Claude Opus 5.5 --- .github/bump_version.py | 11 +++-- .../tests/code_health/test_version_bump.py | 49 +++++++++++++++++++ 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/.github/bump_version.py b/.github/bump_version.py index 38fabc642d..b6bbe44ec5 100644 --- a/.github/bump_version.py +++ b/.github/bump_version.py @@ -72,9 +72,11 @@ def set_lock_version(text: str, package: str, new_version: str) -> str: CI installs with `uv sync --locked`, which refuses a lock whose entry for the project disagrees with pyproject.toml, so the two must move together. Only that entry changes: a dependency can share the old version string. + The entry must be the editable project itself, as uv writes it. """ pattern = re.compile( - rf'^(\[\[package\]\]\nname = "{re.escape(package)}"\nversion = ")[^"\n]*(")$', + rf'^(\[\[package\]\]\nname = "{re.escape(package)}"\nversion = ")[^"\n]*(")$' + r'(?=\nsource = \{ editable = "\." \}$)', re.MULTILINE, ) updated, count = pattern.subn(rf"\g<1>{new_version}\g<2>", text) @@ -85,7 +87,8 @@ def set_lock_version(text: str, package: str, new_version: str) -> str: def update_lock(path: Path, package: str, new_version: str): if not path.exists(): - return + print(f"{path} not found; it must move with pyproject.toml", file=sys.stderr) + sys.exit(1) text = path.read_text() try: updated = set_lock_version(text, package, new_version) @@ -108,8 +111,10 @@ def main(): print(f"Version: {current} -> {new} ({bump})") - update_file(pyproject, current, new) + # The lock first: if it is missing or has no entry for this package, stop + # before pyproject.toml changes. update_lock(root / "uv.lock", get_project_name(pyproject), new) + update_file(pyproject, current, new) if __name__ == "__main__": diff --git a/policyengine_uk/tests/code_health/test_version_bump.py b/policyengine_uk/tests/code_health/test_version_bump.py index c5e2a4d071..3a2a8878c5 100644 --- a/policyengine_uk/tests/code_health/test_version_bump.py +++ b/policyengine_uk/tests/code_health/test_version_bump.py @@ -13,6 +13,9 @@ """ import importlib.util +import shutil +import subprocess +import sys import tomllib from pathlib import Path @@ -125,3 +128,49 @@ def test_rewrite_sets_only_this_package_and_is_idempotent( assert lock_versions(updated) == expected assert bump_version.set_lock_version(updated, PACKAGE, new_version) == updated assert bump_version.set_lock_version(updated, PACKAGE, old_version) == text + + +def run_bump(tmp_path, lock_text): + """Run the real script on a scratch repository with a patch fragment.""" + (tmp_path / ".github").mkdir() + script = tmp_path / ".github" / "bump_version.py" + shutil.copy(SCRIPT, script) + (tmp_path / "pyproject.toml").write_text( + f'[project]\nname = "{PACKAGE}"\nversion = "1.2.3"\n' + ) + (tmp_path / "changelog.d").mkdir() + (tmp_path / "changelog.d" / "change.fixed.md").write_text("- A fix.\n") + if lock_text is not None: + (tmp_path / "uv.lock").write_text(lock_text) + return subprocess.run([sys.executable, str(script)], capture_output=True, text=True) + + +def test_main_moves_pyproject_and_lock_together(tmp_path): + # The lock's own entry starts stale, as on main before this change. + lock = make_lock([("numpy", "1.2.3", False), (PACKAGE, "1.2.0", True)]) + result = run_bump(tmp_path, lock) + assert result.returncode == 0, result.stderr + assert 'version = "1.2.4"' in (tmp_path / "pyproject.toml").read_text() + assert lock_versions((tmp_path / "uv.lock").read_text()) == { + "numpy": "1.2.3", + PACKAGE: "1.2.4", + } + + +@pytest.mark.parametrize( + "lock_text", + [None, make_lock([("numpy", "1.2.3", False)])], + ids=["no-lock", "no-entry"], +) +def test_main_stops_before_changing_anything(tmp_path, lock_text): + result = run_bump(tmp_path, lock_text) + assert result.returncode != 0 + assert 'version = "1.2.3"' in (tmp_path / "pyproject.toml").read_text() + if lock_text is not None: + assert (tmp_path / "uv.lock").read_text() == lock_text + + +def test_a_registry_entry_with_this_name_is_not_rewritten(): + text = make_lock([(PACKAGE, "1.0.0", False)]) + with pytest.raises(ValueError): + bump_version.set_lock_version(text, PACKAGE, "1.0.1")