| |
| """Version stamps: clockface YYWWNN, and the provenance that goes with them. |
| |
| python3 version.py # the next version for this week |
| python3 version.py --parse 263601 # what does that number mean |
| python3 version.py --tag # create the git tag |
| python3 version.py --stamp # the full one-line provenance stamp |
| |
| A version is six digits: two-digit ISO year, two-digit ISO week, two-digit |
| release number within that week. 263601 is the first release of ISO week 36 of |
| 2026, which began 2026-08-31. |
| |
| The year MUST be the ISO week-numbering year (%G), not the calendar year (%y). |
| They disagree either side of new year: 2024-12-30 is in ISO week 1 of 2025, so |
| naive %y%%V yields 2401 -- a year wrong, and it sorts before everything else |
| from 2024. Six digits like this sort lexically into chronological order, which |
| is the whole point of the scheme. |
| |
| The number alone means nothing without what produced it, so a released number |
| is always written with its provenance: |
| |
| clockface 263601 - code 5c4983b - synth 50000 - eval 137 |
| |
| code the git commit the model and evaluator came from |
| synth how many synthetic images it was trained on |
| eval how many REAL photographs the number was measured on |
| """ |
|
|
| from __future__ import annotations |
|
|
| import argparse |
| import datetime as dt |
| import re |
| import subprocess |
|
|
| VERSION_RE = re.compile(r"^v?(\d{2})(\d{2})(\d{2})$") |
|
|
|
|
| def git(*args, cwd=None): |
| try: |
| out = subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True, timeout=10) |
| return out.stdout.strip() if out.returncode == 0 else None |
| except Exception: |
| return None |
|
|
|
|
| def week_stamp(date=None): |
| """Four digits: ISO year (2) + ISO week (2). Never the calendar year.""" |
| d = date or dt.date.today() |
| return f"{d.strftime('%G')[2:]}{d.strftime('%V')}" |
|
|
|
|
| def existing_versions(): |
| tags = git("tag", "--list") or "" |
| out = [] |
| for t in tags.splitlines(): |
| m = VERSION_RE.match(t.strip()) |
| if m: |
| out.append("".join(m.groups())) |
| return sorted(out) |
|
|
|
|
| def next_version(date=None): |
| wk = week_stamp(date) |
| used = [int(v[4:]) for v in existing_versions() if v[:4] == wk] |
| return f"{wk}{max(used, default=0) + 1:02d}" |
|
|
|
|
| def parse(version): |
| m = VERSION_RE.match(str(version).strip()) |
| if not m: |
| raise ValueError(f"not a clockface version: {version!r} (want six digits, e.g. 263601)") |
| yy, ww, nn = (int(g) for g in m.groups()) |
| year = 2000 + yy |
| try: |
| monday = dt.date.fromisocalendar(year, ww, 1) |
| except ValueError as exc: |
| raise ValueError(f"{version}: no ISO week {ww} in {year} ({exc})") |
| return {"version": f"{yy:02d}{ww:02d}{nn:02d}", "iso_year": year, "iso_week": ww, |
| "release": nn, "week_starts": monday.isoformat(), |
| "week_ends": (monday + dt.timedelta(days=6)).isoformat()} |
|
|
|
|
| def code_hash(): |
| """Short commit, flagged dirty when the tree does not match it.""" |
| h = git("rev-parse", "--short", "HEAD") |
| if not h: |
| return "nogit" |
| dirty = git("status", "--porcelain") |
| return f"{h}-dirty" if dirty else h |
|
|
|
|
| def stamp(version=None, synth=None, n_eval=None): |
| v = version or next_version() |
| if not version and not existing_versions(): |
| |
| |
| |
| |
| v += " (UNTAGGED: no release tags exist, so this number is not unique)" |
| parts = [f"clockface {v}", f"code {code_hash()}"] |
| parts.append(f"synth {synth}" if synth is not None else "synth none") |
| parts.append(f"eval {n_eval}" if n_eval is not None else "eval none") |
| return " - ".join(parts) |
|
|
|
|
| def main(): |
| ap = argparse.ArgumentParser(description=__doc__, |
| formatter_class=argparse.RawDescriptionHelpFormatter) |
| ap.add_argument("--parse", metavar="VERSION") |
| ap.add_argument("--tag", action="store_true", help="git tag the next version") |
| ap.add_argument("--stamp", action="store_true") |
| ap.add_argument("--synth", type=int) |
| ap.add_argument("--eval", dest="n_eval", type=int) |
| args = ap.parse_args() |
|
|
| if args.parse: |
| for k, v in parse(args.parse).items(): |
| print(f" {k:<12} {v}") |
| return |
| if args.stamp: |
| print(stamp(synth=args.synth, n_eval=args.n_eval)) |
| return |
| if args.tag: |
| v = next_version() |
| if git("rev-parse", "--short", "HEAD") is None: |
| raise SystemExit("not a git repository") |
| if git("status", "--porcelain"): |
| raise SystemExit("working tree is dirty; commit before tagging, or the " |
| "tag will not describe what was measured") |
| res = subprocess.run(["git", "tag", "-a", v, "-m", f"clockface {v}"], |
| capture_output=True, text=True) |
| if res.returncode: |
| raise SystemExit(res.stderr.strip()) |
| print(f"tagged {v} (push with: git push origin {v})") |
| return |
|
|
| print(next_version()) |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|