Skip to content

Commit 57b38fe

Browse files
committed
ci(bump-version[script]): Rewrite every workspace version literal in one pass
why: `just bump-version 0.0.1a8` failed mid-run: it rewrote pyproject.toml and uv.lock, but `check-versions` then reported four packages with runtime/pyproject drift because the old recipe only globbed `**/pyproject.toml`. Packages carrying `__version__` (and the five with `_EXTENSION_VERSION` / Sphinx setup-dict `"version"` literals that track the workspace version) were silently left behind. what: - Extract bump logic into scripts/ci/bump_version.py with PEP 440 validation and same-version rejection. - Widen glob coverage to pyproject.toml, packages/*/src/**/*.py, tests/**/*.py, and scripts/**/*.py; exclude .venv, build, dist, .git. - Justfile recipe now calls the new script; dead one-liner removed. - Add tests/ci/test_bump_version.py (5 tests) covering rewrite, skip, same-version reject, invalid PEP 440 reject, and CLI summary output.
1 parent 129ca4b commit 57b38fe

4 files changed

Lines changed: 315 additions & 11 deletions

File tree

justfile

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -162,19 +162,11 @@ _entr-warn:
162162
@echo "See https://eradman.com/entrproject/ "
163163
@echo "----------------------------------------------------------"
164164

165-
# Bump the workspace-wide version string in all pyproject.toml files.
165+
# Bump every workspace version literal (pyproject, __init__.py, tests, scripts).
166166
# Usage: just bump-version 0.0.1a8
167167
bump-version new_version:
168168
@echo "Bumping workspace version to {{new_version}}..."
169-
uv run python -c "\
170-
import pathlib, re; \
171-
old = re.search(r'version\s*=\s*\"([^\"]+)\"', \
172-
pathlib.Path('pyproject.toml').read_text()).group(1); \
173-
print(f' {old} -> {{new_version}}'); \
174-
[p.write_text(p.read_text().replace(old, '{{new_version}}')) \
175-
for p in sorted(pathlib.Path('.').glob('**/pyproject.toml')) \
176-
if old in p.read_text()]; \
177-
"
169+
uv run python scripts/ci/bump_version.py {{new_version}}
178170
uv lock
179171
uv run python scripts/ci/package_tools.py check-versions
180-
@echo "Done. Review with: git diff '**/pyproject.toml' uv.lock"
172+
@echo "Done. Review with: git diff"

scripts/ci/bump_version.py

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
"""Rewrite the workspace version literal across pyproject, source, tests, and scripts.
2+
3+
The workspace keeps its version duplicated in several places:
4+
5+
- ``pyproject.toml`` (root + every publishable package)
6+
- ``__version__`` / ``_EXTENSION_VERSION`` constants in package ``__init__.py``
7+
- Sphinx ``setup()`` return-dict ``"version"`` keys
8+
- ``tests/test_package_tools.py`` assertions
9+
- ``smoke_gp_sphinx`` template in ``scripts/ci/package_tools.py``
10+
11+
``scripts/ci/package_tools.py check-versions`` catches drift between the
12+
pyproject version and whatever the runtime source says, so any literal missed
13+
by this bump will surface immediately after ``uv lock``.
14+
"""
15+
16+
from __future__ import annotations
17+
18+
import argparse
19+
import pathlib
20+
import sys
21+
import typing as t
22+
23+
if sys.version_info >= (3, 11):
24+
import tomllib
25+
else:
26+
import tomli as tomllib # type: ignore[import-not-found]
27+
28+
try:
29+
from packaging.version import InvalidVersion, Version
30+
except ImportError: # pragma: no cover - packaging is a runtime dep via uv
31+
InvalidVersion = ValueError # type: ignore[assignment,misc]
32+
Version = None # type: ignore[assignment,misc]
33+
34+
35+
#: Glob patterns (relative to the workspace root) that may contain version
36+
#: literals. Order is informational only; rewrites are idempotent.
37+
BUMP_GLOBS: t.Final[tuple[str, ...]] = (
38+
"pyproject.toml",
39+
"packages/*/pyproject.toml",
40+
"packages/*/src/**/*.py",
41+
"tests/**/*.py",
42+
"scripts/**/*.py",
43+
)
44+
45+
#: Path fragments to skip even if a glob matches them.
46+
EXCLUDE_FRAGMENTS: t.Final[tuple[str, ...]] = (
47+
".venv/",
48+
"/build/",
49+
"/dist/",
50+
"/.git/",
51+
"__pycache__/",
52+
)
53+
54+
55+
def _workspace_root() -> pathlib.Path:
56+
"""Return the repository root."""
57+
return pathlib.Path(__file__).resolve().parents[2]
58+
59+
60+
def _read_root_version(root: pathlib.Path) -> str:
61+
"""Return the root ``pyproject.toml`` version string.
62+
63+
Parameters
64+
----------
65+
root : pathlib.Path
66+
Repository root containing the root ``pyproject.toml``.
67+
68+
Returns
69+
-------
70+
str
71+
Version string for the workspace root package.
72+
"""
73+
with (root / "pyproject.toml").open("rb") as handle:
74+
data = tomllib.load(handle)
75+
return t.cast("str", data["project"]["version"])
76+
77+
78+
def _validate_new_version(new_version: str, old_version: str) -> None:
79+
"""Validate that ``new_version`` is PEP 440 and not the same as ``old_version``.
80+
81+
Parameters
82+
----------
83+
new_version : str
84+
Proposed new version.
85+
old_version : str
86+
Current workspace version.
87+
"""
88+
if new_version == old_version:
89+
message = f"new version {new_version!r} equals current version"
90+
raise SystemExit(message)
91+
if Version is not None:
92+
try:
93+
Version(new_version)
94+
except InvalidVersion as exc:
95+
message = f"invalid PEP 440 version {new_version!r}: {exc}"
96+
raise SystemExit(message) from exc
97+
98+
99+
def _iter_candidate_files(root: pathlib.Path) -> t.Iterator[pathlib.Path]:
100+
"""Yield files matching any :data:`BUMP_GLOBS` pattern, deduplicated."""
101+
seen: set[pathlib.Path] = set()
102+
for pattern in BUMP_GLOBS:
103+
for path in sorted(root.glob(pattern)):
104+
if not path.is_file():
105+
continue
106+
resolved = path.resolve()
107+
if resolved in seen:
108+
continue
109+
as_posix = resolved.as_posix()
110+
if any(fragment in as_posix for fragment in EXCLUDE_FRAGMENTS):
111+
continue
112+
seen.add(resolved)
113+
yield path
114+
115+
116+
def _rewrite_file(
117+
path: pathlib.Path,
118+
old_version: str,
119+
new_version: str,
120+
) -> int:
121+
"""Rewrite ``old_version`` -> ``new_version`` in ``path``; return replacement count.
122+
123+
Parameters
124+
----------
125+
path : pathlib.Path
126+
File to rewrite.
127+
old_version : str
128+
Literal to replace. Matched verbatim; no regex.
129+
new_version : str
130+
Replacement literal.
131+
132+
Returns
133+
-------
134+
int
135+
Number of occurrences replaced. Zero if the file did not change.
136+
"""
137+
original = path.read_text()
138+
if old_version not in original:
139+
return 0
140+
updated = original.replace(old_version, new_version)
141+
replacements = original.count(old_version)
142+
path.write_text(updated)
143+
return replacements
144+
145+
146+
def bump_workspace_version(
147+
new_version: str,
148+
*,
149+
root: pathlib.Path | None = None,
150+
) -> list[tuple[pathlib.Path, int]]:
151+
"""Rewrite every workspace version literal to ``new_version``.
152+
153+
Parameters
154+
----------
155+
new_version : str
156+
Target version, validated as PEP 440.
157+
root : pathlib.Path | None
158+
Repository root. Defaults to the script's enclosing workspace.
159+
160+
Returns
161+
-------
162+
list[tuple[pathlib.Path, int]]
163+
(path, replacement_count) pairs for every file touched.
164+
"""
165+
workspace_root = root if root is not None else _workspace_root()
166+
old_version = _read_root_version(workspace_root)
167+
_validate_new_version(new_version, old_version)
168+
169+
changes: list[tuple[pathlib.Path, int]] = []
170+
for path in _iter_candidate_files(workspace_root):
171+
count = _rewrite_file(path, old_version, new_version)
172+
if count:
173+
changes.append((path, count))
174+
return changes
175+
176+
177+
def _build_parser() -> argparse.ArgumentParser:
178+
"""Build the CLI argument parser."""
179+
parser = argparse.ArgumentParser(
180+
description=(
181+
"Rewrite the shared workspace version across every "
182+
"pyproject.toml, __init__.py, and test file."
183+
),
184+
)
185+
parser.add_argument("new_version", help="Target version (PEP 440)")
186+
return parser
187+
188+
189+
def main(argv: t.Sequence[str] | None = None) -> int:
190+
"""CLI entry point."""
191+
args = _build_parser().parse_args(argv)
192+
root = _workspace_root()
193+
old_version = _read_root_version(root)
194+
changes = bump_workspace_version(args.new_version, root=root)
195+
196+
total_replacements = sum(count for _, count in changes)
197+
print(f" {old_version} -> {args.new_version}")
198+
for path, count in changes:
199+
try:
200+
rel = path.relative_to(root)
201+
except ValueError:
202+
rel = path
203+
print(f" {rel} ({count})")
204+
print(
205+
f" {len(changes)} file(s) changed, {total_replacements} replacement(s)",
206+
)
207+
return 0
208+
209+
210+
if __name__ == "__main__":
211+
raise SystemExit(main())

tests/ci/__init__.py

Whitespace-only changes.

tests/ci/test_bump_version.py

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
"""Tests for the workspace version-bump CLI."""
2+
3+
from __future__ import annotations
4+
5+
import pathlib
6+
import sys
7+
import textwrap
8+
9+
import pytest
10+
11+
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[2] / "scripts" / "ci"))
12+
import bump_version
13+
14+
15+
def _seed_workspace(tmp_path: pathlib.Path, version: str = "0.0.1a7") -> None:
16+
"""Populate a minimal workspace tree for bump tests."""
17+
(tmp_path / "pyproject.toml").write_text(
18+
textwrap.dedent(f"""
19+
[project]
20+
name = "demo-workspace"
21+
version = "{version}"
22+
""").lstrip(),
23+
)
24+
pkg = tmp_path / "packages" / "demo" / "src" / "demo"
25+
pkg.mkdir(parents=True)
26+
(pkg.parent.parent / "pyproject.toml").write_text(
27+
textwrap.dedent(f"""
28+
[project]
29+
name = "demo"
30+
version = "{version}"
31+
""").lstrip(),
32+
)
33+
(pkg / "__init__.py").write_text(f'__version__ = "{version}"\n')
34+
tests_dir = tmp_path / "tests"
35+
tests_dir.mkdir()
36+
(tests_dir / "test_demo.py").write_text(
37+
f'EXPECTED = "{version}"\n',
38+
)
39+
scripts_dir = tmp_path / "scripts"
40+
scripts_dir.mkdir()
41+
(scripts_dir / "helper.py").write_text(
42+
f'DEFAULT = "{version}"\n',
43+
)
44+
45+
46+
def test_bump_updates_pyproject_and_source(tmp_path: pathlib.Path) -> None:
47+
"""Bumping rewrites pyproject.toml, package source, tests, and scripts."""
48+
_seed_workspace(tmp_path, version="0.0.1a7")
49+
changes = bump_version.bump_workspace_version("0.0.1a8", root=tmp_path)
50+
51+
changed_names = {path.name for path, _ in changes}
52+
assert "pyproject.toml" in changed_names
53+
assert "__init__.py" in changed_names
54+
assert "test_demo.py" in changed_names
55+
assert "helper.py" in changed_names
56+
57+
pkg_init = tmp_path / "packages" / "demo" / "src" / "demo" / "__init__.py"
58+
assert pkg_init.read_text() == '__version__ = "0.0.1a8"\n'
59+
assert '"0.0.1a8"' in (tmp_path / "pyproject.toml").read_text()
60+
61+
62+
def test_bump_skips_files_without_old_version(tmp_path: pathlib.Path) -> None:
63+
"""Files that do not mention the old version are not rewritten."""
64+
_seed_workspace(tmp_path, version="0.0.1a7")
65+
unrelated = tmp_path / "tests" / "test_unrelated.py"
66+
unrelated.write_text('UNRELATED = "something-else"\n')
67+
68+
changes = bump_version.bump_workspace_version("0.0.1a8", root=tmp_path)
69+
assert unrelated.read_text() == 'UNRELATED = "something-else"\n'
70+
assert all(path != unrelated for path, _ in changes)
71+
72+
73+
def test_bump_rejects_same_version(tmp_path: pathlib.Path) -> None:
74+
"""Bumping to the current version is rejected loudly."""
75+
_seed_workspace(tmp_path, version="0.0.1a7")
76+
with pytest.raises(SystemExit, match="equals current version"):
77+
bump_version.bump_workspace_version("0.0.1a7", root=tmp_path)
78+
79+
80+
def test_bump_rejects_invalid_pep440(tmp_path: pathlib.Path) -> None:
81+
"""Non-PEP-440 version strings are rejected."""
82+
_seed_workspace(tmp_path, version="0.0.1a7")
83+
with pytest.raises(SystemExit, match="invalid PEP 440 version"):
84+
bump_version.bump_workspace_version("not-a-version!!", root=tmp_path)
85+
86+
87+
def test_bump_main_prints_summary(
88+
tmp_path: pathlib.Path,
89+
capsys: pytest.CaptureFixture[str],
90+
monkeypatch: pytest.MonkeyPatch,
91+
) -> None:
92+
"""The CLI entry point prints the old -> new header and per-file lines."""
93+
_seed_workspace(tmp_path, version="0.0.1a7")
94+
monkeypatch.setattr(bump_version, "_workspace_root", lambda: tmp_path)
95+
96+
exit_code = bump_version.main(["0.0.1a8"])
97+
98+
assert exit_code == 0
99+
out = capsys.readouterr().out
100+
assert "0.0.1a7 -> 0.0.1a8" in out
101+
assert "file(s) changed" in out

0 commit comments

Comments
 (0)