File size: 4,826 Bytes
63f41a6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Build the Leanstral-8B project workbook from the JSON sheets in ./data.

The xlsx is a BUILD ARTEFACT: edit data/*.json (or use the `journal` / `status`
subcommands below), never the workbook, or the next build will overwrite your edit.

Style follows the Duplex training workbook: one header row (bold white Calibri on
#305496, centred, wrapped), body cells top-aligned and wrapped, frozen header row,
autofilter over the used range, and Source / Confidence / Status columns.

    python build_workbook.py                       # rebuild Leanstral_8B_Workbook.xlsx
    python build_workbook.py journal "event" "result" "evidence" "next action"
    python build_workbook.py status WBS 4.1 "Done"  # set the Status cell of one row by Id

Docs tooling, not align runtime: it is outside the dependency firewall's scan and
may import openpyxl.
"""

from __future__ import annotations

import contextlib
import datetime as dt
import fcntl
import json
import os
import sys
from pathlib import Path

HERE = Path(__file__).resolve().parent
DATA = HERE / "data"
OUT = HERE / "Leanstral_8B_Workbook.xlsx"
LOCK = DATA / ".workbook.lock"


@contextlib.contextmanager
def locked():
    """One writer at a time: journal/status are read-modify-write on data/*.json, and two concurrent
    CLI calls would otherwise read the same snapshot and drop each other's change."""
    LOCK.parent.mkdir(parents=True, exist_ok=True)
    with open(LOCK, "w") as fh:
        fcntl.flock(fh, fcntl.LOCK_EX)
        try:
            yield
        finally:
            fcntl.flock(fh, fcntl.LOCK_UN)

# sheet order in the workbook; each name maps to data/<name>.json
SHEETS = ["README", "WBS", "Recipe_Stack", "Datasets", "Data_Gaps", "Evals_Gates",
          "Experiment_Plan", "Journal", "Sources"]


def load(name: str) -> dict:
    d = json.loads((DATA / f"{name}.json").read_text(encoding="utf-8"))
    cols, widths = d["columns"], d["widths"]
    if len(cols) != len(widths):
        raise SystemExit(f"{name}: {len(cols)} columns but {len(widths)} widths")
    for i, r in enumerate(d["rows"]):
        if len(r) != len(cols):
            raise SystemExit(f"{name} row {i + 2}: {len(r)} cells, expected {len(cols)}: {r[:2]}")
    return d


def save(name: str, d: dict) -> None:
    p = DATA / f"{name}.json"
    tmp = p.with_name(f"{p.name}.tmp{os.getpid()}")
    tmp.write_text(json.dumps(d, indent=1, ensure_ascii=False) + "\n", encoding="utf-8")
    os.replace(tmp, p)


def build() -> Path:
    from openpyxl import Workbook
    from openpyxl.styles import Alignment, Font, PatternFill
    from openpyxl.utils import get_column_letter

    head_font = Font(name="Calibri", bold=True, color="FFFFFFFF")
    head_fill = PatternFill("solid", fgColor="FF305496")
    head_align = Alignment(horizontal="center", vertical="center", wrap_text=True)
    body_font = Font(name="Calibri", size=11)
    body_align = Alignment(vertical="top", wrap_text=True)

    wb = Workbook()
    wb.remove(wb.worksheets[0])
    for name in SHEETS:
        d = load(name)
        ws = wb.create_sheet(name)
        ws.append(d["columns"])
        for c in ws[1]:
            c.font, c.fill, c.alignment = head_font, head_fill, head_align
        for r in d["rows"]:
            ws.append(r)
        for row in ws.iter_rows(min_row=2):
            for c in row:
                c.font, c.alignment = body_font, body_align
        for i, w in enumerate(d["widths"], start=1):
            ws.column_dimensions[get_column_letter(i)].width = w
        ws.freeze_panes = "A2"
        ws.auto_filter.ref = f"A1:{get_column_letter(len(d['columns']))}{ws.max_row}"
    tmp = OUT.with_name(f"{OUT.stem}.tmp{os.getpid()}.xlsx")
    wb.save(tmp)
    os.replace(tmp, OUT)
    return OUT


def journal(event: str, result: str, evidence: str, nxt: str) -> None:
    d = load("Journal")
    d["rows"].append([dt.date.today().isoformat(), event, result, evidence, nxt])
    save("Journal", d)


def set_status(sheet: str, row_id: str, status: str) -> None:
    d = load(sheet)
    col = d["columns"].index("Status")
    hits = [r for r in d["rows"] if r[0] == row_id]
    if len(hits) != 1:
        raise SystemExit(f"{sheet}: {len(hits)} rows with Id {row_id!r}")
    hits[0][col] = status
    save(sheet, d)


def main(argv: list[str]) -> int:
    with locked():
        return _main(argv)


def _main(argv: list[str]) -> int:
    if argv[:1] == ["journal"]:
        if len(argv) != 5:
            raise SystemExit("usage: journal EVENT RESULT EVIDENCE NEXT")
        journal(*argv[1:])
    elif argv[:1] == ["status"]:
        if len(argv) != 4:
            raise SystemExit("usage: status SHEET ID STATUS")
        set_status(*argv[1:])
    elif argv:
        raise SystemExit(__doc__)
    print(build())
    return 0


if __name__ == "__main__":
    sys.exit(main(sys.argv[1:]))