Skip to content

Commit 53cbae2

Browse files
committed
Version script
1 parent 9137397 commit 53cbae2

3 files changed

Lines changed: 236 additions & 8 deletions

File tree

offwork/core/version.py

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,35 @@
1+
"""Single source of truth for the package version.
2+
3+
When offwork is pip-installed, :func:`importlib.metadata.version` returns
4+
the version baked into the installed distribution. When running from a
5+
source checkout (no ``offwork-*.dist-info`` available), we fall back to
6+
parsing ``pyproject.toml`` directly so we never have to keep two version
7+
strings in sync.
8+
"""
9+
10+
import tomllib
11+
from pathlib import Path
112
from importlib.metadata import PackageNotFoundError
213
from importlib.metadata import version as _pkg_version
314

4-
_FALLBACK_VERSION = "0.1.2"
15+
16+
def _read_pyproject_version() -> str:
17+
"""Walk up from this file to find ``pyproject.toml`` and read its version."""
18+
here = Path(__file__).resolve()
19+
for parent in here.parents:
20+
candidate = parent / "pyproject.toml"
21+
if candidate.exists():
22+
with candidate.open("rb") as f:
23+
data = tomllib.load(f)
24+
project = data.get("project") or {}
25+
ver = project.get("version")
26+
if isinstance(ver, str):
27+
return ver
28+
break
29+
return "0.0.0+unknown"
30+
531

632
try:
733
_VERSION: str = _pkg_version("offwork")
834
except PackageNotFoundError:
9-
# Not installed as a package (e.g. running from source checkout).
10-
_VERSION = _FALLBACK_VERSION
35+
_VERSION = _read_pyproject_version()

scripts/release.py

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
#!/usr/bin/env python3
2+
"""Cut a new offwork release.
3+
4+
Usage::
5+
6+
python scripts/release.py {major|minor|patch} # bump from current
7+
python scripts/release.py 1.2.3 # set explicit version
8+
python scripts/release.py {major|minor|patch} --dry-run
9+
python scripts/release.py {major|minor|patch} --skip-tests
10+
python scripts/release.py {major|minor|patch} --skip-publish
11+
12+
Steps performed:
13+
14+
1. Refuse to run on a dirty working tree (override with ``--allow-dirty``).
15+
2. Bump the version in ``pyproject.toml`` (single source of truth).
16+
3. Run ``mypy offwork`` and ``pytest -q`` (skip with ``--skip-tests``).
17+
4. Build sdist + wheel into ``dist/`` and run ``twine check``.
18+
5. Create the git commit + annotated tag ``vX.Y.Z``.
19+
6. Push commit and tag to ``origin`` (skip with ``--skip-push``).
20+
7. Upload to PyPI via ``twine`` (skip with ``--skip-publish``).
21+
22+
The script is idempotent up to step 5: anything before the tag can be
23+
re-run safely after fixing problems.
24+
"""
25+
26+
from __future__ import annotations
27+
28+
import argparse
29+
import re
30+
import shutil
31+
import subprocess
32+
import sys
33+
from pathlib import Path
34+
35+
try:
36+
import tomllib
37+
except ModuleNotFoundError: # pragma: no cover
38+
import tomli as tomllib # type: ignore[no-redef]
39+
40+
41+
_REPO_ROOT = Path(__file__).resolve().parent.parent
42+
_PYPROJECT = _REPO_ROOT / "pyproject.toml"
43+
_DIST = _REPO_ROOT / "dist"
44+
45+
_SEMVER_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)$")
46+
47+
48+
# -- Helpers ----------------------------------------------------------------
49+
50+
51+
def _run(cmd: list[str], *, check: bool = True) -> subprocess.CompletedProcess[str]:
52+
print(f"\x1b[36m$ {' '.join(cmd)}\x1b[0m", flush=True)
53+
return subprocess.run(cmd, cwd=_REPO_ROOT, check=check, text=True)
54+
55+
56+
def _capture(cmd: list[str]) -> str:
57+
return subprocess.run(
58+
cmd, cwd=_REPO_ROOT, check=True, text=True, capture_output=True,
59+
).stdout.strip()
60+
61+
62+
def _current_version() -> str:
63+
with _PYPROJECT.open("rb") as f:
64+
data = tomllib.load(f)
65+
return data["project"]["version"] # type: ignore[no-any-return]
66+
67+
68+
def _bump(current: str, part: str) -> str:
69+
m = _SEMVER_RE.match(current)
70+
if not m:
71+
raise SystemExit(f"Cannot parse current version {current!r}")
72+
major, minor, patch = (int(x) for x in m.groups())
73+
if part == "major":
74+
return f"{major + 1}.0.0"
75+
if part == "minor":
76+
return f"{major}.{minor + 1}.0"
77+
if part == "patch":
78+
return f"{major}.{minor}.{patch + 1}"
79+
raise SystemExit(f"Unknown bump part: {part}")
80+
81+
82+
def _resolve_target(arg: str, current: str) -> str:
83+
if arg in ("major", "minor", "patch"):
84+
return _bump(current, arg)
85+
if _SEMVER_RE.match(arg):
86+
return arg
87+
raise SystemExit(
88+
f"Argument must be one of 'major', 'minor', 'patch', or X.Y.Z — got {arg!r}"
89+
)
90+
91+
92+
def _write_version(new_version: str) -> None:
93+
text = _PYPROJECT.read_text()
94+
updated, n = re.subn(
95+
r'^version = "[^"]+"$',
96+
f'version = "{new_version}"',
97+
text,
98+
count=1,
99+
flags=re.MULTILINE,
100+
)
101+
if n != 1:
102+
raise SystemExit("Could not locate the 'version = ...' line in pyproject.toml")
103+
_PYPROJECT.write_text(updated)
104+
105+
106+
def _git_clean() -> bool:
107+
return _capture(["git", "status", "--porcelain"]) == ""
108+
109+
110+
def _tag_exists(tag: str) -> bool:
111+
result = subprocess.run(
112+
["git", "rev-parse", "-q", "--verify", f"refs/tags/{tag}"],
113+
cwd=_REPO_ROOT, capture_output=True,
114+
)
115+
return result.returncode == 0
116+
117+
118+
# -- Main flow --------------------------------------------------------------
119+
120+
121+
def main() -> int:
122+
parser = argparse.ArgumentParser(description=__doc__)
123+
parser.add_argument("target", help="'major' | 'minor' | 'patch' | X.Y.Z")
124+
parser.add_argument("--dry-run", action="store_true",
125+
help="Show what would happen without changing anything")
126+
parser.add_argument("--allow-dirty", action="store_true",
127+
help="Skip the clean-working-tree check")
128+
parser.add_argument("--skip-tests", action="store_true",
129+
help="Skip mypy and pytest")
130+
parser.add_argument("--skip-build", action="store_true",
131+
help="Skip building the sdist/wheel and twine check")
132+
parser.add_argument("--skip-tag", action="store_true",
133+
help="Skip the git commit + tag")
134+
parser.add_argument("--skip-push", action="store_true",
135+
help="Skip pushing the commit and tag to origin")
136+
parser.add_argument("--skip-publish", action="store_true",
137+
help="Skip uploading to PyPI")
138+
parser.add_argument("--repository", default="pypi",
139+
help="twine --repository value (default: pypi)")
140+
args = parser.parse_args()
141+
142+
current = _current_version()
143+
target = _resolve_target(args.target, current)
144+
tag = f"v{target}"
145+
146+
print(f"\x1b[1mRelease plan:\x1b[0m {current}\x1b[32m{target}\x1b[0m (tag: {tag})")
147+
148+
if not args.allow_dirty and not _git_clean():
149+
raise SystemExit(
150+
"Working tree is dirty. Commit/stash changes or pass --allow-dirty."
151+
)
152+
153+
if _tag_exists(tag) and not args.skip_tag:
154+
raise SystemExit(f"Tag {tag} already exists. Bump again or pass --skip-tag.")
155+
156+
if args.dry_run:
157+
print("\n--dry-run: stopping before any side effects.")
158+
return 0
159+
160+
# 1. Bump version in pyproject.toml
161+
_write_version(target)
162+
print(f"Wrote version {target} to pyproject.toml")
163+
164+
# 2. Sanity checks
165+
if not args.skip_tests:
166+
_run([sys.executable, "-m", "mypy", "offwork"])
167+
_run([sys.executable, "-m", "pytest", "-q", "--ignore=tests/test_e2e.py"])
168+
169+
# 3. Build
170+
if not args.skip_build:
171+
if _DIST.exists():
172+
shutil.rmtree(_DIST)
173+
_run([sys.executable, "-m", "pip", "install", "--quiet",
174+
"--upgrade", "build", "twine"])
175+
_run([sys.executable, "-m", "build"])
176+
_run([sys.executable, "-m", "twine", "check", "dist/*"])
177+
178+
# 4. Commit + tag
179+
if not args.skip_tag:
180+
_run(["git", "add", "pyproject.toml"])
181+
_run(["git", "commit", "-m", f"Release {target}"])
182+
_run(["git", "tag", "-a", tag, "-m", f"Release {target}"])
183+
184+
# 5. Push
185+
if not args.skip_push and not args.skip_tag:
186+
_run(["git", "push", "origin", "HEAD"])
187+
_run(["git", "push", "origin", tag])
188+
189+
# 6. Publish to PyPI
190+
if not args.skip_publish and not args.skip_build:
191+
_run([
192+
sys.executable, "-m", "twine", "upload",
193+
"--repository", args.repository, "dist/*",
194+
])
195+
196+
print(f"\n\x1b[32m✓ Released {target}\x1b[0m")
197+
return 0
198+
199+
200+
if __name__ == "__main__":
201+
raise SystemExit(main())

tests/test_packaging.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
import offwork
1414
import pytest
1515

16-
from offwork.core.version import _FALLBACK_VERSION, _VERSION
16+
from offwork.core.version import _VERSION
1717

1818

1919
def _project_root() -> Path:
@@ -58,12 +58,14 @@ def test_init_exports_version(self) -> None:
5858
assert isinstance(offwork.__version__, str)
5959
assert offwork.__version__ # non-empty
6060

61-
def test_pyproject_matches_fallback(self) -> None:
62-
"""The fallback version in version.py must match pyproject.toml."""
61+
def test_source_fallback_reads_pyproject(self) -> None:
62+
"""The source-checkout fallback parses pyproject.toml directly."""
63+
from offwork.core.version import _read_pyproject_version
64+
6365
pyproject = _project_root() / "pyproject.toml"
6466
with open(pyproject, "rb") as f:
65-
data = tomllib.load(f)
66-
assert data["project"]["version"] == _FALLBACK_VERSION
67+
expected = tomllib.load(f)["project"]["version"]
68+
assert _read_pyproject_version() == expected
6769

6870

6971
class TestIsolatedInstallation:

0 commit comments

Comments
 (0)