new conventions
This commit is contained in:
@@ -3,13 +3,18 @@
|
|||||||
# Derived from where this file sits, so the folder can be copied anywhere and
|
# 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.
|
# renamed and still work. The logic lives in the Python, never here.
|
||||||
#
|
#
|
||||||
# make check prove it, on a tree it builds itself
|
# make book SRC=../station the whole operation, measured at both ends
|
||||||
# make ir SRC=../station extract -> out/ir.json
|
# 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 graph out/ir.json -> out/graph.svg
|
||||||
# make index out/ir.json -> out/index.md
|
# 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
|
# 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:
|
# The pipeline is three commands and they compose, which is the point:
|
||||||
#
|
#
|
||||||
# python3 -m docgen.extractors.python --root SRC -o ir.json
|
# python3 -m docgen.extractors.python --root SRC -o ir.json
|
||||||
@@ -25,14 +30,24 @@ RUN := PYTHONPATH=$(PARENT) $(PY) -m
|
|||||||
OUT ?= $(HERE)/out
|
OUT ?= $(HERE)/out
|
||||||
SRC ?=
|
SRC ?=
|
||||||
SCHEMA ?=
|
SCHEMA ?=
|
||||||
|
OPENAPI ?=
|
||||||
|
HAR ?=
|
||||||
STYLE ?= lucid
|
STYLE ?= lucid
|
||||||
THEME ?=
|
THEME ?=
|
||||||
DEPTH ?= 2
|
DEPTH ?= 2
|
||||||
SCALE ?= 0.55
|
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))
|
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
|
help: ## List every target
|
||||||
@echo "docgen — static analysis of a tree, and the artifacts that fall out of it"
|
@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
|
||||||
@echo " SRC=/path/to/tree what to read OUT=/path where output goes"
|
@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 " 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"
|
@echo " DEPTH=2 how deep to draw"
|
||||||
|
@echo
|
||||||
check: ## Prove the pipeline, offline, needing nothing installed
|
@echo " Three levels of test, by what they assert about:"
|
||||||
@$(PY) $(HERE)/selftest.py
|
@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
|
ir: ## Extract SRC into OUT/ir.json
|
||||||
@test -n "$(SRC)" || { echo "Error: set SRC=/path/to/tree" >&2; exit 1; }
|
@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)/index.md
|
||||||
@$(RUN) $(PKG).emitters index $(OUT)/ir.json -o $(OUT)/sidebar.json
|
@$(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
|
self: ## docgen's book of the widest tree it can see, then check it
|
||||||
@$(MAKE) --no-print-directory ir SRC=$(PARENT)/.. OUT=$(OUT)
|
@$(eval SELF_SRC := $(shell PYTHONPATH=$(PARENT) $(PY) -c "from $(PKG) import reference; \
|
||||||
@$(MAKE) --no-print-directory index OUT=$(OUT)
|
r = reference.root(); print(r if r else '$(HERE)')"))
|
||||||
@$(MAKE) --no-print-directory graph OUT=$(OUT)
|
@echo " self-hosting on $(SELF_SRC)"
|
||||||
@$(MAKE) --no-print-directory site OUT=$(OUT)
|
@$(MAKE) --no-print-directory book SRC=$(SELF_SRC) SLUG=self \
|
||||||
@$(MAKE) --no-print-directory minimap OUT=$(OUT)
|
BOOK=$(OUT)/book/self OUT=$(OUT)
|
||||||
@$(MAKE) --no-print-directory explore OUT=$(OUT)
|
|
||||||
@echo
|
@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
|
doctor: ## Report whether this machine can run it
|
||||||
@printf 'python : '; $(PY) --version 2>&1 || echo MISSING
|
@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 '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 '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 '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 \
|
@printf 'styles : '; $(RUN) $(PKG).style 2>/dev/null \
|
||||||
|| $(RUN) $(PKG) 2>/dev/null \
|
|| $(RUN) $(PKG) 2>/dev/null \
|
||||||
|| PYTHONPATH=$(PARENT) $(PY) -c "from $(PKG).style import Style; print(', '.join(Style.available()))"
|
|| PYTHONPATH=$(PARENT) $(PY) -c "from $(PKG).style import Style; print(', '.join(Style.available()))"
|
||||||
|
|||||||
315
soleprint/atlas2/docgen/book/__init__.py
Normal file
315
soleprint/atlas2/docgen/book/__init__.py
Normal 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())
|
||||||
80
soleprint/atlas2/docgen/book/__main__.py
Normal file
80
soleprint/atlas2/docgen/book/__main__.py
Normal 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())
|
||||||
350
soleprint/atlas2/docgen/book/build.py
Normal file
350
soleprint/atlas2/docgen/book/build.py
Normal 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
|
||||||
305
soleprint/atlas2/docgen/book/checks.py
Normal file
305
soleprint/atlas2/docgen/book/checks.py
Normal 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())
|
||||||
195
soleprint/atlas2/docgen/book/larder.py
Normal file
195
soleprint/atlas2/docgen/book/larder.py
Normal 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 |
@@ -1,13 +1,14 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
<?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">
|
<svg xmlns="http://www.w3.org/2000/svg" width="886pt" height="2251pt" viewBox="0 0 886 2251">
|
||||||
<rect width="886" height="1630" fill="#0a0a0a"/>
|
<rect width="886" height="2251" 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="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="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="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="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="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="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="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="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="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="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="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="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>
|
<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="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="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>
|
<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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="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="28" y="325" 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="538" y="325" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen</text>
|
||||||
<text x="28" y="450" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen.extractors</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="450" 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="1155" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen</text>
|
<text x="28" y="1549" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen.extractors</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="538" y="1549" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen.book</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="1762" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen.extractors.python</text>
|
||||||
<text x="28" y="1312" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen.ir</text>
|
<text x="292" y="1762" 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="556" y="1762" 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="738" y="1762" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen.style</text>
|
||||||
<text x="410" y="1312" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen.lab</text>
|
<text x="28" y="2043" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen.style</text>
|
||||||
<text x="510" y="1312" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen.notebook</text>
|
<text x="128" y="2043" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">(root)</text>
|
||||||
<text x="28" y="259" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">site</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="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="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>
|
<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="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="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="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="28" y="377" 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="110" y="366" 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="192" y="366" 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="274" y="364" 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="356" y="362" 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="438" y="358" 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="538" y="1347" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">selftest</text>
|
||||||
<text x="620" y="421" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">usage</text>
|
<text x="620" y="498" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">book</text>
|
||||||
<text x="702" y="370" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">db</text>
|
<text x="702" y="437" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">style</text>
|
||||||
<text x="784" y="359" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">openapi</text>
|
<text x="784" y="394" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">reference</text>
|
||||||
<text x="28" y="486" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">code_main</text>
|
<text x="28" y="1399" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">ops</text>
|
||||||
<text x="110" y="482" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">usage_main</text>
|
<text x="110" y="1399" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">notebook</text>
|
||||||
<text x="192" y="482" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">db_main</text>
|
<text x="192" y="1399" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">emitters</text>
|
||||||
<text x="274" y="480" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">openapi_mai</text>
|
<text x="274" y="1399" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">lab</text>
|
||||||
<text x="356" y="480" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">python</text>
|
<text x="356" y="1399" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">ir</text>
|
||||||
<text x="438" y="480" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">__main__</text>
|
<text x="438" y="1399" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">extractors</text>
|
||||||
<text x="538" y="1132" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">selftest</text>
|
<text x="538" y="1526" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">code</text>
|
||||||
<text x="620" y="556" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">style</text>
|
<text x="620" y="1520" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">usage</text>
|
||||||
<text x="702" y="480" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">ops</text>
|
<text x="702" y="1480" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">openapi</text>
|
||||||
<text x="784" y="480" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">notebook</text>
|
<text x="784" y="1466" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">db</text>
|
||||||
<text x="28" y="1184" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">emitters</text>
|
<text x="28" y="1584" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">code_main</text>
|
||||||
<text x="110" y="1184" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">lab</text>
|
<text x="110" y="1582" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">python</text>
|
||||||
<text x="192" y="1184" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">ir</text>
|
<text x="192" y="1580" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">usage_main</text>
|
||||||
<text x="274" y="1184" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">extractors</text>
|
<text x="274" y="1580" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">db_main</text>
|
||||||
<text x="374" y="1288" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">collect</text>
|
<text x="356" y="1579" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">openapi_mai</text>
|
||||||
<text x="456" y="1252" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">resolve</text>
|
<text x="438" y="1578" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">__main__</text>
|
||||||
<text x="538" y="1189" 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="638" y="1288" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">validate</text>
|
<text x="620" y="1717" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">checks</text>
|
||||||
<text x="720" y="1244" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">model</text>
|
<text x="702" y="1662" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">larder</text>
|
||||||
<text x="28" y="1340" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">__main__</text>
|
<text x="784" y="1604" 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="28" y="1896" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">collect</text>
|
||||||
<text x="210" y="1378" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">__main__</text>
|
<text x="110" y="1870" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">resolve</text>
|
||||||
<text x="310" y="1340" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">docgen</text>
|
<text x="192" y="1796" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">__main__</text>
|
||||||
<text x="410" y="1402" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">pg_probe</text>
|
<text x="292" y="1934" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">validate</text>
|
||||||
<text x="510" y="1458" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">spec</text>
|
<text x="374" y="1860" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">model</text>
|
||||||
<rect x="28" y="1609.0" width="9" height="9" rx="2" fill="#1a1a1a"/>
|
<text x="456" y="1792" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">__main__</text>
|
||||||
<text x="41" y="1617.0" font-family="Helvetica,sans-Serif" font-size="9" fill="#666666">module</text>
|
<text x="556" y="2020" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">filter</text>
|
||||||
<rect x="86" y="1609.0" width="9" height="9" rx="2" fill="#1d4ed8"/>
|
<text x="638" y="1828" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">__main__</text>
|
||||||
<text x="99" y="1617.0" font-family="Helvetica,sans-Serif" font-size="9" fill="#666666">class</text>
|
<text x="738" y="1888" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">extract</text>
|
||||||
<rect x="138" y="1609.0" width="9" height="9" rx="2" fill="#d4a574"/>
|
<text x="28" y="2164" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">tokens</text>
|
||||||
<text x="151" y="1617.0" font-family="Helvetica,sans-Serif" font-size="9" fill="#666666">interface</text>
|
<text x="128" y="2072" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">docgen</text>
|
||||||
<rect x="214" y="1609.0" width="9" height="9" rx="2" fill="#15803d"/>
|
<text x="228" y="2133" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">pg_probe</text>
|
||||||
<text x="227" y="1617.0" font-family="Helvetica,sans-Serif" font-size="9" fill="#666666">function</text>
|
<text x="328" y="2190" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">spec</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>
|
<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>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 73 KiB |
@@ -22,6 +22,7 @@
|
|||||||
|
|
||||||
<div class="group">Architecture</div>
|
<div class="group">Architecture</div>
|
||||||
<a href="#layers">The three concerns</a>
|
<a href="#layers">The three concerns</a>
|
||||||
|
<a href="#book">The book</a>
|
||||||
<a href="#ir">The IR</a>
|
<a href="#ir">The IR</a>
|
||||||
<a href="#shape">Shape decides the drawing</a>
|
<a href="#shape">Shape decides the drawing</a>
|
||||||
|
|
||||||
@@ -39,6 +40,7 @@
|
|||||||
<a href="#style">Style & colour</a>
|
<a href="#style">Style & colour</a>
|
||||||
|
|
||||||
<div class="group">Reference</div>
|
<div class="group">Reference</div>
|
||||||
|
<a href="#standalone">Standalone</a>
|
||||||
<a href="#commands">Commands</a>
|
<a href="#commands">Commands</a>
|
||||||
<a href="#deps">Dependencies</a>
|
<a href="#deps">Dependencies</a>
|
||||||
<a href="#testing">Testing</a>
|
<a href="#testing">Testing</a>
|
||||||
@@ -107,7 +109,23 @@
|
|||||||
<!-- ─────────────────────────────────────────────────────────────── -->
|
<!-- ─────────────────────────────────────────────────────────────── -->
|
||||||
<h2 id="quick">Five minutes</h2>
|
<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>
|
<pre><code><span class="c"># 1. read something</span>
|
||||||
python3 -m docgen.extractors.python --root ../station/tools/histgen -o ir.json
|
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>
|
<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 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 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>
|
<p>
|
||||||
Everything is offline and self-contained. No server, no CDN, no build step —
|
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>
|
</figcaption>
|
||||||
</figure>
|
</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/<slug>/
|
||||||
|
├── 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>
|
<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.
|
between a representative leaf and clip the line at the cluster border.
|
||||||
</p>
|
</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>
|
<h2 id="commands">Commands</h2>
|
||||||
|
|
||||||
<h3>Make</h3>
|
<h3>Make</h3>
|
||||||
<table>
|
<table>
|
||||||
<tr><th>target</th><th>does</th></tr>
|
<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 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 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>
|
<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 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 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 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>
|
</table>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
Variables: <code>SRC</code>, <code>OUT</code>, <code>SCHEMA</code>,
|
Variables: <code>SRC</code>, <code>OUT</code>, <code>SCHEMA</code>,
|
||||||
<code>STYLE</code>, <code>THEME</code>, <code>SCALE</code>, <code>DEPTH</code>,
|
<code>OPENAPI</code>, <code>HAR</code>, <code>BOOK</code>, <code>SLUG</code>,
|
||||||
<code>PY</code>. The Makefile derives its own package name from where it sits,
|
<code>READER</code>, <code>OVERLAY</code>, <code>STYLE</code>,
|
||||||
so the folder can be copied anywhere and renamed and still work.
|
<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>
|
</p>
|
||||||
|
|
||||||
<h3>Modules</h3>
|
<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 code --root SRC -o ir.json
|
||||||
python3 -m docgen.extractors db --schema schema.json -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
|
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>
|
<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>
|
<p>
|
||||||
The core is <strong>standard library only</strong>. Everything else is optional
|
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
|
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>
|
<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=<dir></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>
|
<p>
|
||||||
Four of those are the <em>design</em> rather than regressions, and they are the
|
Of docgen's own checks, four assert the <em>architecture</em> rather than guard
|
||||||
ones to keep if anything is ever cut:
|
a regression, and they are the ones to keep if anything is ever cut:
|
||||||
</p>
|
</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li><b>No visual field reaches the IR</b> — extractors cannot decide appearance.</li>
|
<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>
|
<p>
|
||||||
Self-hosting is the honest end-to-end check, and it is where the real bugs came
|
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
|
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
|
reached. <code>make self</code> builds docgen's book <em>of soleprint</em> and
|
||||||
index does not read like the system, something is wrong.
|
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>
|
</p>
|
||||||
|
|
||||||
<!-- ─────────────────────────────────────────────────────────────── -->
|
<!-- ─────────────────────────────────────────────────────────────── -->
|
||||||
|
|||||||
@@ -248,9 +248,84 @@ body {
|
|||||||
.figure a:hover { border-color: var(--accent); }
|
.figure a:hover { border-color: var(--accent); }
|
||||||
.figure img { display: block; width: 100%; height: auto; }
|
.figure img { display: block; width: 100%; height: auto; }
|
||||||
.figure figcaption { color: var(--dim); font-size: 10px; margin-top: .4rem; }
|
.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:
|
def _slots(style) -> dict:
|
||||||
s = style.slot
|
s = style.slot
|
||||||
return {
|
return {
|
||||||
@@ -308,8 +383,15 @@ def _sections(items: list, depth: int = 0) -> str:
|
|||||||
return "".join(out)
|
return "".join(out)
|
||||||
|
|
||||||
|
|
||||||
def emit(ir: dict, style, *, graph: str | None = None, title: str = "") -> dict:
|
def emit(ir: dict, style, *, graph: str | None = None, title: str = "",
|
||||||
"""IR + Style -> {filename: text}. Write them next to each other."""
|
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
|
from .index import to_sidebar
|
||||||
|
|
||||||
meta = ir.get("meta", {})
|
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
|
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]))
|
summary = " · ".join(f"{v} {k}" for k, v in sorted(counts.items(), key=lambda kv: -kv[1]))
|
||||||
|
|
||||||
|
ledger = _ledger(book) if book else ""
|
||||||
|
|
||||||
figure = ""
|
figure = ""
|
||||||
if graph:
|
if graph:
|
||||||
figure = (
|
figure = (
|
||||||
@@ -360,6 +444,7 @@ def emit(ir: dict, style, *, graph: str | None = None, title: str = "") -> dict:
|
|||||||
<h1>{escape(name)}</h1>
|
<h1>{escape(name)}</h1>
|
||||||
<p class="lede">Generated from <code>{escape(meta.get("source", "?"))}</code> ·
|
<p class="lede">Generated from <code>{escape(meta.get("source", "?"))}</code> ·
|
||||||
{escape(summary)}. Regenerated, not edited.</p>
|
{escape(summary)}. Regenerated, not edited.</p>
|
||||||
|
{ledger}
|
||||||
{figure}
|
{figure}
|
||||||
{_sections(side["items"])}
|
{_sections(side["items"])}
|
||||||
{ext_html}
|
{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 = Path(out_dir)
|
||||||
out_dir.mkdir(parents=True, exist_ok=True)
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
written = []
|
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 = out_dir / name
|
||||||
path.write_text(text)
|
path.write_text(text)
|
||||||
written.append(path)
|
written.append(path)
|
||||||
|
|||||||
@@ -204,7 +204,8 @@ def _count_errors(node) -> int:
|
|||||||
return n
|
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."""
|
"""Walk a tree and map its structure. No edges — see the module docstring."""
|
||||||
root = Path(root).resolve()
|
root = Path(root).resolve()
|
||||||
if not root.is_dir():
|
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",
|
skip = {"__pycache__", ".git", ".venv", "venv", "node_modules", "dist", "build",
|
||||||
"bin", "obj", *exclude}
|
"bin", "obj", *exclude}
|
||||||
|
|
||||||
|
from ..book.larder import Larder
|
||||||
|
|
||||||
g = Graph(Meta(source=source, root=root.name))
|
g = Graph(Meta(source=source, root=root.name))
|
||||||
packages: set[str] = set()
|
packages: set[str] = set()
|
||||||
files = [
|
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)
|
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:
|
for path in files:
|
||||||
try:
|
try:
|
||||||
module, decls = extract_file(path, root)
|
module, decls = extract_file(path, root)
|
||||||
@@ -229,6 +240,7 @@ def extract(root, suffixes=None, exclude=(), source: str = "code") -> Graph:
|
|||||||
raise
|
raise
|
||||||
except Exception as e: # noqa: BLE001 - one bad file must not cost the run
|
except Exception as e: # noqa: BLE001 - one bad file must not cost the run
|
||||||
rel = path.relative_to(root)
|
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,
|
g.node(".".join([*rel.parts[:-1], rel.stem]), "module", rel.stem,
|
||||||
attrs={"file": rel.as_posix(), "error": f"{type(e).__name__}: {e}"})
|
attrs={"file": rel.as_posix(), "error": f"{type(e).__name__}: {e}"})
|
||||||
continue
|
continue
|
||||||
@@ -239,6 +251,7 @@ def extract(root, suffixes=None, exclude=(), source: str = "code") -> Graph:
|
|||||||
attrs = {"file": module["file"], "lines": module["lines"]}
|
attrs = {"file": module["file"], "lines": module["lines"]}
|
||||||
if module["errors"]:
|
if module["errors"]:
|
||||||
attrs["error"] = f"{module['errors']} unparsed region(s)"
|
attrs["error"] = f"{module['errors']} unparsed region(s)"
|
||||||
|
unparsed += 1
|
||||||
g.node(module["id"], "module", module["label"], parent=parent, attrs=attrs)
|
g.node(module["id"], "module", module["label"], parent=parent, attrs=attrs)
|
||||||
|
|
||||||
for d in decls:
|
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,
|
parent=".".join(parts[: i - 1]) or None,
|
||||||
attrs={"lines": 0, "directory": True})
|
attrs={"lines": 0, "directory": True})
|
||||||
known.add(pid)
|
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
|
return g
|
||||||
|
|||||||
@@ -37,7 +37,8 @@ from pathlib import Path
|
|||||||
from ..ir import Graph, Meta
|
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.
|
"""A graphgen-compatible schema dict -> IR.
|
||||||
|
|
||||||
Accepts both spellings of the contract: the on-disk `schema.json` form,
|
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
|
`graphgen.schema.load_graph_schema` returns, where it is a list. They are
|
||||||
the same data and callers have both.
|
the same data and callers have both.
|
||||||
"""
|
"""
|
||||||
|
from ..book.larder import Larder
|
||||||
|
|
||||||
g = Graph(Meta(source=source, root=root))
|
g = Graph(Meta(source=source, root=root))
|
||||||
models = data.get("models", {})
|
models = data.get("models", {})
|
||||||
listed = models.values() if isinstance(models, dict) else 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"
|
kind = "references" if rel.get("type") == "M2M" else "foreign_key"
|
||||||
g.edge(src, dst, kind, attrs={"label": rel.get("from_field", "")})
|
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)
|
_dedupe(g)
|
||||||
for missing in sorted(
|
for missing in sorted(
|
||||||
{e.target for e in g.edges} - {n.id for n in g.nodes}
|
{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
|
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."""
|
"""Read a schema.json from disk."""
|
||||||
path = Path(schema_path)
|
path = Path(schema_path)
|
||||||
data = json.loads(path.read_text())
|
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))
|
||||||
|
|||||||
@@ -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
|
only_kinds(ir, {"endpoint"}) -> the surface, as a notebook
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from ..ir import Graph, Meta
|
from ..ir import Graph, Meta
|
||||||
|
|
||||||
|
|
||||||
def _modelgen():
|
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
|
Imported lazily and by path rather than as a hard dependency. This is the
|
||||||
to atlas and may depend on a station tool, but it should not fail to import
|
**only** seam between docgen and the wider repo — see `reference.py`, which
|
||||||
because one is missing.
|
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()
|
from .. import reference
|
||||||
for parent in here.parents:
|
|
||||||
tools = parent / "station" / "tools"
|
if reference.on_path() is None:
|
||||||
if (tools / "modelgen").is_dir():
|
raise reference.missing(
|
||||||
if str(tools) not in sys.path:
|
"modelgen",
|
||||||
sys.path.insert(0, str(tools))
|
"OpenAPI is read through station/tools/modelgen/loader/extract/"
|
||||||
from modelgen.loader.extract.openapi import OpenAPIExtractor
|
"openapi.py, which parses the spec and resolves $ref",
|
||||||
|
)
|
||||||
return OpenAPIExtractor
|
try:
|
||||||
raise ImportError(
|
from modelgen.loader.extract.openapi import OpenAPIExtractor
|
||||||
"modelgen not found — docgen reads OpenAPI through "
|
except ImportError as e:
|
||||||
"station/tools/modelgen/loader/extract/openapi.py, which parses the spec "
|
raise reference.missing(
|
||||||
"and resolves $ref. It is not reimplemented here."
|
"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:
|
def _type_name(hint) -> str:
|
||||||
@@ -61,8 +67,55 @@ def _type_name(hint) -> str:
|
|||||||
return getattr(hint, "__name__", str(hint))
|
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."""
|
"""An OpenAPI file -> IR."""
|
||||||
|
from ..book.larder import Larder
|
||||||
|
|
||||||
OpenAPIExtractor = _modelgen()
|
OpenAPIExtractor = _modelgen()
|
||||||
path = Path(spec_path)
|
path = Path(spec_path)
|
||||||
extractor = OpenAPIExtractor(path)
|
extractor = OpenAPIExtractor(path)
|
||||||
@@ -71,6 +124,7 @@ def extract(spec_path, source: str = "openapi") -> Graph:
|
|||||||
|
|
||||||
g = Graph(Meta(source=source, root=path.name))
|
g = Graph(Meta(source=source, root=path.name))
|
||||||
known = {m.name for m in models}
|
known = {m.name for m in models}
|
||||||
|
refs = _refs(path)
|
||||||
|
|
||||||
for model in models:
|
for model in models:
|
||||||
attrs = {}
|
attrs = {}
|
||||||
@@ -83,7 +137,9 @@ def extract(spec_path, source: str = "openapi") -> Graph:
|
|||||||
a["nullable"] = True
|
a["nullable"] = True
|
||||||
if field.name in ("id", "uuid"):
|
if field.name in ("id", "uuid"):
|
||||||
a["pk"] = True
|
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:
|
if target in known and target != model.name:
|
||||||
a["references"] = target
|
a["references"] = target
|
||||||
g.edge(model.name, target, "foreign_key", attrs={"label": field.name})
|
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:
|
if getattr(e, "response_model", None) in known:
|
||||||
g.edge(eid, e.response_model, "returns")
|
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
|
return g
|
||||||
|
|||||||
@@ -17,13 +17,20 @@ from .collect import collect
|
|||||||
from .resolve import to_ir
|
from .resolve import to_ir
|
||||||
|
|
||||||
|
|
||||||
def extract(root, exclude=(), source="python"):
|
def extract(root, exclude=(), source="python", identity=None):
|
||||||
"""Walk `root`, return an IR Graph. Never raises on a bad file."""
|
"""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()
|
root = Path(root).resolve()
|
||||||
if not root.is_dir():
|
if not root.is_dir():
|
||||||
raise NotADirectoryError(f"not a directory: {root}")
|
raise NotADirectoryError(f"not a directory: {root}")
|
||||||
modules = collect(root, exclude=exclude)
|
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"]
|
__all__ = ["extract", "collect", "to_ir"]
|
||||||
|
|||||||
@@ -64,9 +64,30 @@ def _resolve(name: str, module: Module, known: set[str]) -> str | None:
|
|||||||
return 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."""
|
"""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 ------------------------------------------------------
|
# -- nodes we own ------------------------------------------------------
|
||||||
known: set[str] = set()
|
known: set[str] = set()
|
||||||
|
|||||||
@@ -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
|
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."""
|
"""A HAR file -> IR of what was actually called."""
|
||||||
|
from ..book.larder import Larder
|
||||||
|
|
||||||
path = Path(har_path)
|
path = Path(har_path)
|
||||||
har = json.loads(path.read_text())
|
har = json.loads(path.read_text())
|
||||||
entries = (har.get("log") or {}).get("entries") or []
|
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
|
calls = [] # (key, kind, facts) in order
|
||||||
seen: dict[str, dict] = {}
|
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 {}
|
req = entry.get("request") or {}
|
||||||
res = entry.get("response") or {}
|
res = entry.get("response") or {}
|
||||||
method = (req.get("method") or "GET").upper()
|
method = (req.get("method") or "GET").upper()
|
||||||
url = req.get("url") or ""
|
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)
|
split = urlsplit(url)
|
||||||
templated, id_formats = _template(split.path or "/")
|
templated, id_formats = _template(split.path or "/")
|
||||||
body = _body(entry)
|
body = _body(entry)
|
||||||
@@ -199,6 +216,10 @@ def extract(har_path, source: str = "usage") -> Graph:
|
|||||||
|
|
||||||
g = Graph(Meta(source=source, root=path.name))
|
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 = {}
|
order = {}
|
||||||
for i, key in enumerate(calls):
|
for i, key in enumerate(calls):
|
||||||
order.setdefault(key, i)
|
order.setdefault(key, i)
|
||||||
|
|||||||
154
soleprint/atlas2/docgen/fixtures/orders.yaml
Normal file
154
soleprint/atlas2/docgen/fixtures/orders.yaml
Normal 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
|
||||||
@@ -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.
|
`cls`, `shape` and `style`, and that is exactly the mistake this replaces.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from copy import deepcopy
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -37,20 +38,37 @@ class Meta:
|
|||||||
extractions of an unchanged tree differ, which destroys the diff emitter's
|
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,
|
only useful property. Set it when the run time is the fact being recorded,
|
||||||
not by habit.
|
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
|
source: str
|
||||||
root: str
|
root: str
|
||||||
schema_version: str = SCHEMA_VERSION
|
schema_version: str = SCHEMA_VERSION
|
||||||
generated_at: str | None = None
|
generated_at: str | None = None
|
||||||
|
larder: dict | None = None
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
def to_dict(self) -> dict:
|
||||||
return {
|
out = {
|
||||||
"source": self.source,
|
"source": self.source,
|
||||||
"root": self.root,
|
"root": self.root,
|
||||||
"schema_version": self.schema_version,
|
"schema_version": self.schema_version,
|
||||||
"generated_at": self.generated_at,
|
"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
|
@dataclass
|
||||||
|
|||||||
@@ -28,6 +28,33 @@
|
|||||||
"generated_at": {
|
"generated_at": {
|
||||||
"type": ["string", "null"],
|
"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."
|
"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." }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ that produce a broken diagram.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
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):
|
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.append(f"meta.schema_version must be a string, got {type(version).__name__}")
|
||||||
|
|
||||||
|
problems.extend(_check_larder(data["meta"].get("larder"), meta_schema))
|
||||||
|
|
||||||
# -- nodes ------------------------------------------------------------
|
# -- nodes ------------------------------------------------------------
|
||||||
node_required, node_allowed = _fields(schema, "nodes")
|
node_required, node_allowed = _fields(schema, "nodes")
|
||||||
seen: set[str] = set()
|
seen: set[str] = set()
|
||||||
@@ -169,6 +172,79 @@ def check(data: dict, *, strict_visual: bool = True) -> list[str]:
|
|||||||
return problems
|
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:
|
def validate(data: dict, **kw) -> dict:
|
||||||
"""check(), but raises. For use at a boundary where carrying on is wrong."""
|
"""check(), but raises. For use at a boundary where carrying on is wrong."""
|
||||||
problems = check(data, **kw)
|
problems = check(data, **kw)
|
||||||
|
|||||||
107
soleprint/atlas2/docgen/reference.py
Normal file
107
soleprint/atlas2/docgen/reference.py
Normal 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"
|
||||||
|
)
|
||||||
@@ -52,6 +52,8 @@ exp_mod = __import__(f"{PKG}.emitters.explore", fromlist=["*"])
|
|||||||
spec_mod = __import__(f"{PKG}.notebook", fromlist=["*"])
|
spec_mod = __import__(f"{PKG}.notebook", fromlist=["*"])
|
||||||
db_ex = __import__(f"{PKG}.extractors.db", fromlist=["*"])
|
db_ex = __import__(f"{PKG}.extractors.db", fromlist=["*"])
|
||||||
usage_ex = __import__(f"{PKG}.extractors.usage", 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
|
check_ir = ir_mod.check
|
||||||
Style, StyleError = style_mod.Style, style_mod.StyleError
|
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")
|
print("\n4. emitters")
|
||||||
|
|
||||||
@@ -794,15 +998,20 @@ check(
|
|||||||
"the server assigns it",
|
"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:
|
try:
|
||||||
oa = __import__(f"{PKG}.extractors.openapi", fromlist=["*"])
|
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():
|
if not spec.exists():
|
||||||
raise ImportError("no petstore fixture")
|
raise ImportError(f"fixture missing: {spec}")
|
||||||
real = oa.extract(spec).to_dict()
|
real = oa.extract(spec).to_dict()
|
||||||
except (ImportError, Exception) as e: # noqa: BLE001
|
except Exception as e: # noqa: BLE001 - absent modelgen is a skip, not a failure
|
||||||
skip("openapi -> IR", str(e).splitlines()[0][:60])
|
skip("openapi -> IR", str(e).splitlines()[0][:70])
|
||||||
else:
|
else:
|
||||||
check("a real spec extracts", check_ir(real) == [], str(check_ir(real)[:2]))
|
check("a real spec extracts", check_ir(real) == [], str(check_ir(real)[:2]))
|
||||||
eps = [n for n in real["nodes"] if n["kind"] == "endpoint"]
|
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"]},
|
{"table", "column"} <= {n["kind"] for n in real["nodes"]},
|
||||||
"so the ER emitter draws an API's data model without knowing it is one",
|
"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(
|
check(
|
||||||
"and the ER emitter does draw it",
|
"a $ref between schemas survives as a foreign key",
|
||||||
erd_mod.emit(ops_mod.only_kinds(real, {"table"}), lucid).startswith("<?xml"),
|
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 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()
|
tmp.cleanup()
|
||||||
print()
|
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")
|
print(f"{len(PASS)} passed, {len(FAIL)} failed, {len(SKIP)} skipped")
|
||||||
if FAIL:
|
if FAIL:
|
||||||
print("\nfailed:")
|
print("\nfailed:")
|
||||||
|
|||||||
Reference in New Issue
Block a user