#!/usr/bin/env python3 """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(): # next_version() derives NN by scanning tags. With no tags it returns 01 # forever, so two different releases can both be stamped ...01 -- which # happened: two models were published under 263701. Say so rather than # emit a number that looks unique and is not. 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()