Skip to content

Commit 040e4da

Browse files
committed
Add synchronized version bump Make targets
1 parent bd488a7 commit 040e4da

4 files changed

Lines changed: 168 additions & 7 deletions

File tree

‎CONTRIBUTING.md‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,20 @@ this release does not pretend to provide complete generated response models.
3535
Mypy checks library signatures and bodies; Flake8 and formatting cover both the
3636
library and tests. Runtime tests continue to cover Python 3.8 in CI.
3737

38+
## Versioning
39+
40+
```bash
41+
make version # Show the current version
42+
make check-version # Verify both version fields agree
43+
make bump-patch # 2.0.0 -> 2.0.1
44+
make bump-minor # 2.0.0 -> 2.1.0
45+
make bump-major # 2.0.0 -> 3.0.0
46+
make bump-version VERSION=2.1.0 # Set an explicit stable version
47+
```
48+
49+
Bumps update `pyproject.toml` and `subfork.__version__` together. They do not
50+
commit, tag, or publish. Review and commit the changes before releasing.
51+
3852
## Documentation site
3953

4054
Public documentation lives in `docs/`. Its `mkpages.yml` selects the dark theme.

‎Makefile‎

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,37 @@ PYTHON ?= python
22

33
.PHONY: format lint typecheck test check build
44
format:
5-
$(PYTHON) -m isort src tests
6-
$(PYTHON) -m black src tests
5+
$(PYTHON) -m isort src tests scripts
6+
$(PYTHON) -m black src tests scripts
77

88
lint:
9-
$(PYTHON) -m black --check src tests
10-
$(PYTHON) -m isort --check-only src tests
11-
$(PYTHON) -m flake8 src tests
9+
$(PYTHON) -m black --check src tests scripts
10+
$(PYTHON) -m isort --check-only src tests scripts
11+
$(PYTHON) -m flake8 src tests scripts
1212

1313
typecheck:
1414
$(PYTHON) -m mypy
1515

1616
test:
1717
$(PYTHON) -m pytest
1818

19-
check: lint typecheck test
19+
check: lint typecheck test check-version
2020

21-
build:
21+
build: check-version
2222
$(PYTHON) -m build
2323
$(PYTHON) -m twine check dist/*
24+
25+
.PHONY: version check-version bump-patch bump-minor bump-major bump-version
26+
export VERSION
27+
28+
version:
29+
@$(PYTHON) scripts/bump-version.py --show
30+
31+
check-version:
32+
@$(PYTHON) scripts/bump-version.py --check
33+
34+
bump-patch bump-minor bump-major:
35+
@$(PYTHON) scripts/bump-version.py --bump $(@:bump-%=%)
36+
37+
bump-version:
38+
@$(PYTHON) scripts/bump-version.py --set

‎scripts/bump-version.py‎

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
"""Keep package metadata and the public client version synchronized."""
2+
3+
import argparse
4+
import os
5+
import re
6+
from pathlib import Path
7+
from typing import Dict, Optional, Sequence, Tuple
8+
9+
ROOT = Path(__file__).resolve().parents[1]
10+
SEMVER = re.compile(r"(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)")
11+
12+
13+
def version_files(root: Path) -> Tuple[str, Dict[Path, Tuple[str, re.Match]]]:
14+
"""Read both declarations and reject missing, invalid, or mismatched versions."""
15+
files = {}
16+
versions = []
17+
for relative, pattern in (
18+
("pyproject.toml", r'(?ms)^\[project\]\s*\n(?:(?!^\[).)*?^version = "([^"]+)"'),
19+
("src/subfork/__init__.py", r'(?m)^__version__ = "([^"]+)"'),
20+
):
21+
path = root / relative
22+
content = path.read_text(encoding="utf-8")
23+
matches = list(re.finditer(pattern, content))
24+
if len(matches) != 1 or not SEMVER.fullmatch(matches[0].group(1)):
25+
raise ValueError("Expected one stable x.y.z version in {}.".format(relative))
26+
files[path] = (content, matches[0])
27+
versions.append(matches[0].group(1))
28+
if versions[0] != versions[1]:
29+
raise ValueError("Version drift between pyproject.toml and __version__; no files changed.")
30+
return versions[0], files
31+
32+
33+
def next_version(current: str, requested: str) -> str:
34+
"""Compute a stable release version, resetting lower components on major/minor bumps."""
35+
if SEMVER.fullmatch(requested):
36+
return requested
37+
major, minor, patch = map(int, current.split("."))
38+
if requested == "major":
39+
return "{}.0.0".format(major + 1)
40+
if requested == "minor":
41+
return "{}.{}.0".format(major, minor + 1)
42+
if requested == "patch":
43+
return "{}.{}.{}".format(major, minor, patch + 1)
44+
raise ValueError("Use major, minor, patch, or a stable x.y.z version.")
45+
46+
47+
def main(argv: Optional[Sequence[str]] = None) -> int:
48+
"""Show, check, or update the version without committing or tagging a release."""
49+
parser = argparse.ArgumentParser(description=__doc__)
50+
group = parser.add_mutually_exclusive_group(required=True)
51+
group.add_argument("--show", action="store_true")
52+
group.add_argument("--check", action="store_true")
53+
group.add_argument("--bump", choices=("major", "minor", "patch"))
54+
group.add_argument("--set", action="store_true", help="Read the explicit version from VERSION")
55+
args = parser.parse_args(argv)
56+
try:
57+
current, files = version_files(ROOT)
58+
if args.show or args.check:
59+
print(current if args.show else "Version fields match: " + current)
60+
return 0
61+
requested = os.environ.get("VERSION", "") if args.set else args.bump
62+
if not requested:
63+
raise ValueError("Usage: make bump-version VERSION=x.y.z")
64+
version = next_version(current, requested)
65+
for path, (content, match) in files.items():
66+
updated = content[: match.start(1)] + version + content[match.end(1) :]
67+
if updated != content:
68+
path.write_text(updated, encoding="utf-8")
69+
print("{} -> {}".format(current, version))
70+
return 0
71+
except (OSError, ValueError) as error:
72+
parser.exit(1, str(error) + "\n")
73+
74+
75+
if __name__ == "__main__":
76+
raise SystemExit(main())

‎tests/test_version.py‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
"""Exercise release targets in isolated copies without bumping this checkout."""
2+
3+
import shutil
4+
import subprocess
5+
from pathlib import Path
6+
7+
import pytest
8+
9+
ROOT = Path(__file__).resolve().parents[1]
10+
11+
12+
@pytest.mark.parametrize(
13+
"target,version",
14+
[
15+
("bump-patch", "2.3.5"),
16+
("bump-minor", "2.4.0"),
17+
("bump-major", "3.0.0"),
18+
("bump-version", "4.5.6"),
19+
("version", "2.3.4"),
20+
("check-version", "2.3.4"),
21+
],
22+
)
23+
def test_version_targets(tmp_path: Path, target: str, version: str) -> None:
24+
"""Run Make targets against a fixture and preserve unrelated metadata."""
25+
import sys
26+
27+
(tmp_path / "scripts").mkdir()
28+
(tmp_path / "src/subfork").mkdir(parents=True)
29+
shutil.copy(ROOT / "Makefile", tmp_path)
30+
shutil.copy(ROOT / "scripts/bump-version.py", tmp_path / "scripts")
31+
project = tmp_path / "pyproject.toml"
32+
init = tmp_path / "src/subfork/__init__.py"
33+
project.write_text('[project]\nname = "subfork"\nversion = "2.3.4"\n')
34+
init.write_text('__version__ = "2.3.4"\n')
35+
result = subprocess.run(
36+
["make", target, "PYTHON=" + sys.executable, "VERSION=4.5.6"],
37+
cwd=tmp_path,
38+
capture_output=True,
39+
text=True,
40+
)
41+
assert result.returncode == 0, result.stderr
42+
assert version in result.stdout
43+
assert project.read_text() == '[project]\nname = "subfork"\nversion = "' + version + '"\n'
44+
assert init.read_text() == '__version__ = "' + version + '"\n'
45+
# A mismatch must fail before either file is changed.
46+
init.write_text('__version__ = "9.9.9"\n')
47+
before = project.read_text()
48+
result = subprocess.run(
49+
[sys.executable, "scripts/bump-version.py", "--bump", "patch"],
50+
cwd=tmp_path,
51+
capture_output=True,
52+
text=True,
53+
)
54+
assert result.returncode == 1
55+
assert project.read_text() == before
56+
assert init.read_text() == '__version__ = "9.9.9"\n'

0 commit comments

Comments
 (0)