new conventions

This commit is contained in:
2026-09-14 06:13:22 -03:00
parent 37c4d588ea
commit a29e0708e8
22 changed files with 3924 additions and 682 deletions

View File

@@ -3,13 +3,18 @@
# Derived from where this file sits, so the folder can be copied anywhere and
# renamed and still work. The logic lives in the Python, never here.
#
# make check prove it, on a tree it builds itself
# make ir SRC=../station extract -> out/ir.json
# make book SRC=../station the whole operation, measured at both ends
# make check prove docgen, on a tree it builds itself
# make check BOOK=out/book/x prove one book — its own level
# make ir SRC=../station extract -> out/ir.json (one step, on its own)
# make graph out/ir.json -> out/graph.svg
# make index out/ir.json -> out/index.md
# make self run the whole pipeline over soleprint itself
# make self docgen's book of itself, then check it
# make doctor what this machine has
#
# Every target below is one step of a book and still works alone — that is the
# property the book spine exists to preserve, not to replace.
#
# The pipeline is three commands and they compose, which is the point:
#
# python3 -m docgen.extractors.python --root SRC -o ir.json
@@ -25,14 +30,24 @@ RUN := PYTHONPATH=$(PARENT) $(PY) -m
OUT ?= $(HERE)/out
SRC ?=
SCHEMA ?=
OPENAPI ?=
HAR ?=
STYLE ?= lucid
THEME ?=
DEPTH ?= 2
SCALE ?= 0.55
BOOK ?=
SLUG ?=
# NOT `LANG`: that is the shell's locale variable, so `?=` inherits
# en_US.UTF-8 from the environment and --lang rejects it.
READER ?= python
OVERLAY ?=
THEME_ARG := $(if $(THEME),--theme $(THEME))
SLUG_ARG := $(if $(SLUG),--slug $(SLUG))
OVER_ARG := $(if $(OVERLAY),--overlay $(OVERLAY))
.PHONY: help check ir db code graph index site minimap explore docs view self doctor clean
.PHONY: help book check ir db code graph index site minimap explore docs view self doctor clean
help: ## List every target
@echo "docgen — static analysis of a tree, and the artifacts that fall out of it"
@@ -42,10 +57,34 @@ help: ## List every target
@echo
@echo " SRC=/path/to/tree what to read OUT=/path where output goes"
@echo " SCHEMA=schema.json a database instead STYLE=lucid THEME=dark|lucid"
@echo " OPENAPI=spec.yaml an API document HAR=session.har a recording"
@echo " BOOK=/path where a book goes, and which book to check"
@echo " READER=python|code ast, or tree-sitter SLUG=name what to call the book"
@echo " OVERLAY=overlay.json hand-written notebook additions, re-applied every build"
@echo " DEPTH=2 how deep to draw"
check: ## Prove the pipeline, offline, needing nothing installed
@$(PY) $(HERE)/selftest.py
@echo
@echo " Three levels of test, by what they assert about:"
@echo " make doctor the machine. Never fails."
@echo " make check docgen. Exits 1."
@echo " make check BOOK=<dir> that book. Exits 1."
check: ## Prove docgen (or one book, with BOOK=<dir>)
@if [ -n "$(BOOK)" ]; then \
$(RUN) $(PKG).book.checks "$(BOOK)"; \
else \
$(PY) $(HERE)/selftest.py; \
fi
book: ## SRC (or SCHEMA/OPENAPI/HAR) -> one operation, measured at both ends
@test -n "$(SRC)$(SCHEMA)$(OPENAPI)$(HAR)" \
|| { echo "Error: set SRC=/path/to/tree (or SCHEMA=, OPENAPI=, HAR=)" >&2; exit 1; }
@$(RUN) $(PKG).book \
$(if $(SRC),--root "$(SRC)" --lang $(READER)) \
$(if $(SCHEMA),--schema "$(SCHEMA)") \
$(if $(OPENAPI),--openapi "$(OPENAPI)") \
$(if $(HAR),--har "$(HAR)") \
-o "$(if $(BOOK),$(BOOK),$(OUT)/book)" \
--style $(STYLE) $(THEME_ARG) $(SLUG_ARG) $(OVER_ARG)
ir: ## Extract SRC into OUT/ir.json
@test -n "$(SRC)" || { echo "Error: set SRC=/path/to/tree" >&2; exit 1; }
@@ -104,15 +143,14 @@ index: ## OUT/ir.json -> OUT/index.md and OUT/sidebar.json
@$(RUN) $(PKG).emitters index $(OUT)/ir.json -o $(OUT)/index.md
@$(RUN) $(PKG).emitters index $(OUT)/ir.json -o $(OUT)/sidebar.json
self: ## Run the whole pipeline over soleprint itself — the honest end-to-end check
@$(MAKE) --no-print-directory ir SRC=$(PARENT)/.. OUT=$(OUT)
@$(MAKE) --no-print-directory index OUT=$(OUT)
@$(MAKE) --no-print-directory graph OUT=$(OUT)
@$(MAKE) --no-print-directory site OUT=$(OUT)
@$(MAKE) --no-print-directory minimap OUT=$(OUT)
@$(MAKE) --no-print-directory explore OUT=$(OUT)
self: ## docgen's book of the widest tree it can see, then check it
@$(eval SELF_SRC := $(shell PYTHONPATH=$(PARENT) $(PY) -c "from $(PKG) import reference; \
r = reference.root(); print(r if r else '$(HERE)')"))
@echo " self-hosting on $(SELF_SRC)"
@$(MAKE) --no-print-directory book SRC=$(SELF_SRC) SLUG=self \
BOOK=$(OUT)/book/self OUT=$(OUT)
@echo
@echo " Read $(OUT)/index.md, or open $(OUT)/site/index.html"
@$(MAKE) --no-print-directory check BOOK=$(OUT)/book/self
doctor: ## Report whether this machine can run it
@printf 'python : '; $(PY) --version 2>&1 || echo MISSING
@@ -120,6 +158,9 @@ doctor: ## Report whether this machine can run it
@printf 'tree-sit : '; $(PY) -c 'import tree_sitter, tree_sitter_c_sharp, tree_sitter_typescript; print("ok — C# and TypeScript available")' 2>/dev/null || echo 'absent — Python only. pip install tree_sitter tree_sitter_c_sharp tree_sitter_typescript'
@printf 'networkx : '; $(PY) -c 'import networkx; print(networkx.__version__ + " — for lab/ experiments")' 2>/dev/null || echo 'absent — only used in lab/'
@printf 'package : %s (from %s)\n' '$(PKG)' '$(PARENT)'
@printf 'yaml : '; $(PY) -c 'import yaml; print("ok — needed only to read OpenAPI")' 2>/dev/null || echo 'absent — only used by the OpenAPI reader'
@printf 'reference: '; PYTHONPATH=$(PARENT) $(PY) -c "from $(PKG) import reference; print(reference.describe())"
@printf 'styles : '; $(RUN) $(PKG).style 2>/dev/null \
|| $(RUN) $(PKG) 2>/dev/null \
|| PYTHONPATH=$(PARENT) $(PY) -c "from $(PKG).style import Style; print(', '.join(Style.available()))"

View File

@@ -0,0 +1,315 @@
"""
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.site`, `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 site 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, *, site=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 site 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 site is not None:
site = Path(site)
if site.exists():
artifact, size = str(site.relative_to(self.out)), _tree_bytes(site)
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())

View File

@@ -0,0 +1,80 @@
""" python3 -m docgen.book --root ../station -o out/book/station
One book: larder measure, the steps, the web output, book measure. The step
artifacts land in `steps/` and are ordinary files — nothing here needs this
command to have been the thing that produced them.
"""
import argparse
import sys
from pathlib import Path
KINDS = ("python", "code", "db", "openapi", "usage")
def main(argv=None) -> int:
p = argparse.ArgumentParser(
prog="python3 -m docgen.book",
description="Run one docgen operation, measured at both ends.",
)
src = p.add_mutually_exclusive_group(required=True)
src.add_argument("--root", type=Path, help="A source tree (Python, or --lang code).")
src.add_argument("--schema", type=Path, help="A graphgen-compatible schema.json.")
src.add_argument("--openapi", type=Path, help="An OpenAPI document.")
src.add_argument("--har", type=Path, help="A HAR recording.")
p.add_argument("--lang", choices=("python", "code"), default="python",
help="With --root: the stdlib ast reader, or tree-sitter. Default python.")
p.add_argument("--output", "-o", type=Path, required=True,
help="The book directory. Created if absent.")
p.add_argument("--slug", help="What to call it. Defaults to the source's name.")
p.add_argument("--style", default="lucid")
p.add_argument("--theme", default=None, help="dark (default) or lucid.")
p.add_argument("--overlay", type=Path,
help="A hand-written overlay, re-applied on every build.")
p.add_argument("--exclude", action="append", default=[],
help="Directory name to skip. Repeatable.")
p.add_argument("--quiet", "-q", action="store_true")
args = p.parse_args(argv)
if args.root is not None:
kind, source = args.lang, args.root
elif args.schema is not None:
kind, source = "db", args.schema
elif args.openapi is not None:
kind, source = "openapi", args.openapi
else:
kind, source = "usage", args.har
if not Path(source).exists():
print(f"Error: {source} does not exist", file=sys.stderr)
return 1
overlay = None
if args.overlay:
if not args.overlay.exists():
# Absent is fine and is the documented default; named-but-missing is
# a typo, and quietly building without it would hide the typo.
print(f"Error: overlay {args.overlay} does not exist", file=sys.stderr)
return 1
from ..notebook import spec as spec_mod
overlay = spec_mod.load(args.overlay)
from .build import run
try:
book = run(kind, source, args.output, slug=args.slug, style=args.style,
theme=args.theme, exclude=tuple(args.exclude), overlay=overlay,
quiet=args.quiet)
except Exception as e: # noqa: BLE001 - the CLI reports, it does not traceback
print(f"Error: {type(e).__name__}: {e}", file=sys.stderr)
return 1
# Exit 1 when the two ends do not reconcile. The book is still written —
# the evidence is the point — but a build that lost input should fail a
# pipeline rather than pass quietly.
lost = [r for r in book.compare() if not r.get("ok")]
return 1 if lost else 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,350 @@
"""
Run a book: larder, the steps, the web output, the book measure.
python3 -m docgen.book --root ../station -o out/book/station
Composes functions that already exist. Nothing here parses, lays out or styles
anything — `ops.overview`, `emitters.dot`, `emitters.site` and `notebook.spec`
do all of it, and this decides the order and writes the ledger. If this file
ever starts doing the work, the seam has moved to the wrong place.
## The notebook is the sequence, not an item in it
So it is not a step. Its **first cell is the larder measure and its last cell is
the book measure**, which is what makes the two ends part of the document rather
than part of the tooling. Between them, one pair of cells per step: what the
step did, and a code cell that loads that step's artifact on its own.
That last part is the "usable by themselves" property made executable. Each cell
reads one artifact and prints one fact, so a reader can start anywhere in the
sequence, and `selftest.py` runs them.
"""
import json
from pathlib import Path
from . import Book
from .larder import Larder
# Loader for the notebook's step cells. Guarded on purpose: a notebook is
# opened from wherever somebody happens to open it, and a traceback on cell 2
# is a worse answer than a sentence saying which directory to run it from.
PRELUDE = '''from pathlib import Path
import json
BOOK = Path.cwd() # the book directory — change if you opened this elsewhere
def load(rel):
"""One step's artifact, on its own. Returns None when it is not here.
A step's artifact can be a directory — the explorer is one — so this returns
a file list for those rather than trying to read a directory as text.
"""
p = BOOK / rel
if not p.exists():
print(f"{rel} is not here — run: make book OUT={BOOK}")
return None
if p.is_dir():
return sorted(f.relative_to(p).as_posix() for f in p.rglob("*") if f.is_file())
return json.loads(p.read_text()) if p.suffix == ".json" else p.read_text()
'''
# Every extractor a book can run, as data rather than as an if/elif chain.
#
# This is the shape that gives the extension contract teeth. `selftest.py` loops
# over this dict and asserts each entry reports a larder measure — so adding an
# extractor makes the test start asking about it without anyone remembering to
# go and add a case. rig's `CONFIG_OVERRIDABLE` loop is the precedent, and it
# found two real bugs that way.
#
# kind -> (module, function, what the source is)
EXTRACTORS = {
"python": (".extractors.python", "extract", "tree"),
"code": (".extractors.code", "extract", "tree"),
"db": (".extractors.db", "extract", "file"),
"openapi": (".extractors.openapi", "extract", "file"),
"usage": (".extractors.usage", "extract", "file"),
}
# Extractors that walk a directory take an exclude list; the ones that read a
# single document have nothing to exclude.
TAKES_EXCLUDE = {"python", "code"}
def extract(kind: str, source, *, exclude=(), identity=None):
"""(IR dict, Larder) for one source type, dispatched through EXTRACTORS."""
from importlib import import_module
if kind not in EXTRACTORS:
raise ValueError(
f"no extractor named {kind!r} — have {', '.join(sorted(EXTRACTORS))}"
)
module_name, fn_name, _ = EXTRACTORS[kind]
# The package name is derived, not written: the Makefile takes PKG from the
# directory name so the folder can be copied anywhere and renamed, and a
# literal "docgen" here would quietly undo that.
root_pkg = __package__.rsplit(".", 1)[0]
fn = getattr(import_module(module_name, package=root_pkg), fn_name)
kwargs = {"identity": identity or str(source)}
if kind in TAKES_EXCLUDE:
kwargs["exclude"] = exclude
g = fn(source, **kwargs)
ir = g.to_dict()
measured = ir["meta"].get("larder")
if measured is None:
# Not fatal. An extractor that cannot count its input still produces a
# book; what it does not get to do is pretend it measured one.
return ir, None
return ir, Larder.from_dict(measured)
def spec_from(book: Book, ir: dict) -> dict:
"""The book's ledger -> a notebook spec, with the two measures at the ends.
Reuses `notebook.spec`'s step vocabulary rather than inventing one, so
`merge()` keeps working and a hand-written overlay can annotate a spine step
the same way it annotates any other.
"""
from ..notebook import spec as spec_mod
steps = [
spec_mod._step(
"larder", "md", title=f"{book.slug} — what came in",
text=(
f"`{book.larder.identity}`\n\n**{book.larder.line()}**\n\n"
"This is the first step of the book and the only measure of the "
"input. Everything below is derived from it, so a number here "
"that looks wrong makes everything below it suspect."
+ (
"\n\nCould not be read:\n\n"
+ "\n".join(f"- `{f['name']}` — {f['error']}"
for f in book.larder.failed)
if book.larder.failed else ""
)
),
),
spec_mod._step("prelude", "code", title="Reading a step on its own",
code=PRELUDE),
]
for s in book.steps:
if s.id in ("larder", "book") or not s.artifact:
continue
steps.append(spec_mod._step(
f"step-{s.id}", "md", title=s.label,
text=f"`{s.artifact}` — {s.bytes:,} bytes" + (f"\n\n{s.note}" if s.note else ""),
))
steps.append(spec_mod._step(
f"load-{s.id}", "code", code=_load_cell(s.id, s.artifact),
))
# Where the larder is API-shaped, the generated endpoint walkthrough slots in
# as further steps. For a tree of source there are no endpoints and this adds
# nothing, which is the correct amount for it to add.
if any(n["kind"] in ("endpoint", "operation") for n in ir.get("nodes") or []):
generated = spec_mod.from_ir(ir)
steps.extend(s for s in generated["steps"] if s["id"] not in ("intro",))
m = book.measure()
# `external` is reported on its own below, so it is dropped here rather than
# appearing twice in one line — which is how it read before.
summary = " · ".join(f"{v} {k}" for k, v in
sorted(m["by_kind"].items(), key=lambda kv: -kv[1])
if k != "external")
reconciled = "\n".join(
f"- {'' if r['ok'] else ''} {r['claim']}{r['why']}"
for r in book.compare()
)
steps.append(spec_mod._step(
"book", "md", title=f"{book.slug} — what came out",
text=(
f"**{summary} · {m['edges']} edges · {m['external']} external**\n\n"
f"{len(m['artifacts'])} artifact(s), {m['bytes']:,} bytes.\n\n"
f"Reconciled against what came in:\n\n{reconciled}\n\n"
"The web output is `site/index.html`."
),
))
return {"version": spec_mod.SPEC_VERSION, "steps": steps}
def _load_cell(step_id: str, artifact: str) -> str:
"""A cell that reads one artifact and prints one fact about it.
The fact has to suit the artifact. An earlier version printed "nodes, edges"
for every JSON file, so `sidebar.json` — which has neither — reported
"0 nodes, 0 edges", which is a true sentence about the wrong thing and worse
than saying nothing.
"""
if not artifact.endswith((".json", ".svg", ".md")):
# A directory, e.g. the explorer.
return (
f'files = load("{artifact}")\n'
'if files is not None:\n'
' print(f"{len(files)} file(s)")\n'
' print("\\n".join(files[:5]))'
)
if artifact.endswith(".json"):
return (
f'data = load("{artifact}")\n'
'if data:\n'
' if "nodes" in data:\n'
' print(f\'{len(data["nodes"])} nodes, {len(data.get("edges", []))} edges\')\n'
' else:\n'
' print(", ".join(f"{k}: {len(v) if isinstance(v, (list, dict)) else v}"\n'
' for k, v in data.items()))'
)
if artifact.endswith(".svg"):
# No IPython import, guarded or otherwise. A generated notebook is a
# build artifact and has to run wherever it is opened; requiring a
# kernel package in order to *load a file* would make the cell fail on
# the machine that produced it, which is where this was found.
return (
f'svg = load("{artifact}")\n'
'if svg:\n'
' print(f"{len(svg):,} bytes of SVG")\n'
' # In Jupyter: from IPython.display import SVG; SVG(svg)'
)
if artifact.endswith(".md"):
return (
f'text = load("{artifact}")\n'
'if text:\n'
' print(text[:400])'
)
return f'print(load("{artifact}") is not None)'
def run(kind: str, source, out, *, slug: str | None = None, style: str = "lucid",
theme: str | None = None, exclude=(), overlay=None, quiet: bool = False) -> Book:
"""The whole book, in one process. Returns it; `book.json` is written."""
from ..ir import check
from ..ops import classify, overview
from ..style import Style
out = Path(out)
slug = slug or Path(str(source)).name or "book"
steps_dir = out / "steps"
steps_dir.mkdir(parents=True, exist_ok=True)
def say(text):
if not quiet:
print(text)
ir, larder = extract(kind, source, exclude=exclude)
if larder is None:
larder = Larder(kind=kind, identity=str(source), unit="document", seen=0)
book = Book(slug=slug, larder=larder, out=out)
book.ir = ir
say(f" larder {larder.line()}")
problems = check(ir)
if problems:
# Recorded as a step rather than raised: a book that cannot be trusted
# should exist and say so, because the alternative is that nobody can
# see what went wrong.
book.step("validate", "the IR did not validate", note="; ".join(problems[:3]))
say(f" WARNING IR has {len(problems)} problem(s)")
p = steps_dir / "ir.json"
p.write_text(json.dumps(ir, indent=2) + "\n")
book.step("ir", "the graph, extracted", p, note=f"{len(ir['nodes'])} nodes")
view = overview(ir)
p = steps_dir / "view.json"
p.write_text(json.dumps(view, indent=2) + "\n")
verdict = classify(view)
book.step("view", "the default view for this source", p,
note=f"{verdict['kind']}{verdict['why']}")
style_obj = Style.load(style, theme=theme)
# The drawing, chosen by structure rather than by the caller. `classify`
# already decided; this runs what it named.
graph_rel = None
if verdict["emitter"] == "erd":
from ..emitters.erd import emit as erd_emit
p = steps_dir / "graph.svg"
p.write_text(erd_emit(view, style_obj))
graph_rel, label = "steps/graph.svg", "drawn as an ERD"
elif verdict["emitter"] == "index":
from ..emitters.index import to_markdown
p = steps_dir / "graph.md"
p.write_text(to_markdown(view))
graph_rel, label = None, "not a diagram — written as a list"
else:
from ..emitters.dot import emit as dot_emit, render
p = steps_dir / "graph.svg"
try:
p.write_bytes(render(dot_emit(view, style_obj,
rankdir=(verdict.get("options") or {}).get("rankdir"))))
graph_rel, label = "steps/graph.svg", f"drawn as a {verdict['kind']}"
except Exception as e: # noqa: BLE001 - graphviz may not be installed
p = None
label = f"not drawn — {type(e).__name__}"
book.step("graph", label, skipped=str(e)[:200])
if p is not None:
book.step("graph", label, p, note=verdict["why"])
from ..emitters.index import to_markdown, to_sidebar
p = steps_dir / "index.md"
p.write_text(to_markdown(ir))
book.step("index", "readable without a diagram", p)
p = steps_dir / "sidebar.json"
p.write_text(json.dumps(to_sidebar(ir), indent=2) + "\n")
book.step("sidebar", "navigation, for whatever renders it", p)
try:
from ..emitters.minimap import emit as mm_emit
p = steps_dir / "minimap.svg"
p.write_text(mm_emit(ir, style_obj))
book.step("minimap", "what is where, read from the colours", p)
except Exception as e: # noqa: BLE001
book.step("minimap", "minimap not drawn", skipped=f"{type(e).__name__}: {e}")
try:
from ..emitters.explore import write as exp_write
exp_write(ir, style_obj, out / "explore")
book.step("explore", "navigate on one side, explore on the other",
out / "explore")
except Exception as e: # noqa: BLE001
book.step("explore", "explorer not built", skipped=f"{type(e).__name__}: {e}")
# -- the last step ----------------------------------------------------
from ..emitters.site import write as site_write
site_dir = out / "site"
site_write(view, style_obj, site_dir,
graph=Path(graph_rel).name if graph_rel else None,
title=slug, book=book.to_dict())
if graph_rel and (out / graph_rel).exists():
(site_dir / Path(graph_rel).name).write_bytes((out / graph_rel).read_bytes())
# close() BEFORE the notebook spec is built, not after. The spec quotes the
# book measure, and the book measure only includes the site once the last
# step exists — build it the other way round and the notebook says 7
# artifacts while book.json says 8, which is exactly what it did.
book.close(site=site_dir)
from ..emitters.notebook import write as nb_write
from ..notebook import spec as spec_mod
spec, spec_problems = spec_mod.merge(spec_from(book, ir), overlay)
nb_write(spec, out / "notebook.ipynb")
# Recorded by name and not by size: a notebook cannot report its own byte
# count without changing it.
book.notebook = "notebook.ipynb"
for pr in spec_problems:
say(f" overlay {pr}")
path = book.write()
m = book.measure()
say(f" book {m['nodes']} nodes · {m['edges']} edges · "
f"{m['external']} external · {len(m['artifacts'])} artifacts")
for r in book.compare():
say(f" {'ok ' if r['ok'] else 'LOST'} {r['claim']}")
say(f" open {site_dir / 'index.html'}")
say(f" {path}")
return book

View File

@@ -0,0 +1,305 @@
"""
Does *this* book still hold — the third test level.
python3 -m docgen.book.checks out/book/station
docgen has three levels of test, and they differ by what they assert *about*.
The distinction is rig's, from `rig/ctrl/selftest.sh`, and it is worth keeping
because it decides what a failure means:
make doctor the MACHINE. Never fails; it reports.
make check DOCGEN. Exits 1. "docgen no longer does what it says."
make check BOOK=<dir>
THIS BOOK. Exits 1. "this book no longer holds."
The third is the one that reaches a project docgen has never seen. It is also
where **framework and custom checks live together**, at different levels:
generated the spine's own assertions, identical for every book. Both
measures present, the reconciliation holding, every artifact
where the ledger says it is, the notebook still executing.
custom hand-written, in the book's own `checks.py`, using the same
check/note/skip helpers — so a project's line and a framework
line read identically and fail identically.
Same split as the notebook's base and overlay, for the same reason: generation
alone cannot know what *this* project cares about, and hand-authoring alone rots.
## The discipline these are written in
Carried from rig verbatim, because it is the point of the whole level:
> Each check is ONE decision that has already been made, with the reason above
> it — not coverage, and deliberately not an exhaustive sweep of use cases. A
> rule without its reason gets overridden the first time it is inconvenient.
> Failing one should read as **"you are about to undo this"** rather than
> "something broke".
## Writing a book's own checks
Put a `checks.py` next to `book.json`:
def checks(book, check, note, skip):
note("what this project will not give up")
# Payments moved once already and the move broke three dashboards.
# If it is not here, something renamed it again.
check("payments is still a module", True,
any(n["id"] == "app.payments" for n in book.ir["nodes"]))
`book` carries `.data` (the parsed `book.json`), `.ir` (the extracted graph),
and `.dir`. `check` takes (name, expected, actual) — expected first, because a
failure report is only useful if it says what was wanted.
"""
import json
import sys
from pathlib import Path
from . import FIRST, LAST
class Loaded:
"""A book read back off disk, for checking rather than building."""
def __init__(self, directory):
self.dir = Path(directory)
path = self.dir / "book.json"
if not path.exists():
raise FileNotFoundError(
f"{path} does not exist — is {self.dir} a book directory?\n"
"Build one with: python3 -m docgen.book --root <src> -o <dir>"
)
self.data = json.loads(path.read_text())
ir_path = self.dir / "steps" / "ir.json"
self.ir = json.loads(ir_path.read_text()) if ir_path.exists() else None
nb_path = self.dir / (self.data.get("notebook") or "notebook.ipynb")
self.notebook = json.loads(nb_path.read_text()) if nb_path.exists() else None
@property
def larder(self) -> dict:
return self.data.get("larder") or {}
@property
def measure(self) -> dict:
return self.data.get("book") or {}
class Report:
"""rig's reporting shape: sections, one line per decision, nothing aborts."""
def __init__(self):
self.passed, self.failed, self.skipped = 0, [], 0
def note(self, text: str) -> None:
print(f"\n{text}")
def check(self, name: str, expected, actual) -> bool:
if expected == actual:
print(f" ok {name}")
self.passed += 1
return True
print(f" FAIL {name}\n expected: {expected!r}\n got: {actual!r}")
self.failed.append(name)
return False
def skip(self, name: str, why: str) -> None:
print(f" -- {name} ({why})")
self.skipped += 1
def total(self) -> int:
print()
# stdout is block-buffered when redirected and stderr is not, so the
# failure summary printed below would otherwise arrive BEFORE the checks
# it summarises — which is how this was found, piping to `tail`.
sys.stdout.flush()
if not self.failed:
print(f"{self.passed} checks passed — this book still holds"
+ (f", {self.skipped} skipped" if self.skipped else ""))
return 0
print(f"FAILED — {len(self.failed)} of {self.passed + len(self.failed)}: "
f"{', '.join(self.failed)}", file=sys.stderr)
print("Read the comment next to the check. A failure here means a decision "
"has drifted, not that a tool is broken.", file=sys.stderr)
return 1
def generated(book: Loaded, r: Report) -> None:
"""The spine's own assertions. Identical for every book, by design."""
r.note("the book is bracketed — both ends present")
# The one structural promise the whole design makes: an operation is
# measured at both ends. A book missing either end is not a short book, it
# is a book whose numbers cannot be reconciled against anything.
ids = [s["id"] for s in book.data.get("steps") or []]
r.check("the first step is the larder measure", FIRST, ids[0] if ids else None)
r.check("the last step is the book measure", LAST, ids[-1] if ids else None)
r.check("nothing is bracketed twice", [1, 1],
[ids.count(FIRST), ids.count(LAST)])
r.note("what came in was measured, not guessed")
larder = book.larder
for key in ("kind", "identity", "unit", "seen", "read", "failed"):
r.check(f"larder records {key}", True, key in larder)
# read is derived, so a stored value that disagrees means somebody wrote it
# by hand. This is the same arithmetic ir/validate.py enforces; asserted
# again here because a book can be assembled without going through the IR.
r.check("read == seen - failed", larder.get("read"),
max(0, larder.get("seen", 0) - len(larder.get("failed") or [])))
# The one field that could carry a secret out of a database URL.
r.check("the identity carries no password", True,
":***@" in larder.get("identity", "") or "@" not in larder.get("identity", ""))
r.note("the two ends reconcile")
reconciled = book.data.get("reconciled") or []
r.check("something was reconciled", True, len(reconciled) > 0)
for item in reconciled:
# Each of these is a claim the book makes about itself. A false one
# means the document below is smaller than the source and does not say
# so, which is the single failure this level exists to catch.
r.check(item["claim"], True, item["ok"])
r.note("the ledger describes files that exist")
for step in book.data.get("steps") or []:
artifact = step.get("artifact")
if not artifact:
continue
path = book.dir / artifact
# A ledger naming a file that is not there is worse than no ledger: it
# is a manifest somebody will build tooling against.
r.check(f"{artifact} is where the ledger says", True, path.exists())
if path.is_file():
r.check(f"{artifact} is {step['bytes']} bytes", step["bytes"],
path.stat().st_size)
r.note("the notebook carries the sequence, with the measures at its ends")
if book.notebook is None:
r.skip("notebook", "no notebook.ipynb in this book")
else:
cells = book.notebook.get("cells") or []
first = "".join(cells[0]["source"]) if cells else ""
last = "".join(cells[-1]["source"]) if cells else ""
r.check("its first cell is what came in", True, "what came in" in first)
r.check("its last cell is what came out", True, "what came out" in last)
# Compiling is not enough — see selftest.py. `json.dumps` writes
# `false`/`true`/`null`, which are valid Python *identifiers*, so a
# generated body full of them compiles and then raises NameError.
ran, failure = _run_cells(cells, book.dir)
r.check(f"its {ran} code cells run, not merely compile", None, failure)
r.note("the web output exists and shows both ends")
index = book.dir / "site" / "index.html"
if not index.exists():
r.skip("site", "no site/index.html in this book")
else:
html = index.read_text()
# The site is the last step precisely because it is the artifact somebody
# definitely opens. A page that shows the result without showing what
# went in is the thing this whole change corrects.
r.check("the page says what came in", True, "what came in" in html)
r.check("the page says what came out", True, "what came out" in html)
if larder.get("failed"):
r.check("the page admits the book is incomplete", True,
"This book is incomplete" in html)
r.note("the book is reproducible")
# A timestamp would make two builds of an unchanged larder differ, which
# destroys the only useful property a ledger has: that a diff means a real
# change. Same rule as the IR's `generated_at`.
r.check("no timestamp in the ledger", True, "generated_at" not in json.dumps(book.data))
def custom(book: Loaded, r: Report) -> None:
"""This book's own assertions, if it has any."""
path = book.dir / "checks.py"
if not path.exists():
r.note("this book's own checks")
r.skip("custom checks", f"no {path.name} — write one to assert what this project cares about")
return
namespace: dict = {"__file__": str(path), "__name__": "book_checks"}
try:
exec(compile(path.read_text(), str(path), "exec"), namespace)
except Exception as e: # noqa: BLE001 - report, do not traceback
r.note("this book's own checks")
r.check(f"{path.name} loads", None, f"{type(e).__name__}: {e}")
return
fn = namespace.get("checks")
if not callable(fn):
r.note("this book's own checks")
r.check(f"{path.name} defines checks(book, check, note, skip)", True, False)
return
try:
fn(book, r.check, r.note, r.skip)
except Exception as e: # noqa: BLE001
r.check(f"{path.name} ran to completion", None, f"{type(e).__name__}: {e}")
def _run_cells(cells: list, cwd: Path) -> tuple[int, str | None]:
"""Execute the notebook's code cells from the book directory."""
import contextlib
import io
import os
ns, ran, failure = {}, 0, None
previous = os.getcwd()
try:
os.chdir(cwd)
with contextlib.redirect_stdout(io.StringIO()):
for c in cells:
if c.get("cell_type") != "code":
continue
src = "".join(c["source"])
if "urlopen" in src or ("call(" in src and "def call" not in src):
# Anything that would reach the network is compiled, not run.
try:
compile(src, c["id"], "exec")
ran += 1
except SyntaxError as e:
failure = f"{c['id']}: {e}"
break
continue
try:
exec(compile(src, c["id"], "exec"), ns)
ran += 1
except Exception as e: # noqa: BLE001 - any failure is the finding
failure = f"{c['id']}: {type(e).__name__}: {e}"
break
finally:
os.chdir(previous)
return ran, failure
def main(argv=None) -> int:
import argparse
p = argparse.ArgumentParser(
prog="python3 -m docgen.book.checks",
description="Check one book: the spine's assertions, then its own.",
)
p.add_argument("book", type=Path, help="A book directory (holding book.json).")
p.add_argument("--only", choices=("generated", "custom"),
help="Run one level rather than both.")
args = p.parse_args(argv)
try:
book = Loaded(args.book)
except (FileNotFoundError, json.JSONDecodeError) as e:
print(f"Error: {e}", file=sys.stderr)
return 1
print(f"{book.dir}{book.data.get('slug', '?')}")
r = Report()
if args.only != "custom":
generated(book, r)
if args.only != "generated":
custom(book, r)
return r.total()
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,195 @@
"""
What went in — the first step of every book.
A **larder** is the stocked source together with what connects it: a repo, a
live database, an OpenAPI endpoint, a HAR capture. Deliberately not "bucket".
A bucket is somewhere bytes sit; a larder is stocked from outside, has an
inventory, and goes stale. All three of those are properties worth measuring,
and they are the reason this file exists.
## Why a measure at all
Every extractor knew what it read and threw the number away. `Meta` carried
`source` and `root` — which extractor ran, and what it was pointed at — and
nothing about what was actually consumed. So a run that read 45 of 47 files
produced exactly the same document as one that read all 47, and the diagram
looked complete either way.
That is the failure this prevents, and it is the same failure the IR already
guards against one level down: an unresolved name becomes an `external` node
rather than being dropped, because *silently losing a thing is worse than
recording an unresolved one*. A larder measure is that rule applied to the
input as a whole.
## seen, failed, read
seen how many units the larder offered
failed the ones that could not be consumed, by name, with the reason
read seen - len(failed), derived and never stored
Three fields where two numbers would do, on purpose: `read` as a stored value
invites the question "does it include the failures", and every reader answers it
differently. Derived, there is nothing to get wrong.
## Redaction
`identity` is the one field in this whole tool that can carry a credential —
a database DSN has the password in it. So it is scrubbed here at construction,
and `ir/validate.py` sweeps for the scrub having worked. That is the same
discipline as `VISUAL_KEYS`: the architectural rule is a check, not a convention.
Paths are left as the caller gave them rather than resolved. An absolute path is
not a secret but it is machine-specific, and `meta.root` is already a bare name
for exactly that reason.
"""
import re
from dataclasses import dataclass, field
# What `seen` counts, per source type. Closed, like the IR's `kind` vocabulary:
# a larder that counts something with no name here is a larder nobody can
# compare against another one.
UNITS = ("file", "table", "path", "entry", "document")
# Query-string and connection-string keys whose value is a secret. Matched
# case-insensitively, because ODBC writes `Password=` and URLs write `password=`.
SECRET_KEYS = (
"password", "passwd", "pwd", "secret", "token", "access_token", "refresh_token",
"api_key", "apikey", "key", "sig", "signature", "auth", "credentials",
)
MASK = "***"
def _count(label: str, n: int) -> str:
"""Agree a label with its count, both directions.
Two directions because both arise: the closed `UNITS` are singular and need
pluralising, while `extra` keys are written plural by the extractor that
knows them ("packages", "hosts") and need singularising at one. Naive `+ "s"`
gives "entrys"; naive nothing gives "1 hosts".
"""
if n == 1:
return label[:-3] + "y" if label.endswith("ies") else label.rstrip("s") or label
if label.endswith("y"):
return label[:-1] + "ies"
return label if label.endswith("s") else label + "s"
def redact(identity: str) -> str:
"""Strip credentials out of a path or connection string.
Three shapes, which is all of them in practice:
postgresql://user:hunter2@host:5432/db -> postgresql://user:***@host:5432/db
https://api/x?token=abc123 -> https://api/x?token=***
Driver=x;Server=y;Password=hunter2; -> Driver=x;Server=y;Password=***;
The user, host, port and database survive. Those are what someone reading
the measure needs in order to recognise which larder this was, and none of
them is a secret.
"""
if not identity:
return identity
# scheme://user:secret@host — the password is between the first colon after
# the scheme and the last @ of the authority.
identity = re.sub(
r"(?P<scheme>[a-zA-Z][a-zA-Z0-9+.\-]*://)(?P<user>[^:/@\s]+):(?P<secret>[^@/\s]*)@",
lambda m: f"{m.group('scheme')}{m.group('user')}:{MASK}@",
identity,
)
# key=value, in a query string or a semicolon-delimited connection string.
keys = "|".join(re.escape(k) for k in SECRET_KEYS)
identity = re.sub(
rf"(?i)\b(?P<key>{keys})(?P<sep>\s*=\s*)(?P<secret>[^&;\s]*)",
lambda m: f"{m.group('key')}{m.group('sep')}{MASK}",
identity,
)
return identity
@dataclass
class Larder:
"""What one source offered, and how much of it was consumed.
Lands in `meta.larder`. Never in `nodes` or `attrs` — the IR is structure,
and provenance is meta. That split is the same one that keeps colour out.
"""
kind: str # which extractor stocked it
identity: str # path or redacted DSN
unit: str # one of UNITS
seen: int = 0
failed: list[dict] = field(default_factory=list)
extra: dict = field(default_factory=dict) # per-source facts
def __post_init__(self):
self.identity = redact(self.identity)
if self.unit not in UNITS:
raise ValueError(
f"larder unit {self.unit!r} is not one of {UNITS}"
"a unit nobody else uses cannot be compared against another larder"
)
@property
def read(self) -> int:
"""`seen` minus what failed. Derived, so it cannot disagree with itself."""
return max(0, self.seen - len(self.failed))
def fail(self, name: str, error: str) -> None:
"""Record a unit that could not be consumed, by name and reason."""
self.failed.append({"name": name, "error": error})
def to_dict(self) -> dict:
"""Key order fixed so an unchanged larder serialises byte-identically."""
out = {
"kind": self.kind,
"identity": self.identity,
"unit": self.unit,
"seen": self.seen,
"read": self.read,
"failed": [{"name": f["name"], "error": f["error"]} for f in self.failed],
}
if self.extra:
out["extra"] = {k: self.extra[k] for k in sorted(self.extra)}
return out
@classmethod
def from_dict(cls, data: dict) -> "Larder":
"""Round-trip. `read` is dropped: it is derived, not carried."""
return cls(
kind=data["kind"],
identity=data["identity"],
unit=data["unit"],
seen=data.get("seen", 0),
failed=list(data.get("failed") or []),
extra=dict(data.get("extra") or {}),
)
def line(self) -> str:
"""The one-line human form — the notebook's first cell, and the CLI.
Reads as a sentence because it is the first thing anyone sees about a
book, and "47 files read, 2 failed" is the fact that decides whether the
rest of the document is worth trusting. Which is also why the grammar
gets attention it would not otherwise deserve: "2 entrys read, 1 hosts"
reads as a machine talking, and a measure nobody reads is not a measure.
"""
parts = [f"{self.read} {_count(self.unit, self.read)} read"]
if self.failed:
parts.append(f"{len(self.failed)} failed")
for key in sorted(self.extra):
value = self.extra[key]
label = key.replace("_", " ")
if isinstance(value, int):
parts.append(f"{value} {_count(label, value)}")
else:
parts.append(f"{value} {label}")
return f"{self.identity}" + ", ".join(parts)
def of(kind: str, identity: str, unit: str, **extra) -> Larder:
"""Shorthand for the common case: `of("db", dsn, "table", dialect=...)`."""
return Larder(kind=kind, identity=identity, unit=unit, extra=extra)

File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 83 KiB

View File

@@ -1,13 +1,14 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg xmlns="http://www.w3.org/2000/svg" width="886pt" height="1630pt" viewBox="0 0 886 1630">
<rect width="886" height="1630" fill="#0a0a0a"/>
<rect x="28.0" y="46.0" width="74.0" height="204.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.emitters.site — 408 lines</title></rect>
<rect x="28.0" y="172.5" width="74.0" height="6.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.site._slots" data-kind="function" class="blk"><title>_slots — function, 13 lines</title></rect>
<rect x="28.0" y="180.0" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.site._fill" data-kind="function" class="blk"><title>_fill — function, 4 lines</title></rect>
<rect x="28.0" y="183.0" width="74.0" height="8.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.site._sidebar" data-kind="function" class="blk"><title>_sidebar — function, 17 lines</title></rect>
<rect x="28.0" y="192.5" width="74.0" height="7.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.site._sections" data-kind="function" class="blk"><title>_sections — function, 15 lines</title></rect>
<rect x="28.0" y="201.0" width="74.0" height="43.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.site.emit" data-kind="function" class="blk"><title>emit — function, 86 lines</title></rect>
<rect x="28.0" y="245.0" width="74.0" height="4.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.site.write" data-kind="function" class="blk"><title>write — function, 9 lines</title></rect>
<svg xmlns="http://www.w3.org/2000/svg" width="886pt" height="2251pt" viewBox="0 0 886 2251">
<rect width="886" height="2251" fill="#0a0a0a"/>
<rect x="28.0" y="46.0" width="74.0" height="247.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.emitters.site — 494 lines</title></rect>
<rect x="28.0" y="182.0" width="74.0" height="27.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.site._ledger" data-kind="function" class="blk"><title>_ledger — function, 54 lines</title></rect>
<rect x="28.0" y="210.0" width="74.0" height="6.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.site._slots" data-kind="function" class="blk"><title>_slots — function, 13 lines</title></rect>
<rect x="28.0" y="217.5" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.site._fill" data-kind="function" class="blk"><title>_fill — function, 4 lines</title></rect>
<rect x="28.0" y="220.5" width="74.0" height="8.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.site._sidebar" data-kind="function" class="blk"><title>_sidebar — function, 17 lines</title></rect>
<rect x="28.0" y="230.0" width="74.0" height="7.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.site._sections" data-kind="function" class="blk"><title>_sections — function, 15 lines</title></rect>
<rect x="28.0" y="238.5" width="74.0" height="48.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.site.emit" data-kind="function" class="blk"><title>emit — function, 96 lines</title></rect>
<rect x="28.0" y="287.5" width="74.0" height="5.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.site.write" data-kind="function" class="blk"><title>write — function, 10 lines</title></rect>
<rect x="110.0" y="46.0" width="74.0" height="158.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.emitters.explore — 316 lines</title></rect>
<rect x="110.0" y="65.5" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.explore._is_schema" data-kind="function" class="blk"><title>_is_schema — function, 4 lines</title></rect>
<rect x="110.0" y="68.5" width="74.0" height="24.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.explore._neighbourhood_svgs" data-kind="function" class="blk"><title>_neighbourhood_svgs — function, 48 lines</title></rect>
@@ -70,178 +71,257 @@
<rect x="684.0" y="57.5" width="74.0" height="28.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.auto.main" data-kind="function" class="blk"><title>main — function, 56 lines</title></rect>
<rect x="766.0" y="46.0" width="74.0" height="37.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.emitters.cli_notebook — 74 lines</title></rect>
<rect x="766.0" y="54.0" width="74.0" height="28.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.cli_notebook.main" data-kind="function" class="blk"><title>main — function, 57 lines</title></rect>
<rect x="28.0" y="288.0" width="74.0" height="37.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.emitters.cli_site — 74 lines</title></rect>
<rect x="28.0" y="296.0" width="74.0" height="28.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.cli_site.main" data-kind="function" class="blk"><title>main — function, 57 lines</title></rect>
<rect x="110.0" y="288.0" width="74.0" height="25.5" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.emitters.cli_erd — 51 lines</title></rect>
<rect x="110.0" y="294.0" width="74.0" height="19.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.cli_erd.main" data-kind="function" class="blk"><title>main — function, 38 lines</title></rect>
<rect x="192.0" y="288.0" width="74.0" height="25.5" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.emitters.cli_minimap — 51 lines</title></rect>
<rect x="192.0" y="294.5" width="74.0" height="18.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.cli_minimap.main" data-kind="function" class="blk"><title>main — function, 37 lines</title></rect>
<rect x="274.0" y="288.0" width="74.0" height="24.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.emitters.cli_explore — 48 lines</title></rect>
<rect x="274.0" y="294.0" width="74.0" height="17.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.cli_explore.main" data-kind="function" class="blk"><title>main — function, 35 lines</title></rect>
<rect x="356.0" y="288.0" width="74.0" height="22.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.emitters.cli_index — 44 lines</title></rect>
<rect x="356.0" y="293.5" width="74.0" height="16.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.cli_index.main" data-kind="function" class="blk"><title>main — function, 32 lines</title></rect>
<rect x="438.0" y="288.0" width="74.0" height="18.5" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.emitters.__main__ — 37 lines</title></rect>
<rect x="438.0" y="290.5" width="74.0" height="13.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.__main__.main" data-kind="function" class="blk"><title>main — function, 27 lines</title></rect>
<rect x="538.0" y="288.0" width="74.0" height="130.5" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.extractors.code — 261 lines</title></rect>
<rect x="538.0" y="335.5" width="74.0" height="2.0" rx="2" fill="#1d4ed8" stroke="none" opacity="0.95" data-id="docgen.extractors.code.MissingParser" data-kind="class" class="blk"><title>MissingParser — class, 2 lines</title></rect>
<rect x="538.0" y="337.5" width="74.0" height="10.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.code._parser" data-kind="function" class="blk"><title>_parser — function, 21 lines</title></rect>
<rect x="538.0" y="349.0" width="74.0" height="5.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.code._name" data-kind="function" class="blk"><title>_name — function, 10 lines</title></rect>
<rect x="538.0" y="355.0" width="74.0" height="17.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.code._walk" data-kind="function" class="blk"><title>_walk — function, 34 lines</title></rect>
<rect x="538.0" y="373.0" width="74.0" height="13.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.code.extract_file" data-kind="function" class="blk"><title>extract_file — function, 27 lines</title></rect>
<rect x="538.0" y="387.5" width="74.0" height="2.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.code._count_errors" data-kind="function" class="blk"><title>_count_errors — function, 5 lines</title></rect>
<rect x="538.0" y="391.0" width="74.0" height="27.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.code.extract" data-kind="function" class="blk"><title>extract — function, 54 lines</title></rect>
<rect x="620.0" y="288.0" width="74.0" height="124.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.extractors.usage — 248 lines</title></rect>
<rect x="620.0" y="320.0" width="74.0" height="13.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.usage._template" data-kind="function" class="blk"><title>_template — function, 27 lines</title></rect>
<rect x="620.0" y="334.5" width="74.0" height="5.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.usage._body" data-kind="function" class="blk"><title>_body — function, 10 lines</title></rect>
<rect x="620.0" y="340.5" width="74.0" height="7.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.usage._shape" data-kind="function" class="blk"><title>_shape — function, 15 lines</title></rect>
<rect x="620.0" y="349.0" width="74.0" height="5.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.usage._graphql" data-kind="function" class="blk"><title>_graphql — function, 11 lines</title></rect>
<rect x="620.0" y="355.5" width="74.0" height="56.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.usage.extract" data-kind="function" class="blk"><title>extract — function, 112 lines</title></rect>
<rect x="702.0" y="288.0" width="74.0" height="72.5" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.extractors.db — 145 lines</title></rect>
<rect x="702.0" y="307.5" width="74.0" height="33.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.db.from_schema_dict" data-kind="function" class="blk"><title>from_schema_dict — function, 67 lines</title></rect>
<rect x="702.0" y="342.0" width="74.0" height="5.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.db._relation" data-kind="function" class="blk"><title>_relation — function, 10 lines</title></rect>
<rect x="702.0" y="348.0" width="74.0" height="3.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.db._plain_type" data-kind="function" class="blk"><title>_plain_type — function, 6 lines</title></rect>
<rect x="702.0" y="352.0" width="74.0" height="4.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.db._dedupe" data-kind="function" class="blk"><title>_dedupe — function, 9 lines</title></rect>
<rect x="702.0" y="357.5" width="74.0" height="2.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.db.extract" data-kind="function" class="blk"><title>extract — function, 5 lines</title></rect>
<rect x="784.0" y="288.0" width="74.0" height="62.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.extractors.openapi — 124 lines</title></rect>
<rect x="784.0" y="304.0" width="74.0" height="10.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.openapi._modelgen" data-kind="function" class="blk"><title>_modelgen — function, 21 lines</title></rect>
<rect x="784.0" y="315.5" width="74.0" height="3.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.openapi._type_name" data-kind="function" class="blk"><title>_type_name — function, 6 lines</title></rect>
<rect x="784.0" y="319.5" width="74.0" height="30.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.openapi.extract" data-kind="function" class="blk"><title>extract — function, 60 lines</title></rect>
<rect x="28.0" y="456.5" width="74.0" height="20.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.extractors.code_main — 40 lines</title></rect>
<rect x="28.0" y="460.5" width="74.0" height="15.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.code_main.main" data-kind="function" class="blk"><title>main — function, 31 lines</title></rect>
<rect x="110.0" y="456.5" width="74.0" height="16.5" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.extractors.usage_main — 33 lines</title></rect>
<rect x="110.0" y="460.5" width="74.0" height="12.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.usage_main.main" data-kind="function" class="blk"><title>main — function, 24 lines</title></rect>
<rect x="192.0" y="456.5" width="74.0" height="16.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.extractors.db_main — 32 lines</title></rect>
<rect x="192.0" y="461.5" width="74.0" height="10.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.db_main.main" data-kind="function" class="blk"><title>main — function, 21 lines</title></rect>
<rect x="274.0" y="456.5" width="74.0" height="15.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.extractors.openapi_main — 30 lines</title></rect>
<rect x="274.0" y="460.5" width="74.0" height="10.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.openapi_main.main" data-kind="function" class="blk"><title>main — function, 21 lines</title></rect>
<rect x="356.0" y="456.5" width="74.0" height="15.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.extractors.python — 30 lines</title></rect>
<rect x="356.0" y="466.0" width="74.0" height="3.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.python.extract" data-kind="function" class="blk"><title>extract — function, 7 lines</title></rect>
<rect x="438.0" y="456.5" width="74.0" height="14.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.extractors.__main__ — 26 lines</title></rect>
<rect x="438.0" y="459.2" width="74.0" height="8.6" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.__main__.main" data-kind="function" class="blk"><title>main — function, 16 lines</title></rect>
<rect x="538.0" y="456.5" width="74.0" height="666.5" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.selftest — 1333 lines</title></rect>
<rect x="538.0" y="500.0" width="74.0" height="2.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.selftest.check" data-kind="function" class="blk"><title>check — function, 5 lines</title></rect>
<rect x="538.0" y="503.5" width="74.0" height="3.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.selftest._err" data-kind="function" class="blk"><title>_err — function, 7 lines</title></rect>
<rect x="538.0" y="508.0" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.selftest.skip" data-kind="function" class="blk"><title>skip — function, 3 lines</title></rect>
<rect x="538.0" y="510.5" width="74.0" height="2.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.selftest.build_tree" data-kind="function" class="blk"><title>build_tree — function, 5 lines</title></rect>
<rect x="538.0" y="869.0" width="74.0" height="3.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.selftest._entry" data-kind="function" class="blk"><title>_entry — function, 7 lines</title></rect>
<rect x="620.0" y="456.5" width="74.0" height="90.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.style — 180 lines</title></rect>
<rect x="620.0" y="477.5" width="74.0" height="2.0" rx="2" fill="#1d4ed8" stroke="none" opacity="0.95" data-id="docgen.style.StyleError" data-kind="class" class="blk"><title>StyleError — class, 2 lines</title></rect>
<rect x="620.0" y="479.5" width="74.0" height="66.5" rx="2" fill="#1d4ed8" stroke="none" opacity="0.95" data-id="docgen.style.Style" data-kind="class" class="blk"><title>Style — class, 133 lines</title></rect>
<rect x="627.0" y="481.0" width="60.0" height="8.5" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.style.Style.__init__" data-kind="function" class="blk"><title>__init__ — function, 17 lines</title></rect>
<rect x="627.0" y="491.5" width="60.0" height="6.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.style.Style.load" data-kind="function" class="blk"><title>load — function, 12 lines</title></rect>
<rect x="627.0" y="498.5" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.style.Style.available" data-kind="function" class="blk"><title>available — function, 2 lines</title></rect>
<rect x="627.0" y="500.0" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.style.Style.themes" data-kind="function" class="blk"><title>themes — function, 2 lines</title></rect>
<rect x="627.0" y="502.5" width="60.0" height="16.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.style.Style.validate" data-kind="function" class="blk"><title>validate — function, 32 lines</title></rect>
<rect x="627.0" y="520.0" width="60.0" height="6.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.style.Style._resolve" data-kind="function" class="blk"><title>_resolve — function, 12 lines</title></rect>
<rect x="627.0" y="526.5" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.style.Style._lookup" data-kind="function" class="blk"><title>_lookup — function, 3 lines</title></rect>
<rect x="627.0" y="528.5" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.style.Style.node" data-kind="function" class="blk"><title>node — function, 2 lines</title></rect>
<rect x="627.0" y="530.0" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.style.Style.group" data-kind="function" class="blk"><title>group — function, 2 lines</title></rect>
<rect x="627.0" y="531.5" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.style.Style.edge" data-kind="function" class="blk"><title>edge — function, 2 lines</title></rect>
<rect x="627.0" y="533.0" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.style.Style.graph" data-kind="function" class="blk"><title>graph — function, 2 lines</title></rect>
<rect x="627.0" y="534.5" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.style.Style.geom" data-kind="function" class="blk"><title>geom — function, 2 lines</title></rect>
<rect x="627.0" y="536.0" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.style.Style.slot" data-kind="function" class="blk"><title>slot — function, 2 lines</title></rect>
<rect x="627.0" y="537.5" width="60.0" height="6.5" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.style.Style.domain_slot" data-kind="function" class="blk"><title>domain_slot — function, 13 lines</title></rect>
<rect x="627.0" y="544.5" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.style.Style.limits" data-kind="function" class="blk"><title>limits — function, 3 lines</title></rect>
<rect x="702.0" y="456.5" width="74.0" height="14.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.ops — 28 lines</title></rect>
<rect x="784.0" y="456.5" width="74.0" height="14.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.notebook — 15 lines</title></rect>
<rect x="28.0" y="1161.0" width="74.0" height="14.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.emitters — 12 lines</title></rect>
<rect x="110.0" y="1161.0" width="74.0" height="14.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.lab — 11 lines</title></rect>
<rect x="192.0" y="1161.0" width="74.0" height="14.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.ir — 7 lines</title></rect>
<rect x="274.0" y="1161.0" width="74.0" height="14.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.extractors — 2 lines</title></rect>
<rect x="374.0" y="1161.0" width="74.0" height="118.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.extractors.python.collect — 236 lines</title></rect>
<rect x="374.0" y="1177.5" width="74.0" height="5.0" rx="2" fill="#1d4ed8" stroke="none" opacity="0.95" data-id="docgen.extractors.python.collect.Definition" data-kind="class" class="blk"><title>Definition — class, 10 lines</title></rect>
<rect x="374.0" y="1184.0" width="74.0" height="6.0" rx="2" fill="#1d4ed8" stroke="none" opacity="0.95" data-id="docgen.extractors.python.collect.Module" data-kind="class" class="blk"><title>Module — class, 12 lines</title></rect>
<rect x="374.0" y="1191.0" width="74.0" height="33.5" rx="2" fill="#1d4ed8" stroke="none" opacity="0.95" data-id="docgen.extractors.python.collect._Collector" data-kind="class" class="blk"><title>_Collector — class, 67 lines</title></rect>
<rect x="381.0" y="1192.5" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.extractors.python.collect._Collector.__init__" data-kind="function" class="blk"><title>__init__ — function, 3 lines</title></rect>
<rect x="381.0" y="1195.5" width="60.0" height="8.5" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.extractors.python.collect._Collector._define" data-kind="function" class="blk"><title>_define — function, 17 lines</title></rect>
<rect x="381.0" y="1204.5" width="60.0" height="2.5" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.extractors.python.collect._Collector.visit_ClassDef" data-kind="function" class="blk"><title>visit_ClassDef — function, 5 lines</title></rect>
<rect x="381.0" y="1207.5" width="60.0" height="2.5" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.extractors.python.collect._Collector.visit_FunctionDef" data-kind="function" class="blk"><title>visit_FunctionDef — function, 5 lines</title></rect>
<rect x="381.0" y="1213.0" width="60.0" height="4.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.extractors.python.collect._Collector.visit_Import" data-kind="function" class="blk"><title>visit_Import — function, 8 lines</title></rect>
<rect x="381.0" y="1217.5" width="60.0" height="7.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.extractors.python.collect._Collector.visit_ImportFrom" data-kind="function" class="blk"><title>visit_ImportFrom — function, 14 lines</title></rect>
<rect x="374.0" y="1225.5" width="74.0" height="2.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.python.collect._first_line" data-kind="function" class="blk"><title>_first_line — function, 5 lines</title></rect>
<rect x="374.0" y="1229.0" width="74.0" height="7.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.python.collect._name_of" data-kind="function" class="blk"><title>_name_of — function, 15 lines</title></rect>
<rect x="374.0" y="1237.5" width="74.0" height="8.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.python.collect._resolve_relative" data-kind="function" class="blk"><title>_resolve_relative — function, 16 lines</title></rect>
<rect x="374.0" y="1246.5" width="74.0" height="13.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.python.collect.module_name" data-kind="function" class="blk"><title>module_name — function, 26 lines</title></rect>
<rect x="374.0" y="1260.5" width="74.0" height="10.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.python.collect.collect_file" data-kind="function" class="blk"><title>collect_file — function, 21 lines</title></rect>
<rect x="374.0" y="1272.0" width="74.0" height="6.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.python.collect.collect" data-kind="function" class="blk"><title>collect — function, 13 lines</title></rect>
<rect x="456.0" y="1161.0" width="74.0" height="81.5" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.extractors.python.resolve — 163 lines</title></rect>
<rect x="456.0" y="1175.0" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.python.resolve._id_for" data-kind="function" class="blk"><title>_id_for — function, 2 lines</title></rect>
<rect x="456.0" y="1177.0" width="74.0" height="16.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.python.resolve._resolve" data-kind="function" class="blk"><title>_resolve — function, 32 lines</title></rect>
<rect x="456.0" y="1194.0" width="74.0" height="48.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.python.resolve.to_ir" data-kind="function" class="blk"><title>to_ir — function, 96 lines</title></rect>
<rect x="463.0" y="1222.5" width="60.0" height="4.5" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.extractors.python.resolve.to_ir._point_at" data-kind="function" class="blk"><title>_point_at — function, 9 lines</title></rect>
<rect x="538.0" y="1161.0" width="74.0" height="19.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.extractors.python.__main__ — 38 lines</title></rect>
<rect x="538.0" y="1166.0" width="74.0" height="11.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.python.__main__.main" data-kind="function" class="blk"><title>main — function, 23 lines</title></rect>
<rect x="638.0" y="1161.0" width="74.0" height="118.5" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.ir.validate — 237 lines</title></rect>
<rect x="638.0" y="1182.0" width="74.0" height="2.0" rx="2" fill="#1d4ed8" stroke="none" opacity="0.95" data-id="docgen.ir.validate.IRError" data-kind="class" class="blk"><title>IRError — class, 2 lines</title></rect>
<rect x="638.0" y="1184.0" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ir.validate._schema" data-kind="function" class="blk"><title>_schema — function, 2 lines</title></rect>
<rect x="638.0" y="1186.0" width="74.0" height="2.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ir.validate._props" data-kind="function" class="blk"><title>_props — function, 5 lines</title></rect>
<rect x="638.0" y="1189.5" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ir.validate._fields" data-kind="function" class="blk"><title>_fields — function, 4 lines</title></rect>
<rect x="638.0" y="1192.5" width="74.0" height="53.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ir.validate.check" data-kind="function" class="blk"><title>check — function, 106 lines</title></rect>
<rect x="638.0" y="1246.5" width="74.0" height="3.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ir.validate.validate" data-kind="function" class="blk"><title>validate — function, 6 lines</title></rect>
<rect x="638.0" y="1250.5" width="74.0" height="11.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ir.validate.check_model_matches_schema" data-kind="function" class="blk"><title>check_model_matches_schema — function, 23 lines</title></rect>
<rect x="638.0" y="1263.0" width="74.0" height="16.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ir.validate.main" data-kind="function" class="blk"><title>main — function, 32 lines</title></rect>
<rect x="720.0" y="1161.0" width="74.0" height="73.5" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.ir.model — 147 lines</title></rect>
<rect x="720.0" y="1177.0" width="74.0" height="10.5" rx="2" fill="#1d4ed8" stroke="none" opacity="0.95" data-id="docgen.ir.model.Meta" data-kind="class" class="blk"><title>Meta — class, 21 lines</title></rect>
<rect x="727.0" y="1184.0" width="60.0" height="3.5" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.ir.model.Meta.to_dict" data-kind="function" class="blk"><title>to_dict — function, 7 lines</title></rect>
<rect x="720.0" y="1189.0" width="74.0" height="10.5" rx="2" fill="#1d4ed8" stroke="none" opacity="0.95" data-id="docgen.ir.model.Node" data-kind="class" class="blk"><title>Node — class, 21 lines</title></rect>
<rect x="727.0" y="1193.5" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.ir.model.Node.__post_init__" data-kind="function" class="blk"><title>__post_init__ — function, 3 lines</title></rect>
<rect x="727.0" y="1195.5" width="60.0" height="4.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.ir.model.Node.to_dict" data-kind="function" class="blk"><title>to_dict — function, 8 lines</title></rect>
<rect x="720.0" y="1201.0" width="74.0" height="7.5" rx="2" fill="#1d4ed8" stroke="none" opacity="0.95" data-id="docgen.ir.model.Edge" data-kind="class" class="blk"><title>Edge — class, 15 lines</title></rect>
<rect x="727.0" y="1205.0" width="60.0" height="3.5" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.ir.model.Edge.to_dict" data-kind="function" class="blk"><title>to_dict — function, 7 lines</title></rect>
<rect x="720.0" y="1210.0" width="74.0" height="24.0" rx="2" fill="#1d4ed8" stroke="none" opacity="0.95" data-id="docgen.ir.model.Graph" data-kind="class" class="blk"><title>Graph — class, 48 lines</title></rect>
<rect x="727.0" y="1214.5" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.ir.model.Graph.node" data-kind="function" class="blk"><title>node — function, 4 lines</title></rect>
<rect x="727.0" y="1217.0" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.ir.model.Graph.edge" data-kind="function" class="blk"><title>edge — function, 4 lines</title></rect>
<rect x="727.0" y="1219.5" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.ir.model.Graph.has" data-kind="function" class="blk"><title>has — function, 2 lines</title></rect>
<rect x="727.0" y="1222.0" width="60.0" height="8.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.ir.model.Graph.to_dict" data-kind="function" class="blk"><title>to_dict — function, 16 lines</title></rect>
<rect x="727.0" y="1231.0" width="60.0" height="3.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.ir.model.Graph.from_dict" data-kind="function" class="blk"><title>from_dict — function, 6 lines</title></rect>
<rect x="28.0" y="1317.5" width="74.0" height="14.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.ir.__main__ — 9 lines</title></rect>
<rect x="128.0" y="1317.5" width="74.0" height="242.5" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.ops.filter — 485 lines</title></rect>
<rect x="128.0" y="1335.5" width="74.0" height="28.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ops.filter._rebuild" data-kind="function" class="blk"><title>_rebuild — function, 57 lines</title></rect>
<rect x="135.0" y="1340.0" width="60.0" height="2.5" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.ops.filter._rebuild.surviving_parent" data-kind="function" class="blk"><title>surviving_parent — function, 5 lines</title></rect>
<rect x="135.0" y="1347.0" width="60.0" height="3.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.ops.filter._rebuild.lift" data-kind="function" class="blk"><title>lift — function, 6 lines</title></rect>
<rect x="128.0" y="1365.0" width="74.0" height="7.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ops.filter.drop_kinds" data-kind="function" class="blk"><title>drop_kinds — function, 14 lines</title></rect>
<rect x="128.0" y="1373.0" width="74.0" height="7.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ops.filter.only_kinds" data-kind="function" class="blk"><title>only_kinds — function, 14 lines</title></rect>
<rect x="128.0" y="1381.0" width="74.0" height="6.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ops.filter.drop_stdlib" data-kind="function" class="blk"><title>drop_stdlib — function, 13 lines</title></rect>
<rect x="128.0" y="1388.5" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ops.filter.drop_external" data-kind="function" class="blk"><title>drop_external — function, 3 lines</title></rect>
<rect x="128.0" y="1391.0" width="74.0" height="6.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ops.filter.subtree" data-kind="function" class="blk"><title>subtree — function, 13 lines</title></rect>
<rect x="128.0" y="1398.5" width="74.0" height="22.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ops.filter.neighbourhood" data-kind="function" class="blk"><title>neighbourhood — function, 45 lines</title></rect>
<rect x="128.0" y="1422.0" width="74.0" height="9.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ops.filter.collapse_to_depth" data-kind="function" class="blk"><title>collapse_to_depth — function, 18 lines</title></rect>
<rect x="135.0" y="1427.0" width="60.0" height="3.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.ops.filter.collapse_to_depth.level" data-kind="function" class="blk"><title>level — function, 6 lines</title></rect>
<rect x="128.0" y="1432.0" width="74.0" height="7.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ops.filter.drop_builtins" data-kind="function" class="blk"><title>drop_builtins — function, 14 lines</title></rect>
<rect x="128.0" y="1440.0" width="74.0" height="17.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ops.filter.overview" data-kind="function" class="blk"><title>overview — function, 35 lines</title></rect>
<rect x="128.0" y="1458.5" width="74.0" height="36.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ops.filter.shape" data-kind="function" class="blk"><title>shape — function, 72 lines</title></rect>
<rect x="135.0" y="1482.5" width="60.0" height="4.5" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.ops.filter.shape.rank_of" data-kind="function" class="blk"><title>rank_of — function, 9 lines</title></rect>
<rect x="128.0" y="1495.5" width="74.0" height="12.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ops.filter.split" data-kind="function" class="blk"><title>split — function, 24 lines</title></rect>
<rect x="128.0" y="1508.5" width="74.0" height="51.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ops.filter.classify" data-kind="function" class="blk"><title>classify — function, 102 lines</title></rect>
<rect x="210.0" y="1317.5" width="74.0" height="51.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.ops.__main__ — 102 lines</title></rect>
<rect x="210.0" y="1324.0" width="74.0" height="42.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ops.__main__.main" data-kind="function" class="blk"><title>main — function, 84 lines</title></rect>
<rect x="310.0" y="1317.5" width="74.0" height="14.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen — 2 lines</title></rect>
<rect x="410.0" y="1317.5" width="74.0" height="75.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.lab.pg_probe — 150 lines</title></rect>
<rect x="410.0" y="1353.0" width="74.0" height="15.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.lab.pg_probe.probe" data-kind="function" class="blk"><title>probe — function, 30 lines</title></rect>
<rect x="410.0" y="1375.0" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.lab.pg_probe._simplify" data-kind="function" class="blk"><title>_simplify — function, 3 lines</title></rect>
<rect x="410.0" y="1377.5" width="74.0" height="12.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.lab.pg_probe.main" data-kind="function" class="blk"><title>main — function, 25 lines</title></rect>
<rect x="510.0" y="1317.5" width="74.0" height="132.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.notebook.spec — 264 lines</title></rect>
<rect x="510.0" y="1345.5" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.notebook.spec._step" data-kind="function" class="blk"><title>_step — function, 4 lines</title></rect>
<rect x="510.0" y="1348.5" width="74.0" height="54.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.notebook.spec.from_ir" data-kind="function" class="blk"><title>from_ir — function, 108 lines</title></rect>
<rect x="517.0" y="1355.5" width="60.0" height="2.5" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.notebook.spec.from_ir._order" data-kind="function" class="blk"><title>_order — function, 5 lines</title></rect>
<rect x="510.0" y="1403.5" width="74.0" height="10.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.notebook.spec.scaffold" data-kind="function" class="blk"><title>scaffold — function, 20 lines</title></rect>
<rect x="510.0" y="1414.5" width="74.0" height="29.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.notebook.spec.merge" data-kind="function" class="blk"><title>merge — function, 58 lines</title></rect>
<rect x="510.0" y="1444.5" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.notebook.spec.load" data-kind="function" class="blk"><title>load — function, 2 lines</title></rect>
<rect x="510.0" y="1446.5" width="74.0" height="2.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.notebook.spec.dump" data-kind="function" class="blk"><title>dump — function, 5 lines</title></rect>
<rect x="28.0" y="331.0" width="74.0" height="37.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.emitters.cli_site — 74 lines</title></rect>
<rect x="28.0" y="339.0" width="74.0" height="28.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.cli_site.main" data-kind="function" class="blk"><title>main — function, 57 lines</title></rect>
<rect x="110.0" y="331.0" width="74.0" height="25.5" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.emitters.cli_erd — 51 lines</title></rect>
<rect x="110.0" y="337.0" width="74.0" height="19.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.cli_erd.main" data-kind="function" class="blk"><title>main — function, 38 lines</title></rect>
<rect x="192.0" y="331.0" width="74.0" height="25.5" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.emitters.cli_minimap — 51 lines</title></rect>
<rect x="192.0" y="337.5" width="74.0" height="18.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.cli_minimap.main" data-kind="function" class="blk"><title>main — function, 37 lines</title></rect>
<rect x="274.0" y="331.0" width="74.0" height="24.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.emitters.cli_explore — 48 lines</title></rect>
<rect x="274.0" y="337.0" width="74.0" height="17.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.cli_explore.main" data-kind="function" class="blk"><title>main — function, 35 lines</title></rect>
<rect x="356.0" y="331.0" width="74.0" height="22.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.emitters.cli_index — 44 lines</title></rect>
<rect x="356.0" y="336.5" width="74.0" height="16.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.cli_index.main" data-kind="function" class="blk"><title>main — function, 32 lines</title></rect>
<rect x="438.0" y="331.0" width="74.0" height="18.5" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.emitters.__main__ — 37 lines</title></rect>
<rect x="438.0" y="333.5" width="74.0" height="13.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.__main__.main" data-kind="function" class="blk"><title>main — function, 27 lines</title></rect>
<rect x="538.0" y="331.0" width="74.0" height="1007.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.selftest — 2014 lines</title></rect>
<rect x="538.0" y="375.5" width="74.0" height="2.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.selftest.check" data-kind="function" class="blk"><title>check — function, 5 lines</title></rect>
<rect x="538.0" y="379.0" width="74.0" height="3.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.selftest._err" data-kind="function" class="blk"><title>_err — function, 7 lines</title></rect>
<rect x="538.0" y="383.5" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.selftest.skip" data-kind="function" class="blk"><title>skip — function, 3 lines</title></rect>
<rect x="538.0" y="386.0" width="74.0" height="2.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.selftest.build_tree" data-kind="function" class="blk"><title>build_tree — function, 5 lines</title></rect>
<rect x="538.0" y="515.5" width="74.0" height="97.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.selftest._harvesting" data-kind="function" class="blk"><title>_harvesting — function, 194 lines</title></rect>
<rect x="538.0" y="858.0" width="74.0" height="3.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.selftest._entry" data-kind="function" class="blk"><title>_entry — function, 7 lines</title></rect>
<rect x="538.0" y="1111.0" width="74.0" height="139.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.selftest._books" data-kind="function" class="blk"><title>_books — function, 279 lines</title></rect>
<rect x="538.0" y="1255.5" width="74.0" height="76.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.selftest._standalone" data-kind="function" class="blk"><title>_standalone — function, 153 lines</title></rect>
<rect x="620.0" y="331.0" width="74.0" height="158.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.book — 316 lines</title></rect>
<rect x="620.0" y="360.0" width="74.0" height="10.0" rx="2" fill="#1d4ed8" stroke="none" opacity="0.95" data-id="docgen.book.Step" data-kind="class" class="blk"><title>Step — class, 20 lines</title></rect>
<rect x="627.0" y="365.0" width="60.0" height="5.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.book.Step.to_dict" data-kind="function" class="blk"><title>to_dict — function, 10 lines</title></rect>
<rect x="620.0" y="381.5" width="74.0" height="15.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.book._unit_counts" data-kind="function" class="blk"><title>_unit_counts — function, 30 lines</title></rect>
<rect x="620.0" y="397.5" width="74.0" height="89.0" rx="2" fill="#1d4ed8" stroke="none" opacity="0.95" data-id="docgen.book.Book" data-kind="class" class="blk"><title>Book — class, 178 lines</title></rect>
<rect x="627.0" y="399.0" width="60.0" height="8.5" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.book.Book.__init__" data-kind="function" class="blk"><title>__init__ — function, 17 lines</title></rect>
<rect x="627.0" y="409.0" width="60.0" height="7.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.book.Book.step" data-kind="function" class="blk"><title>step — function, 14 lines</title></rect>
<rect x="627.0" y="417.5" width="60.0" height="10.5" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.book.Book.measure" data-kind="function" class="blk"><title>measure — function, 21 lines</title></rect>
<rect x="627.0" y="428.5" width="60.0" height="31.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.book.Book.compare" data-kind="function" class="blk"><title>compare — function, 62 lines</title></rect>
<rect x="627.0" y="461.0" width="60.0" height="13.5" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.book.Book.close" data-kind="function" class="blk"><title>close — function, 27 lines</title></rect>
<rect x="627.0" y="475.0" width="60.0" height="8.5" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.book.Book.to_dict" data-kind="function" class="blk"><title>to_dict — function, 17 lines</title></rect>
<rect x="627.0" y="484.0" width="60.0" height="2.5" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.book.Book.write" data-kind="function" class="blk"><title>write — function, 5 lines</title></rect>
<rect x="620.0" y="487.5" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.book._tree_bytes" data-kind="function" class="blk"><title>_tree_bytes — function, 2 lines</title></rect>
<rect x="702.0" y="331.0" width="74.0" height="97.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.style — 194 lines</title></rect>
<rect x="702.0" y="353.5" width="74.0" height="2.0" rx="2" fill="#1d4ed8" stroke="none" opacity="0.95" data-id="docgen.style.StyleError" data-kind="class" class="blk"><title>StyleError — class, 2 lines</title></rect>
<rect x="702.0" y="355.5" width="74.0" height="66.5" rx="2" fill="#1d4ed8" stroke="none" opacity="0.95" data-id="docgen.style.Style" data-kind="class" class="blk"><title>Style — class, 133 lines</title></rect>
<rect x="709.0" y="357.0" width="60.0" height="8.5" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.style.Style.__init__" data-kind="function" class="blk"><title>__init__ — function, 17 lines</title></rect>
<rect x="709.0" y="367.5" width="60.0" height="6.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.style.Style.load" data-kind="function" class="blk"><title>load — function, 12 lines</title></rect>
<rect x="709.0" y="374.5" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.style.Style.available" data-kind="function" class="blk"><title>available — function, 2 lines</title></rect>
<rect x="709.0" y="376.0" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.style.Style.themes" data-kind="function" class="blk"><title>themes — function, 2 lines</title></rect>
<rect x="709.0" y="378.5" width="60.0" height="16.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.style.Style.validate" data-kind="function" class="blk"><title>validate — function, 32 lines</title></rect>
<rect x="709.0" y="396.0" width="60.0" height="6.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.style.Style._resolve" data-kind="function" class="blk"><title>_resolve — function, 12 lines</title></rect>
<rect x="709.0" y="402.5" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.style.Style._lookup" data-kind="function" class="blk"><title>_lookup — function, 3 lines</title></rect>
<rect x="709.0" y="404.5" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.style.Style.node" data-kind="function" class="blk"><title>node — function, 2 lines</title></rect>
<rect x="709.0" y="406.0" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.style.Style.group" data-kind="function" class="blk"><title>group — function, 2 lines</title></rect>
<rect x="709.0" y="407.5" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.style.Style.edge" data-kind="function" class="blk"><title>edge — function, 2 lines</title></rect>
<rect x="709.0" y="409.0" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.style.Style.graph" data-kind="function" class="blk"><title>graph — function, 2 lines</title></rect>
<rect x="709.0" y="410.5" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.style.Style.geom" data-kind="function" class="blk"><title>geom — function, 2 lines</title></rect>
<rect x="709.0" y="412.0" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.style.Style.slot" data-kind="function" class="blk"><title>slot — function, 2 lines</title></rect>
<rect x="709.0" y="413.5" width="60.0" height="6.5" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.style.Style.domain_slot" data-kind="function" class="blk"><title>domain_slot — function, 13 lines</title></rect>
<rect x="709.0" y="420.5" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.style.Style.limits" data-kind="function" class="blk"><title>limits — function, 3 lines</title></rect>
<rect x="702.0" y="423.0" width="74.0" height="4.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.style.harvest" data-kind="function" class="blk"><title>harvest — function, 9 lines</title></rect>
<rect x="784.0" y="331.0" width="74.0" height="54.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.reference — 108 lines</title></rect>
<rect x="784.0" y="354.0" width="74.0" height="3.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.reference._candidates" data-kind="function" class="blk"><title>_candidates — function, 7 lines</title></rect>
<rect x="784.0" y="358.5" width="74.0" height="4.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.reference.root" data-kind="function" class="blk"><title>root — function, 9 lines</title></rect>
<rect x="784.0" y="364.0" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.reference.station_tools" data-kind="function" class="blk"><title>station_tools — function, 4 lines</title></rect>
<rect x="784.0" y="367.0" width="74.0" height="5.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.reference.describe" data-kind="function" class="blk"><title>describe — function, 10 lines</title></rect>
<rect x="784.0" y="373.0" width="74.0" height="6.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.reference.on_path" data-kind="function" class="blk"><title>on_path — function, 13 lines</title></rect>
<rect x="784.0" y="380.5" width="74.0" height="4.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.reference.missing" data-kind="function" class="blk"><title>missing — function, 8 lines</title></rect>
<rect x="28.0" y="1376.0" width="74.0" height="14.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.ops — 28 lines</title></rect>
<rect x="110.0" y="1376.0" width="74.0" height="14.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.notebook — 15 lines</title></rect>
<rect x="192.0" y="1376.0" width="74.0" height="14.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.emitters — 12 lines</title></rect>
<rect x="274.0" y="1376.0" width="74.0" height="14.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.lab — 11 lines</title></rect>
<rect x="356.0" y="1376.0" width="74.0" height="14.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.ir — 7 lines</title></rect>
<rect x="438.0" y="1376.0" width="74.0" height="14.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.extractors — 2 lines</title></rect>
<rect x="538.0" y="1376.0" width="74.0" height="141.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.extractors.code — 282 lines</title></rect>
<rect x="538.0" y="1423.5" width="74.0" height="2.0" rx="2" fill="#1d4ed8" stroke="none" opacity="0.95" data-id="docgen.extractors.code.MissingParser" data-kind="class" class="blk"><title>MissingParser — class, 2 lines</title></rect>
<rect x="538.0" y="1425.5" width="74.0" height="10.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.code._parser" data-kind="function" class="blk"><title>_parser — function, 21 lines</title></rect>
<rect x="538.0" y="1437.0" width="74.0" height="5.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.code._name" data-kind="function" class="blk"><title>_name — function, 10 lines</title></rect>
<rect x="538.0" y="1443.0" width="74.0" height="17.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.code._walk" data-kind="function" class="blk"><title>_walk — function, 34 lines</title></rect>
<rect x="538.0" y="1461.0" width="74.0" height="13.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.code.extract_file" data-kind="function" class="blk"><title>extract_file — function, 27 lines</title></rect>
<rect x="538.0" y="1475.5" width="74.0" height="2.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.code._count_errors" data-kind="function" class="blk"><title>_count_errors — function, 5 lines</title></rect>
<rect x="538.0" y="1479.0" width="74.0" height="37.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.code.extract" data-kind="function" class="blk"><title>extract — function, 75 lines</title></rect>
<rect x="620.0" y="1376.0" width="74.0" height="134.5" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.extractors.usage — 269 lines</title></rect>
<rect x="620.0" y="1408.0" width="74.0" height="13.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.usage._template" data-kind="function" class="blk"><title>_template — function, 27 lines</title></rect>
<rect x="620.0" y="1422.5" width="74.0" height="5.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.usage._body" data-kind="function" class="blk"><title>_body — function, 10 lines</title></rect>
<rect x="620.0" y="1428.5" width="74.0" height="7.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.usage._shape" data-kind="function" class="blk"><title>_shape — function, 15 lines</title></rect>
<rect x="620.0" y="1437.0" width="74.0" height="5.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.usage._graphql" data-kind="function" class="blk"><title>_graphql — function, 11 lines</title></rect>
<rect x="620.0" y="1443.5" width="74.0" height="66.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.usage.extract" data-kind="function" class="blk"><title>extract — function, 133 lines</title></rect>
<rect x="702.0" y="1376.0" width="74.0" height="95.5" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.extractors.openapi — 191 lines</title></rect>
<rect x="702.0" y="1391.5" width="74.0" height="14.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.openapi._modelgen" data-kind="function" class="blk"><title>_modelgen — function, 28 lines</title></rect>
<rect x="702.0" y="1406.5" width="74.0" height="3.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.openapi._type_name" data-kind="function" class="blk"><title>_type_name — function, 6 lines</title></rect>
<rect x="702.0" y="1410.5" width="74.0" height="21.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.openapi._refs" data-kind="function" class="blk"><title>_refs — function, 43 lines</title></rect>
<rect x="702.0" y="1433.0" width="74.0" height="38.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.openapi.extract" data-kind="function" class="blk"><title>extract — function, 76 lines</title></rect>
<rect x="784.0" y="1376.0" width="74.0" height="80.5" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.extractors.db — 161 lines</title></rect>
<rect x="784.0" y="1395.5" width="74.0" height="41.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.db.from_schema_dict" data-kind="function" class="blk"><title>from_schema_dict — function, 82 lines</title></rect>
<rect x="784.0" y="1437.5" width="74.0" height="5.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.db._relation" data-kind="function" class="blk"><title>_relation — function, 10 lines</title></rect>
<rect x="784.0" y="1443.5" width="74.0" height="3.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.db._plain_type" data-kind="function" class="blk"><title>_plain_type — function, 6 lines</title></rect>
<rect x="784.0" y="1447.5" width="74.0" height="4.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.db._dedupe" data-kind="function" class="blk"><title>_dedupe — function, 9 lines</title></rect>
<rect x="784.0" y="1453.0" width="74.0" height="3.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.db.extract" data-kind="function" class="blk"><title>extract — function, 6 lines</title></rect>
<rect x="28.0" y="1555.0" width="74.0" height="20.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.extractors.code_main — 40 lines</title></rect>
<rect x="28.0" y="1559.0" width="74.0" height="15.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.code_main.main" data-kind="function" class="blk"><title>main — function, 31 lines</title></rect>
<rect x="110.0" y="1555.0" width="74.0" height="18.5" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.extractors.python — 37 lines</title></rect>
<rect x="110.0" y="1564.5" width="74.0" height="7.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.python.extract" data-kind="function" class="blk"><title>extract — function, 14 lines</title></rect>
<rect x="192.0" y="1555.0" width="74.0" height="16.5" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.extractors.usage_main — 33 lines</title></rect>
<rect x="192.0" y="1559.0" width="74.0" height="12.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.usage_main.main" data-kind="function" class="blk"><title>main — function, 24 lines</title></rect>
<rect x="274.0" y="1555.0" width="74.0" height="16.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.extractors.db_main — 32 lines</title></rect>
<rect x="274.0" y="1560.0" width="74.0" height="10.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.db_main.main" data-kind="function" class="blk"><title>main — function, 21 lines</title></rect>
<rect x="356.0" y="1555.0" width="74.0" height="15.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.extractors.openapi_main — 30 lines</title></rect>
<rect x="356.0" y="1559.0" width="74.0" height="10.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.openapi_main.main" data-kind="function" class="blk"><title>main — function, 21 lines</title></rect>
<rect x="438.0" y="1555.0" width="74.0" height="14.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.extractors.__main__ — 26 lines</title></rect>
<rect x="438.0" y="1557.7" width="74.0" height="8.6" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.__main__.main" data-kind="function" class="blk"><title>main — function, 16 lines</title></rect>
<rect x="538.0" y="1555.0" width="74.0" height="175.5" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.book.build — 351 lines</title></rect>
<rect x="538.0" y="1592.5" width="74.0" height="13.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.book.build.extract" data-kind="function" class="blk"><title>extract — function, 27 lines</title></rect>
<rect x="538.0" y="1607.0" width="74.0" height="34.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.book.build.spec_from" data-kind="function" class="blk"><title>spec_from — function, 68 lines</title></rect>
<rect x="538.0" y="1642.0" width="74.0" height="22.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.book.build._load_cell" data-kind="function" class="blk"><title>_load_cell — function, 44 lines</title></rect>
<rect x="538.0" y="1665.0" width="74.0" height="65.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.book.build.run" data-kind="function" class="blk"><title>run — function, 130 lines</title></rect>
<rect x="545.0" y="1671.0" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.book.build.run.say" data-kind="function" class="blk"><title>say — function, 3 lines</title></rect>
<rect x="620.0" y="1555.0" width="74.0" height="153.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.book.checks — 306 lines</title></rect>
<rect x="620.0" y="1585.5" width="74.0" height="13.0" rx="2" fill="#1d4ed8" stroke="none" opacity="0.95" data-id="docgen.book.checks.Loaded" data-kind="class" class="blk"><title>Loaded — class, 26 lines</title></rect>
<rect x="627.0" y="1587.0" width="60.0" height="7.5" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.book.checks.Loaded.__init__" data-kind="function" class="blk"><title>__init__ — function, 15 lines</title></rect>
<rect x="627.0" y="1595.5" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.book.checks.Loaded.larder" data-kind="function" class="blk"><title>larder — function, 2 lines</title></rect>
<rect x="627.0" y="1597.5" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.book.checks.Loaded.measure" data-kind="function" class="blk"><title>measure — function, 2 lines</title></rect>
<rect x="620.0" y="1599.5" width="74.0" height="18.5" rx="2" fill="#1d4ed8" stroke="none" opacity="0.95" data-id="docgen.book.checks.Report" data-kind="class" class="blk"><title>Report — class, 37 lines</title></rect>
<rect x="627.0" y="1601.0" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.book.checks.Report.__init__" data-kind="function" class="blk"><title>__init__ — function, 2 lines</title></rect>
<rect x="627.0" y="1602.5" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.book.checks.Report.note" data-kind="function" class="blk"><title>note — function, 2 lines</title></rect>
<rect x="627.0" y="1604.0" width="60.0" height="4.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.book.checks.Report.check" data-kind="function" class="blk"><title>check — function, 8 lines</title></rect>
<rect x="627.0" y="1608.5" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.book.checks.Report.skip" data-kind="function" class="blk"><title>skip — function, 3 lines</title></rect>
<rect x="627.0" y="1610.5" width="60.0" height="7.5" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.book.checks.Report.total" data-kind="function" class="blk"><title>total — function, 15 lines</title></rect>
<rect x="620.0" y="1619.0" width="74.0" height="41.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.book.checks.generated" data-kind="function" class="blk"><title>generated — function, 83 lines</title></rect>
<rect x="620.0" y="1661.5" width="74.0" height="13.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.book.checks.custom" data-kind="function" class="blk"><title>custom — function, 26 lines</title></rect>
<rect x="620.0" y="1675.5" width="74.0" height="16.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.book.checks._run_cells" data-kind="function" class="blk"><title>_run_cells — function, 33 lines</title></rect>
<rect x="620.0" y="1693.0" width="74.0" height="12.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.book.checks.main" data-kind="function" class="blk"><title>main — function, 25 lines</title></rect>
<rect x="702.0" y="1555.0" width="74.0" height="98.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.book.larder — 196 lines</title></rect>
<rect x="702.0" y="1586.5" width="74.0" height="6.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.book.larder._count" data-kind="function" class="blk"><title>_count — function, 13 lines</title></rect>
<rect x="702.0" y="1594.0" width="74.0" height="16.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.book.larder.redact" data-kind="function" class="blk"><title>redact — function, 32 lines</title></rect>
<rect x="702.0" y="1611.5" width="74.0" height="38.5" rx="2" fill="#1d4ed8" stroke="none" opacity="0.95" data-id="docgen.book.larder.Larder" data-kind="class" class="blk"><title>Larder — class, 77 lines</title></rect>
<rect x="709.0" y="1618.5" width="60.0" height="3.5" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.book.larder.Larder.__post_init__" data-kind="function" class="blk"><title>__post_init__ — function, 7 lines</title></rect>
<rect x="709.0" y="1623.0" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.book.larder.Larder.read" data-kind="function" class="blk"><title>read — function, 3 lines</title></rect>
<rect x="709.0" y="1625.0" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.book.larder.Larder.fail" data-kind="function" class="blk"><title>fail — function, 3 lines</title></rect>
<rect x="709.0" y="1627.0" width="60.0" height="6.5" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.book.larder.Larder.to_dict" data-kind="function" class="blk"><title>to_dict — function, 13 lines</title></rect>
<rect x="709.0" y="1634.5" width="60.0" height="5.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.book.larder.Larder.from_dict" data-kind="function" class="blk"><title>from_dict — function, 10 lines</title></rect>
<rect x="709.0" y="1640.0" width="60.0" height="10.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.book.larder.Larder.line" data-kind="function" class="blk"><title>line — function, 20 lines</title></rect>
<rect x="702.0" y="1651.0" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.book.larder.of" data-kind="function" class="blk"><title>of — function, 3 lines</title></rect>
<rect x="784.0" y="1555.0" width="74.0" height="40.5" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.book.__main__ — 81 lines</title></rect>
<rect x="784.0" y="1562.0" width="74.0" height="31.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.book.__main__.main" data-kind="function" class="blk"><title>main — function, 62 lines</title></rect>
<rect x="28.0" y="1768.5" width="74.0" height="118.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.extractors.python.collect — 236 lines</title></rect>
<rect x="28.0" y="1785.0" width="74.0" height="5.0" rx="2" fill="#1d4ed8" stroke="none" opacity="0.95" data-id="docgen.extractors.python.collect.Definition" data-kind="class" class="blk"><title>Definition — class, 10 lines</title></rect>
<rect x="28.0" y="1791.5" width="74.0" height="6.0" rx="2" fill="#1d4ed8" stroke="none" opacity="0.95" data-id="docgen.extractors.python.collect.Module" data-kind="class" class="blk"><title>Module — class, 12 lines</title></rect>
<rect x="28.0" y="1798.5" width="74.0" height="33.5" rx="2" fill="#1d4ed8" stroke="none" opacity="0.95" data-id="docgen.extractors.python.collect._Collector" data-kind="class" class="blk"><title>_Collector — class, 67 lines</title></rect>
<rect x="35.0" y="1800.0" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.extractors.python.collect._Collector.__init__" data-kind="function" class="blk"><title>__init__ — function, 3 lines</title></rect>
<rect x="35.0" y="1803.0" width="60.0" height="8.5" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.extractors.python.collect._Collector._define" data-kind="function" class="blk"><title>_define — function, 17 lines</title></rect>
<rect x="35.0" y="1812.0" width="60.0" height="2.5" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.extractors.python.collect._Collector.visit_ClassDef" data-kind="function" class="blk"><title>visit_ClassDef — function, 5 lines</title></rect>
<rect x="35.0" y="1815.0" width="60.0" height="2.5" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.extractors.python.collect._Collector.visit_FunctionDef" data-kind="function" class="blk"><title>visit_FunctionDef — function, 5 lines</title></rect>
<rect x="35.0" y="1820.5" width="60.0" height="4.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.extractors.python.collect._Collector.visit_Import" data-kind="function" class="blk"><title>visit_Import — function, 8 lines</title></rect>
<rect x="35.0" y="1825.0" width="60.0" height="7.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.extractors.python.collect._Collector.visit_ImportFrom" data-kind="function" class="blk"><title>visit_ImportFrom — function, 14 lines</title></rect>
<rect x="28.0" y="1833.0" width="74.0" height="2.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.python.collect._first_line" data-kind="function" class="blk"><title>_first_line — function, 5 lines</title></rect>
<rect x="28.0" y="1836.5" width="74.0" height="7.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.python.collect._name_of" data-kind="function" class="blk"><title>_name_of — function, 15 lines</title></rect>
<rect x="28.0" y="1845.0" width="74.0" height="8.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.python.collect._resolve_relative" data-kind="function" class="blk"><title>_resolve_relative — function, 16 lines</title></rect>
<rect x="28.0" y="1854.0" width="74.0" height="13.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.python.collect.module_name" data-kind="function" class="blk"><title>module_name — function, 26 lines</title></rect>
<rect x="28.0" y="1868.0" width="74.0" height="10.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.python.collect.collect_file" data-kind="function" class="blk"><title>collect_file — function, 21 lines</title></rect>
<rect x="28.0" y="1879.5" width="74.0" height="6.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.python.collect.collect" data-kind="function" class="blk"><title>collect — function, 13 lines</title></rect>
<rect x="110.0" y="1768.5" width="74.0" height="92.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.extractors.python.resolve — 184 lines</title></rect>
<rect x="110.0" y="1782.5" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.python.resolve._id_for" data-kind="function" class="blk"><title>_id_for — function, 2 lines</title></rect>
<rect x="110.0" y="1784.5" width="74.0" height="16.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.python.resolve._resolve" data-kind="function" class="blk"><title>_resolve — function, 32 lines</title></rect>
<rect x="110.0" y="1801.5" width="74.0" height="8.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.python.resolve.larder_of" data-kind="function" class="blk"><title>larder_of — function, 17 lines</title></rect>
<rect x="110.0" y="1811.0" width="74.0" height="49.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.python.resolve.to_ir" data-kind="function" class="blk"><title>to_ir — function, 98 lines</title></rect>
<rect x="117.0" y="1840.5" width="60.0" height="4.5" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.extractors.python.resolve.to_ir._point_at" data-kind="function" class="blk"><title>_point_at — function, 9 lines</title></rect>
<rect x="192.0" y="1768.5" width="74.0" height="19.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.extractors.python.__main__ — 38 lines</title></rect>
<rect x="192.0" y="1773.5" width="74.0" height="11.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.python.__main__.main" data-kind="function" class="blk"><title>main — function, 23 lines</title></rect>
<rect x="292.0" y="1768.5" width="74.0" height="156.5" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.ir.validate — 313 lines</title></rect>
<rect x="292.0" y="1790.0" width="74.0" height="2.0" rx="2" fill="#1d4ed8" stroke="none" opacity="0.95" data-id="docgen.ir.validate.IRError" data-kind="class" class="blk"><title>IRError — class, 2 lines</title></rect>
<rect x="292.0" y="1792.0" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ir.validate._schema" data-kind="function" class="blk"><title>_schema — function, 2 lines</title></rect>
<rect x="292.0" y="1794.0" width="74.0" height="2.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ir.validate._props" data-kind="function" class="blk"><title>_props — function, 5 lines</title></rect>
<rect x="292.0" y="1797.5" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ir.validate._fields" data-kind="function" class="blk"><title>_fields — function, 4 lines</title></rect>
<rect x="292.0" y="1800.5" width="74.0" height="54.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ir.validate.check" data-kind="function" class="blk"><title>check — function, 108 lines</title></rect>
<rect x="292.0" y="1862.0" width="74.0" height="29.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ir.validate._check_larder" data-kind="function" class="blk"><title>_check_larder — function, 58 lines</title></rect>
<rect x="292.0" y="1892.0" width="74.0" height="3.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ir.validate.validate" data-kind="function" class="blk"><title>validate — function, 6 lines</title></rect>
<rect x="292.0" y="1896.0" width="74.0" height="11.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ir.validate.check_model_matches_schema" data-kind="function" class="blk"><title>check_model_matches_schema — function, 23 lines</title></rect>
<rect x="292.0" y="1908.5" width="74.0" height="16.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ir.validate.main" data-kind="function" class="blk"><title>main — function, 32 lines</title></rect>
<rect x="374.0" y="1768.5" width="74.0" height="82.5" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.ir.model — 165 lines</title></rect>
<rect x="374.0" y="1785.0" width="74.0" height="19.0" rx="2" fill="#1d4ed8" stroke="none" opacity="0.95" data-id="docgen.ir.model.Meta" data-kind="class" class="blk"><title>Meta — class, 38 lines</title></rect>
<rect x="381.0" y="1795.0" width="60.0" height="9.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.ir.model.Meta.to_dict" data-kind="function" class="blk"><title>to_dict — function, 18 lines</title></rect>
<rect x="374.0" y="1805.5" width="74.0" height="10.5" rx="2" fill="#1d4ed8" stroke="none" opacity="0.95" data-id="docgen.ir.model.Node" data-kind="class" class="blk"><title>Node — class, 21 lines</title></rect>
<rect x="381.0" y="1810.0" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.ir.model.Node.__post_init__" data-kind="function" class="blk"><title>__post_init__ — function, 3 lines</title></rect>
<rect x="381.0" y="1812.0" width="60.0" height="4.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.ir.model.Node.to_dict" data-kind="function" class="blk"><title>to_dict — function, 8 lines</title></rect>
<rect x="374.0" y="1817.5" width="74.0" height="7.5" rx="2" fill="#1d4ed8" stroke="none" opacity="0.95" data-id="docgen.ir.model.Edge" data-kind="class" class="blk"><title>Edge — class, 15 lines</title></rect>
<rect x="381.0" y="1821.5" width="60.0" height="3.5" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.ir.model.Edge.to_dict" data-kind="function" class="blk"><title>to_dict — function, 7 lines</title></rect>
<rect x="374.0" y="1826.5" width="74.0" height="24.0" rx="2" fill="#1d4ed8" stroke="none" opacity="0.95" data-id="docgen.ir.model.Graph" data-kind="class" class="blk"><title>Graph — class, 48 lines</title></rect>
<rect x="381.0" y="1831.0" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.ir.model.Graph.node" data-kind="function" class="blk"><title>node — function, 4 lines</title></rect>
<rect x="381.0" y="1833.5" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.ir.model.Graph.edge" data-kind="function" class="blk"><title>edge — function, 4 lines</title></rect>
<rect x="381.0" y="1836.0" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.ir.model.Graph.has" data-kind="function" class="blk"><title>has — function, 2 lines</title></rect>
<rect x="381.0" y="1838.5" width="60.0" height="8.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.ir.model.Graph.to_dict" data-kind="function" class="blk"><title>to_dict — function, 16 lines</title></rect>
<rect x="381.0" y="1847.5" width="60.0" height="3.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.ir.model.Graph.from_dict" data-kind="function" class="blk"><title>from_dict — function, 6 lines</title></rect>
<rect x="456.0" y="1768.5" width="74.0" height="14.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.ir.__main__ — 9 lines</title></rect>
<rect x="556.0" y="1768.5" width="74.0" height="242.5" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.ops.filter — 485 lines</title></rect>
<rect x="556.0" y="1786.5" width="74.0" height="28.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ops.filter._rebuild" data-kind="function" class="blk"><title>_rebuild — function, 57 lines</title></rect>
<rect x="563.0" y="1791.0" width="60.0" height="2.5" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.ops.filter._rebuild.surviving_parent" data-kind="function" class="blk"><title>surviving_parent — function, 5 lines</title></rect>
<rect x="563.0" y="1798.0" width="60.0" height="3.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.ops.filter._rebuild.lift" data-kind="function" class="blk"><title>lift — function, 6 lines</title></rect>
<rect x="556.0" y="1816.0" width="74.0" height="7.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ops.filter.drop_kinds" data-kind="function" class="blk"><title>drop_kinds — function, 14 lines</title></rect>
<rect x="556.0" y="1824.0" width="74.0" height="7.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ops.filter.only_kinds" data-kind="function" class="blk"><title>only_kinds — function, 14 lines</title></rect>
<rect x="556.0" y="1832.0" width="74.0" height="6.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ops.filter.drop_stdlib" data-kind="function" class="blk"><title>drop_stdlib — function, 13 lines</title></rect>
<rect x="556.0" y="1839.5" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ops.filter.drop_external" data-kind="function" class="blk"><title>drop_external — function, 3 lines</title></rect>
<rect x="556.0" y="1842.0" width="74.0" height="6.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ops.filter.subtree" data-kind="function" class="blk"><title>subtree — function, 13 lines</title></rect>
<rect x="556.0" y="1849.5" width="74.0" height="22.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ops.filter.neighbourhood" data-kind="function" class="blk"><title>neighbourhood — function, 45 lines</title></rect>
<rect x="556.0" y="1873.0" width="74.0" height="9.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ops.filter.collapse_to_depth" data-kind="function" class="blk"><title>collapse_to_depth — function, 18 lines</title></rect>
<rect x="563.0" y="1878.0" width="60.0" height="3.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.ops.filter.collapse_to_depth.level" data-kind="function" class="blk"><title>level — function, 6 lines</title></rect>
<rect x="556.0" y="1883.0" width="74.0" height="7.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ops.filter.drop_builtins" data-kind="function" class="blk"><title>drop_builtins — function, 14 lines</title></rect>
<rect x="556.0" y="1891.0" width="74.0" height="17.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ops.filter.overview" data-kind="function" class="blk"><title>overview — function, 35 lines</title></rect>
<rect x="556.0" y="1909.5" width="74.0" height="36.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ops.filter.shape" data-kind="function" class="blk"><title>shape — function, 72 lines</title></rect>
<rect x="563.0" y="1933.5" width="60.0" height="4.5" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.ops.filter.shape.rank_of" data-kind="function" class="blk"><title>rank_of — function, 9 lines</title></rect>
<rect x="556.0" y="1946.5" width="74.0" height="12.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ops.filter.split" data-kind="function" class="blk"><title>split — function, 24 lines</title></rect>
<rect x="556.0" y="1959.5" width="74.0" height="51.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ops.filter.classify" data-kind="function" class="blk"><title>classify — function, 102 lines</title></rect>
<rect x="638.0" y="1768.5" width="74.0" height="51.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.ops.__main__ — 102 lines</title></rect>
<rect x="638.0" y="1775.0" width="74.0" height="42.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ops.__main__.main" data-kind="function" class="blk"><title>main — function, 84 lines</title></rect>
<rect x="738.0" y="1768.5" width="74.0" height="111.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.style.extract — 222 lines</title></rect>
<rect x="738.0" y="1799.0" width="74.0" height="8.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.style.extract._values_from" data-kind="function" class="blk"><title>_values_from — function, 17 lines</title></rect>
<rect x="738.0" y="1808.5" width="74.0" height="22.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.style.extract._normalise" data-kind="function" class="blk"><title>_normalise — function, 45 lines</title></rect>
<rect x="738.0" y="1832.0" width="74.0" height="12.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.style.extract._svg_files" data-kind="function" class="blk"><title>_svg_files — function, 25 lines</title></rect>
<rect x="738.0" y="1845.5" width="74.0" height="21.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.style.extract.harvest" data-kind="function" class="blk"><title>harvest — function, 43 lines</title></rect>
<rect x="738.0" y="1868.0" width="74.0" height="3.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.style.extract.write" data-kind="function" class="blk"><title>write — function, 7 lines</title></rect>
<rect x="738.0" y="1872.5" width="74.0" height="6.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.style.extract.summarise" data-kind="function" class="blk"><title>summarise — function, 13 lines</title></rect>
<rect x="28.0" y="2049.0" width="74.0" height="105.5" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.style.tokens — 211 lines</title></rect>
<rect x="28.0" y="2077.0" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.style.tokens._top" data-kind="function" class="blk"><title>_top — function, 2 lines</title></rect>
<rect x="28.0" y="2079.0" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.style.tokens._mode" data-kind="function" class="blk"><title>_mode — function, 3 lines</title></rect>
<rect x="28.0" y="2081.5" width="74.0" height="3.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.style.tokens._luminance" data-kind="function" class="blk"><title>_luminance — function, 6 lines</title></rect>
<rect x="28.0" y="2085.5" width="74.0" height="50.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.style.tokens.derive" data-kind="function" class="blk"><title>derive — function, 101 lines</title></rect>
<rect x="35.0" y="2107.5" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.style.tokens.derive.accent" data-kind="function" class="blk"><title>accent — function, 2 lines</title></rect>
<rect x="28.0" y="2137.0" width="74.0" height="7.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.style.tokens.geometry" data-kind="function" class="blk"><title>geometry — function, 14 lines</title></rect>
<rect x="28.0" y="2145.0" width="74.0" height="3.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.style.tokens.write" data-kind="function" class="blk"><title>write — function, 7 lines</title></rect>
<rect x="28.0" y="2149.5" width="74.0" height="4.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.style.tokens.from_folder" data-kind="function" class="blk"><title>from_folder — function, 9 lines</title></rect>
<rect x="128.0" y="2049.0" width="74.0" height="14.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen — 2 lines</title></rect>
<rect x="228.0" y="2049.0" width="74.0" height="75.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.lab.pg_probe — 150 lines</title></rect>
<rect x="228.0" y="2084.5" width="74.0" height="15.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.lab.pg_probe.probe" data-kind="function" class="blk"><title>probe — function, 30 lines</title></rect>
<rect x="228.0" y="2106.5" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.lab.pg_probe._simplify" data-kind="function" class="blk"><title>_simplify — function, 3 lines</title></rect>
<rect x="228.0" y="2109.0" width="74.0" height="12.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.lab.pg_probe.main" data-kind="function" class="blk"><title>main — function, 25 lines</title></rect>
<rect x="328.0" y="2049.0" width="74.0" height="132.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.notebook.spec — 264 lines</title></rect>
<rect x="328.0" y="2077.0" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.notebook.spec._step" data-kind="function" class="blk"><title>_step — function, 4 lines</title></rect>
<rect x="328.0" y="2080.0" width="74.0" height="54.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.notebook.spec.from_ir" data-kind="function" class="blk"><title>from_ir — function, 108 lines</title></rect>
<rect x="335.0" y="2087.0" width="60.0" height="2.5" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.notebook.spec.from_ir._order" data-kind="function" class="blk"><title>_order — function, 5 lines</title></rect>
<rect x="328.0" y="2135.0" width="74.0" height="10.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.notebook.spec.scaffold" data-kind="function" class="blk"><title>scaffold — function, 20 lines</title></rect>
<rect x="328.0" y="2146.0" width="74.0" height="29.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.notebook.spec.merge" data-kind="function" class="blk"><title>merge — function, 58 lines</title></rect>
<rect x="328.0" y="2176.0" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.notebook.spec.load" data-kind="function" class="blk"><title>load — function, 2 lines</title></rect>
<rect x="328.0" y="2178.0" width="74.0" height="2.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.notebook.spec.dump" data-kind="function" class="blk"><title>dump — function, 5 lines</title></rect>
<text x="28" y="40" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen.emitters</text>
<text x="28" y="282" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen.emitters</text>
<text x="538" y="282" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen.extractors</text>
<text x="28" y="450" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen.extractors</text>
<text x="538" y="450" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen</text>
<text x="28" y="1155" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen</text>
<text x="374" y="1155" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen.extractors.python</text>
<text x="638" y="1155" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen.ir</text>
<text x="28" y="1312" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen.ir</text>
<text x="128" y="1312" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen.ops</text>
<text x="310" y="1312" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">(root)</text>
<text x="410" y="1312" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen.lab</text>
<text x="510" y="1312" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen.notebook</text>
<text x="28" y="259" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">site</text>
<text x="28" y="325" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen.emitters</text>
<text x="538" y="325" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen</text>
<text x="28" y="1370" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen</text>
<text x="538" y="1370" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen.extractors</text>
<text x="28" y="1549" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen.extractors</text>
<text x="538" y="1549" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen.book</text>
<text x="28" y="1762" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen.extractors.python</text>
<text x="292" y="1762" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen.ir</text>
<text x="556" y="1762" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen.ops</text>
<text x="738" y="1762" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen.style</text>
<text x="28" y="2043" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen.style</text>
<text x="128" y="2043" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">(root)</text>
<text x="228" y="2043" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen.lab</text>
<text x="328" y="2043" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen.notebook</text>
<text x="28" y="302" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">site</text>
<text x="110" y="213" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">explore</text>
<text x="192" y="201" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">dot</text>
<text x="274" y="194" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">notebook</text>
@@ -251,48 +331,56 @@
<text x="602" y="98" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">cli_dot</text>
<text x="684" y="95" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">auto</text>
<text x="766" y="92" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">cli_noteboo</text>
<text x="28" y="334" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">cli_site</text>
<text x="110" y="322" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">cli_erd</text>
<text x="192" y="322" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">cli_minimap</text>
<text x="274" y="321" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">cli_explore</text>
<text x="356" y="319" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">cli_index</text>
<text x="438" y="316" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">__main__</text>
<text x="538" y="428" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">code</text>
<text x="620" y="421" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">usage</text>
<text x="702" y="370" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">db</text>
<text x="784" y="359" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">openapi</text>
<text x="28" y="486" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">code_main</text>
<text x="110" y="482" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">usage_main</text>
<text x="192" y="482" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">db_main</text>
<text x="274" y="480" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">openapi_mai</text>
<text x="356" y="480" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">python</text>
<text x="438" y="480" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">__main__</text>
<text x="538" y="1132" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">selftest</text>
<text x="620" y="556" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">style</text>
<text x="702" y="480" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">ops</text>
<text x="784" y="480" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">notebook</text>
<text x="28" y="1184" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">emitters</text>
<text x="110" y="1184" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">lab</text>
<text x="192" y="1184" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">ir</text>
<text x="274" y="1184" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">extractors</text>
<text x="374" y="1288" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">collect</text>
<text x="456" y="1252" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">resolve</text>
<text x="538" y="1189" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">__main__</text>
<text x="638" y="1288" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">validate</text>
<text x="720" y="1244" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">model</text>
<text x="28" y="1340" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">__main__</text>
<text x="128" y="1569" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">filter</text>
<text x="210" y="1378" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">__main__</text>
<text x="310" y="1340" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">docgen</text>
<text x="410" y="1402" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">pg_probe</text>
<text x="510" y="1458" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">spec</text>
<rect x="28" y="1609.0" width="9" height="9" rx="2" fill="#1a1a1a"/>
<text x="41" y="1617.0" font-family="Helvetica,sans-Serif" font-size="9" fill="#666666">module</text>
<rect x="86" y="1609.0" width="9" height="9" rx="2" fill="#1d4ed8"/>
<text x="99" y="1617.0" font-family="Helvetica,sans-Serif" font-size="9" fill="#666666">class</text>
<rect x="138" y="1609.0" width="9" height="9" rx="2" fill="#d4a574"/>
<text x="151" y="1617.0" font-family="Helvetica,sans-Serif" font-size="9" fill="#666666">interface</text>
<rect x="214" y="1609.0" width="9" height="9" rx="2" fill="#15803d"/>
<text x="227" y="1617.0" font-family="Helvetica,sans-Serif" font-size="9" fill="#666666">function</text>
<text x="858" y="1617.0" text-anchor="end" font-family="Helvetica,sans-Serif" font-size="9" fill="#666666">45 files · 6,933 lines · 1px ≈ 2.0 lines</text>
<text x="28" y="377" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">cli_site</text>
<text x="110" y="366" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">cli_erd</text>
<text x="192" y="366" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">cli_minimap</text>
<text x="274" y="364" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">cli_explore</text>
<text x="356" y="362" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">cli_index</text>
<text x="438" y="358" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">__main__</text>
<text x="538" y="1347" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">selftest</text>
<text x="620" y="498" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">book</text>
<text x="702" y="437" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">style</text>
<text x="784" y="394" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">reference</text>
<text x="28" y="1399" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">ops</text>
<text x="110" y="1399" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">notebook</text>
<text x="192" y="1399" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">emitters</text>
<text x="274" y="1399" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">lab</text>
<text x="356" y="1399" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">ir</text>
<text x="438" y="1399" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">extractors</text>
<text x="538" y="1526" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">code</text>
<text x="620" y="1520" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">usage</text>
<text x="702" y="1480" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">openapi</text>
<text x="784" y="1466" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">db</text>
<text x="28" y="1584" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">code_main</text>
<text x="110" y="1582" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">python</text>
<text x="192" y="1580" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">usage_main</text>
<text x="274" y="1580" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">db_main</text>
<text x="356" y="1579" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">openapi_mai</text>
<text x="438" y="1578" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">__main__</text>
<text x="538" y="1740" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">build</text>
<text x="620" y="1717" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">checks</text>
<text x="702" y="1662" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">larder</text>
<text x="784" y="1604" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">__main__</text>
<text x="28" y="1896" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">collect</text>
<text x="110" y="1870" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">resolve</text>
<text x="192" y="1796" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">__main__</text>
<text x="292" y="1934" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">validate</text>
<text x="374" y="1860" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">model</text>
<text x="456" y="1792" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">__main__</text>
<text x="556" y="2020" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">filter</text>
<text x="638" y="1828" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">__main__</text>
<text x="738" y="1888" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">extract</text>
<text x="28" y="2164" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">tokens</text>
<text x="128" y="2072" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">docgen</text>
<text x="228" y="2133" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">pg_probe</text>
<text x="328" y="2190" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">spec</text>
<rect x="28" y="2230.0" width="9" height="9" rx="2" fill="#1a1a1a"/>
<text x="41" y="2238.0" font-family="Helvetica,sans-Serif" font-size="9" fill="#666666">module</text>
<rect x="86" y="2230.0" width="9" height="9" rx="2" fill="#1d4ed8"/>
<text x="99" y="2238.0" font-family="Helvetica,sans-Serif" font-size="9" fill="#666666">class</text>
<rect x="138" y="2230.0" width="9" height="9" rx="2" fill="#d4a574"/>
<text x="151" y="2238.0" font-family="Helvetica,sans-Serif" font-size="9" fill="#666666">interface</text>
<rect x="214" y="2230.0" width="9" height="9" rx="2" fill="#15803d"/>
<text x="227" y="2238.0" font-family="Helvetica,sans-Serif" font-size="9" fill="#666666">function</text>
<text x="858" y="2238.0" text-anchor="end" font-family="Helvetica,sans-Serif" font-size="9" fill="#666666">53 files · 9,752 lines · 1px ≈ 2.0 lines</text>
</svg>

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 73 KiB

View File

@@ -22,6 +22,7 @@
<div class="group">Architecture</div>
<a href="#layers">The three concerns</a>
<a href="#book">The book</a>
<a href="#ir">The IR</a>
<a href="#shape">Shape decides the drawing</a>
@@ -39,6 +40,7 @@
<a href="#style">Style &amp; colour</a>
<div class="group">Reference</div>
<a href="#standalone">Standalone</a>
<a href="#commands">Commands</a>
<a href="#deps">Dependencies</a>
<a href="#testing">Testing</a>
@@ -107,7 +109,23 @@
<!-- ─────────────────────────────────────────────────────────────── -->
<h2 id="quick">Five minutes</h2>
<p>Three commands, and they compose. That is the whole interface.</p>
<p>
One command, if you want the whole thing — a <a href="#book">book</a>: what
went in, every step, what came out, and a page to open.
</p>
<pre><code>make book SRC=/path/to/repo BOOK=out/book/mine
make check BOOK=out/book/mine</code></pre>
<pre><code> larder /path/to/repo — 45 files read, 2 failed, 12 packages
book 312 nodes · 244 edges · 26 external · 8 artifacts
ok 45 file(s) read produced 45 module(s)
open out/book/mine/site/index.html</code></pre>
<p>
Underneath it is three commands that compose, and each still works on its own.
That is the interface, and the book does not replace it:
</p>
<pre><code><span class="c"># 1. read something</span>
python3 -m docgen.extractors.python --root ../station/tools/histgen -o ir.json
@@ -123,7 +141,7 @@ python3 -m docgen.emitters auto view.json -o out/</code></pre>
<pre><code>make ir SRC=/path/to/repo OUT=out <span class="c"># extract</span>
make explore OUT=out <span class="c"># the two-pane navigator</span>
make site OUT=out <span class="c"># a docs site with a sidebar</span>
make self <span class="c"># run the whole thing over soleprint</span></code></pre>
make self <span class="c"># docgen's book of soleprint, then check it</span></code></pre>
<p>
Everything is offline and self-contained. No server, no CDN, no build step —
@@ -166,6 +184,151 @@ make self <span class="c"># run the whole thing over s
</figcaption>
</figure>
<!-- ─────────────────────────────────────────────────────────────── -->
<h2 id="book">The book</h2>
<p>
A <b>book</b> is one docgen operation, and it has a fixed shape: it begins by
saying what went in and ends by saying what came out. Everything in between is
an ordinary file that stands on its own.
</p>
<pre><code>larder ──► step ──► step ──► step ──► book
what each one usable what
came in by itself came out</code></pre>
<p>
The word comes from Atlas 1.0, where a book is a <em>larder</em> composed with a
pattern into something published and served. docgen had already built all three
parts under other names, so this is less an adoption than a renaming back.
</p>
<h3>Why both ends, rather than just the result</h3>
<p>
Because the two numbers are only worth having together. <em>"1,505 nodes"</em>
is not a fact about anything. <em>"225 files in, 1,505 nodes out, nothing
lost"</em> is. Before this, an extractor that read 45 of 47 files produced
exactly the same document as one that read all 47, and the diagram looked
complete either way — there was nowhere for the other two to be mentioned.
</p>
<div class="note warn">
<b>A clean diagram over an incomplete read is a lie by omission.</b> It is also
the failure mode the IR already guards against one level down: an unresolved
name becomes an <code>external</code> node rather than being dropped, because
silently losing a thing is worse than recording an unresolved one. The larder
measure is that same rule applied to the input as a whole.
</div>
<h3>The larder — what came in</h3>
<p>
Deliberately not called a bucket. A bucket is somewhere bytes sit; a larder is
<em>stocked from outside</em>, has an inventory, and goes stale. All three are
worth measuring, and they are what the measure records:
</p>
<pre><code>"larder": {
"kind": "python", <span class="c">// which extractor stocked it</span>
"identity": "../../station", <span class="c">// path, or a DSN with the password masked</span>
"unit": "file", <span class="c">// file | table | path | entry | document</span>
"seen": 47, <span class="c">// what the larder offered</span>
"read": 45, <span class="c">// seen - len(failed), derived</span>
"failed": [{"name": "a.py", "error": "syntax: line 3"}],
"extra": {"packages": 12}
}</code></pre>
<p>
<code>read</code> is derived and never stored. Stored, it invites the question
<em>"does that include the failures?"</em> and every reader answers it
differently; derived, there is nothing to get wrong — and
<code>ir/validate.py</code> fails a document whose arithmetic disagrees with
itself.
</p>
<p>
Failures are recorded <b>by name</b>, not counted. A count tells you a book is
incomplete; a name tells you which part of it to distrust.
</p>
<div class="note">
<b><code>identity</code> is the one field in docgen that could carry a secret</b>
— a database DSN has the password in it. It is masked at construction, and
<code>validate.py</code> then sweeps for the mask having worked, using its own
independent key list. A scrubber graded by its own word is not graded.
</div>
<h3>The book measure — what came out, and reconciled</h3>
<p>
Counts by kind, edges by kind, externals, and every artifact with its byte
count. On its own that is just a summary. What makes it a measure is that it is
<em>reconciled</em> against the larder:
</p>
<pre><code> larder ../../station — 45 files read, 2 failed, 12 packages
book 312 nodes · 244 edges · 26 external · 8 artifacts
ok 45 file(s) read produced 45 module(s)
ok 2 file(s) could not be read
ok 2 unreadable file(s) appear as 2 marked module(s)</code></pre>
<p>
The relation differs by source and is declared per extractor, because getting
it wrong gives a check that passes for the wrong reason. One file becomes one
module node — fewer means input was dropped. Four hundred HAR entries becoming
twelve endpoints is not a loss, it is the <em>point</em> of the capture. And a
file that failed to parse must still appear in the graph, carrying its error,
or the picture is smaller than the source and says nothing about it.
</p>
<p>
<code>python3 -m docgen.book</code> exits 1 when a reconciliation fails. The
book is still written — the evidence is the point — but a build that lost input
should fail a pipeline rather than pass quietly.
</p>
<h3>The notebook is the sequence, the web is the last step</h3>
<p>
These two rule what gets generated, and each for its own reason.
</p>
<p>
The <b>notebook</b> is not one artifact; it is the sequence. Its first cell is
the larder measure and its last cell is the book measure, which is what puts
the two ends in the <em>document</em> rather than only in the tooling. Between
them, one pair of cells per step: what the step did, and a cell that loads that
step's artifact and prints one fact about it. That is the "usable by
themselves" property made executable, and the test suite runs those cells.
</p>
<p>
The <b>web output</b> is last, so nothing depends on it, so it can be replaced
wholesale without touching anything upstream. That is exactly what lets it rule
the output without being a stable contract — the book measure is the promise,
and the page displaying it is free to change drastically and often. It is also
the artifact somebody definitely opens, which is why <em>"2 of 47 files could
not be read"</em> has to appear there, above the diagram rather than below it.
</p>
<div class="note">
<b>The spine is scaffolding, not a gate.</b> Running one step alone is still a
book, just a short one — <code>make ir</code> works exactly as it did. An
operation that cannot measure something says what it could not measure and
carries on. Gating would destroy the property that makes the intermediate
artifacts useful, which is the whole reason the sequence is worth having.
</div>
<h3>What a book looks like on disk</h3>
<pre><code>book/&lt;slug&gt;/
├── book.json <span class="c">both measures, the steps, artifacts with byte counts</span>
├── steps/ <span class="c">every intermediate — ir.json, view.json, graph.svg, …</span>
├── notebook.ipynb <span class="c">the sequence; first and last cells are the measures</span>
├── overlay.json <span class="c">hand-written, optional, re-applied every build</span>
├── checks.py <span class="c">this book's own assertions — optional</span>
└── site/ <span class="c">the web output, both measures at the top</span></code></pre>
<pre><code>make book SRC=../station BOOK=out/book/station
make check BOOK=out/book/station</code></pre>
<!-- ─────────────────────────────────────────────────────────────── -->
<h2 id="ir">The IR</h2>
@@ -627,13 +790,96 @@ python3 -m docgen.emitters notebook ir.json --overlay overlay.json -o walkthroug
between a representative leaf and clip the line at the cluster border.
</p>
<!-- ─────────────────────────────────────────────────────────────── -->
<h2 id="standalone">Standalone</h2>
<p>
Copy the <code>docgen/</code> folder anywhere and it works. The Makefile
derives its own package name from where it sits, so it can be renamed too, and
everything else resolves inside the directory.
</p>
<pre><code>cp -r docgen /somewhere/else
cd /somewhere/else/docgen
make doctor <span class="c"># what this machine has</span>
make check <span class="c"># the suite, from the copy</span>
make book SRC=/path/to/any/repo BOOK=out/book/theirs</code></pre>
<h3>One seam, and it is optional</h3>
<p>
Exactly one capability needs more than the folder: reading an <b>OpenAPI</b>
document goes through <code>station/tools/modelgen</code>, which parses the
spec and resolves <code>$ref</code>. It is deliberately not reimplemented here
— a second OpenAPI reader in one repo is two things to keep correct.
</p>
<p>
So if you are using docgen standalone but keeping the repo alongside as
reference, point at it:
</p>
<pre><code>export DOCGEN_REFERENCE=/path/to/repo
make doctor
<span class="c"># reference: /path/to/repo (from $DOCGEN_REFERENCE)</span></code></pre>
<p>
Resolution is <code>$DOCGEN_REFERENCE</code> first, then walking up from the
package — so in place it needs no configuration, and an explicit path wins
when set. Without it, the four other extractors and every emitter work
unchanged; the OpenAPI reader reports what to set, and the suite
<em>skips</em> rather than fails.
</p>
<div class="note">
<b>An env var rather than a config file</b>, because there is one setting and
it is a path. A config file for one path is a file to find, parse, document and
validate, and the first question anyone asks of it is "where does it live" —
which is the same question again.
</div>
<h3>It is asserted, not asserted-in-prose</h3>
<p>
A standalone claim decays the moment somebody adds a convenient import, and it
decays <em>silently</em>, because the suite still passes inside the repo. So
the suite reads its own source:
</p>
<ul>
<li><b>Imports are the stdlib, docgen itself, and a short allow-list</b>
tree-sitter, lxml, yaml, networkx, modelgen, each optional and each with a
reason. A new name is a new dependency in a folder meant to be copied.</li>
<li><b>Only <code>extractors/openapi.py</code> imports modelgen.</b> One seam
is a seam; two is a dependency.</li>
<li><b>Only <code>reference.py</code> knows the repo layout</b>, so pointing
docgen elsewhere is one change rather than a search.</li>
<li><b>A book writes only inside its own output directory</b> — nothing is
left in the tree being read.</li>
</ul>
<p>
The folder was also literally copied to <code>/tmp</code> and run, which is
how the one real bug here was found: a check asserting the reference repo is
reachable, correct in place and wrong the moment there was nothing above. It
now reports which case applies instead of assuming one.
</p>
<table>
<tr><th>context</th><th>checks</th><th>skipped</th></tr>
<tr><td>in the repo</td><td>250</td><td>tree-sitter (259 with it)</td></tr>
<tr><td>copied out</td><td>242</td><td>tree-sitter, OpenAPI, the in-place case</td></tr>
<tr><td>copied out, <code>DOCGEN_REFERENCE</code> set</td><td>249</td>
<td>tree-sitter, the in-place case</td></tr>
</table>
<!-- ─────────────────────────────────────────────────────────────── -->
<h2 id="commands">Commands</h2>
<h3>Make</h3>
<table>
<tr><th>target</th><th>does</th></tr>
<tr><td><code>make check</code></td><td>the whole test suite, offline, nothing installed</td></tr>
<tr><td><code>make book SRC=…</code></td><td><b>one whole operation</b>, measured at both ends</td></tr>
<tr><td><code>make check</code></td><td>docgen's own suite, offline, nothing installed</td></tr>
<tr><td><code>make check BOOK=…</code></td><td>one book's own level — generated and custom</td></tr>
<tr><td><code>make doctor</code></td><td>what this machine has and what it is missing</td></tr>
<tr><td><code>make ir SRC=…</code></td><td>extract Python into <code>OUT/ir.json</code></td></tr>
<tr><td><code>make code SRC=…</code></td><td>extract C#/TypeScript <span class="pill opt">tree-sitter</span></td></tr>
@@ -644,18 +890,30 @@ python3 -m docgen.emitters notebook ir.json --overlay overlay.json -o walkthroug
<tr><td><code>make minimap</code></td><td>what is where, read from the colours</td></tr>
<tr><td><code>make explore</code></td><td>the two-pane navigator</td></tr>
<tr><td><code>make site</code></td><td>a self-contained docs site</td></tr>
<tr><td><code>make self</code></td><td>the whole pipeline over soleprint itself</td></tr>
<tr><td><code>make self</code></td><td>docgen's book of soleprint, then check it</td></tr>
</table>
<p>
Variables: <code>SRC</code>, <code>OUT</code>, <code>SCHEMA</code>,
<code>STYLE</code>, <code>THEME</code>, <code>SCALE</code>, <code>DEPTH</code>,
<code>PY</code>. The Makefile derives its own package name from where it sits,
so the folder can be copied anywhere and renamed and still work.
<code>OPENAPI</code>, <code>HAR</code>, <code>BOOK</code>, <code>SLUG</code>,
<code>READER</code>, <code>OVERLAY</code>, <code>STYLE</code>,
<code>THEME</code>, <code>SCALE</code>, <code>DEPTH</code>, <code>PY</code>.
The Makefile derives its own package name from where it sits, so the folder can
be copied anywhere and renamed and still work.
</p>
<p>
<code>READER</code> rather than <code>LANG</code> because <code>LANG</code> is
the shell's locale variable, so <code>?=</code> inherits
<code>en_US.UTF-8</code> from the environment and the argument is rejected.
Every target above is one step of a book and still works alone — that is the
property the spine exists to preserve, not to replace.
</p>
<h3>Modules</h3>
<pre><code>python3 -m docgen.extractors.python --root SRC -o ir.json
<pre><code>python3 -m docgen.book --root SRC -o out/book/slug <span class="c"># the whole operation</span>
python3 -m docgen.book.checks out/book/slug <span class="c"># that book's level</span>
python3 -m docgen.extractors.python --root SRC -o ir.json
python3 -m docgen.extractors code --root SRC -o ir.json
python3 -m docgen.extractors db --schema schema.json -o ir.json
python3 -m docgen.extractors openapi --spec spec.yaml -o ir.json
@@ -676,6 +934,11 @@ python3 -m docgen.emitters explore ir.json -o explore/</code></pre>
<!-- ─────────────────────────────────────────────────────────────── -->
<h2 id="deps">Dependencies</h2>
<p>
Everything below is optional. The stdlib covers the whole structural path — see <a href="#standalone">standalone</a>
for the one seam out of the folder.
</p>
<p>
The core is <strong>standard library only</strong>. Everything else is optional
and reported by <code>make doctor</code>; when something is missing you lose
@@ -707,11 +970,68 @@ python3 -m docgen.emitters explore ir.json -o explore/</code></pre>
<!-- ─────────────────────────────────────────────────────────────── -->
<h2 id="testing">Testing</h2>
<pre><code>make check <span class="c"># 191 checks, offline, no network</span></code></pre>
<p>
Three levels, and they differ by <em>what they assert about</em>. The
distinction decides what a failure means, which is why it is worth keeping:
</p>
<table>
<tr><th>command</th><th>asks about</th><th>fails?</th></tr>
<tr><td><code>make doctor</code></td><td>the machine — what is installed</td>
<td>never; it reports</td></tr>
<tr><td><code>make check</code></td><td>docgen — 259 checks</td>
<td>exit 1</td></tr>
<tr><td><code>make check BOOK=&lt;dir&gt;</code></td><td>that one book</td>
<td>exit 1</td></tr>
</table>
<p>
All 259 with both optional dependencies installed, 244 with neither — the suite
skips rather than fails when tree-sitter, lxml or the OpenAPI reader is absent.
See <a href="#standalone">standalone</a> for the counts outside the repo.
</p>
<h3>The book level, where custom checks live</h3>
<p>
The third level is the one that reaches a project docgen has never seen, and it
is where <b>framework and hand-written checks live together</b>. The generated
half is the spine's own assertions, identical for every book: both measures
present, the reconciliation holding, every artifact where the ledger says it
is, the notebook still executing. The custom half goes in the book's own
<code>checks.py</code> and uses the same helpers, so a project's line and a
framework line read identically and fail identically.
</p>
<pre><code><span class="c"># out/book/station/checks.py</span>
<span class="k">def</span> checks(book, check, note, skip):
note("what this project will not give up")
<span class="c"># Payments moved once already and the move broke three dashboards.</span>
<span class="c"># If it is not here, something renamed it again.</span>
check("payments is still a module", True,
any(n["id"] == "app.payments" <span class="k">for</span> n <span class="k">in</span> book.ir["nodes"]))</code></pre>
<p>
Same split as the notebook's base and overlay, for the same reason: generation
alone cannot know what <em>this</em> project cares about, and hand-authoring
alone rots.
</p>
<div class="note">
<b>Each check is one decision that has already been made, with the reason above
it.</b> Not coverage, and deliberately not an exhaustive sweep. A rule without
its reason gets overridden the first time it is inconvenient, so failing a
check should read as <em>"you are about to undo this"</em> rather than
<em>"something broke"</em>. The idiom is carried from rig's
<code>ctrl/selftest.sh</code>, which is where the three-level split comes from.
</div>
<h3>The four that are the design</h3>
<p>
Four of those are the <em>design</em> rather than regressions, and they are the
ones to keep if anything is ever cut:
Of docgen's own checks, four assert the <em>architecture</em> rather than guard
a regression, and they are the ones to keep if anything is ever cut:
</p>
<ul>
<li><b>No visual field reaches the IR</b> — extractors cannot decide appearance.</li>
@@ -736,8 +1056,10 @@ python3 -m docgen.emitters explore ir.json -o explore/</code></pre>
<p>
Self-hosting is the honest end-to-end check, and it is where the real bugs came
from — two name-resolution faults and a duplicate-id crash that no fixture had
reached. <code>make self</code> runs the whole pipeline over soleprint; if the
index does not read like the system, something is wrong.
reached. <code>make self</code> builds docgen's book <em>of soleprint</em> and
then runs that book's own level against it, so the two ends have to reconcile
on 225 real files. If the index does not read like the system, something is
wrong.
</p>
<!-- ─────────────────────────────────────────────────────────────── -->

View File

@@ -248,7 +248,82 @@ body {
.figure a:hover { border-color: var(--accent); }
.figure img { display: block; width: 100%; height: auto; }
.figure figcaption { color: var(--dim); font-size: 10px; margin-top: .4rem; }
/* Both ends of the book, side by side and above the diagram. Above, because a
reader who scrolls past the picture has already formed an impression, and
"2 files could not be read" has to arrive before that and not after. */
.ledger { display: flex; gap: 1px; background: var(--border); border: 1px solid var(--border);
border-radius: 8px; overflow: hidden; margin: 0 0 1.5rem; }
.ledger > div { flex: 1 1 0; background: var(--surface); padding: .7rem .9rem; min-width: 0; }
.ledger dt { color: var(--dim); font-size: 9.5px; text-transform: uppercase;
letter-spacing: .07em; margin-bottom: .3rem; }
.ledger dd { margin: 0; color: var(--text); font-size: 12.5px; }
.ledger dd .sub { display: block; color: var(--muted); font-size: 11px; margin-top: .2rem;
overflow-wrap: anywhere; }
.ledger .lost { color: var(--artery); }
.ledger .kept { color: var(--ok); }
.gap { border: 1px solid var(--artery); border-left-width: 3px; border-radius: 6px;
background: var(--surface); padding: .7rem .9rem; margin: 0 0 1.5rem; font-size: 12px; }
.gap b { color: var(--artery); }
.gap ul { margin: .4rem 0 0 1.1rem; color: var(--muted); }
.gap code { font-size: 11px; }
"""
def _ledger(book: dict) -> str:
"""The two measures, and the gap between them if there is one.
This is the whole reason the site is the book's last step rather than just
another emitter: it is the one artifact somebody definitely opens, so it is
where "45 of 47 files" has to appear. A diagram cannot say it, and a log
nobody reads does not count as having said it.
"""
larder = book.get("larder") or {}
measure = book.get("book") or {}
failed = larder.get("failed") or []
kinds = measure.get("by_kind") or {}
out_summary = " · ".join(f"{v} {k}" for k, v in
sorted(kinds.items(), key=lambda kv: -kv[1])[:4])
unit = larder.get("unit", "unit")
read, seen = larder.get("read", 0), larder.get("seen", 0)
plural = unit if read == 1 else (unit[:-1] + "ies" if unit.endswith("y") else unit + "s")
in_line = f"{read} {plural} read"
if failed:
in_line += f' <span class="lost"{len(failed)} of {seen} could not be</span>'
panel = (
'<div class="ledger">'
f'<div><dt>what came in</dt><dd>{in_line}'
f'<span class="sub">{escape(larder.get("identity", "?"))}</span></dd></div>'
f'<div><dt>what came out</dt><dd>{escape(out_summary) or "nothing"}'
f'<span class="sub">{measure.get("edges", 0)} edges · '
f'{measure.get("external", 0)} external · '
# "step artifacts", not "artifacts": this page is written before the
# book's last step closes, so it cannot count itself. Saying `step`
# makes the number true rather than one short of book.json's.
f'{len(measure.get("artifacts") or [])} step artifacts</span></dd></div>'
"</div>"
)
# A reconciliation that failed is not a footnote. It means the document
# below is incomplete in a way the document below cannot show.
lost = [r for r in (book.get("reconciled") or []) if not r.get("ok")]
if lost or failed:
items = "".join(f"<li>{escape(r['claim'])}{escape(r['why'])}</li>" for r in lost)
items += "".join(
f"<li><code>{escape(f['name'])}</code> — {escape(f['error'])}</li>"
for f in failed[:12]
)
if len(failed) > 12:
items += f"<li>and {len(failed) - 12} more</li>"
panel += (
'<div class="gap"><b>This book is incomplete.</b> '
"What is drawn below is everything that could be read, which is not "
f"everything there is.<ul>{items}</ul></div>"
)
return panel
def _slots(style) -> dict:
@@ -308,8 +383,15 @@ def _sections(items: list, depth: int = 0) -> str:
return "".join(out)
def emit(ir: dict, style, *, graph: str | None = None, title: str = "") -> dict:
"""IR + Style -> {filename: text}. Write them next to each other."""
def emit(ir: dict, style, *, graph: str | None = None, title: str = "",
book: dict | None = None) -> dict:
"""IR + Style -> {filename: text}. Write them next to each other.
`book` is a book ledger (`book/__init__.py`). When present the page opens
with both measures — what went in, what came out — because a page that
shows only the result is the thing the measure exists to correct.
Optional, so the site emitter still works on a bare IR.
"""
from .index import to_sidebar
meta = ir.get("meta", {})
@@ -322,6 +404,8 @@ def emit(ir: dict, style, *, graph: str | None = None, title: str = "") -> dict:
counts[n["kind"]] = counts.get(n["kind"], 0) + 1
summary = " · ".join(f"{v} {k}" for k, v in sorted(counts.items(), key=lambda kv: -kv[1]))
ledger = _ledger(book) if book else ""
figure = ""
if graph:
figure = (
@@ -360,6 +444,7 @@ def emit(ir: dict, style, *, graph: str | None = None, title: str = "") -> dict:
<h1>{escape(name)}</h1>
<p class="lede">Generated from <code>{escape(meta.get("source", "?"))}</code> ·
{escape(summary)}. Regenerated, not edited.</p>
{ledger}
{figure}
{_sections(side["items"])}
{ext_html}
@@ -396,11 +481,12 @@ document.querySelectorAll('h2[id], h3[id]').forEach(function (h) {{ obs.observe(
}
def write(ir: dict, style, out_dir, *, graph: str | None = None, title: str = "") -> list[Path]:
def write(ir: dict, style, out_dir, *, graph: str | None = None, title: str = "",
book: dict | None = None) -> list[Path]:
out_dir = Path(out_dir)
out_dir.mkdir(parents=True, exist_ok=True)
written = []
for name, text in emit(ir, style, graph=graph, title=title).items():
for name, text in emit(ir, style, graph=graph, title=title, book=book).items():
path = out_dir / name
path.write_text(text)
written.append(path)

View File

@@ -204,7 +204,8 @@ def _count_errors(node) -> int:
return n
def extract(root, suffixes=None, exclude=(), source: str = "code") -> Graph:
def extract(root, suffixes=None, exclude=(), source: str = "code",
identity: str | None = None) -> Graph:
"""Walk a tree and map its structure. No edges — see the module docstring."""
root = Path(root).resolve()
if not root.is_dir():
@@ -214,6 +215,8 @@ def extract(root, suffixes=None, exclude=(), source: str = "code") -> Graph:
skip = {"__pycache__", ".git", ".venv", "venv", "node_modules", "dist", "build",
"bin", "obj", *exclude}
from ..book.larder import Larder
g = Graph(Meta(source=source, root=root.name))
packages: set[str] = set()
files = [
@@ -222,6 +225,14 @@ def extract(root, suffixes=None, exclude=(), source: str = "code") -> Graph:
and not any(part in skip for part in p.relative_to(root).parts)
]
# The measure is built alongside the walk rather than recomputed after it.
# tree-sitter never raises on bad syntax — it produces ERROR nodes — so
# "failed" here means a file that could not be handled at all, and the
# partially-parsed ones are counted separately under `unparsed_regions`.
larder = Larder(kind="code", identity=identity or root.name, unit="file",
seen=len(files))
unparsed = 0
for path in files:
try:
module, decls = extract_file(path, root)
@@ -229,6 +240,7 @@ def extract(root, suffixes=None, exclude=(), source: str = "code") -> Graph:
raise
except Exception as e: # noqa: BLE001 - one bad file must not cost the run
rel = path.relative_to(root)
larder.fail(rel.as_posix(), f"{type(e).__name__}: {e}")
g.node(".".join([*rel.parts[:-1], rel.stem]), "module", rel.stem,
attrs={"file": rel.as_posix(), "error": f"{type(e).__name__}: {e}"})
continue
@@ -239,6 +251,7 @@ def extract(root, suffixes=None, exclude=(), source: str = "code") -> Graph:
attrs = {"file": module["file"], "lines": module["lines"]}
if module["errors"]:
attrs["error"] = f"{module['errors']} unparsed region(s)"
unparsed += 1
g.node(module["id"], "module", module["label"], parent=parent, attrs=attrs)
for d in decls:
@@ -257,4 +270,12 @@ def extract(root, suffixes=None, exclude=(), source: str = "code") -> Graph:
parent=".".join(parts[: i - 1]) or None,
attrs={"lines": 0, "directory": True})
known.add(pid)
larder.extra["languages"] = len({p.suffix for p in files})
if unparsed:
# Worth its own number rather than folding into `failed`: a file with an
# unparsed region still contributed structure, so calling it a failure
# would understate what the book contains.
larder.extra["unparsed_regions"] = unparsed
g.meta.larder = larder.to_dict()
return g

View File

@@ -37,7 +37,8 @@ from pathlib import Path
from ..ir import Graph, Meta
def from_schema_dict(data: dict, root: str = "schema", source: str = "db") -> Graph:
def from_schema_dict(data: dict, root: str = "schema", source: str = "db",
identity: str | None = None) -> Graph:
"""A graphgen-compatible schema dict -> IR.
Accepts both spellings of the contract: the on-disk `schema.json` form,
@@ -45,6 +46,8 @@ def from_schema_dict(data: dict, root: str = "schema", source: str = "db") -> Gr
`graphgen.schema.load_graph_schema` returns, where it is a list. They are
the same data and callers have both.
"""
from ..book.larder import Larder
g = Graph(Meta(source=source, root=root))
models = data.get("models", {})
listed = models.values() if isinstance(models, dict) else models
@@ -96,6 +99,18 @@ def from_schema_dict(data: dict, root: str = "schema", source: str = "db") -> Gr
kind = "references" if rel.get("type") == "M2M" else "foreign_key"
g.edge(src, dst, kind, attrs={"label": rel.get("from_field", "")})
# A schema is a published contract, so there is nothing here that can fail
# to be read — every table it declares is a table. `seen == read` always,
# and saying so is more useful than omitting the measure: it distinguishes
# "no failures" from "not measured".
larder = Larder(kind="db", identity=identity or root, unit="table",
seen=len(names))
rels = data.get("relationships") or []
larder.extra["relationships"] = len(rels)
if data.get("source"):
larder.extra["dialect"] = str(data["source"])
g.meta.larder = larder.to_dict()
_dedupe(g)
for missing in sorted(
{e.target for e in g.edges} - {n.id for n in g.nodes}
@@ -137,8 +152,9 @@ def _dedupe(g: Graph) -> None:
g.edges = keep
def extract(schema_path, source: str = "db") -> Graph:
def extract(schema_path, source: str = "db", identity: str | None = None) -> Graph:
"""Read a schema.json from disk."""
path = Path(schema_path)
data = json.loads(path.read_text())
return from_schema_dict(data, root=path.parent.name or path.stem, source=source)
return from_schema_dict(data, root=path.parent.name or path.stem, source=source,
identity=identity or str(schema_path))

View File

@@ -24,33 +24,39 @@ are a separate `kind`, so a view can ask for one or the other.
only_kinds(ir, {"endpoint"}) -> the surface, as a notebook
"""
import sys
from pathlib import Path
from ..ir import Graph, Meta
def _modelgen():
"""modelgen, from wherever this instance keeps station tools.
"""modelgen's OpenAPI reader, from wherever the reference repo is.
Imported lazily and by path rather than as a hard dependency: docgen belongs
to atlas and may depend on a station tool, but it should not fail to import
because one is missing.
Imported lazily and by path rather than as a hard dependency. This is the
**only** seam between docgen and the wider repo — see `reference.py`, which
resolves it from `$DOCGEN_REFERENCE` or by walking up. Every other extractor
and every emitter works with nothing above `docgen/`.
Not reimplemented here on purpose: modelgen already parses the spec and
resolves `$ref`, and a second OpenAPI reader in the same repo is two things
to keep correct.
"""
here = Path(__file__).resolve()
for parent in here.parents:
tools = parent / "station" / "tools"
if (tools / "modelgen").is_dir():
if str(tools) not in sys.path:
sys.path.insert(0, str(tools))
from modelgen.loader.extract.openapi import OpenAPIExtractor
from .. import reference
return OpenAPIExtractor
raise ImportError(
"modelgen not found — docgen reads OpenAPI through "
"station/tools/modelgen/loader/extract/openapi.py, which parses the spec "
"and resolves $ref. It is not reimplemented here."
if reference.on_path() is None:
raise reference.missing(
"modelgen",
"OpenAPI is read through station/tools/modelgen/loader/extract/"
"openapi.py, which parses the spec and resolves $ref",
)
try:
from modelgen.loader.extract.openapi import OpenAPIExtractor
except ImportError as e:
raise reference.missing(
"modelgen.loader.extract.openapi",
f"the reference repo was found but the module did not import ({e})",
) from None
return OpenAPIExtractor
def _type_name(hint) -> str:
@@ -61,8 +67,55 @@ def _type_name(hint) -> str:
return getattr(hint, "__name__", str(hint))
def extract(spec_path, source: str = "openapi") -> Graph:
def _refs(path: Path) -> dict[str, dict[str, str]]:
"""{schema: {field: referenced schema}} — the relationships, recovered.
modelgen resolves an inter-schema `$ref` to the literal string `dict`, so by
the time its fields reach us the *target* is gone. Without this, an API's
data model draws as disconnected cards: three tables, no foreign keys, and
nothing saying the relationships were lost. Which is exactly the failure
this tool is otherwise built to prevent.
So one key is read directly, and one only: `$ref` under a schema's
`properties`. That is not parsing OpenAPI — no paths, no bodies, no
responses, no `$ref` resolution, no composition keywords. modelgen still
does all of the reading that matters, and this recovers the single fact its
type mapping cannot carry.
Returns `{}` on anything unexpected. A missing relationship is a worse
diagram; a raised exception here would be no diagram at all.
"""
try:
import yaml # available: modelgen just used it
except ImportError:
return {}
try:
doc = yaml.safe_load(path.read_text()) or {}
schemas = ((doc.get("components") or {}).get("schemas")) or {}
except Exception: # noqa: BLE001 - see the docstring
return {}
out: dict[str, dict[str, str]] = {}
for name, schema in schemas.items():
if not isinstance(schema, dict):
continue
for field, spec in (schema.get("properties") or {}).items():
if not isinstance(spec, dict):
continue
# A direct reference, or an array of them — `lines: [OrderLine]` is
# the same relationship as `order: Order`, pointing the other way.
ref = spec.get("$ref")
if not ref and isinstance(spec.get("items"), dict):
ref = spec["items"].get("$ref")
if isinstance(ref, str) and ref.startswith("#/components/schemas/"):
out.setdefault(name, {})[field] = ref.rsplit("/", 1)[-1]
return out
def extract(spec_path, source: str = "openapi", identity: str | None = None) -> Graph:
"""An OpenAPI file -> IR."""
from ..book.larder import Larder
OpenAPIExtractor = _modelgen()
path = Path(spec_path)
extractor = OpenAPIExtractor(path)
@@ -71,6 +124,7 @@ def extract(spec_path, source: str = "openapi") -> Graph:
g = Graph(Meta(source=source, root=path.name))
known = {m.name for m in models}
refs = _refs(path)
for model in models:
attrs = {}
@@ -83,7 +137,9 @@ def extract(spec_path, source: str = "openapi") -> Graph:
a["nullable"] = True
if field.name in ("id", "uuid"):
a["pk"] = True
target = _type_name(field.type_hint)
# The recovered $ref target wins over the mapped type name: modelgen
# says `dict` where the spec said which schema.
target = refs.get(model.name, {}).get(field.name) or _type_name(field.type_hint)
if target in known and target != model.name:
a["references"] = target
g.edge(model.name, target, "foreign_key", attrs={"label": field.name})
@@ -120,4 +176,15 @@ def extract(spec_path, source: str = "openapi") -> Graph:
if getattr(e, "response_model", None) in known:
g.edge(eid, e.response_model, "returns")
# A spec is the larder here, and `path` is the unit because that is what a
# spec is an inventory of. Schemas and enums are counted separately: a spec
# with 40 schemas and 3 endpoints is a data model, and the measure should
# make that visible before the diagram does.
larder = Larder(kind="openapi", identity=identity or path.name, unit="path",
seen=len({e.path for e in endpoints}))
larder.extra["operations"] = len(endpoints)
larder.extra["schemas"] = len(models)
if enums:
larder.extra["enums"] = len(enums)
g.meta.larder = larder.to_dict()
return g

View File

@@ -17,13 +17,20 @@ from .collect import collect
from .resolve import to_ir
def extract(root, exclude=(), source="python"):
"""Walk `root`, return an IR Graph. Never raises on a bad file."""
def extract(root, exclude=(), source="python", identity=None):
"""Walk `root`, return an IR Graph. Never raises on a bad file.
`identity` is what the larder measure calls this source — the path as the
caller wrote it, which is what they will recognise. It defaults to the
directory name rather than the resolved path, for the same reason
`meta.root` does: an absolute path is not a secret but it is machine
specific, and the measure should mean the same thing on two machines.
"""
root = Path(root).resolve()
if not root.is_dir():
raise NotADirectoryError(f"not a directory: {root}")
modules = collect(root, exclude=exclude)
return to_ir(modules, root=root.name, source=source)
return to_ir(modules, root=root.name, source=source, identity=identity)
__all__ = ["extract", "collect", "to_ir"]

View File

@@ -64,9 +64,30 @@ def _resolve(name: str, module: Module, known: set[str]) -> str | None:
return None
def to_ir(modules: list[Module], root: str, source: str = "python") -> Graph:
def larder_of(modules: list[Module], identity: str) -> "Larder":
"""The input measure for a tree of Python files.
Every fact here was already computed and then discarded: `collect_file`
records `Module.error` for a file it could not read or parse, and nothing
downstream ever reported how many there were. A run that parsed 45 of 47
files produced the same document as one that parsed all 47.
"""
from ...book.larder import Larder
larder = Larder(kind="python", identity=identity, unit="file",
seen=len(modules))
for m in modules:
if m.error:
larder.fail(m.path, m.error)
larder.extra["packages"] = len({m.package for m in modules if m.package})
return larder
def to_ir(modules: list[Module], root: str, source: str = "python",
identity: str | None = None) -> Graph:
"""Collected modules -> a validated-shaped IR graph."""
g = Graph(Meta(source=source, root=root))
g = Graph(Meta(source=source, root=root,
larder=larder_of(modules, identity or root).to_dict()))
# -- nodes we own ------------------------------------------------------
known: set[str] = set()

View File

@@ -133,8 +133,10 @@ def _graphql(body: dict | None) -> tuple[str | None, str | None]:
return (name or (m.group(2) if m and m.group(2) else "anonymous")), op_type
def extract(har_path, source: str = "usage") -> Graph:
def extract(har_path, source: str = "usage", identity: str | None = None) -> Graph:
"""A HAR file -> IR of what was actually called."""
from ..book.larder import Larder
path = Path(har_path)
har = json.loads(path.read_text())
entries = (har.get("log") or {}).get("entries") or []
@@ -143,11 +145,26 @@ def extract(har_path, source: str = "usage") -> Graph:
calls = [] # (key, kind, facts) in order
seen: dict[str, dict] = {}
for entry in entries:
# The unit is the HAR entry — one recorded request — not the distinct call.
# A capture's size is what it is, and the collapse from many entries to few
# endpoints is a fact worth being able to show against this number.
larder = Larder(kind="usage", identity=identity or path.name, unit="entry",
seen=len(entries))
for i, entry in enumerate(entries):
req = entry.get("request") or {}
res = entry.get("response") or {}
method = (req.get("method") or "GET").upper()
url = req.get("url") or ""
if not url:
# Without a URL there is nothing to template, and `urlsplit("")`
# yields an empty path that `_template` turns into "/". That made a
# malformed entry indistinguishable from a real call to the root.
# Recorded by name and skipped, because a capture is somebody's
# session and quietly inventing a request in it is worse than
# reporting a gap.
larder.fail(f"entries[{i}]", "no request URL")
continue
split = urlsplit(url)
templated, id_formats = _template(split.path or "/")
body = _body(entry)
@@ -199,6 +216,10 @@ def extract(har_path, source: str = "usage") -> Graph:
g = Graph(Meta(source=source, root=path.name))
larder.extra["hosts"] = len({r["host"] for r in seen.values() if r.get("host")})
larder.extra["calls"] = len(seen)
g.meta.larder = larder.to_dict()
order = {}
for i, key in enumerate(calls):
order.setdefault(key, i)

View File

@@ -0,0 +1,154 @@
# docgen's own OpenAPI fixture.
#
# Small, and shipped here rather than borrowed from station/tools/shuntgen so the
# framework test does not need the repo to be next door. A test's fixtures belong
# to the test — the suite still skips without modelgen, because reading OpenAPI
# genuinely needs it, but it no longer skips for want of a file.
#
# What it exercises, deliberately:
# - schemas that reference each other, so `foreign_key` edges have somewhere
# to come from and the ERD emitter has something to draw
# - an enum, which is a separate path in modelgen's reader
# - more than three paths, and one non-collection path with a parameter
# - a create body whose `id` is server-assigned, which is what the notebook
# emitter's "primary key is left out of a create body" check is about
openapi: 3.0.3
info:
title: Orders
version: "1.0.0"
paths:
/customers:
get:
operationId: listCustomers
responses:
"200":
description: Every customer.
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/Customer"
post:
operationId: createCustomer
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/Customer"
responses:
"201":
description: Created.
content:
application/json:
schema:
$ref: "#/components/schemas/Customer"
/customers/{customerId}:
get:
operationId: getCustomer
parameters:
- name: customerId
in: path
required: true
schema:
type: integer
responses:
"200":
description: One customer.
content:
application/json:
schema:
$ref: "#/components/schemas/Customer"
/orders:
get:
operationId: listOrders
parameters:
- name: status
in: query
required: false
schema:
$ref: "#/components/schemas/OrderStatus"
responses:
"200":
description: Orders, newest first.
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/Order"
post:
operationId: createOrder
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/Order"
responses:
"201":
description: Created.
content:
application/json:
schema:
$ref: "#/components/schemas/Order"
/orders/{orderId}/lines:
get:
operationId: listOrderLines
parameters:
- name: orderId
in: path
required: true
schema:
type: integer
responses:
"200":
description: The lines on one order.
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/OrderLine"
components:
schemas:
OrderStatus:
type: string
enum: [pending, paid, shipped, cancelled]
Customer:
type: object
required: [id, email]
properties:
id:
type: integer
description: Server-assigned.
email:
type: string
name:
type: string
Order:
type: object
required: [id, customer]
properties:
id:
type: integer
description: Server-assigned.
customer:
$ref: "#/components/schemas/Customer"
status:
$ref: "#/components/schemas/OrderStatus"
total:
type: number
OrderLine:
type: object
required: [id, order, sku]
properties:
id:
type: integer
order:
$ref: "#/components/schemas/Order"
sku:
type: string
quantity:
type: integer

View File

@@ -23,6 +23,7 @@ in a theme that has no cylinders. The previous iteration of this model carried
`cls`, `shape` and `style`, and that is exactly the mistake this replaces.
"""
from copy import deepcopy
from dataclasses import dataclass, field
from typing import Any
@@ -37,20 +38,37 @@ class Meta:
extractions of an unchanged tree differ, which destroys the diff emitter's
only useful property. Set it when the run time is the fact being recorded,
not by habit.
`larder` is what was actually read — see `book/larder.py`. It is optional so
that an IR written by hand, or by an extractor that cannot count its input,
is still valid; what it must never be is *wrong*. Absent means "not
measured", which a reader can act on. A zero would be a claim.
"""
source: str
root: str
schema_version: str = SCHEMA_VERSION
generated_at: str | None = None
larder: dict | None = None
def to_dict(self) -> dict:
return {
out = {
"source": self.source,
"root": self.root,
"schema_version": self.schema_version,
"generated_at": self.generated_at,
}
# Omitted rather than null when unmeasured: `meta.larder` present with a
# null inside is two ways to say the same thing, and consumers end up
# checking for both.
#
# Deep-copied because the rest of this model copies too — `Node.to_dict`
# does `dict(self.attrs)`. A shallow copy is not enough here: `failed` is
# a list of dicts, so aliasing it would let a caller editing a serialised
# document reach back into the live measure.
if self.larder is not None:
out["larder"] = deepcopy(self.larder)
return out
@dataclass

View File

@@ -28,6 +28,33 @@
"generated_at": {
"type": ["string", "null"],
"description": "ISO-8601, or null. Null is the default and the honest one: a timestamp makes two extractions of the same tree differ, which breaks diffing. Set it only when the run time is itself the fact being recorded."
},
"larder": {
"type": ["object", "null"],
"description": "What was actually read: the first step of a book, and the only measure of the input this document carries. Absent means not measured, which a reader can act on; a zero would be a claim. Provenance lives here and never in nodes or attrs, the same split that keeps colour out of the IR.",
"required": ["kind", "identity", "unit", "seen", "read", "failed"],
"additionalProperties": false,
"properties": {
"kind": { "type": "string", "description": "Which extractor stocked it: python, code, db, openapi, usage." },
"identity": { "type": "string", "description": "The path, or a connection string with its credentials masked. Scrubbed at construction in book/larder.py and swept for in validate.py — the one field in the IR that could carry a secret." },
"unit": { "type": "string", "description": "What `seen` counts: file, table, path, entry, document. Closed vocabulary, so two larders can be compared." },
"seen": { "type": "integer", "description": "How many units the larder offered." },
"read": { "type": "integer", "description": "How many were consumed. Always seen - len(failed); derived on the dataclass so the two cannot disagree." },
"failed": {
"type": "array",
"description": "What could not be consumed, by name and reason. Named rather than counted: a count says a book is incomplete, a name says which part of it to distrust.",
"items": {
"type": "object",
"required": ["name", "error"],
"additionalProperties": false,
"properties": {
"name": { "type": "string" },
"error": { "type": "string" }
}
}
},
"extra": { "type": "object", "description": "Per-source facts that do not generalise: packages for a tree, dialect for a database, hosts for a capture." }
}
}
}
},

View File

@@ -22,6 +22,7 @@ that produce a broken diagram.
"""
import json
import re
import sys
from pathlib import Path
@@ -90,6 +91,8 @@ def check(data: dict, *, strict_visual: bool = True) -> list[str]:
if version is not None and not isinstance(version, str):
problems.append(f"meta.schema_version must be a string, got {type(version).__name__}")
problems.extend(_check_larder(data["meta"].get("larder"), meta_schema))
# -- nodes ------------------------------------------------------------
node_required, node_allowed = _fields(schema, "nodes")
seen: set[str] = set()
@@ -169,6 +172,79 @@ def check(data: dict, *, strict_visual: bool = True) -> list[str]:
return problems
# A credential that survived redaction, detected independently of the code that
# does the redacting. `book/larder.py` has its own key list; this has a second
# one on purpose. If the two ever disagree the check fires, which is the point —
# a scrubber that is graded by its own word is not graded. Same reason
# VISUAL_KEYS lives here and not in schema.json.
_DSN_PASSWORD = re.compile(r"[a-zA-Z][a-zA-Z0-9+.\-]*://[^:/@\s]+:(?P<secret>[^@/\s]+)@")
_SECRET_PARAM = re.compile(
r"(?i)\b(?:password|passwd|pwd|secret|token|access_token|refresh_token"
r"|api_key|apikey|sig|signature|credentials)\s*=\s*(?P<secret>[^&;\s]+)"
)
_MASKED = {"***", "xxx", "redacted", "[redacted]", "masked"}
def _check_larder(larder, meta_schema: dict) -> list[str]:
"""The input measure, checked — shape, arithmetic, and no leaked secret.
Absent is fine and means "not measured". Present and wrong is not, because
the whole value of the measure is that the number can be trusted against the
result; a larder claiming 47 reads when it read 45 is worse than no larder.
"""
if larder is None:
return []
if not isinstance(larder, dict):
return [f"meta.larder must be an object, got {type(larder).__name__}"]
problems = []
spec = meta_schema["properties"]["larder"]
for key in spec["required"]:
if key not in larder:
problems.append(f"meta.larder is missing {key!r}")
for key in larder:
if key not in spec["properties"]:
problems.append(f"meta.larder has unknown key {key!r}")
failed = larder.get("failed")
if failed is not None and not isinstance(failed, list):
problems.append("meta.larder.failed must be an array")
failed = None
elif failed:
for i, f in enumerate(failed):
if not isinstance(f, dict) or "name" not in f or "error" not in f:
problems.append(f"meta.larder.failed[{i}] needs both 'name' and 'error'")
# read is derived. Stored, it can disagree with itself, and this is where
# that shows up rather than in a book measure nobody can reconcile.
seen, read = larder.get("seen"), larder.get("read")
if isinstance(seen, int) and isinstance(read, int) and isinstance(failed, list):
if read != max(0, seen - len(failed)):
problems.append(
f"meta.larder.read is {read} but seen={seen} with {len(failed)} failed "
f"implies {max(0, seen - len(failed))} — the measure disagrees with itself"
)
if isinstance(read, int) and isinstance(seen, int) and read > seen:
problems.append(f"meta.larder.read ({read}) exceeds seen ({seen})")
unit = larder.get("unit")
if unit is not None and not isinstance(unit, str):
problems.append(f"meta.larder.unit must be a string, got {type(unit).__name__}")
identity = larder.get("identity")
if isinstance(identity, str):
for pattern, what in ((_DSN_PASSWORD, "a DSN password"),
(_SECRET_PARAM, "a secret parameter")):
m = pattern.search(identity)
if m and m.group("secret").lower() not in _MASKED:
problems.append(
f"meta.larder.identity still contains {what}"
"redact() in book/larder.py did not mask it, and this document "
"must not be written anywhere"
)
return problems
def validate(data: dict, **kw) -> dict:
"""check(), but raises. For use at a boundary where carrying on is wrong."""
problems = check(data, **kw)

View File

@@ -0,0 +1,107 @@
"""
Where the rest of spr is, when docgen is not inside it.
docgen is meant to be usable **standalone** — copied out of the repo, pointed at
someone else's codebase, with nothing above it. One capability genuinely needs
more than that: reading an OpenAPI document goes through
`station/tools/modelgen`, which parses the spec and resolves `$ref`, and is
deliberately not reimplemented here.
So there is exactly one seam to the wider repo, and this is it. Everything else
in docgen resolves inside its own directory.
## How it resolves, in order
1. $DOCGEN_REFERENCE an explicit path to the repo root
2. walking up from this file when docgen still sits inside the repo
(1) is the case that matters when docgen is used standalone with the repo kept
alongside as reference. (2) is what happens in place, and needs no configuration.
## Why an env var and not a config file
Because there is one setting and it is a path. A config file for one path is a
file to find, parse, document and validate, and the first question anyone asks
of it is "where does it live" — which is the same question again. The Makefile
passes it through, `make doctor` reports it, and the error message when it is
missing says the variable name.
## Absent is a normal state, not an error
Nothing here raises on a failed lookup. The OpenAPI extractor reports what to
set and every other part of docgen carries on, because for four of the five
extractors this file is irrelevant.
"""
import os
import sys
from pathlib import Path
ENV_VAR = "DOCGEN_REFERENCE"
# What proves a directory is the repo root rather than some other directory:
# the station tools tree, which is the only thing docgen ever reaches for.
MARKER = Path("station") / "tools"
def _candidates():
explicit = os.environ.get(ENV_VAR)
if explicit:
yield Path(explicit).expanduser()
# Walking up covers the in-place case and costs nothing when it fails.
for parent in Path(__file__).resolve().parents:
yield parent
def root() -> Path | None:
"""The repo root holding `station/tools`, or None if it is not reachable."""
for candidate in _candidates():
try:
if (candidate / MARKER).is_dir():
return candidate
except OSError:
continue
return None
def station_tools() -> Path | None:
"""The `station/tools` directory itself, or None."""
found = root()
return found / MARKER if found else None
def describe() -> str:
"""One line for `make doctor`. Says how it resolved, not just whether."""
explicit = os.environ.get(ENV_VAR)
found = root()
if found is None:
return (f"absent — set {ENV_VAR}=/path/to/repo to read OpenAPI "
"(everything else works without it)")
how = f"from ${ENV_VAR}" if explicit and Path(explicit).expanduser() == found \
else "found by walking up"
return f"{found} ({how})"
def on_path() -> Path | None:
"""Put `station/tools` on `sys.path` and return it, or None.
Prepending rather than appending: a `modelgen` earlier on the path is
somebody else's, and silently reading the wrong one is worse than not
finding it at all.
"""
tools = station_tools()
if tools is None:
return None
if str(tools) not in sys.path:
sys.path.insert(0, str(tools))
return tools
def missing(what: str, why: str) -> ImportError:
"""The error to raise when a reference-dependent capability is used."""
return ImportError(
f"{what} is not reachable — {why}\n"
f"docgen is standalone except for this one seam. Point at the repo with:\n"
f" export {ENV_VAR}=/path/to/repo\n"
f"and check it with: make doctor"
)

View File

@@ -52,6 +52,8 @@ exp_mod = __import__(f"{PKG}.emitters.explore", fromlist=["*"])
spec_mod = __import__(f"{PKG}.notebook", fromlist=["*"])
db_ex = __import__(f"{PKG}.extractors.db", fromlist=["*"])
usage_ex = __import__(f"{PKG}.extractors.usage", fromlist=["*"])
tokens_mod = __import__(f"{PKG}.style.tokens", fromlist=["*"])
extract_mod = __import__(f"{PKG}.style.extract", fromlist=["*"])
check_ir = ir_mod.check
Style, StyleError = style_mod.Style, style_mod.StyleError
@@ -361,6 +363,208 @@ check(
)
# --------------------------------------------------------------------------
print("\n3b. harvesting — a binding recovered from diagrams that cannot leave")
def _harvesting():
"""Its own scope on purpose: `tmp` out here is the whole run's temp
directory, and a `with TemporaryDirectory() as tmp` would delete it."""
# The shape a Graphviz harvest really has, and the reason frequency is the wrong
# signal for the canvas: every label carries a `fill`, so the ink (87) outnumbers
# the background (43) and "most common is the background" inverts the theme.
HARVEST = {
"source": "/tmp/diagrams",
"files_read": 3,
"files_failed": [],
"tokens": {
"fill": [
{"value": "#1f2933", "count": 87},
{"value": "#ffffff", "count": 43},
{"value": "#e8effd", "count": 20},
{"value": "#2b5fd9", "count": 12},
{"value": "#1a7f45", "count": 9},
{"value": "#c0392b", "count": 4},
],
"stroke": [{"value": "#9aa5b1", "count": 60}, {"value": "#616e7c", "count": 18}],
"stroke-width": [{"value": "1", "count": 90}, {"value": "4", "count": 3}],
"font-family": [{"value": "Helvetica", "count": 70}],
"font-size": [{"value": "11", "count": 70}, {"value": "14", "count": 6}],
"rx": [{"value": "4", "count": 12}],
},
}
harvested = tokens_mod.derive(HARVEST, "harvested")
check(
"a harvest produces a theme, not a style file",
set(harvested) == {"harvested"} and set(harvested["harvested"]) == {"note", "slots"},
f"got {sorted(harvested['harvested'])} — what counting recovers is the binding, "
"not what a kind should look like",
)
slots = harvested["harvested"]["slots"]
check(
"the canvas and the ink are the lightness extremes",
(slots["surface-0"], slots["text"]) == ("#ffffff", "#1f2933"),
f"surface-0={slots['surface-0']} text={slots['text']} — by frequency the ink "
"wins the background and the theme's own text is invisible against it",
)
check(
"the accents are the colours that are neither canvas nor ink",
{slots["accent"], slots["artery"], slots["atlas"]} <= {"#e8effd", "#2b5fd9", "#1a7f45", "#c0392b"},
f"accent={slots['accent']} artery={slots['artery']} atlas={slots['atlas']}",
)
missing = [s for s in tokens_mod.REQUIRED_SLOTS if s not in slots]
check(
"every slot a style file needs is bound, so a partial harvest still loads",
not missing,
f"unbound: {missing}",
)
check(
"a readable harvest says nothing",
"_warning" not in slots,
"a warning on a theme that is fine is a warning nobody reads",
)
flipped = tokens_mod.derive(HARVEST, "harvested", dark=True)["harvested"]["slots"]
check(
"polarity flips whole — canvas and ink together, never one of them",
(flipped["surface-0"], flipped["text"]) == (slots["text"], slots["surface-0"]),
)
# Polarity is the guess most likely to be wrong, so being wrong has to be loud.
DARK_ONLY = json.loads(json.dumps(HARVEST))
DARK_ONLY["tokens"]["fill"] = [
{"value": "#1f2933", "count": 87},
{"value": "#2b3440", "count": 43},
]
warned = tokens_mod.derive(DARK_ONLY, "harvested")["harvested"]["slots"]
check(
"a theme nobody could read says so",
"_warning" in warned and "unreadable" in warned["_warning"],
f"got {warned.get('_warning', 'no warning')!r}",
)
geom = tokens_mod.geometry(HARVEST)
check(
"widths take the mode, not the mean",
geom["hairline"] == "1",
f"got {geom['hairline']} — the mean of 1 and 4 is 2.5, a width no diagram uses",
)
check(
"the type scale is derived from the base size, not harvested three times",
(geom["font-size-sm"], geom["font-size-base"], geom["font-size-header"])
== ("10", "11", "13"),
f"got {geom['font-size-sm']}/{geom['font-size-base']}/{geom['font-size-header']}",
)
check(
"rounding survives as the binary DOT can express",
geom["rounded"] is True,
"the radius is dropped on purpose — `rounded` has no scalar",
)
# The claim the whole split rests on: one set of rules, several bindings. A theme
# recovered from someone else's diagrams has to render through docgen's own rules
# with nothing rewritten.
merged = json.loads((HERE / "style" / "lucid.json").read_text())
merged["themes"].update(harvested)
try:
harvested_style = Style(merged, theme="harvested", name="harvested")
rendered, why = slots["surface-0"] in dot_mod.emit(ir, harvested_style), ""
except StyleError as e:
rendered, why = False, str(e)
check(
"a harvested theme drops into the shipped rules and renders",
rendered,
why or "if this fails the rules and the binding are not actually separable",
)
# -- the two promises extract.py makes to a confidential folder ---------------
# Both are asserted rather than trusted, because both are the reason it is safe
# to point this at diagrams that may not be sent anywhere.
extract_src = ast.parse((HERE / "style" / "extract.py").read_text())
imported = set()
for node in ast.walk(extract_src):
if isinstance(node, ast.Import):
imported.update(a.name.split(".")[0] for a in node.names)
elif isinstance(node, ast.ImportFrom) and node.module:
imported.add(node.module.split(".")[0])
NETWORK = {"socket", "ssl", "urllib", "http", "requests", "httpx", "ftplib",
"smtplib", "asyncio", "xmlrpc", "webbrowser"}
check(
"the harvester imports nothing that can open a socket",
not (imported & NETWORK),
f"{sorted(imported & NETWORK)} — a confidential diagram stays off every network path",
)
check(
"it never reads .text or .tail",
not [n for n in ast.walk(extract_src)
if isinstance(n, ast.Attribute) and n.attr in ("text", "tail")],
"a palette does not need to know what the diagram says",
)
try:
import lxml # noqa: F401
HAS_LXML = True
except ImportError:
HAS_LXML = False
if not HAS_LXML:
skip("harvest reads real SVG", "lxml is not installed — pip install lxml")
else:
FIXTURE_SVG = (
'<svg xmlns="http://www.w3.org/2000/svg">'
'<g><rect fill="#FFF" stroke="#9AA5B1" stroke-width="1.00" rx="4"/>'
'<rect style="fill:#2b5fd9;stroke-width:1" fill="#ffffff"/>'
'<circle fill="rgb(26,127,69)"/>'
'<path fill="url(#gradient-3)"/>'
f'<text font-family="Helvetica, Arial" font-size="11.00" fill="#1f2933">{MARKER}</text>'
"</g></svg>"
)
with tempfile.TemporaryDirectory(prefix="docgen-harvest-") as tmp:
folder = Path(tmp)
(folder / "one.svg").write_text(FIXTURE_SVG)
data = extract_mod.harvest(folder)
blob = json.dumps(data)
check(
"no text from the diagram reaches the output",
MARKER not in blob,
"the fixture's label is in the harvest — rule 1 of extract.py is broken",
)
found = {p: {e["value"] for e in data["tokens"][p]} for p in data["tokens"]}
check(
"one spelling per value — #FFF and #ffffff are not two entries",
found["fill"] >= {"#ffffff", "#1a7f45"} and "#fff" not in found["fill"],
f"fills: {sorted(found['fill'])}",
)
check(
"an inline style beats the presentation attribute, as the spec says",
"#2b5fd9" in found["fill"],
f"fills: {sorted(found['fill'])} — the rect declares both",
)
check(
"a gradient reference is not a colour",
not any(v.startswith("url(") for v in found["fill"]),
f"fills: {sorted(found['fill'])}",
)
check(
"a font stack collapses to the font that will actually render",
found["font-family"] == {"Helvetica"},
f"got {sorted(found['font-family'])}",
)
check(
"1.00 and 11.00 are tidied to 1 and 11",
found["stroke-width"] == {"1"} and found["font-size"] == {"11"},
f"widths {sorted(found['stroke-width'])}, sizes {sorted(found['font-size'])}",
)
_harvesting()
# --------------------------------------------------------------------------
print("\n4. emitters")
@@ -794,15 +998,20 @@ check(
"the server assigns it",
)
# The chain, if modelgen is next door: spec -> IR -> notebook, nothing by hand.
# The chain: spec -> IR -> notebook, nothing by hand.
#
# The fixture is docgen's own, shipped in `fixtures/`, so this no longer skips
# for want of a file when docgen is used standalone. It still skips without
# modelgen, because reading OpenAPI genuinely needs it — that is the one seam
# out of docgen, and `reference.py` is where it resolves.
try:
oa = __import__(f"{PKG}.extractors.openapi", fromlist=["*"])
spec = HERE.parent.parent / "station/tools/shuntgen/fixtures/petstore.yaml"
spec = HERE / "fixtures" / "orders.yaml"
if not spec.exists():
raise ImportError("no petstore fixture")
raise ImportError(f"fixture missing: {spec}")
real = oa.extract(spec).to_dict()
except (ImportError, Exception) as e: # noqa: BLE001
skip("openapi -> IR", str(e).splitlines()[0][:60])
except Exception as e: # noqa: BLE001 - absent modelgen is a skip, not a failure
skip("openapi -> IR", str(e).splitlines()[0][:70])
else:
check("a real spec extracts", check_ir(real) == [], str(check_ir(real)[:2]))
eps = [n for n in real["nodes"] if n["kind"] == "endpoint"]
@@ -812,9 +1021,29 @@ else:
{"table", "column"} <= {n["kind"] for n in real["nodes"]},
"so the ER emitter draws an API's data model without knowing it is one",
)
# modelgen maps an inter-schema `$ref` to the literal string `dict`, so the
# target is gone by the time its fields reach docgen. Without recovering it
# an API's data model draws as disconnected cards — three tables, no keys,
# and nothing saying the relationships were lost. Found by writing a fixture
# that has refs; the borrowed one was never checked for this.
fks = {(e["source"], e["target"]) for e in real["edges"] if e["kind"] == "foreign_key"}
check(
"a $ref between schemas survives as a foreign key",
fks == {("Order", "Customer"), ("OrderLine", "Order")},
f"got {sorted(fks)} — an ERD of an API with no edges is the failure this tool "
"exists to prevent, one level up",
)
check(
"and the ER emitter does draw it",
erd_mod.emit(ops_mod.only_kinds(real, {"table"}), lucid).startswith("<?xml"),
"and the referencing column says what it points at",
[n["attrs"]["references"] for n in real["nodes"]
if n["id"] == "Order.customer"] == ["Customer"],
)
svg = erd_mod.emit(real, lucid)
check("and the ER emitter does draw it", svg.startswith("<?xml"))
check(
"...with the relationships in the drawing",
svg.count("<path") >= len(fks),
f"{svg.count('<path')} paths for {len(fks)} foreign keys",
)
@@ -1324,6 +1553,458 @@ _shutil.rmtree(exp_dir, ignore_errors=True)
# --------------------------------------------------------------------------
tmp.cleanup()
print()
# --------------------------------------------------------------------------
print("\n15. the book — every operation measured at both ends")
def _books():
"""Its own scope, so nothing here can rebind the run's `tmp` (see 3b)."""
larder_mod = __import__(f"{PKG}.book.larder", fromlist=["*"])
book_mod = __import__(f"{PKG}.book", fromlist=["*"])
build_mod = __import__(f"{PKG}.book.build", fromlist=["*"])
checks_mod = __import__(f"{PKG}.book.checks", fromlist=["*"])
Larder = larder_mod.Larder
# -- the input measure, which nothing had before ----------------------
check(
"the unit vocabulary is closed",
larder_mod.UNITS == ("file", "table", "path", "entry", "document"),
f"got {larder_mod.UNITS} — a unit nobody else uses cannot be compared",
)
try:
Larder(kind="x", identity="y", unit="thingy")
refused = False
except ValueError:
refused = True
check("a unit outside it is refused, not recorded", refused)
# `read` derived rather than stored is the one design decision here, and it
# is the one that stops the measure disagreeing with itself.
l = Larder(kind="python", identity="src", unit="file", seen=47)
l.fail("a.py", "syntax"); l.fail("b.py", "unreadable")
check("read is seen minus failed, derived", (l.seen, l.read, len(l.failed)) == (47, 45, 2),
f"seen={l.seen} read={l.read} failed={len(l.failed)}")
check(
"the measure reads as a sentence",
l.line() == "src — 45 files read, 2 failed",
f"got {l.line()!r}",
)
# "2 entrys read, 1 hosts" is how this read before. A measure nobody reads
# is not a measure, so the grammar gets attention it would not otherwise.
e = Larder(kind="usage", identity="s.har", unit="entry", seen=2)
e.extra["hosts"] = 1
check("counts and labels agree", e.line() == "s.har — 2 entries read, 1 host",
f"got {e.line()!r}")
# -- redaction, the one field that can carry a secret -----------------
cases = [
("postgresql://app:hunter2@db:5432/shop", "postgresql://app:***@db:5432/shop"),
("https://api/v1?token=abc123&page=2", "https://api/v1?token=***&page=2"),
("Driver=x;Server=y;Password=hunter2;Uid=app", "Driver=x;Server=y;Password=***;Uid=app"),
("../../station", "../../station"),
]
wrong = [f"{src} -> {larder_mod.redact(src)}" for src, want in cases
if larder_mod.redact(src) != want]
check("credentials are masked and nothing else is", not wrong, "; ".join(wrong))
check(
"the secret is gone, the recognisable part is not",
"hunter2" not in larder_mod.redact(cases[0][0]) and "db:5432/shop" in larder_mod.redact(cases[0][0]),
)
# The scrubber is graded by a second, independent key list in validate.py.
leaked = dict(ir["meta"], larder={
"kind": "db", "identity": "postgresql://app:hunter2@db/shop",
"unit": "table", "seen": 1, "read": 1, "failed": [],
})
problems = ir_validate.check({**ir, "meta": leaked})
check(
"a secret that survived redaction fails validation",
any("identity still contains" in p for p in problems),
f"got {problems[:2]} — a scrubber graded by its own word is not graded",
)
# And the arithmetic, because a book can be assembled without the IR.
lying = dict(ir["meta"], larder={
"kind": "python", "identity": "src", "unit": "file",
"seen": 10, "read": 10, "failed": [{"name": "a.py", "error": "x"}],
})
check(
"a measure that disagrees with itself fails validation",
any("disagrees with itself" in p for p in ir_validate.check({**ir, "meta": lying})),
)
# -- every extractor reports one. The extension contract, with teeth. --
# Looped over the registry rather than a hand-written list, so adding an
# extractor makes this start asking about it.
check(
"every registered extractor is in the relation table",
set(build_mod.EXTRACTORS) == set(book_mod.RELATION),
f"registry {sorted(build_mod.EXTRACTORS)} vs relations {sorted(book_mod.RELATION)}"
" — a new extractor must say how its units show up in the output",
)
unmeasured = []
for kind in sorted(build_mod.EXTRACTORS):
module_name, fn_name, _ = build_mod.EXTRACTORS[kind]
try:
mod = __import__(f"{PKG}{module_name}", fromlist=["*"])
except ImportError as exc:
unmeasured.append(f"{kind}: not importable ({exc})")
continue
fn = getattr(mod, fn_name, None)
if fn is None:
unmeasured.append(f"{kind}: no {fn_name}()")
elif "identity" not in fn.__code__.co_varnames[:fn.__code__.co_argcount]:
unmeasured.append(f"{kind}: {fn_name}() takes no `identity`")
check(
"every registered extractor can name its larder",
not unmeasured,
"; ".join(unmeasured) + " <- an extractor is done when it measures its input",
)
# -- the reconciliation, which is what having both ends is for ---------
b = book_mod.Book("pretend", Larder(kind="python", identity="p", unit="file", seen=10),
"/nonexistent")
b.ir = {"nodes": [{"id": f"m{i}", "kind": "module", "label": "m",
"attrs": {"file": f"m{i}.py"}} for i in range(3)], "edges": []}
lost = [r for r in b.compare() if not r["ok"]]
check(
"input read but not produced is reported as lost",
len(lost) == 1 and lost[0]["id"] == "units-accounted-for",
f"got {[r['id'] for r in b.compare()]}",
)
check("and it says how much", "7 file(s)" in lost[0]["why"], lost[0]["why"][:70])
# A file that failed to parse still gets a node carrying attrs.error, so the
# two populations must be counted apart. Counting them together made
# "2 files read produced 4 modules" pass a check meant to prove nothing was
# dropped — which is how it was written first.
b2 = book_mod.Book("split", Larder(kind="python", identity="p", unit="file", seen=4),
"/nonexistent")
b2.larder.fail("c.py", "syntax"); b2.larder.fail("d.py", "syntax")
b2.ir = {"edges": [], "nodes": [
{"id": "a", "kind": "module", "label": "a", "attrs": {"file": "a.py"}},
{"id": "b", "kind": "module", "label": "b", "attrs": {"file": "b.py"}},
{"id": "c", "kind": "module", "label": "c", "attrs": {"file": "c.py", "error": "syntax"}},
{"id": "d", "kind": "module", "label": "d", "attrs": {"file": "d.py", "error": "syntax"}},
]}
ids = {r["id"]: r for r in b2.compare()}
check(
"read and failed units are reconciled separately",
ids["units-accounted-for"]["claim"] == "2 file(s) read produced 2 module(s)",
f"got {ids['units-accounted-for']['claim']!r}",
)
check(
"an unreadable unit must still appear in the graph",
ids["failures-still-in-the-graph"]["ok"],
"the whole-input form of `an unresolved name becomes an external node`",
)
b2.ir["nodes"] = b2.ir["nodes"][:2] # drop the marked ones
check(
"...and its absence is caught",
not {r["id"]: r for r in b2.compare()}["failures-still-in-the-graph"]["ok"],
"a picture smaller than its source, with nothing saying so",
)
# -- end to end, on a tree with a file that cannot be parsed ----------
fixture = Path(tmp.name) / "book-src"
(fixture / "app").mkdir(parents=True, exist_ok=True)
(fixture / "app" / "__init__.py").write_text('"""An app."""\n')
(fixture / "app" / "models.py").write_text('"""Models."""\n\n\nclass User:\n pass\n')
(fixture / "app" / "broken.py").write_text("def oops(:\n")
out = Path(tmp.name) / "book-out"
built = build_mod.run("python", fixture, out, slug="fixture", quiet=True)
check("a book writes its ledger", (out / "book.json").exists())
ledger = json.loads((out / "book.json").read_text())
step_ids = [s["id"] for s in ledger["steps"]]
check(
"the first and last steps are the two measures",
(step_ids[0], step_ids[-1]) == (book_mod.FIRST, book_mod.LAST),
f"got {step_ids}",
)
check(
"the larder measured the broken file as failed, by name",
[f["name"] for f in ledger["larder"]["failed"]] == ["app/broken.py"],
f"got {ledger['larder']['failed']}",
)
check(
"the book measure counts what came out",
ledger["book"]["nodes"] == len(json.loads((out / "steps" / "ir.json").read_text())["nodes"]),
)
check(
"every intermediate step is still a file on its own",
all((out / s["artifact"]).exists() for s in ledger["steps"] if s.get("artifact")),
"that property is the reason the notebook is a sequence and not one artifact",
)
# The notebook is the sequence, so the measures are its first and last cells
# rather than something the tooling knows and the document does not.
nb = json.loads((out / "notebook.ipynb").read_text())
check(
"the notebook opens with what came in and closes with what came out",
"what came in" in "".join(nb["cells"][0]["source"])
and "what came out" in "".join(nb["cells"][-1]["source"]),
)
check(
"it names the file it could not read",
"app/broken.py" in "".join(nb["cells"][0]["source"]),
)
# A generated notebook is a build artifact and must run where it was built.
# An earlier version imported IPython to *load* an SVG and failed here.
ran, failure = checks_mod._run_cells(nb["cells"], out)
check(f"its {ran} code cells run from the book directory", failure is None, failure or "")
html = (out / "site" / "index.html").read_text()
check(
"the web output shows both ends",
"what came in" in html and "what came out" in html,
"the site is the last step because it is the artifact somebody opens",
)
check(
"and admits when the book is incomplete",
"This book is incomplete" in html and "app/broken.py" in html,
"a clean diagram over 2 of 3 files is a lie by omission",
)
# Reproducible in the strict sense, for the same reason the IR is: a diff
# has to mean a real change.
again = Path(tmp.name) / "book-out-2"
build_mod.run("python", fixture, again, slug="fixture", quiet=True)
differs = [
name for name in ("book.json", "notebook.ipynb", "steps/ir.json", "site/index.html")
if (out / name).read_bytes() != (again / name).read_bytes()
]
check("the same larder builds the same bytes", not differs, f"differ: {differs}")
# -- the book test level ----------------------------------------------
loaded = checks_mod.Loaded(out)
report = checks_mod.Report()
import io as _io2
import contextlib as _ctx2
with _ctx2.redirect_stdout(_io2.StringIO()):
checks_mod.generated(loaded, report)
check(
f"the generated book checks pass on a real book ({report.passed})",
not report.failed,
f"failed: {report.failed}",
)
# Framework and custom living together is the whole point of the level.
(out / "checks.py").write_text(
"def checks(book, check, note, skip):\n"
" note('this project')\n"
" check('models survived', True,\n"
" any(n['id'] == 'app.models' for n in book.ir['nodes']))\n"
" check('wrong on purpose', 9, 0)\n"
)
report2 = checks_mod.Report()
with _ctx2.redirect_stdout(_io2.StringIO()):
checks_mod.custom(checks_mod.Loaded(out), report2)
check(
"a book's own checks run beside the generated ones",
report2.passed == 1 and report2.failed == ["wrong on purpose"],
f"passed={report2.passed} failed={report2.failed}",
)
# -- the architectural rules, still holding ---------------------------
blob = json.dumps(ledger)
# Swept where a visual field could actually land — `larder.extra` is the one
# open bag in the ledger. NOT over the whole document: `by_kind` is keyed by
# IR node kind, and `class` is both a legitimate kind and a member of
# VISUAL_KEYS, so a text sweep reports `"class": 15` as a style leak. The
# first version of this check did exactly that.
leaked_keys = sorted(set(ledger["larder"].get("extra") or {}) & ir_validate.VISUAL_KEYS)
check(
"no visual field reached the ledger's open bag",
not leaked_keys,
f"larder.extra has {leaked_keys} — the ledger records what was read, not how it looks",
)
node_kinds = {n["kind"] for n in json.loads((out / "steps" / "ir.json").read_text())["nodes"]}
check(
"the book measure is keyed by IR kind, nothing else",
set(ledger["book"]["by_kind"]) <= node_kinds,
f"{sorted(set(ledger['book']['by_kind']) - node_kinds)} is in by_kind but is not a kind",
)
check(
"no timestamp reached the ledger",
"generated_at" not in blob,
"a timestamp makes two builds of an unchanged larder differ",
)
check(
"provenance is in meta, never in nodes",
not any("larder" in (n.get("attrs") or {})
for n in json.loads((out / "steps" / "ir.json").read_text())["nodes"]),
"the IR is structure; provenance is meta, the same split that keeps colour out",
)
_books()
# --------------------------------------------------------------------------
print("\n16. standalone — docgen works with nothing above it")
def _standalone():
"""docgen is meant to be copied out of the repo and used on its own.
That claim decays the moment somebody adds a convenient import, and it
decays silently: the suite still passes *inside* the repo. So it is asserted
from the source rather than by copying the folder in CI, which is the same
move as rig's `no host-project references` grep.
"""
ref_mod = __import__(f"{PKG}.reference", fromlist=["*"])
# Third-party modules docgen is allowed to reach for, each optional and each
# with a documented reason. Anything else is a new dependency, and a new
# dependency in a folder meant to be copied around is worth noticing.
ALLOWED = {
"tree_sitter", "tree_sitter_c_sharp", "tree_sitter_typescript",
"tree_sitter_python", # extractors/code.py, optional
"lxml", # style/extract.py, optional
"yaml", # extractors/openapi.py, optional
"networkx", # lab/ only
"modelgen", # the one seam — see reference.py
}
# The one module allowed to import `modelgen`, and the one allowed to go
# looking above the package. Two names, so a third is a decision.
SEAM_IMPORTER = "extractors/openapi.py"
SEAM_RESOLVER = "reference.py"
stdlib = set(sys.stdlib_module_names)
own = {PKG}
files = sorted(p for p in HERE.rglob("*.py") if "__pycache__" not in p.parts)
foreign, seam_leaks = [], []
for path in files:
rel = path.relative_to(HERE).as_posix()
try:
tree = ast.parse(path.read_text())
except SyntaxError:
continue
for node in ast.walk(tree):
if isinstance(node, ast.Import):
names = [a.name.split(".")[0] for a in node.names]
elif isinstance(node, ast.ImportFrom):
if node.level: # relative — inside the package by definition
continue
names = [node.module.split(".")[0]] if node.module else []
else:
continue
for name in names:
if name in stdlib or name in own:
continue
if name not in ALLOWED:
foreign.append(f"{rel}: {name}")
elif name == "modelgen" and rel != SEAM_IMPORTER:
seam_leaks.append(f"{rel}: {name}")
check(
"docgen imports the stdlib, itself, and a short allow-list",
not foreign,
"; ".join(foreign) + " <- a new dependency in a folder meant to be copied",
)
check(
f"only {SEAM_IMPORTER} imports modelgen",
not seam_leaks,
"; ".join(seam_leaks) + " <- one seam is a seam; two is a dependency",
)
# The repo path is named in exactly one place, so pointing docgen at a
# reference repo is one change rather than a search.
hardcoded = []
for path in files:
rel = path.relative_to(HERE).as_posix()
if rel in (SEAM_RESOLVER, "selftest.py"):
continue
text = path.read_text()
for line in text.splitlines():
if "station/tools" in line and not line.lstrip().startswith("#") \
and '"""' not in line and "station/tools" not in line.split("#")[-1]:
hardcoded.append(f"{rel}: {line.strip()[:60]}")
check(
f"the repo layout is known only to {SEAM_RESOLVER}",
not hardcoded,
"; ".join(hardcoded),
)
# Resolution order is the thing a standalone user depends on, so it is
# asserted rather than described: an explicit path must win over the walk,
# or setting the variable would appear to do nothing inside the repo.
import os as _os
saved = _os.environ.get(ref_mod.ENV_VAR)
try:
fake = Path(tmp.name) / "fake-repo"
(fake / "station" / "tools" / "modelgen").mkdir(parents=True, exist_ok=True)
_os.environ[ref_mod.ENV_VAR] = str(fake)
check(
"an explicit path wins over walking up",
ref_mod.root() == fake,
f"got {ref_mod.root()} — setting the variable inside the repo would do nothing",
)
check(
"and it says how it resolved, not just whether",
ref_mod.ENV_VAR in ref_mod.describe(),
ref_mod.describe(),
)
_os.environ.pop(ref_mod.ENV_VAR)
# Both outcomes are correct and which one applies is the fact worth
# printing: inside the repo, no configuration should be needed; copied
# out, there is nothing to find and saying so is the point. Asserting
# the in-repo answer unconditionally failed the moment the folder was
# actually copied out, which is how this was found.
walked = ref_mod.root()
if walked is None:
skip("the in-place case", "standalone — nothing above this folder, as intended")
else:
# What root() promises is a directory holding station/tools — not a
# particular depth above docgen, which is an assumption about where
# somebody chose to put it.
check(
"inside a repo it needs no configuration",
(walked / "station" / "tools").is_dir(),
f"walked to {walked}, which has no station/tools",
)
finally:
if saved is None:
_os.environ.pop(ref_mod.ENV_VAR, None)
else:
_os.environ[ref_mod.ENV_VAR] = saved
# Absent is a normal state. Four of the five extractors never touch it.
check(
"a missing reference is reported, not raised at import time",
ref_mod.missing("x", "y").__class__ is ImportError
and ref_mod.ENV_VAR in str(ref_mod.missing("x", "y")),
"the error has to name the variable to set",
)
# Everything a book writes has to stay inside the book directory, or a
# standalone user cannot tell what docgen created.
fixture = Path(tmp.name) / "sa-src"
fixture.mkdir(exist_ok=True)
(fixture / "one.py").write_text('"""One."""\n\n\nclass A:\n pass\n')
out = Path(tmp.name) / "sa-book"
build_mod = __import__(f"{PKG}.book.build", fromlist=["*"])
build_mod.run("python", fixture, out, slug="sa", quiet=True)
strays = [p.name for p in fixture.iterdir() if p.name != "one.py"]
check(
"a book writes only inside its own output directory",
not strays,
f"left {strays} in the source tree",
)
check(
"the shipped OpenAPI fixture travels with docgen",
(HERE / "fixtures" / "orders.yaml").exists(),
"a test's fixtures belong to the test, not to a sibling project",
)
_standalone()
print(f"{len(PASS)} passed, {len(FAIL)} failed, {len(SKIP)} skipped")
if FAIL:
print("\nfailed:")