316 lines
13 KiB
Python
316 lines
13 KiB
Python
"""
|
|
A **book** — one docgen operation, measured at both ends.
|
|
|
|
larder ──► step ──► step ──► step ──► book
|
|
what each one usable what
|
|
came in by itself came out
|
|
|
|
The first step says what went into the operation. The last step says what came
|
|
out, and renders it for the web. Everything between is an ordinary artifact that
|
|
stands on its own — `ir.json` is still an `ir.json`, and `make ir` still works
|
|
without knowing this file exists.
|
|
|
|
## Why both ends, and why they are steps rather than a wrapper
|
|
|
|
Because the two numbers are only worth having **together**. "102 nodes" is not a
|
|
fact about anything; "49 files in, 102 nodes out, nothing lost" is. A book that
|
|
read 45 of 47 files and drew a clean diagram is lying by omission, and before
|
|
this there was no place for the other 2 to be mentioned.
|
|
|
|
They are steps, not a wrapper, because a wrapper is something you can forget to
|
|
apply. A step is in the sequence, and the sequence is the thing the notebook
|
|
emits — so the measure is in the document whether or not anyone remembered.
|
|
|
|
## The two things it is not
|
|
|
|
**Not a gate.** Running one step alone is still a book, just a short one. An
|
|
operation that cannot measure something says what it could not measure and
|
|
carries on. Gating would break the property that makes the intermediate
|
|
artifacts useful, and that property is the whole reason the sequence is worth
|
|
having.
|
|
|
|
**Not a new pipeline.** Everything here composes functions that already existed
|
|
— `ops.overview`, `emitters.dot`, `emitters.minisite`, `notebook.spec`. The spine
|
|
adds a ledger and two measurements. If it ever starts doing the work itself,
|
|
something has gone wrong.
|
|
|
|
## The web end is deliberately the loose one
|
|
|
|
It is last, so nothing depends on it, so it can be replaced wholesale without
|
|
touching a single thing upstream. That is what lets it rule what gets generated
|
|
without being a stable contract: the book measure is the promise, and the page
|
|
that displays it is free to change drastically and often.
|
|
"""
|
|
|
|
import json
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
|
|
from .larder import Larder
|
|
|
|
# The two spine steps. Named here because the notebook, the minisite and the checks
|
|
# all have to agree on what they are called, and three string literals in three
|
|
# files is how they stop agreeing.
|
|
FIRST = "larder"
|
|
LAST = "book"
|
|
|
|
|
|
@dataclass
|
|
class Step:
|
|
"""One thing the operation did, and what it left behind."""
|
|
|
|
id: str
|
|
label: str
|
|
artifact: str | None = None # relative to the book directory
|
|
bytes: int = 0
|
|
note: str = ""
|
|
skipped: str = "" # why, when it did not run
|
|
|
|
def to_dict(self) -> dict:
|
|
out = {"id": self.id, "label": self.label}
|
|
if self.artifact:
|
|
out["artifact"] = self.artifact
|
|
out["bytes"] = self.bytes
|
|
if self.note:
|
|
out["note"] = self.note
|
|
if self.skipped:
|
|
out["skipped"] = self.skipped
|
|
return out
|
|
|
|
|
|
# How a larder's unit shows up on the output side. Per-kind because the relation
|
|
# genuinely differs, and because getting it wrong produces a check that passes
|
|
# for the wrong reason — which is what the first version of this did.
|
|
#
|
|
# relation "exact" one unit in, one node out. Fewer means input was dropped.
|
|
# "collapse" many units in, fewer nodes out, by design.
|
|
# noun what to call the output-side thing, in the claim
|
|
# represents whether a unit that FAILED still appears as a node. Where it does,
|
|
# that is checkable and is the whole-input form of the rule that an
|
|
# unresolved name becomes an `external` node rather than vanishing.
|
|
RELATION = {
|
|
"python": ("exact", "module", True),
|
|
"code": ("exact", "module", True),
|
|
"db": ("exact", "table", False),
|
|
"openapi": ("exact", "endpoint", False),
|
|
# A HAR entry with no URL has nothing to represent, so there is no node to
|
|
# look for. Its absence is the correct outcome and is recorded in `failed`.
|
|
"usage": ("collapse", "call", False),
|
|
}
|
|
|
|
|
|
def _unit_counts(ir: dict, larder: Larder) -> tuple[int, int]:
|
|
"""(nodes from units that were read, nodes from units that failed).
|
|
|
|
Split because a failed file still gets a module node — carrying
|
|
`attrs.error`, so the gap is visible in the graph rather than only in a log.
|
|
Counting them together made "2 files read produced 4 modules" pass a check
|
|
that was supposed to prove nothing had been dropped.
|
|
"""
|
|
nodes = ir.get("nodes") or []
|
|
|
|
if larder.kind in ("python", "code"):
|
|
mods = [n for n in nodes
|
|
if n["kind"] == "module" and (n.get("attrs") or {}).get("file")]
|
|
broken = {n["attrs"]["file"] for n in mods if (n.get("attrs") or {}).get("error")}
|
|
whole = {n["attrs"]["file"] for n in mods} - broken
|
|
return len(whole), len(broken)
|
|
|
|
if larder.kind == "db":
|
|
return sum(1 for n in nodes if n["kind"] == "table"), 0
|
|
|
|
if larder.kind == "openapi":
|
|
return len({
|
|
(n.get("attrs") or {}).get("path") for n in nodes
|
|
if n["kind"] == "endpoint" and (n.get("attrs") or {}).get("path")
|
|
}), 0
|
|
|
|
if larder.kind == "usage":
|
|
return sum(1 for n in nodes if n["kind"] in ("endpoint", "operation")), 0
|
|
|
|
return 0, 0
|
|
|
|
|
|
class Book:
|
|
"""A named operation, its ledger, and the two measures that bracket it."""
|
|
|
|
def __init__(self, slug: str, larder: Larder, out):
|
|
self.slug = slug
|
|
self.larder = larder
|
|
self.out = Path(out)
|
|
self.steps: list[Step] = []
|
|
self.ir: dict | None = None
|
|
self.notebook: str | None = None
|
|
|
|
# The first step, recorded before any work happens. Doing it here rather
|
|
# than at the end is the difference between a measure and a summary: it
|
|
# says what the operation *set out* to read, so a crash halfway leaves a
|
|
# book that still says what went in.
|
|
self.steps.append(Step(
|
|
id=FIRST,
|
|
label="what came in",
|
|
note=larder.line(),
|
|
))
|
|
|
|
# -- the ledger -------------------------------------------------------
|
|
|
|
def step(self, id: str, label: str, path=None, note: str = "",
|
|
skipped: str = "") -> Step:
|
|
"""Record a step. `path` is written already; this measures it."""
|
|
artifact, size = None, 0
|
|
if path is not None:
|
|
path = Path(path)
|
|
if path.exists():
|
|
artifact = str(path.relative_to(self.out)) if self.out in path.parents \
|
|
or path.parent == self.out else str(path)
|
|
size = path.stat().st_size if path.is_file() else _tree_bytes(path)
|
|
s = Step(id=id, label=label, artifact=artifact, bytes=size,
|
|
note=note, skipped=skipped)
|
|
self.steps.append(s)
|
|
return s
|
|
|
|
# -- the last step ----------------------------------------------------
|
|
|
|
def measure(self) -> dict:
|
|
"""What came out. Counted off the final IR and the ledger."""
|
|
ir = self.ir or {"nodes": [], "edges": []}
|
|
by_kind: dict[str, int] = {}
|
|
for n in ir.get("nodes") or []:
|
|
by_kind[n["kind"]] = by_kind.get(n["kind"], 0) + 1
|
|
edge_kinds: dict[str, int] = {}
|
|
for e in ir.get("edges") or []:
|
|
edge_kinds[e["kind"]] = edge_kinds.get(e["kind"], 0) + 1
|
|
|
|
artifacts = [s for s in self.steps if s.artifact]
|
|
return {
|
|
"nodes": sum(by_kind.values()),
|
|
"by_kind": {k: by_kind[k] for k in sorted(by_kind)},
|
|
"edges": sum(edge_kinds.values()),
|
|
"edges_by_kind": {k: edge_kinds[k] for k in sorted(edge_kinds)},
|
|
"external": by_kind.get("external", 0),
|
|
"steps": len(self.steps),
|
|
"artifacts": [{"path": s.artifact, "bytes": s.bytes} for s in artifacts],
|
|
"bytes": sum(s.bytes for s in artifacts),
|
|
}
|
|
|
|
def compare(self) -> list[dict]:
|
|
"""Reconcile the two ends. This is what having both is *for*.
|
|
|
|
Returns observations, each with a verdict, rather than raising: the book
|
|
is already built by the time anyone can compare, and refusing to write it
|
|
would destroy the evidence. `book/checks.py` turns these into pass/fail
|
|
at the book test level, and the CLI exits 1 when one of them is not ok.
|
|
"""
|
|
out = []
|
|
ir = self.ir or {}
|
|
produced, represented = _unit_counts(ir, self.larder)
|
|
relation, noun, represents = RELATION.get(
|
|
self.larder.kind, ("collapse", "node", False))
|
|
read, unit = self.larder.read, self.larder.unit
|
|
n_failed = len(self.larder.failed)
|
|
|
|
if relation == "exact":
|
|
ok = produced >= read
|
|
out.append({
|
|
"id": "units-accounted-for",
|
|
"ok": ok,
|
|
"claim": f"{read} {unit}(s) read produced {produced} {noun}(s)",
|
|
"why": (
|
|
"one unit in, one node out" if ok else
|
|
f"{read - produced} {unit}(s) were read and produced nothing — "
|
|
"the input was dropped between the extractor and the document, "
|
|
"which is the failure this measure exists to catch"
|
|
),
|
|
})
|
|
else:
|
|
out.append({
|
|
"id": "collapse-is-intended",
|
|
"ok": produced >= 1 or read == 0,
|
|
"claim": f"{read} {unit}(s) collapsed to {produced} {noun}(s)",
|
|
"why": "many recorded requests describe few endpoints — that is the point",
|
|
})
|
|
|
|
if n_failed:
|
|
out.append({
|
|
"id": "failures-surfaced",
|
|
"ok": True,
|
|
"claim": f"{n_failed} {unit}(s) could not be read",
|
|
"why": "named in the larder measure and on the landing page, not only in a log",
|
|
})
|
|
|
|
# The whole-input form of "an unresolved name becomes an `external` node".
|
|
# A file that failed to parse must still be in the graph, or the diagram
|
|
# shows a tree that is smaller than the tree on disk and says nothing.
|
|
if represents and n_failed:
|
|
out.append({
|
|
"id": "failures-still-in-the-graph",
|
|
"ok": represented >= n_failed,
|
|
"claim": f"{n_failed} unreadable {unit}(s) appear as {represented} "
|
|
f"marked {noun}(s)",
|
|
"why": (
|
|
"a gap that is drawn can be seen" if represented >= n_failed else
|
|
f"{n_failed - represented} unreadable {unit}(s) are missing from the "
|
|
"graph entirely — the picture is smaller than the source and does "
|
|
"not say so"
|
|
),
|
|
})
|
|
return out
|
|
|
|
# -- writing ----------------------------------------------------------
|
|
|
|
def close(self, *, minisite=None) -> Step:
|
|
"""Append the last step. Call once, after the web output is written.
|
|
|
|
Must be called BEFORE the notebook is built, because the notebook quotes
|
|
the book measure and the measure is not complete until this step exists.
|
|
|
|
The minisite is this step's *artifact*. The notebook is not: it is the whole
|
|
sequence's rendering rather than an item in it, and it is set on
|
|
`self.notebook` afterwards, by name only — a notebook cannot report its
|
|
own byte count without changing it.
|
|
"""
|
|
m = self.measure()
|
|
summary = " · ".join(f"{v} {k}" for k, v in
|
|
sorted(m["by_kind"].items(), key=lambda kv: -kv[1]))
|
|
artifact, size = None, 0
|
|
if minisite is not None:
|
|
minisite = Path(minisite)
|
|
if minisite.exists():
|
|
artifact, size = str(minisite.relative_to(self.out)), _tree_bytes(minisite)
|
|
self.steps.append(Step(
|
|
id=LAST,
|
|
label="what came out",
|
|
artifact=artifact,
|
|
bytes=size,
|
|
note=f"{summary} · {m['edges']} edges" if summary else "nothing was produced",
|
|
))
|
|
return self.steps[-1]
|
|
|
|
def to_dict(self) -> dict:
|
|
"""The book, as the ledger someone else can read.
|
|
|
|
`generated_at` is absent for the same reason the IR's is: an unchanged
|
|
larder must serialise to the same bytes, or nothing downstream can tell
|
|
a real change from a rebuild.
|
|
"""
|
|
out = {
|
|
"slug": self.slug,
|
|
"larder": self.larder.to_dict(),
|
|
"steps": [s.to_dict() for s in self.steps],
|
|
"book": self.measure(),
|
|
"reconciled": self.compare(),
|
|
}
|
|
if self.notebook:
|
|
out["notebook"] = self.notebook
|
|
return out
|
|
|
|
def write(self) -> Path:
|
|
self.out.mkdir(parents=True, exist_ok=True)
|
|
path = self.out / "book.json"
|
|
path.write_text(json.dumps(self.to_dict(), indent=2, sort_keys=False) + "\n")
|
|
return path
|
|
|
|
|
|
def _tree_bytes(root: Path) -> int:
|
|
return sum(p.stat().st_size for p in root.rglob("*") if p.is_file())
|