Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions .github/bump_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import re
import sys
import tomllib
from pathlib import Path


Expand Down Expand Up @@ -59,6 +60,46 @@ 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.
The entry must be the editable project itself, as uv writes it.
"""
pattern = re.compile(
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)
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():
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)
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"
Expand All @@ -70,6 +111,9 @@ def main():

print(f"Version: {current} -> {new} ({bump})")

# 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)


Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/versioning.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,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
Expand Down
1 change: 1 addition & 0 deletions changelog.d/uv-lock-version-in-step.fixed.md
Original file line number Diff line number Diff line change
@@ -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.
176 changes: 176 additions & 0 deletions policyengine_uk/tests/code_health/test_version_bump.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
"""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 shutil
import subprocess
import sys
import tomllib
from pathlib import Path

import pytest
from hypothesis import HealthCheck, given, settings
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")


# 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),
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


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")
Loading