File size: 5,287 Bytes
3e73a0f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
#!/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()