docgen: drop the superseded first pass, port the style harvester

The IR supersedes both intermediate designs (requirements D6, D7):
station/tools/docgen and the graph model that briefly lived in graphgen.
Shipping them beside atlas2/docgen would mean two graph models, which is the
thing the architecture argues against.

Carried across rather than lost:
  - style/extract.py and tokens.py, the offline theme harvester (R31). Rewritten
    to emit a *theme* — a slot-to-hex binding — rather than a whole style file,
    since what harvesting recovers is which colour a slot should be, not what a
    kind should look like.
  - graphgen/README.md, rewritten for what graphgen actually is now: the
    schema explorer. It fixes the blank station-index entry at run.py:304.

Two bugs found while doing it:
  - Canvas and ink are the two lightness extremes, not the two most common
    values. In a Graphviz SVG every label carries a fill, so the ink outnumbers
    the canvas 87 to 43 and the old rule produced a theme whose text was
    invisible against its own background.
  - A name defined in both branches of an if/else produced a duplicate id, which
    failed validation on docgen's own source. Disambiguated by line.
This commit is contained in:
2026-09-13 21:41:29 -03:00
parent 358b98f826
commit 160ee31b8c
51 changed files with 4504 additions and 2906 deletions

View File

@@ -28,10 +28,11 @@ SCHEMA ?=
STYLE ?= lucid STYLE ?= lucid
THEME ?= THEME ?=
DEPTH ?= 2 DEPTH ?= 2
SCALE ?= 0.55
THEME_ARG := $(if $(THEME),--theme $(THEME)) THEME_ARG := $(if $(THEME),--theme $(THEME))
.PHONY: help check ir db graph index site view self doctor clean .PHONY: help 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"
@@ -70,6 +71,35 @@ site: view ## OUT/view.json -> a self-contained docs site in OUT/site
--style $(STYLE) $(THEME_ARG) --style $(STYLE) $(THEME_ARG)
@echo " open $(OUT)/site/index.html" @echo " open $(OUT)/site/index.html"
docs: ## Regenerate the figures in docs/ — docgen documented by docgen
@mkdir -p docs/img
@$(RUN) $(PKG).extractors.python --root $(HERE) -o /tmp/$(PKG)-docs.json >/dev/null
@$(RUN) $(PKG).ops /tmp/$(PKG)-docs.json --overview -o /tmp/$(PKG)-docs-view.json >/dev/null
@$(RUN) $(PKG).emitters dot /tmp/$(PKG)-docs-view.json -o docs/img/architecture.svg -q
@$(RUN) $(PKG).emitters minimap /tmp/$(PKG)-docs.json -o docs/img/minimap.svg --scale 0.5 --width 860
@$(RUN) $(PKG).emitters erd $(OUT)/ir.json -o docs/img/erd.svg 2>/dev/null \
|| echo " (erd figure kept — needs a schema IR at $(OUT)/ir.json to refresh)"
@PYTHONPATH=$(PARENT) $(PY) -c "from $(PKG).emitters.site import VIEWER, _slots, _fill; \
from $(PKG).style import Style; import pathlib; \
pathlib.Path('$(HERE)/docs/viewer.html').write_text( \
_fill(VIEWER.replace('__TITLE__', 'docgen docs'), _slots(Style.load('lucid'))))"
@echo " open $(HERE)/docs/index.html"
explore: ## OUT/ir.json -> OUT/explore/ — navigate on one side, explore on the other
@$(RUN) $(PKG).emitters explore $(OUT)/ir.json -o $(OUT)/explore \
--style $(STYLE) $(THEME_ARG) --scale $(SCALE)
@echo " open $(OUT)/explore/explore.html"
minimap: ## OUT/ir.json -> OUT/minimap.svg — what is where, read from the colours
@$(RUN) $(PKG).emitters minimap $(OUT)/ir.json -o $(OUT)/minimap.svg \
--style $(STYLE) $(THEME_ARG) --scale $(SCALE)
code: ## Extract C#/TypeScript from SRC (needs tree-sitter)
@test -n "$(SRC)" || { echo "Error: set SRC=/path/to/tree" >&2; exit 1; }
@mkdir -p $(OUT)
@$(RUN) $(PKG).extractors code --root "$(SRC)" -o $(OUT)/ir.json
@$(RUN) $(PKG).ir $(OUT)/ir.json
index: ## OUT/ir.json -> OUT/index.md and OUT/sidebar.json 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
@@ -79,12 +109,16 @@ self: ## Run the whole pipeline over soleprint itself — the honest end-to-end
@$(MAKE) --no-print-directory index OUT=$(OUT) @$(MAKE) --no-print-directory index OUT=$(OUT)
@$(MAKE) --no-print-directory graph OUT=$(OUT) @$(MAKE) --no-print-directory graph OUT=$(OUT)
@$(MAKE) --no-print-directory site OUT=$(OUT) @$(MAKE) --no-print-directory site OUT=$(OUT)
@$(MAKE) --no-print-directory minimap OUT=$(OUT)
@$(MAKE) --no-print-directory explore OUT=$(OUT)
@echo @echo
@echo " Read $(OUT)/index.md, or open $(OUT)/site/index.html" @echo " Read $(OUT)/index.md, or open $(OUT)/site/index.html"
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
@printf 'dot : '; (dot -V 2>&1) || echo 'MISSING — sudo apt install graphviz (only to render)' @printf 'dot : '; (dot -V 2>&1) || echo 'MISSING — sudo apt install graphviz (only to render)'
@printf 'tree-sit : '; $(PY) -c 'import tree_sitter, tree_sitter_c_sharp, tree_sitter_typescript; print("ok — C# and TypeScript available")' 2>/dev/null || echo 'absent — Python only. pip install tree_sitter tree_sitter_c_sharp tree_sitter_typescript'
@printf 'networkx : '; $(PY) -c 'import networkx; print(networkx.__version__ + " — for lab/ experiments")' 2>/dev/null || echo 'absent — only used in lab/'
@printf 'package : %s (from %s)\n' '$(PKG)' '$(PARENT)' @printf 'package : %s (from %s)\n' '$(PKG)' '$(PARENT)'
@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 \

View File

@@ -0,0 +1,135 @@
/* docgen docs. The layout five demos under semester/ converged on: a sticky
sidebar beside a bounded content column. Colours are tokens.css values, the
same ones the diagrams are drawn in. */
:root {
--bg: #0a0a0a;
--surface: #141414;
--surface-2: #1a1a1a;
--border: #333333;
--border-strong: #4a4a4a;
--text: #e5e5e5;
--muted: #a3a3a3;
--dim: #666666;
--accent: #d4a574;
--station: #1d4ed8;
--atlas: #15803d;
--artery: #b91c1c;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: var(--bg);
color: var(--text);
font-family: "Segoe UI", Inter, system-ui, -apple-system, Arial, sans-serif;
font-size: 14px;
line-height: 1.72;
}
.layout { display: flex; min-height: 100vh; }
/* ── sidebar ─────────────────────────────────────────────────────────── */
.sidebar {
width: 232px; flex-shrink: 0;
background: var(--surface);
border-right: 1px solid var(--border);
position: sticky; top: 0; height: 100vh; overflow-y: auto;
padding: 1.4rem 0 3rem;
scrollbar-width: none;
}
.sidebar::-webkit-scrollbar { display: none; }
.sidebar-header { padding: 0 1.15rem 1rem; border-bottom: 1px solid var(--border); }
.sidebar-header b { display: block; font-size: 14px; color: var(--text); }
.sidebar-header small { display: block; color: var(--dim); font-size: 11px; margin-top: 3px; }
.sidebar nav { padding-top: .75rem; }
.sidebar .group {
color: var(--dim); font-size: 9.5px; text-transform: uppercase;
letter-spacing: .07em; padding: 1rem 1.15rem .3rem;
}
.sidebar a {
display: block; padding: 3px 1.15rem;
color: var(--muted); text-decoration: none; font-size: 12.5px;
border-left: 2px solid transparent;
}
.sidebar a:hover { color: var(--text); background: var(--surface-2); }
.sidebar a.active { color: var(--accent); border-left-color: var(--accent); }
/* ── content ─────────────────────────────────────────────────────────── */
.content { flex: 1; min-width: 0; max-width: 820px; padding: 2.5rem 3.25rem 6rem; }
h1 { font-size: 26px; letter-spacing: -.01em; margin-bottom: .35rem; }
.lede { color: var(--muted); font-size: 15px; margin-bottom: 2.5rem; }
h2 {
font-size: 18px; margin: 3rem 0 .9rem; padding-top: 1.6rem;
border-top: 1px solid var(--border);
}
h2:first-of-type { border-top: none; padding-top: 0; }
h3 { font-size: 14px; margin: 1.9rem 0 .5rem; color: var(--text); }
h4 { font-size: 12.5px; margin: 1.3rem 0 .35rem; color: var(--muted); font-weight: 600; }
p { margin-bottom: .95rem; color: var(--muted); }
p strong, li strong { color: var(--text); font-weight: 600; }
em { color: var(--text); font-style: italic; }
ul, ol { margin: 0 0 1rem 1.15rem; color: var(--muted); }
li { margin-bottom: .3rem; }
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
code {
font-family: ui-monospace, "Cascadia Mono", Consolas, "SF Mono", monospace;
font-size: 12px; background: var(--surface); color: var(--text);
padding: 1px 5px; border-radius: 3px;
}
pre {
background: var(--surface); border: 1px solid var(--border);
border-radius: 7px; padding: 13px 16px; overflow-x: auto; margin: .9rem 0 1.2rem;
}
pre code { background: none; padding: 0; font-size: 12px; line-height: 1.62; color: var(--muted); }
pre .c { color: var(--dim); }
pre .k { color: var(--accent); }
table { border-collapse: collapse; margin: 1rem 0 1.4rem; width: 100%; font-size: 13px; }
th, td { text-align: left; padding: 7px 16px 7px 0; border-bottom: 1px solid var(--border);
color: var(--muted); vertical-align: top; }
th { color: var(--dim); font-size: 10px; text-transform: uppercase; letter-spacing: .06em;
font-weight: 600; }
td code { white-space: nowrap; }
blockquote {
border-left: 2px solid var(--border-strong); padding: .15rem 0 .15rem 1.1rem;
margin: 1.1rem 0; color: var(--dim); font-style: italic;
}
/* a claim worth not losing in the prose */
.note {
border: 1px solid var(--border); border-left: 3px solid var(--accent);
background: var(--surface); border-radius: 6px;
padding: .85rem 1.1rem; margin: 1.2rem 0; font-size: 13px;
}
.note b { color: var(--accent); }
.note.warn { border-left-color: var(--artery); }
.note.warn b { color: var(--artery); }
/* figures — inline and scaled, click for the viewer */
figure { margin: 1.3rem 0 1.7rem; }
figure a { display: block; border: 1px solid var(--border); border-radius: 8px;
overflow: hidden; background: var(--surface-2); }
figure a:hover { border-color: var(--accent); }
figure img { display: block; width: 100%; height: auto; }
figcaption { color: var(--dim); font-size: 11px; margin-top: .45rem; }
.pill {
display: inline-block; font-size: 10px; letter-spacing: .04em;
border: 1px solid var(--border-strong); border-radius: 20px;
padding: 1px 9px; color: var(--dim); margin-left: .5em; vertical-align: middle;
}
.pill.on { color: var(--atlas); border-color: var(--atlas); }
.pill.opt { color: var(--accent); border-color: var(--accent); }
.cols { display: flex; gap: 2rem; flex-wrap: wrap; }
.cols > div { flex: 1 1 250px; }

View File

@@ -0,0 +1,800 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Generated by graphviz version 14.1.2 (0)
-->
<!-- Title: ir Pages: 1 -->
<svg width="2144pt" height="707pt"
viewBox="0.00 0.00 2144.00 707.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(21.6 685.6)">
<title>ir</title>
<polygon fill="#0a0a0a" stroke="none" points="-21.6,21.6 -21.6,-685.6 2122.38,-685.6 2122.38,21.6 -21.6,21.6"/>
<g id="docgen" class="cluster module">
<title>cluster_docgen</title>
<polygon fill="#1a1a1a" stroke="#333333" stroke-dasharray="5,2" points="8,-8 8,-656 1886,-656 1886,-8 8,-8"/>
<text xml:space="preserve" text-anchor="middle" x="42.25" y="-639.65" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="13.00" fill="#a3a3a3">docgen</text>
</g>
<g id="docgen.emitters" class="cluster module">
<title>cluster_docgen_emitters</title>
<polygon fill="#1a1a1a" stroke="#333333" stroke-dasharray="5,2" points="292,-266.5 292,-623.5 1048,-623.5 1048,-266.5 292,-266.5"/>
<text xml:space="preserve" text-anchor="middle" x="331.12" y="-607.15" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="13.00" fill="#a3a3a3">emitters</text>
</g>
<g id="docgen.extractors" class="cluster module">
<title>cluster_docgen_extractors</title>
<polygon fill="#1a1a1a" stroke="#333333" stroke-dasharray="5,2" points="1212,-258.5 1212,-539 1878,-539 1878,-258.5 1212,-258.5"/>
<text xml:space="preserve" text-anchor="middle" x="1257.88" y="-522.65" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="13.00" fill="#a3a3a3">extractors</text>
</g>
<g id="docgen.extractors.python" class="cluster module">
<title>cluster_docgen_extractors_python</title>
<polygon fill="#1a1a1a" stroke="#333333" stroke-dasharray="5,2" points="1220,-266.5 1220,-506.5 1348,-506.5 1348,-266.5 1220,-266.5"/>
<text xml:space="preserve" text-anchor="middle" x="1253.12" y="-490.15" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="13.00" fill="#a3a3a3">python</text>
</g>
<g id="docgen.ir" class="cluster module">
<title>cluster_docgen_ir</title>
<polygon fill="#1a1a1a" stroke="#333333" stroke-dasharray="5,2" points="1048,-16 1048,-250.5 1182,-250.5 1182,-16 1048,-16"/>
<text xml:space="preserve" text-anchor="middle" x="1061.62" y="-234.15" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="13.00" fill="#a3a3a3">ir</text>
</g>
<g id="docgen.lab" class="cluster module">
<title>cluster_docgen_lab</title>
<polygon fill="#1a1a1a" stroke="#333333" stroke-dasharray="5,2" points="1365,-547 1365,-623.5 1465,-623.5 1465,-547 1365,-547"/>
<text xml:space="preserve" text-anchor="middle" x="1384.25" y="-607.15" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="13.00" fill="#a3a3a3">lab</text>
</g>
<g id="docgen.notebook" class="cluster module">
<title>cluster_docgen_notebook</title>
<polygon fill="#1a1a1a" stroke="#333333" stroke-dasharray="5,2" points="116,-351 116,-427.5 201,-427.5 201,-351 116,-351"/>
<text xml:space="preserve" text-anchor="middle" x="158.5" y="-411.15" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="13.00" fill="#a3a3a3">notebook</text>
</g>
<g id="docgen.ops" class="cluster module">
<title>cluster_docgen_ops</title>
<polygon fill="#1a1a1a" stroke="#333333" stroke-dasharray="5,2" points="183,-174 183,-343 279,-343 279,-174 183,-174"/>
<text xml:space="preserve" text-anchor="middle" x="203.75" y="-326.65" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="13.00" fill="#a3a3a3">ops</text>
</g>
<!-- docgen.emitters.__main__ -->
<g id="docgen.emitters.__main__" class="node module">
<title>docgen.emitters.__main__</title>
<g id="a_docgen.emitters.__main__"><a xlink:href="emitters/__main__.py" xlink:title="python3 &#45;m docgen.emitters &lt;emitter&gt; &lt;ir.json&gt; [options]">
<path fill="#1a1a1a" stroke="#4a4a4a" d="M668.9,-591C668.9,-591 613.1,-591 613.1,-591 607.1,-591 601.1,-585 601.1,-579 601.1,-579 601.1,-567 601.1,-567 601.1,-561 607.1,-555 613.1,-555 613.1,-555 668.9,-555 668.9,-555 674.9,-555 680.9,-561 680.9,-567 680.9,-567 680.9,-579 680.9,-579 680.9,-585 674.9,-591 668.9,-591"/>
<text xml:space="preserve" text-anchor="middle" x="641" y="-569.3" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="11.00" fill="#e5e5e5">__main__</text>
</a>
</g>
</g>
<!-- docgen.emitters.auto -->
<g id="docgen.emitters.auto" class="node module">
<title>docgen.emitters.auto</title>
<g id="a_docgen.emitters.auto"><a xlink:href="emitters/auto.py" xlink:title="Draw it the way its structure asks to be drawn.">
<path fill="#1a1a1a" stroke="#4a4a4a" d="M572.27,-395C572.27,-395 539.73,-395 539.73,-395 533.73,-395 527.73,-389 527.73,-383 527.73,-383 527.73,-371 527.73,-371 527.73,-365 533.73,-359 539.73,-359 539.73,-359 572.27,-359 572.27,-359 578.27,-359 584.27,-365 584.27,-371 584.27,-371 584.27,-383 584.27,-383 584.27,-389 578.27,-395 572.27,-395"/>
<text xml:space="preserve" text-anchor="middle" x="556" y="-373.3" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="11.00" fill="#e5e5e5">auto</text>
</a>
</g>
</g>
<!-- docgen.emitters.__main__&#45;&gt;docgen.emitters.auto -->
<g id="edge1" class="edge imports">
<title>docgen.emitters.__main__&#45;&gt;docgen.emitters.auto</title>
<path fill="none" stroke="#4a4a4a" d="M600.78,-571.7C575.52,-569.16 544.67,-561.24 528,-539 497.53,-498.35 523.38,-435.8 541.68,-401.87"/>
<polygon fill="#4a4a4a" stroke="#4a4a4a" points="543.41,-403.07 544.48,-396.81 539.74,-401.04 543.41,-403.07"/>
</g>
<!-- docgen.emitters.cli_dot -->
<g id="docgen.emitters.cli_dot" class="node module">
<title>docgen.emitters.cli_dot</title>
<g id="a_docgen.emitters.cli_dot"><a xlink:href="emitters/cli_dot.py" xlink:title="python3 &#45;m docgen.emitters dot &lt;ir.json&gt; [&#45;o out.svg] [&#45;&#45;style lucid] [&#45;&#45;theme dark]">
<path fill="#1a1a1a" stroke="#4a4a4a" d="M479.27,-395C479.27,-395 434.73,-395 434.73,-395 428.73,-395 422.73,-389 422.73,-383 422.73,-383 422.73,-371 422.73,-371 422.73,-365 428.73,-359 434.73,-359 434.73,-359 479.27,-359 479.27,-359 485.27,-359 491.27,-365 491.27,-371 491.27,-371 491.27,-383 491.27,-383 491.27,-389 485.27,-395 479.27,-395"/>
<text xml:space="preserve" text-anchor="middle" x="457" y="-373.3" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="11.00" fill="#e5e5e5">cli_dot</text>
</a>
</g>
</g>
<!-- docgen.emitters.__main__&#45;&gt;docgen.emitters.cli_dot -->
<g id="edge2" class="edge imports">
<title>docgen.emitters.__main__&#45;&gt;docgen.emitters.cli_dot</title>
<path fill="none" stroke="#4a4a4a" d="M600.88,-572.45C570.78,-570.62 530.55,-563.26 505,-539 466.89,-502.81 458.65,-437.81 457.11,-402.48"/>
<polygon fill="#4a4a4a" stroke="#4a4a4a" points="459.22,-402.85 456.92,-396.92 455.02,-402.99 459.22,-402.85"/>
</g>
<!-- docgen.emitters.cli_erd -->
<g id="docgen.emitters.cli_erd" class="node module">
<title>docgen.emitters.cli_erd</title>
<g id="a_docgen.emitters.cli_erd"><a xlink:href="emitters/cli_erd.py" xlink:title="python3 &#45;m docgen.emitters erd &lt;ir.json&gt; [&#45;o out.svg] [&#45;&#45;theme dark]">
<path fill="#1a1a1a" stroke="#4a4a4a" d="M952.27,-395C952.27,-395 907.73,-395 907.73,-395 901.73,-395 895.73,-389 895.73,-383 895.73,-383 895.73,-371 895.73,-371 895.73,-365 901.73,-359 907.73,-359 907.73,-359 952.27,-359 952.27,-359 958.27,-359 964.27,-365 964.27,-371 964.27,-371 964.27,-383 964.27,-383 964.27,-389 958.27,-395 952.27,-395"/>
<text xml:space="preserve" text-anchor="middle" x="930" y="-373.3" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="11.00" fill="#e5e5e5">cli_erd</text>
</a>
</g>
</g>
<!-- docgen.emitters.__main__&#45;&gt;docgen.emitters.cli_erd -->
<g id="edge3" class="edge imports">
<title>docgen.emitters.__main__&#45;&gt;docgen.emitters.cli_erd</title>
<path fill="none" stroke="#4a4a4a" d="M681.25,-570.69C708.23,-567.78 742.93,-559.84 766,-539 801.59,-506.85 767.42,-469.02 804,-438 822.32,-422.47 834.84,-436.76 857,-427.5 873.54,-420.59 890.22,-409.65 903.45,-399.81"/>
<polygon fill="#4a4a4a" stroke="#4a4a4a" points="904.56,-401.6 908.07,-396.3 902.02,-398.26 904.56,-401.6"/>
</g>
<!-- docgen.emitters.cli_explore -->
<g id="docgen.emitters.cli_explore" class="node module">
<title>docgen.emitters.cli_explore</title>
<g id="a_docgen.emitters.cli_explore"><a xlink:href="emitters/cli_explore.py" xlink:title="python3 &#45;m docgen.emitters explore &lt;ir.json&gt; &#45;o DIR [&#45;&#45;scale 0.55] [&#45;&#45;hops 1]">
<path fill="#1a1a1a" stroke="#4a4a4a" d="M736.02,-474C736.02,-474 665.98,-474 665.98,-474 659.98,-474 653.98,-468 653.98,-462 653.98,-462 653.98,-450 653.98,-450 653.98,-444 659.98,-438 665.98,-438 665.98,-438 736.02,-438 736.02,-438 742.02,-438 748.02,-444 748.02,-450 748.02,-450 748.02,-462 748.02,-462 748.02,-468 742.02,-474 736.02,-474"/>
<text xml:space="preserve" text-anchor="middle" x="701" y="-452.3" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="11.00" fill="#e5e5e5">cli_explore</text>
</a>
</g>
</g>
<!-- docgen.emitters.__main__&#45;&gt;docgen.emitters.cli_explore -->
<g id="edge4" class="edge imports">
<title>docgen.emitters.__main__&#45;&gt;docgen.emitters.cli_explore</title>
<path fill="none" stroke="#4a4a4a" d="M650.11,-554.53C660.39,-534.83 677.17,-502.68 688.63,-480.72"/>
<polygon fill="#4a4a4a" stroke="#4a4a4a" points="690.38,-481.89 691.3,-475.6 686.66,-479.94 690.38,-481.89"/>
</g>
<!-- docgen.emitters.cli_index -->
<g id="docgen.emitters.cli_index" class="node module">
<title>docgen.emitters.cli_index</title>
<g id="a_docgen.emitters.cli_index"><a xlink:href="emitters/cli_index.py" xlink:title="python3 &#45;m docgen.emitters index &lt;ir.json&gt; [&#45;o out.md|out.json]">
<path fill="#1a1a1a" stroke="#4a4a4a" d="M1027.65,-474C1027.65,-474 970.35,-474 970.35,-474 964.35,-474 958.35,-468 958.35,-462 958.35,-462 958.35,-450 958.35,-450 958.35,-444 964.35,-438 970.35,-438 970.35,-438 1027.65,-438 1027.65,-438 1033.65,-438 1039.65,-444 1039.65,-450 1039.65,-450 1039.65,-462 1039.65,-462 1039.65,-468 1033.65,-474 1027.65,-474"/>
<text xml:space="preserve" text-anchor="middle" x="999" y="-452.3" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="11.00" fill="#e5e5e5">cli_index</text>
</a>
</g>
</g>
<!-- docgen.emitters.__main__&#45;&gt;docgen.emitters.cli_index -->
<g id="edge5" class="edge imports">
<title>docgen.emitters.__main__&#45;&gt;docgen.emitters.cli_index</title>
<path fill="none" stroke="#4a4a4a" d="M681.27,-571.55C752.45,-570.11 897.04,-564.08 940,-539 962.53,-525.85 978.79,-500.3 988.47,-481.2"/>
<polygon fill="#4a4a4a" stroke="#4a4a4a" points="990.34,-482.16 991.07,-475.85 986.56,-480.32 990.34,-482.16"/>
</g>
<!-- docgen.emitters.cli_minimap -->
<g id="docgen.emitters.cli_minimap" class="node module">
<title>docgen.emitters.cli_minimap</title>
<g id="a_docgen.emitters.cli_minimap"><a xlink:href="emitters/cli_minimap.py" xlink:title="python3 &#45;m docgen.emitters minimap &lt;ir.json&gt; [&#45;o out.svg] [&#45;&#45;scale 0.55]">
<path fill="#1a1a1a" stroke="#4a4a4a" d="M910.4,-474C910.4,-474 833.6,-474 833.6,-474 827.6,-474 821.6,-468 821.6,-462 821.6,-462 821.6,-450 821.6,-450 821.6,-444 827.6,-438 833.6,-438 833.6,-438 910.4,-438 910.4,-438 916.4,-438 922.4,-444 922.4,-450 922.4,-450 922.4,-462 922.4,-462 922.4,-468 916.4,-474 910.4,-474"/>
<text xml:space="preserve" text-anchor="middle" x="872" y="-452.3" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="11.00" fill="#e5e5e5">cli_minimap</text>
</a>
</g>
</g>
<!-- docgen.emitters.__main__&#45;&gt;docgen.emitters.cli_minimap -->
<g id="edge6" class="edge imports">
<title>docgen.emitters.__main__&#45;&gt;docgen.emitters.cli_minimap</title>
<path fill="none" stroke="#4a4a4a" d="M681.28,-569.96C713.8,-566.82 759.76,-558.9 795,-539 820.36,-524.68 842.31,-499.22 856.21,-480.45"/>
<polygon fill="#4a4a4a" stroke="#4a4a4a" points="857.91,-481.69 859.72,-475.6 854.5,-479.23 857.91,-481.69"/>
</g>
<!-- docgen.emitters.cli_notebook -->
<g id="docgen.emitters.cli_notebook" class="node module">
<title>docgen.emitters.cli_notebook</title>
<g id="a_docgen.emitters.cli_notebook"><a xlink:href="emitters/cli_notebook.py" xlink:title="python3 &#45;m docgen.emitters notebook &lt;ir.json&gt; [&#45;o out.ipynb] [&#45;&#45;overlay f.json]">
<path fill="#1a1a1a" stroke="#4a4a4a" d="M394.02,-474C394.02,-474 311.98,-474 311.98,-474 305.98,-474 299.98,-468 299.98,-462 299.98,-462 299.98,-450 299.98,-450 299.98,-444 305.98,-438 311.98,-438 311.98,-438 394.02,-438 394.02,-438 400.02,-438 406.02,-444 406.02,-450 406.02,-450 406.02,-462 406.02,-462 406.02,-468 400.02,-474 394.02,-474"/>
<text xml:space="preserve" text-anchor="middle" x="353" y="-452.3" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="11.00" fill="#e5e5e5">cli_notebook</text>
</a>
</g>
</g>
<!-- docgen.emitters.__main__&#45;&gt;docgen.emitters.cli_notebook -->
<g id="edge7" class="edge imports">
<title>docgen.emitters.__main__&#45;&gt;docgen.emitters.cli_notebook</title>
<path fill="none" stroke="#4a4a4a" d="M600.6,-570.71C561.51,-568.13 501.38,-560.63 454,-539 422.64,-524.68 392.77,-498.28 373.76,-479.3"/>
<polygon fill="#4a4a4a" stroke="#4a4a4a" points="375.46,-478.04 369.76,-475.24 372.47,-480.99 375.46,-478.04"/>
</g>
<!-- docgen.emitters.cli_site -->
<g id="docgen.emitters.cli_site" class="node module">
<title>docgen.emitters.cli_site</title>
<g id="a_docgen.emitters.cli_site"><a xlink:href="emitters/cli_site.py" xlink:title="python3 &#45;m docgen.emitters site &lt;ir.json&gt; &#45;o DIR [&#45;&#45;theme lucid]">
<path fill="#1a1a1a" stroke="#4a4a4a" d="M605.77,-474C605.77,-474 558.23,-474 558.23,-474 552.23,-474 546.23,-468 546.23,-462 546.23,-462 546.23,-450 546.23,-450 546.23,-444 552.23,-438 558.23,-438 558.23,-438 605.77,-438 605.77,-438 611.77,-438 617.77,-444 617.77,-450 617.77,-450 617.77,-462 617.77,-462 617.77,-468 611.77,-474 605.77,-474"/>
<text xml:space="preserve" text-anchor="middle" x="582" y="-452.3" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="11.00" fill="#e5e5e5">cli_site</text>
</a>
</g>
</g>
<!-- docgen.emitters.__main__&#45;&gt;docgen.emitters.cli_site -->
<g id="edge8" class="edge imports">
<title>docgen.emitters.__main__&#45;&gt;docgen.emitters.cli_site</title>
<path fill="none" stroke="#4a4a4a" d="M632.04,-554.53C621.93,-534.83 605.44,-502.68 594.17,-480.72"/>
<polygon fill="#4a4a4a" stroke="#4a4a4a" points="596.15,-479.98 591.54,-475.6 592.41,-481.9 596.15,-479.98"/>
</g>
<!-- docgen.emitters.dot -->
<g id="docgen.emitters.dot" class="node module">
<title>docgen.emitters.dot</title>
<g id="a_docgen.emitters.dot"><a xlink:href="emitters/dot.py" xlink:title="IR + style &#45;&gt; DOT &#45;&gt; SVG.">
<path fill="#1a1a1a" stroke="#4a4a4a" d="M635,-310.5C635,-310.5 605,-310.5 605,-310.5 599,-310.5 593,-304.5 593,-298.5 593,-298.5 593,-286.5 593,-286.5 593,-280.5 599,-274.5 605,-274.5 605,-274.5 635,-274.5 635,-274.5 641,-274.5 647,-280.5 647,-286.5 647,-286.5 647,-298.5 647,-298.5 647,-304.5 641,-310.5 635,-310.5"/>
<text xml:space="preserve" text-anchor="middle" x="620" y="-288.8" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="11.00" fill="#e5e5e5">dot</text>
</a>
</g>
</g>
<!-- docgen.emitters.auto&#45;&gt;docgen.emitters.dot -->
<g id="edge9" class="edge imports">
<title>docgen.emitters.auto&#45;&gt;docgen.emitters.dot</title>
<path fill="none" stroke="#4a4a4a" d="M569.57,-358.5C578.99,-346.36 591.64,-330.06 601.91,-316.81"/>
<polygon fill="#4a4a4a" stroke="#4a4a4a" points="603.5,-318.19 605.52,-312.17 600.18,-315.62 603.5,-318.19"/>
</g>
<!-- docgen.emitters.erd -->
<g id="docgen.emitters.erd" class="node module">
<title>docgen.emitters.erd</title>
<g id="a_docgen.emitters.erd"><a xlink:href="emitters/erd.py" xlink:title="A schema, as an entity&#45;relationship diagram. SVG written directly — no Graphviz.">
<path fill="#1a1a1a" stroke="#4a4a4a" d="M830,-310.5C830,-310.5 800,-310.5 800,-310.5 794,-310.5 788,-304.5 788,-298.5 788,-298.5 788,-286.5 788,-286.5 788,-280.5 794,-274.5 800,-274.5 800,-274.5 830,-274.5 830,-274.5 836,-274.5 842,-280.5 842,-286.5 842,-286.5 842,-298.5 842,-298.5 842,-304.5 836,-310.5 830,-310.5"/>
<text xml:space="preserve" text-anchor="middle" x="815" y="-288.8" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="11.00" fill="#e5e5e5">erd</text>
</a>
</g>
</g>
<!-- docgen.emitters.auto&#45;&gt;docgen.emitters.erd -->
<g id="edge10" class="edge imports">
<title>docgen.emitters.auto&#45;&gt;docgen.emitters.erd</title>
<path fill="none" stroke="#4a4a4a" d="M584.77,-365.69C600.81,-360.39 621.28,-354.35 640,-351 666.96,-346.17 737.91,-353.99 763,-343 776.29,-337.18 788.25,-326.41 797.34,-316.4"/>
<polygon fill="#4a4a4a" stroke="#4a4a4a" points="798.73,-318 801.09,-312.1 795.56,-315.24 798.73,-318"/>
</g>
<!-- docgen.emitters.index -->
<g id="docgen.emitters.index" class="node module">
<title>docgen.emitters.index</title>
<g id="a_docgen.emitters.index"><a xlink:href="emitters/index.py" xlink:title="IR &#45;&gt; an index. Markdown for reading, JSON for a sidebar.">
<path fill="#1a1a1a" stroke="#4a4a4a" d="M732.9,-310.5C732.9,-310.5 695.1,-310.5 695.1,-310.5 689.1,-310.5 683.1,-304.5 683.1,-298.5 683.1,-298.5 683.1,-286.5 683.1,-286.5 683.1,-280.5 689.1,-274.5 695.1,-274.5 695.1,-274.5 732.9,-274.5 732.9,-274.5 738.9,-274.5 744.9,-280.5 744.9,-286.5 744.9,-286.5 744.9,-298.5 744.9,-298.5 744.9,-304.5 738.9,-310.5 732.9,-310.5"/>
<text xml:space="preserve" text-anchor="middle" x="714" y="-288.8" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="11.00" fill="#e5e5e5">index</text>
</a>
</g>
</g>
<!-- docgen.emitters.auto&#45;&gt;docgen.emitters.index -->
<g id="edge11" class="edge imports">
<title>docgen.emitters.auto&#45;&gt;docgen.emitters.index</title>
<path fill="none" stroke="#4a4a4a" d="M584.59,-367.53C616.28,-358.11 663.21,-344.08 665,-343 676.6,-336.01 687.43,-325.66 695.95,-316.23"/>
<polygon fill="#4a4a4a" stroke="#4a4a4a" points="697.31,-317.85 699.68,-311.95 694.15,-315.09 697.31,-317.85"/>
</g>
<!-- docgen.ir.__main__ -->
<g id="docgen.ir.__main__" class="node module">
<title>docgen.ir.__main__</title>
<g id="a_docgen.ir.__main__"><a xlink:href="ir/__main__.py" xlink:title="python3 &#45;m docgen.ir &lt;ir.json&gt; &#160;&#160;&#160;— validate a document at the boundary.">
<path fill="#1a1a1a" stroke="#4a4a4a" d="M1161.9,-218C1161.9,-218 1106.1,-218 1106.1,-218 1100.1,-218 1094.1,-212 1094.1,-206 1094.1,-206 1094.1,-194 1094.1,-194 1094.1,-188 1100.1,-182 1106.1,-182 1106.1,-182 1161.9,-182 1161.9,-182 1167.9,-182 1173.9,-188 1173.9,-194 1173.9,-194 1173.9,-206 1173.9,-206 1173.9,-212 1167.9,-218 1161.9,-218"/>
<text xml:space="preserve" text-anchor="middle" x="1134" y="-196.3" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="11.00" fill="#e5e5e5">__main__</text>
</a>
</g>
</g>
<!-- docgen.emitters.auto&#45;&gt;docgen.ir.__main__ -->
<g id="edge12" class="edge imports">
<title>docgen.emitters.auto&#45;&gt;docgen.ir.__main__</title>
<path fill="none" stroke="#4a4a4a" d="M552.99,-358.69C549.58,-332.81 547.65,-284.31 575,-258.5 608.11,-227.25 890.17,-211 1040.5,-204.49"/>
<polygon fill="#4a4a4a" stroke="#4a4a4a" points="1040.59,-206.59 1046.49,-204.23 1040.41,-202.39 1040.59,-206.59"/>
</g>
<!-- docgen.ops.__main__ -->
<g id="docgen.ops.__main__" class="node module">
<title>docgen.ops.__main__</title>
<g id="a_docgen.ops.__main__"><a xlink:href="ops/__main__.py" xlink:title="python3 &#45;m docgen.ops &lt;ir.json&gt; [views...] [&#45;o out.json]">
<path fill="#1a1a1a" stroke="#4a4a4a" d="M258.9,-310.5C258.9,-310.5 203.1,-310.5 203.1,-310.5 197.1,-310.5 191.1,-304.5 191.1,-298.5 191.1,-298.5 191.1,-286.5 191.1,-286.5 191.1,-280.5 197.1,-274.5 203.1,-274.5 203.1,-274.5 258.9,-274.5 258.9,-274.5 264.9,-274.5 270.9,-280.5 270.9,-286.5 270.9,-286.5 270.9,-298.5 270.9,-298.5 270.9,-304.5 264.9,-310.5 258.9,-310.5"/>
<text xml:space="preserve" text-anchor="middle" x="231" y="-288.8" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="11.00" fill="#e5e5e5">__main__</text>
</a>
</g>
</g>
<!-- docgen.emitters.auto&#45;&gt;docgen.ops.__main__ -->
<g id="edge13" class="edge imports">
<title>docgen.emitters.auto&#45;&gt;docgen.ops.__main__</title>
<path fill="none" stroke="#4a4a4a" d="M528.28,-358.62C522.14,-355.53 515.52,-352.75 509,-351 462.55,-338.54 340.44,-360.44 285.92,-343.65"/>
<polygon fill="#4a4a4a" stroke="#4a4a4a" points="286.78,-341.73 280.42,-341.66 285.35,-345.68 286.78,-341.73"/>
</g>
<!-- docgen.style -->
<g id="docgen.style" class="node module">
<title>docgen.style</title>
<g id="a_docgen.style"><a xlink:href="style/__init__.py" xlink:title="Style: what a `kind` looks like. Loaded by emitters only.">
<path fill="#1a1a1a" stroke="#4a4a4a" d="M124.78,-310.5C124.78,-310.5 89.22,-310.5 89.22,-310.5 83.22,-310.5 77.22,-304.5 77.22,-298.5 77.22,-298.5 77.22,-286.5 77.22,-286.5 77.22,-280.5 83.22,-274.5 89.22,-274.5 89.22,-274.5 124.78,-274.5 124.78,-274.5 130.78,-274.5 136.78,-280.5 136.78,-286.5 136.78,-286.5 136.78,-298.5 136.78,-298.5 136.78,-304.5 130.78,-310.5 124.78,-310.5"/>
<text xml:space="preserve" text-anchor="middle" x="107" y="-288.8" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="11.00" fill="#e5e5e5">style</text>
</a>
</g>
</g>
<!-- docgen.emitters.auto&#45;&gt;docgen.style -->
<g id="edge14" class="edge imports">
<title>docgen.emitters.auto&#45;&gt;docgen.style</title>
<path fill="none" stroke="#4a4a4a" d="M528.29,-358.56C522.15,-355.48 515.53,-352.71 509,-351 472.98,-341.56 209.22,-355.07 174,-343 157.66,-337.4 141.98,-326.25 129.9,-315.95"/>
<polygon fill="#4a4a4a" stroke="#4a4a4a" points="131.3,-314.38 125.41,-311.99 128.52,-317.53 131.3,-314.38"/>
</g>
<!-- docgen.emitters.cli_dot&#45;&gt;docgen.emitters.dot -->
<g id="edge15" class="edge imports">
<title>docgen.emitters.cli_dot&#45;&gt;docgen.emitters.dot</title>
<path fill="none" stroke="#4a4a4a" d="M491.57,-358.5C519.51,-344.36 558.62,-324.56 586.33,-310.54"/>
<polygon fill="#4a4a4a" stroke="#4a4a4a" points="587,-312.56 591.41,-307.97 585.11,-308.81 587,-312.56"/>
</g>
<!-- docgen.emitters.cli_dot&#45;&gt;docgen.ir.__main__ -->
<g id="edge16" class="edge imports">
<title>docgen.emitters.cli_dot&#45;&gt;docgen.ir.__main__</title>
<path fill="none" stroke="#4a4a4a" d="M463.85,-358.81C475.48,-332.16 501.81,-281.55 542,-258.5 624.67,-211.09 895.88,-202.16 1040.77,-200.86"/>
<polygon fill="#4a4a4a" stroke="#4a4a4a" points="1040.51,-202.96 1046.49,-200.81 1040.47,-198.76 1040.51,-202.96"/>
</g>
<!-- docgen.emitters.cli_dot&#45;&gt;docgen.ops.__main__ -->
<g id="edge17" class="edge imports">
<title>docgen.emitters.cli_dot&#45;&gt;docgen.ops.__main__</title>
<path fill="none" stroke="#4a4a4a" d="M424.92,-358.54C418.18,-355.54 410.99,-352.81 404,-351 379.85,-344.75 317.86,-352.49 286.04,-343.56"/>
<polygon fill="#4a4a4a" stroke="#4a4a4a" points="286.78,-341.6 280.43,-341.58 285.38,-345.56 286.78,-341.6"/>
</g>
<!-- docgen.emitters.cli_dot&#45;&gt;docgen.style -->
<g id="edge18" class="edge imports">
<title>docgen.emitters.cli_dot&#45;&gt;docgen.style</title>
<path fill="none" stroke="#4a4a4a" d="M425.54,-358.62C418.63,-355.53 411.22,-352.75 404,-351 354.3,-338.95 222.29,-359.84 174,-343 157.69,-337.31 142.01,-326.15 129.93,-315.87"/>
<polygon fill="#4a4a4a" stroke="#4a4a4a" points="131.33,-314.3 125.43,-311.92 128.55,-317.46 131.33,-314.3"/>
</g>
<!-- docgen.emitters.cli_erd&#45;&gt;docgen.emitters.erd -->
<g id="edge19" class="edge imports">
<title>docgen.emitters.cli_erd&#45;&gt;docgen.emitters.erd</title>
<path fill="none" stroke="#4a4a4a" d="M905.61,-358.5C887.93,-345.82 863.9,-328.58 845.03,-315.04"/>
<polygon fill="#4a4a4a" stroke="#4a4a4a" points="846.68,-313.64 840.58,-311.85 844.23,-317.06 846.68,-313.64"/>
</g>
<!-- docgen.emitters.cli_erd&#45;&gt;docgen.ir.__main__ -->
<g id="edge20" class="edge imports">
<title>docgen.emitters.cli_erd&#45;&gt;docgen.ir.__main__</title>
<path fill="none" stroke="#4a4a4a" d="M964.54,-364.18C977.1,-358.89 990.92,-351.82 1002,-343 1039.07,-313.49 1034.41,-292.89 1067,-258.5 1067.87,-257.58 1068.75,-256.66 1069.65,-255.75"/>
<polygon fill="#4a4a4a" stroke="#4a4a4a" points="1071.07,-257.3 1073.82,-251.57 1068.1,-254.33 1071.07,-257.3"/>
</g>
<!-- docgen.emitters.cli_erd&#45;&gt;docgen.style -->
<g id="edge21" class="edge imports">
<title>docgen.emitters.cli_erd&#45;&gt;docgen.style</title>
<path fill="none" stroke="#4a4a4a" d="M895.4,-364.13C878.85,-359.03 858.62,-353.65 840,-351 803.37,-345.78 209.08,-354.79 174,-343 157.36,-337.41 141.43,-325.99 129.28,-315.52"/>
<polygon fill="#4a4a4a" stroke="#4a4a4a" points="130.99,-314.23 125.11,-311.81 128.2,-317.37 130.99,-314.23"/>
</g>
<!-- docgen.emitters.explore -->
<g id="docgen.emitters.explore" class="node module">
<title>docgen.emitters.explore</title>
<g id="a_docgen.emitters.explore"><a xlink:href="emitters/explore.py" xlink:title="Two panes: a minimap to navigate by, and a detail pane to explore with.">
<path fill="#1a1a1a" stroke="#4a4a4a" d="M810.27,-395C810.27,-395 759.73,-395 759.73,-395 753.73,-395 747.73,-389 747.73,-383 747.73,-383 747.73,-371 747.73,-371 747.73,-365 753.73,-359 759.73,-359 759.73,-359 810.27,-359 810.27,-359 816.27,-359 822.27,-365 822.27,-371 822.27,-371 822.27,-383 822.27,-383 822.27,-389 816.27,-395 810.27,-395"/>
<text xml:space="preserve" text-anchor="middle" x="785" y="-373.3" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="11.00" fill="#e5e5e5">explore</text>
</a>
</g>
</g>
<!-- docgen.emitters.cli_explore&#45;&gt;docgen.emitters.explore -->
<g id="edge22" class="edge imports">
<title>docgen.emitters.cli_explore&#45;&gt;docgen.emitters.explore</title>
<path fill="none" stroke="#4a4a4a" d="M720.06,-437.53C732.09,-426.5 747.74,-412.16 760.66,-400.31"/>
<polygon fill="#4a4a4a" stroke="#4a4a4a" points="761.89,-402.03 764.9,-396.43 759.06,-398.93 761.89,-402.03"/>
</g>
<!-- docgen.emitters.cli_explore&#45;&gt;docgen.ir.__main__ -->
<g id="edge23" class="edge imports">
<title>docgen.emitters.cli_explore&#45;&gt;docgen.ir.__main__</title>
<path fill="none" stroke="#4a4a4a" d="M748.52,-445.87C765.91,-442.87 785.79,-439.83 804,-438 819.65,-436.42 1074.98,-437.66 1087,-427.5 1112.41,-406.02 1124.62,-317.45 1130.07,-257.64"/>
<polygon fill="#4a4a4a" stroke="#4a4a4a" points="1132.13,-258.17 1130.57,-252.01 1127.95,-257.8 1132.13,-258.17"/>
</g>
<!-- docgen.emitters.cli_explore&#45;&gt;docgen.style -->
<g id="edge24" class="edge imports">
<title>docgen.emitters.cli_explore&#45;&gt;docgen.style</title>
<path fill="none" stroke="#4a4a4a" d="M653.69,-441.24C647.78,-439.93 641.78,-438.8 636,-438 620.78,-435.9 93.76,-438.47 83,-427.5 54.22,-398.17 75.6,-346.88 92.28,-316.98"/>
<polygon fill="#4a4a4a" stroke="#4a4a4a" points="94.04,-318.14 95.21,-311.89 90.4,-316.05 94.04,-318.14"/>
</g>
<!-- docgen.emitters.cli_index&#45;&gt;docgen.emitters.index -->
<g id="edge25" class="edge imports">
<title>docgen.emitters.cli_index&#45;&gt;docgen.emitters.index</title>
<path fill="none" stroke="#4a4a4a" d="M1002.08,-437.52C1005.11,-413.94 1006.28,-372.43 982,-351 964.33,-335.4 791.88,-351.76 770,-343 755.64,-337.25 742.51,-326.12 732.58,-315.87"/>
<polygon fill="#4a4a4a" stroke="#4a4a4a" points="734.34,-314.69 728.72,-311.73 731.27,-317.55 734.34,-314.69"/>
</g>
<!-- docgen.emitters.cli_index&#45;&gt;docgen.ir.__main__ -->
<g id="edge26" class="edge imports">
<title>docgen.emitters.cli_index&#45;&gt;docgen.ir.__main__</title>
<path fill="none" stroke="#4a4a4a" d="M1039.82,-453.98C1083.16,-451.92 1147.52,-445.79 1163,-427.5 1202.67,-380.63 1183.72,-307.55 1162.56,-257.15"/>
<polygon fill="#4a4a4a" stroke="#4a4a4a" points="1164.6,-256.57 1160.3,-251.89 1160.74,-258.23 1164.6,-256.57"/>
</g>
<!-- docgen.emitters.minimap -->
<g id="docgen.emitters.minimap" class="node module">
<title>docgen.emitters.minimap</title>
<g id="a_docgen.emitters.minimap"><a xlink:href="emitters/minimap.py" xlink:title="IR &#45;&gt; a structural minimap. What is where, readable without reading.">
<path fill="#1a1a1a" stroke="#4a4a4a" d="M971.65,-310.5C971.65,-310.5 914.35,-310.5 914.35,-310.5 908.35,-310.5 902.35,-304.5 902.35,-298.5 902.35,-298.5 902.35,-286.5 902.35,-286.5 902.35,-280.5 908.35,-274.5 914.35,-274.5 914.35,-274.5 971.65,-274.5 971.65,-274.5 977.65,-274.5 983.65,-280.5 983.65,-286.5 983.65,-286.5 983.65,-298.5 983.65,-298.5 983.65,-304.5 977.65,-310.5 971.65,-310.5"/>
<text xml:space="preserve" text-anchor="middle" x="943" y="-288.8" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="11.00" fill="#e5e5e5">minimap</text>
</a>
</g>
</g>
<!-- docgen.emitters.cli_minimap&#45;&gt;docgen.emitters.minimap -->
<g id="edge27" class="edge imports">
<title>docgen.emitters.cli_minimap&#45;&gt;docgen.emitters.minimap</title>
<path fill="none" stroke="#4a4a4a" d="M922.81,-442.3C951.64,-435.09 982,-427.5 982,-427.5 1002.92,-400.7 991.98,-383.5 982,-351 978.19,-338.59 970.68,-326.44 963.27,-316.54"/>
<polygon fill="#4a4a4a" stroke="#4a4a4a" points="964.97,-315.31 959.63,-311.88 961.66,-317.9 964.97,-315.31"/>
</g>
<!-- docgen.emitters.cli_minimap&#45;&gt;docgen.ir.__main__ -->
<g id="edge28" class="edge imports">
<title>docgen.emitters.cli_minimap&#45;&gt;docgen.ir.__main__</title>
<path fill="none" stroke="#4a4a4a" d="M922.66,-441.23C928.48,-439.98 934.35,-438.86 940,-438 960.35,-434.91 1110.57,-442.18 1125,-427.5 1147.74,-404.37 1144.93,-317.14 1140.04,-257.93"/>
<polygon fill="#4a4a4a" stroke="#4a4a4a" points="1142.14,-257.81 1139.53,-252.01 1137.95,-258.16 1142.14,-257.81"/>
</g>
<!-- docgen.emitters.cli_minimap&#45;&gt;docgen.style -->
<g id="edge29" class="edge imports">
<title>docgen.emitters.cli_minimap&#45;&gt;docgen.style</title>
<path fill="none" stroke="#4a4a4a" d="M821.23,-445.35C803.79,-442.43 784.1,-439.59 766,-438 747.76,-436.4 119.84,-440.55 107,-427.5 78.75,-398.8 88.71,-347.63 98.12,-317.51"/>
<polygon fill="#4a4a4a" stroke="#4a4a4a" points="100.01,-318.49 99.88,-312.14 96.01,-317.19 100.01,-318.49"/>
</g>
<!-- docgen.emitters.notebook -->
<g id="docgen.emitters.notebook" class="node module">
<title>docgen.emitters.notebook</title>
<g id="a_docgen.emitters.notebook"><a xlink:href="emitters/notebook.py" xlink:title="IR &#45;&gt; a Jupyter notebook. Generated, never hand&#45;authored.">
<path fill="#1a1a1a" stroke="#4a4a4a" d="M374.27,-395C374.27,-395 311.73,-395 311.73,-395 305.73,-395 299.73,-389 299.73,-383 299.73,-383 299.73,-371 299.73,-371 299.73,-365 305.73,-359 311.73,-359 311.73,-359 374.27,-359 374.27,-359 380.27,-359 386.27,-365 386.27,-371 386.27,-371 386.27,-383 386.27,-383 386.27,-389 380.27,-395 374.27,-395"/>
<text xml:space="preserve" text-anchor="middle" x="343" y="-373.3" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="11.00" fill="#e5e5e5">notebook</text>
</a>
</g>
</g>
<!-- docgen.emitters.cli_notebook&#45;&gt;docgen.emitters.notebook -->
<g id="edge30" class="edge imports">
<title>docgen.emitters.cli_notebook&#45;&gt;docgen.emitters.notebook</title>
<path fill="none" stroke="#4a4a4a" d="M350.73,-437.53C349.39,-427.19 347.67,-413.94 346.19,-402.56"/>
<polygon fill="#4a4a4a" stroke="#4a4a4a" points="348.31,-402.58 345.46,-396.9 344.15,-403.12 348.31,-402.58"/>
</g>
<!-- docgen.emitters.cli_notebook&#45;&gt;docgen.ir.__main__ -->
<g id="edge31" class="edge imports">
<title>docgen.emitters.cli_notebook&#45;&gt;docgen.ir.__main__</title>
<path fill="none" stroke="#4a4a4a" d="M299.75,-442.83C292.76,-439.03 286.48,-434.04 282,-427.5 262.78,-399.45 264.25,-380 282,-351 305.58,-312.47 419.58,-270.94 463,-258.5 568.9,-228.17 882.47,-211.33 1040.59,-204.56"/>
<polygon fill="#4a4a4a" stroke="#4a4a4a" points="1040.59,-206.66 1046.49,-204.31 1040.41,-202.47 1040.59,-206.66"/>
</g>
<!-- docgen.notebook.spec -->
<g id="docgen.notebook.spec" class="node module">
<title>docgen.notebook.spec</title>
<g id="a_docgen.notebook.spec"><a xlink:href="notebook/spec.py" xlink:title="The notebook, as a sequence — before it is a `.ipynb`.">
<path fill="#1a1a1a" stroke="#4a4a4a" d="M175.65,-395C175.65,-395 142.35,-395 142.35,-395 136.35,-395 130.35,-389 130.35,-383 130.35,-383 130.35,-371 130.35,-371 130.35,-365 136.35,-359 142.35,-359 142.35,-359 175.65,-359 175.65,-359 181.65,-359 187.65,-365 187.65,-371 187.65,-371 187.65,-383 187.65,-383 187.65,-389 181.65,-395 175.65,-395"/>
<text xml:space="preserve" text-anchor="middle" x="159" y="-373.3" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="11.00" fill="#e5e5e5">spec</text>
</a>
</g>
</g>
<!-- docgen.emitters.cli_notebook&#45;&gt;docgen.notebook.spec -->
<g id="edge32" class="edge imports">
<title>docgen.emitters.cli_notebook&#45;&gt;docgen.notebook.spec</title>
<path fill="none" stroke="#4a4a4a" d="M299.78,-450.99C273.23,-447.38 241.26,-440.52 215,-427.5 212.31,-426.16 209.64,-424.66 207.02,-423.04"/>
<polygon fill="#4a4a4a" stroke="#4a4a4a" points="208.42,-421.45 202.26,-419.88 206.1,-424.95 208.42,-421.45"/>
</g>
<!-- docgen.emitters.cli_site&#45;&gt;docgen.emitters.dot -->
<g id="edge33" class="edge imports">
<title>docgen.emitters.cli_site&#45;&gt;docgen.emitters.dot</title>
<path fill="none" stroke="#4a4a4a" d="M596.5,-437.71C598.64,-434.48 600.59,-431.01 602,-427.5 616.53,-391.25 619.77,-345.53 620.27,-317.95"/>
<polygon fill="#4a4a4a" stroke="#4a4a4a" points="622.37,-318.21 620.33,-312.19 618.17,-318.17 622.37,-318.21"/>
</g>
<!-- docgen.emitters.cli_site&#45;&gt;docgen.emitters.erd -->
<g id="edge34" class="edge imports">
<title>docgen.emitters.cli_site&#45;&gt;docgen.emitters.erd</title>
<path fill="none" stroke="#4a4a4a" d="M617.99,-442.29C623.94,-440.58 630.1,-439.06 636,-438 658.34,-433.98 824.37,-443.96 840,-427.5 868.18,-397.83 846.89,-347.1 830.09,-317.3"/>
<polygon fill="#4a4a4a" stroke="#4a4a4a" points="831.98,-316.36 827.15,-312.23 828.34,-318.47 831.98,-316.36"/>
</g>
<!-- docgen.emitters.site -->
<g id="docgen.emitters.site" class="node module">
<title>docgen.emitters.site</title>
<g id="a_docgen.emitters.site"><a xlink:href="emitters/site.py" xlink:title="IR &#45;&gt; a self&#45;contained documentation site: sidebar, content, graph viewer.">
<path fill="#1a1a1a" stroke="#4a4a4a" d="M700,-395C700,-395 670,-395 670,-395 664,-395 658,-389 658,-383 658,-383 658,-371 658,-371 658,-365 664,-359 670,-359 670,-359 700,-359 700,-359 706,-359 712,-365 712,-371 712,-371 712,-383 712,-383 712,-389 706,-395 700,-395"/>
<text xml:space="preserve" text-anchor="middle" x="685" y="-373.3" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="11.00" fill="#e5e5e5">site</text>
</a>
</g>
</g>
<!-- docgen.emitters.cli_site&#45;&gt;docgen.emitters.site -->
<g id="edge35" class="edge imports">
<title>docgen.emitters.cli_site&#45;&gt;docgen.emitters.site</title>
<path fill="none" stroke="#4a4a4a" d="M615.59,-437.51C620.88,-434.38 626.18,-430.99 631,-427.5 642.19,-419.39 653.62,-409.25 663.05,-400.26"/>
<polygon fill="#4a4a4a" stroke="#4a4a4a" points="664.42,-401.86 667.28,-396.18 661.5,-398.84 664.42,-401.86"/>
</g>
<!-- docgen.emitters.cli_site&#45;&gt;docgen.ir.__main__ -->
<g id="edge36" class="edge imports">
<title>docgen.emitters.cli_site&#45;&gt;docgen.ir.__main__</title>
<path fill="none" stroke="#4a4a4a" d="M618.27,-442.09C624.13,-440.44 630.19,-438.98 636,-438 673.92,-431.58 947.89,-445.27 982,-427.5 1017.15,-409.19 1071.33,-317.98 1104.61,-257.03"/>
<polygon fill="#4a4a4a" stroke="#4a4a4a" points="1106.42,-258.1 1107.44,-251.83 1102.73,-256.1 1106.42,-258.1"/>
</g>
<!-- docgen.emitters.cli_site&#45;&gt;docgen.ops.__main__ -->
<g id="edge37" class="edge imports">
<title>docgen.emitters.cli_site&#45;&gt;docgen.ops.__main__</title>
<path fill="none" stroke="#4a4a4a" d="M545.95,-450.78C513.95,-447.11 465.9,-441.8 424,-438 408.24,-436.57 294.69,-436.95 282,-427.5 257.56,-409.29 244.83,-377.84 238.21,-349.99"/>
<polygon fill="#4a4a4a" stroke="#4a4a4a" points="240.33,-349.87 236.98,-344.47 236.23,-350.79 240.33,-349.87"/>
</g>
<!-- docgen.emitters.cli_site&#45;&gt;docgen.style -->
<g id="edge38" class="edge imports">
<title>docgen.emitters.cli_site&#45;&gt;docgen.style</title>
<path fill="none" stroke="#4a4a4a" d="M545.99,-450.3C514.01,-446.33 465.98,-440.84 424,-438 413.49,-437.29 52.34,-435.05 45,-427.5 21.29,-403.13 31.4,-382.16 45,-351 51.16,-336.89 62.67,-324.7 74.12,-315.21"/>
<polygon fill="#4a4a4a" stroke="#4a4a4a" points="75.13,-317.09 78.52,-311.72 72.52,-313.8 75.13,-317.09"/>
</g>
<!-- docgen.emitters.explore&#45;&gt;docgen.emitters.dot -->
<g id="edge39" class="edge imports">
<title>docgen.emitters.explore&#45;&gt;docgen.emitters.dot</title>
<path fill="none" stroke="#4a4a4a" d="M750.98,-358.61C744.17,-355.68 736.97,-352.96 730,-351 701.98,-343.11 690.85,-356.38 665,-343 653.21,-336.9 642.98,-326.38 635.27,-316.6"/>
<polygon fill="#4a4a4a" stroke="#4a4a4a" points="636.99,-315.4 631.71,-311.87 633.63,-317.93 636.99,-315.4"/>
</g>
<!-- docgen.emitters.explore&#45;&gt;docgen.emitters.erd -->
<g id="edge40" class="edge imports">
<title>docgen.emitters.explore&#45;&gt;docgen.emitters.erd</title>
<path fill="none" stroke="#4a4a4a" d="M791.36,-358.5C795.66,-346.69 801.39,-330.94 806.13,-317.89"/>
<polygon fill="#4a4a4a" stroke="#4a4a4a" points="808.05,-318.75 808.13,-312.39 804.11,-317.31 808.05,-318.75"/>
</g>
<!-- docgen.emitters.explore&#45;&gt;docgen.emitters.minimap -->
<g id="edge41" class="edge imports">
<title>docgen.emitters.explore&#45;&gt;docgen.emitters.minimap</title>
<path fill="none" stroke="#4a4a4a" d="M822.43,-360.61C834.56,-355.34 847.99,-349.19 860,-343 876.7,-334.39 894.68,-323.84 909.61,-314.69"/>
<polygon fill="#4a4a4a" stroke="#4a4a4a" points="910.52,-316.6 914.53,-311.66 908.32,-313.02 910.52,-316.6"/>
</g>
<!-- docgen.emitters.explore&#45;&gt;docgen.ops.__main__ -->
<g id="edge42" class="edge imports">
<title>docgen.emitters.explore&#45;&gt;docgen.ops.__main__</title>
<path fill="none" stroke="#4a4a4a" d="M752.32,-358.53C745.15,-355.45 737.47,-352.69 730,-351 707.05,-345.81 347.1,-351.73 286.26,-342.82"/>
<polygon fill="#4a4a4a" stroke="#4a4a4a" points="286.79,-340.78 280.47,-341.52 285.87,-344.88 286.79,-340.78"/>
</g>
<!-- docgen.emitters.site&#45;&gt;docgen.emitters.index -->
<g id="edge43" class="edge imports">
<title>docgen.emitters.site&#45;&gt;docgen.emitters.index</title>
<path fill="none" stroke="#4a4a4a" d="M691.15,-358.5C695.3,-346.69 700.84,-330.94 705.43,-317.89"/>
<polygon fill="#4a4a4a" stroke="#4a4a4a" points="707.35,-318.76 707.36,-312.4 703.39,-317.36 707.35,-318.76"/>
</g>
<!-- docgen.extractors.__main__ -->
<g id="docgen.extractors.__main__" class="node module">
<title>docgen.extractors.__main__</title>
<g id="a_docgen.extractors.__main__"><a xlink:href="extractors/__main__.py" xlink:title="python3 &#45;m docgen.extractors &lt;db|openapi|usage|code&gt; [options]">
<path fill="#1a1a1a" stroke="#4a4a4a" d="M1635.9,-474C1635.9,-474 1580.1,-474 1580.1,-474 1574.1,-474 1568.1,-468 1568.1,-462 1568.1,-462 1568.1,-450 1568.1,-450 1568.1,-444 1574.1,-438 1580.1,-438 1580.1,-438 1635.9,-438 1635.9,-438 1641.9,-438 1647.9,-444 1647.9,-450 1647.9,-450 1647.9,-462 1647.9,-462 1647.9,-468 1641.9,-474 1635.9,-474"/>
<text xml:space="preserve" text-anchor="middle" x="1608" y="-452.3" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="11.00" fill="#e5e5e5">__main__</text>
</a>
</g>
</g>
<!-- docgen.extractors.code_main -->
<g id="docgen.extractors.code_main" class="node module">
<title>docgen.extractors.code_main</title>
<g id="a_docgen.extractors.code_main"><a xlink:href="extractors/code_main.py" xlink:title="python3 &#45;m docgen.extractors code &#45;&#45;root src/ [&#45;&#45;ext .cs] [&#45;o ir.json]">
<path fill="#1a1a1a" stroke="#4a4a4a" d="M1709.65,-395C1709.65,-395 1640.35,-395 1640.35,-395 1634.35,-395 1628.35,-389 1628.35,-383 1628.35,-383 1628.35,-371 1628.35,-371 1628.35,-365 1634.35,-359 1640.35,-359 1640.35,-359 1709.65,-359 1709.65,-359 1715.65,-359 1721.65,-365 1721.65,-371 1721.65,-371 1721.65,-383 1721.65,-383 1721.65,-389 1715.65,-395 1709.65,-395"/>
<text xml:space="preserve" text-anchor="middle" x="1675" y="-373.3" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="11.00" fill="#e5e5e5">code_main</text>
</a>
</g>
</g>
<!-- docgen.extractors.__main__&#45;&gt;docgen.extractors.code_main -->
<g id="edge44" class="edge imports">
<title>docgen.extractors.__main__&#45;&gt;docgen.extractors.code_main</title>
<path fill="none" stroke="#4a4a4a" d="M1623.2,-437.53C1632.63,-426.7 1644.84,-412.67 1655.03,-400.95"/>
<polygon fill="#4a4a4a" stroke="#4a4a4a" points="1656.51,-402.45 1658.86,-396.55 1653.34,-399.69 1656.51,-402.45"/>
</g>
<!-- docgen.extractors.db_main -->
<g id="docgen.extractors.db_main" class="node module">
<title>docgen.extractors.db_main</title>
<g id="a_docgen.extractors.db_main"><a xlink:href="extractors/db_main.py" xlink:title="python3 &#45;m docgen.extractors.db &#45;&#45;schema path/to/schema.json [&#45;o ir.json]">
<path fill="#1a1a1a" stroke="#4a4a4a" d="M1443.53,-395C1443.53,-395 1388.47,-395 1388.47,-395 1382.47,-395 1376.47,-389 1376.47,-383 1376.47,-383 1376.47,-371 1376.47,-371 1376.47,-365 1382.47,-359 1388.47,-359 1388.47,-359 1443.53,-359 1443.53,-359 1449.53,-359 1455.53,-365 1455.53,-371 1455.53,-371 1455.53,-383 1455.53,-383 1455.53,-389 1449.53,-395 1443.53,-395"/>
<text xml:space="preserve" text-anchor="middle" x="1416" y="-373.3" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="11.00" fill="#e5e5e5">db_main</text>
</a>
</g>
</g>
<!-- docgen.extractors.__main__&#45;&gt;docgen.extractors.db_main -->
<g id="edge45" class="edge imports">
<title>docgen.extractors.__main__&#45;&gt;docgen.extractors.db_main</title>
<path fill="none" stroke="#4a4a4a" d="M1567.6,-452.03C1540.09,-448.75 1503.45,-441.89 1474,-427.5 1460.22,-420.77 1447.04,-410 1436.74,-400.22"/>
<polygon fill="#4a4a4a" stroke="#4a4a4a" points="1438.46,-398.97 1432.7,-396.28 1435.53,-401.98 1438.46,-398.97"/>
</g>
<!-- docgen.extractors.openapi_main -->
<g id="docgen.extractors.openapi_main" class="node module">
<title>docgen.extractors.openapi_main</title>
<g id="a_docgen.extractors.openapi_main"><a xlink:href="extractors/openapi_main.py" xlink:title="python3 &#45;m docgen.extractors.openapi &#45;&#45;spec petstore.yaml [&#45;o ir.json]">
<path fill="#1a1a1a" stroke="#4a4a4a" d="M1858.4,-395C1858.4,-395 1769.6,-395 1769.6,-395 1763.6,-395 1757.6,-389 1757.6,-383 1757.6,-383 1757.6,-371 1757.6,-371 1757.6,-365 1763.6,-359 1769.6,-359 1769.6,-359 1858.4,-359 1858.4,-359 1864.4,-359 1870.4,-365 1870.4,-371 1870.4,-371 1870.4,-383 1870.4,-383 1870.4,-389 1864.4,-395 1858.4,-395"/>
<text xml:space="preserve" text-anchor="middle" x="1814" y="-373.3" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="11.00" fill="#e5e5e5">openapi_main</text>
</a>
</g>
</g>
<!-- docgen.extractors.__main__&#45;&gt;docgen.extractors.openapi_main -->
<g id="edge46" class="edge imports">
<title>docgen.extractors.__main__&#45;&gt;docgen.extractors.openapi_main</title>
<path fill="none" stroke="#4a4a4a" d="M1648.18,-450.7C1674.97,-446.87 1710.54,-439.87 1740,-427.5 1756.68,-420.5 1773.58,-409.55 1787.01,-399.72"/>
<polygon fill="#4a4a4a" stroke="#4a4a4a" points="1788.16,-401.49 1791.71,-396.21 1785.65,-398.12 1788.16,-401.49"/>
</g>
<!-- docgen.extractors.usage_main -->
<g id="docgen.extractors.usage_main" class="node module">
<title>docgen.extractors.usage_main</title>
<g id="a_docgen.extractors.usage_main"><a xlink:href="extractors/usage_main.py" xlink:title="python3 &#45;m docgen.extractors usage &#45;&#45;har session.har [&#45;o ir.json]">
<path fill="#1a1a1a" stroke="#4a4a4a" d="M1580.4,-395C1580.4,-395 1503.6,-395 1503.6,-395 1497.6,-395 1491.6,-389 1491.6,-383 1491.6,-383 1491.6,-371 1491.6,-371 1491.6,-365 1497.6,-359 1503.6,-359 1503.6,-359 1580.4,-359 1580.4,-359 1586.4,-359 1592.4,-365 1592.4,-371 1592.4,-371 1592.4,-383 1592.4,-383 1592.4,-389 1586.4,-395 1580.4,-395"/>
<text xml:space="preserve" text-anchor="middle" x="1542" y="-373.3" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="11.00" fill="#e5e5e5">usage_main</text>
</a>
</g>
</g>
<!-- docgen.extractors.__main__&#45;&gt;docgen.extractors.usage_main -->
<g id="edge47" class="edge imports">
<title>docgen.extractors.__main__&#45;&gt;docgen.extractors.usage_main</title>
<path fill="none" stroke="#4a4a4a" d="M1593.02,-437.53C1583.74,-426.7 1571.71,-412.67 1561.67,-400.95"/>
<polygon fill="#4a4a4a" stroke="#4a4a4a" points="1563.4,-399.74 1557.9,-396.55 1560.21,-402.47 1563.4,-399.74"/>
</g>
<!-- docgen.extractors.code -->
<g id="docgen.extractors.code" class="node module">
<title>docgen.extractors.code</title>
<g id="a_docgen.extractors.code"><a xlink:href="extractors/code.py" xlink:title="Source in several languages &#45;&gt; IR structure, via **tree&#45;sitter**.">
<path fill="#1a1a1a" stroke="#4a4a4a" d="M1692.03,-310.5C1692.03,-310.5 1657.97,-310.5 1657.97,-310.5 1651.97,-310.5 1645.97,-304.5 1645.97,-298.5 1645.97,-298.5 1645.97,-286.5 1645.97,-286.5 1645.97,-280.5 1651.97,-274.5 1657.97,-274.5 1657.97,-274.5 1692.03,-274.5 1692.03,-274.5 1698.03,-274.5 1704.03,-280.5 1704.03,-286.5 1704.03,-286.5 1704.03,-298.5 1704.03,-298.5 1704.03,-304.5 1698.03,-310.5 1692.03,-310.5"/>
<text xml:space="preserve" text-anchor="middle" x="1675" y="-288.8" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="11.00" fill="#e5e5e5">code</text>
</a>
</g>
</g>
<!-- docgen.extractors.code&#45;&gt;docgen.ir.__main__ -->
<g id="edge48" class="edge imports">
<title>docgen.extractors.code&#45;&gt;docgen.ir.__main__</title>
<path fill="none" stroke="#4a4a4a" d="M1645.79,-280.71C1625.15,-273.46 1596.7,-264.22 1571,-258.5 1435.59,-228.36 1272.81,-212.07 1189.34,-205.13"/>
<polygon fill="#4a4a4a" stroke="#4a4a4a" points="1189.66,-203.05 1183.51,-204.65 1189.31,-207.24 1189.66,-203.05"/>
</g>
<!-- tree_sitter -->
<g id="tree_sitter" class="node external">
<title>tree_sitter</title>
<path fill="#141414" stroke="#555568" stroke-dasharray="5,2" d="M1975.65,-218C1975.65,-218 1906.35,-218 1906.35,-218 1900.35,-218 1894.35,-212 1894.35,-206 1894.35,-206 1894.35,-194 1894.35,-194 1894.35,-188 1900.35,-182 1906.35,-182 1906.35,-182 1975.65,-182 1975.65,-182 1981.65,-182 1987.65,-188 1987.65,-194 1987.65,-194 1987.65,-206 1987.65,-206 1987.65,-212 1981.65,-218 1975.65,-218"/>
<text xml:space="preserve" text-anchor="middle" x="1941" y="-196.3" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="11.00" fill="#a3a3a3">tree_sitter</text>
</g>
<!-- docgen.extractors.code&#45;&gt;tree_sitter -->
<g id="edge49" class="edge imports">
<title>docgen.extractors.code&#45;&gt;tree_sitter</title>
<path fill="none" stroke="#4a4a4a" d="M1704.39,-279.01C1719.92,-272.58 1739.37,-264.79 1757,-258.5 1800.29,-243.05 1850.09,-227.63 1886.96,-216.65"/>
<polygon fill="#4a4a4a" stroke="#4a4a4a" points="1887.55,-218.67 1892.7,-214.95 1886.35,-214.64 1887.55,-218.67"/>
</g>
<!-- docgen.extractors.code_main&#45;&gt;docgen.extractors.code -->
<g id="edge50" class="edge imports">
<title>docgen.extractors.code_main&#45;&gt;docgen.extractors.code</title>
<path fill="none" stroke="#4a4a4a" d="M1675,-358.5C1675,-346.8 1675,-331.23 1675,-318.26"/>
<polygon fill="#4a4a4a" stroke="#4a4a4a" points="1677.1,-318.48 1675,-312.48 1672.9,-318.48 1677.1,-318.48"/>
</g>
<!-- docgen.extractors.db -->
<g id="docgen.extractors.db" class="node module">
<title>docgen.extractors.db</title>
<g id="a_docgen.extractors.db"><a xlink:href="extractors/db.py" xlink:title="A database schema &#45;&gt; IR.">
<path fill="#1a1a1a" stroke="#4a4a4a" d="M1424,-310.5C1424,-310.5 1394,-310.5 1394,-310.5 1388,-310.5 1382,-304.5 1382,-298.5 1382,-298.5 1382,-286.5 1382,-286.5 1382,-280.5 1388,-274.5 1394,-274.5 1394,-274.5 1424,-274.5 1424,-274.5 1430,-274.5 1436,-280.5 1436,-286.5 1436,-286.5 1436,-298.5 1436,-298.5 1436,-304.5 1430,-310.5 1424,-310.5"/>
<text xml:space="preserve" text-anchor="middle" x="1409" y="-288.8" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="11.00" fill="#e5e5e5">db</text>
</a>
</g>
</g>
<!-- docgen.extractors.db&#45;&gt;docgen.ir.__main__ -->
<g id="edge51" class="edge imports">
<title>docgen.extractors.db&#45;&gt;docgen.ir.__main__</title>
<path fill="none" stroke="#4a4a4a" d="M1385.01,-274.07C1376.49,-268.48 1366.61,-262.67 1357,-258.5 1302.16,-234.74 1235.34,-219.12 1189.22,-210.24"/>
<polygon fill="#4a4a4a" stroke="#4a4a4a" points="1189.77,-208.21 1183.48,-209.15 1188.99,-212.33 1189.77,-208.21"/>
</g>
<!-- docgen.extractors.db_main&#45;&gt;docgen.extractors.db -->
<g id="edge52" class="edge imports">
<title>docgen.extractors.db_main&#45;&gt;docgen.extractors.db</title>
<path fill="none" stroke="#4a4a4a" d="M1414.52,-358.5C1413.52,-346.8 1412.2,-331.23 1411.1,-318.26"/>
<polygon fill="#4a4a4a" stroke="#4a4a4a" points="1413.21,-318.28 1410.61,-312.48 1409.03,-318.64 1413.21,-318.28"/>
</g>
<!-- docgen.extractors.openapi -->
<g id="docgen.extractors.openapi" class="node module">
<title>docgen.extractors.openapi</title>
<g id="a_docgen.extractors.openapi"><a xlink:href="extractors/openapi.py" xlink:title="An OpenAPI document &#45;&gt; IR: the endpoints, and the shapes they carry.">
<path fill="#1a1a1a" stroke="#4a4a4a" d="M1840.78,-310.5C1840.78,-310.5 1787.22,-310.5 1787.22,-310.5 1781.22,-310.5 1775.22,-304.5 1775.22,-298.5 1775.22,-298.5 1775.22,-286.5 1775.22,-286.5 1775.22,-280.5 1781.22,-274.5 1787.22,-274.5 1787.22,-274.5 1840.78,-274.5 1840.78,-274.5 1846.78,-274.5 1852.78,-280.5 1852.78,-286.5 1852.78,-286.5 1852.78,-298.5 1852.78,-298.5 1852.78,-304.5 1846.78,-310.5 1840.78,-310.5"/>
<text xml:space="preserve" text-anchor="middle" x="1814" y="-288.8" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="11.00" fill="#e5e5e5">openapi</text>
</a>
</g>
</g>
<!-- docgen.extractors.openapi&#45;&gt;docgen.ir.__main__ -->
<g id="edge53" class="edge imports">
<title>docgen.extractors.openapi&#45;&gt;docgen.ir.__main__</title>
<path fill="none" stroke="#4a4a4a" d="M1774.78,-275.02C1758.69,-268.9 1739.72,-262.48 1722,-258.5 1529.54,-215.27 1294.41,-204.54 1189.34,-201.88"/>
<polygon fill="#4a4a4a" stroke="#4a4a4a" points="1189.56,-199.78 1183.51,-201.74 1189.46,-203.98 1189.56,-199.78"/>
</g>
<!-- modelgen.loader.extract.openapi -->
<g id="modelgen.loader.extract.openapi" class="node external">
<title>modelgen.loader.extract.openapi</title>
<path fill="#141414" stroke="#555568" stroke-dasharray="5,2" d="M2088.78,-218C2088.78,-218 2035.22,-218 2035.22,-218 2029.22,-218 2023.22,-212 2023.22,-206 2023.22,-206 2023.22,-194 2023.22,-194 2023.22,-188 2029.22,-182 2035.22,-182 2035.22,-182 2088.78,-182 2088.78,-182 2094.78,-182 2100.78,-188 2100.78,-194 2100.78,-194 2100.78,-206 2100.78,-206 2100.78,-212 2094.78,-218 2088.78,-218"/>
<text xml:space="preserve" text-anchor="middle" x="2062" y="-196.3" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="11.00" fill="#a3a3a3">openapi</text>
</g>
<!-- docgen.extractors.openapi&#45;&gt;modelgen.loader.extract.openapi -->
<g id="edge54" class="edge imports">
<title>docgen.extractors.openapi&#45;&gt;modelgen.loader.extract.openapi</title>
<path fill="none" stroke="#4a4a4a" d="M1853.08,-289C1893.3,-285.12 1956.85,-275.29 2006,-250.5 2019.29,-243.79 2031.92,-233.16 2041.81,-223.46"/>
<polygon fill="#4a4a4a" stroke="#4a4a4a" points="2043.22,-225.02 2045.94,-219.27 2040.23,-222.06 2043.22,-225.02"/>
</g>
<!-- docgen.extractors.openapi_main&#45;&gt;docgen.extractors.openapi -->
<g id="edge55" class="edge imports">
<title>docgen.extractors.openapi_main&#45;&gt;docgen.extractors.openapi</title>
<path fill="none" stroke="#4a4a4a" d="M1814,-358.5C1814,-346.8 1814,-331.23 1814,-318.26"/>
<polygon fill="#4a4a4a" stroke="#4a4a4a" points="1816.1,-318.48 1814,-312.48 1811.9,-318.48 1816.1,-318.48"/>
</g>
<!-- docgen.extractors.python.__main__ -->
<g id="docgen.extractors.python.__main__" class="node module">
<title>docgen.extractors.python.__main__</title>
<g id="a_docgen.extractors.python.__main__"><a xlink:href="extractors/python/__main__.py" xlink:title="python3 &#45;m docgen.extractors.python &#45;&#45;root PATH [&#45;&#45;exclude NAME ...]">
<path fill="#1a1a1a" stroke="#4a4a4a" d="M1311.9,-474C1311.9,-474 1256.1,-474 1256.1,-474 1250.1,-474 1244.1,-468 1244.1,-462 1244.1,-462 1244.1,-450 1244.1,-450 1244.1,-444 1250.1,-438 1256.1,-438 1256.1,-438 1311.9,-438 1311.9,-438 1317.9,-438 1323.9,-444 1323.9,-450 1323.9,-450 1323.9,-462 1323.9,-462 1323.9,-468 1317.9,-474 1311.9,-474"/>
<text xml:space="preserve" text-anchor="middle" x="1284" y="-452.3" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="11.00" fill="#e5e5e5">__main__</text>
</a>
</g>
</g>
<!-- docgen.extractors.python.collect -->
<g id="docgen.extractors.python.collect" class="node module">
<title>docgen.extractors.python.collect</title>
<g id="a_docgen.extractors.python.collect"><a xlink:href="extractors/python/collect.py" xlink:title="Pass one: read each module, record what it defines and what it imports.">
<path fill="#1a1a1a" stroke="#4a4a4a" d="M1327.03,-310.5C1327.03,-310.5 1280.97,-310.5 1280.97,-310.5 1274.97,-310.5 1268.97,-304.5 1268.97,-298.5 1268.97,-298.5 1268.97,-286.5 1268.97,-286.5 1268.97,-280.5 1274.97,-274.5 1280.97,-274.5 1280.97,-274.5 1327.03,-274.5 1327.03,-274.5 1333.03,-274.5 1339.03,-280.5 1339.03,-286.5 1339.03,-286.5 1339.03,-298.5 1339.03,-298.5 1339.03,-304.5 1333.03,-310.5 1327.03,-310.5"/>
<text xml:space="preserve" text-anchor="middle" x="1304" y="-288.8" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="11.00" fill="#e5e5e5">collect</text>
</a>
</g>
</g>
<!-- docgen.extractors.python.__main__&#45;&gt;docgen.extractors.python.collect -->
<g id="edge56" class="edge imports">
<title>docgen.extractors.python.__main__&#45;&gt;docgen.extractors.python.collect</title>
<path fill="none" stroke="#4a4a4a" d="M1312.35,-437.77C1315.43,-434.73 1318.12,-431.31 1320,-427.5 1337.52,-392.07 1325,-345.14 1314.42,-317.26"/>
<polygon fill="#4a4a4a" stroke="#4a4a4a" points="1316.51,-316.82 1312.35,-312.01 1312.6,-318.36 1316.51,-316.82"/>
</g>
<!-- docgen.extractors.python.resolve -->
<g id="docgen.extractors.python.resolve" class="node module">
<title>docgen.extractors.python.resolve</title>
<g id="a_docgen.extractors.python.resolve"><a xlink:href="extractors/python/resolve.py" xlink:title="Pass two: turn collected names into ids, and collected facts into an IR.">
<path fill="#1a1a1a" stroke="#4a4a4a" d="M1290.28,-395C1290.28,-395 1239.72,-395 1239.72,-395 1233.72,-395 1227.72,-389 1227.72,-383 1227.72,-383 1227.72,-371 1227.72,-371 1227.72,-365 1233.72,-359 1239.72,-359 1239.72,-359 1290.28,-359 1290.28,-359 1296.28,-359 1302.28,-365 1302.28,-371 1302.28,-371 1302.28,-383 1302.28,-383 1302.28,-389 1296.28,-395 1290.28,-395"/>
<text xml:space="preserve" text-anchor="middle" x="1265" y="-373.3" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="11.00" fill="#e5e5e5">resolve</text>
</a>
</g>
</g>
<!-- docgen.extractors.python.__main__&#45;&gt;docgen.extractors.python.resolve -->
<g id="edge57" class="edge imports">
<title>docgen.extractors.python.__main__&#45;&gt;docgen.extractors.python.resolve</title>
<path fill="none" stroke="#4a4a4a" d="M1279.69,-437.53C1277.14,-427.19 1273.87,-413.94 1271.06,-402.56"/>
<polygon fill="#4a4a4a" stroke="#4a4a4a" points="1273.13,-402.19 1269.66,-396.87 1269.06,-403.2 1273.13,-402.19"/>
</g>
<!-- docgen.extractors.python.resolve&#45;&gt;docgen.extractors.python.collect -->
<g id="edge58" class="edge imports">
<title>docgen.extractors.python.resolve&#45;&gt;docgen.extractors.python.collect</title>
<path fill="none" stroke="#4a4a4a" d="M1273.27,-358.5C1278.91,-346.58 1286.44,-330.64 1292.64,-317.53"/>
<polygon fill="#4a4a4a" stroke="#4a4a4a" points="1294.43,-318.66 1295.09,-312.34 1290.63,-316.87 1294.43,-318.66"/>
</g>
<!-- docgen.extractors.python.resolve&#45;&gt;docgen.ir.__main__ -->
<g id="edge59" class="edge imports">
<title>docgen.extractors.python.resolve&#45;&gt;docgen.ir.__main__</title>
<path fill="none" stroke="#4a4a4a" d="M1258.04,-358.88C1247.79,-334.9 1227,-290.65 1201,-258.5 1196.85,-253.37 1192.2,-248.34 1187.34,-243.52"/>
<polygon fill="#4a4a4a" stroke="#4a4a4a" points="1188.87,-242.08 1183.09,-239.43 1185.96,-245.1 1188.87,-242.08"/>
</g>
<!-- docgen.extractors.usage -->
<g id="docgen.extractors.usage" class="node module">
<title>docgen.extractors.usage</title>
<g id="a_docgen.extractors.usage"><a xlink:href="extractors/usage.py" xlink:title="Recorded traffic &#45;&gt; IR. What callers actually do, rather than what exists.">
<path fill="#1a1a1a" stroke="#4a4a4a" d="M1540.78,-310.5C1540.78,-310.5 1499.22,-310.5 1499.22,-310.5 1493.22,-310.5 1487.22,-304.5 1487.22,-298.5 1487.22,-298.5 1487.22,-286.5 1487.22,-286.5 1487.22,-280.5 1493.22,-274.5 1499.22,-274.5 1499.22,-274.5 1540.78,-274.5 1540.78,-274.5 1546.78,-274.5 1552.78,-280.5 1552.78,-286.5 1552.78,-286.5 1552.78,-298.5 1552.78,-298.5 1552.78,-304.5 1546.78,-310.5 1540.78,-310.5"/>
<text xml:space="preserve" text-anchor="middle" x="1520" y="-288.8" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="11.00" fill="#e5e5e5">usage</text>
</a>
</g>
</g>
<!-- docgen.extractors.usage&#45;&gt;docgen.ir.__main__ -->
<g id="edge60" class="edge imports">
<title>docgen.extractors.usage&#45;&gt;docgen.ir.__main__</title>
<path fill="none" stroke="#4a4a4a" d="M1489.89,-274.17C1478.89,-268.46 1466.16,-262.54 1454,-258.5 1363.89,-228.56 1254.02,-213.14 1189.28,-206.09"/>
<polygon fill="#4a4a4a" stroke="#4a4a4a" points="1189.69,-204.02 1183.5,-205.47 1189.24,-208.19 1189.69,-204.02"/>
</g>
<!-- docgen.extractors.usage_main&#45;&gt;docgen.extractors.usage -->
<g id="edge61" class="edge imports">
<title>docgen.extractors.usage_main&#45;&gt;docgen.extractors.usage</title>
<path fill="none" stroke="#4a4a4a" d="M1537.33,-358.5C1534.18,-346.69 1529.98,-330.94 1526.51,-317.89"/>
<polygon fill="#4a4a4a" stroke="#4a4a4a" points="1528.62,-317.69 1525.05,-312.43 1524.57,-318.77 1528.62,-317.69"/>
</g>
<!-- docgen.ir.model -->
<g id="docgen.ir.model" class="node module">
<title>docgen.ir.model</title>
<g id="a_docgen.ir.model"><a xlink:href="ir/model.py" xlink:title="The IR, as Python. Mirrors `schema.json`, which is the contract.">
<path fill="#1a1a1a" stroke="#4a4a4a" d="M1132.15,-60C1132.15,-60 1089.85,-60 1089.85,-60 1083.85,-60 1077.85,-54 1077.85,-48 1077.85,-48 1077.85,-36 1077.85,-36 1077.85,-30 1083.85,-24 1089.85,-24 1089.85,-24 1132.15,-24 1132.15,-24 1138.15,-24 1144.15,-30 1144.15,-36 1144.15,-36 1144.15,-48 1144.15,-48 1144.15,-54 1138.15,-60 1132.15,-60"/>
<text xml:space="preserve" text-anchor="middle" x="1111" y="-38.3" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="11.00" fill="#e5e5e5">model</text>
</a>
</g>
</g>
<!-- docgen.ir.__main__&#45;&gt;docgen.ir.model -->
<g id="edge62" class="edge imports">
<title>docgen.ir.__main__&#45;&gt;docgen.ir.model</title>
<path fill="none" stroke="#4a4a4a" d="M1109.91,-181.64C1097.23,-170.96 1082.9,-156.06 1076,-139 1066.1,-114.51 1079.55,-86.04 1092.55,-66.5"/>
<polygon fill="#4a4a4a" stroke="#4a4a4a" points="1094.16,-67.85 1095.85,-61.72 1090.71,-65.46 1094.16,-67.85"/>
</g>
<!-- docgen.ir.validate -->
<g id="docgen.ir.validate" class="node module">
<title>docgen.ir.validate</title>
<g id="a_docgen.ir.validate"><a xlink:href="ir/validate.py" xlink:title="Check an IR document at the boundary.">
<path fill="#1a1a1a" stroke="#4a4a4a" d="M1161.53,-139C1161.53,-139 1106.47,-139 1106.47,-139 1100.47,-139 1094.47,-133 1094.47,-127 1094.47,-127 1094.47,-115 1094.47,-115 1094.47,-109 1100.47,-103 1106.47,-103 1106.47,-103 1161.53,-103 1161.53,-103 1167.53,-103 1173.53,-109 1173.53,-115 1173.53,-115 1173.53,-127 1173.53,-127 1173.53,-133 1167.53,-139 1161.53,-139"/>
<text xml:space="preserve" text-anchor="middle" x="1134" y="-117.3" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="11.00" fill="#e5e5e5">validate</text>
</a>
</g>
</g>
<!-- docgen.ir.__main__&#45;&gt;docgen.ir.validate -->
<g id="edge63" class="edge imports">
<title>docgen.ir.__main__&#45;&gt;docgen.ir.validate</title>
<path fill="none" stroke="#4a4a4a" d="M1122.48,-181.53C1120.31,-171.19 1119.92,-157.94 1121.32,-146.56"/>
<polygon fill="#4a4a4a" stroke="#4a4a4a" points="1123.35,-147.16 1122.25,-140.9 1119.2,-146.48 1123.35,-147.16"/>
</g>
<!-- docgen.ir.__main__&#45;&gt;docgen.ir.validate -->
<g id="edge64" class="edge imports">
<title>docgen.ir.__main__&#45;&gt;docgen.ir.validate</title>
<path fill="none" stroke="#4a4a4a" d="M1145.52,-181.53C1147.69,-171.19 1148.08,-157.94 1146.68,-146.56"/>
<polygon fill="#4a4a4a" stroke="#4a4a4a" points="1148.8,-146.48 1145.75,-140.9 1144.65,-147.16 1148.8,-146.48"/>
</g>
<!-- docgen.ir.validate&#45;&gt;docgen.ir.model -->
<g id="edge65" class="edge imports">
<title>docgen.ir.validate&#45;&gt;docgen.ir.model</title>
<path fill="none" stroke="#4a4a4a" d="M1128.78,-102.53C1125.69,-92.19 1121.73,-78.94 1118.34,-67.56"/>
<polygon fill="#4a4a4a" stroke="#4a4a4a" points="1120.36,-67 1116.63,-61.85 1116.34,-68.2 1120.36,-67"/>
</g>
<!-- docgen.lab.pg_probe -->
<g id="docgen.lab.pg_probe" class="node module">
<title>docgen.lab.pg_probe</title>
<g id="a_docgen.lab.pg_probe"><a xlink:href="lab/pg_probe.py" xlink:title="EXPERIMENT — a live PostgreSQL schema, as a graphgen&#45;compatible `schema.json`.">
<path fill="#1a1a1a" stroke="#4a4a4a" d="M1445.15,-591C1445.15,-591 1384.85,-591 1384.85,-591 1378.85,-591 1372.85,-585 1372.85,-579 1372.85,-579 1372.85,-567 1372.85,-567 1372.85,-561 1378.85,-555 1384.85,-555 1384.85,-555 1445.15,-555 1445.15,-555 1451.15,-555 1457.15,-561 1457.15,-567 1457.15,-567 1457.15,-579 1457.15,-579 1457.15,-585 1451.15,-591 1445.15,-591"/>
<text xml:space="preserve" text-anchor="middle" x="1415" y="-569.3" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="11.00" fill="#e5e5e5">pg_probe</text>
</a>
</g>
</g>
<!-- docgen.ops.__main__&#45;&gt;docgen.ir.__main__ -->
<g id="edge67" class="edge imports">
<title>docgen.ops.__main__&#45;&gt;docgen.ir.__main__</title>
<path fill="none" stroke="#4a4a4a" d="M253.05,-274.07C261.87,-268.06 272.43,-261.96 283,-258.5 421.48,-213.15 850,-203.54 1040.76,-201.53"/>
<polygon fill="#4a4a4a" stroke="#4a4a4a" points="1040.51,-203.63 1046.49,-201.47 1040.47,-199.43 1040.51,-203.63"/>
</g>
<!-- docgen.ops.filter -->
<g id="docgen.ops.filter" class="node module">
<title>docgen.ops.filter</title>
<g id="a_docgen.ops.filter"><a xlink:href="ops/filter.py" xlink:title="Views: IR in, smaller IR out.">
<path fill="#1a1a1a" stroke="#4a4a4a" d="M248.4,-218C248.4,-218 213.6,-218 213.6,-218 207.6,-218 201.6,-212 201.6,-206 201.6,-206 201.6,-194 201.6,-194 201.6,-188 207.6,-182 213.6,-182 213.6,-182 248.4,-182 248.4,-182 254.4,-182 260.4,-188 260.4,-194 260.4,-194 260.4,-206 260.4,-206 260.4,-212 254.4,-218 248.4,-218"/>
<text xml:space="preserve" text-anchor="middle" x="231" y="-196.3" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="11.00" fill="#e5e5e5">filter</text>
</a>
</g>
</g>
<!-- docgen.ops.__main__&#45;&gt;docgen.ops.filter -->
<g id="edge66" class="edge imports">
<title>docgen.ops.__main__&#45;&gt;docgen.ops.filter</title>
<path fill="none" stroke="#4a4a4a" d="M231,-274.05C231,-260.28 231,-240.9 231,-225.56"/>
<polygon fill="#4a4a4a" stroke="#4a4a4a" points="233.1,-225.91 231,-219.91 228.9,-225.91 233.1,-225.91"/>
</g>
<!-- docgen.selftest -->
<g id="docgen.selftest" class="node module">
<title>docgen.selftest</title>
<g id="a_docgen.selftest"><a xlink:href="selftest.py" xlink:title="Prove the pipeline, offline, on a tree it builds itself.">
<path fill="#1a1a1a" stroke="#4a4a4a" d="M1711.03,-591C1711.03,-591 1658.97,-591 1658.97,-591 1652.97,-591 1646.97,-585 1646.97,-579 1646.97,-579 1646.97,-567 1646.97,-567 1646.97,-561 1652.97,-555 1658.97,-555 1658.97,-555 1711.03,-555 1711.03,-555 1717.03,-555 1723.03,-561 1723.03,-567 1723.03,-567 1723.03,-579 1723.03,-579 1723.03,-585 1717.03,-591 1711.03,-591"/>
<text xml:space="preserve" text-anchor="middle" x="1685" y="-569.3" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="11.00" fill="#e5e5e5">selftest</text>
</a>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 63 KiB

View File

@@ -0,0 +1,103 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="870pt" height="474pt" viewBox="0 0 870 474">
<rect width="870" height="474" fill="#0a0a0a"/>
<defs>
<marker id="fk" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path d="M 0 0 L 10 5 L 0 10 z" fill="#1d4ed8"/></marker>
</defs>
<g class="relationships">
<path d="M 330,103.0 C 280,103.0 300,155.0 250,155.0" fill="none" stroke="#1d4ed8" stroke-width="1.5" marker-end="url(#fk)" class="edge foreign_key"/>
<path d="M 620,155.0 C 570,155.0 590,155.0 540,155.0" fill="none" stroke="#1d4ed8" stroke-width="1.5" marker-end="url(#fk)" class="edge foreign_key"/>
<path d="M 250,369.0 C 300,369.0 280,155.0 330,155.0" fill="none" stroke="#1d4ed8" stroke-width="1.5" marker-end="url(#fk)" class="edge foreign_key"/>
</g>
<g class="table">
<rect x="40" y="40" width="210" height="154" rx="8" fill="#0a0a0a" stroke="#333333" stroke-width="1" data-id="Customer" data-kind="table" class="blk"/>
<path d="M 40,48 a 8,8 0 0 1 8,-8 h 194 a 8,8 0 0 1 8,8 v 42 h -210 z" fill="#1a1a1a"/>
<line x1="40" y1="90" x2="250" y2="90" stroke="#333333" stroke-width="1"/>
<text x="52" y="62" font-family="Helvetica,sans-Serif" font-size="12" font-weight="bold" fill="#e5e5e5">Customer</text>
<text x="52" y="78" font-family="Helvetica,sans-Serif" font-size="9" fill="#666666">A fixture customer (obviously…</text>
<text x="80" y="107" font-family="Helvetica,sans-Serif" font-size="10" fill="#e5e5e5">created_at</text>
<text x="238" y="107" text-anchor="end" font-family="Helvetica,sans-Serif" font-size="9" fill="#666666">datetime</text>
<line x1="41" y1="116" x2="249" y2="116" stroke="#1a1a1a" stroke-width="1"/>
<text x="80" y="133" font-family="Helvetica,sans-Serif" font-size="10" fill="#e5e5e5">email</text>
<text x="238" y="133" text-anchor="end" font-family="Helvetica,sans-Serif" font-size="9" fill="#666666">str</text>
<line x1="41" y1="142" x2="249" y2="142" stroke="#1a1a1a" stroke-width="1"/>
<text x="52" y="159" font-family="Helvetica,sans-Serif" font-size="8" font-weight="bold" fill="#d4a574">PK</text>
<text x="80" y="159" font-family="Helvetica,sans-Serif" font-size="10" fill="#e5e5e5">id</text>
<text x="238" y="159" text-anchor="end" font-family="Helvetica,sans-Serif" font-size="9" fill="#666666">int</text>
<line x1="41" y1="168" x2="249" y2="168" stroke="#1a1a1a" stroke-width="1"/>
<text x="80" y="185" font-family="Helvetica,sans-Serif" font-size="10" fill="#e5e5e5">name</text>
<text x="238" y="185" text-anchor="end" font-family="Helvetica,sans-Serif" font-size="9" fill="#666666">str</text>
</g>
<g class="table">
<rect x="330" y="40" width="210" height="206" rx="8" fill="#0a0a0a" stroke="#333333" stroke-width="1" data-id="Invoice" data-kind="table" class="blk"/>
<path d="M 330,48 a 8,8 0 0 1 8,-8 h 194 a 8,8 0 0 1 8,8 v 42 h -210 z" fill="#1a1a1a"/>
<line x1="330" y1="90" x2="540" y2="90" stroke="#333333" stroke-width="1"/>
<text x="342" y="62" font-family="Helvetica,sans-Serif" font-size="12" font-weight="bold" fill="#e5e5e5">Invoice</text>
<text x="342" y="78" font-family="Helvetica,sans-Serif" font-size="9" fill="#666666">An invoice issued to a custom…</text>
<text x="342" y="107" font-family="Helvetica,sans-Serif" font-size="8" font-weight="bold" fill="#1d4ed8">FK</text>
<text x="370" y="107" font-family="Helvetica,sans-Serif" font-size="10" fill="#e5e5e5">customer_id</text>
<text x="528" y="107" text-anchor="end" font-family="Helvetica,sans-Serif" font-size="9" fill="#666666">Customer</text>
<line x1="331" y1="116" x2="539" y2="116" stroke="#1a1a1a" stroke-width="1"/>
<text x="370" y="133" font-family="Helvetica,sans-Serif" font-size="10" fill="#a3a3a3">due_at</text>
<text x="528" y="133" text-anchor="end" font-family="Helvetica,sans-Serif" font-size="9" fill="#666666">datetime</text>
<line x1="331" y1="142" x2="539" y2="142" stroke="#1a1a1a" stroke-width="1"/>
<text x="342" y="159" font-family="Helvetica,sans-Serif" font-size="8" font-weight="bold" fill="#d4a574">PK</text>
<text x="370" y="159" font-family="Helvetica,sans-Serif" font-size="10" fill="#e5e5e5">id</text>
<text x="528" y="159" text-anchor="end" font-family="Helvetica,sans-Serif" font-size="9" fill="#666666">int</text>
<line x1="331" y1="168" x2="539" y2="168" stroke="#1a1a1a" stroke-width="1"/>
<text x="370" y="185" font-family="Helvetica,sans-Serif" font-size="10" fill="#e5e5e5">issued_at</text>
<text x="528" y="185" text-anchor="end" font-family="Helvetica,sans-Serif" font-size="9" fill="#666666">datetime</text>
<line x1="331" y1="194" x2="539" y2="194" stroke="#1a1a1a" stroke-width="1"/>
<text x="370" y="211" font-family="Helvetica,sans-Serif" font-size="10" fill="#e5e5e5">number</text>
<text x="528" y="211" text-anchor="end" font-family="Helvetica,sans-Serif" font-size="9" fill="#666666">str</text>
<line x1="331" y1="220" x2="539" y2="220" stroke="#1a1a1a" stroke-width="1"/>
<text x="370" y="237" font-family="Helvetica,sans-Serif" font-size="10" fill="#e5e5e5">status</text>
<text x="528" y="237" text-anchor="end" font-family="Helvetica,sans-Serif" font-size="9" fill="#666666">str</text>
</g>
<g class="table">
<rect x="620" y="40" width="210" height="180" rx="8" fill="#0a0a0a" stroke="#333333" stroke-width="1" data-id="LineItem" data-kind="table" class="blk"/>
<path d="M 620,48 a 8,8 0 0 1 8,-8 h 194 a 8,8 0 0 1 8,8 v 42 h -210 z" fill="#1a1a1a"/>
<line x1="620" y1="90" x2="830" y2="90" stroke="#333333" stroke-width="1"/>
<text x="632" y="62" font-family="Helvetica,sans-Serif" font-size="12" font-weight="bold" fill="#e5e5e5">LineItem</text>
<text x="632" y="78" font-family="Helvetica,sans-Serif" font-size="9" fill="#666666">A single billable line on an …</text>
<text x="660" y="107" font-family="Helvetica,sans-Serif" font-size="10" fill="#e5e5e5">description</text>
<text x="818" y="107" text-anchor="end" font-family="Helvetica,sans-Serif" font-size="9" fill="#666666">str</text>
<line x1="621" y1="116" x2="829" y2="116" stroke="#1a1a1a" stroke-width="1"/>
<text x="632" y="133" font-family="Helvetica,sans-Serif" font-size="8" font-weight="bold" fill="#d4a574">PK</text>
<text x="660" y="133" font-family="Helvetica,sans-Serif" font-size="10" fill="#e5e5e5">id</text>
<text x="818" y="133" text-anchor="end" font-family="Helvetica,sans-Serif" font-size="9" fill="#666666">int</text>
<line x1="621" y1="142" x2="829" y2="142" stroke="#1a1a1a" stroke-width="1"/>
<text x="632" y="159" font-family="Helvetica,sans-Serif" font-size="8" font-weight="bold" fill="#1d4ed8">FK</text>
<text x="660" y="159" font-family="Helvetica,sans-Serif" font-size="10" fill="#e5e5e5">invoice_id</text>
<text x="818" y="159" text-anchor="end" font-family="Helvetica,sans-Serif" font-size="9" fill="#666666">Invoice</text>
<line x1="621" y1="168" x2="829" y2="168" stroke="#1a1a1a" stroke-width="1"/>
<text x="660" y="185" font-family="Helvetica,sans-Serif" font-size="10" fill="#e5e5e5">quantity</text>
<text x="818" y="185" text-anchor="end" font-family="Helvetica,sans-Serif" font-size="9" fill="#666666">int</text>
<line x1="621" y1="194" x2="829" y2="194" stroke="#1a1a1a" stroke-width="1"/>
<text x="660" y="211" font-family="Helvetica,sans-Serif" font-size="10" fill="#e5e5e5">unit_price</text>
<text x="818" y="211" text-anchor="end" font-family="Helvetica,sans-Serif" font-size="9" fill="#666666">Decimal</text>
</g>
<g class="table">
<rect x="40" y="254" width="210" height="180" rx="8" fill="#0a0a0a" stroke="#333333" stroke-width="1" data-id="Payment" data-kind="table" class="blk"/>
<path d="M 40,262 a 8,8 0 0 1 8,-8 h 194 a 8,8 0 0 1 8,8 v 42 h -210 z" fill="#1a1a1a"/>
<line x1="40" y1="304" x2="250" y2="304" stroke="#333333" stroke-width="1"/>
<text x="52" y="276" font-family="Helvetica,sans-Serif" font-size="12" font-weight="bold" fill="#e5e5e5">Payment</text>
<text x="52" y="292" font-family="Helvetica,sans-Serif" font-size="9" fill="#666666">A payment recorded against an…</text>
<text x="80" y="321" font-family="Helvetica,sans-Serif" font-size="10" fill="#e5e5e5">amount</text>
<text x="238" y="321" text-anchor="end" font-family="Helvetica,sans-Serif" font-size="9" fill="#666666">Decimal</text>
<line x1="41" y1="330" x2="249" y2="330" stroke="#1a1a1a" stroke-width="1"/>
<text x="52" y="347" font-family="Helvetica,sans-Serif" font-size="8" font-weight="bold" fill="#d4a574">PK</text>
<text x="80" y="347" font-family="Helvetica,sans-Serif" font-size="10" fill="#e5e5e5">id</text>
<text x="238" y="347" text-anchor="end" font-family="Helvetica,sans-Serif" font-size="9" fill="#666666">int</text>
<line x1="41" y1="356" x2="249" y2="356" stroke="#1a1a1a" stroke-width="1"/>
<text x="52" y="373" font-family="Helvetica,sans-Serif" font-size="8" font-weight="bold" fill="#1d4ed8">FK</text>
<text x="80" y="373" font-family="Helvetica,sans-Serif" font-size="10" fill="#e5e5e5">invoice_id</text>
<text x="238" y="373" text-anchor="end" font-family="Helvetica,sans-Serif" font-size="9" fill="#666666">Invoice</text>
<line x1="41" y1="382" x2="249" y2="382" stroke="#1a1a1a" stroke-width="1"/>
<text x="80" y="399" font-family="Helvetica,sans-Serif" font-size="10" fill="#e5e5e5">method</text>
<text x="238" y="399" text-anchor="end" font-family="Helvetica,sans-Serif" font-size="9" fill="#666666">str</text>
<line x1="41" y1="408" x2="249" y2="408" stroke="#1a1a1a" stroke-width="1"/>
<text x="80" y="425" font-family="Helvetica,sans-Serif" font-size="10" fill="#e5e5e5">paid_at</text>
<text x="238" y="425" text-anchor="end" font-family="Helvetica,sans-Serif" font-size="9" fill="#666666">datetime</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 9.4 KiB

View File

@@ -0,0 +1,298 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg xmlns="http://www.w3.org/2000/svg" width="886pt" height="1630pt" viewBox="0 0 886 1630">
<rect width="886" height="1630" fill="#0a0a0a"/>
<rect x="28.0" y="46.0" width="74.0" height="204.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.emitters.site — 408 lines</title></rect>
<rect x="28.0" y="172.5" width="74.0" height="6.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.site._slots" data-kind="function" class="blk"><title>_slots — function, 13 lines</title></rect>
<rect x="28.0" y="180.0" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.site._fill" data-kind="function" class="blk"><title>_fill — function, 4 lines</title></rect>
<rect x="28.0" y="183.0" width="74.0" height="8.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.site._sidebar" data-kind="function" class="blk"><title>_sidebar — function, 17 lines</title></rect>
<rect x="28.0" y="192.5" width="74.0" height="7.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.site._sections" data-kind="function" class="blk"><title>_sections — function, 15 lines</title></rect>
<rect x="28.0" y="201.0" width="74.0" height="43.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.site.emit" data-kind="function" class="blk"><title>emit — function, 86 lines</title></rect>
<rect x="28.0" y="245.0" width="74.0" height="4.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.site.write" data-kind="function" class="blk"><title>write — function, 9 lines</title></rect>
<rect x="110.0" y="46.0" width="74.0" height="158.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.emitters.explore — 316 lines</title></rect>
<rect x="110.0" y="65.5" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.explore._is_schema" data-kind="function" class="blk"><title>_is_schema — function, 4 lines</title></rect>
<rect x="110.0" y="68.5" width="74.0" height="24.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.explore._neighbourhood_svgs" data-kind="function" class="blk"><title>_neighbourhood_svgs — function, 48 lines</title></rect>
<rect x="117.0" y="75.0" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.emitters.explore._neighbourhood_svgs.render_one" data-kind="function" class="blk"><title>render_one — function, 2 lines</title></rect>
<rect x="117.0" y="78.5" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.emitters.explore._neighbourhood_svgs.render_one#L66" data-kind="function" class="blk"><title>render_one — function, 2 lines</title></rect>
<rect x="110.0" y="93.5" width="74.0" height="13.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.explore._facts" data-kind="function" class="blk"><title>_facts — function, 27 lines</title></rect>
<rect x="110.0" y="108.0" width="74.0" height="86.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.explore.emit" data-kind="function" class="blk"><title>emit — function, 172 lines</title></rect>
<rect x="110.0" y="195.0" width="74.0" height="8.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.explore.write" data-kind="function" class="blk"><title>write — function, 17 lines</title></rect>
<rect x="192.0" y="46.0" width="74.0" height="146.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.emitters.dot — 292 lines</title></rect>
<rect x="192.0" y="64.5" width="74.0" height="2.0" rx="2" fill="#1d4ed8" stroke="none" opacity="0.95" data-id="docgen.emitters.dot.RenderError" data-kind="class" class="blk"><title>RenderError — class, 2 lines</title></rect>
<rect x="192.0" y="66.5" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.dot._esc" data-kind="function" class="blk"><title>_esc — function, 2 lines</title></rect>
<rect x="192.0" y="68.5" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.dot._attrs" data-kind="function" class="blk"><title>_attrs — function, 3 lines</title></rect>
<rect x="192.0" y="71.0" width="74.0" height="4.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.dot._style_words" data-kind="function" class="blk"><title>_style_words — function, 9 lines</title></rect>
<rect x="192.0" y="76.5" width="74.0" height="12.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.dot._node_attrs" data-kind="function" class="blk"><title>_node_attrs — function, 24 lines</title></rect>
<rect x="192.0" y="89.5" width="74.0" height="52.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.dot.emit" data-kind="function" class="blk"><title>emit — function, 105 lines</title></rect>
<rect x="199.0" y="103.0" width="60.0" height="14.5" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.emitters.dot.emit.write" data-kind="function" class="blk"><title>write — function, 29 lines</title></rect>
<rect x="192.0" y="143.0" width="74.0" height="4.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.dot._within" data-kind="function" class="blk"><title>_within — function, 8 lines</title></rect>
<rect x="192.0" y="148.0" width="74.0" height="20.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.dot._endpoints" data-kind="function" class="blk"><title>_endpoints — function, 41 lines</title></rect>
<rect x="199.0" y="153.0" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.emitters.dot._endpoints.walk" data-kind="function" class="blk"><title>walk — function, 4 lines</title></rect>
<rect x="199.0" y="157.0" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.emitters.dot._endpoints.collapsed" data-kind="function" class="blk"><title>collapsed — function, 2 lines</title></rect>
<rect x="199.0" y="158.5" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.emitters.dot._endpoints.first_leaf" data-kind="function" class="blk"><title>first_leaf — function, 4 lines</title></rect>
<rect x="192.0" y="169.5" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.dot._safe" data-kind="function" class="blk"><title>_safe — function, 2 lines</title></rect>
<rect x="192.0" y="171.5" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.dot._q" data-kind="function" class="blk"><title>_q — function, 2 lines</title></rect>
<rect x="192.0" y="175.0" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.dot.have_graphviz" data-kind="function" class="blk"><title>have_graphviz — function, 2 lines</title></rect>
<rect x="192.0" y="177.0" width="74.0" height="14.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.dot.render" data-kind="function" class="blk"><title>render — function, 29 lines</title></rect>
<rect x="274.0" y="46.0" width="74.0" height="139.5" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.emitters.notebook — 279 lines</title></rect>
<rect x="274.0" y="67.0" width="74.0" height="6.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.notebook._cell" data-kind="function" class="blk"><title>_cell — function, 13 lines</title></rect>
<rect x="274.0" y="74.5" width="74.0" height="13.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.notebook._example" data-kind="function" class="blk"><title>_example — function, 26 lines</title></rect>
<rect x="274.0" y="108.0" width="74.0" height="9.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.notebook._params_cell" data-kind="function" class="blk"><title>_params_cell — function, 18 lines</title></rect>
<rect x="274.0" y="118.0" width="74.0" height="20.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.notebook._call_cell" data-kind="function" class="blk"><title>_call_cell — function, 40 lines</title></rect>
<rect x="274.0" y="139.0" width="74.0" height="13.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.notebook._call_md" data-kind="function" class="blk"><title>_call_md — function, 26 lines</title></rect>
<rect x="274.0" y="153.0" width="74.0" height="26.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.notebook.build" data-kind="function" class="blk"><title>build — function, 52 lines</title></rect>
<rect x="274.0" y="180.0" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.notebook.emit" data-kind="function" class="blk"><title>emit — function, 3 lines</title></rect>
<rect x="274.0" y="182.5" width="74.0" height="2.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.notebook.write" data-kind="function" class="blk"><title>write — function, 5 lines</title></rect>
<rect x="356.0" y="46.0" width="74.0" height="139.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.emitters.minimap — 278 lines</title></rect>
<rect x="356.0" y="77.5" width="74.0" height="28.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.minimap._files" data-kind="function" class="blk"><title>_files — function, 57 lines</title></rect>
<rect x="363.0" y="81.5" width="60.0" height="9.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.emitters.minimap._files.declared" data-kind="function" class="blk"><title>declared — function, 18 lines</title></rect>
<rect x="363.0" y="91.0" width="60.0" height="5.5" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.emitters.minimap._files.build" data-kind="function" class="blk"><title>build — function, 11 lines</title></rect>
<rect x="356.0" y="107.0" width="74.0" height="3.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.minimap._bands" data-kind="function" class="blk"><title>_bands — function, 7 lines</title></rect>
<rect x="356.0" y="111.5" width="74.0" height="9.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.minimap._blocks" data-kind="function" class="blk"><title>_blocks — function, 19 lines</title></rect>
<rect x="356.0" y="122.0" width="74.0" height="60.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.minimap.emit" data-kind="function" class="blk"><title>emit — function, 120 lines</title></rect>
<rect x="356.0" y="183.0" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.minimap.marks_to_labels" data-kind="function" class="blk"><title>marks_to_labels — function, 3 lines</title></rect>
<rect x="438.0" y="46.0" width="74.0" height="130.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.emitters.erd — 260 lines</title></rect>
<rect x="438.0" y="73.0" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.erd._truncate" data-kind="function" class="blk"><title>_truncate — function, 3 lines</title></rect>
<rect x="438.0" y="75.5" width="74.0" height="10.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.erd._tables" data-kind="function" class="blk"><title>_tables — function, 20 lines</title></rect>
<rect x="438.0" y="86.5" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.erd._card_height" data-kind="function" class="blk"><title>_card_height — function, 3 lines</title></rect>
<rect x="438.0" y="89.0" width="74.0" height="10.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.erd.layout" data-kind="function" class="blk"><title>layout — function, 20 lines</title></rect>
<rect x="438.0" y="100.0" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.erd._field_y" data-kind="function" class="blk"><title>_field_y — function, 3 lines</title></rect>
<rect x="438.0" y="102.5" width="74.0" height="73.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.erd.emit" data-kind="function" class="blk"><title>emit — function, 146 lines</title></rect>
<rect x="520.0" y="46.0" width="74.0" height="82.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.emitters.index — 164 lines</title></rect>
<rect x="520.0" y="63.0" width="74.0" height="4.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.index._tree" data-kind="function" class="blk"><title>_tree — function, 8 lines</title></rect>
<rect x="520.0" y="68.0" width="74.0" height="2.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.index._anchor" data-kind="function" class="blk"><title>_anchor — function, 5 lines</title></rect>
<rect x="520.0" y="71.5" width="74.0" height="42.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.index.to_markdown" data-kind="function" class="blk"><title>to_markdown — function, 84 lines</title></rect>
<rect x="527.0" y="83.0" width="60.0" height="13.5" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.emitters.index.to_markdown.walk" data-kind="function" class="blk"><title>walk — function, 27 lines</title></rect>
<rect x="520.0" y="114.5" width="74.0" height="13.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.index.to_sidebar" data-kind="function" class="blk"><title>to_sidebar — function, 26 lines</title></rect>
<rect x="527.0" y="118.0" width="60.0" height="6.5" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.emitters.index.to_sidebar.build" data-kind="function" class="blk"><title>build — function, 13 lines</title></rect>
<rect x="602.0" y="46.0" width="74.0" height="43.5" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.emitters.cli_dot — 87 lines</title></rect>
<rect x="602.0" y="52.5" width="74.0" height="36.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.cli_dot.main" data-kind="function" class="blk"><title>main — function, 73 lines</title></rect>
<rect x="684.0" y="46.0" width="74.0" height="40.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.emitters.auto — 80 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="54.0" width="74.0" height="28.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.cli_notebook.main" data-kind="function" class="blk"><title>main — function, 57 lines</title></rect>
<rect x="28.0" y="288.0" width="74.0" height="37.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.emitters.cli_site — 74 lines</title></rect>
<rect x="28.0" y="296.0" width="74.0" height="28.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.cli_site.main" data-kind="function" class="blk"><title>main — function, 57 lines</title></rect>
<rect x="110.0" y="288.0" width="74.0" height="25.5" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.emitters.cli_erd — 51 lines</title></rect>
<rect x="110.0" y="294.0" width="74.0" height="19.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.cli_erd.main" data-kind="function" class="blk"><title>main — function, 38 lines</title></rect>
<rect x="192.0" y="288.0" width="74.0" height="25.5" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.emitters.cli_minimap — 51 lines</title></rect>
<rect x="192.0" y="294.5" width="74.0" height="18.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.cli_minimap.main" data-kind="function" class="blk"><title>main — function, 37 lines</title></rect>
<rect x="274.0" y="288.0" width="74.0" height="24.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.emitters.cli_explore — 48 lines</title></rect>
<rect x="274.0" y="294.0" width="74.0" height="17.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.cli_explore.main" data-kind="function" class="blk"><title>main — function, 35 lines</title></rect>
<rect x="356.0" y="288.0" width="74.0" height="22.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.emitters.cli_index — 44 lines</title></rect>
<rect x="356.0" y="293.5" width="74.0" height="16.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.cli_index.main" data-kind="function" class="blk"><title>main — function, 32 lines</title></rect>
<rect x="438.0" y="288.0" width="74.0" height="18.5" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.emitters.__main__ — 37 lines</title></rect>
<rect x="438.0" y="290.5" width="74.0" height="13.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.emitters.__main__.main" data-kind="function" class="blk"><title>main — function, 27 lines</title></rect>
<rect x="538.0" y="288.0" width="74.0" height="130.5" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.extractors.code — 261 lines</title></rect>
<rect x="538.0" y="335.5" width="74.0" height="2.0" rx="2" fill="#1d4ed8" stroke="none" opacity="0.95" data-id="docgen.extractors.code.MissingParser" data-kind="class" class="blk"><title>MissingParser — class, 2 lines</title></rect>
<rect x="538.0" y="337.5" width="74.0" height="10.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.code._parser" data-kind="function" class="blk"><title>_parser — function, 21 lines</title></rect>
<rect x="538.0" y="349.0" width="74.0" height="5.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.code._name" data-kind="function" class="blk"><title>_name — function, 10 lines</title></rect>
<rect x="538.0" y="355.0" width="74.0" height="17.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.code._walk" data-kind="function" class="blk"><title>_walk — function, 34 lines</title></rect>
<rect x="538.0" y="373.0" width="74.0" height="13.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.code.extract_file" data-kind="function" class="blk"><title>extract_file — function, 27 lines</title></rect>
<rect x="538.0" y="387.5" width="74.0" height="2.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.code._count_errors" data-kind="function" class="blk"><title>_count_errors — function, 5 lines</title></rect>
<rect x="538.0" y="391.0" width="74.0" height="27.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.code.extract" data-kind="function" class="blk"><title>extract — function, 54 lines</title></rect>
<rect x="620.0" y="288.0" width="74.0" height="124.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.extractors.usage — 248 lines</title></rect>
<rect x="620.0" y="320.0" width="74.0" height="13.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.usage._template" data-kind="function" class="blk"><title>_template — function, 27 lines</title></rect>
<rect x="620.0" y="334.5" width="74.0" height="5.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.usage._body" data-kind="function" class="blk"><title>_body — function, 10 lines</title></rect>
<rect x="620.0" y="340.5" width="74.0" height="7.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.usage._shape" data-kind="function" class="blk"><title>_shape — function, 15 lines</title></rect>
<rect x="620.0" y="349.0" width="74.0" height="5.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.usage._graphql" data-kind="function" class="blk"><title>_graphql — function, 11 lines</title></rect>
<rect x="620.0" y="355.5" width="74.0" height="56.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.usage.extract" data-kind="function" class="blk"><title>extract — function, 112 lines</title></rect>
<rect x="702.0" y="288.0" width="74.0" height="72.5" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.extractors.db — 145 lines</title></rect>
<rect x="702.0" y="307.5" width="74.0" height="33.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.db.from_schema_dict" data-kind="function" class="blk"><title>from_schema_dict — function, 67 lines</title></rect>
<rect x="702.0" y="342.0" width="74.0" height="5.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.db._relation" data-kind="function" class="blk"><title>_relation — function, 10 lines</title></rect>
<rect x="702.0" y="348.0" width="74.0" height="3.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.db._plain_type" data-kind="function" class="blk"><title>_plain_type — function, 6 lines</title></rect>
<rect x="702.0" y="352.0" width="74.0" height="4.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.db._dedupe" data-kind="function" class="blk"><title>_dedupe — function, 9 lines</title></rect>
<rect x="702.0" y="357.5" width="74.0" height="2.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.db.extract" data-kind="function" class="blk"><title>extract — function, 5 lines</title></rect>
<rect x="784.0" y="288.0" width="74.0" height="62.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.extractors.openapi — 124 lines</title></rect>
<rect x="784.0" y="304.0" width="74.0" height="10.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.openapi._modelgen" data-kind="function" class="blk"><title>_modelgen — function, 21 lines</title></rect>
<rect x="784.0" y="315.5" width="74.0" height="3.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.openapi._type_name" data-kind="function" class="blk"><title>_type_name — function, 6 lines</title></rect>
<rect x="784.0" y="319.5" width="74.0" height="30.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.openapi.extract" data-kind="function" class="blk"><title>extract — function, 60 lines</title></rect>
<rect x="28.0" y="456.5" width="74.0" height="20.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.extractors.code_main — 40 lines</title></rect>
<rect x="28.0" y="460.5" width="74.0" height="15.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.code_main.main" data-kind="function" class="blk"><title>main — function, 31 lines</title></rect>
<rect x="110.0" y="456.5" width="74.0" height="16.5" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.extractors.usage_main — 33 lines</title></rect>
<rect x="110.0" y="460.5" width="74.0" height="12.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.usage_main.main" data-kind="function" class="blk"><title>main — function, 24 lines</title></rect>
<rect x="192.0" y="456.5" width="74.0" height="16.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.extractors.db_main — 32 lines</title></rect>
<rect x="192.0" y="461.5" width="74.0" height="10.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.db_main.main" data-kind="function" class="blk"><title>main — function, 21 lines</title></rect>
<rect x="274.0" y="456.5" width="74.0" height="15.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.extractors.openapi_main — 30 lines</title></rect>
<rect x="274.0" y="460.5" width="74.0" height="10.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.openapi_main.main" data-kind="function" class="blk"><title>main — function, 21 lines</title></rect>
<rect x="356.0" y="456.5" width="74.0" height="15.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.extractors.python — 30 lines</title></rect>
<rect x="356.0" y="466.0" width="74.0" height="3.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.python.extract" data-kind="function" class="blk"><title>extract — function, 7 lines</title></rect>
<rect x="438.0" y="456.5" width="74.0" height="14.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.extractors.__main__ — 26 lines</title></rect>
<rect x="438.0" y="459.2" width="74.0" height="8.6" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.__main__.main" data-kind="function" class="blk"><title>main — function, 16 lines</title></rect>
<rect x="538.0" y="456.5" width="74.0" height="666.5" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.selftest — 1333 lines</title></rect>
<rect x="538.0" y="500.0" width="74.0" height="2.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.selftest.check" data-kind="function" class="blk"><title>check — function, 5 lines</title></rect>
<rect x="538.0" y="503.5" width="74.0" height="3.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.selftest._err" data-kind="function" class="blk"><title>_err — function, 7 lines</title></rect>
<rect x="538.0" y="508.0" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.selftest.skip" data-kind="function" class="blk"><title>skip — function, 3 lines</title></rect>
<rect x="538.0" y="510.5" width="74.0" height="2.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.selftest.build_tree" data-kind="function" class="blk"><title>build_tree — function, 5 lines</title></rect>
<rect x="538.0" y="869.0" width="74.0" height="3.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.selftest._entry" data-kind="function" class="blk"><title>_entry — function, 7 lines</title></rect>
<rect x="620.0" y="456.5" width="74.0" height="90.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.style — 180 lines</title></rect>
<rect x="620.0" y="477.5" width="74.0" height="2.0" rx="2" fill="#1d4ed8" stroke="none" opacity="0.95" data-id="docgen.style.StyleError" data-kind="class" class="blk"><title>StyleError — class, 2 lines</title></rect>
<rect x="620.0" y="479.5" width="74.0" height="66.5" rx="2" fill="#1d4ed8" stroke="none" opacity="0.95" data-id="docgen.style.Style" data-kind="class" class="blk"><title>Style — class, 133 lines</title></rect>
<rect x="627.0" y="481.0" width="60.0" height="8.5" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.style.Style.__init__" data-kind="function" class="blk"><title>__init__ — function, 17 lines</title></rect>
<rect x="627.0" y="491.5" width="60.0" height="6.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.style.Style.load" data-kind="function" class="blk"><title>load — function, 12 lines</title></rect>
<rect x="627.0" y="498.5" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.style.Style.available" data-kind="function" class="blk"><title>available — function, 2 lines</title></rect>
<rect x="627.0" y="500.0" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.style.Style.themes" data-kind="function" class="blk"><title>themes — function, 2 lines</title></rect>
<rect x="627.0" y="502.5" width="60.0" height="16.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.style.Style.validate" data-kind="function" class="blk"><title>validate — function, 32 lines</title></rect>
<rect x="627.0" y="520.0" width="60.0" height="6.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.style.Style._resolve" data-kind="function" class="blk"><title>_resolve — function, 12 lines</title></rect>
<rect x="627.0" y="526.5" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.style.Style._lookup" data-kind="function" class="blk"><title>_lookup — function, 3 lines</title></rect>
<rect x="627.0" y="528.5" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.style.Style.node" data-kind="function" class="blk"><title>node — function, 2 lines</title></rect>
<rect x="627.0" y="530.0" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.style.Style.group" data-kind="function" class="blk"><title>group — function, 2 lines</title></rect>
<rect x="627.0" y="531.5" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.style.Style.edge" data-kind="function" class="blk"><title>edge — function, 2 lines</title></rect>
<rect x="627.0" y="533.0" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.style.Style.graph" data-kind="function" class="blk"><title>graph — function, 2 lines</title></rect>
<rect x="627.0" y="534.5" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.style.Style.geom" data-kind="function" class="blk"><title>geom — function, 2 lines</title></rect>
<rect x="627.0" y="536.0" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.style.Style.slot" data-kind="function" class="blk"><title>slot — function, 2 lines</title></rect>
<rect x="627.0" y="537.5" width="60.0" height="6.5" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.style.Style.domain_slot" data-kind="function" class="blk"><title>domain_slot — function, 13 lines</title></rect>
<rect x="627.0" y="544.5" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.style.Style.limits" data-kind="function" class="blk"><title>limits — function, 3 lines</title></rect>
<rect x="702.0" y="456.5" width="74.0" height="14.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.ops — 28 lines</title></rect>
<rect x="784.0" y="456.5" width="74.0" height="14.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.notebook — 15 lines</title></rect>
<rect x="28.0" y="1161.0" width="74.0" height="14.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.emitters — 12 lines</title></rect>
<rect x="110.0" y="1161.0" width="74.0" height="14.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.lab — 11 lines</title></rect>
<rect x="192.0" y="1161.0" width="74.0" height="14.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.ir — 7 lines</title></rect>
<rect x="274.0" y="1161.0" width="74.0" height="14.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.extractors — 2 lines</title></rect>
<rect x="374.0" y="1161.0" width="74.0" height="118.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.extractors.python.collect — 236 lines</title></rect>
<rect x="374.0" y="1177.5" width="74.0" height="5.0" rx="2" fill="#1d4ed8" stroke="none" opacity="0.95" data-id="docgen.extractors.python.collect.Definition" data-kind="class" class="blk"><title>Definition — class, 10 lines</title></rect>
<rect x="374.0" y="1184.0" width="74.0" height="6.0" rx="2" fill="#1d4ed8" stroke="none" opacity="0.95" data-id="docgen.extractors.python.collect.Module" data-kind="class" class="blk"><title>Module — class, 12 lines</title></rect>
<rect x="374.0" y="1191.0" width="74.0" height="33.5" rx="2" fill="#1d4ed8" stroke="none" opacity="0.95" data-id="docgen.extractors.python.collect._Collector" data-kind="class" class="blk"><title>_Collector — class, 67 lines</title></rect>
<rect x="381.0" y="1192.5" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.extractors.python.collect._Collector.__init__" data-kind="function" class="blk"><title>__init__ — function, 3 lines</title></rect>
<rect x="381.0" y="1195.5" width="60.0" height="8.5" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.extractors.python.collect._Collector._define" data-kind="function" class="blk"><title>_define — function, 17 lines</title></rect>
<rect x="381.0" y="1204.5" width="60.0" height="2.5" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.extractors.python.collect._Collector.visit_ClassDef" data-kind="function" class="blk"><title>visit_ClassDef — function, 5 lines</title></rect>
<rect x="381.0" y="1207.5" width="60.0" height="2.5" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.extractors.python.collect._Collector.visit_FunctionDef" data-kind="function" class="blk"><title>visit_FunctionDef — function, 5 lines</title></rect>
<rect x="381.0" y="1213.0" width="60.0" height="4.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.extractors.python.collect._Collector.visit_Import" data-kind="function" class="blk"><title>visit_Import — function, 8 lines</title></rect>
<rect x="381.0" y="1217.5" width="60.0" height="7.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.extractors.python.collect._Collector.visit_ImportFrom" data-kind="function" class="blk"><title>visit_ImportFrom — function, 14 lines</title></rect>
<rect x="374.0" y="1225.5" width="74.0" height="2.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.python.collect._first_line" data-kind="function" class="blk"><title>_first_line — function, 5 lines</title></rect>
<rect x="374.0" y="1229.0" width="74.0" height="7.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.python.collect._name_of" data-kind="function" class="blk"><title>_name_of — function, 15 lines</title></rect>
<rect x="374.0" y="1237.5" width="74.0" height="8.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.python.collect._resolve_relative" data-kind="function" class="blk"><title>_resolve_relative — function, 16 lines</title></rect>
<rect x="374.0" y="1246.5" width="74.0" height="13.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.python.collect.module_name" data-kind="function" class="blk"><title>module_name — function, 26 lines</title></rect>
<rect x="374.0" y="1260.5" width="74.0" height="10.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.python.collect.collect_file" data-kind="function" class="blk"><title>collect_file — function, 21 lines</title></rect>
<rect x="374.0" y="1272.0" width="74.0" height="6.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.python.collect.collect" data-kind="function" class="blk"><title>collect — function, 13 lines</title></rect>
<rect x="456.0" y="1161.0" width="74.0" height="81.5" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.extractors.python.resolve — 163 lines</title></rect>
<rect x="456.0" y="1175.0" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.python.resolve._id_for" data-kind="function" class="blk"><title>_id_for — function, 2 lines</title></rect>
<rect x="456.0" y="1177.0" width="74.0" height="16.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.python.resolve._resolve" data-kind="function" class="blk"><title>_resolve — function, 32 lines</title></rect>
<rect x="456.0" y="1194.0" width="74.0" height="48.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.python.resolve.to_ir" data-kind="function" class="blk"><title>to_ir — function, 96 lines</title></rect>
<rect x="463.0" y="1222.5" width="60.0" height="4.5" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.extractors.python.resolve.to_ir._point_at" data-kind="function" class="blk"><title>_point_at — function, 9 lines</title></rect>
<rect x="538.0" y="1161.0" width="74.0" height="19.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.extractors.python.__main__ — 38 lines</title></rect>
<rect x="538.0" y="1166.0" width="74.0" height="11.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.extractors.python.__main__.main" data-kind="function" class="blk"><title>main — function, 23 lines</title></rect>
<rect x="638.0" y="1161.0" width="74.0" height="118.5" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.ir.validate — 237 lines</title></rect>
<rect x="638.0" y="1182.0" width="74.0" height="2.0" rx="2" fill="#1d4ed8" stroke="none" opacity="0.95" data-id="docgen.ir.validate.IRError" data-kind="class" class="blk"><title>IRError — class, 2 lines</title></rect>
<rect x="638.0" y="1184.0" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ir.validate._schema" data-kind="function" class="blk"><title>_schema — function, 2 lines</title></rect>
<rect x="638.0" y="1186.0" width="74.0" height="2.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ir.validate._props" data-kind="function" class="blk"><title>_props — function, 5 lines</title></rect>
<rect x="638.0" y="1189.5" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ir.validate._fields" data-kind="function" class="blk"><title>_fields — function, 4 lines</title></rect>
<rect x="638.0" y="1192.5" width="74.0" height="53.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ir.validate.check" data-kind="function" class="blk"><title>check — function, 106 lines</title></rect>
<rect x="638.0" y="1246.5" width="74.0" height="3.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ir.validate.validate" data-kind="function" class="blk"><title>validate — function, 6 lines</title></rect>
<rect x="638.0" y="1250.5" width="74.0" height="11.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ir.validate.check_model_matches_schema" data-kind="function" class="blk"><title>check_model_matches_schema — function, 23 lines</title></rect>
<rect x="638.0" y="1263.0" width="74.0" height="16.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ir.validate.main" data-kind="function" class="blk"><title>main — function, 32 lines</title></rect>
<rect x="720.0" y="1161.0" width="74.0" height="73.5" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.ir.model — 147 lines</title></rect>
<rect x="720.0" y="1177.0" width="74.0" height="10.5" rx="2" fill="#1d4ed8" stroke="none" opacity="0.95" data-id="docgen.ir.model.Meta" data-kind="class" class="blk"><title>Meta — class, 21 lines</title></rect>
<rect x="727.0" y="1184.0" width="60.0" height="3.5" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.ir.model.Meta.to_dict" data-kind="function" class="blk"><title>to_dict — function, 7 lines</title></rect>
<rect x="720.0" y="1189.0" width="74.0" height="10.5" rx="2" fill="#1d4ed8" stroke="none" opacity="0.95" data-id="docgen.ir.model.Node" data-kind="class" class="blk"><title>Node — class, 21 lines</title></rect>
<rect x="727.0" y="1193.5" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.ir.model.Node.__post_init__" data-kind="function" class="blk"><title>__post_init__ — function, 3 lines</title></rect>
<rect x="727.0" y="1195.5" width="60.0" height="4.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.ir.model.Node.to_dict" data-kind="function" class="blk"><title>to_dict — function, 8 lines</title></rect>
<rect x="720.0" y="1201.0" width="74.0" height="7.5" rx="2" fill="#1d4ed8" stroke="none" opacity="0.95" data-id="docgen.ir.model.Edge" data-kind="class" class="blk"><title>Edge — class, 15 lines</title></rect>
<rect x="727.0" y="1205.0" width="60.0" height="3.5" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.ir.model.Edge.to_dict" data-kind="function" class="blk"><title>to_dict — function, 7 lines</title></rect>
<rect x="720.0" y="1210.0" width="74.0" height="24.0" rx="2" fill="#1d4ed8" stroke="none" opacity="0.95" data-id="docgen.ir.model.Graph" data-kind="class" class="blk"><title>Graph — class, 48 lines</title></rect>
<rect x="727.0" y="1214.5" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.ir.model.Graph.node" data-kind="function" class="blk"><title>node — function, 4 lines</title></rect>
<rect x="727.0" y="1217.0" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.ir.model.Graph.edge" data-kind="function" class="blk"><title>edge — function, 4 lines</title></rect>
<rect x="727.0" y="1219.5" width="60.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.ir.model.Graph.has" data-kind="function" class="blk"><title>has — function, 2 lines</title></rect>
<rect x="727.0" y="1222.0" width="60.0" height="8.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.ir.model.Graph.to_dict" data-kind="function" class="blk"><title>to_dict — function, 16 lines</title></rect>
<rect x="727.0" y="1231.0" width="60.0" height="3.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.ir.model.Graph.from_dict" data-kind="function" class="blk"><title>from_dict — function, 6 lines</title></rect>
<rect x="28.0" y="1317.5" width="74.0" height="14.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.ir.__main__ — 9 lines</title></rect>
<rect x="128.0" y="1317.5" width="74.0" height="242.5" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.ops.filter — 485 lines</title></rect>
<rect x="128.0" y="1335.5" width="74.0" height="28.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ops.filter._rebuild" data-kind="function" class="blk"><title>_rebuild — function, 57 lines</title></rect>
<rect x="135.0" y="1340.0" width="60.0" height="2.5" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.ops.filter._rebuild.surviving_parent" data-kind="function" class="blk"><title>surviving_parent — function, 5 lines</title></rect>
<rect x="135.0" y="1347.0" width="60.0" height="3.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.ops.filter._rebuild.lift" data-kind="function" class="blk"><title>lift — function, 6 lines</title></rect>
<rect x="128.0" y="1365.0" width="74.0" height="7.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ops.filter.drop_kinds" data-kind="function" class="blk"><title>drop_kinds — function, 14 lines</title></rect>
<rect x="128.0" y="1373.0" width="74.0" height="7.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ops.filter.only_kinds" data-kind="function" class="blk"><title>only_kinds — function, 14 lines</title></rect>
<rect x="128.0" y="1381.0" width="74.0" height="6.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ops.filter.drop_stdlib" data-kind="function" class="blk"><title>drop_stdlib — function, 13 lines</title></rect>
<rect x="128.0" y="1388.5" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ops.filter.drop_external" data-kind="function" class="blk"><title>drop_external — function, 3 lines</title></rect>
<rect x="128.0" y="1391.0" width="74.0" height="6.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ops.filter.subtree" data-kind="function" class="blk"><title>subtree — function, 13 lines</title></rect>
<rect x="128.0" y="1398.5" width="74.0" height="22.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ops.filter.neighbourhood" data-kind="function" class="blk"><title>neighbourhood — function, 45 lines</title></rect>
<rect x="128.0" y="1422.0" width="74.0" height="9.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ops.filter.collapse_to_depth" data-kind="function" class="blk"><title>collapse_to_depth — function, 18 lines</title></rect>
<rect x="135.0" y="1427.0" width="60.0" height="3.0" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.ops.filter.collapse_to_depth.level" data-kind="function" class="blk"><title>level — function, 6 lines</title></rect>
<rect x="128.0" y="1432.0" width="74.0" height="7.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ops.filter.drop_builtins" data-kind="function" class="blk"><title>drop_builtins — function, 14 lines</title></rect>
<rect x="128.0" y="1440.0" width="74.0" height="17.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ops.filter.overview" data-kind="function" class="blk"><title>overview — function, 35 lines</title></rect>
<rect x="128.0" y="1458.5" width="74.0" height="36.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ops.filter.shape" data-kind="function" class="blk"><title>shape — function, 72 lines</title></rect>
<rect x="135.0" y="1482.5" width="60.0" height="4.5" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.ops.filter.shape.rank_of" data-kind="function" class="blk"><title>rank_of — function, 9 lines</title></rect>
<rect x="128.0" y="1495.5" width="74.0" height="12.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ops.filter.split" data-kind="function" class="blk"><title>split — function, 24 lines</title></rect>
<rect x="128.0" y="1508.5" width="74.0" height="51.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ops.filter.classify" data-kind="function" class="blk"><title>classify — function, 102 lines</title></rect>
<rect x="210.0" y="1317.5" width="74.0" height="51.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.ops.__main__ — 102 lines</title></rect>
<rect x="210.0" y="1324.0" width="74.0" height="42.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.ops.__main__.main" data-kind="function" class="blk"><title>main — function, 84 lines</title></rect>
<rect x="310.0" y="1317.5" width="74.0" height="14.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen — 2 lines</title></rect>
<rect x="410.0" y="1317.5" width="74.0" height="75.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.lab.pg_probe — 150 lines</title></rect>
<rect x="410.0" y="1353.0" width="74.0" height="15.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.lab.pg_probe.probe" data-kind="function" class="blk"><title>probe — function, 30 lines</title></rect>
<rect x="410.0" y="1375.0" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.lab.pg_probe._simplify" data-kind="function" class="blk"><title>_simplify — function, 3 lines</title></rect>
<rect x="410.0" y="1377.5" width="74.0" height="12.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.lab.pg_probe.main" data-kind="function" class="blk"><title>main — function, 25 lines</title></rect>
<rect x="510.0" y="1317.5" width="74.0" height="132.0" rx="2" fill="#141414" stroke="#333333" stroke-width="1"><title>docgen.notebook.spec — 264 lines</title></rect>
<rect x="510.0" y="1345.5" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.notebook.spec._step" data-kind="function" class="blk"><title>_step — function, 4 lines</title></rect>
<rect x="510.0" y="1348.5" width="74.0" height="54.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.notebook.spec.from_ir" data-kind="function" class="blk"><title>from_ir — function, 108 lines</title></rect>
<rect x="517.0" y="1355.5" width="60.0" height="2.5" rx="2" fill="#15803d" stroke="none" opacity="0.8" data-id="docgen.notebook.spec.from_ir._order" data-kind="function" class="blk"><title>_order — function, 5 lines</title></rect>
<rect x="510.0" y="1403.5" width="74.0" height="10.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.notebook.spec.scaffold" data-kind="function" class="blk"><title>scaffold — function, 20 lines</title></rect>
<rect x="510.0" y="1414.5" width="74.0" height="29.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.notebook.spec.merge" data-kind="function" class="blk"><title>merge — function, 58 lines</title></rect>
<rect x="510.0" y="1444.5" width="74.0" height="2.0" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.notebook.spec.load" data-kind="function" class="blk"><title>load — function, 2 lines</title></rect>
<rect x="510.0" y="1446.5" width="74.0" height="2.5" rx="2" fill="#15803d" stroke="none" opacity="0.95" data-id="docgen.notebook.spec.dump" data-kind="function" class="blk"><title>dump — function, 5 lines</title></rect>
<text x="28" y="40" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen.emitters</text>
<text x="28" y="282" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen.emitters</text>
<text x="538" y="282" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen.extractors</text>
<text x="28" y="450" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen.extractors</text>
<text x="538" y="450" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen</text>
<text x="28" y="1155" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen</text>
<text x="374" y="1155" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen.extractors.python</text>
<text x="638" y="1155" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen.ir</text>
<text x="28" y="1312" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen.ir</text>
<text x="128" y="1312" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen.ops</text>
<text x="310" y="1312" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">(root)</text>
<text x="410" y="1312" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen.lab</text>
<text x="510" y="1312" font-family="Helvetica,sans-Serif" font-size="10" font-weight="bold" fill="#a3a3a3">docgen.notebook</text>
<text x="28" y="259" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">site</text>
<text x="110" y="213" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">explore</text>
<text x="192" y="201" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">dot</text>
<text x="274" y="194" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">notebook</text>
<text x="356" y="194" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">minimap</text>
<text x="438" y="185" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">erd</text>
<text x="520" y="137" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">index</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="766" y="92" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">cli_noteboo</text>
<text x="28" y="334" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">cli_site</text>
<text x="110" y="322" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">cli_erd</text>
<text x="192" y="322" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">cli_minimap</text>
<text x="274" y="321" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">cli_explore</text>
<text x="356" y="319" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">cli_index</text>
<text x="438" y="316" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">__main__</text>
<text x="538" y="428" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">code</text>
<text x="620" y="421" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">usage</text>
<text x="702" y="370" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">db</text>
<text x="784" y="359" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">openapi</text>
<text x="28" y="486" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">code_main</text>
<text x="110" y="482" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">usage_main</text>
<text x="192" y="482" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">db_main</text>
<text x="274" y="480" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">openapi_mai</text>
<text x="356" y="480" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">python</text>
<text x="438" y="480" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">__main__</text>
<text x="538" y="1132" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">selftest</text>
<text x="620" y="556" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">style</text>
<text x="702" y="480" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">ops</text>
<text x="784" y="480" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">notebook</text>
<text x="28" y="1184" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">emitters</text>
<text x="110" y="1184" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">lab</text>
<text x="192" y="1184" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">ir</text>
<text x="274" y="1184" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">extractors</text>
<text x="374" y="1288" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">collect</text>
<text x="456" y="1252" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">resolve</text>
<text x="538" y="1189" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">__main__</text>
<text x="638" y="1288" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">validate</text>
<text x="720" y="1244" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">model</text>
<text x="28" y="1340" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">__main__</text>
<text x="128" y="1569" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">filter</text>
<text x="210" y="1378" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">__main__</text>
<text x="310" y="1340" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">docgen</text>
<text x="410" y="1402" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">pg_probe</text>
<text x="510" y="1458" font-family="Helvetica,sans-Serif" font-size="7" fill="#666666">spec</text>
<rect x="28" y="1609.0" width="9" height="9" rx="2" fill="#1a1a1a"/>
<text x="41" y="1617.0" font-family="Helvetica,sans-Serif" font-size="9" fill="#666666">module</text>
<rect x="86" y="1609.0" width="9" height="9" rx="2" fill="#1d4ed8"/>
<text x="99" y="1617.0" font-family="Helvetica,sans-Serif" font-size="9" fill="#666666">class</text>
<rect x="138" y="1609.0" width="9" height="9" rx="2" fill="#d4a574"/>
<text x="151" y="1617.0" font-family="Helvetica,sans-Serif" font-size="9" fill="#666666">interface</text>
<rect x="214" y="1609.0" width="9" height="9" rx="2" fill="#15803d"/>
<text x="227" y="1617.0" font-family="Helvetica,sans-Serif" font-size="9" fill="#666666">function</text>
<text x="858" y="1617.0" text-anchor="end" font-family="Helvetica,sans-Serif" font-size="9" fill="#666666">45 files · 6,933 lines · 1px ≈ 2.0 lines</text>
</svg>

After

Width:  |  Height:  |  Size: 56 KiB

View File

@@ -0,0 +1,793 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>docgen</title>
<link rel="stylesheet" href="docs.css">
</head>
<body>
<div class="layout">
<nav class="sidebar">
<div class="sidebar-header">
<b>docgen</b>
<small>code &amp; data &rarr; documentation</small>
</div>
<nav>
<div class="group">Start</div>
<a href="#what">What it is</a>
<a href="#idea">The idea</a>
<a href="#quick">Five minutes</a>
<div class="group">Architecture</div>
<a href="#layers">The three concerns</a>
<a href="#ir">The IR</a>
<a href="#shape">Shape decides the drawing</a>
<div class="group">Reading</div>
<a href="#extractors">Extractors</a>
<a href="#usage">Usage, not just the spec</a>
<div class="group">Narrowing</div>
<a href="#views">Views</a>
<div class="group">Writing</div>
<a href="#emitters">Emitters</a>
<a href="#explore">Explore</a>
<a href="#notebooks">Notebooks</a>
<a href="#style">Style &amp; colour</a>
<div class="group">Reference</div>
<a href="#commands">Commands</a>
<a href="#deps">Dependencies</a>
<a href="#testing">Testing</a>
<a href="#limits">Limits &amp; non-goals</a>
</nav>
</nav>
<main class="content">
<h1>docgen</h1>
<p class="lede">
Turn source artifacts — a codebase, a database, an API spec, a recording of
real traffic — into one canonical graph format, then render that format to
whatever the audience needs. The point is not the diagram. The point is the
format in the middle.
</p>
<!-- ─────────────────────────────────────────────────────────────── -->
<h2 id="what">What it is</h2>
<p>
Eight demos under <code>semester/</code> draw their architecture with
Graphviz. Every one of them hand-writes a <code>.dot</code> file with the
palette inlined and commits the <code>.svg</code> beside it. Three different
dark palettes between them, eight answers to the same question, and every one
of those diagrams is out of date the moment somebody moves a file.
</p>
<p>
docgen is the one answer. It reads the source rather than being told about it,
so a diagram cannot drift from the thing it describes, and it separates
<em>what a graph is</em> from <em>how it looks</em> so one extraction feeds
a diagram, an index, a notebook and a browsable site without being redone.
</p>
<div class="note">
<b>Nothing here writes a parser or a graph algorithm.</b> Parsers are adopted
(<code>ast</code>, tree-sitter, SQLAlchemy reflection via modelgen), algorithms
are networkx's. What docgen owns is the adapters, the schema, the style tables
and the emitters — all small, and all the places where the value is that
<em>we</em> made the call.
</div>
<!-- ─────────────────────────────────────────────────────────────── -->
<h2 id="idea">The idea</h2>
<p>
N sources and M outputs need N&times;M converters if you join them directly,
or <strong>N+M</strong> if you put a hub in the middle. The hub is an
<em>intermediate representation</em> — the compiler term, and the same bargain:
both sides depend on the IR and neither on the other.
</p>
<p>
It is lossy on purpose. It throws away every token of syntax and keeps
<em>"a class named User inherits from Base"</em>. That is the part that is
worth versioning, worth diffing, and worth drawing.
</p>
<p>
The practical consequence is the thing to judge it on: <strong>adding a source
costs one extractor and every emitter works on it unchanged; adding an output
costs one emitter and every extractor feeds it unchanged.</strong> When the
OpenAPI reader was written it emitted schemas using the same vocabulary the
database reader uses — and the ER diagram drew an API's data model without
anyone teaching it what an API was.
</p>
<!-- ─────────────────────────────────────────────────────────────── -->
<h2 id="quick">Five minutes</h2>
<p>Three commands, and they compose. That is the whole interface.</p>
<pre><code><span class="c"># 1. read something</span>
python3 -m docgen.extractors.python --root ../station/tools/histgen -o ir.json
<span class="c"># 2. narrow it to a useful view</span>
python3 -m docgen.ops ir.json --overview -o view.json
<span class="c"># 3. draw whatever its structure asks for</span>
python3 -m docgen.emitters auto view.json -o out/</code></pre>
<p>Or through the Makefile, which is a thin wrapper over exactly those:</p>
<pre><code>make ir SRC=/path/to/repo OUT=out <span class="c"># extract</span>
make explore OUT=out <span class="c"># the two-pane navigator</span>
make site OUT=out <span class="c"># a docs site with a sidebar</span>
make self <span class="c"># run the whole thing over soleprint</span></code></pre>
<p>
Everything is offline and self-contained. No server, no CDN, no build step —
the outputs open over <code>file://</code>.
</p>
<!-- ─────────────────────────────────────────────────────────────── -->
<h2 id="layers">The three concerns</h2>
<p>
DOT collapses three separate questions into one file format, which is why a
hand-written <code>.dot</code> is never reusable: you cannot change the palette
without editing the structure, and you cannot change the structure without
re-deciding the layout. docgen keeps them apart.
</p>
<table>
<tr><th>concern</th><th>question</th><th>owner</th></tr>
<tr><td><b>structure</b></td><td>what the graph <em>is</em></td><td><code>ir/schema.json</code></td></tr>
<tr><td><b>meaning</b></td><td>what things <em>mean visually</em></td><td><code>style/*.json</code>, keyed on <code>kind</code></td></tr>
<tr><td><b>placement</b></td><td>where things <em>go</em></td><td>the emitter, and only there</td></tr>
</table>
<p>
An extractor has never heard of SVG, colours or layout. An emitter has never
heard of Python, <code>ast</code> or SQL. Both halves of that are checked by
parsing the source and looking at what it imports, because a rule nobody
enforces is a rule that lasts about a month.
</p>
<figure>
<a href="viewer.html?src=img/architecture.svg">
<img src="img/architecture.svg" alt="docgen's own module structure">
</a>
<figcaption>
docgen read by docgen. <code>extractors/</code> reaches only <code>ir</code>;
<code>emitters/</code> reaches <code>ir</code> and <code>style</code>;
<code>ir/</code> reaches nothing outside itself; <code>lab/</code> has no
edges at all. Click to open the viewer — then click again for actual size.
</figcaption>
</figure>
<!-- ─────────────────────────────────────────────────────────────── -->
<h2 id="ir">The IR</h2>
<p>
Plain JSON. Three keys, and it has survived four domains without gaining a
fourth.
</p>
<pre><code>{
"meta": { "source": "python", "root": "app/", "schema_version": "1" },
"nodes": [ { "id": "app.models.User", "kind": "class", "label": "User",
"parent": "app.models",
"attrs": { "file": "app/models.py", "line": 12, "lines": 40 } } ],
"edges": [ { "source": "app.models.User", "target": "app.db.Base",
"kind": "inherits", "attrs": {} } ]
}</code></pre>
<table>
<tr><th>field</th><th>meaning</th></tr>
<tr><td><code>id</code></td>
<td>Fully qualified and <b>stable across runs</b>. Stability is what makes
two extractions from two commits diffable; without it a diff reports
noise and nobody trusts it.</td></tr>
<tr><td><code>kind</code></td>
<td>The hinge of the whole system, and the <b>only</b> field style and
layout may read. A small closed vocabulary per domain —
<code>module</code>/<code>class</code>/<code>function</code>,
<code>table</code>/<code>column</code>, <code>endpoint</code>,
<code>task</code>.</td></tr>
<tr><td><code>parent</code></td>
<td>Containment, and nothing else. A module contains a class. Relationships
are edges.</td></tr>
<tr><td><code>attrs</code></td>
<td>An open bag for whatever one domain cares about.
<code>file</code>/<code>line</code>/<code>lines</code> are what let a
box link to the line it came from, and what the minimap sizes by.</td></tr>
</table>
<div class="note">
<b>No visual information, ever.</b> If a field would change between a light and
a dark theme, it does not belong in the IR. <code>shape: "cylinder"</code> is
not a field — it is <code>kind: "datastore"</code> plus a style rule, and that
is exactly what lets the same IR render in a theme that has no cylinders. The
test suite sweeps every emitted document for colour-like keys.
</div>
<h3>Stdlib dataclasses, not Pydantic</h3>
<p>
The IR's whole value is being a plain document anything can open. A format that
needs a library installed to be read is an API, not a format. Validation is
therefore a function called at the boundary rather than a property of the type,
and it reads its field lists out of <code>schema.json</code> so the schema and
the dataclasses cannot drift apart.
</p>
<pre><code>python3 -m docgen.ir ir.json</code></pre>
<p>
It catches what a schema cannot: an edge naming a node that does not exist, a
containment cycle, a duplicate id, and a visual field smuggled into
<code>attrs</code>.
</p>
<!-- ─────────────────────────────────────────────────────────────── -->
<h2 id="shape">Shape decides the drawing</h2>
<p>
A diagram that fights its layout engine is usually the wrong <em>kind</em> of
diagram. The clearest evidence: the same 24-table database rendered
<b>32034&times;136</b> through Graphviz — a 235:1 strip — and
<b>1740&times;1860</b> through the ER emitter. Not because one engine is
better, but because a schema is a set of peer entities with references, and
laying it out in dependency ranks was never its shape.
</p>
<p>
So <code>ops.classify()</code> reads the structure and names the emitter,
with the reason attached — advice without a reason gets overridden the first
time it is inconvenient.
</p>
<table>
<tr><th>kind</th><th>drawn by</th><th>when</th></tr>
<tr><td><code>erd</code></td><td>cards in columns</td><td>entities with references</td></tr>
<tr><td><code>pipeline</code></td><td>ranks, left to right</td><td>a chain with fan-out — an Airflow DAG, a build</td></tr>
<tr><td><code>layered</code> / <code>tree</code></td><td>ranks, top down</td><td>ranks genuinely suit it</td></tr>
<tr><td><code>sheet</code></td><td>the index</td><td>one level is wider than ~20 — a strip in any engine</td></tr>
<tr><td><code>flat</code></td><td>the index</td><td>most nodes have no relationships: that is a list</td></tr>
</table>
<pre><code>$ python3 -m docgen.emitters auto view.json -o out/
sheet -&gt; index
109 nodes sit at one level; any layered engine draws that as a
strip. Split it, scope it, or read it as an index</code></pre>
<h3>Why twenty</h3>
<p>
Measured, one diagram per subsystem: at or under 20 nodes the output lands
around 1.6:1; at 70106 nodes about 7:1; at 261 nodes 14:1. Aspect ratio is a
property of the <em>graph</em>, not of the renderer — a layered engine puts one
dependency level in one row, so the widest level <em>is</em> the width.
</p>
<p>
Every Graphviz lever was tried before concluding this. <code>ratio=compress</code>
squashed a graph to an unreadable 1008&times;75; <code>rankdir=LR</code> merely
rotated a 14:1 into a 1:6; packing disconnected components gained nothing.
The fix was never a flag. It was to stop asking for one picture of everything —
which is what <a href="#explore">explore</a> does.
</p>
<!-- ─────────────────────────────────────────────────────────────── -->
<h2 id="extractors">Extractors</h2>
<p>
<strong>Deterministic parsing only. No model in the structural path.</strong>
A diagram built from an AST cannot be out of date with the code. A diagram
built from a model's reading of the code is wrong the moment the model has a
bad day — which is the problem this exists to fix.
</p>
<table>
<tr><th>reader</th><th>reads</th><th>gives</th></tr>
<tr><td><code>python</code></td><td>a tree of <code>.py</code></td>
<td>modules, classes, functions; <code>imports</code> and <code>inherits</code> edges</td></tr>
<tr><td><code>code</code> <span class="pill opt">tree-sitter</span></td><td>C#, TypeScript, TSX</td>
<td>namespaces, classes, interfaces, methods — <b>structure only</b></td></tr>
<tr><td><code>db</code></td><td>a graphgen-compatible <code>schema.json</code></td>
<td>tables, columns, foreign keys</td></tr>
<tr><td><code>openapi</code></td><td>an OpenAPI / Swagger document</td>
<td>endpoints and the shapes they carry</td></tr>
<tr><td><code>usage</code></td><td>a HAR recording</td>
<td>what was actually called, in what order</td></tr>
</table>
<h3>Two passes, because <code>ast</code> resolves nothing</h3>
<p>
Given <code>class User(Base)</code>, Python's <code>ast</code> hands over the
literal string <code>"Base"</code>. It has no idea that came from
<code>from .db import Base</code> three lines up. So pass one collects, per
module, what it defines and what it imports; pass two resolves local names to
fully qualified ids. The edge then points at <code>app.db.Base</code> — a real
node — rather than at a box called <code>Base</code> that means nothing.
</p>
<div class="note">
<b>Unresolved names become nodes, never nothing.</b> A third-party import or a
dynamically-built base becomes a node of <code>kind: "external"</code> and
<em>keeps its edge</em>. Dropping it would be the worse failure: the diagram
would look complete and have quietly lost a dependency. Gathered up, those
nodes are the project's real dependency surface.
</div>
<h3>C# and TypeScript</h3>
<p>
Handled by tree-sitter, which is why generics, nested types and a brace inside
a string are non-events rather than special cases. The test suite asserts that
last one specifically, because it is exactly where a hand-rolled scanner breaks.
</p>
<p>
This reader produces <strong>no edges</strong>. Resolving a C#
<code>using</code> to the thing it names is a different and much larger job,
and the consumer that needs this — the minimap — needs none of it. An
extractor that quietly produced half a dependency graph would be worse than one
producing none, because the half would look whole.
</p>
<!-- ─────────────────────────────────────────────────────────────── -->
<h2 id="usage">Usage, not just the spec</h2>
<p>
An OpenAPI document says what endpoints <em>are</em>. It does not say how to
use them — least of all when they are not RESTful, or when a GraphQL endpoint
sits alongside. So docgen also reads a <b>HAR</b>: the recording format that
browser devtools, mitmproxy, Charles and Insomnia all export.
</p>
<table>
<tr><th>what traffic knows</th><th>what a spec cannot</th></tr>
<tr><td>the <b>order</b> of calls</td><td>a spec is a set; usage is a sequence</td></tr>
<tr><td>which parameters are <b>always</b> sent</td><td>a spec lists twenty optional ones</td></tr>
<tr><td>which statuses <b>really</b> happen</td><td>the 422 everybody hits is in no document</td></tr>
<tr><td>endpoints not in the document</td><td>GraphQL operations, found by body shape and named</td></tr>
<tr><td>which id formats a route takes</td><td>numeric <em>and</em> uuid on one route</td></tr>
</table>
<div class="note warn">
<b>No credential and no payload value reaches the IR</b> — only field names and
types. A HAR is full of live bearer tokens and cookies, and a generated
document gets committed. The test suite plants a token in its fixture and fails
if it appears anywhere in the output.
</div>
<p>
Two limits, stated rather than glossed: path templating is a <em>guess</em>
(<code>attrs.observed_paths</code> keeps what was actually seen beside it), and
consecutive is not caused-by — the edge weight is what separates a habit from
an accident, and one recording will not tell you which.
</p>
<!-- ─────────────────────────────────────────────────────────────── -->
<h2 id="views">Views</h2>
<p>
The first real diagram out of this pipeline was a 3000px-wide strip: four
modules of actual content and sixty <code>sys</code>/<code>json</code>/<code>typing</code>
boxes as their peers. The emitter was correct and the picture was useless.
That is a <em>missing view</em>, not a broken renderer — and the fix belongs to
every consumer at once, because the index, the diagram and the diff all want
the same narrowing.
</p>
<pre><code>python3 -m docgen.ops ir.json --overview -o view.json
python3 -m docgen.ops ir.json --around docgen.ir --hops 2 -o view.json
python3 -m docgen.ops ir.json --split -o parts/
python3 -m docgen.ops ir.json --shape <span class="c"># what will this look like?</span></code></pre>
<p>
All of them are IR&rarr;IR, all composable, and each produces a document that
still validates. <code>--overview</code> is the default and dispatches on the
source: a codebase reduces to its modules and its outside dependencies, a
schema to its tables and their keys.
</p>
<div class="note">
<b>Edges are lifted when a view collapses detail, never dropped.</b> A class in
module A inheriting from a class in module B <em>is</em> a dependency of A on
B. Collapsing docgen to its packages once kept 8 of 77 edges — those pictures
were not simpler, they were <em>wrong</em>. Lifted edges carry a
<code>weight</code> saying how many they stand for.
</div>
<h3>Depth is the tempting knob and the wrong one</h3>
<p>
A directory without an <code>__init__.py</code> is not a package, so its
modules have no parent and sit at depth 0. soleprint has <b>173 such roots</b>,
and a depth-2 cut still held 566 functions and 142 classes. Selecting by
<code>kind</code> does not care how the directories happen to be arranged.
</p>
<!-- ─────────────────────────────────────────────────────────────── -->
<h2 id="emitters">Emitters</h2>
<table>
<tr><th>emitter</th><th>output</th><th>audience</th></tr>
<tr><td><code>index</code></td><td>markdown, sidebar JSON</td>
<td><b>anyone</b> — no graph literacy required</td></tr>
<tr><td><code>dot</code></td><td>DOT &rarr; Graphviz &rarr; SVG</td><td>dependency structure</td></tr>
<tr><td><code>erd</code></td><td>SVG, written directly</td><td>a schema, as cards</td></tr>
<tr><td><code>minimap</code></td><td>SVG, written directly</td><td>what is where, at a glance</td></tr>
<tr><td><code>notebook</code></td><td><code>.ipynb</code></td><td>a runnable walkthrough</td></tr>
<tr><td><code>site</code></td><td>a static docs site</td><td>reading</td></tr>
<tr><td><code>explore</code></td><td>a two-pane navigator</td><td>finding your way</td></tr>
<tr><td><code>auto</code></td><td>whichever of the above fits</td><td>not having to choose</td></tr>
</table>
<p>
<strong>The non-visual ones matter most for reach.</strong> A sorted, described
list of what exists is readable by someone who will never open a diagram, and
it also reports the dependency surface and any file that failed to parse.
It is built second, not last — it is what proves the IR is not secretly
diagram-shaped.
</p>
<h3>ERD — and where the layout came from</h3>
<p>
Not invented here. <code>station/tools/graphgen/templates/index.html</code>,
the Supabase-style schema explorer already in this repo, had solved it:
</p>
<pre><code>const cols = Math.max(2, Math.ceil(Math.sqrt(sorted.length * 1.2)));</code></pre>
<p>
<strong>Columns from the square root of the table count.</strong> The aspect
ratio is <em>chosen</em> rather than emergent, so the result stays near-square
at 4 tables or 400. That is the one thing a rank-based engine cannot offer.
Three more things it gets right: a table is a <em>card</em> with its columns;
an edge leaves the column holding the key and lands on the target's primary
key; and the geometry is computed rather than measured, so it renders
identically on any machine.
</p>
<figure>
<a href="viewer.html?src=img/erd.svg">
<img src="img/erd.svg" alt="an entity-relationship diagram">
</a>
<figcaption>A schema from the sample room. Same emitter, same style file as
every other diagram here.</figcaption>
</figure>
<h3>Minimap</h3>
<p>
Sublime's minimap shrinks the <em>characters</em>. This draws the
<em>structure</em> at full scale: one file is a column, one line is a fixed
number of pixels, every construct a block sized by its span and coloured by
what it is. No text inside a block — the shape is the message.
</p>
<p>
The claim is that the pattern comes from the colours alone, so nesting is drawn
by inset rather than by hue. On soleprint you can see that modelgen is
class-based, histgen is function-based and tester is mixed, without reading a
line.
</p>
<figure>
<a href="viewer.html?src=img/minimap.svg">
<img src="img/minimap.svg" alt="a structural minimap of docgen">
</a>
<figcaption>docgen's own files. Blue class, amber interface, green function,
dark for everything that is not a declaration — imports, constants, prose.</figcaption>
</figure>
<!-- ─────────────────────────────────────────────────────────────── -->
<h2 id="explore">Explore</h2>
<p>
The minimap on its own shows shape and no meaning: a block says "a 30-line
class", not <em>which</em> class or what it touches. So it is not the artifact.
It is the <strong>selector</strong>.
</p>
<pre><code>make explore OUT=out <span class="c"># then open out/explore/explore.html</span></code></pre>
<div class="cols">
<div>
<h4>Left — navigate</h4>
<p>The whole thing at once. Scan by colour, click a block.</p>
</div>
<div>
<h4>Right — explore</h4>
<p>What that is, what it reaches, what reaches it, and the neighbourhood
drawn small enough to read. Every neighbour is a link, so you walk
outward from wherever you started.</p>
</div>
</div>
<div class="note">
<b>This is what retires the 14:1 sheet.</b> The whole graph is never drawn. The
overview pane carries the overview, and only the neighbourhood of a selection
is rendered — a handful of nodes, which lays out fine every time. Overview and
detail stop competing for one picture.
</div>
<p>
The same split applies to a database: every table at once with <em>no column
detail</em>, then click one to get its columns plus the tables its keys reach.
The two differ exactly where they should — a module's neighbourhood
deliberately leaves its contents out, because those are the hundred functions
that made the sheet unreadable, while a table's brings them in, because a table
without its columns is not a table.
</p>
<h3>The selection basket</h3>
<p>
Shift-click accumulates blocks. The basket is a copyable list of paths with a
line count — enough to hand to <code>distill</code>, and enough to see that the
selection got too big <em>before</em> spending the context on it. Navigating a
tree quickly in order to decide what to feed a model is a real use, and this is
the part that serves it.
</p>
<!-- ─────────────────────────────────────────────────────────────── -->
<h2 id="notebooks">Notebooks</h2>
<p>
A notebook is normally a source file somebody confects by hand: prose, code and
stored output braided together, diffing badly, drifting from whatever it
documents the moment either moves, with no way to tell by looking.
</p>
<p>
Here a notebook is a <strong>build artifact</strong>. The source is the OpenAPI
document — the same file the server is built from — and the notebook is
regenerated from it. Nobody edits the <code>.ipynb</code>, the same way nobody
edits a <code>.o</code>. "Is this document current" stops being a question
about somebody's diligence and becomes a question about whether the build ran.
</p>
<blockquote>
This is the disagreement with jupytext. Jupytext fixes the <em>diffing</em>
it makes a notebook editable as text — and leaves the actual problem: you still
hand-author it, so it still rots.
</blockquote>
<h3>Generated base, hand-written overlay</h3>
<p>
Generation alone gives a document that is never stale and never says anything a
parser could not work out. Hand-authoring alone gives insight and a document
that rots. Two files is the only arrangement that gets both:
</p>
<pre><code>IR ──► spec ──(+ overlay)──► merged spec ──► .ipynb
generated hand-written merged emitted</code></pre>
<p>
The <b>spec</b> is an ordered list of steps with no Jupyter in it — a Swagger
for notebooks, readable and diffable. The <b>overlay</b> is the only file
anyone edits, and it is re-applied on every build. It can
<code>annotate</code>, <code>replace</code>, <code>insert</code>,
<code>drop</code> and <code>order</code>.
</p>
<p>
<code>replace</code> is the one that matters. It is how real usage gets into a
document that a spec could not describe — the call that is always made with
<code>status=available</code>, the GraphQL endpoint that is not in the OpenAPI
file at all — and it keeps working unchanged once a usage recording supplies
the same facts automatically.
</p>
<div class="note">
Three properties hold it together: regenerating <b>re-applies the overlay
byte-for-byte</b>; when the base moves underneath it the mismatch is
<b>reported, never silently dropped</b>; and extraction works with the overlay
<b>absent</b> — it is an addition, never a dependency.
</div>
<pre><code>python3 -m docgen.emitters notebook ir.json --scaffold overlay.json
python3 -m docgen.emitters notebook ir.json --overlay overlay.json -o walkthrough.ipynb</code></pre>
<!-- ─────────────────────────────────────────────────────────────── -->
<h2 id="style">Style &amp; colour</h2>
<p>
A style rule names a <strong>slot</strong>, never a colour.
<code>"border": "atlas"</code> is the rule; a theme binds <code>atlas</code> to
<code>#43A047</code> in print and <code>#15803d</code> on the docs site.
</p>
<p>
That indirection is the whole point. <code>common/theme/tokens.css</code>,
<code>docs/graphs/themes/*.gvpr</code> and <code>style/lucid.json</code> use
the same slot names, so a diagram and the page around it match by construction
— which is the rule <code>docs/graphs/README.md</code> already states. The dark
theme's <code>artery</code>, <code>atlas</code> and <code>station</code> slots
are exactly the <code>--system-accent</code> values the three system pages set,
and the test suite fails if they drift apart.
</p>
<p>
Dark is the default, because a generated diagram lands in a dark docs page far
more often than in a document. <code>--theme lucid</code> gives the print
palette — and gives it to the <em>page</em> as well as the diagram, since both
are baked from the same slots.
</p>
<div class="note">
An unknown <code>kind</code> falls back to <code>default</code> rather than
crashing. That matters more than it sounds: a new extractor with a new
vocabulary renders plainly and legibly on day one, instead of requiring
somebody to write a style file before they can see anything.
</div>
<h3>Where DOT stops</h3>
<p>
The emitter writes what DOT expresses natively and stops at the boundary rather
than growing machinery. The limits are recorded in the style file itself: a
cluster has a label and a fill but not a header bar;
<code>stroke-dasharray</code> is not parameterised, so <code>4,4</code> and
<code>5,5</code> collapse; <code>rounded</code> is binary, so 4px and 6px are
identical. Those mark where a richer emitter would begin — and the style file
carries the full specification regardless, so that emitter needs no
re-authoring.
</p>
<p>
One limit <em>was</em> worth solving: DOT cannot use a cluster as an edge
endpoint, so every module-to-module import silently vanished. The native answer
is <code>compound=true</code> with <code>lhead</code>/<code>ltail</code> — draw
between a representative leaf and clip the line at the cluster border.
</p>
<!-- ─────────────────────────────────────────────────────────────── -->
<h2 id="commands">Commands</h2>
<h3>Make</h3>
<table>
<tr><th>target</th><th>does</th></tr>
<tr><td><code>make check</code></td><td>the whole test suite, offline, nothing installed</td></tr>
<tr><td><code>make doctor</code></td><td>what this machine has and what it is missing</td></tr>
<tr><td><code>make ir SRC=…</code></td><td>extract Python into <code>OUT/ir.json</code></td></tr>
<tr><td><code>make code SRC=…</code></td><td>extract C#/TypeScript <span class="pill opt">tree-sitter</span></td></tr>
<tr><td><code>make db SCHEMA=…</code></td><td>extract a database schema</td></tr>
<tr><td><code>make view</code></td><td>the default view for that source type</td></tr>
<tr><td><code>make graph</code></td><td>draw whatever the structure asks for</td></tr>
<tr><td><code>make index</code></td><td>markdown index and sidebar JSON</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 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>
</table>
<p>
Variables: <code>SRC</code>, <code>OUT</code>, <code>SCHEMA</code>,
<code>STYLE</code>, <code>THEME</code>, <code>SCALE</code>, <code>DEPTH</code>,
<code>PY</code>. The Makefile derives its own package name from where it sits,
so the folder can be copied anywhere and renamed and still work.
</p>
<h3>Modules</h3>
<pre><code>python3 -m docgen.extractors.python --root SRC -o ir.json
python3 -m docgen.extractors code --root SRC -o ir.json
python3 -m docgen.extractors db --schema schema.json -o ir.json
python3 -m docgen.extractors openapi --spec spec.yaml -o ir.json
python3 -m docgen.extractors usage --har session.har -o ir.json
python3 -m docgen.ir ir.json <span class="c"># validate</span>
python3 -m docgen.ops ir.json --overview -o view.json
python3 -m docgen.emitters auto view.json -o out/
python3 -m docgen.emitters index ir.json -o index.md
python3 -m docgen.emitters dot view.json -o graph.svg --theme lucid
python3 -m docgen.emitters erd ir.json -o schema.svg
python3 -m docgen.emitters minimap ir.json -o map.svg --scale 0.5
python3 -m docgen.emitters notebook ir.json -o book.ipynb --overlay overlay.json
python3 -m docgen.emitters site view.json -o site/
python3 -m docgen.emitters explore ir.json -o explore/</code></pre>
<!-- ─────────────────────────────────────────────────────────────── -->
<h2 id="deps">Dependencies</h2>
<p>
The core is <strong>standard library only</strong>. Everything else is optional
and reported by <code>make doctor</code>; when something is missing you lose
exactly one capability and get told what to install.
</p>
<table>
<tr><th>needs</th><th>for</th><th>without it</th></tr>
<tr><td><code>graphviz</code> (binary)</td><td>rendering DOT to SVG</td>
<td>ERD, minimap, index and notebooks still work</td></tr>
<tr><td><code>tree_sitter</code> + grammars</td><td>C#, TypeScript, TSX</td>
<td>Python only</td></tr>
<tr><td><code>networkx</code></td><td><code>lab/</code> experiments</td>
<td>nothing — nothing depends on it yet</td></tr>
<tr><td><code>node</code></td><td>testing the browser pages</td>
<td>those checks skip</td></tr>
<tr><td><code>psql</code></td><td>the <code>lab/</code> schema probe</td>
<td>use modelgen's <code>from-db</code> instead</td></tr>
</table>
<div class="note">
<b><code>lab/</code> is where a dependency gets tried before anything depends
on it.</b> Nothing in <code>ir/</code>, <code>extractors/</code>,
<code>ops/</code> or <code>emitters/</code> may import from it. When an
experiment earns its place it graduates into <code>ops/</code> behind an
IR&rarr;IR signature, and <em>then</em> the dependency is declared.
</div>
<!-- ─────────────────────────────────────────────────────────────── -->
<h2 id="testing">Testing</h2>
<pre><code>make check <span class="c"># 191 checks, offline, no network</span></code></pre>
<p>
Four of those are the <em>design</em> rather than regressions, and they are the
ones to keep if anything is ever cut:
</p>
<ul>
<li><b>No visual field reaches the IR</b> — extractors cannot decide appearance.</li>
<li><b>No emitter reads a source file</b> — the layering, checked from the other side by parsing imports.</li>
<li><b>Style names slots, not colours</b> — one colour language rather than three.</li>
<li><b>Ids are stable across runs</b> — without it, diffing is noise.</li>
</ul>
<div class="note">
<b>Golden tests go on the IR, never on the SVG.</b> Graphviz measures label
text with the host's fonts to size nodes, so identical input produces different
geometry on a machine with different fontconfig. The IR is deterministic; the
SVG is not. Pinning the wrong one gives a suite that fails on somebody else's
laptop for no reason anyone can act on.
</div>
<p>
The browser pages are JavaScript, so they are tested as JavaScript: a stub DOM
under <code>node</code> drives the viewer's zoom and 1:1 toggle, and the
explorer's select-and-walk. Both skip cleanly where node is absent.
</p>
<p>
Self-hosting is the honest end-to-end check, and it is where the real bugs came
from — two name-resolution faults and a duplicate-id crash that no fixture had
reached. <code>make self</code> runs the whole pipeline over soleprint; if the
index does not read like the system, something is wrong.
</p>
<!-- ─────────────────────────────────────────────────────────────── -->
<h2 id="limits">Limits &amp; non-goals</h2>
<p>Things deliberately not done, with the reason, so they are not re-litigated:</p>
<ul>
<li><b>No layout engine.</b> No positioning, no <code>neato -n2</code>, no ELK.
Aspect ratio was solved by choosing the right emitter and by not drawing
everything at once.</li>
<li><b>No <code>calls</code> edges.</b> Resolving <code>self.foo()</code> needs
type inference, and a call graph that is quietly 60% right is worse than
none because it reads as authoritative.</li>
<li><b>No edges from the C#/TypeScript reader.</b> Structure only — half a
dependency graph would look whole.</li>
<li><b>No model in the structural path.</b> Annotation — summarising a module,
naming a cluster — is a later layer, cached to its own file keyed by node
id, merged at emit time, and extraction must work with it absent.</li>
<li><b>No configuration knobs</b> until two real consumers disagree.</li>
</ul>
<p>Known gaps, stated plainly:</p>
<ul>
<li>The C# reader is verified against a written fixture, <b>not a real
repository</b>. That is the next check that matters.</li>
<li>The diff emitter is not built. Stable ids exist to make it possible, and
two IRs from two commits is the cheapest useful thing left.</li>
<li>Minimap blocks carry no names. Rendering them legible only at 1:1, through
the viewer, is the other half of reading a file without reading every line.</li>
</ul>
</main>
</div>
<script>
// Highlight the section being read. No dependency, no build step.
var links = [].slice.call(document.querySelectorAll('.sidebar a[href^="#"]'));
var byId = {};
links.forEach(function (a) { byId[a.getAttribute('href').slice(1)] = a; });
var observer = new IntersectionObserver(function (entries) {
entries.forEach(function (entry) {
var a = byId[entry.target.id];
if (a && entry.isIntersecting) {
links.forEach(function (l) { l.classList.remove('active'); });
a.classList.add('active');
}
});
}, { rootMargin: '-8% 0px -82% 0px' });
document.querySelectorAll('h2[id]').forEach(function (h) { observer.observe(h); });
</script>
</body>
</html>

View File

@@ -0,0 +1,119 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>docgen docs</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #0a0a0a; overflow: hidden; width: 100vw; height: 100vh;
font-family: "Segoe UI", Inter, system-ui, -apple-system, Arial, sans-serif; }
#container { width: 100vw; height: 100vh; overflow: hidden; cursor: grab; }
#container.dragging { cursor: grabbing; }
img { transform-origin: 0 0; user-select: none; -webkit-user-drag: none; }
#hud {
position: fixed; bottom: 14px; left: 14px; display: flex; gap: 8px;
align-items: center; font-size: 11px; color: #a3a3a3;
background: #141414; border: 1px solid #333333;
border-radius: 6px; padding: 5px 9px; user-select: none;
}
#hud b { color: #e5e5e5; font-weight: 600; font-variant-numeric: tabular-nums; }
#hud span { opacity: .7; }
a.back { position: fixed; top: 14px; left: 14px; font-size: 11px;
color: #a3a3a3; text-decoration: none; background: #141414;
border: 1px solid #333333; border-radius: 6px; padding: 5px 9px; }
a.back:hover { color: #e5e5e5; }
</style>
</head>
<body>
<div id="container"><img id="img" alt=""></div>
<a class="back" href="index.html">&larr; docs</a>
<div id="hud"><b id="pct">100%</b><span id="mode">fit</span><span>&middot; click 1:1 &middot; drag &middot; wheel</span></div>
<script>
var src = new URLSearchParams(location.search).get('src');
var img = document.getElementById('img');
var container = document.getElementById('container');
var pct = document.getElementById('pct');
var modeEl = document.getElementById('mode');
if (src) { img.src = src; document.title = src + ' — docgen docs'; }
var scale = 1, x = 0, y = 0, fitScale = 1, mode = 'fit';
var dragging = false, moved = false, startX, startY, startPanX, startPanY;
function apply() {
img.style.transform = 'translate(' + x + 'px,' + y + 'px) scale(' + scale + ')';
pct.textContent = Math.round(scale * 100) + '%';
modeEl.textContent = mode;
}
function fit() {
var sw = window.innerWidth / img.naturalWidth;
var sh = window.innerHeight / img.naturalHeight;
fitScale = Math.min(sw, sh) * 0.95;
scale = fitScale;
x = (window.innerWidth - img.naturalWidth * scale) / 2;
y = (window.innerHeight - img.naturalHeight * scale) / 2;
mode = 'fit';
apply();
}
// Zoom about a point in the viewport, so what is under the cursor stays there.
function zoomAt(px, py, factor) {
x = px - (px - x) * factor;
y = py - (py - y) * factor;
scale *= factor;
mode = Math.abs(scale - fitScale) < 0.001 ? 'fit'
: (Math.abs(scale - 1) < 0.001 ? '1:1' : 'free');
apply();
}
img.onload = fit;
window.addEventListener('resize', function () { if (mode === 'fit') fit(); });
container.addEventListener('wheel', function (e) {
e.preventDefault();
var rect = container.getBoundingClientRect();
zoomAt(e.clientX - rect.left, e.clientY - rect.top, e.deltaY < 0 ? 1.12 : 0.89);
}, { passive: false });
container.addEventListener('mousedown', function (e) {
if (e.button !== 0) return;
dragging = true; moved = false;
startX = e.clientX; startY = e.clientY; startPanX = x; startPanY = y;
container.classList.add('dragging');
e.preventDefault();
});
window.addEventListener('mousemove', function (e) {
if (!dragging) return;
if (Math.abs(e.clientX - startX) > 3 || Math.abs(e.clientY - startY) > 3) moved = true;
x = startPanX + (e.clientX - startX);
y = startPanY + (e.clientY - startY);
apply();
});
window.addEventListener('mouseup', function (e) {
if (!dragging) return;
dragging = false;
container.classList.remove('dragging');
// A click that moved the mouse was a drag, and must not also toggle.
if (moved) return;
if (mode === '1:1') { fit(); return; }
// Toggle to actual size about the point clicked, so the thing you aimed at
// is the thing you end up looking at.
var rect = container.getBoundingClientRect();
zoomAt(e.clientX - rect.left, e.clientY - rect.top, 1 / scale);
mode = '1:1';
apply();
});
container.addEventListener('dblclick', fit);
window.addEventListener('keydown', function (e) {
if (e.key === '0' || e.key === 'f') fit();
if (e.key === '1') { var r = container.getBoundingClientRect();
zoomAt(r.width / 2, r.height / 2, 1 / scale); mode = '1:1'; apply(); }
if (e.key === 'Escape') location.href = 'index.html';
});
</script>
</body>
</html>

View File

@@ -6,7 +6,7 @@ import sys
def main(argv=None): def main(argv=None):
argv = sys.argv[1:] if argv is None else argv argv = sys.argv[1:] if argv is None else argv
if not argv: if not argv:
print("usage: python3 -m docgen.emitters <auto|dot|index|erd|notebook|site> <ir.json> [-o OUT] [--style NAME] [--theme NAME]", print("usage: python3 -m docgen.emitters <auto|dot|index|erd|notebook|site|minimap|explore> <ir.json> [-o OUT] [--style NAME] [--theme NAME]",
file=sys.stderr) file=sys.stderr)
return 2 return 2
name, rest = argv[0], argv[1:] name, rest = argv[0], argv[1:]
@@ -22,8 +22,12 @@ def main(argv=None):
from .cli_notebook import main as run from .cli_notebook import main as run
elif name == "site": elif name == "site":
from .cli_site import main as run from .cli_site import main as run
elif name == "minimap":
from .cli_minimap import main as run
elif name == "explore":
from .cli_explore import main as run
else: else:
print(f"Error: no emitter {name!r} — have: auto, dot, index, erd, notebook, site", file=sys.stderr) print(f"Error: no emitter {name!r} — have: auto, dot, index, erd, notebook, site, minimap, explore", file=sys.stderr)
return 1 return 1
return run(rest) return run(rest)

View File

@@ -0,0 +1,47 @@
""" python3 -m docgen.emitters explore <ir.json> -o DIR [--scale 0.55] [--hops 1]"""
import argparse
import json
import sys
from pathlib import Path
from ..ir import check
from ..style import Style, StyleError
from .explore import write
def main(argv=None):
p = argparse.ArgumentParser(prog="python3 -m docgen.emitters explore")
p.add_argument("ir", type=Path)
p.add_argument("--output", "-o", type=Path, required=True, help="Directory.")
p.add_argument("--style", default="lucid")
p.add_argument("--theme", default=None)
p.add_argument("--scale", type=float, default=0.55)
p.add_argument("--width", type=int, default=1100)
p.add_argument("--hops", type=int, default=1)
p.add_argument("--title", default="")
args = p.parse_args(argv)
try:
data = json.loads(args.ir.read_text())
except (OSError, json.JSONDecodeError) as e:
print(f"Error: could not read {args.ir}: {e}", file=sys.stderr)
return 1
problems = check(data)
if problems:
print(f"Error: {args.ir} is not a valid IR ({len(problems)}):", file=sys.stderr)
for pr in problems[:5]:
print(f" {pr}", file=sys.stderr)
return 1
try:
style = Style.load(args.style, theme=args.theme)
path = write(data, style, args.output, scale=args.scale, width=args.width,
hops=args.hops, title=args.title)
except (StyleError, ValueError) as e:
print(f"Error: {e}", file=sys.stderr)
return 1
graphs = len(list((args.output / "graphs").glob("*.svg"))) if (args.output / "graphs").exists() else 0
print(f" explore {path} {graphs} neighbourhood diagram(s)")
return 0

View File

@@ -0,0 +1,50 @@
""" python3 -m docgen.emitters minimap <ir.json> [-o out.svg] [--scale 0.55]"""
import argparse
import json
import re
import sys
from pathlib import Path
from ..ir import check
from ..style import Style, StyleError
from .minimap import emit
def main(argv=None):
p = argparse.ArgumentParser(prog="python3 -m docgen.emitters minimap")
p.add_argument("ir", type=Path)
p.add_argument("--output", "-o", type=Path)
p.add_argument("--style", default="lucid")
p.add_argument("--theme", default=None)
p.add_argument("--scale", type=float, default=0.55, help="Pixels per source line.")
p.add_argument("--width", type=int, default=1180, help="Wrap a shelf past this.")
args = p.parse_args(argv)
try:
data = json.loads(args.ir.read_text())
except (OSError, json.JSONDecodeError) as e:
print(f"Error: could not read {args.ir}: {e}", file=sys.stderr)
return 1
problems = check(data)
if problems:
print(f"Error: {args.ir} is not a valid IR ({len(problems)}):", file=sys.stderr)
for pr in problems[:5]:
print(f" {pr}", file=sys.stderr)
return 1
try:
style = Style.load(args.style, theme=args.theme)
svg = emit(data, style, scale=args.scale, target_width=args.width)
except (StyleError, ValueError) as e:
print(f"Error: {e}", file=sys.stderr)
return 1
if args.output:
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(svg)
m = re.search(r'width="(\d+)pt" height="(\d+)pt"', svg)
print(f" minimap {args.output} {m.group(1)}x{m.group(2)}" if m else "")
else:
sys.stdout.write(svg)
return 0

View File

@@ -79,12 +79,12 @@ def _tables(ir: dict) -> list[dict]:
return sorted(out, key=lambda t: t["id"]) return sorted(out, key=lambda t: t["id"])
def _card_height(table: dict) -> int: def _card_height(table: dict, columns: bool = True) -> int:
header = HDR_H_DOC if table["doc"] else HDR_H header = HDR_H_DOC if table["doc"] else HDR_H
return header + len(table["fields"]) * FIELD_H return header + (len(table["fields"]) * FIELD_H if columns else 0)
def layout(tables: list[dict], edges: list[dict]) -> dict[str, tuple[int, int]]: def layout(tables: list[dict], edges: list[dict], columns: bool = True) -> dict[str, tuple[int, int]]:
"""Place cards in √n columns, referenced tables first. """Place cards in √n columns, referenced tables first.
Sorting by "is the target of a foreign key" puts the tables everything Sorting by "is the target of a foreign key" puts the tables everything
@@ -102,7 +102,7 @@ def layout(tables: list[dict], edges: list[dict]) -> dict[str, tuple[int, int]]:
for i, table in enumerate(ordered): for i, table in enumerate(ordered):
col = i % cols col = i % cols
pos[table["id"]] = (col * col_w + PAD, cursor[col]) pos[table["id"]] = (col * col_w + PAD, cursor[col])
cursor[col] += _card_height(table) + ROW_GAP cursor[col] += _card_height(table, columns) + ROW_GAP
return pos return pos
@@ -111,8 +111,14 @@ def _field_y(table: dict, index: int, top: int) -> float:
return top + header + (max(index, 0) + 0.5) * FIELD_H return top + header + (max(index, 0) + 0.5) * FIELD_H
def emit(ir: dict, style) -> str: def emit(ir: dict, style, *, columns: bool = True) -> str:
"""IR (a db document) + Style -> SVG text.""" """IR (a db document) + Style -> SVG text.
`columns=False` draws the header of every card and none of its contents —
the whole schema at a glance, which is what you want before you know which
table you care about. Two hundred tables with their columns is a reference;
two hundred names is a map.
"""
tables = _tables(ir) tables = _tables(ir)
if not tables: if not tables:
raise ValueError( raise ValueError(
@@ -121,11 +127,11 @@ def emit(ir: dict, style) -> str:
) )
edges = [e for e in ir["edges"] if e["kind"] in ("foreign_key", "references")] edges = [e for e in ir["edges"] if e["kind"] in ("foreign_key", "references")]
by_id = {t["id"]: t for t in tables} by_id = {t["id"]: t for t in tables}
pos = layout(tables, edges) pos = layout(tables, edges, columns)
s = style.slot s = style.slot
width = max(x for x, _ in pos.values()) + CARD_W + PAD width = max(x for x, _ in pos.values()) + CARD_W + PAD
height = max(y + _card_height(by_id[t]) for t, (_, y) in pos.items()) + PAD height = max(y + _card_height(by_id[t], columns) for t, (_, y) in pos.items()) + PAD
out = [ out = [
'<?xml version="1.0" encoding="UTF-8" standalone="no"?>', '<?xml version="1.0" encoding="UTF-8" standalone="no"?>',
@@ -161,8 +167,12 @@ def emit(ir: dict, style) -> str:
leaving_right = dx_ >= sx leaving_right = dx_ >= sx
x1 = sx + CARD_W if leaving_right else sx x1 = sx + CARD_W if leaving_right else sx
x2 = dx_ if leaving_right else dx_ + CARD_W x2 = dx_ if leaving_right else dx_ + CARD_W
if columns:
y1 = _field_y(src, from_idx, sy_top) y1 = _field_y(src, from_idx, sy_top)
y2 = _field_y(dst, to_idx, dy_top) y2 = _field_y(dst, to_idx, dy_top)
else:
y1 = sy_top + _card_height(src, False) / 2
y2 = dy_top + _card_height(dst, False) / 2
ctrl = min(max(abs(x2 - x1) * 0.5, 50), 180) ctrl = min(max(abs(x2 - x1) * 0.5, 50), 180)
c1 = x1 + ctrl if leaving_right else x1 - ctrl c1 = x1 + ctrl if leaving_right else x1 - ctrl
@@ -178,11 +188,12 @@ def emit(ir: dict, style) -> str:
for table in tables: for table in tables:
x, y = pos[table["id"]] x, y = pos[table["id"]]
header = HDR_H_DOC if table["doc"] else HDR_H header = HDR_H_DOC if table["doc"] else HDR_H
h = _card_height(table) h = _card_height(table, columns)
out.append(f'<g class="table" id="{escape(table["id"])}">') out.append(f'<g class="table">')
out.append( out.append(
f'<rect x="{x}" y="{y}" width="{CARD_W}" height="{h}" rx="{RADIUS}" ' f'<rect x="{x}" y="{y}" width="{CARD_W}" height="{h}" rx="{RADIUS}" '
f'fill="{s("surface-0")}" stroke="{s("border")}" stroke-width="1"/>' f'fill="{s("surface-0")}" stroke="{s("border")}" stroke-width="1" '
f'data-id="{escape(table["id"])}" data-kind="table" class="blk"/>'
) )
# Header band, clipped to the card's rounded top by drawing a rounded # Header band, clipped to the card's rounded top by drawing a rounded
# rect and squaring its bottom with a second one. # rect and squaring its bottom with a second one.
@@ -191,6 +202,7 @@ def emit(ir: dict, style) -> str:
f'h {CARD_W - 2 * RADIUS} a {RADIUS},{RADIUS} 0 0 1 {RADIUS},{RADIUS} ' f'h {CARD_W - 2 * RADIUS} a {RADIUS},{RADIUS} 0 0 1 {RADIUS},{RADIUS} '
f'v {header - RADIUS} h {-CARD_W} z" fill="{s("surface-2")}"/>' f'v {header - RADIUS} h {-CARD_W} z" fill="{s("surface-2")}"/>'
) )
if columns:
out.append( out.append(
f'<line x1="{x}" y1="{y + header}" x2="{x + CARD_W}" y2="{y + header}" ' f'<line x1="{x}" y1="{y + header}" x2="{x + CARD_W}" y2="{y + header}" '
f'stroke="{s("border")}" stroke-width="1"/>' f'stroke="{s("border")}" stroke-width="1"/>'
@@ -207,7 +219,7 @@ def emit(ir: dict, style) -> str:
f'{escape(_truncate(table["doc"], CARD_W - 24))}</text>' f'{escape(_truncate(table["doc"], CARD_W - 24))}</text>'
) )
for i, field in enumerate(table["fields"]): for i, field in enumerate(table["fields"] if columns else []):
fy = y + header + i * FIELD_H fy = y + header + i * FIELD_H
attrs = field.get("attrs") or {} attrs = field.get("attrs") or {}
name = field.get("label") or field["id"].rsplit(".", 1)[-1] name = field.get("label") or field["id"].rsplit(".", 1)[-1]

View File

@@ -0,0 +1,315 @@
"""
Two panes: a minimap to navigate by, and a detail pane to explore with.
The minimap on its own showed shape and no meaning — a block said "a 30-line
class" and not *which* class, or what it touched. It is not the artifact. It is
the **selector**.
left the whole tree as coloured blocks. Scan, then click.
right what that block is, what it reaches, what reaches it — and the
neighbourhood drawn, small enough to read.
**This is also what solves the 14:1 problem.** The whole-graph diagram was
unusable because 109 nodes sat at one dependency level, and no engine draws that
well. Here the whole graph is never drawn: the minimap carries the overview, and
only the neighbourhood of a selection is rendered — a handful of nodes, which
lays out fine every time. Overview and detail stop competing for one picture.
## Selecting for an LLM
The other reason to navigate a tree quickly is to decide what to feed a model.
Blocks can be added to a basket, and the basket is a copyable list of file paths
plus a line count — enough to hand to `distill` or paste, and enough to see that
the selection got too big before spending the context on it.
## Offline and static
The neighbourhood diagrams are rendered at build time, one small SVG per module,
so the page needs no layout engine, no server and no network. Everything is
computed here and read there.
"""
import json
import re
from html import escape
from pathlib import Path
MAX_NEIGHBOURS = 24 # past this, a list reads better than a picture
def _is_schema(ir: dict) -> bool:
return (ir.get("meta") or {}).get("source") == "db" or any(
n["kind"] == "table" for n in ir["nodes"]
)
def _neighbourhood_svgs(ir: dict, style, out_dir: Path, hops: int) -> dict[str, str]:
"""One small diagram per navigable thing, pre-rendered. id -> filename.
A schema walks table by table: the selected table **with its columns**, plus
the tables its keys reach, each clickable to step further. A codebase walks
module by module through its imports. Same operation, different drawing —
which is the `classify` rule applied one level down.
"""
from ..ops import neighbourhood
schema = _is_schema(ir)
if schema:
from .erd import emit as draw
def render_one(view):
return draw(view, style).encode()
wanted = "table"
else:
from .dot import emit as dot_emit, have_graphviz, render
if not have_graphviz():
return {}
def render_one(view):
return render(dot_emit(view, style))
wanted = "module"
graphs_dir = out_dir / "graphs"
graphs_dir.mkdir(parents=True, exist_ok=True)
made: dict[str, str] = {}
for node in ir["nodes"]:
if node["kind"] != wanted:
continue
# A table without its columns is not a table, so a schema's
# neighbourhood carries contents; a module's does not, because its
# contents are the hundred functions that made the sheet unreadable.
view = neighbourhood(ir, node["id"], hops=hops, with_contents=schema)
if len(view["nodes"]) < 2:
continue
if not schema and len(view["nodes"]) > MAX_NEIGHBOURS:
continue
if schema and sum(1 for n in view["nodes"] if n["kind"] == "table") > 12:
continue
name = re.sub(r"[^A-Za-z0-9_.-]", "_", node["id"]) + ".svg"
try:
(graphs_dir / name).write_bytes(render_one(view))
except Exception: # noqa: BLE001 - one bad graph must not cost the page
continue
made[node["id"]] = f"graphs/{name}"
return made
def _facts(ir: dict) -> dict:
"""Everything the detail pane needs, keyed by id."""
out: dict[str, dict] = {}
for n in ir["nodes"]:
a = n.get("attrs") or {}
out[n["id"]] = {
"label": n.get("label") or n["id"],
"kind": n["kind"],
"parent": n.get("parent"),
"file": a.get("file"),
"line": a.get("line"),
"lines": a.get("lines"),
"doc": a.get("doc"),
"error": a.get("error"),
"out": [],
"in": [],
"members": [],
}
for n in ir["nodes"]:
if n.get("parent") in out:
out[n["parent"]]["members"].append(n["id"])
for e in ir["edges"]:
if e["source"] in out:
out[e["source"]]["out"].append([e["target"], e["kind"]])
if e["target"] in out:
out[e["target"]]["in"].append([e["source"], e["kind"]])
return out
def emit(ir: dict, style, minimap_svg: str, graphs: dict[str, str],
title: str = "") -> str:
s = style.slot
meta = ir.get("meta", {})
name = title or meta.get("root", "explore")
facts = _facts(ir)
# The minimap goes inline rather than in an <img>: a block has to be
# clickable, and an image is one opaque rectangle.
inner = minimap_svg[minimap_svg.index("<svg"):]
return f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{escape(name)} — explore</title>
<style>
:root {{
--bg: {s("surface-0")}; --surface: {s("surface-1", s("surface-2"))};
--surface-2: {s("surface-2")}; --border: {s("border")};
--text: {s("text")}; --muted: {s("text-muted")}; --dim: {s("text-dim")};
--accent: {s("accent")};
}}
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{ background: var(--bg); color: var(--text); overflow: hidden;
font-family: "Segoe UI", Inter, system-ui, -apple-system, Arial, sans-serif;
font-size: 12px; line-height: 1.6; }}
.split {{ display: flex; height: 100vh; }}
/* navigate */
.nav {{ flex: 1 1 58%; overflow: auto; padding: 14px; position: relative; }}
.nav svg {{ display: block; }}
.blk {{ cursor: pointer; }}
.blk:hover {{ stroke: var(--accent); stroke-width: 1.5; }}
.blk.sel {{ stroke: var(--accent); stroke-width: 2; }}
.blk.basket {{ stroke: var(--accent); stroke-width: 1; stroke-dasharray: 2 2; }}
/* explore */
.side {{ flex: 0 0 42%; max-width: 560px; border-left: 1px solid var(--border);
background: var(--surface); overflow: auto; padding: 18px 20px; }}
.side h2 {{ font-size: 15px; margin-bottom: 2px; }}
.side .kind {{ color: var(--accent); font-size: 10px; text-transform: uppercase;
letter-spacing: .05em; }}
.side .path {{ color: var(--dim); font-size: 11px; margin: 6px 0 12px;
font-family: ui-monospace, Consolas, monospace; }}
.side .doc {{ color: var(--muted); margin-bottom: 14px; }}
.side h3 {{ font-size: 10px; text-transform: uppercase; letter-spacing: .05em;
color: var(--dim); margin: 16px 0 6px; }}
.side ul {{ list-style: none; }}
.side li {{ padding: 2px 0; color: var(--muted); }}
.side a {{ color: var(--muted); text-decoration: none; cursor: pointer; }}
.side a:hover {{ color: var(--accent); }}
.side .rel {{ color: var(--dim); font-size: 10px; margin-left: .4em; }}
.side img {{ width: 100%; border: 1px solid var(--border); border-radius: 6px;
background: var(--bg); margin-top: 6px; }}
.empty {{ color: var(--dim); }}
.bar {{ position: sticky; top: 0; background: var(--surface);
border-bottom: 1px solid var(--border); margin: -18px -20px 14px;
padding: 10px 20px; display: flex; gap: 10px; align-items: center; }}
button {{ background: var(--surface-2); color: var(--muted); cursor: pointer;
border: 1px solid var(--border); border-radius: 5px; padding: 4px 9px;
font-size: 11px; font-family: inherit; }}
button:hover {{ color: var(--text); border-color: var(--accent); }}
#basket {{ color: var(--accent); }}
textarea {{ width: 100%; height: 120px; background: var(--bg); color: var(--muted);
border: 1px solid var(--border); border-radius: 6px; padding: 8px;
font-family: ui-monospace, Consolas, monospace; font-size: 10px; }}
</style>
</head>
<body>
<div class="split">
<div class="nav" id="nav">{inner}</div>
<aside class="side">
<div class="bar">
<b>{escape(name)}</b>
<span class="rel" id="basket">0 selected</span>
<button onclick="showBasket()">selection</button>
<button onclick="clearBasket()">clear</button>
</div>
<div id="detail"><p class="empty">Click a block. Shift-click adds it to the
selection, for feeding somewhere else.</p></div>
</aside>
</div>
<script>
var FACTS = {json.dumps(facts)};
var GRAPHS = {json.dumps(graphs)};
var basket = [];
function esc(t) {{ return String(t).replace(/[&<>"]/g, function (c) {{
return {{'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;'}}[c]; }}); }}
function link(id, rel) {{
var f = FACTS[id];
var label = f ? f.label : id;
return '<li><a onclick="select(\\'' + id.replace(/'/g, "\\\\'") + '\\')">' +
esc(label) + '</a>' + (rel ? '<span class="rel">' + esc(rel) + '</span>' : '') +
'<span class="rel">' + esc(id) + '</span></li>';
}}
function group(pairs) {{
if (!pairs.length) return '<p class="empty">none</p>';
return '<ul>' + pairs.slice(0, 60).map(function (p) {{ return link(p[0], p[1]); }}).join('') + '</ul>';
}}
function select(id) {{
var f = FACTS[id];
if (!f) return;
document.querySelectorAll('.blk.sel').forEach(function (b) {{ b.classList.remove('sel'); }});
document.querySelectorAll('[data-id="' + CSS.escape(id) + '"]').forEach(function (b) {{
b.classList.add('sel');
}});
var h = '<h2>' + esc(f.label) + '</h2><div class="kind">' + esc(f.kind) + '</div>';
var where = f.file ? f.file + (f.line ? ':' + f.line : '') : id;
h += '<div class="path">' + esc(where) + (f.lines ? ' · ' + f.lines + ' lines' : '') + '</div>';
if (f.doc) h += '<div class="doc">' + esc(f.doc) + '</div>';
if (f.error) h += '<div class="doc">⚠ ' + esc(f.error) + '</div>';
if (GRAPHS[id]) {{
h += '<h3>neighbourhood</h3><img src="' + GRAPHS[id] + '" alt="">';
}}
h += '<h3>reaches (' + f.out.length + ')</h3>' + group(f.out);
h += '<h3>reached by (' + f['in'].length + ')</h3>' + group(f['in']);
if (f.members.length) {{
h += '<h3>contains (' + f.members.length + ')</h3>' +
group(f.members.map(function (m) {{ return [m, FACTS[m] ? FACTS[m].kind : '']; }}));
}}
document.getElementById('detail').innerHTML = h;
}}
function toggleBasket(id) {{
var i = basket.indexOf(id);
if (i >= 0) basket.splice(i, 1); else basket.push(id);
document.querySelectorAll('[data-id="' + CSS.escape(id) + '"]').forEach(function (b) {{
b.classList.toggle('basket', basket.indexOf(id) >= 0);
}});
document.getElementById('basket').textContent = basket.length + ' selected';
}}
function showBasket() {{
// A copyable list of paths and a line count — enough to hand to distill, and
// enough to see the selection got too big before spending context on it.
var files = [], lines = 0, seen = {{}};
basket.forEach(function (id) {{
var f = FACTS[id];
if (!f) return;
var p = f.file || id;
if (!seen[p]) {{ seen[p] = 1; files.push(p); lines += (f.lines || 0); }}
}});
document.getElementById('detail').innerHTML =
'<h2>Selection</h2><div class="kind">' + files.length + ' files · ~' + lines +
' lines</div><div class="path">Paste, or feed to distill.</div>' +
'<textarea readonly>' + esc(files.join('\\n')) + '</textarea>';
}}
function clearBasket() {{
basket = [];
document.querySelectorAll('.blk.basket').forEach(function (b) {{ b.classList.remove('basket'); }});
document.getElementById('basket').textContent = '0 selected';
document.getElementById('detail').innerHTML = '<p class="empty">Cleared.</p>';
}}
document.getElementById('nav').addEventListener('click', function (e) {{
var b = e.target.closest('.blk');
if (!b) return;
if (e.shiftKey) toggleBasket(b.dataset.id); else select(b.dataset.id);
}});
</script>
</body>
</html>
"""
def write(ir: dict, style, out_dir, *, scale: float = 0.55, width: int = 1100,
hops: int = 1, title: str = "") -> Path:
from .minimap import emit as minimap_emit
out_dir = Path(out_dir)
out_dir.mkdir(parents=True, exist_ok=True)
if _is_schema(ir):
# The whole schema, no columns: a map rather than a reference. The
# minimap's line-span geometry means nothing for a table.
from .erd import emit as erd_emit
svg = erd_emit(ir, style, columns=False)
else:
svg = minimap_emit(ir, style, scale=scale, target_width=width)
graphs = _neighbourhood_svgs(ir, style, out_dir, hops)
path = out_dir / "explore.html"
path.write_text(emit(ir, style, svg, graphs, title=title))
return path

View File

@@ -0,0 +1,277 @@
"""
IR -> a structural minimap. What is where, readable without reading.
Sublime's minimap shrinks the *characters*. This draws the *structure* at full
scale: one file is one column, one line is a fixed number of pixels, and every
construct is a block sized by the lines it actually occupies and coloured by
what it is. Nothing is summarised and no text is rendered — the point is to see
the shape of a codebase without reading a line of it.
a tall solid block one long class
a column of thin stripes many small functions
a wide pale gap module-level code, imports, comments
one block filling a file the 800-line thing everybody avoids
**The pattern has to come from the colours alone**, so `kind` is the only thing
that varies and nesting is drawn by inset rather than by hue: a method inside a
class is the class's colour, indented. Scanning a hundred files then shows which
are class-shaped, which are a pile of loose functions, and which are one block.
Not a node-edge graph, and deliberately not forced into one — it answers "what
is where", which a dependency diagram never does.
## Layout
Files are grouped by their package, packed left to right into shelves, and each
shelf is as tall as its tallest file. Grouping by package is what makes *where*
legible: the shape of a subsystem is the shape of its band.
## Geometry
Computed, never measured — `lines × SCALE`. Deterministic, portable, and
independent of any font, which is the same property `erd` has and `dot` does
not.
"""
from html import escape
COL_W = 74 # one file
COL_GAP = 8
SCALE = 0.55 # pixels per line of source
MIN_H = 14
PAD = 28
LABEL_H = 18
ROW_GAP = 26
PKG_GAP = 18
INSET = 7 # per level of nesting
TARGET_W = 1180 # wrap a shelf past this
# Which slot each kind is drawn in. Everything else lands on `default`, so an
# unfamiliar vocabulary still renders rather than vanishing.
KIND_SLOT = {
"class": "station",
"interface": "accent",
"function": "atlas",
"module": "surface-2",
"table": "station",
"endpoint": "accent",
"operation": "accent",
"task": "station",
"external": "muted",
}
def _files(ir: dict) -> list[dict]:
"""Modules with their constructs, nested, each carrying a line span."""
by_id = {n["id"]: n for n in ir["nodes"]}
kids: dict[str, list] = {}
for n in ir["nodes"]:
if n.get("parent"):
kids.setdefault(n["parent"], []).append(n)
def declared(node: dict) -> list:
"""A node's drawable children, with wrappers dissolved.
A C# `namespace` and a TypeScript `module` come through as `kind:
"module"` nested inside a file. Drawing them adds a level of inset to
everything without adding information — and worse, the file's own
contents then appear twice. They are descended through and not drawn.
"""
out = []
for c in sorted(kids.get(node["id"], []),
key=lambda c: ((c.get("attrs") or {}).get("line", 0))):
if not (c.get("attrs") or {}).get("line"):
continue
if c["kind"] == "module":
out.extend(declared(c)) # a wrapper: keep what is inside it
else:
out.append(c)
return out
def build(node: dict, depth: int) -> dict:
a = node.get("attrs") or {}
return {
"id": node["id"],
"label": node.get("label") or node["id"],
"kind": node["kind"],
"line": a.get("line", 1),
"lines": max(1, a.get("lines", 1)),
"depth": depth,
"children": [build(c, depth + 1) for c in declared(node)],
}
out = []
for n in ir["nodes"]:
if n["kind"] != "module":
continue
a = n.get("attrs") or {}
total = a.get("lines")
if not total:
continue
# A file, not a namespace. Both arrive as `module`; only a file has a
# length without a starting line, because a file starts at the start.
if a.get("line"):
continue
node = build(n, 0)
node["total"] = total
node["package"] = n["id"].rsplit(".", 1)[0] if "." in n["id"] else ""
node["error"] = a.get("error")
out.append(node)
return out
def _bands(files: list[dict]) -> list[tuple[str, list]]:
groups: dict[str, list] = {}
for f in files:
groups.setdefault(f["package"] or "(root)", []).append(f)
for members in groups.values():
members.sort(key=lambda f: -f["total"])
return sorted(groups.items(), key=lambda kv: (-len(kv[1]), kv[0]))
def _blocks(node: dict, top: float, height: float, x: float, out: list, style, file_lines: int):
"""Place a construct and everything inside it."""
slot = KIND_SLOT.get(node["kind"], "border")
inset = INSET * max(0, node["depth"] - 1)
out.append({
"x": x + inset,
"y": top,
"w": COL_W - 2 * inset,
"h": max(2.0, height),
"fill": style.slot(slot, style.slot("border")),
"depth": node["depth"],
"id": node["id"],
"kind": node["kind"],
"title": f'{node["label"]}{node["kind"]}, {node["lines"]} lines',
})
for child in node["children"]:
offset = (child["line"] - node["line"]) / max(node["lines"], 1)
child_h = child["lines"] / max(node["lines"], 1) * height
_blocks(child, top + offset * height, child_h, x, out, style, file_lines)
def emit(ir: dict, style, *, scale: float = SCALE, target_width: int = TARGET_W) -> str:
"""IR + Style -> SVG text. No Graphviz, no font measurement."""
files = _files(ir)
if not files:
raise ValueError(
"nothing to map — a minimap needs modules carrying `attrs.lines`. "
"Was this extracted with the python reader?"
)
s = style.slot
placed, labels, marks = [], [], []
x, y, shelf_h, max_x = PAD, PAD + LABEL_H, 0, 0
current_package = None
# One continuous flow, files ordered by package, wrapping at the target
# width — rather than a row per package. A real tree has many small
# packages (soleprint: 234 files in 70) and a row each gave a 1196x10165
# ribbon. The aspect ratio has to be chosen, not left to emerge; the
# grouping stays legible because a package's files are still adjacent.
ordered = [(pkg, f) for pkg, members in _bands(files) for f in members]
for package, f in ordered:
height = max(MIN_H, f["total"] * scale)
starts_package = package != current_package
gap = COL_GAP + (PKG_GAP if starts_package and x > PAD else 0)
if x + gap + COL_W > target_width and x > PAD:
y += shelf_h + ROW_GAP
x, shelf_h, gap = PAD, 0, 0
starts_package = True # a wrap re-labels, so a row is readable alone
elif x > PAD:
x += gap
if starts_package:
marks.append({"x": x, "y": y - 6, "text": package})
current_package = package
placed.append({
"x": x, "y": y, "w": COL_W, "h": height,
"fill": s("surface-1", s("surface-2")), "depth": -1,
"title": f'{f["id"]}{f["total"]} lines'
+ (f' — NOT PARSED: {f["error"]}' if f["error"] else ""),
"outline": s("border"),
})
for child in f["children"]:
offset = (child["line"] - 1) / max(f["total"], 1)
_blocks(child, y + offset * height,
child["lines"] / max(f["total"], 1) * height,
x, placed, style, f["total"])
labels.append({"x": x, "y": y + height + 9, "text": f["label"][:11], "band": False})
shelf_h = max(shelf_h, height + 12)
max_x = max(max_x, x + COL_W)
x += COL_W
y += shelf_h + PAD
labels = marks_to_labels(marks) + labels
width = max(max_x + PAD, 420)
height = y + 30
out = [
'<?xml version="1.0" encoding="UTF-8" standalone="no"?>',
f'<svg xmlns="http://www.w3.org/2000/svg" width="{width:.0f}pt" '
f'height="{height:.0f}pt" viewBox="0 0 {width:.0f} {height:.0f}">',
f'<rect width="{width:.0f}" height="{height:.0f}" fill="{s("surface-0")}"/>',
]
for b in placed:
extra = (f' stroke="{b["outline"]}" stroke-width="1"' if b.get("outline")
else ' stroke="none"')
# Nested blocks sit on their parent, so a little transparency keeps the
# containment readable instead of hiding it.
opacity = "" if b["depth"] < 0 else f' opacity="{0.95 if b["depth"] <= 1 else 0.8}"'
ident = (f' data-id="{escape(b["id"])}" data-kind="{b.get("kind", "")}" '
f'class="blk"' if b.get("id") else "")
out.append(
f'<rect x="{b["x"]:.1f}" y="{b["y"]:.1f}" width="{b["w"]:.1f}" '
f'height="{b["h"]:.1f}" rx="2" fill="{b["fill"]}"{extra}{opacity}{ident}>'
f'<title>{escape(b["title"])}</title></rect>'
)
for lab in labels:
if lab["band"]:
out.append(
f'<text x="{lab["x"]:.0f}" y="{lab["y"]:.0f}" font-family="Helvetica,sans-Serif" '
f'font-size="10" font-weight="bold" fill="{s("text-muted")}">'
f'{escape(lab["text"])}</text>'
)
else:
out.append(
f'<text x="{lab["x"]:.0f}" y="{lab["y"]:.0f}" font-family="Helvetica,sans-Serif" '
f'font-size="7" fill="{s("text-dim")}">{escape(lab["text"])}</text>'
)
# A legend, because the whole claim is that the colours carry the meaning.
lx = PAD
ly = height - 14
for kind in ("module", "class", "interface", "function"):
slot = KIND_SLOT.get(kind, "border")
out.append(
f'<rect x="{lx}" y="{ly - 7}" width="9" height="9" rx="2" '
f'fill="{s(slot, s("border"))}"/>'
)
out.append(
f'<text x="{lx + 13}" y="{ly + 1}" font-family="Helvetica,sans-Serif" '
f'font-size="9" fill="{s("text-dim")}">{kind}</text>'
)
lx += 22 + len(kind) * 6
if lx > width - 220:
ly += 13 # the legend ran into the summary; put it on its own line
out.append(
f'<text x="{width - PAD:.0f}" y="{ly + 1}" text-anchor="end" '
f'font-family="Helvetica,sans-Serif" font-size="9" fill="{s("text-dim")}">'
f'{len(files)} files · {sum(f["total"] for f in files):,} lines · '
f'1px ≈ {1 / scale:.1f} lines</text>'
)
out.append("</svg>")
return "\n".join(out) + "\n"
def marks_to_labels(marks: list) -> list:
"""Package names, as band labels above the first file of each group."""
return [{"x": m["x"], "y": m["y"], "text": m["text"], "band": True} for m in marks]

View File

@@ -0,0 +1,67 @@
// Drive the explorer's logic under a stub DOM: select, walk, basket.
const fs = require('fs');
const html = fs.readFileSync(process.argv[2], 'utf8');
const script = html.split('<script>').pop().split('</script>')[0];
const detail = { innerHTML: '' };
const basketEl = { textContent: '' };
const nav = { addEventListener() {} };
const ids = { detail, basket: basketEl, nav };
global.document = {
getElementById: (i) => ids[i],
querySelectorAll: () => [],
};
global.CSS = { escape: (s) => s };
const api = new Function(script + '; return {select, toggleBasket, showBasket, clearBasket, FACTS, GRAPHS};')();
let ok = true;
const check = (name, cond) => {
console.log(` ${cond ? 'ok ' : 'FAIL'} ${name}`);
if (!cond) ok = false;
};
const ids_ = Object.keys(api.FACTS);
const withEdges = ids_.filter((i) => api.FACTS[i].out.length || api.FACTS[i]['in'].length);
check('facts are embedded for every node', ids_.length > 0);
check('some node has relationships', withEdges.length > 0);
const target = withEdges[0];
api.select(target);
check('selecting renders a detail pane', detail.innerHTML.includes('<h2>'));
check('it names what the thing is', detail.innerHTML.includes(api.FACTS[target].kind));
check('it lists what it reaches', detail.innerHTML.includes('reaches ('));
check('it lists what reaches it', detail.innerHTML.includes('reached by ('));
// Walking forward: every link in the pane must be a selectable id.
const links = [...detail.innerHTML.matchAll(/select\('([^']+)'\)/g)].map((m) => m[1]);
check('neighbours are offered as links to walk to', links.length > 0);
const reachable = links.every((i) => api.FACTS[i] !== undefined);
check('every link resolves to a real node', reachable);
if (links.length) {
api.select(links[0]);
check('walking forward re-renders on the neighbour',
detail.innerHTML.includes(api.FACTS[links[0]].label));
}
const withGraph = ids_.find((i) => api.GRAPHS[i]);
if (withGraph) {
api.select(withGraph);
check('a neighbourhood diagram is shown when one exists',
detail.innerHTML.includes('<img src="graphs/'));
} else {
check('neighbourhood diagrams were generated', false);
}
api.toggleBasket(target);
check('the basket counts a selection', basketEl.textContent === '1 selected');
api.showBasket();
check('the basket is a copyable list of paths', detail.innerHTML.includes('<textarea'));
check('...with a line count, to see it got too big',
/~\d+\s*lines/.test(detail.innerHTML));
api.toggleBasket(target);
check('toggling again removes it', basketEl.textContent === '0 selected');
process.exit(ok ? 0 : 1);

View File

@@ -1,4 +1,4 @@
""" python3 -m docgen.extractors <db|openapi|usage> [options]""" """ python3 -m docgen.extractors <db|openapi|usage|code> [options]"""
import sys import sys
@@ -8,6 +8,9 @@ def main(argv=None):
if argv and argv[0] == "openapi": if argv and argv[0] == "openapi":
from .openapi_main import main as run from .openapi_main import main as run
return run(argv[1:]) return run(argv[1:])
if argv and argv[0] == "code":
from .code_main import main as run
return run(argv[1:])
if argv and argv[0] == "usage": if argv and argv[0] == "usage":
from .usage_main import main as run from .usage_main import main as run
return run(argv[1:]) return run(argv[1:])

View File

@@ -0,0 +1,260 @@
"""
Source in several languages -> IR structure, via **tree-sitter**.
python3 -m docgen.extractors code --root src/ --lang auto -o ir.json
Adopts a parser rather than writing one, which is the rule the brief sets and
the reason this handles generics, strings containing braces, nested types and
`#region` without any of them being a special case. `pip install tree_sitter
tree_sitter_c_sharp tree_sitter_typescript tree_sitter_python`.
**Optional, never required.** Python still goes through the stdlib `ast`
extractor, which does two-pass name resolution tree-sitter would have to
reimplement. Without tree-sitter installed, docgen loses C# and TypeScript and
nothing else — the import is lazy and the error says what to install.
## Structure only, and that is the point
This produces what is *where*: declarations, their nesting, and the lines each
occupies. It produces **no edges**. Resolving a C# `using` or a TypeScript
`import` to the thing it names is a different and much larger job, and the
consumer that needs this — the minimap — needs none of it.
Saying so matters: an extractor that quietly produced half a dependency graph
would be worse than one that produces none, because the half would look whole.
## Vocabulary
module a file, or a namespace
class class, struct, record, enum — a type with members
interface kept separate from class on purpose: in C# and TypeScript the
distinction is most of what reading a file tells you, and the
minimap's whole claim is that the pattern comes from the colours
function method, constructor, property, function, arrow function
"""
import sys
from pathlib import Path
from ..ir import Graph, Meta
# Extension -> (grammar module, language function). `.tsx` needs its own parser:
# the TSX grammar is a different language, not an option on the TypeScript one.
LANGUAGES = {
".cs": ("tree_sitter_c_sharp", "language"),
".ts": ("tree_sitter_typescript", "language_typescript"),
".mts": ("tree_sitter_typescript", "language_typescript"),
".tsx": ("tree_sitter_typescript", "language_tsx"),
".py": ("tree_sitter_python", "language"),
}
# Declaration node types, per grammar, mapped onto the IR's small vocabulary.
DECLARATIONS = {
"c_sharp": {
"namespace_declaration": "module",
"file_scoped_namespace_declaration": "module",
"class_declaration": "class",
"struct_declaration": "class",
"record_declaration": "class",
"record_struct_declaration": "class",
"enum_declaration": "class",
"interface_declaration": "interface",
"method_declaration": "function",
"constructor_declaration": "function",
"destructor_declaration": "function",
"property_declaration": "function",
"operator_declaration": "function",
"local_function_statement": "function",
},
"typescript": {
"module": "module",
"internal_module": "module",
"class_declaration": "class",
"abstract_class_declaration": "class",
"enum_declaration": "class",
"interface_declaration": "interface",
"type_alias_declaration": "interface",
"function_declaration": "function",
"generator_function_declaration": "function",
"method_definition": "function",
"public_field_definition": "function",
},
"python": {
"class_definition": "class",
"function_definition": "function",
"decorated_definition": None, # descend; the real node is inside
},
}
GRAMMAR_FAMILY = {
"tree_sitter_c_sharp": "c_sharp",
"tree_sitter_typescript": "typescript",
"tree_sitter_python": "python",
}
class MissingParser(ImportError):
"""tree-sitter, or one of its grammars, is not installed."""
def _parser(suffix: str):
"""(Parser, family) for a file extension. Lazy, so the import is optional."""
if suffix not in LANGUAGES:
raise MissingParser(f"no grammar registered for {suffix!r}")
module_name, fn = LANGUAGES[suffix]
try:
from tree_sitter import Language, Parser
except ImportError:
raise MissingParser(
"tree-sitter is not installed — C# and TypeScript need it.\n"
" pip install tree_sitter tree_sitter_c_sharp tree_sitter_typescript\n"
"Python does not: it uses the stdlib `ast` extractor."
) from None
try:
grammar = __import__(module_name)
except ImportError:
raise MissingParser(
f"{module_name} is not installed — needed for {suffix} files.\n"
f" pip install {module_name.replace('_', '-')}"
) from None
return Parser(Language(getattr(grammar, fn)())), GRAMMAR_FAMILY[module_name]
def _name(node, source: bytes) -> str | None:
"""A declaration's name, or None when it has none worth recording."""
field = node.child_by_field_name("name")
if field is not None:
return source[field.start_byte:field.end_byte].decode("utf-8", "replace")
# C# properties and TS fields sometimes carry the name as an identifier child.
for child in node.children:
if child.type in ("identifier", "property_identifier", "type_identifier"):
return source[child.start_byte:child.end_byte].decode("utf-8", "replace")
return None
def _walk(node, source: bytes, family: str, out: list, parent: str | None, prefix: str,
seen_ids: set | None = None):
"""Collect declarations, depth-first, keeping nesting in the id."""
table = DECLARATIONS[family]
seen_ids = seen_ids if seen_ids is not None else set()
for child in node.children:
kind = table.get(child.type, ...)
if kind is None:
# A wrapper — a decorated definition, say. Descend without naming it.
_walk(child, source, family, out, parent, prefix, seen_ids)
continue
if kind is ...:
_walk(child, source, family, out, parent, prefix, seen_ids)
continue
name = _name(child, source)
if not name:
_walk(child, source, family, out, parent, prefix, seen_ids)
continue
nid = f"{prefix}.{name}" if prefix else name
if nid in seen_ids:
# Same reason as the Python extractor: an overload, a partial class,
# or a name declared twice in different branches. The line keeps the
# id unique without making it unstable.
nid = f"{nid}#L{child.start_point[0] + 1}"
seen_ids.add(nid)
out.append({
"id": nid,
"kind": kind,
"label": name,
"parent": parent,
"line": child.start_point[0] + 1,
"lines": max(1, child.end_point[0] - child.start_point[0] + 1),
})
_walk(child, source, family, out, nid, nid, seen_ids)
def extract_file(path: Path, root: Path) -> tuple[dict, list] | None:
"""One file -> (module node, declarations). None when unparseable."""
parser, family = _parser(path.suffix)
try:
source = path.read_bytes()
except OSError as e:
return {"error": str(e)}, []
rel = path.relative_to(root)
module_id = ".".join([*rel.parts[:-1], rel.stem])
tree = parser.parse(source)
decls: list = []
_walk(tree.root_node, source, family, decls, module_id, module_id)
module = {
"id": module_id,
"kind": "module",
"label": rel.stem,
"parent": ".".join(rel.parts[:-1]) or None,
"lines": source.count(b"\n") + 1,
"file": rel.as_posix(),
# tree-sitter never fails to parse; it produces ERROR nodes instead. That
# is more useful than an exception, and worth recording rather than
# silently accepting a partial tree.
"errors": _count_errors(tree.root_node),
}
return module, decls
def _count_errors(node) -> int:
n = 1 if node.type == "ERROR" or node.is_missing else 0
for child in node.children:
n += _count_errors(child)
return n
def extract(root, suffixes=None, exclude=(), source: str = "code") -> Graph:
"""Walk a tree and map its structure. No edges — see the module docstring."""
root = Path(root).resolve()
if not root.is_dir():
raise NotADirectoryError(f"not a directory: {root}")
wanted = tuple(suffixes) if suffixes else tuple(LANGUAGES)
skip = {"__pycache__", ".git", ".venv", "venv", "node_modules", "dist", "build",
"bin", "obj", *exclude}
g = Graph(Meta(source=source, root=root.name))
packages: set[str] = set()
files = [
p for p in sorted(root.rglob("*"))
if p.suffix in wanted and p.is_file()
and not any(part in skip for part in p.relative_to(root).parts)
]
for path in files:
try:
module, decls = extract_file(path, root)
except MissingParser:
raise
except Exception as e: # noqa: BLE001 - one bad file must not cost the run
rel = path.relative_to(root)
g.node(".".join([*rel.parts[:-1], rel.stem]), "module", rel.stem,
attrs={"file": rel.as_posix(), "error": f"{type(e).__name__}: {e}"})
continue
parent = module["parent"]
if parent:
packages.add(parent)
attrs = {"file": module["file"], "lines": module["lines"]}
if module["errors"]:
attrs["error"] = f"{module['errors']} unparsed region(s)"
g.node(module["id"], "module", module["label"], parent=parent, attrs=attrs)
for d in decls:
g.node(d["id"], d["kind"], d["label"], parent=d["parent"],
attrs={"file": module["file"], "line": d["line"], "lines": d["lines"]})
# Directories that hold files but are not themselves files still need to
# exist, or every module in them is an orphan.
known = {n.id for n in g.nodes}
for pkg in sorted(packages):
parts = pkg.split(".")
for i in range(1, len(parts) + 1):
pid = ".".join(parts[:i])
if pid not in known:
g.node(pid, "module", parts[i - 1],
parent=".".join(parts[: i - 1]) or None,
attrs={"lines": 0, "directory": True})
known.add(pid)
return g

View File

@@ -0,0 +1,39 @@
""" python3 -m docgen.extractors code --root src/ [--ext .cs] [-o ir.json]"""
import argparse
import json
import sys
from pathlib import Path
def main(argv=None):
p = argparse.ArgumentParser(prog="python3 -m docgen.extractors code")
p.add_argument("--root", "-s", required=True, type=Path)
p.add_argument("--output", "-o", type=Path)
p.add_argument("--ext", action="append", default=[],
help="Limit to these extensions. Default: every registered one.")
p.add_argument("--exclude", action="append", default=[])
args = p.parse_args(argv)
from .code import LANGUAGES, MissingParser, extract
try:
ir = extract(args.root, suffixes=args.ext or None, exclude=tuple(args.exclude))
except MissingParser as e:
print(f"Error: {e}", file=sys.stderr)
return 1
except (NotADirectoryError, OSError) as e:
print(f"Error: {e}", file=sys.stderr)
return 1
text = json.dumps(ir.to_dict(), indent=2) + "\n"
if args.output:
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(text)
from collections import Counter
c = Counter(n.kind for n in ir.nodes)
print(f"{len(ir.nodes)} nodes -> {args.output} "
+ " · ".join(f"{v} {k}" for k, v in sorted(c.items(), key=lambda kv: -kv[1])))
else:
sys.stdout.write(text)
return 0

View File

@@ -101,8 +101,16 @@ def to_ir(modules: list[Module], root: str, source: str = "python") -> Graph:
attrs=attrs, attrs=attrs,
) )
taken: set[str] = set()
for d in m.defines: for d in m.defines:
nid = _id_for(m, d.qualname) nid = _id_for(m, d.qualname)
if nid in taken:
# Two definitions can share a qualified name — the same helper
# defined in both branches of an `if`, or a name rebound later.
# That is legal Python and the id has to stay unique, so the
# line disambiguates. Stable across runs, and it says which one.
nid = f"{nid}#L{d.lineno}"
taken.add(nid)
parent_qual = d.qualname.rsplit(".", 1)[0] if "." in d.qualname else None parent_qual = d.qualname.rsplit(".", 1)[0] if "." in d.qualname else None
parent = _id_for(m, parent_qual) if parent_qual else m.name parent = _id_for(m, parent_qual) if parent_qual else m.name
a = {"file": m.path, "line": d.lineno, "lines": max(1, d.end_lineno - d.lineno + 1)} a = {"file": m.path, "line": d.lineno, "lines": max(1, d.end_lineno - d.lineno + 1)}

View File

@@ -160,7 +160,8 @@ def subtree(ir: dict, root_id: str) -> dict:
return _rebuild(ir, keep) return _rebuild(ir, keep)
def neighbourhood(ir: dict, node_id: str, hops: int = 1, *, undirected: bool = True) -> dict: def neighbourhood(ir: dict, node_id: str, hops: int = 1, *, undirected: bool = True,
with_contents: bool = False) -> dict:
"""This node and everything within `hops` edges of it. """This node and everything within `hops` edges of it.
The view behind "what does this touch, and what touches it". Containment The view behind "what does this touch, and what touches it". Containment
@@ -188,6 +189,21 @@ def neighbourhood(ir: dict, node_id: str, hops: int = 1, *, undirected: bool = T
while parent and parent not in keep: while parent and parent not in keep:
keep.add(parent) keep.add(parent)
parent = by_id.get(parent, {}).get("parent") parent = by_id.get(parent, {}).get("parent")
if with_contents:
# A table without its columns is not a table. Ancestors come along by
# default because the tree has to stay whole; descendants do not, and
# for anything card-shaped they are the substance.
children: dict[str, list] = {}
for n in ir["nodes"]:
if n.get("parent"):
children.setdefault(n["parent"], []).append(n["id"])
stack = list(keep)
while stack:
for kid in children.get(stack.pop(), ()):
if kid not in keep:
keep.add(kid)
stack.append(kid)
return _rebuild(ir, keep) return _rebuild(ir, keep)

View File

@@ -47,6 +47,8 @@ ops_mod = __import__(f"{PKG}.ops", fromlist=["*"])
erd_mod = __import__(f"{PKG}.emitters.erd", fromlist=["*"]) erd_mod = __import__(f"{PKG}.emitters.erd", fromlist=["*"])
nb_mod = __import__(f"{PKG}.emitters.notebook", fromlist=["*"]) nb_mod = __import__(f"{PKG}.emitters.notebook", fromlist=["*"])
site_mod = __import__(f"{PKG}.emitters.site", fromlist=["*"]) site_mod = __import__(f"{PKG}.emitters.site", fromlist=["*"])
mm_mod = __import__(f"{PKG}.emitters.minimap", fromlist=["*"])
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=["*"])
@@ -1108,6 +1110,217 @@ else:
_shutil.rmtree(site_dir, ignore_errors=True) _shutil.rmtree(site_dir, ignore_errors=True)
# --------------------------------------------------------------------------
print("\n12. the minimap — what is where, read from the colours")
mm = mm_mod.emit(ir, lucid, scale=1.0)
check("it produces SVG", mm.startswith("<?xml") and "</svg>" in mm)
check(
"a block is sized by the lines it occupies",
mm_mod._files(ir)[0]["total"] > 1
and all(f["total"] >= 1 for f in mm_mod._files(ir)),
)
check(
"no text is rendered inside a block",
"<text" in mm and mm.count("<rect") > mm.count("<text"),
"the shape is the message; labels are for the margins",
)
# The claim: kind is the only thing that varies, so the pattern is in the colour.
slots = {mm_mod.KIND_SLOT[k] for k in ("class", "interface", "function")}
check(
"class, interface and function are three different colours",
len({lucid.slot(x) for x in slots}) == 3,
f"got {[lucid.slot(x) for x in slots]}",
)
for kind in ("class", "function"):
check(f"a {kind} is drawn in its slot",
lucid.slot(mm_mod.KIND_SLOT[kind]) in mm)
# A namespace and a file both arrive as `module`; drawing both puts a file's
# contents on the canvas twice and insets everything for nothing.
wrapped = {
"meta": {"source": "code", "root": "x", "schema_version": "1"},
"nodes": [
{"id": "F", "kind": "module", "label": "F", "parent": None,
"attrs": {"file": "F.cs", "lines": 40}},
{"id": "F.Ns", "kind": "module", "label": "Ns", "parent": "F",
"attrs": {"file": "F.cs", "line": 2, "lines": 38}},
{"id": "F.Ns.C", "kind": "class", "label": "C", "parent": "F.Ns",
"attrs": {"file": "F.cs", "line": 4, "lines": 30}},
],
"edges": [],
}
mapped = mm_mod._files(wrapped)
check(
"a namespace wrapper is dissolved, not drawn as a second file",
len(mapped) == 1 and mapped[0]["id"] == "F",
f"got {[f['id'] for f in mapped]}",
)
check(
"...and what was inside it survives, re-parented",
[c["label"] for c in mapped[0]["children"]] == ["C"],
f"got {[c['label'] for c in mapped[0]['children']]}",
)
# Layout is chosen, not emergent — the same lesson as the aspect-ratio work.
# A band per package gave 1196x10165 on a tree of 234 files in 70 packages.
many = {
"meta": {"source": "python", "root": "x", "schema_version": "1"},
"nodes": [
{"id": f"p{i}.m", "kind": "module", "label": "m", "parent": None,
"attrs": {"file": f"p{i}/m.py", "lines": 60}}
for i in range(60)
],
"edges": [],
}
wide = mm_mod.emit(many, lucid, scale=0.5, target_width=1000)
import re as _re2
m2 = _re2.search(r'width="(\d+)pt" height="(\d+)pt"', wide)
w2, h2 = int(m2.group(1)), int(m2.group(2))
check(
f"60 files in 60 packages still pack ({w2}x{h2})",
h2 < w2 * 3,
"a row per package turns a wide map into a ribbon",
)
check(
"an IR with no line spans says so rather than drawing nothing",
"lines" in _err(lambda: mm_mod.emit(
{"meta": {"source": "x", "root": "x", "schema_version": "1"},
"nodes": [], "edges": []}, lucid)),
)
# --------------------------------------------------------------------------
print("\n13. tree-sitter — C# and TypeScript, when it is installed")
try:
code_ex = __import__(f"{PKG}.extractors.code", fromlist=["*"])
code_ex._parser(".cs")
except Exception as e: # noqa: BLE001
skip("tree-sitter", str(e).splitlines()[0][:64])
else:
cs = ROOT.parent / "cs"
(cs / "Core").mkdir(parents=True, exist_ok=True)
(cs / "Core" / "Shop.cs").write_text(
'using System;\n'
'\n'
'namespace Shop.Core\n'
'{\n'
' public interface IRepo { Task<int> CountAsync(); }\n'
'\n'
' public class Repo : IRepo\n'
' {\n'
' public async Task<int> CountAsync()\n'
' {\n'
' var sql = "SELECT 1 WHERE x = {0}";\n'
' return 1;\n'
' }\n'
'\n'
' private enum Mode { A, B }\n'
' }\n'
'}\n'
)
got = code_ex.extract(cs).to_dict()
kinds = {n["id"].rsplit(".", 1)[-1]: n["kind"] for n in got["nodes"]}
check("the C# IR validates", check_ir(got) == [], str(check_ir(got)[:2]))
check("a class is a class", kinds.get("Repo") == "class", str(kinds))
check(
"an interface is kept apart from a class",
kinds.get("IRepo") == "interface",
"in C# and TypeScript that distinction is most of what a file tells you",
)
check("a method is a function", kinds.get("CountAsync") == "function")
iface_method = next(n for n in got["nodes"] if n["id"].endswith(".IRepo.CountAsync"))
check(
"an interface's one-line method is one line",
iface_method["attrs"]["lines"] == 1,
"and is a different node from the class's implementation of it",
)
check("a nested enum is found", kinds.get("Mode") == "class")
# `.endswith("Repo.CountAsync")` also matches `IRepo.CountAsync`, whose
# one-line declaration is correct — so name the class's method exactly.
method = next(n for n in got["nodes"] if n["id"].endswith(".Repo.CountAsync"))
check(
"a brace inside a string does not end a block",
method["attrs"]["lines"] >= 4,
f'got {method["attrs"]["lines"]} lines — a scanner would stop at the brace in the string',
)
check(
"structure only — no edges are invented",
got["edges"] == [],
"resolving a `using` is a different job, and half a graph looks whole",
)
check("the minimap draws it", mm_mod.emit(got, lucid).startswith("<?xml"))
_shutil.rmtree(cs, ignore_errors=True)
# --------------------------------------------------------------------------
print("\n14. explore — navigate on one side, explore on the other")
# The minimap alone showed shape and no meaning. It is not the artifact, it is
# the selector — and that is also what retires the 14:1 sheet: the whole graph
# is never drawn, only the neighbourhood of a selection.
exp_dir = ROOT.parent / "exp"
page = exp_mod.write(db_ir, lucid, exp_dir, title="schema")
html = page.read_text()
check("it writes one self-contained page", page.name == "explore.html")
check(
"a schema navigates on table headers, not line spans",
"erd" not in html and 'data-kind="table"' in html,
"the minimap's geometry is lines-of-source, which means nothing for a table",
)
check(
"the overview carries no columns",
html.count('data-kind="table"') == sum(
1 for n in db_ir["nodes"] if n["kind"] == "table"
),
)
check("the minimap is inline, so a block can be clicked", "<svg" in html and "<img src=" in html)
check("facts travel with the page", '"kind"' in html and '"out"' in html)
# `xmlns="http://www.w3.org/2000/svg"` is an XML namespace — an identifier, not
# something anything fetches. What must not appear is a *reference*.
fetched = re.findall(r'(?:src|href)\s*=\s*"(https?://[^"]+)"', html)
check("nothing is fetched from the network", not fetched, str(fetched[:3]))
graphs = sorted((exp_dir / "graphs").glob("*.svg"))
check(f"neighbourhoods are pre-rendered ({len(graphs)})", len(graphs) >= 1)
check(
"a table's neighbourhood keeps its columns",
any("column" not in g.read_text() or "PK" in g.read_text() for g in graphs),
"a table without its columns is not a table",
)
# A module's neighbourhood must NOT drag its contents in — that is the sheet.
code_ir = ops_mod.overview(ir)
mod_view = ops_mod.neighbourhood(code_ir, next(
n["id"] for n in code_ir["nodes"] if n["kind"] == "module"), hops=1)
check(
"a module's neighbourhood stays small",
len(mod_view["nodes"]) <= 24,
"its contents are the hundred functions that made the sheet unreadable",
)
if not _shutil.which("node"):
skip("explore behaviour", "node not installed")
else:
harness = HERE / "explore_test.js"
if not harness.exists():
skip("explore behaviour", "explore_test.js missing")
else:
proc = _sub.run(["node", str(harness), str(page)], capture_output=True, text=True)
for line in proc.stdout.strip().splitlines():
name = line.strip()[5:]
(PASS if line.strip().startswith("ok") else FAIL).append(f"explore: {name}")
print(f" {line.strip()[:4]} explore: {name}")
if proc.returncode and not proc.stdout.strip():
check("the explore harness runs", False, proc.stderr.strip()[:200])
_shutil.rmtree(exp_dir, ignore_errors=True)
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
tmp.cleanup() tmp.cleanup()
print() print()

View File

@@ -25,6 +25,9 @@ can see anything.
import json import json
from pathlib import Path from pathlib import Path
# Harvesting a theme from real diagrams. Imported lazily by callers that want
# it; `extract` needs lxml and the rest of the package does not.
HERE = Path(__file__).resolve().parent HERE = Path(__file__).resolve().parent
# Keys whose value is a slot name to be resolved against the theme. Anything # Keys whose value is a slot name to be resolved against the theme. Anything
@@ -177,3 +180,14 @@ class Style:
def limits(self) -> dict: def limits(self) -> dict:
"""Where this style asks for more than the target can express.""" """Where this style asks for more than the target can express."""
return {k: v for k, v in self.data.get("limits", {}).items() if k not in NOTE_KEYS} return {k: v for k, v in self.data.get("limits", {}).items() if k not in NOTE_KEYS}
def harvest(folder, out_dir, name: str = "harvested"):
"""A folder of exported diagrams -> tokens.json and a theme to paste in.
Offline, and it never reads text content: style values are visual metadata,
so the semantics of a confidential diagram are not needed and not touched.
"""
from .tokens import from_folder
return from_folder(folder, out_dir, name)

View File

@@ -190,6 +190,16 @@
"text": "text", "text": "text",
"bold": true, "bold": true,
"note": "A GraphQL operation \u2014 usually one endpoint carrying many, which is why it is its own kind rather than a path." "note": "A GraphQL operation \u2014 usually one endpoint carrying many, which is why it is its own kind rather than a path."
},
"interface": {
"shape": "box",
"rounded": true,
"fill": "surface-0",
"border": "accent",
"text": "text",
"dashed": true,
"bold": true,
"note": "A contract rather than an implementation. Dashed, because that is the convention everywhere else too."
} }
}, },
"groups": { "groups": {

View File

@@ -0,0 +1,210 @@
"""
A harvested token vocabulary -> a **theme**.
`extract.py` says which values a folder of real diagrams uses and how often.
This decides which of them are the house palette. The rule is the plain one:
the top two or three fills *are* the palette
the modal stroke width *is* the house line weight
Nothing cleverer. Frequency is a good enough signal because a real diagram set
repeats its own vocabulary constantly and its one-offs stay one-offs.
## It produces a theme, not a style file
A style file says *what a `kind` looks like* — "a class is filled with
`surface-0` and outlined in `station`". That is a design decision and no amount
of counting hexes recovers it. What harvesting recovers is the **binding**:
which colour `station` should be. So the output is a `themes` entry — a slot to
hex map — that drops into an existing style file beside `dark` and `lucid`.
That split is the point of the whole colour language. One set of rules, several
bindings, and a new binding costs a paste rather than a rewrite.
## The judgement calls, named
Three, all of the kind that should be visible rather than buried:
- **Canvas and ink are the two lightness extremes, not the two most common
values.** Frequency is the wrong signal: in a Graphviz SVG every label carries
a `fill`, so the ink outnumbers the canvas 87 to 43, and "most common is the
background" produces a theme whose text is invisible against it.
- **Polarity is assumed light**, because a folder of exports is usually for
print. `dark=True` flips it. Get it backwards and the contrast check says so
rather than letting an unreadable theme through quietly.
- **Slot assignments are a guess.** Which accent means `artery` is a decision,
not a measurement. The note says so, and they are meant to be checked.
- **Widths and sizes take the mode, not the mean.** A mean of 1 and 4 is 2.5,
which is a width the diagrams never use.
"""
import json
from pathlib import Path
# The slots a theme has to bind for a style file to load. Anything a harvest
# cannot find gets a neutral, so a partial harvest is still usable.
REQUIRED_SLOTS = (
"surface-0", "surface-1", "surface-2",
"border", "border-strong",
"text", "text-muted", "text-dim", "text-on-fill",
"accent", "accent-soft",
"artery", "artery-soft", "atlas", "atlas-soft", "station", "station-soft",
"ok", "error", "muted", "muted-soft",
"alt-1", "alt-1-soft", "alt-2", "alt-2-soft",
)
def _top(tokens: dict, prop: str, n: int = 10) -> list[str]:
return [e["value"] for e in tokens.get(prop, [])[:n] if e["value"].startswith("#")]
def _mode(tokens: dict, prop: str, fallback: str) -> str:
entries = [e for e in tokens.get(prop, []) if e["value"] not in ("none", "transparent")]
return entries[0]["value"] if entries else fallback
def _luminance(hexv: str) -> float:
"""Rough perceptual lightness, 0..1. Enough to sort pale from dark."""
if not hexv.startswith("#") or len(hexv) != 7:
return 0.5
r, g, b = (int(hexv[i: i + 2], 16) / 255 for i in (1, 3, 5))
return 0.2126 * r + 0.7152 * g + 0.0722 * b
def derive(data: dict, name: str = "harvested", *, dark: bool = False) -> dict:
"""Token JSON -> a `themes` entry: {name: {note, slots}}.
Paste the result into a style file's `themes` and select it with
`--theme <name>`. Every slot is bound, so the file loads; the ones a harvest
could not distinguish fall back to a neutral rather than being absent, which
would fail validation at load.
"""
tokens = data.get("tokens", {})
fills = _top(tokens, "fill")
strokes = _top(tokens, "stroke")
# The canvas and the ink are the two **lightness extremes**, not the two
# most common values. Frequency is the wrong signal here: in a Graphviz SVG
# every label carries a `fill`, so the ink outnumbers the canvas 87 to 43
# and "most common is the background" puts the text colour on the canvas —
# which renders a theme whose text is invisible against its own background.
if fills:
lightest = max(fills, key=_luminance)
darkest = min(fills, key=_luminance)
else:
lightest, darkest = "#ffffff", "#333333"
# Polarity is a guess, and it is the one the caller most needs to check:
# a light export and a dark one use the same two extremes the other way
# round. Light is assumed, because a folder of exports is usually for print.
background, ink = (lightest, darkest) if not dark else (darkest, lightest)
mid = [f for f in fills if f not in (lightest, darkest)]
surface = (min(mid, key=lambda f: abs(_luminance(f) - _luminance(background)))
if mid else background)
border = strokes[0] if strokes else "#9aa5b1"
strong = strokes[1] if len(strokes) > 1 else border
# Accents are the frequent colours that are neither the canvas nor the ink —
# the ones a diagram uses to mean something.
neutral = {background, surface, ink, border, strong}
accents = [f for f in fills + strokes if f not in neutral]
seen, ordered = set(), []
for a in accents:
if a not in seen:
seen.add(a)
ordered.append(a)
def accent(i: int, fallback: str) -> str:
return ordered[i] if i < len(ordered) else fallback
slots = {
"surface-0": background,
"surface-1": surface,
"surface-2": surface,
"border": border,
"border-strong": strong,
"text": ink,
"text-muted": strong,
"text-dim": border,
"text-on-fill": background,
"accent": accent(0, strong),
"accent-soft": surface,
"artery": accent(1, strong),
"artery-soft": surface,
"atlas": accent(2, strong),
"atlas-soft": surface,
"station": accent(3, strong),
"station-soft": surface,
"ok": accent(2, strong),
"error": accent(1, strong),
"muted": border,
"muted-soft": surface,
"alt-1": accent(4, strong),
"alt-1-soft": surface,
"alt-2": accent(5, strong),
"alt-2-soft": surface,
}
for slot in REQUIRED_SLOTS:
slots.setdefault(slot, border)
contrast = abs(_luminance(slots["text"]) - _luminance(slots["surface-0"]))
if contrast < 0.25:
# Not fatal — the harvest is still the best available guess — but a
# theme nobody can read is worth saying out loud rather than shipping.
slots["_warning"] = (
f"text and surface-0 differ by {contrast:.2f} in lightness; this theme "
"is close to unreadable. The polarity is probably backwards."
)
return {
name: {
"note": (
f"Harvested from {data.get('files_read', 0)} diagram(s) in "
f"{data.get('source', 'a target folder')}. Frequency-sorted: the top "
"fills are the palette, the modal stroke width the house line weight. "
"Slot *assignments* are a guess — which accent means `artery` is a "
"decision, not a measurement, so check them. Polarity was assumed "
f"{'dark' if dark else 'light'} — pass dark=True if that is backwards. "
"No text content was read. "
f"Also seen, unassigned: {', '.join(ordered[6:12]) or 'none'}."
),
"slots": slots,
}
}
def geometry(data: dict) -> dict:
"""The non-colour half: line weight, type size, corner rounding."""
tokens = data.get("tokens", {})
size = _mode(tokens, "font-size", "11")
return {
"hairline": _mode(tokens, "stroke-width", "1"),
"font-size-base": size,
"font-size-sm": str(max(int(float(size)) - 1, 6)),
"font-size-header": str(int(float(size)) + 2),
"font": _mode(tokens, "font-family", "Helvetica"),
# Lucid's `rounding` is a scalar and DOT's `rounded` is binary, so the
# radius is dropped. Its presence is the only part that survives.
"rounded": bool(tokens.get("rx")),
}
def write(data: dict, out, name: str = "harvested", *, dark: bool = False) -> Path:
"""Write the theme entry, ready to paste into a style file's `themes`."""
out = Path(out)
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(json.dumps({"themes": derive(data, name, dark=dark),
"geometry": geometry(data)}, indent=2) + "\n")
return out
def from_folder(folder, out_dir, name: str = "harvested", *, dark: bool = False) -> dict:
"""The whole path: a folder of exports -> tokens.json + a theme."""
from . import extract
out_dir = Path(out_dir)
tokens_path = extract.write(folder, out_dir / "tokens.json")
data = json.loads(tokens_path.read_text())
theme_path = write(data, out_dir / f"{name}.theme.json", name, dark=dark)
return {"tokens": tokens_path, "theme": theme_path, "data": data}

View File

@@ -1,4 +0,0 @@
# Everything this makes. Regenerate with `make notebook`, `make graph`.
out/
__pycache__/
*.pyc

View File

@@ -1,88 +0,0 @@
# docgen — one target per thing this makes.
#
# The folder is meant to be copied out of soleprint and used on its own, so
# everything here is derived from where this file sits rather than written down:
# copy the directory anywhere, `cd` into it, and `make` works. Renaming it works
# too, since the package name comes from the directory.
#
# This tool is a library, not a CLI. There is no `python -m docgen` and no
# argparse — the prompt is explicit that it is consumed from a notebook. So each
# target below is a one-line `python3 -c` against the public surface, which
# doubles as the shortest possible worked example of calling it.
#
# make check prove it works, on fixtures it builds
# make notebook the three .ipynb variants -> out/
# make graph the example graph, every profile -> out/
# make extract DIR=~/diagrams a folder of exports -> tokens + a profile
# make profiles what profiles are available
# make doctor what this machine has
#
# The logic lives in the Python, never here.
HERE := $(patsubst %/,%,$(dir $(abspath $(lastword $(MAKEFILE_LIST)))))
PKG := $(notdir $(HERE))
PARENT := $(patsubst %/,%,$(dir $(HERE)))
PY ?= python3
# Run the package from its parent, which is what an import needs and what lets
# this work without installing anything.
RUN := PYTHONPATH=$(PARENT) $(PY) -c
OUT ?= $(HERE)/out
DIR ?=
NAME ?= extracted
.PHONY: help check notebook graph extract profiles doctor clean
help: ## List every target
@echo "docgen — graph generation and document output, with the style as data"
@echo
@grep -E '^[a-z-]+:.*?## .*$$' $(MAKEFILE_LIST) \
| awk 'BEGIN{FS=":.*?## "}{printf " \033[1m%-12s\033[0m %s\n", $$1, $$2}'
@echo
@echo " OUT=/path where output goes (default: $(PKG)/out)"
@echo " DIR=/path the folder of exported diagrams, for extract"
@echo " NAME=lucid-real what to call the extracted profile"
@echo
@echo " It is a library. From a notebook:"
@echo " from $(PKG) import Profile, emit, render # graphgen owns Graph"
check: ## Prove the whole thing works, needing nothing installed and no network
@$(PY) $(HERE)/selftest.py
notebook: ## Emit vanilla, executable and live .ipynb into OUT
@$(RUN) "from $(PKG).export import write_all; \
from $(PKG).export.specs import vanilla; \
[print(' ', p) for p in write_all(vanilla.build(), '$(OUT)')]"
graph: ## Render graphgen's example graph through every shipped profile into OUT
@$(RUN) "from $(PKG).demo import render_all; render_all('$(OUT)')"
extract: ## A folder of exported SVG/PDF diagrams -> tokens.json + a profile
@test -n "$(DIR)" || { echo "Error: set DIR=/path/to/diagrams" >&2; exit 1; }
@test -d "$(DIR)" || { echo "Error: no such folder: $(DIR)" >&2; exit 1; }
@$(RUN) "from $(PKG).style import from_folder, summarise; \
r = from_folder('$(DIR)', '$(OUT)', '$(NAME)'); \
print(summarise(r['data'])); \
print(); print(' tokens ', r['tokens']); print(' profile ', r['profile']); \
print(); print(' Drop the profile into $(PKG)/profiles/ to use it by name,'); \
print(' or pass its path: Profile.load(\"%s\")' % r['profile'])"
profiles: ## What style profiles are available
@$(RUN) "from $(PKG) import Profile; \
[print(' %-12s %s' % (n, Profile.load(n).data.get('note','')[:88])) for n in Profile.available()]"
doctor: ## Report whether this machine can run it
@printf 'python : '; $(PY) --version 2>&1 || echo MISSING
@printf 'dot : '; (dot -V 2>&1) || echo 'MISSING — sudo apt install graphviz (needed to render)'
@printf 'pdftocairo : '; (pdftocairo -v 2>&1 | head -1) || echo 'MISSING — sudo apt install poppler-utils (only for PDF sources)'
@printf 'lxml : '; $(PY) -c 'import lxml.etree; print(lxml.etree.__version__)' 2>/dev/null || echo 'MISSING — pip install lxml (only for extraction)'
@printf 'package : %s (from %s)\n' '$(PKG)' '$(PARENT)'
@printf 'graphgen : '; test -d '$(PARENT)/graphgen' \
&& echo 'beside this folder — `make graph` will work' \
|| echo 'absent — everything works except `make graph` (it owns the model)'
@$(RUN) "import $(PKG), $(PKG).style, $(PKG).export" >/dev/null 2>&1 \
&& echo 'import : ok' || echo 'import : FAILED — is the folder intact?'
clean: ## Delete OUT. Nothing else is ever written to
@rm -rf "$(OUT)" && echo "Removed $(OUT)"

View File

@@ -1,256 +0,0 @@
# docgen
The interface to Graphviz and DOT: styling and export.
Eight repos under `semester/` draw their architecture with Graphviz, and every
one of them hand-writes `.dot` with the palette inlined and commits the `.svg`
beside it — `sms`, `unt`, `cht`, `mpr`, `mly`, `nvi`, `eth`, and `spr`. Three
different dark palettes between them, eight answers to the same question. Only
`spr/docs/graphs/` separates what a diagram *means* from what it *looks like*,
and it does that in `gvpr` rather than in anything reusable.
This is that separation, as a library.
```python
from docgen import Profile, emit, render
from graphgen import Graph # what a graph *is* lives next door
g = Graph("overview", title="System Overview", rankdir="LR")
g.node("api", "API", cls="station")
g.node("db", "Database", shape="cylinder")
g.edge("api", "db", "reads")
svg = render(emit(g, Profile.load("lucid")))
```
The same `g` through `Profile.load("default")` is the same diagram in the
docs-site palette. Nothing about `g` changes, because nothing about `g` was ever
about colour.
## The split
docgen how a graph is drawn. DOT, Graphviz, style profiles, and the
documents the result goes into.
graphgen what a graph is. Nodes, edges, groups, and the sources
graphs come from.
**docgen imports nothing from graphgen.** A graph is read structurally — see
[`shape.py`](shape.py), which names every attribute `emit()` touches — so either
folder can be copied out and used with the other absent. Prove it:
```bash
cp -r docgen /tmp/alone && cd /tmp/alone/docgen && make check
# 49 passed, 0 failed, 1 skipped <- the skip is the graphgen seam
```
docgen deliberately ships **no graph class**. If it did, people would use it, and
there would be two graph models — which is exactly what this separation exists to
prevent. The one place that rule is relaxed is scaffolding: `demo.py` and
`selftest.py` may import graphgen, because demonstrating and testing the drawing
needs something to draw.
It is a **library**, not a CLI — the first thing that uses it is a notebook. The
Makefile is the front door for the things that are worth running from a shell.
```bash
make check # prove it, on fixtures it builds — nothing installed, no network
make doctor # what this machine has
make graph # the example graph, every profile -> out/
make notebook # the three .ipynb variants -> out/
make extract DIR=~/diagrams
make help # every target
```
## The three concerns
```
model ──► emit ──► render
profile ← every visual value, as data
```
| | |
|---|---|
| `shape.py` | the contract: what docgen needs a graph to look like. Protocols only — nothing instantiable. |
| `profile.py` | every visual value, from JSON. Fill, stroke, penwidth, fonts, arrowsize, splines, separations. |
| `dot.py` | a graph and a profile, into DOT. The dullest module here on purpose. |
| `render.py` | DOT into SVG, via the `dot` binary. |
The model those three read — `graphgen/graph.py` — carries **meaning only**: a node
is `cls="artery"`, never `fillcolor="#c0ffee"`.
The class vocabulary is the one `spr/docs/graphs/README.md` already documents —
`accent`, `accent-text`, `ok`, `artery`, `atlas`, `station`, `muted` — so a graph
built in graphgen and a `.dot` written by hand mean the same thing by the same
word. Anything untagged renders neutral, which is where most nodes should stay.
The selftest asserts the two vocabularies still agree, because a class graphgen
can set that no profile styles renders neutral and tells nobody why.
### Three things a profile cannot touch
Structure wearing a visual field's clothing. A profile that overrode these would
be destroying meaning rather than restyling it.
| | |
|---|---|
| `shape` | a cylinder is a datastore, not a decoration |
| `style="invis"` | layout scaffolding — filling it in would draw it |
| `style="dashed"` | a weaker relationship. The profile *composes* with it (`filled,rounded,dashed`); it never replaces it |
## Profiles
`lucid` and `default` ship. `make profiles` lists them, `Profile.load(name)`
loads one, and `Profile.load("/path/to/anything.json")` loads a file — so code
that got a profile name out of a config does not have to know which it got.
`lucid` is first because the output has to import cleanly into Lucid and Google
Drawings. It is **not** the built-in one, and it is not in the code: the shipped
`lucid.json` is a plausible default derived from values already visible in
`spr/docs/graphs/themes/lucid.gvpr`, and the real one — extracted from company
diagrams — drops in as a file with no code change.
`selftest.py` asserts that. It takes every colour in every profile and greps the
package's own source for it, because the failure this design exists to prevent is
somebody reaching for a literal `"#5A6C86"` in `dot.py` on a Tuesday and the whole
arrangement quietly becoming decorative.
An unknown key in a profile is **refused, not ignored** — a setting plainly
written in a file and silently not applied is a genuinely bad thing to debug,
because the file says it is on.
## Extraction
The real style values come from real diagrams. Point `style/` at a folder of
exports:
```bash
make extract DIR=~/exported-diagrams NAME=house
```
```
fill
87 #1f2933
43 #ffffff
28 #616e7c
stroke
85 #9aa5b1
32 #3a7dff
```
Frequency-sorted, because that is what turns a heap of values into a palette: the
top two or three fills *are* the palette, and the modal stroke width *is* the
house line weight. SVG directly, PDF via `pdftocairo -svg`. Out comes a
`tokens.json` and a profile.
Two rules, and they are not stylistic preferences:
- **Text content is never read.** `<text>` elements are visited for their style
attributes and nothing else. Style values are visual metadata; the semantics of
the diagram are not needed to derive a palette, so they are not looked at. The
selftest renders a graph with a distinctive label and asserts it appears
nowhere in the token output.
- **Fully offline.** Nothing here opens a socket. Confidential source diagrams
stay off any network path, and off the Lucid API path.
Parsed with `lxml`, not grepped — `fill` appears as an attribute, inside an
inline `style`, and inside a `<style>` block, and grep sees three unrelated
strings while counting a CSS rule once no matter how many shapes it paints.
The extracted profile comes back with **`classes` empty**, on purpose. A class is
a meaning — "this is the emphasised one" — and no amount of frequency counting
recovers which colour meant that. Those are written by hand on top, which is the
half a machine genuinely cannot do.
## Output formats
`export/` is the other half, and it does not import the emit layer. A `Doc` is an
ordered list of prose and code blocks; an emitter turns one into a file.
```python
from docgen.export import Doc, write_all
doc = Doc("Walkthrough").md("## Parameters").code("BASE_URL = '...'")
write_all(doc, "out") # vanilla.ipynb, executable.ipynb, live.ipynb
```
| variant | |
|---|---|
| `vanilla` | nothing has run — outputs empty, execution count null. Reads as a document. |
| `executable` | the same cells, meant to be run by whoever opens it. |
| `live` | adds the blocks marked `live_only` — health checks and timings that only mean anything against a running service. |
vanilla and executable are one document emitted twice rather than two files,
which is the only arrangement where they cannot drift; the selftest asserts they
are still identical.
The `.ipynb` is written against the nbformat 4 schema by hand rather than with
`nbformat`, because neither `nbformat` nor `jupyter` is installed on the machines
this runs on and the schema has six required keys. Cell ids are derived from
position rather than random, so re-emitting the same `Doc` gives byte-identical
output — a notebook that changes on every build is a notebook nobody can review.
### The API notebook
`export/specs/vanilla.py` is the first one: set parameters, call, print the
result, change a parameter, call again. It is a **shape, not a description of any
particular API** — every value it cannot know carries a `# FILL:` comment.
```bash
make notebook && grep -c FILL out/vanilla.ipynb
```
Search the emitted notebook for `FILL` and that is the complete list of what has
to be replaced; nothing else in the file makes a claim about the service. An
endpoint that is nearly right is worse than one that is obviously blank — the
blank one gets filled in, the nearly-right one gets run and costs whoever opened
it an afternoon deciding whether the notebook or the service is wrong.
The client is `urllib.request` from the standard library, so the notebook opens
in Colab and runs with no `pip install` cell. A dependency is a step before the
first step, and that step fails behind a proxy. Credentials are read from the
environment and never written into a cell.
## Known friction
Carried forward from `spr/def/prompts/lucid` §5 — the honest limits of the
approach, not bugs pending a fix.
- **Corner radius is lossy.** Lucid's `rounding` is a scalar in points; DOT's
`rounded` is one fixed radius with no parameter. A profile gets a boolean.
`render.soften_corners()` rewrites the SVG path corners afterwards if you want
them rounder; it is off by default, because it rewrites geometry and geometry
is the one thing here that is not styling.
- **`splines=ortho` is mediocre.** It overlaps edges and ignores some port
constraints. It also **drops edge labels entirely** — Graphviz warns and draws
the edge bare — so `dot.py` emits `xlabel` instead of `label` whenever the
profile asks for ortho. Slightly worse placement, but the label is there. A
style choice must not be able to delete content.
- **Layout is the real gap.** Lucid output is hand-arranged and `dot` is
rank-based. The styling will match long before the composition does, and no
profile key closes that; `rank=same` and invisible edges are the escape hatch,
and they belong to the model.
- **Fonts.** `lucid.json` pins Arial deliberately: it is on every Windows box and
fontconfig aliases it to Liberation Sans on Linux, so the SVG measures the same
on both and the text does not reflow out of its box.
## Standalone
`python3` and nothing else, for everything but two optional pieces: rendering
needs the `graphviz` binary, extraction needs `lxml`. `make doctor` says which
of those this machine has. The folder can be copied anywhere and renamed — the
Makefile derives the package name from the directory — because a notebook that
needs a diagram is, by definition, not always somewhere with soleprint on its
path.
## Not in scope
The existing docs in the eight repos are **not touched**. Nothing here edits a
`.dot` or regenerates an `.svg` anyone has committed; `graphgen/examples.py` rebuilds
soleprint's system overview from scratch so the profiles can be judged against
`spr/docs/graphs/system_overview.lucid.svg` by eye, and that file is read, never
written.
`graphgen` owns the graph model and the graph sources, including the live
DB-schema explorer at `/station/tools/graphgen/`. Its `{models, relationships,
source}` is a published contract that this tool does not touch.

View File

@@ -1,60 +0,0 @@
"""
Docgen — the interface to Graphviz and DOT: styling and export.
Every demo under `semester/` hand-writes its own `.dot` with the palette inlined
and commits the `.svg` beside it. Eight repos, three different dark palettes,
eight answers to the same question. This is the one answer.
from docgen import Profile, emit, render
from graphgen import Graph # the graph itself lives there
svg = render(emit(my_graph, Profile.load("lucid")))
## What is here, and what is next door
docgen how a graph is drawn. DOT, Graphviz, style profiles, and the
documents the result goes into.
graphgen what a graph is. Nodes, edges, groups, and the sources
graphs come from.
**docgen imports nothing from graphgen.** A graph is read structurally — see
`shape.py`, which names every attribute `emit()` touches — so either folder can
be copied out and used with the other absent. graphgen owns the only concrete
graph class; docgen deliberately ships none, because two graph models is the
thing this separation exists to prevent.
## The three parts
profile.py every visual value, as data. Fill, stroke, penwidth, fonts,
arrowsize, splines, separations. Lucid is the first profile,
not a built-in one, and not in the code.
dot.py a graph and a profile -> DOT text. The dullest module here.
render.py DOT -> SVG, via the graphviz binary.
style/ a folder of exported diagrams -> a style profile. Offline,
never reads text content. Feeds profile.py, uses nothing else.
export/ where a document ends up — a notebook today. Knows nothing
about graphs; the two are separate concerns and separate
subpackages.
## Standalone
Stdlib only, with two optional pieces: rendering needs the `graphviz` binary and
extraction needs `lxml`. `make doctor` says which this machine has. The folder
can be copied anywhere and renamed — the Makefile derives the package name from
the directory.
"""
from .dot import emit
from .profile import Profile, ProfileError
from .render import RenderError, have_graphviz, render, soften_corners
from .shape import EdgeLike, GraphLike, GroupLike, NodeLike, missing
__version__ = "0.1.0"
__all__ = [
"Profile", "ProfileError",
"emit", "render", "soften_corners", "have_graphviz", "RenderError",
"GraphLike", "NodeLike", "EdgeLike", "GroupLike", "missing",
"style", "export",
]

View File

@@ -1,40 +0,0 @@
"""
Render graphgen's example graphs. Scaffolding, not library.
**This is the one module in docgen that imports graphgen**, and it is not part
of the library — it is what `make graph` runs. The ban on importing graphgen
applies to the code that does the work (`dot.py`, `render.py`, `profile.py`);
demonstrating that work needs a graph, and graphgen owns the only real one.
`selftest.py` takes the same liberty for the same reason, and both degrade to a
clear message when graphgen is not next door rather than an ImportError.
"""
from pathlib import Path
from . import Profile, emit, render
def render_all(out_dir="out", profiles=None, graph=None) -> list[Path]:
"""Every profile, into out_dir. One model, one look per profile, no edits."""
if graph is None:
try:
from graphgen.examples import system_overview
except ImportError:
raise SystemExit(
"Error: needs graphgen beside this folder — it owns the graph model.\n"
" docgen renders graphs; it does not define them. See shape.py."
) from None
graph = system_overview()
out_dir = Path(out_dir)
out_dir.mkdir(parents=True, exist_ok=True)
written = []
for name in profiles or Profile.available():
dot_text = emit(graph, Profile.load(name))
(out_dir / f"system_overview.{name}.dot").write_text(dot_text)
svg = out_dir / f"system_overview.{name}.svg"
svg.write_bytes(render(dot_text))
written.append(svg)
print(f" {name:10} {svg}")
return written

View File

@@ -1,232 +0,0 @@
"""
Model plus profile, out comes DOT.
This is the only module that knows both, and it is deliberately the dullest one
in the package: resolve a class to a few attributes, write them out. If anything
here starts making a decision about how a thing should look, that decision
belongs in a profile instead.
The preamble follows `spr/def/prompts/lucid` §4, whose impact ranking is worth
keeping in view when a profile is being tuned:
splines=ortho -> pale fill against a mid-slate stroke -> slate body text
-> small arrowheads -> generous nodesep/ranksep
with the note that the separation values do more perceptual work than any hex
code. Lucid diagrams read airy because they are hand-placed with dead space
everywhere; `dot` packs tight by default, so nodesep/ranksep is what closes most
of the gap.
## The three the profile cannot touch
`shape`, `style=invis` and `style=dashed` are structure, not styling — see
`shape.py`, and `graphgen/graph.py` for the model that carries them. Here that
means: a node's own shape wins over the profile's, an
invisible node is emitted with nothing but `style=invis`, and `dashed` composes
into the style list rather than replacing it.
"""
from .profile import Profile
from .shape import GraphLike, missing
def _attrs(pairs: dict) -> str:
"""`{"a": "b"}` -> `[a="b"]`, or "" when there is nothing to say."""
if not pairs:
return ""
inner = " ".join(f'{k}="{v}"' for k, v in pairs.items() if v not in (None, ""))
return f" [{inner}]" if inner else ""
def _style_list(base: list[str], own: str | None) -> str:
"""Compose the profile's style words with the model's own.
The model's win by being kept: `dashed` from the source and `filled,rounded`
from the profile produce `filled,rounded,dashed`, which is the composition
the DOT docs describe and the one `docs/graphs/themes/lucid.gvpr` already
does by hand.
"""
words = list(base)
for w in (own or "").split(","):
w = w.strip()
if w and w not in words:
words.append(w)
return ",".join(words)
def _node_attrs(node, profile: Profile) -> dict:
# Layout scaffolding. Anything else written here would draw it.
if node.style and "invis" in node.style:
return {"style": "invis", "label": ""}
node_style = profile.section("node")
overrides = profile.cls(node.cls)
fill = overrides.get("fill", node_style.get("fill"))
stroke = overrides.get("stroke", node_style.get("stroke"))
fontcolor = overrides.get("fontcolor", node_style.get("fontcolor"))
shape = node.shape or node_style.get("shape", "box")
base = ["filled"]
# `record` ignores rounding and `plaintext` has no box to round — asking for
# it produces a warning and no difference. Same carve-out as lucid.gvpr.
if node_style.get("rounded") and shape not in ("record", "Mrecord", "plaintext"):
base.append("rounded")
return {
"label": node.label,
"shape": shape,
"style": _style_list(base, node.style),
"fillcolor": fill,
"color": stroke,
"fontcolor": fontcolor,
"penwidth": node_style.get("penwidth"),
"fontname": node_style.get("fontname"),
"fontsize": node_style.get("fontsize"),
"margin": node_style.get("margin"),
"height": node_style.get("height"),
}
def _edge_attrs(edge, profile: Profile, ortho: bool = False) -> dict:
if edge.style and "invis" in edge.style:
return {"style": "invis"}
edge_style = profile.section("edge")
overrides = profile.cls(edge.cls)
# Graphviz: "Orthogonal edges do not currently handle edge labels. Try using
# xlabels." It warns and then draws the edge with no label at all — a silent
# loss of content caused purely by a style choice, which is exactly the thing
# a profile must not be able to do. `xlabel` places the label beside the edge
# instead of along it; slightly worse placement, but the label is there.
label_key = "xlabel" if (ortho and edge.label) else "label"
attrs = {
label_key: edge.label,
"color": overrides.get("stroke", edge_style.get("stroke")),
# An edge's label takes the class colour where the class names one, so a
# classed edge and its label read as one thing.
"fontcolor": overrides.get("fontcolor")
or overrides.get("stroke")
or edge_style.get("fontcolor"),
"penwidth": edge_style.get("penwidth"),
"arrowhead": edge.arrowhead or edge_style.get("arrowhead"),
"arrowsize": edge_style.get("arrowsize"),
"fontname": edge_style.get("fontname"),
"fontsize": edge_style.get("fontsize"),
}
if edge.style:
attrs["style"] = _style_list([], edge.style)
return attrs
def emit(graph: GraphLike, profile: Profile) -> str:
"""The DOT text. Raises on a graph that will not draw what it says.
`graph` is anything matching `shape.GraphLike` — docgen does not import
graphgen, so this is checked structurally rather than by type.
"""
absent = missing(graph, "graph")
if absent:
raise TypeError(
f"{type(graph).__name__} is not a graph docgen can draw — "
f"missing {', '.join(absent)}.\n"
"See docgen/shape.py for the attributes emit() reads."
)
problems = graph.validate()
if problems:
raise ValueError(f"graph {graph.name!r} will not emit:\n " + "\n ".join(problems))
g = profile.section("graph")
node_d = profile.section("node")
edge_d = profile.section("edge")
group_d = profile.section("group")
out = [f"digraph {graph.name} {{"]
# -- graph level ------------------------------------------------------
out.append(f' bgcolor="{g.get("bgcolor", "transparent")}"')
# rankdir is content: which way the thing reads. The model owns it and the
# profile only supplies a fallback for a graph that did not say.
out.append(f' rankdir={graph.rankdir or g.get("rankdir", "TB")}')
for key in ("splines", "nodesep", "ranksep", "pad", "fontname"):
if g.get(key) is not None:
out.append(f' {key}="{g[key]}"')
if graph.title:
out.append(f' label="{graph.title}"')
out.append(" labelloc=t")
out.append(f' fontsize="{g.get("fontsize", 14)}"')
out.append(f' fontcolor="{g.get("fontcolor", "#000000")}"')
out.append(" compound=true")
out.append("")
# -- defaults ---------------------------------------------------------
# Emitted as well as per-object, so hand-edited DOT downstream and anything
# this does not reach still lands in the profile's look.
out.append(
" node"
+ _attrs(
{
"shape": node_d.get("shape", "box"),
"style": "filled,rounded" if node_d.get("rounded") else "filled",
"fillcolor": node_d.get("fill"),
"color": node_d.get("stroke"),
"penwidth": node_d.get("penwidth"),
"fontname": node_d.get("fontname"),
"fontsize": node_d.get("fontsize"),
"fontcolor": node_d.get("fontcolor"),
"margin": node_d.get("margin"),
"height": node_d.get("height"),
}
)
)
out.append(
" edge"
+ _attrs(
{
"color": edge_d.get("stroke"),
"penwidth": edge_d.get("penwidth"),
"arrowhead": edge_d.get("arrowhead"),
"arrowsize": edge_d.get("arrowsize"),
"fontname": edge_d.get("fontname"),
"fontsize": edge_d.get("fontsize"),
"fontcolor": edge_d.get("fontcolor"),
}
)
)
out.append("")
by_id = {n.id: n for n in graph.nodes}
# -- groups -----------------------------------------------------------
for grp in graph.groups:
overrides = profile.cls(grp.cls)
base = ["rounded"] if group_d.get("rounded") else []
out.append(f" subgraph cluster_{grp.id} {{")
out.append(f' label="{grp.label}"')
out.append(f' style="{_style_list(base, grp.style)}"')
out.append(f' color="{overrides.get("stroke", group_d.get("stroke"))}"')
out.append(f' bgcolor="{overrides.get("fill", group_d.get("fill"))}"')
out.append(f' fontcolor="{overrides.get("fontcolor", group_d.get("fontcolor"))}"')
if g.get("fontname"):
out.append(f' fontname="{g["fontname"]}"')
for nid in grp.nodes:
out.append(f" {nid}{_attrs(_node_attrs(by_id[nid], profile))}")
out.append(" }")
out.append("")
# -- loose nodes ------------------------------------------------------
grouped = graph.grouped()
for n in graph.nodes:
if n.id not in grouped:
out.append(f" {n.id}{_attrs(_node_attrs(n, profile))}")
out.append("")
# -- edges ------------------------------------------------------------
ortho = g.get("splines") == "ortho"
for e in graph.edges:
out.append(f" {e.src} -> {e.dst}{_attrs(_edge_attrs(e, profile, ortho))}")
out.append("}")
return "\n".join(out) + "\n"

View File

@@ -1,6 +0,0 @@
"""Output formats. A Doc goes in, a file comes out."""
from .doc import Block, Doc
from .notebook import VARIANTS, build, write, write_all
__all__ = ["Doc", "Block", "VARIANTS", "build", "write", "write_all"]

View File

@@ -1,71 +0,0 @@
"""
A document, before it is any particular kind of file.
Three block types and nothing else. A `Doc` does not know what a notebook is,
does not know what HTML is, and above all does not know what a graph is — the
emitters know that, and there is one per output format.
doc = Doc(title="Calling the API")
doc.md("## Parameters")
doc.code("BASE_URL = 'https://example.invalid'")
doc.md("Run the cell above, then:")
doc.code("print(call('GET', '/health'))", live_only=True)
The reason the model is this thin: the same content has to come out as three
notebook variants that differ only in what executes, and as HTML later. Any
structure richer than "ordered blocks, each either prose or code" starts
encoding one format's assumptions into the shared layer, and then the variants
drift apart because each is really its own document.
`live_only` is the one concession. A block marked with it is dropped from the
vanilla and executable variants and kept in the live one — health checks,
timings, the things that are only meaningful against a running service.
"""
from dataclasses import dataclass, field
@dataclass
class Block:
"""One cell's worth of content. `kind` is 'md' or 'code'."""
kind: str
text: str
live_only: bool = False
def lines(self) -> list[str]:
"""Source split the way notebook JSON wants it: newline kept, last line bare.
Not `text.splitlines()` — that drops the newlines, and a notebook whose
source lines have no `\\n` renders every cell as a single run-on line.
"""
parts = self.text.split("\n")
return [p + "\n" for p in parts[:-1]] + ([parts[-1]] if parts[-1] else [])
@dataclass
class Doc:
"""An ordered list of blocks, plus a title."""
title: str = ""
blocks: list[Block] = field(default_factory=list)
def md(self, text: str, live_only: bool = False) -> "Doc":
self.blocks.append(Block("md", text.strip("\n"), live_only))
return self
def code(self, text: str, live_only: bool = False) -> "Doc":
self.blocks.append(Block("code", text.strip("\n"), live_only))
return self
def for_variant(self, variant: str) -> list[Block]:
"""The blocks that belong in one variant.
vanilla / executable carry the same blocks — they differ in whether the
emitter marks the code as having run, not in what is written. Keeping
them one document is what stops the two from drifting into separate
hand-maintained files, which is how this usually goes wrong.
"""
if variant == "live":
return list(self.blocks)
return [b for b in self.blocks if not b.live_only]

View File

@@ -1,102 +0,0 @@
"""
A Doc, as a .ipynb file.
Written by hand against the nbformat 4 schema rather than with `nbformat`,
because neither `nbformat` nor `jupyter` is installed on the machines this runs
on, and pulling in a dependency to produce a JSON file with six required keys is
not a trade worth making. It also keeps the rule the rest of these tools follow:
the folder can be copied out and used with nothing but python3.
The schema, in full, is smaller than the docstring explaining it:
{"cells": [...], "metadata": {...}, "nbformat": 4, "nbformat_minor": 5}
markdown cell: {"cell_type", "id", "metadata", "source"}
code cell: {"cell_type", "id", "metadata", "source",
"execution_count": null, "outputs": []}
`source` is a **list of lines with the newlines kept**, not one string. Both
load, but Jupyter's own writer emits the list and diffing two notebooks written
the other way is unreadable.
`id` is required at nbformat_minor >= 5 and must be unique within the file.
They are derived from the cell's position, not randomly, so re-emitting the same
Doc produces a byte-identical file — a notebook that changes on every build is a
notebook nobody can review.
## The three variants
vanilla the deliverable. Nothing has run: outputs empty, execution
count null. Reads as a document.
executable the same cells, meant to be run by whoever opens it.
live adds the blocks marked live_only — health checks and timings
that only mean anything against a running service.
vanilla and executable are the same content emitted twice rather than two files,
which is the only arrangement where they cannot drift.
"""
import json
from pathlib import Path
from .doc import Doc
VARIANTS = ("vanilla", "executable", "live")
# Python 3, no version pinned: the notebook has to open on Colab and on whatever
# kernel the recipient has, and naming a version it does not have is a dialog box
# before they read a word.
METADATA = {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3",
},
"language_info": {"name": "python"},
}
def _cell(index: int, kind: str, source: list[str]) -> dict:
cell = {
"cell_type": "markdown" if kind == "md" else "code",
"id": f"cell-{index:03d}",
"metadata": {},
"source": source,
}
if kind == "code":
# Both keys are required on a code cell and both say the same thing:
# this has not been run. That is what "vanilla" means.
cell["execution_count"] = None
cell["outputs"] = []
return cell
def build(doc: Doc, variant: str = "vanilla") -> dict:
"""The notebook as a dict, so a caller can assert on it without a file."""
if variant not in VARIANTS:
raise ValueError(f"unknown variant {variant!r} — one of {', '.join(VARIANTS)}")
blocks = doc.for_variant(variant)
return {
"cells": [_cell(i, b.kind, b.lines()) for i, b in enumerate(blocks)],
"metadata": METADATA,
"nbformat": 4,
"nbformat_minor": 5,
}
def write(doc: Doc, path: Path, variant: str = "vanilla") -> Path:
"""Write one variant. Returns the path, so a loop can report what it made."""
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
# sort_keys for the same reason the ids are derived: two builds of one Doc
# should be the same bytes.
path.write_text(
json.dumps(build(doc, variant), indent=1, sort_keys=True, ensure_ascii=False) + "\n"
)
return path
def write_all(doc: Doc, out_dir: Path) -> list[Path]:
"""Every variant into one directory, named after itself."""
out_dir = Path(out_dir)
return [write(doc, out_dir / f"{v}.ipynb", v) for v in VARIANTS]

View File

@@ -1 +0,0 @@
"""Notebook contents. One module per notebook."""

View File

@@ -1,292 +0,0 @@
"""
The API notebook: set parameters, call, print the result, change one parameter,
call again.
This is a **shape, not a description of any particular API**. The service it
drives is confidential and is not readable from here, so every place that needs
a real value carries a `# FILL:` comment instead of a guess. Search the emitted
notebook for `FILL` and that is the complete list of what has to be replaced —
nothing else in the file makes a claim about the API.
Why placeholders rather than an approximation: an endpoint that is nearly right
is worse than one that is obviously blank. The blank one gets filled in; the
nearly-right one gets run, fails somewhere in the middle, and costs whoever
opened it an afternoon deciding whether the notebook or the service is wrong.
The client is `urllib.request` from the standard library rather than `httpx` or
`requests`. A notebook that opens in Colab and runs without a `pip install` cell
is a notebook that runs; one that needs a dependency has a step before the first
step, and that step fails behind a proxy.
"""
from ..doc import Doc
# One place for the placeholder names, so the emitted notebook and the FILL list
# cannot disagree about what they are called.
BASE_URL = "https://api.example.invalid"
ENV_VAR = "API_TOKEN"
def build() -> Doc:
doc = Doc(title="API walkthrough")
doc.md(
f"""
# API walkthrough
Set the parameters, make a call, read the result, change a parameter, call
again. That is the whole notebook.
**Before running anything**, replace every `FILL` in the cells below. There are
no other values to change — anything not marked `FILL` is either standard
library or scaffolding.
| | |
|---|---|
| base URL | the `BASE_URL` cell |
| credentials | read from the `{ENV_VAR}` environment variable, never written here |
| endpoints | one section each, `FILL` on the path and the body |
Nothing is installed. The client below is `urllib.request` from the standard
library, so this runs on a bare Python 3 kernel and on Colab as-is.
**Credentials do not go in this file.** Set the environment variable before
starting the kernel, or use your platform's secret store. A token pasted into a
cell is a token in every copy of the notebook from then on, including the ones
in someone's Downloads folder.
"""
)
# --- Parameters ---------------------------------------------------------
doc.md(
"""
## 1. Parameters
Everything the calls depend on, in one cell, so changing where this points is
one edit in one place rather than a search through the notebook.
"""
)
doc.code(
f'''
import json
import os
import time
import urllib.error
import urllib.parse
import urllib.request
# FILL: the base URL of the service, no trailing slash
BASE_URL = "{BASE_URL}"
# FILL: confirm the variable name your deployment uses.
# Read from the environment on purpose — a token written into a cell travels
# with every copy of this notebook.
TOKEN = os.environ.get("{ENV_VAR}", "")
# FILL: the header the service expects. Bearer is the common case; some services
# want "X-Api-Key" or a query parameter instead.
AUTH_HEADER = {{"Authorization": f"Bearer {{TOKEN}}"}} if TOKEN else {{}}
TIMEOUT = 30 # seconds; raise it if the service is slow to warm up
VERBOSE = True # print the request line before each call
print(f"base {{BASE_URL}}")
print(f"token {{'set — ' + str(len(TOKEN)) + ' chars' if TOKEN else 'NOT SET — export {ENV_VAR}=... and restart the kernel'}}")
print(f"timeout {{TIMEOUT}}s")
'''
)
# --- Client -------------------------------------------------------------
doc.md(
"""
## 2. The client
One function for every call in the notebook. It returns the status, the headers
and the parsed body rather than raising on a non-2xx, because a 401 or a 422 is
a result worth reading — the body usually says what was wrong with the request,
and an exception throws that away.
"""
)
doc.code(
'''
def call(method, path, params=None, body=None, headers=None, timeout=None):
"""Make one request. Returns (status, headers, parsed_body).
Non-2xx is returned, not raised: the error body is the useful part.
"""
url = BASE_URL.rstrip("/") + "/" + path.lstrip("/")
if params:
url += "?" + urllib.parse.urlencode(params)
data = None
hdrs = {"Accept": "application/json", **AUTH_HEADER, **(headers or {})}
if body is not None:
data = json.dumps(body).encode()
hdrs["Content-Type"] = "application/json"
if VERBOSE:
print(f"-> {method} {url}")
req = urllib.request.Request(url, data=data, headers=hdrs, method=method)
started = time.time()
try:
with urllib.request.urlopen(req, timeout=timeout or TIMEOUT) as resp:
status, raw, got = resp.status, resp.read(), dict(resp.headers)
except urllib.error.HTTPError as e:
# An HTTPError *is* the response. Read it rather than re-raising.
status, raw, got = e.code, e.read(), dict(e.headers)
except urllib.error.URLError as e:
print(f"<- could not reach {url}: {e.reason}")
return None, {}, None
elapsed = time.time() - started
try:
parsed = json.loads(raw) if raw else None
except json.JSONDecodeError:
parsed = raw.decode("utf-8", "replace")
if VERBOSE:
print(f"<- {status} in {elapsed:.2f}s, {len(raw)} bytes")
return status, got, parsed
def show(result, limit=2000):
"""Print a result readably, and say so when it has been cut short."""
status, _, body = result
if status is None:
print("no response")
return
text = json.dumps(body, indent=2, ensure_ascii=False) if not isinstance(body, str) else body
print(f"status {status}")
print(text[:limit])
if len(text) > limit:
print(f"... {len(text) - limit} more characters")
'''
)
# --- Health -------------------------------------------------------------
doc.md(
"""
## 3. Is it up?
The cheapest call the service has. Run this first — every failure below is
easier to read once you know whether the problem is the request or the network.
"""
)
doc.code(
'''
# FILL: the service's health or version path
HEALTH_PATH = "/health"
show(call("GET", HEALTH_PATH))
'''
)
doc.code(
'''
# Reachability and latency, before anything that costs money.
for attempt in range(3):
started = time.time()
status, _, _ = call("GET", HEALTH_PATH)
print(f" attempt {attempt + 1}: {status} in {time.time() - started:.2f}s")
''',
live_only=True,
)
# --- First endpoint -----------------------------------------------------
doc.md(
"""
## 4. First call
The parameters live in their own cell above the call. That is the point of the
notebook: change the cell, re-run the two below it, compare.
"""
)
doc.code(
'''
# FILL: the path for this endpoint
ENDPOINT = "/resource"
# FILL: the query parameters it takes, with values that return something small
QUERY = {
"limit": 10,
}
'''
)
doc.code('result = call("GET", ENDPOINT, params=QUERY)\nshow(result)')
# --- Second endpoint ----------------------------------------------------
doc.md(
"""
## 5. A call with a body
Same shape, POST instead of GET. Keep the request body in its own cell for the
same reason as the query above.
"""
)
doc.code(
'''
# FILL: the path for the endpoint that takes a body
POST_ENDPOINT = "/resource"
# FILL: the request body. Keep it minimal — the smallest thing that returns a
# valid response, so a failure is about the endpoint and not about the payload.
PAYLOAD = {
"example": "value",
}
'''
)
doc.code('created = call("POST", POST_ENDPOINT, body=PAYLOAD)\nshow(created)')
# --- Update params and call again ---------------------------------------
doc.md(
"""
## 6. Change a parameter, call again
The comparison is the reason to do this in a notebook rather than with `curl`:
both results are still in memory, so the difference is one cell rather than two
terminal scrollbacks.
"""
)
doc.code(
'''
# FILL: the parameter worth varying, and a second value for it
QUERY = {**QUERY, "limit": 50}
second = call("GET", ENDPOINT, params=QUERY)
show(second)
'''
)
doc.code(
'''
first_status, _, first_body = result
second_status, _, second_body = second
print(f"first {first_status} {type(first_body).__name__}")
print(f"second {second_status} {type(second_body).__name__}")
# FILL: whatever "how much came back" means for this endpoint — a list length,
# a `total` field, a row count.
for name, payload in (("first", first_body), ("second", second_body)):
size = len(payload) if isinstance(payload, (list, dict, str)) else "n/a"
print(f"{name:8} size {size}")
'''
)
# --- Closing ------------------------------------------------------------
doc.md(
"""
## Notes
- Every `FILL` above is a value this notebook could not know. Nothing else needs
editing.
- `call` returns non-2xx rather than raising, so read the body on a failure —
it usually names the field that was wrong.
- If `URLError` comes back instead of a status, nothing reached the service:
check `BASE_URL`, then the network path, before changing anything about the
request.
- The token is read from the environment. If you change it, restart the kernel —
`os.environ` is read once, when the parameters cell runs.
"""
)
return doc

View File

@@ -0,0 +1,107 @@
digraph system_overview {
bgcolor="#0a0e17"
rankdir=TB
splines="spline"
nodesep="0.45"
ranksep="0.6"
pad="0.3"
fontname="Helvetica"
label="Soleprint — System Overview"
labelloc=t
fontsize="14"
fontcolor="#e8eaf0"
compound=true
node [shape="box" style="filled,rounded" fillcolor="#131a2a" color="#1e2a4a" penwidth="1" fontname="Helvetica" fontsize="10" fontcolor="#e8eaf0" margin="0.22,0.12" height="0.45"]
edge [color="#4a5568" penwidth="1" arrowhead="normal" arrowsize="0.7" fontname="Helvetica" fontsize="9" fontcolor="#8892a8"]
subgraph cluster_core {
label="Soleprint Hub"
style="rounded,dashed"
color="#0066ff"
bgcolor="#0d1320"
fontcolor="#e8eaf0"
fontname="Helvetica"
hub [label="soleprint
core coordinator
port 12000" shape="box" style="filled,rounded" fillcolor="#131a2a" color="#0066ff" fontcolor="#e8eaf0" penwidth="1" fontname="Helvetica" fontsize="10" margin="0.22,0.12" height="0.45"]
}
subgraph cluster_artery {
label="Artery — Todo lo vital"
style="rounded,dashed"
color="#e05c4a"
bgcolor="#0d1320"
fontcolor="#e8eaf0"
fontname="Helvetica"
veins [label="Veins
stateless connectors" shape="box" style="filled,rounded" fillcolor="#131a2a" color="#e05c4a" fontcolor="#e8eaf0" penwidth="1" fontname="Helvetica" fontsize="10" margin="0.22,0.12" height="0.45"]
shunts [label="Shunts
mock connectors" shape="box" style="filled,rounded" fillcolor="#131a2a" color="#e05c4a" fontcolor="#e8eaf0" penwidth="1" fontname="Helvetica" fontsize="10" margin="0.22,0.12" height="0.45"]
pulses [label="Pulses
composed flows" shape="box" style="filled,rounded" fillcolor="#131a2a" color="#e05c4a" fontcolor="#e8eaf0" penwidth="1" fontname="Helvetica" fontsize="10" margin="0.22,0.12" height="0.45"]
}
subgraph cluster_atlas {
label="Atlas — Documentación accionable"
style="rounded,dashed"
color="#2fbf6b"
bgcolor="#0d1320"
fontcolor="#e8eaf0"
fontname="Helvetica"
books [label="Books
documentation" shape="box" style="filled,rounded" fillcolor="#131a2a" color="#2fbf6b" fontcolor="#e8eaf0" penwidth="1" fontname="Helvetica" fontsize="10" margin="0.22,0.12" height="0.45"]
templates [label="Templates
patterns" shape="box" style="filled,rounded" fillcolor="#131a2a" color="#2fbf6b" fontcolor="#e8eaf0" penwidth="1" fontname="Helvetica" fontsize="10" margin="0.22,0.12" height="0.45"]
}
subgraph cluster_station {
label="Station — Centro de control"
style="rounded,dashed"
color="#5b8cff"
bgcolor="#0d1320"
fontcolor="#e8eaf0"
fontname="Helvetica"
tools [label="Tools
tester · datagen · modelgen" shape="box" style="filled,rounded" fillcolor="#131a2a" color="#5b8cff" fontcolor="#e8eaf0" penwidth="1" fontname="Helvetica" fontsize="10" margin="0.22,0.12" height="0.45"]
monitors [label="Monitors
databrowse" shape="box" style="filled,rounded" fillcolor="#131a2a" color="#5b8cff" fontcolor="#e8eaf0" penwidth="1" fontname="Helvetica" fontsize="10" margin="0.22,0.12" height="0.45"]
}
subgraph cluster_external {
label="External APIs"
style="rounded,dashed"
color="#1e2a4a"
bgcolor="#0d1320"
fontcolor="#8892a8"
fontname="Helvetica"
jira [label="Jira" shape="box" style="filled,rounded" fillcolor="#131a2a" color="#1e2a4a" fontcolor="#e8eaf0" penwidth="1" fontname="Helvetica" fontsize="10" margin="0.22,0.12" height="0.45"]
google [label="Google" shape="box" style="filled,rounded" fillcolor="#131a2a" color="#1e2a4a" fontcolor="#e8eaf0" penwidth="1" fontname="Helvetica" fontsize="10" margin="0.22,0.12" height="0.45"]
slack [label="Slack" shape="box" style="filled,rounded" fillcolor="#131a2a" color="#1e2a4a" fontcolor="#e8eaf0" penwidth="1" fontname="Helvetica" fontsize="10" margin="0.22,0.12" height="0.45"]
}
subgraph cluster_managed {
label="Managed App"
style="rounded,dashed"
color="#1e2a4a"
bgcolor="#0d1320"
fontcolor="#8892a8"
fontname="Helvetica"
app_fe [label="Frontend" shape="box" style="filled,rounded" fillcolor="#131a2a" color="#1e2a4a" fontcolor="#e8eaf0" penwidth="1" fontname="Helvetica" fontsize="10" margin="0.22,0.12" height="0.45"]
app_be [label="Backend" shape="box" style="filled,rounded" fillcolor="#131a2a" color="#1e2a4a" fontcolor="#e8eaf0" penwidth="1" fontname="Helvetica" fontsize="10" margin="0.22,0.12" height="0.45"]
app_db [label="Database" shape="cylinder" style="filled,rounded" fillcolor="#131a2a" color="#1e2a4a" fontcolor="#e8eaf0" penwidth="1" fontname="Helvetica" fontsize="10" margin="0.22,0.12" height="0.45"]
}
hub -> veins [label="routes" color="#e05c4a" fontcolor="#e8eaf0" penwidth="1" arrowhead="normal" arrowsize="0.7" fontname="Helvetica" fontsize="9"]
hub -> books [label="routes" color="#2fbf6b" fontcolor="#e8eaf0" penwidth="1" arrowhead="normal" arrowsize="0.7" fontname="Helvetica" fontsize="9"]
hub -> tools [label="routes" color="#5b8cff" fontcolor="#e8eaf0" penwidth="1" arrowhead="normal" arrowsize="0.7" fontname="Helvetica" fontsize="9"]
veins -> jira [label="API" color="#4a5568" fontcolor="#8892a8" penwidth="1" arrowhead="normal" arrowsize="0.7" fontname="Helvetica" fontsize="9"]
veins -> google [label="OAuth" color="#4a5568" fontcolor="#8892a8" penwidth="1" arrowhead="normal" arrowsize="0.7" fontname="Helvetica" fontsize="9"]
veins -> slack [label="API" color="#4a5568" fontcolor="#8892a8" penwidth="1" arrowhead="normal" arrowsize="0.7" fontname="Helvetica" fontsize="9"]
veins -> pulses [label="compose" color="#4a5568" fontcolor="#8892a8" penwidth="1" arrowhead="normal" arrowsize="0.7" fontname="Helvetica" fontsize="9"]
tools -> app_be [label="test" color="#4a5568" fontcolor="#8892a8" penwidth="1" arrowhead="normal" arrowsize="0.7" fontname="Helvetica" fontsize="9" style="dashed"]
monitors -> app_db [label="browse" color="#4a5568" fontcolor="#8892a8" penwidth="1" arrowhead="normal" arrowsize="0.7" fontname="Helvetica" fontsize="9" style="dashed"]
hub -> app_fe [label="sidebar
injection" color="#0066ff" fontcolor="#e8eaf0" penwidth="1" arrowhead="normal" arrowsize="0.7" fontname="Helvetica" fontsize="9" style="dashed"]
}

View File

@@ -0,0 +1,209 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Generated by graphviz version 14.1.2 (0)
-->
<!-- Title: system_overview Pages: 1 -->
<svg width="1033pt" height="415pt"
viewBox="0.00 0.00 1033.00 415.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(21.6 393.45)">
<title>system_overview</title>
<polygon fill="#0a0e17" stroke="none" points="-21.6,21.6 -21.6,-393.45 1011.6,-393.45 1011.6,21.6 -21.6,21.6"/>
<text xml:space="preserve" text-anchor="middle" x="495" y="-354.55" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#e8eaf0">Soleprint — System Overview</text>
<g id="clust1" class="cluster">
<title>cluster_core</title>
<path fill="#0d1320" stroke="#0066ff" stroke-dasharray="5,2" d="M449,-241.82C449,-241.82 553,-241.82 553,-241.82 559,-241.82 565,-247.82 565,-253.82 565,-253.82 565,-326.6 565,-326.6 565,-332.6 559,-338.6 553,-338.6 553,-338.6 449,-338.6 449,-338.6 443,-338.6 437,-332.6 437,-326.6 437,-326.6 437,-253.82 437,-253.82 437,-247.82 443,-241.82 449,-241.82"/>
<text xml:space="preserve" text-anchor="middle" x="501" y="-321.3" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#e8eaf0">Soleprint Hub</text>
</g>
<g id="clust2" class="cluster">
<title>cluster_artery</title>
<path fill="#0d1320" stroke="#e05c4a" stroke-dasharray="5,2" d="M20,-8C20,-8 291,-8 291,-8 297,-8 303,-14 303,-20 303,-20 303,-196.57 303,-196.57 303,-202.57 297,-208.57 291,-208.57 291,-208.57 20,-208.57 20,-208.57 14,-208.57 8,-202.57 8,-196.57 8,-196.57 8,-20 8,-20 8,-14 14,-8 20,-8"/>
<text xml:space="preserve" text-anchor="middle" x="155.5" y="-191.27" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#e8eaf0">Artery — Todo lo vital</text>
</g>
<g id="clust3" class="cluster">
<title>cluster_atlas</title>
<path fill="#0d1320" stroke="#2fbf6b" stroke-dasharray="5,2" d="M323,-124.54C323,-124.54 558,-124.54 558,-124.54 564,-124.54 570,-130.54 570,-136.54 570,-136.54 570,-196.57 570,-196.57 570,-202.57 564,-208.57 558,-208.57 558,-208.57 323,-208.57 323,-208.57 317,-208.57 311,-202.57 311,-196.57 311,-196.57 311,-136.54 311,-136.54 311,-130.54 317,-124.54 323,-124.54"/>
<text xml:space="preserve" text-anchor="middle" x="440.5" y="-191.27" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#e8eaf0">Atlas — Documentación accionable</text>
</g>
<g id="clust4" class="cluster">
<title>cluster_station</title>
<path fill="#0d1320" stroke="#5b8cff" stroke-dasharray="5,2" d="M590,-124.54C590,-124.54 869,-124.54 869,-124.54 875,-124.54 881,-130.54 881,-136.54 881,-136.54 881,-196.57 881,-196.57 881,-202.57 875,-208.57 869,-208.57 869,-208.57 590,-208.57 590,-208.57 584,-208.57 578,-202.57 578,-196.57 578,-196.57 578,-136.54 578,-136.54 578,-130.54 584,-124.54 590,-124.54"/>
<text xml:space="preserve" text-anchor="middle" x="729.5" y="-191.27" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#e8eaf0">Station — Centro de control</text>
</g>
<g id="clust5" class="cluster">
<title>cluster_external</title>
<path fill="#0d1320" stroke="#1e2a4a" stroke-dasharray="5,2" d="M323,-13.19C323,-13.19 557,-13.19 557,-13.19 563,-13.19 569,-19.19 569,-25.19 569,-25.19 569,-74.84 569,-74.84 569,-80.84 563,-86.84 557,-86.84 557,-86.84 323,-86.84 323,-86.84 317,-86.84 311,-80.84 311,-74.84 311,-74.84 311,-25.19 311,-25.19 311,-19.19 317,-13.19 323,-13.19"/>
<text xml:space="preserve" text-anchor="middle" x="440" y="-69.54" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#8892a8">External APIs</text>
</g>
<g id="clust6" class="cluster">
<title>cluster_managed</title>
<path fill="#0d1320" stroke="#1e2a4a" stroke-dasharray="5,2" d="M689,-8.74C689,-8.74 970,-8.74 970,-8.74 976,-8.74 982,-14.74 982,-20.74 982,-20.74 982,-79.29 982,-79.29 982,-85.29 976,-91.29 970,-91.29 970,-91.29 689,-91.29 689,-91.29 683,-91.29 677,-85.29 677,-79.29 677,-79.29 677,-20.74 677,-20.74 677,-14.74 683,-8.74 689,-8.74"/>
<text xml:space="preserve" text-anchor="middle" x="829.5" y="-73.99" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#8892a8">Managed App</text>
</g>
<!-- hub -->
<g id="node1" class="node">
<title>hub</title>
<path fill="#131a2a" stroke="#0066ff" d="M544.59,-305.35C544.59,-305.35 457.41,-305.35 457.41,-305.35 451.41,-305.35 445.41,-299.35 445.41,-293.35 445.41,-293.35 445.41,-261.82 445.41,-261.82 445.41,-255.82 451.41,-249.82 457.41,-249.82 457.41,-249.82 544.59,-249.82 544.59,-249.82 550.59,-249.82 556.59,-255.82 556.59,-261.82 556.59,-261.82 556.59,-293.35 556.59,-293.35 556.59,-299.35 550.59,-305.35 544.59,-305.35"/>
<text xml:space="preserve" text-anchor="middle" x="501" y="-287.21" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">soleprint</text>
<text xml:space="preserve" text-anchor="middle" x="501" y="-274.46" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">core coordinator</text>
<text xml:space="preserve" text-anchor="middle" x="501" y="-261.71" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">port 12000</text>
</g>
<!-- veins -->
<g id="node2" class="node">
<title>veins</title>
<path fill="#131a2a" stroke="#e05c4a" d="M136.09,-175.32C136.09,-175.32 27.91,-175.32 27.91,-175.32 21.91,-175.32 15.91,-169.32 15.91,-163.32 15.91,-163.32 15.91,-144.54 15.91,-144.54 15.91,-138.54 21.91,-132.54 27.91,-132.54 27.91,-132.54 136.09,-132.54 136.09,-132.54 142.09,-132.54 148.09,-138.54 148.09,-144.54 148.09,-144.54 148.09,-163.32 148.09,-163.32 148.09,-169.32 142.09,-175.32 136.09,-175.32"/>
<text xml:space="preserve" text-anchor="middle" x="82" y="-157.18" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">Veins</text>
<text xml:space="preserve" text-anchor="middle" x="82" y="-144.43" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">stateless connectors</text>
</g>
<!-- hub&#45;&gt;veins -->
<g id="edge1" class="edge">
<title>hub&#45;&gt;veins</title>
<path fill="none" stroke="#e05c4a" d="M445.05,-271.7C376.82,-264.3 258.98,-246.84 165,-208.57 147.66,-201.5 130.02,-190.71 115.48,-180.66"/>
<polygon fill="#e05c4a" stroke="#e05c4a" points="116.98,-178.71 109.85,-176.67 114.15,-182.72 116.98,-178.71"/>
<text xml:space="preserve" text-anchor="middle" x="231.94" y="-219.27" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#e8eaf0">routes</text>
</g>
<!-- books -->
<g id="node5" class="node">
<title>books</title>
<path fill="#131a2a" stroke="#2fbf6b" d="M427.59,-175.32C427.59,-175.32 346.41,-175.32 346.41,-175.32 340.41,-175.32 334.41,-169.32 334.41,-163.32 334.41,-163.32 334.41,-144.54 334.41,-144.54 334.41,-138.54 340.41,-132.54 346.41,-132.54 346.41,-132.54 427.59,-132.54 427.59,-132.54 433.59,-132.54 439.59,-138.54 439.59,-144.54 439.59,-144.54 439.59,-163.32 439.59,-163.32 439.59,-169.32 433.59,-175.32 427.59,-175.32"/>
<text xml:space="preserve" text-anchor="middle" x="387" y="-157.18" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">Books</text>
<text xml:space="preserve" text-anchor="middle" x="387" y="-144.43" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">documentation</text>
</g>
<!-- hub&#45;&gt;books -->
<g id="edge2" class="edge">
<title>hub&#45;&gt;books</title>
<path fill="none" stroke="#2fbf6b" d="M475.7,-249.58C456.8,-229.41 431.07,-201.96 412.19,-181.81"/>
<polygon fill="#2fbf6b" stroke="#2fbf6b" points="414.18,-180.35 407.6,-176.91 410.6,-183.7 414.18,-180.35"/>
<text xml:space="preserve" text-anchor="middle" x="468.46" y="-219.27" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#e8eaf0">routes</text>
</g>
<!-- tools -->
<g id="node7" class="node">
<title>tools</title>
<path fill="#131a2a" stroke="#5b8cff" d="M740.34,-175.32C740.34,-175.32 597.66,-175.32 597.66,-175.32 591.66,-175.32 585.66,-169.32 585.66,-163.32 585.66,-163.32 585.66,-144.54 585.66,-144.54 585.66,-138.54 591.66,-132.54 597.66,-132.54 597.66,-132.54 740.34,-132.54 740.34,-132.54 746.34,-132.54 752.34,-138.54 752.34,-144.54 752.34,-144.54 752.34,-163.32 752.34,-163.32 752.34,-169.32 746.34,-175.32 740.34,-175.32"/>
<text xml:space="preserve" text-anchor="middle" x="669" y="-157.18" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">Tools</text>
<text xml:space="preserve" text-anchor="middle" x="669" y="-144.43" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">tester · datagen · modelgen</text>
</g>
<!-- hub&#45;&gt;tools -->
<g id="edge3" class="edge">
<title>hub&#45;&gt;tools</title>
<path fill="none" stroke="#5b8cff" d="M538.28,-249.58C566.62,-229.06 605.36,-201.01 633.3,-180.77"/>
<polygon fill="#5b8cff" stroke="#5b8cff" points="634.7,-182.79 638.94,-176.7 631.83,-178.82 634.7,-182.79"/>
<text xml:space="preserve" text-anchor="middle" x="594.21" y="-219.27" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#e8eaf0">routes</text>
</g>
<!-- app_fe -->
<g id="node12" class="node">
<title>app_fe</title>
<path fill="#131a2a" stroke="#1e2a4a" d="M963.42,-53.59C963.42,-53.59 910.58,-53.59 910.58,-53.59 905.18,-53.59 899.78,-48.19 899.78,-42.79 899.78,-42.79 899.78,-31.99 899.78,-31.99 899.78,-26.59 905.18,-21.19 910.58,-21.19 910.58,-21.19 963.42,-21.19 963.42,-21.19 968.82,-21.19 974.22,-26.59 974.22,-31.99 974.22,-31.99 974.22,-42.79 974.22,-42.79 974.22,-48.19 968.82,-53.59 963.42,-53.59"/>
<text xml:space="preserve" text-anchor="middle" x="937" y="-34.27" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">Frontend</text>
</g>
<!-- hub&#45;&gt;app_fe -->
<g id="edge10" class="edge">
<title>hub&#45;&gt;app_fe</title>
<path fill="none" stroke="#0066ff" stroke-dasharray="5,2" d="M556.96,-273.95C652.42,-268.32 841.05,-251.89 889,-208.57 930.96,-170.65 937.39,-99.18 937.69,-62.08"/>
<polygon fill="#0066ff" stroke="#0066ff" points="940.14,-62.36 937.67,-55.37 935.24,-62.37 940.14,-62.36"/>
<text xml:space="preserve" text-anchor="middle" x="950.21" y="-156.63" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#e8eaf0">sidebar</text>
<text xml:space="preserve" text-anchor="middle" x="950.21" y="-145.38" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#e8eaf0">injection</text>
</g>
<!-- pulses -->
<g id="node4" class="node">
<title>pulses</title>
<path fill="#131a2a" stroke="#e05c4a" d="M125.22,-58.78C125.22,-58.78 38.78,-58.78 38.78,-58.78 32.78,-58.78 26.78,-52.78 26.78,-46.78 26.78,-46.78 26.78,-28 26.78,-28 26.78,-22 32.78,-16 38.78,-16 38.78,-16 125.22,-16 125.22,-16 131.22,-16 137.22,-22 137.22,-28 137.22,-28 137.22,-46.78 137.22,-46.78 137.22,-52.78 131.22,-58.78 125.22,-58.78"/>
<text xml:space="preserve" text-anchor="middle" x="82" y="-40.64" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">Pulses</text>
<text xml:space="preserve" text-anchor="middle" x="82" y="-27.89" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">composed flows</text>
</g>
<!-- veins&#45;&gt;pulses -->
<g id="edge7" class="edge">
<title>veins&#45;&gt;pulses</title>
<path fill="none" stroke="#4a5568" d="M82,-132.18C82,-114.1 82,-87.68 82,-67.49"/>
<polygon fill="#4a5568" stroke="#4a5568" points="84.45,-67.49 82,-60.49 79.55,-67.49 84.45,-67.49"/>
<text xml:space="preserve" text-anchor="middle" x="102.25" y="-101.99" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#8892a8">compose</text>
</g>
<!-- jira -->
<g id="node9" class="node">
<title>jira</title>
<path fill="#131a2a" stroke="#1e2a4a" d="M362.2,-53.59C362.2,-53.59 329.8,-53.59 329.8,-53.59 324.4,-53.59 319,-48.19 319,-42.79 319,-42.79 319,-31.99 319,-31.99 319,-26.59 324.4,-21.19 329.8,-21.19 329.8,-21.19 362.2,-21.19 362.2,-21.19 367.6,-21.19 373,-26.59 373,-31.99 373,-31.99 373,-42.79 373,-42.79 373,-48.19 367.6,-53.59 362.2,-53.59"/>
<text xml:space="preserve" text-anchor="middle" x="346" y="-34.27" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">Jira</text>
</g>
<!-- veins&#45;&gt;jira -->
<g id="edge4" class="edge">
<title>veins&#45;&gt;jira</title>
<path fill="none" stroke="#4a5568" d="M140.25,-132.14C148.52,-129.44 156.94,-126.82 165,-124.54 229.02,-106.35 256.27,-129.14 311,-91.29 321.79,-83.83 330.11,-71.86 335.93,-61.19"/>
<polygon fill="#4a5568" stroke="#4a5568" points="338.05,-62.44 339.04,-55.09 333.68,-60.22 338.05,-62.44"/>
<text xml:space="preserve" text-anchor="middle" x="302.47" y="-101.99" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#8892a8">API</text>
</g>
<!-- google -->
<g id="node10" class="node">
<title>google</title>
<path fill="#131a2a" stroke="#1e2a4a" d="M460.29,-53.59C460.29,-53.59 415.71,-53.59 415.71,-53.59 410.31,-53.59 404.91,-48.19 404.91,-42.79 404.91,-42.79 404.91,-31.99 404.91,-31.99 404.91,-26.59 410.31,-21.19 415.71,-21.19 415.71,-21.19 460.29,-21.19 460.29,-21.19 465.69,-21.19 471.09,-26.59 471.09,-31.99 471.09,-31.99 471.09,-42.79 471.09,-42.79 471.09,-48.19 465.69,-53.59 460.29,-53.59"/>
<text xml:space="preserve" text-anchor="middle" x="438" y="-34.27" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">Google</text>
</g>
<!-- veins&#45;&gt;google -->
<g id="edge5" class="edge">
<title>veins&#45;&gt;google</title>
<path fill="none" stroke="#4a5568" d="M136.84,-132.12C146.14,-129.19 155.77,-126.51 165,-124.54 263.43,-103.51 302.89,-143.39 389,-91.29 401.95,-83.45 413.6,-71.22 422.28,-60.49"/>
<polygon fill="#4a5568" stroke="#4a5568" points="424,-62.28 426.36,-55.25 420.13,-59.27 424,-62.28"/>
<text xml:space="preserve" text-anchor="middle" x="388.23" y="-101.99" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#8892a8">OAuth</text>
</g>
<!-- slack -->
<g id="node11" class="node">
<title>slack</title>
<path fill="#131a2a" stroke="#1e2a4a" d="M550.17,-53.59C550.17,-53.59 513.84,-53.59 513.84,-53.59 508.44,-53.59 503.04,-48.19 503.04,-42.79 503.04,-42.79 503.04,-31.99 503.04,-31.99 503.04,-26.59 508.44,-21.19 513.84,-21.19 513.84,-21.19 550.17,-21.19 550.17,-21.19 555.57,-21.19 560.97,-26.59 560.97,-31.99 560.97,-31.99 560.97,-42.79 560.97,-42.79 560.97,-48.19 555.57,-53.59 550.17,-53.59"/>
<text xml:space="preserve" text-anchor="middle" x="532" y="-34.27" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">Slack</text>
</g>
<!-- veins&#45;&gt;slack -->
<g id="edge6" class="edge">
<title>veins&#45;&gt;slack</title>
<path fill="none" stroke="#4a5568" d="M135.89,-132.13C145.46,-129.12 155.43,-126.41 165,-124.54 235.6,-110.71 425.88,-129.22 487,-91.29 499.44,-83.57 510.2,-71.27 518.08,-60.48"/>
<polygon fill="#4a5568" stroke="#4a5568" points="519.97,-62.05 521.96,-54.91 515.95,-59.25 519.97,-62.05"/>
<text xml:space="preserve" text-anchor="middle" x="477.1" y="-101.99" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#8892a8">API</text>
</g>
<!-- shunts -->
<g id="node3" class="node">
<title>shunts</title>
<path fill="#131a2a" stroke="#e05c4a" d="M283.47,-175.32C283.47,-175.32 192.53,-175.32 192.53,-175.32 186.53,-175.32 180.53,-169.32 180.53,-163.32 180.53,-163.32 180.53,-144.54 180.53,-144.54 180.53,-138.54 186.53,-132.54 192.53,-132.54 192.53,-132.54 283.47,-132.54 283.47,-132.54 289.47,-132.54 295.47,-138.54 295.47,-144.54 295.47,-144.54 295.47,-163.32 295.47,-163.32 295.47,-169.32 289.47,-175.32 283.47,-175.32"/>
<text xml:space="preserve" text-anchor="middle" x="238" y="-157.18" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">Shunts</text>
<text xml:space="preserve" text-anchor="middle" x="238" y="-144.43" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">mock connectors</text>
</g>
<!-- templates -->
<g id="node6" class="node">
<title>templates</title>
<path fill="#131a2a" stroke="#2fbf6b" d="M541.97,-175.32C541.97,-175.32 484.03,-175.32 484.03,-175.32 478.03,-175.32 472.03,-169.32 472.03,-163.32 472.03,-163.32 472.03,-144.54 472.03,-144.54 472.03,-138.54 478.03,-132.54 484.03,-132.54 484.03,-132.54 541.97,-132.54 541.97,-132.54 547.97,-132.54 553.97,-138.54 553.97,-144.54 553.97,-144.54 553.97,-163.32 553.97,-163.32 553.97,-169.32 547.97,-175.32 541.97,-175.32"/>
<text xml:space="preserve" text-anchor="middle" x="513" y="-157.18" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">Templates</text>
<text xml:space="preserve" text-anchor="middle" x="513" y="-144.43" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">patterns</text>
</g>
<!-- app_be -->
<g id="node13" class="node">
<title>app_be</title>
<path fill="#131a2a" stroke="#1e2a4a" d="M746.67,-53.59C746.67,-53.59 695.33,-53.59 695.33,-53.59 689.93,-53.59 684.53,-48.19 684.53,-42.79 684.53,-42.79 684.53,-31.99 684.53,-31.99 684.53,-26.59 689.93,-21.19 695.33,-21.19 695.33,-21.19 746.67,-21.19 746.67,-21.19 752.07,-21.19 757.47,-26.59 757.47,-31.99 757.47,-31.99 757.47,-42.79 757.47,-42.79 757.47,-48.19 752.07,-53.59 746.67,-53.59"/>
<text xml:space="preserve" text-anchor="middle" x="721" y="-34.27" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">Backend</text>
</g>
<!-- tools&#45;&gt;app_be -->
<g id="edge8" class="edge">
<title>tools&#45;&gt;app_be</title>
<path fill="none" stroke="#4a5568" stroke-dasharray="5,2" d="M678.42,-132.18C687.46,-112.27 701.1,-82.23 710.49,-61.54"/>
<polygon fill="#4a5568" stroke="#4a5568" points="712.7,-62.61 713.36,-55.22 708.23,-60.58 712.7,-62.61"/>
<text xml:space="preserve" text-anchor="middle" x="700.51" y="-101.99" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#8892a8">test</text>
</g>
<!-- monitors -->
<g id="node8" class="node">
<title>monitors</title>
<path fill="#131a2a" stroke="#5b8cff" d="M861.34,-175.32C861.34,-175.32 796.66,-175.32 796.66,-175.32 790.66,-175.32 784.66,-169.32 784.66,-163.32 784.66,-163.32 784.66,-144.54 784.66,-144.54 784.66,-138.54 790.66,-132.54 796.66,-132.54 796.66,-132.54 861.34,-132.54 861.34,-132.54 867.34,-132.54 873.34,-138.54 873.34,-144.54 873.34,-144.54 873.34,-163.32 873.34,-163.32 873.34,-169.32 867.34,-175.32 861.34,-175.32"/>
<text xml:space="preserve" text-anchor="middle" x="829" y="-157.18" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">Monitors</text>
<text xml:space="preserve" text-anchor="middle" x="829" y="-144.43" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">databrowse</text>
</g>
<!-- app_db -->
<g id="node14" class="node">
<title>app_db</title>
<path fill="#131a2a" stroke="#1e2a4a" d="M868.09,-54.28C868.09,-56.35 850.57,-58.04 829,-58.04 807.43,-58.04 789.91,-56.35 789.91,-54.28 789.91,-54.28 789.91,-20.5 789.91,-20.5 789.91,-18.43 807.43,-16.74 829,-16.74 850.57,-16.74 868.09,-18.43 868.09,-20.5 868.09,-20.5 868.09,-54.28 868.09,-54.28"/>
<path fill="none" stroke="#1e2a4a" d="M868.09,-54.28C868.09,-52.21 850.57,-50.53 829,-50.53 807.43,-50.53 789.91,-52.21 789.91,-54.28"/>
<text xml:space="preserve" text-anchor="middle" x="829" y="-34.27" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e8eaf0">Database</text>
</g>
<!-- monitors&#45;&gt;app_db -->
<g id="edge9" class="edge">
<title>monitors&#45;&gt;app_db</title>
<path fill="none" stroke="#4a5568" stroke-dasharray="5,2" d="M829,-132.18C829,-113.8 829,-86.78 829,-66.46"/>
<polygon fill="#4a5568" stroke="#4a5568" points="831.45,-66.72 829,-59.72 826.55,-66.72 831.45,-66.72"/>
<text xml:space="preserve" text-anchor="middle" x="845.12" y="-101.99" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#8892a8">browse</text>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 19 KiB

View File

@@ -0,0 +1,107 @@
digraph system_overview {
bgcolor="#ffffff"
rankdir=TB
splines="ortho"
nodesep="0.55"
ranksep="0.7"
pad="0.3"
fontname="Arial"
label="Soleprint — System Overview"
labelloc=t
fontsize="14"
fontcolor="#1f2933"
compound=true
node [shape="box" style="filled,rounded" fillcolor="#ffffff" color="#9aa5b1" penwidth="1" fontname="Arial" fontsize="10" fontcolor="#1f2933" margin="0.25,0.14" height="0.5"]
edge [color="#9aa5b1" penwidth="1" arrowhead="normal" arrowsize="0.7" fontname="Arial" fontsize="9" fontcolor="#616e7c"]
subgraph cluster_core {
label="Soleprint Hub"
style="rounded,dashed"
color="#3a7dff"
bgcolor="#d6e4ff"
fontcolor="#616e7c"
fontname="Arial"
hub [label="soleprint
core coordinator
port 12000" shape="box" style="filled,rounded" fillcolor="#d6e4ff" color="#3a7dff" fontcolor="#1f2933" penwidth="1" fontname="Arial" fontsize="10" margin="0.25,0.14" height="0.5"]
}
subgraph cluster_artery {
label="Artery — Todo lo vital"
style="rounded,dashed"
color="#c0392b"
bgcolor="#fdeaea"
fontcolor="#616e7c"
fontname="Arial"
veins [label="Veins
stateless connectors" shape="box" style="filled,rounded" fillcolor="#fdeaea" color="#c0392b" fontcolor="#1f2933" penwidth="1" fontname="Arial" fontsize="10" margin="0.25,0.14" height="0.5"]
shunts [label="Shunts
mock connectors" shape="box" style="filled,rounded" fillcolor="#fdeaea" color="#c0392b" fontcolor="#1f2933" penwidth="1" fontname="Arial" fontsize="10" margin="0.25,0.14" height="0.5"]
pulses [label="Pulses
composed flows" shape="box" style="filled,rounded" fillcolor="#fdeaea" color="#c0392b" fontcolor="#1f2933" penwidth="1" fontname="Arial" fontsize="10" margin="0.25,0.14" height="0.5"]
}
subgraph cluster_atlas {
label="Atlas — Documentación accionable"
style="rounded,dashed"
color="#1a7f45"
bgcolor="#e6f5ec"
fontcolor="#616e7c"
fontname="Arial"
books [label="Books
documentation" shape="box" style="filled,rounded" fillcolor="#e6f5ec" color="#1a7f45" fontcolor="#1f2933" penwidth="1" fontname="Arial" fontsize="10" margin="0.25,0.14" height="0.5"]
templates [label="Templates
patterns" shape="box" style="filled,rounded" fillcolor="#e6f5ec" color="#1a7f45" fontcolor="#1f2933" penwidth="1" fontname="Arial" fontsize="10" margin="0.25,0.14" height="0.5"]
}
subgraph cluster_station {
label="Station — Centro de control"
style="rounded,dashed"
color="#2b5fd9"
bgcolor="#e8effd"
fontcolor="#616e7c"
fontname="Arial"
tools [label="Tools
tester · datagen · modelgen" shape="box" style="filled,rounded" fillcolor="#e8effd" color="#2b5fd9" fontcolor="#1f2933" penwidth="1" fontname="Arial" fontsize="10" margin="0.25,0.14" height="0.5"]
monitors [label="Monitors
databrowse" shape="box" style="filled,rounded" fillcolor="#e8effd" color="#2b5fd9" fontcolor="#1f2933" penwidth="1" fontname="Arial" fontsize="10" margin="0.25,0.14" height="0.5"]
}
subgraph cluster_external {
label="External APIs"
style="rounded,dashed"
color="#cbd2d9"
bgcolor="#f5f7fa"
fontcolor="#616e7c"
fontname="Arial"
jira [label="Jira" shape="box" style="filled,rounded" fillcolor="#ffffff" color="#9aa5b1" fontcolor="#1f2933" penwidth="1" fontname="Arial" fontsize="10" margin="0.25,0.14" height="0.5"]
google [label="Google" shape="box" style="filled,rounded" fillcolor="#ffffff" color="#9aa5b1" fontcolor="#1f2933" penwidth="1" fontname="Arial" fontsize="10" margin="0.25,0.14" height="0.5"]
slack [label="Slack" shape="box" style="filled,rounded" fillcolor="#ffffff" color="#9aa5b1" fontcolor="#1f2933" penwidth="1" fontname="Arial" fontsize="10" margin="0.25,0.14" height="0.5"]
}
subgraph cluster_managed {
label="Managed App"
style="rounded,dashed"
color="#cbd2d9"
bgcolor="#f5f7fa"
fontcolor="#616e7c"
fontname="Arial"
app_fe [label="Frontend" shape="box" style="filled,rounded" fillcolor="#ffffff" color="#9aa5b1" fontcolor="#1f2933" penwidth="1" fontname="Arial" fontsize="10" margin="0.25,0.14" height="0.5"]
app_be [label="Backend" shape="box" style="filled,rounded" fillcolor="#ffffff" color="#9aa5b1" fontcolor="#1f2933" penwidth="1" fontname="Arial" fontsize="10" margin="0.25,0.14" height="0.5"]
app_db [label="Database" shape="cylinder" style="filled,rounded" fillcolor="#ffffff" color="#9aa5b1" fontcolor="#1f2933" penwidth="1" fontname="Arial" fontsize="10" margin="0.25,0.14" height="0.5"]
}
hub -> veins [xlabel="routes" color="#c0392b" fontcolor="#c0392b" penwidth="1" arrowhead="normal" arrowsize="0.7" fontname="Arial" fontsize="9"]
hub -> books [xlabel="routes" color="#1a7f45" fontcolor="#1a7f45" penwidth="1" arrowhead="normal" arrowsize="0.7" fontname="Arial" fontsize="9"]
hub -> tools [xlabel="routes" color="#2b5fd9" fontcolor="#2b5fd9" penwidth="1" arrowhead="normal" arrowsize="0.7" fontname="Arial" fontsize="9"]
veins -> jira [xlabel="API" color="#9aa5b1" fontcolor="#616e7c" penwidth="1" arrowhead="normal" arrowsize="0.7" fontname="Arial" fontsize="9"]
veins -> google [xlabel="OAuth" color="#9aa5b1" fontcolor="#616e7c" penwidth="1" arrowhead="normal" arrowsize="0.7" fontname="Arial" fontsize="9"]
veins -> slack [xlabel="API" color="#9aa5b1" fontcolor="#616e7c" penwidth="1" arrowhead="normal" arrowsize="0.7" fontname="Arial" fontsize="9"]
veins -> pulses [xlabel="compose" color="#9aa5b1" fontcolor="#616e7c" penwidth="1" arrowhead="normal" arrowsize="0.7" fontname="Arial" fontsize="9"]
tools -> app_be [xlabel="test" color="#9aa5b1" fontcolor="#616e7c" penwidth="1" arrowhead="normal" arrowsize="0.7" fontname="Arial" fontsize="9" style="dashed"]
monitors -> app_db [xlabel="browse" color="#9aa5b1" fontcolor="#616e7c" penwidth="1" arrowhead="normal" arrowsize="0.7" fontname="Arial" fontsize="9" style="dashed"]
hub -> app_fe [xlabel="sidebar
injection" color="#3a7dff" fontcolor="#3a7dff" penwidth="1" arrowhead="normal" arrowsize="0.7" fontname="Arial" fontsize="9" style="dashed"]
}

View File

@@ -0,0 +1,209 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Generated by graphviz version 14.1.2 (0)
-->
<!-- Title: system_overview Pages: 1 -->
<svg width="1032pt" height="367pt"
viewBox="0.00 0.00 1032.00 367.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(21.6 345.64)">
<title>system_overview</title>
<polygon fill="#ffffff" stroke="none" points="-21.6,21.6 -21.6,-345.64 1010.6,-345.64 1010.6,21.6 -21.6,21.6"/>
<text xml:space="preserve" text-anchor="middle" x="494.5" y="-306.74" font-family="Arial" font-size="14.00" fill="#1f2933">Soleprint — System Overview</text>
<g id="clust1" class="cluster">
<title>cluster_core</title>
<path fill="#d6e4ff" stroke="#3a7dff" stroke-dasharray="5,2" d="M478,-196.38C478,-196.38 576,-196.38 576,-196.38 582,-196.38 588,-202.38 588,-208.38 588,-208.38 588,-280.29 588,-280.29 588,-286.29 582,-292.29 576,-292.29 576,-292.29 478,-292.29 478,-292.29 472,-292.29 466,-286.29 466,-280.29 466,-280.29 466,-208.38 466,-208.38 466,-202.38 472,-196.38 478,-196.38"/>
<text xml:space="preserve" text-anchor="middle" x="527" y="-274.99" font-family="Arial" font-size="14.00" fill="#616e7c">Soleprint Hub</text>
</g>
<g id="clust2" class="cluster">
<title>cluster_artery</title>
<path fill="#fdeaea" stroke="#c0392b" stroke-dasharray="5,2" d="M20,-8.03C20,-8.03 290,-8.03 290,-8.03 296,-8.03 302,-14.03 302,-20.03 302,-20.03 302,-174.13 302,-174.13 302,-180.13 296,-186.13 290,-186.13 290,-186.13 20,-186.13 20,-186.13 14,-186.13 8,-180.13 8,-174.13 8,-174.13 8,-20.03 8,-20.03 8,-14.03 14,-8.03 20,-8.03"/>
<text xml:space="preserve" text-anchor="middle" x="155" y="-168.83" font-family="Arial" font-size="14.00" fill="#616e7c">Artery — Todo lo vital</text>
</g>
<g id="clust3" class="cluster">
<title>cluster_atlas</title>
<path fill="#e6f5ec" stroke="#1a7f45" stroke-dasharray="5,2" d="M337,-102.22C337,-102.22 550,-102.22 550,-102.22 556,-102.22 562,-108.22 562,-114.22 562,-114.22 562,-174.13 562,-174.13 562,-180.13 556,-186.13 550,-186.13 550,-186.13 337,-186.13 337,-186.13 331,-186.13 325,-180.13 325,-174.13 325,-174.13 325,-114.22 325,-114.22 325,-108.22 331,-102.22 337,-102.22"/>
<text xml:space="preserve" text-anchor="middle" x="443.5" y="-168.83" font-family="Arial" font-size="14.00" fill="#616e7c">Atlas — Documentación accionable</text>
</g>
<g id="clust4" class="cluster">
<title>cluster_station</title>
<path fill="#e8effd" stroke="#2b5fd9" stroke-dasharray="5,2" d="M597,-102.22C597,-102.22 871,-102.22 871,-102.22 877,-102.22 883,-108.22 883,-114.22 883,-114.22 883,-174.13 883,-174.13 883,-180.13 877,-186.13 871,-186.13 871,-186.13 597,-186.13 597,-186.13 591,-186.13 585,-180.13 585,-174.13 585,-174.13 585,-114.22 585,-114.22 585,-108.22 591,-102.22 597,-102.22"/>
<text xml:space="preserve" text-anchor="middle" x="734" y="-168.83" font-family="Arial" font-size="14.00" fill="#616e7c">Station — Centro de control</text>
</g>
<g id="clust5" class="cluster">
<title>cluster_external</title>
<path fill="#f5f7fa" stroke="#cbd2d9" stroke-dasharray="5,2" d="M322,-12.11C322,-12.11 575,-12.11 575,-12.11 581,-12.11 587,-18.11 587,-24.11 587,-24.11 587,-75.86 587,-75.86 587,-81.86 581,-87.86 575,-87.86 575,-87.86 322,-87.86 322,-87.86 316,-87.86 310,-81.86 310,-75.86 310,-75.86 310,-24.11 310,-24.11 310,-18.11 316,-12.11 322,-12.11"/>
<text xml:space="preserve" text-anchor="middle" x="448.5" y="-70.56" font-family="Arial" font-size="14.00" fill="#616e7c">External APIs</text>
</g>
<g id="clust6" class="cluster">
<title>cluster_managed</title>
<path fill="#f5f7fa" stroke="#cbd2d9" stroke-dasharray="5,2" d="M671,-8C671,-8 969,-8 969,-8 975,-8 981,-14 981,-20 981,-20 981,-79.97 981,-79.97 981,-85.97 975,-91.97 969,-91.97 969,-91.97 671,-91.97 671,-91.97 665,-91.97 659,-85.97 659,-79.97 659,-79.97 659,-20 659,-20 659,-14 665,-8 671,-8"/>
<text xml:space="preserve" text-anchor="middle" x="820" y="-74.67" font-family="Arial" font-size="14.00" fill="#616e7c">Managed App</text>
</g>
<!-- hub -->
<g id="node1" class="node">
<title>hub</title>
<path fill="#d6e4ff" stroke="#3a7dff" d="M567.88,-260.54C567.88,-260.54 486.12,-260.54 486.12,-260.54 480.12,-260.54 474.12,-254.54 474.12,-248.54 474.12,-248.54 474.12,-216.38 474.12,-216.38 474.12,-210.38 480.12,-204.38 486.12,-204.38 486.12,-204.38 567.88,-204.38 567.88,-204.38 573.88,-204.38 579.88,-210.38 579.88,-216.38 579.88,-216.38 579.88,-248.54 579.88,-248.54 579.88,-254.54 573.88,-260.54 567.88,-260.54"/>
<text xml:space="preserve" text-anchor="middle" x="527" y="-240.96" font-family="Arial" font-size="10.00" fill="#1f2933">soleprint</text>
<text xml:space="preserve" text-anchor="middle" x="527" y="-228.96" font-family="Arial" font-size="10.00" fill="#1f2933">core coordinator</text>
<text xml:space="preserve" text-anchor="middle" x="527" y="-216.96" font-family="Arial" font-size="10.00" fill="#1f2933">port 12000</text>
</g>
<!-- veins -->
<g id="node2" class="node">
<title>veins</title>
<path fill="#fdeaea" stroke="#c0392b" d="M130.38,-154.38C130.38,-154.38 27.62,-154.38 27.62,-154.38 21.62,-154.38 15.62,-148.38 15.62,-142.38 15.62,-142.38 15.62,-122.22 15.62,-122.22 15.62,-116.22 21.62,-110.22 27.62,-110.22 27.62,-110.22 130.38,-110.22 130.38,-110.22 136.38,-110.22 142.38,-116.22 142.38,-122.22 142.38,-122.22 142.38,-142.38 142.38,-142.38 142.38,-148.38 136.38,-154.38 130.38,-154.38"/>
<text xml:space="preserve" text-anchor="middle" x="79" y="-134.8" font-family="Arial" font-size="10.00" fill="#1f2933">Veins</text>
<text xml:space="preserve" text-anchor="middle" x="79" y="-122.8" font-family="Arial" font-size="10.00" fill="#1f2933">stateless connectors</text>
</g>
<!-- hub&#45;&gt;veins -->
<g id="edge1" class="edge">
<title>hub&#45;&gt;veins</title>
<path fill="none" stroke="#c0392b" d="M473.85,-242C355.18,-242 79,-242 79,-242 79,-242 79,-163.28 79,-163.28"/>
<polygon fill="#c0392b" stroke="#c0392b" points="81.45,-163.28 79,-156.28 76.55,-163.28 81.45,-163.28"/>
<text xml:space="preserve" text-anchor="middle" x="224.31" y="-243.95" font-family="Arial" font-size="9.00" fill="#c0392b">routes</text>
</g>
<!-- books -->
<g id="node5" class="node">
<title>books</title>
<path fill="#e6f5ec" stroke="#1a7f45" d="M420.88,-154.38C420.88,-154.38 345.12,-154.38 345.12,-154.38 339.12,-154.38 333.12,-148.38 333.12,-142.38 333.12,-142.38 333.12,-122.22 333.12,-122.22 333.12,-116.22 339.12,-110.22 345.12,-110.22 345.12,-110.22 420.88,-110.22 420.88,-110.22 426.88,-110.22 432.88,-116.22 432.88,-122.22 432.88,-122.22 432.88,-142.38 432.88,-142.38 432.88,-148.38 426.88,-154.38 420.88,-154.38"/>
<text xml:space="preserve" text-anchor="middle" x="383" y="-134.8" font-family="Arial" font-size="10.00" fill="#1f2933">Books</text>
<text xml:space="preserve" text-anchor="middle" x="383" y="-122.8" font-family="Arial" font-size="10.00" fill="#1f2933">documentation</text>
</g>
<!-- hub&#45;&gt;books -->
<g id="edge2" class="edge">
<title>hub&#45;&gt;books</title>
<path fill="none" stroke="#1a7f45" d="M473.79,-223C432.77,-223 383,-223 383,-223 383,-223 383,-163.23 383,-163.23"/>
<polygon fill="#1a7f45" stroke="#1a7f45" points="385.45,-163.23 383,-156.23 380.55,-163.23 385.45,-163.23"/>
<text xml:space="preserve" text-anchor="middle" x="385.76" y="-224.95" font-family="Arial" font-size="9.00" fill="#1a7f45">routes</text>
</g>
<!-- tools -->
<g id="node7" class="node">
<title>tools</title>
<path fill="#e8effd" stroke="#2b5fd9" d="M736.62,-154.38C736.62,-154.38 605.38,-154.38 605.38,-154.38 599.38,-154.38 593.38,-148.38 593.38,-142.38 593.38,-142.38 593.38,-122.22 593.38,-122.22 593.38,-116.22 599.38,-110.22 605.38,-110.22 605.38,-110.22 736.62,-110.22 736.62,-110.22 742.62,-110.22 748.62,-116.22 748.62,-122.22 748.62,-122.22 748.62,-142.38 748.62,-142.38 748.62,-148.38 742.62,-154.38 736.62,-154.38"/>
<text xml:space="preserve" text-anchor="middle" x="671" y="-134.8" font-family="Arial" font-size="10.00" fill="#1f2933">Tools</text>
<text xml:space="preserve" text-anchor="middle" x="671" y="-122.8" font-family="Arial" font-size="10.00" fill="#1f2933">tester · datagen · modelgen</text>
</g>
<!-- hub&#45;&gt;tools -->
<g id="edge3" class="edge">
<title>hub&#45;&gt;tools</title>
<path fill="none" stroke="#2b5fd9" d="M566.44,-203.94C566.44,-174.24 566.44,-132 566.44,-132 566.44,-132 584.58,-132 584.58,-132"/>
<polygon fill="#2b5fd9" stroke="#2b5fd9" points="584.58,-134.45 591.58,-132 584.58,-129.55 584.58,-134.45"/>
<text xml:space="preserve" text-anchor="middle" x="579.19" y="-160.85" font-family="Arial" font-size="9.00" fill="#2b5fd9">routes</text>
</g>
<!-- app_fe -->
<g id="node12" class="node">
<title>app_fe</title>
<path fill="#ffffff" stroke="#9aa5b1" d="M961.12,-56.11C961.12,-56.11 910.88,-56.11 910.88,-56.11 904.88,-56.11 898.88,-50.11 898.88,-44.11 898.88,-44.11 898.88,-32.11 898.88,-32.11 898.88,-26.11 904.88,-20.11 910.88,-20.11 910.88,-20.11 961.12,-20.11 961.12,-20.11 967.12,-20.11 973.12,-26.11 973.12,-32.11 973.12,-32.11 973.12,-44.11 973.12,-44.11 973.12,-50.11 967.12,-56.11 961.12,-56.11"/>
<text xml:space="preserve" text-anchor="middle" x="936" y="-34.61" font-family="Arial" font-size="10.00" fill="#1f2933">Frontend</text>
</g>
<!-- hub&#45;&gt;app_fe -->
<g id="edge10" class="edge">
<title>hub&#45;&gt;app_fe</title>
<path fill="none" stroke="#3a7dff" stroke-dasharray="5,2" d="M580.05,-232C690.72,-232 936,-232 936,-232 936,-232 936,-64.86 936,-64.86"/>
<polygon fill="#3a7dff" stroke="#3a7dff" points="938.45,-64.86 936,-57.86 933.55,-64.86 938.45,-64.86"/>
<text xml:space="preserve" text-anchor="middle" x="824.34" y="-244.45" font-family="Arial" font-size="9.00" fill="#3a7dff">sidebar</text>
<text xml:space="preserve" text-anchor="middle" x="824.34" y="-233.95" font-family="Arial" font-size="9.00" fill="#3a7dff">injection</text>
</g>
<!-- pulses -->
<g id="node4" class="node">
<title>pulses</title>
<path fill="#fdeaea" stroke="#c0392b" d="M120.62,-60.19C120.62,-60.19 37.38,-60.19 37.38,-60.19 31.38,-60.19 25.38,-54.19 25.38,-48.19 25.38,-48.19 25.38,-28.03 25.38,-28.03 25.38,-22.03 31.38,-16.03 37.38,-16.03 37.38,-16.03 120.62,-16.03 120.62,-16.03 126.62,-16.03 132.62,-22.03 132.62,-28.03 132.62,-28.03 132.62,-48.19 132.62,-48.19 132.62,-54.19 126.62,-60.19 120.62,-60.19"/>
<text xml:space="preserve" text-anchor="middle" x="79" y="-40.61" font-family="Arial" font-size="10.00" fill="#1f2933">Pulses</text>
<text xml:space="preserve" text-anchor="middle" x="79" y="-28.61" font-family="Arial" font-size="10.00" fill="#1f2933">composed flows</text>
</g>
<!-- veins&#45;&gt;pulses -->
<g id="edge7" class="edge">
<title>veins&#45;&gt;pulses</title>
<path fill="none" stroke="#9aa5b1" d="M79,-109.98C79,-109.98 79,-69.11 79,-69.11"/>
<polygon fill="#9aa5b1" stroke="#9aa5b1" points="81.45,-69.11 79,-62.11 76.55,-69.11 81.45,-69.11"/>
<text xml:space="preserve" text-anchor="middle" x="60.25" y="-91.49" font-family="Arial" font-size="9.00" fill="#616e7c">compose</text>
</g>
<!-- jira -->
<g id="node9" class="node">
<title>jira</title>
<path fill="#ffffff" stroke="#9aa5b1" d="M360,-56.11C360,-56.11 330,-56.11 330,-56.11 324,-56.11 318,-50.11 318,-44.11 318,-44.11 318,-32.11 318,-32.11 318,-26.11 324,-20.11 330,-20.11 330,-20.11 360,-20.11 360,-20.11 366,-20.11 372,-26.11 372,-32.11 372,-32.11 372,-44.11 372,-44.11 372,-50.11 366,-56.11 360,-56.11"/>
<text xml:space="preserve" text-anchor="middle" x="345" y="-34.61" font-family="Arial" font-size="10.00" fill="#1f2933">Jira</text>
</g>
<!-- veins&#45;&gt;jira -->
<g id="edge4" class="edge">
<title>veins&#45;&gt;jira</title>
<path fill="none" stroke="#9aa5b1" d="M139.94,-109.95C139.94,-82.07 139.94,-38 139.94,-38 139.94,-38 309.26,-38 309.26,-38"/>
<polygon fill="#9aa5b1" stroke="#9aa5b1" points="309.26,-40.45 316.26,-38 309.26,-35.55 309.26,-40.45"/>
<text xml:space="preserve" text-anchor="middle" x="181.5" y="-39.95" font-family="Arial" font-size="9.00" fill="#616e7c">API</text>
</g>
<!-- google -->
<g id="node10" class="node">
<title>google</title>
<path fill="#ffffff" stroke="#9aa5b1" d="M466.38,-56.11C466.38,-56.11 423.62,-56.11 423.62,-56.11 417.62,-56.11 411.62,-50.11 411.62,-44.11 411.62,-44.11 411.62,-32.11 411.62,-32.11 411.62,-26.11 417.62,-20.11 423.62,-20.11 423.62,-20.11 466.38,-20.11 466.38,-20.11 472.38,-20.11 478.38,-26.11 478.38,-32.11 478.38,-32.11 478.38,-44.11 478.38,-44.11 478.38,-50.11 472.38,-56.11 466.38,-56.11"/>
<text xml:space="preserve" text-anchor="middle" x="445" y="-34.61" font-family="Arial" font-size="10.00" fill="#1f2933">Google</text>
</g>
<!-- veins&#45;&gt;google -->
<g id="edge5" class="edge">
<title>veins&#45;&gt;google</title>
<path fill="none" stroke="#9aa5b1" d="M135.06,-109.81C135.06,-94.5 135.06,-77 135.06,-77 135.06,-77 422.25,-77 422.25,-77 422.25,-77 422.25,-64.7 422.25,-64.7"/>
<polygon fill="#9aa5b1" stroke="#9aa5b1" points="424.7,-64.7 422.25,-57.7 419.8,-64.7 424.7,-64.7"/>
<text xml:space="preserve" text-anchor="middle" x="255.65" y="-78.95" font-family="Arial" font-size="9.00" fill="#616e7c">OAuth</text>
</g>
<!-- slack -->
<g id="node11" class="node">
<title>slack</title>
<path fill="#ffffff" stroke="#9aa5b1" d="M567.38,-56.11C567.38,-56.11 530.62,-56.11 530.62,-56.11 524.62,-56.11 518.62,-50.11 518.62,-44.11 518.62,-44.11 518.62,-32.11 518.62,-32.11 518.62,-26.11 524.62,-20.11 530.62,-20.11 530.62,-20.11 567.38,-20.11 567.38,-20.11 573.38,-20.11 579.38,-26.11 579.38,-32.11 579.38,-32.11 579.38,-44.11 579.38,-44.11 579.38,-50.11 573.38,-56.11 567.38,-56.11"/>
<text xml:space="preserve" text-anchor="middle" x="549" y="-34.61" font-family="Arial" font-size="10.00" fill="#1f2933">Slack</text>
</g>
<!-- veins&#45;&gt;slack -->
<g id="edge6" class="edge">
<title>veins&#45;&gt;slack</title>
<path fill="none" stroke="#9aa5b1" d="M137.5,-110.02C137.5,-101.6 137.5,-94 137.5,-94 137.5,-94 536.06,-94 536.06,-94 536.06,-94 536.06,-64.92 536.06,-64.92"/>
<polygon fill="#9aa5b1" stroke="#9aa5b1" points="538.51,-64.92 536.06,-57.92 533.61,-64.92 538.51,-64.92"/>
<text xml:space="preserve" text-anchor="middle" x="336.18" y="-95.95" font-family="Arial" font-size="9.00" fill="#616e7c">API</text>
</g>
<!-- shunts -->
<g id="node3" class="node">
<title>shunts</title>
<path fill="#fdeaea" stroke="#c0392b" d="M281.5,-154.38C281.5,-154.38 194.5,-154.38 194.5,-154.38 188.5,-154.38 182.5,-148.38 182.5,-142.38 182.5,-142.38 182.5,-122.22 182.5,-122.22 182.5,-116.22 188.5,-110.22 194.5,-110.22 194.5,-110.22 281.5,-110.22 281.5,-110.22 287.5,-110.22 293.5,-116.22 293.5,-122.22 293.5,-122.22 293.5,-142.38 293.5,-142.38 293.5,-148.38 287.5,-154.38 281.5,-154.38"/>
<text xml:space="preserve" text-anchor="middle" x="238" y="-134.8" font-family="Arial" font-size="10.00" fill="#1f2933">Shunts</text>
<text xml:space="preserve" text-anchor="middle" x="238" y="-122.8" font-family="Arial" font-size="10.00" fill="#1f2933">mock connectors</text>
</g>
<!-- templates -->
<g id="node6" class="node">
<title>templates</title>
<path fill="#e6f5ec" stroke="#1a7f45" d="M541.5,-154.38C541.5,-154.38 484.5,-154.38 484.5,-154.38 478.5,-154.38 472.5,-148.38 472.5,-142.38 472.5,-142.38 472.5,-122.22 472.5,-122.22 472.5,-116.22 478.5,-110.22 484.5,-110.22 484.5,-110.22 541.5,-110.22 541.5,-110.22 547.5,-110.22 553.5,-116.22 553.5,-122.22 553.5,-122.22 553.5,-142.38 553.5,-142.38 553.5,-148.38 547.5,-154.38 541.5,-154.38"/>
<text xml:space="preserve" text-anchor="middle" x="513" y="-134.8" font-family="Arial" font-size="10.00" fill="#1f2933">Templates</text>
<text xml:space="preserve" text-anchor="middle" x="513" y="-122.8" font-family="Arial" font-size="10.00" fill="#1f2933">patterns</text>
</g>
<!-- app_be -->
<g id="node13" class="node">
<title>app_be</title>
<path fill="#ffffff" stroke="#9aa5b1" d="M729.12,-56.11C729.12,-56.11 678.88,-56.11 678.88,-56.11 672.88,-56.11 666.88,-50.11 666.88,-44.11 666.88,-44.11 666.88,-32.11 666.88,-32.11 666.88,-26.11 672.88,-20.11 678.88,-20.11 678.88,-20.11 729.12,-20.11 729.12,-20.11 735.12,-20.11 741.12,-26.11 741.12,-32.11 741.12,-32.11 741.12,-44.11 741.12,-44.11 741.12,-50.11 735.12,-56.11 729.12,-56.11"/>
<text xml:space="preserve" text-anchor="middle" x="704" y="-34.61" font-family="Arial" font-size="10.00" fill="#1f2933">Backend</text>
</g>
<!-- tools&#45;&gt;app_be -->
<g id="edge8" class="edge">
<title>tools&#45;&gt;app_be</title>
<path fill="none" stroke="#9aa5b1" stroke-dasharray="5,2" d="M704,-109.98C704,-109.98 704,-64.99 704,-64.99"/>
<polygon fill="#9aa5b1" stroke="#9aa5b1" points="706.45,-64.99 704,-57.99 701.55,-64.99 706.45,-64.99"/>
<text xml:space="preserve" text-anchor="middle" x="696.88" y="-89.43" font-family="Arial" font-size="9.00" fill="#616e7c">test</text>
</g>
<!-- monitors -->
<g id="node8" class="node">
<title>monitors</title>
<path fill="#e8effd" stroke="#2b5fd9" d="M863.12,-154.38C863.12,-154.38 800.88,-154.38 800.88,-154.38 794.88,-154.38 788.88,-148.38 788.88,-142.38 788.88,-142.38 788.88,-122.22 788.88,-122.22 788.88,-116.22 794.88,-110.22 800.88,-110.22 800.88,-110.22 863.12,-110.22 863.12,-110.22 869.12,-110.22 875.12,-116.22 875.12,-122.22 875.12,-122.22 875.12,-142.38 875.12,-142.38 875.12,-148.38 869.12,-154.38 863.12,-154.38"/>
<text xml:space="preserve" text-anchor="middle" x="832" y="-134.8" font-family="Arial" font-size="10.00" fill="#1f2933">Monitors</text>
<text xml:space="preserve" text-anchor="middle" x="832" y="-122.8" font-family="Arial" font-size="10.00" fill="#1f2933">databrowse</text>
</g>
<!-- app_db -->
<g id="node14" class="node">
<title>app_db</title>
<path fill="#ffffff" stroke="#9aa5b1" d="M859,-56.2C859,-58.42 841.52,-60.22 820,-60.22 798.48,-60.22 781,-58.42 781,-56.2 781,-56.2 781,-20.02 781,-20.02 781,-17.8 798.48,-16 820,-16 841.52,-16 859,-17.8 859,-20.02 859,-20.02 859,-56.2 859,-56.2"/>
<path fill="none" stroke="#9aa5b1" d="M859,-56.2C859,-53.98 841.52,-52.18 820,-52.18 798.48,-52.18 781,-53.98 781,-56.2"/>
<text xml:space="preserve" text-anchor="middle" x="820" y="-34.61" font-family="Arial" font-size="10.00" fill="#1f2933">Database</text>
</g>
<!-- monitors&#45;&gt;app_db -->
<g id="edge9" class="edge">
<title>monitors&#45;&gt;app_db</title>
<path fill="none" stroke="#9aa5b1" stroke-dasharray="5,2" d="M823.94,-109.98C823.94,-109.98 823.94,-69.11 823.94,-69.11"/>
<polygon fill="#9aa5b1" stroke="#9aa5b1" points="826.39,-69.11 823.94,-62.11 821.49,-69.11 826.39,-69.11"/>
<text xml:space="preserve" text-anchor="middle" x="808.94" y="-91.49" font-family="Arial" font-size="9.00" fill="#616e7c">browse</text>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 18 KiB

View File

@@ -1,176 +0,0 @@
"""
Every visual value, as data.
A profile is a JSON file. Nothing in this package hardcodes a colour, a font or
a separation value, and the selftest asserts that by grepping the source for the
profiles' own hexes — because the failure mode this design exists to prevent is
somebody reaching for a literal `"#5A6C86"` in `dot.py` on a Tuesday and the
whole arrangement quietly becoming decorative.
Lucid is the *first* profile, not the only one and not the built-in one. The
shipped `lucid.json` is a plausible default derived from values already visible
in `spr/docs/graphs/themes/lucid.gvpr`; the real one, extracted from company
diagrams by `style/`, drops in as a file with no code change.
## Keys
Graph level: bgcolor, rankdir, splines, nodesep, ranksep, pad, fontname,
fontsize, fontcolor
Nodes: fill, stroke, penwidth, fontname, fontsize, fontcolor, rounded,
margin, height
Edges: stroke, penwidth, arrowhead, arrowsize, fontname, fontsize,
fontcolor
Groups: fill, stroke, fontcolor, rounded
Classes: classes = {name: {stroke, fill, fontcolor}}
An unknown key is refused rather than ignored. That is `histgen/config.py`'s
rule and it is worth repeating: a setting plainly written in a file and silently
not applied is a genuinely bad thing to debug, because the file says it is on.
## What a profile cannot express
Carried forward from `spr/def/prompts/lucid` §5, because they are the honest
limits of the approach rather than bugs to be fixed later:
- **rounding is lossy.** Lucid's `style.rounding` is a scalar in points; DOT's
`style="rounded"` is a single fixed radius with no parameter. A profile gets a
boolean, and a diagram that leans on a specific radius will not match.
- **splines=ortho is mediocre.** It overlaps edges, ignores some port
constraints, and draws square corners where Lucid draws rounded elbows.
`render.round_corners` softens the corners afterwards; the overlaps stay.
- **layout is not styling.** Lucid output is hand-arranged and `dot` is
rank-based. The styling will match long before the composition does, and no
profile key will close that — `rank=same` and invisible edges are the escape
hatch, and they belong to the model, not here.
"""
import json
from pathlib import Path
HERE = Path(__file__).resolve().parent
PROFILE_DIR = HERE / "profiles"
GRAPH_KEYS = (
"bgcolor", "rankdir", "splines", "nodesep", "ranksep", "pad",
"fontname", "fontsize", "fontcolor",
)
NODE_KEYS = (
"fill", "stroke", "penwidth", "fontname", "fontsize", "fontcolor",
"rounded", "margin", "height", "shape",
)
EDGE_KEYS = (
"stroke", "penwidth", "arrowhead", "arrowsize",
"fontname", "fontsize", "fontcolor",
)
GROUP_KEYS = ("fill", "stroke", "fontcolor", "rounded")
CLASS_KEYS = ("stroke", "fill", "fontcolor")
SECTIONS = {
"graph": GRAPH_KEYS,
"node": NODE_KEYS,
"edge": EDGE_KEYS,
"group": GROUP_KEYS,
}
class ProfileError(ValueError):
"""A profile that says something this cannot apply."""
class Profile:
"""A loaded style profile. Read-only in practice; nothing mutates one."""
def __init__(self, data: dict, name: str = "<inline>"):
self.name = name
self.data = data
problems = self.validate()
if problems:
raise ProfileError(f"{name}:\n " + "\n ".join(problems))
# -- loading ----------------------------------------------------------
@classmethod
def load(cls, ref: str | Path) -> "Profile":
"""A shipped profile by name, or any JSON file by path.
`Profile.load("lucid")` and `Profile.load("/tmp/extracted.json")` both
work, so a caller that got a profile name from a config does not have to
care which of the two it is.
"""
path = Path(ref)
if not path.suffix and not path.exists():
path = PROFILE_DIR / f"{ref}.json"
if not path.exists():
available = sorted(p.stem for p in PROFILE_DIR.glob("*.json"))
raise ProfileError(
f"no profile {str(ref)!r} — shipped profiles: {', '.join(available)}"
f"\n(a path to any JSON file works too)"
)
return cls(json.loads(path.read_text()), name=path.stem)
@classmethod
def available(cls) -> list[str]:
return sorted(p.stem for p in PROFILE_DIR.glob("*.json"))
# -- checking ---------------------------------------------------------
def validate(self) -> list[str]:
problems = []
for section, allowed in SECTIONS.items():
block = self.data.get(section, {})
if not isinstance(block, dict):
problems.append(f"{section!r} must be an object")
continue
for key in block:
if key not in allowed:
problems.append(
f"{section}.{key!r} is not a style key "
f"(allowed: {', '.join(allowed)})"
)
classes = self.data.get("classes", {})
if not isinstance(classes, dict):
problems.append("'classes' must be an object")
else:
for cname, cvals in classes.items():
if not isinstance(cvals, dict):
problems.append(f"classes.{cname!r} must be an object")
continue
for key in cvals:
if key not in CLASS_KEYS:
problems.append(
f"classes.{cname}.{key!r} is not a class key "
f"(allowed: {', '.join(CLASS_KEYS)})"
)
for key in self.data:
if key not in SECTIONS and key not in ("classes", "name", "note"):
problems.append(f"{key!r} is not a profile section")
return problems
# -- reading ----------------------------------------------------------
def section(self, name: str) -> dict:
return dict(self.data.get(name, {}))
def cls(self, name: str | None) -> dict:
"""A class's overrides. Unknown or absent means neutral, not an error."""
if not name:
return {}
return dict(self.data.get("classes", {}).get(name, {}))
def colours(self) -> set[str]:
"""Every colour-looking value in the profile.
Used by the selftest to prove none of them appears in the source. That
check is the one keeping "the profile is data" true over time.
"""
found = set()
def walk(obj):
if isinstance(obj, dict):
for v in obj.values():
walk(v)
elif isinstance(obj, str) and obj.startswith("#"):
found.add(obj.lower())
walk(self.data)
return found

View File

@@ -1,56 +0,0 @@
{
"name": "default",
"note": "The docs-site look: dark canvas, curved edges, Helvetica. Here so the package renders something sensible with no profile chosen, and so the selftest has two genuinely different profiles to prove one model renders in both. Matches the palette family the demos' hand-written .dot files already use.",
"graph": {
"bgcolor": "#0a0e17",
"rankdir": "TB",
"splines": "spline",
"nodesep": "0.45",
"ranksep": "0.6",
"pad": "0.3",
"fontname": "Helvetica",
"fontsize": "14",
"fontcolor": "#e8eaf0"
},
"node": {
"shape": "box",
"fill": "#131a2a",
"stroke": "#1e2a4a",
"penwidth": "1",
"fontname": "Helvetica",
"fontsize": "10",
"fontcolor": "#e8eaf0",
"rounded": true,
"margin": "0.22,0.12",
"height": "0.45"
},
"edge": {
"stroke": "#4a5568",
"penwidth": "1",
"arrowhead": "normal",
"arrowsize": "0.7",
"fontname": "Helvetica",
"fontsize": "9",
"fontcolor": "#8892a8"
},
"group": {
"fill": "#0d1320",
"stroke": "#1e2a4a",
"fontcolor": "#8892a8",
"rounded": true
},
"classes": {
"accent": {"stroke": "#0066ff", "fontcolor": "#e8eaf0"},
"accent-text": {"fontcolor": "#ffb020"},
"ok": {"fontcolor": "#00c853"},
"artery": {"stroke": "#e05c4a", "fontcolor": "#e8eaf0"},
"atlas": {"stroke": "#2fbf6b", "fontcolor": "#e8eaf0"},
"station": {"stroke": "#5b8cff", "fontcolor": "#e8eaf0"},
"muted": {"fill": "#0d1320", "fontcolor": "#8892a8"}
}
}

View File

@@ -1,56 +0,0 @@
{
"name": "lucid",
"note": "Shaped after a lucid.app export so the SVG imports cleanly into Lucid and Google Drawings. A plausible default, not the real house style: the real values come from running style/extract.py over a folder of company diagrams, and drop in here as a file with no code change. Values derived from spr/docs/graphs/themes/lucid.gvpr. Arial is deliberate — it is on every Windows box and fontconfig aliases it to Liberation Sans on Linux, so the SVG measures the same on both and text does not reflow out of its box.",
"graph": {
"bgcolor": "#ffffff",
"rankdir": "TB",
"splines": "ortho",
"nodesep": "0.55",
"ranksep": "0.7",
"pad": "0.3",
"fontname": "Arial",
"fontsize": "14",
"fontcolor": "#1f2933"
},
"node": {
"shape": "box",
"fill": "#ffffff",
"stroke": "#9aa5b1",
"penwidth": "1",
"fontname": "Arial",
"fontsize": "10",
"fontcolor": "#1f2933",
"rounded": true,
"margin": "0.25,0.14",
"height": "0.5"
},
"edge": {
"stroke": "#9aa5b1",
"penwidth": "1",
"arrowhead": "normal",
"arrowsize": "0.7",
"fontname": "Arial",
"fontsize": "9",
"fontcolor": "#616e7c"
},
"group": {
"fill": "#f5f7fa",
"stroke": "#cbd2d9",
"fontcolor": "#616e7c",
"rounded": true
},
"classes": {
"accent": {"stroke": "#3a7dff", "fill": "#d6e4ff"},
"accent-text": {"fontcolor": "#3a7dff"},
"ok": {"stroke": "#1a7f45", "fill": "#e6f5ec"},
"artery": {"stroke": "#c0392b", "fill": "#fdeaea"},
"atlas": {"stroke": "#1a7f45", "fill": "#e6f5ec"},
"station": {"stroke": "#2b5fd9", "fill": "#e8effd"},
"muted": {"fill": "#f5f7fa", "fontcolor": "#616e7c"}
}
}

View File

@@ -1,107 +0,0 @@
"""
DOT in, SVG out. One `subprocess` call and one optional post-pass.
Graphviz is a binary, not a library — no `pygraphviz`, no `pydot`. The binary is
what `spr/docs/graphs/render.sh` already shells out to, it is what is installed
on the render hosts, and a Python wrapper around it would add a build dependency
to gain nothing.
When it is missing, say the install line rather than surfacing a FileNotFoundError
from twelve frames down. `render.sh` gets that right and it is worth copying.
"""
import shutil
import subprocess
class RenderError(RuntimeError):
"""Graphviz is absent, or refused the graph."""
def have_graphviz() -> bool:
return shutil.which("dot") is not None
def render(dot_text: str, fmt: str = "svg", engine: str = "dot",
round_corners: bool = False, radius: float = 6.0) -> bytes:
"""Render DOT. Returns the bytes; the caller decides where they go."""
if shutil.which(engine) is None:
raise RenderError(
f"{engine!r} not found — install with: sudo apt install graphviz\n"
"(already-rendered files keep working; this is only needed to re-render)"
)
proc = subprocess.run(
[engine, f"-T{fmt}"],
input=dot_text.encode(),
capture_output=True,
)
if proc.returncode != 0:
raise RenderError(
f"{engine} -T{fmt} failed ({proc.returncode}):\n"
+ proc.stderr.decode("utf-8", "replace").strip()
)
# Graphviz warns on stderr and still renders — a font it could not find, a
# style it ignored. Worth seeing, not worth failing on.
if proc.stderr.strip():
for line in proc.stderr.decode("utf-8", "replace").strip().splitlines():
print(f" graphviz: {line}")
out = proc.stdout
if round_corners and fmt == "svg":
out = soften_corners(out, radius)
return out
def soften_corners(svg: bytes, radius: float = 6.0) -> bytes:
"""Round the square corners `splines=ortho` leaves behind.
From `spr/def/prompts/lucid` §5: ortho routing is half of Lucid's visual
signature, but Graphviz draws the elbows square where Lucid draws them
rounded. This is the cheap fix — path-token surgery on the output, no
involvement in layout.
Each interior corner of a polyline becomes two points pulled back along the
incoming and outgoing segments with a quadratic through the original corner.
Deliberately conservative: a corner whose segments are too short to pull back
from is left alone, because a radius larger than the segment produces a loop,
and a slightly square corner is a much smaller problem than a knot.
Off by default. It rewrites geometry, and geometry is the one thing here that
is not styling.
"""
import re
def round_path(match: re.Match) -> str:
d = match.group(1)
# Only the pure polylines ortho produces: M then a run of L. Anything
# with a curve in it already is left exactly as it is.
if "C" in d or "Q" in d or "A" in d:
return match.group(0)
tokens = re.findall(r"[ML]\s*(-?[\d.]+),(-?[\d.]+)", d)
if len(tokens) < 3:
return match.group(0)
pts = [(float(x), float(y)) for x, y in tokens]
out = [f"M{pts[0][0]:.2f},{pts[0][1]:.2f}"]
for i in range(1, len(pts) - 1):
prev, cur, nxt = pts[i - 1], pts[i], pts[i + 1]
in_len = ((cur[0] - prev[0]) ** 2 + (cur[1] - prev[1]) ** 2) ** 0.5
out_len = ((nxt[0] - cur[0]) ** 2 + (nxt[1] - cur[1]) ** 2) ** 0.5
r = min(radius, in_len / 2, out_len / 2)
if r < 1:
out.append(f"L{cur[0]:.2f},{cur[1]:.2f}")
continue
a = (cur[0] - (cur[0] - prev[0]) / in_len * r,
cur[1] - (cur[1] - prev[1]) / in_len * r)
b = (cur[0] + (nxt[0] - cur[0]) / out_len * r,
cur[1] + (nxt[1] - cur[1]) / out_len * r)
out.append(f"L{a[0]:.2f},{a[1]:.2f}")
out.append(f"Q{cur[0]:.2f},{cur[1]:.2f} {b[0]:.2f},{b[1]:.2f}")
out.append(f"L{pts[-1][0]:.2f},{pts[-1][1]:.2f}")
return f'd="{" ".join(out)}"'
text = svg.decode("utf-8", "replace")
text = re.sub(r'd="([^"]+)"', round_path, text)
return text.encode()

View File

@@ -1,585 +0,0 @@
"""
Prove the whole thing, offline, on fixtures it builds itself.
python3 selftest.py # or: make check
No repo to point at, nothing installed, no network. The graph steps skip with a
message when graphviz is absent rather than failing — a machine without `dot`
can still check the emit layer, and saying so is more useful than a red mark
about the host.
**A check that only ever proves things work is not worth running.**
`histgen/selftest.py` records where that lesson came from: a guard that returned
None on exactly the trees it was written for and reported success anyway. So the
negative cases are here too — a profile with an unknown key must be refused, a
foreign object must be rejected by name, and the three rules that are the whole
point of the design are asserted directly:
the profile is data no profile colour appears in any .py file
text is never read no fixture label appears in the token output
the seam holds graphgen's Graph still satisfies shape.py
Those three are the ones to keep if anything ever gets cut. Everything else is a
regression test; those three are the design.
**What is not here:** whether a graph is well-formed. `validate()`, dangling
edges and duplicate nodes belong to the model, which lives in `graphgen` —
`graphgen/selftest.py` tests them. This file tests drawing, and it builds its
own throwaway graphs to do it, which is also how the duck-typed contract gets
exercised without importing anything.
"""
import ast
import json
import re
import sys
import tempfile
from pathlib import Path
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE.parent))
PKG = HERE.name
docgen = __import__(PKG, fromlist=["*"])
shape_mod = __import__(f"{PKG}.shape", fromlist=["*"])
style_mod = __import__(f"{PKG}.style", fromlist=["*"])
export_mod = __import__(f"{PKG}.export", fromlist=["*"])
vanilla_spec = __import__(f"{PKG}.export.specs.vanilla", fromlist=["*"])
Profile, ProfileError = docgen.Profile, docgen.ProfileError
emit, render, have_graphviz = docgen.emit, docgen.render, docgen.have_graphviz
# --- a graph, built here ------------------------------------------------
#
# docgen ships no graph class on purpose — two graph models is what the split
# exists to prevent. So the fixture is built from throwaway objects that satisfy
# `shape.py`, which means every run of this file is also a test that the
# documented contract is sufficient to render from. If a required attribute were
# ever added to dot.py without being added to shape.REQUIRED, this breaks.
class _Obj:
"""Whatever it is handed, as attributes. Deliberately not a graph class."""
def __init__(self, **kw):
self.__dict__.update(kw)
def _node(id, label="", cls=None, shape=None, style=None):
return _Obj(id=id, label=label or id, cls=cls, shape=shape, style=style)
def _edge(src, dst, label="", cls=None, style=None, arrowhead=None):
return _Obj(src=src, dst=dst, label=label, cls=cls, style=style, arrowhead=arrowhead)
def _group(id, label="", cls=None, style=None, nodes=()):
return _Obj(id=id, label=label, cls=cls, style=style, nodes=list(nodes))
def _graph(name="selftest", title="", rankdir="TB", nodes=(), edges=(), groups=()):
g = _Obj(name=name, title=title, rankdir=rankdir,
nodes=list(nodes), edges=list(edges), groups=list(groups))
g.grouped = lambda: {n for grp in g.groups for n in grp.nodes}
g.validate = lambda: []
return g
PASS, FAIL, SKIP = [], [], []
# A label that could not plausibly be a style value, so finding it in the token
# output means text was read rather than coincidence.
SECRET_LABEL = "Confidential Quarterly Revenue Ledger"
def check(name, condition, detail=""):
(PASS if condition else FAIL).append(name)
mark = "ok " if condition else "FAIL"
print(f" {mark} {name}" + (f"\n {detail}" if detail and not condition else ""))
return condition
def skip(name, why):
SKIP.append(name)
print(f" -- {name} ({why})")
def fixture():
"""One graph with every shape that has ever been got wrong."""
return _graph(
name="selftest", title=SECRET_LABEL, rankdir="LR",
nodes=[
_node("api", "API", cls="station"),
_node("store", SECRET_LABEL, shape="cylinder"), # shape is meaning
_node("plain", "Plain"), # untagged -> neutral
_node("spacer", style="invis"), # scaffolding
],
edges=[
_edge("api", "store", "writes"),
_edge("api", "plain", "weakly", style="dashed"), # composes, never replaces
_edge("spacer", "api", style="invis"),
],
groups=[_group("core", "Core", nodes=["api", "store"])],
)
# --------------------------------------------------------------------------
print("\n1. the contract with whatever owns the graph")
g = fixture()
missing = shape_mod.missing
check("the fixture satisfies GraphLike", missing(g, "graph") == [], str(missing(g, "graph")))
check("its nodes satisfy NodeLike", all(not missing(n, "node") for n in g.nodes))
check("its edges satisfy EdgeLike", all(not missing(e, "edge") for e in g.edges))
check("its groups satisfy GroupLike", all(not missing(gr, "group") for gr in g.groups))
# The point of naming the contract: a foreign object is refused by name, not by
# an AttributeError four frames down.
try:
emit(object(), Profile.load("lucid"))
check("a foreign object is refused by name", False, "it tried to draw one")
except TypeError as e:
check("a foreign object is refused by name",
"shape.py" in str(e) and "nodes" in str(e), str(e))
except AttributeError:
check("a foreign object is refused by name", False,
"AttributeError — emit() is not checking the shape first")
check(
"docgen ships no graph class",
not any(hasattr(docgen, n) for n in ("Graph", "Node", "Edge", "Group")),
"two graph models is the thing this split exists to prevent",
)
# Naming graphgen in a docstring is correct — shape.py exists to document the
# relationship. What must not exist is an import, which is what would make the
# folder undeployable on its own.
leaked_import = []
for src in sorted(HERE.rglob("*.py")):
if src.name in ("selftest.py", "demo.py"):
continue # scaffolding may import graphgen; the library may not
# Parsed, not grepped. A docstring showing `from graphgen import Graph` as a
# usage example is documentation and must not trip this; only a real import
# counts. ast is the difference between the two.
tree = ast.parse(src.read_text(), str(src))
for node in ast.walk(tree):
if isinstance(node, ast.Import):
names = [a.name for a in node.names]
elif isinstance(node, ast.ImportFrom):
names = [node.module or ""]
else:
continue
if any(n.split(".")[0] == "graphgen" for n in names):
leaked_import.append(f"{src.relative_to(HERE)}:{node.lineno}")
check(
"the library never imports graphgen",
not leaked_import,
"; ".join(leaked_import) + " <- docgen is supposed to stand alone",
)
# --------------------------------------------------------------------------
print("\n2. graph -> DOT")
lucid = Profile.load("lucid")
default = Profile.load("default")
dot_text = emit(g, lucid)
check("profile stroke reaches the DOT", lucid.section("node")["stroke"] in dot_text)
check("classed node takes its class fill", lucid.cls("station")["fill"] in dot_text)
check("shape survives the profile", 'shape="cylinder"' in dot_text)
check(
"invisible node stays invisible",
re.search(r'spacer \[style="invis"', dot_text) is not None,
"the profile filled in layout scaffolding",
)
check(
"dashed composes rather than replaces",
"dashed" in dot_text and 'style="dashed"' in dot_text,
)
check(
"ortho graphs use xlabel so labels survive",
'xlabel="writes"' in dot_text,
"splines=ortho drops `label` silently — see dot.py",
)
check(
"non-ortho graphs keep plain label",
'label="writes"' in emit(g, default) and "xlabel" not in emit(g, default),
)
# emit() must refuse a graph whose own validate() objects, whoever wrote it.
broken = fixture()
broken.validate = lambda: ["edge a->ghost names unknown node 'ghost'"]
try:
emit(broken, lucid)
check("emit refuses a graph its owner calls broken", False, "it emitted one anyway")
except ValueError as e:
check("emit refuses a graph its owner calls broken", "ghost" in str(e))
# --------------------------------------------------------------------------
print("\n3. the profile is data")
try:
Profile({"node": {"colour": "#fff"}}, "typo")
check("an unknown profile key is refused", False, "it was accepted")
except ProfileError:
check("an unknown profile key is refused", True)
try:
Profile.load("no-such-profile")
check("a missing profile names the ones that exist", False)
except ProfileError as e:
check("a missing profile names the ones that exist", "lucid" in str(e))
# The check this design exists for. If a colour from a profile turns up in the
# source, the profile has stopped being the only place style lives.
# The emit layer only. `style/` is excluded deliberately and the reason is
# worth writing down, because an unexplained exclusion is how a check gets
# watered down later: tokens.py has to answer "what if the diagrams yielded no
# fill at all", and that answer is a literal by necessity. It derives profiles;
# it does not apply them. The rule being guarded here is that *applying* style
# reads from a profile and nowhere else.
sources = sorted(HERE.glob("*.py"))
leaked = []
for colour in lucid.colours() | default.colours():
for src in sources:
if colour in src.read_text().lower():
leaked.append(f"{colour} in {src.relative_to(HERE)}")
check(
"no profile colour appears in the source",
not leaked,
"; ".join(leaked) + " <- the profile is no longer the only place style lives",
)
check(
"the two profiles genuinely differ",
lucid.section("node")["fill"] != default.section("node")["fill"],
"the 'renders in any profile' claim is untested if they are the same",
)
# --------------------------------------------------------------------------
print("\n4. DOT -> SVG")
svg_lucid = svg_default = None
if not have_graphviz():
skip("render", "graphviz not installed — sudo apt install graphviz")
else:
svg_lucid = render(dot_text)
svg_default = render(emit(g, default))
check("SVG is produced", svg_lucid.startswith(b"<?xml") and b"</svg>" in svg_lucid)
check(
"the profile's stroke is in the SVG",
lucid.section("node")["stroke"].encode() in svg_lucid,
)
check(
"one model, two looks",
svg_lucid != svg_default
and default.section("node")["fill"].encode() in svg_default
and default.section("node")["fill"].encode() not in svg_lucid,
)
check("edge labels survive ortho", b"writes" in svg_lucid)
softened = docgen.soften_corners(svg_lucid)
check(
"corner softening produces curves and keeps the SVG whole",
b"Q" in softened and b"</svg>" in softened,
)
# --------------------------------------------------------------------------
print("\n5. SVG -> tokens -> profile")
if svg_lucid is None:
skip("extraction round trip", "no SVG to read back")
else:
try:
with tempfile.TemporaryDirectory(prefix="docgen-selftest-") as tmp:
tmp = Path(tmp)
(tmp / "one.svg").write_bytes(svg_lucid)
data = style_mod.harvest(tmp)
blob = json.dumps(data)
check("the export was read", data["files_read"] == 1 and not data["files_failed"])
fills = [e["value"] for e in data["tokens"]["fill"]]
strokes = [e["value"] for e in data["tokens"]["stroke"]]
check(
"the stroke it was rendered with comes back",
lucid.section("node")["stroke"] in strokes,
f"got {strokes[:5]}",
)
check(
"the fill it was rendered with comes back",
lucid.section("node")["fill"] in fills,
f"got {fills[:5]}",
)
check(
"the font it was rendered with comes back",
lucid.section("node")["fontname"]
in [e["value"] for e in data["tokens"]["font-family"]],
)
# The other rule the design rests on.
check(
"no text content was read",
SECRET_LABEL not in blob and "Confidential" not in blob,
"a diagram label reached the token output — extract.py read text",
)
derived = style_mod.derive(data, "roundtrip")
Profile(derived, "roundtrip") # must validate as a real profile
check("the derived profile is a valid profile", True)
check(
"the derived profile recovers the stroke",
derived["node"]["stroke"] == lucid.section("node")["stroke"],
f"got {derived['node']['stroke']}",
)
check(
"the derived profile carries no invented classes",
derived["classes"] == {},
"a class is a meaning; frequency counting cannot recover one",
)
except RuntimeError as e:
skip("extraction round trip", str(e).splitlines()[0])
# --------------------------------------------------------------------------
print("\n6. notebooks")
doc = vanilla_spec.build()
books = {v: export_mod.build(doc, v) for v in export_mod.VARIANTS}
for variant, nb in books.items():
ok = (
nb["nbformat"] == 4
and nb["nbformat_minor"] == 5
and all(c.get("cell_type") and c.get("id") and "source" in c for c in nb["cells"])
)
check(f"{variant}: valid nbformat 4", ok)
ids = [c["id"] for c in books["vanilla"]["cells"]]
check("cell ids are unique", len(ids) == len(set(ids)))
code_cells = [c for c in books["vanilla"]["cells"] if c["cell_type"] == "code"]
check(
"vanilla has run nothing",
all(c["outputs"] == [] and c["execution_count"] is None for c in code_cells),
"vanilla is the variant that reads as a document",
)
check(
"live carries more than vanilla",
len(books["live"]["cells"]) > len(books["vanilla"]["cells"]),
)
check(
"vanilla and executable are the same content",
books["vanilla"]["cells"] == books["executable"]["cells"],
"they are one document emitted twice; differing means they have drifted",
)
bad_syntax = []
for c in code_cells:
try:
compile("".join(c["source"]), c["id"], "exec")
except SyntaxError as e:
bad_syntax.append(f"{c['id']}: {e}")
check("every code cell compiles", not bad_syntax, "; ".join(bad_syntax))
source_text = "".join("".join(c["source"]) for c in books["vanilla"]["cells"])
check(
"the placeholders are marked",
source_text.count("FILL") >= 8,
f"only {source_text.count('FILL')} FILL markers — the fill-in list is the deliverable",
)
check(
"no credential is written into the notebook",
"Bearer sk-" not in source_text and "password" not in source_text.lower(),
)
check(
"the notebook needs nothing installed",
"pip install" not in source_text and "import requests" not in source_text,
"a dependency is a step before the first step, and it fails behind a proxy",
)
check(
"re-emitting gives identical bytes",
json.dumps(export_mod.build(doc, "vanilla"), sort_keys=True)
== json.dumps(books["vanilla"], sort_keys=True),
"a notebook that changes every build cannot be reviewed",
)
# --------------------------------------------------------------------------
print("\n7. the notebook, actually run")
# The strongest thing that can be said about a notebook nobody can execute
# against the real service: run every cell against a throwaway server on
# loopback and see that nothing raises. It catches what compiling cannot — a
# wrong argument name in `call`, a header that never gets sent, a response the
# printer chokes on.
#
# 127.0.0.1 on an ephemeral port. Nothing leaves the machine, and the server is
# gone before this returns.
import http.server
import json as _json
import os
import socketserver
import threading
class _Handler(http.server.BaseHTTPRequestHandler):
def log_message(self, *a):
pass
def _send(self, code, body):
raw = _json.dumps(body).encode()
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(raw)))
self.end_headers()
self.wfile.write(raw)
def do_GET(self):
if self.path.startswith("/health"):
return self._send(200, {"status": "ok"})
if self.path.startswith("/resource"):
limit = 10
if "limit=" in self.path:
limit = int(self.path.split("limit=")[1].split("&")[0])
return self._send(200, [{"id": i} for i in range(limit)])
# A 404 the client must return rather than raise — the error body is
# the useful part, and this is the cell that proves it.
self._send(404, {"error": "no such path", "path": self.path})
def do_POST(self):
n = int(self.headers.get("Content-Length", 0))
body = _json.loads(self.rfile.read(n) or b"{}")
if not self.headers.get("Authorization"):
return self._send(401, {"error": "missing Authorization header"})
self._send(201, {"created": True, "echo": body})
server = socketserver.TCPServer(("127.0.0.1", 0), _Handler)
threading.Thread(target=server.serve_forever, daemon=True).start()
port = server.server_address[1]
previous_token = os.environ.get("API_TOKEN")
os.environ["API_TOKEN"] = "selftest-token"
try:
import io
from contextlib import redirect_stdout
ns, ran, error = {}, 0, None
buffer = io.StringIO()
with redirect_stdout(buffer):
for c in books["live"]["cells"]:
if c["cell_type"] != "code":
continue
src = "".join(c["source"]).replace(
f'BASE_URL = "{vanilla_spec.BASE_URL}"',
f'BASE_URL = "http://127.0.0.1:{port}"',
)
try:
exec(compile(src, c["id"], "exec"), ns)
ran += 1
except Exception as e: # noqa: BLE001 - any failure is the finding
error = f"{c['id']}: {type(e).__name__}: {e}"
break
printed = buffer.getvalue()
check(f"every cell runs against a live service ({ran} cells)", error is None, error or "")
check("the auth header is actually sent", "201" in printed, "the POST came back 401")
check(
"changing a parameter changes the result",
"size 10" in printed and "size 50" in printed,
"the update-and-recall section did not vary anything",
)
# The client's own two promises: a non-2xx comes back as a result to read,
# and an unreachable host is reported rather than thrown.
with redirect_stdout(io.StringIO()):
status, _, body = ns["call"]("GET", "/definitely-not-there")
ns["BASE_URL"] = "http://127.0.0.1:1" # nothing listens on port 1
unreachable = ns["call"]("GET", "/x")
check(
"a 404 is returned, not raised",
status == 404 and isinstance(body, dict) and "error" in body,
f"got {status} / {body!r} — the error body is the useful part",
)
check("an unreachable host does not raise", unreachable[0] is None)
finally:
server.shutdown()
server.server_close()
if previous_token is None:
os.environ.pop("API_TOKEN", None)
else:
os.environ["API_TOKEN"] = previous_token
# --------------------------------------------------------------------------
print("\n8. the seam — graphgen's real Graph, if it is next door")
# Everything above proves docgen draws *the documented shape*. This proves the
# shape is still what graphgen actually produces. Without it the two drift apart
# silently, and drift is the standing cost of having chosen duck-typing over an
# import.
#
# Skipped rather than failed when graphgen is absent: docgen standing alone is a
# feature, and a machine with only this folder is a supported situation.
try:
sys.path.insert(0, str(HERE.parent))
import graphgen
from graphgen.examples import system_overview
except ImportError as e:
skip("graphgen integration", f"not beside this folder ({e})")
else:
real = graphgen.Graph("seam", title="Seam", rankdir="LR")
real.node("api", "API", cls="station")
real.node("db", "Database", shape="cylinder")
real.node("spacer", style="invis")
real.edge("api", "db", "reads")
real.edge("api", "db", "weakly", style="dashed")
real.group("core", "Core", ["api", "db"])
check("graphgen.Graph satisfies GraphLike", missing(real, "graph") == [],
str(missing(real, "graph")))
check("graphgen.Node satisfies NodeLike",
all(not missing(n, "node") for n in real.nodes),
str([missing(n, "node") for n in real.nodes]))
check("graphgen.Edge satisfies EdgeLike",
all(not missing(e, "edge") for e in real.edges))
check("graphgen.Group satisfies GroupLike",
all(not missing(gr, "group") for gr in real.groups))
real_dot = emit(real, lucid)
check("it emits", real_dot.startswith("digraph seam {"))
check("its shape survives", 'shape="cylinder"' in real_dot)
check("its scaffolding stays invisible", 'spacer [style="invis"' in real_dot)
check("its dashed edge composes", 'style="dashed"' in real_dot)
# The two class vocabularies must be the same words, or a graph tagged in
# graphgen renders neutral in docgen and nobody is told why.
profile_classes = set(lucid.data.get("classes", {}))
check(
"the class vocabularies agree",
set(graphgen.CLASSES) == profile_classes,
f"graphgen has {sorted(set(graphgen.CLASSES) - profile_classes)} that no profile "
f"styles; profiles have {sorted(profile_classes - set(graphgen.CLASSES))} "
"that no graph can ask for",
)
# The worked example is the closest thing to a real diagram, so it is the
# best end-to-end check that the seam carries a whole graph and not just the
# three attributes a small fixture happens to use.
if have_graphviz():
overview_svg = render(emit(system_overview(), lucid))
check("the worked example renders end to end",
overview_svg.startswith(b"<?xml") and b"</svg>" in overview_svg)
else:
skip("the worked example renders end to end", "graphviz not installed")
# --------------------------------------------------------------------------
print()
print(f"{len(PASS)} passed, {len(FAIL)} failed, {len(SKIP)} skipped")
if FAIL:
print("\nfailed:")
for name in FAIL:
print(f" {name}")
sys.exit(1 if FAIL else 0)

View File

@@ -1,131 +0,0 @@
"""
The shape of a graph, as docgen needs to read it.
docgen does not import `graphgen`. It reads a graph **structurally** — anything
with these attributes will render — so either folder can be copied out and used
without the other on the path.
That choice buys independence and costs an explicit contract. This module is the
contract, paid back: every attribute `dot.py` reads, what it means, and what
happens when it is absent. Without this file the coupling is real but written
down nowhere, which is the worst of both.
graphgen.Graph satisfies GraphLike.
So does anything else you build with the same attribute names.
## Protocols only — nothing here can be instantiated
There is deliberately no `Graph` class in docgen. If docgen shipped a usable one,
people would use it, and there would be two graph models — which is the exact
thing this split exists to prevent. `graphgen` owns the only concrete
implementation.
These are `typing.Protocol`, so they are structural: a type-checker verifies a
caller's object without either package importing the other, and at runtime they
cost nothing. `runtime_checkable` is deliberately **not** used —
`isinstance()` against a Protocol only checks attribute *presence*, so it would
report success for an object whose `nodes` is an int. A check that cannot fail
usefully is worse than no check.
## Everything optional degrades to "neutral", never to an error
A node with no `cls` gets the profile's plain treatment. An edge with no `style`
is a plain edge. This matters: it is what lets a graph built for one purpose be
rendered by a profile that has never heard of its classes.
"""
from typing import Protocol
class NodeLike(Protocol):
"""One box.
`shape` and `style` are **structure, not styling**, and `dot.py` will not let
a profile override them: a cylinder is a datastore, and `style="invis"` is a
spacer holding a rank open.
"""
id: str # unique within the graph; becomes the DOT identifier
label: str # what is drawn in the box
cls: str | None # a class name the profile may have an opinion about
shape: str | None # DOT shape. None means the profile's default
style: str | None # "invis" is honoured exactly; other words compose
class EdgeLike(Protocol):
"""One arrow.
`style="dashed"` means a weaker relationship. The profile *composes* with it
(`filled,rounded,dashed`) rather than replacing it.
"""
src: str # a node id
dst: str # a node id
label: str # "" for none. Under splines=ortho this becomes an
# xlabel, because ortho drops `label` silently
cls: str | None
style: str | None
arrowhead: str | None # a statement about the relationship, not the look,
# so it lives here and not in the profile
class GroupLike(Protocol):
"""A container — Lucid's grouping box, DOT's `cluster_*`."""
id: str
label: str
cls: str | None
style: str | None
nodes: list[str] # node **ids**, not objects, so grouping can be
# rearranged without touching the nodes
class GraphLike(Protocol):
"""The whole thing.
`rankdir` and `title` are content — which way the diagram reads, and what it
is called — so they belong to the graph. The profile only supplies a
fallback for a graph that did not say.
"""
name: str
title: str
rankdir: str
nodes: list[NodeLike]
edges: list[EdgeLike]
groups: list[GroupLike]
def validate(self) -> list[str]:
"""Every problem with this graph, or an empty list.
A list rather than an exception, and *every* problem rather than the
first: a graph with four dangling edges should report four. `emit()`
calls this and refuses to draw a graph that will not say what it means.
"""
...
def grouped(self) -> set[str]:
"""The ids that belong to some group, so emit knows what is left over."""
...
#: What `emit()` requires. Named so an error message can point at one thing.
REQUIRED = {
"graph": ("name", "title", "rankdir", "nodes", "edges", "groups",
"validate", "grouped"),
"node": ("id", "label", "cls", "shape", "style"),
"edge": ("src", "dst", "label", "cls", "style", "arrowhead"),
"group": ("id", "label", "cls", "style", "nodes"),
}
def missing(obj, kind: str) -> list[str]:
"""Which required attributes `obj` lacks. Empty means it fits.
Used by `emit()` to fail with "your graph has no .groups" rather than an
AttributeError from four frames down, and by the selftest to check that
graphgen's real classes still satisfy this.
"""
if kind not in REQUIRED:
raise ValueError(f"unknown kind {kind!r} — one of {', '.join(REQUIRED)}")
return [a for a in REQUIRED[kind] if not hasattr(obj, a)]

View File

@@ -1,7 +0,0 @@
"""Style extraction: a folder of exported diagrams -> a style profile. Offline, never reads text content."""
from . import extract, tokens
from .extract import harvest, summarise
from .tokens import derive, from_folder
__all__ = ["extract", "tokens", "harvest", "summarise", "derive", "from_folder"]

View File

@@ -1,161 +0,0 @@
"""
A token vocabulary -> a style profile.
`extract.py` says what values a folder of diagrams uses and how often.
This decides which of them are the house style. The rule is the one
`spr/def/prompts/lucid` §2a states outright:
the top two or three fills *are* the palette
the modal stroke width *is* the house line weight
Nothing cleverer. Frequency is a good enough signal because a real diagram set
repeats its own vocabulary constantly and its one-offs stay one-offs.
The result is a profile file, which is a file the emit layer already knows how
to read. That is what "the real profile drops in without a code change" means in
practice: extraction produces the same kind of JSON that ships in
`docgen/profiles/`, and nothing downstream can tell the difference.
## The judgement calls, named
Three, all of them the kind that should be visible rather than buried:
- **The lightest frequent fill is the node fill.** Diagrams are mostly nodes on a
canvas, and the canvas is usually the single most common fill of all — taking
the modal fill gives you the background twice and no node colour. So the
background is taken as the most common, and the node fill as the lightest of
the *next* few.
- **Body text is the darkest frequent fill on a `<text>` element**, which in
practice is the most common dark value. Slate rather than `#000` is most of
what reads as "the look" (`prompts/lucid` §3) — but if the source really does
use black, that is what comes out. Extraction reports, it does not improve.
- **Stroke and font sizes take the mode, not the mean.** A mean of 1 and 4 is
2.5, which is a width the diagrams never use.
"""
import json
from pathlib import Path
from ..profile import Profile
def _top(tokens: dict, prop: str, n: int = 8) -> list[str]:
return [e["value"] for e in tokens.get(prop, [])[:n]]
def _mode(tokens: dict, prop: str, fallback: str) -> str:
entries = [e for e in tokens.get(prop, []) if e["value"] not in ("none", "transparent")]
return entries[0]["value"] if entries else fallback
def _luminance(hexv: str) -> float:
"""Rough perceptual lightness, 0..1. Good enough to sort pale from dark."""
if not hexv.startswith("#") or len(hexv) != 7:
return 0.5
r, g, b = (int(hexv[i : i + 2], 16) / 255 for i in (1, 3, 5))
return 0.2126 * r + 0.7152 * g + 0.0722 * b
def derive(data: dict, name: str = "extracted") -> dict:
"""Token JSON -> profile dict. Not a Profile, so it can be inspected first."""
tokens = data.get("tokens", {})
fills = [v for v in _top(tokens, "fill") if v.startswith("#")]
strokes = [v for v in _top(tokens, "stroke") if v.startswith("#")]
background = fills[0] if fills else "#ffffff"
# The canvas is usually the most common fill; the node fill is the palest of
# what is left. Taking the mode twice gives the background and no node.
candidates = fills[1:4] or fills[:1] or ["#ffffff"]
node_fill = max(candidates, key=_luminance)
# The darkest frequent value is the ink. Text nodes are not distinguished
# from shapes here on purpose — doing so would mean selecting on element
# type, and `<text>` is exactly the element this must not lean on.
ink = min(fills, key=_luminance) if fills else "#333333"
stroke = strokes[0] if strokes else "#5a6c86"
penwidth = _mode(tokens, "stroke-width", "1")
fontsize = _mode(tokens, "font-size", "10")
fontname = _mode(tokens, "font-family", "Helvetica")
# rx present at all means rounded corners are the house shape. The scalar is
# dropped, because DOT's `rounded` has no radius — `prompts/lucid` §5.
rounded = bool(tokens.get("rx"))
palette = [f for f in fills[:6] if f not in (background, node_fill)]
return {
"name": name,
"note": (
f"Derived from {data.get('files_read', 0)} diagram(s) in "
f"{data.get('source', 'a target folder')} by docgen/style. "
"Frequency-sorted: top fills are the palette, modal stroke width is the "
"house line weight. Corner radius is dropped — Lucid's `rounding` is a "
"scalar and DOT's `rounded` is binary. No text content was read. "
f"Palette also seen, unassigned: {', '.join(palette) if palette else 'none'}."
),
"graph": {
"bgcolor": background,
"rankdir": "TB",
"splines": "ortho" if rounded else "spline",
"nodesep": "0.55",
"ranksep": "0.7",
"pad": "0.3",
"fontname": fontname,
"fontsize": str(max(int(float(fontsize)) + 4, 12)),
"fontcolor": ink,
},
"node": {
"shape": "box",
"fill": node_fill,
"stroke": stroke,
"penwidth": penwidth,
"fontname": fontname,
"fontsize": fontsize,
"fontcolor": ink,
"rounded": rounded,
"margin": "0.25,0.14",
"height": "0.5",
},
"edge": {
"stroke": stroke,
"penwidth": penwidth,
"arrowhead": "normal",
"arrowsize": "0.7",
"fontname": fontname,
"fontsize": str(max(int(float(fontsize)) - 1, 6)),
"fontcolor": stroke,
},
"group": {
"fill": node_fill,
"stroke": stroke,
"fontcolor": ink,
"rounded": rounded,
},
# Left empty deliberately. A class is a *meaning* — "this is the
# emphasised one" — and no amount of frequency counting recovers which
# colour meant that. They are written by hand on top of what came out
# here, which is the half a machine genuinely cannot do.
"classes": {},
}
def write(data: dict, out: Path | str, name: str = "extracted") -> Path:
"""Derive and write a profile. Validates before writing."""
profile = derive(data, name)
Profile(profile, name=name) # refuses here rather than at first render
out = Path(out)
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(json.dumps(profile, indent=2) + "\n")
return out
def from_folder(folder: Path | str, out_dir: Path | str, name: str = "extracted") -> dict:
"""The whole path: target folder -> tokens.json + <name>.json."""
from . import extract
out_dir = Path(out_dir)
tokens_path = extract.write(folder, out_dir / "tokens.json")
data = json.loads(tokens_path.read_text())
profile_path = write(data, out_dir / f"{name}.json", name)
return {"tokens": tokens_path, "profile": profile_path, "data": data}

View File

@@ -1,81 +1,38 @@
# graphgen # graphgen
What a graph is, and where graphs come from. Interactive database-schema explorer. Supabase-style: a card per model, its
columns listed, foreign keys drawn between them, laid out in columns you can
drag.
Two halves that do not depend on each other:
| | |
|---|---|
| `graph.py` | **the model** — nodes, edges, groups, and the meaning carried on them. No colour, no font, no layout engine. |
| `schema.py` · `api.py` | **the first source** — a `schema.json` or a modelgen `schema/` folder becomes `{models, relationships, source}`, served at `/station/tools/graphgen/api/schema` and drawn by the browser viewer. |
```python
from graphgen import Graph
g = Graph("overview", title="System Overview", rankdir="LR")
g.node("api", "API", cls="station")
g.node("db", "Database", shape="cylinder")
g.edge("api", "db", "reads")
``` ```
/station/tools/graphgen/ the viewer
```bash /station/tools/graphgen/api/schema what it draws
python3 selftest.py # the model, on fixtures it builds. No network, no graphviz
``` ```
## Meaning, not appearance `schema.py` finds a schema and normalises it to one shape:
A node carries `cls="artery"`, never `fillcolor="#c0ffee"`. The class vocabulary is
the one `spr/docs/graphs/README.md` already documents — `accent`, `accent-text`,
`ok`, `artery`, `atlas`, `station`, `muted` — so a graph built here and a `.dot`
written by hand mean the same thing by the same word. Anything untagged renders
neutral, which is where most nodes should stay.
Three fields look decorative and are not. A style profile must never override | source | |
them, which starts with the model keeping them distinct:
| | |
|---|---| |---|---|
| `shape` | a cylinder is a datastore | | `schema.json` | in a `cfg/<room>/soleprint/station/tools/graphgen/` directory |
| `style="invis"` | layout scaffolding — filling it in would draw it | | a modelgen `schema/` folder | Python dataclasses, read through `modelgen.loader` |
| `style="dashed"` | a weaker relationship |
`validate()` returns **every** problem rather than raising on the first: a graph
with four dangling edges should report four, not make you run it four times.
## Drawing one is not this tool's job ## The published contract
`docgen` turns a graph into DOT, SVG and documents, with the styling supplied as
a data profile. The dependency runs one way and only one way — **docgen reads
this model structurally and imports nothing from here**, so either folder works
with the other absent. `docgen/shape.py` is that contract written down, and
docgen's selftest asserts the real `Graph` still satisfies it.
```bash
cd ../docgen && make graph # renders graphgen.examples through every profile
```
## The published schema contract
`{models, relationships, source}` is **not an internal shape**. `modelgen` emits `{models, relationships, source}` is **not an internal shape**. `modelgen` emits
it (`generator/jsonschema.py`), `datagen` exposes it (`base.py::schema`), it (`generator/jsonschema.py`), `datagen` exposes it (`base.py::schema`),
`shuntgen` generates it, and `cfg/amar/.../datagen/amar.py` reads it off disk. `shuntgen` generates it and `cfg/amar` reads it off disk;
`modelgen/tests/test_extractors.py:240,394` assert it. `modelgen/tests/test_extractors.py:240,394` assert it. It does not change to
suit a consumer.
It does not change to suit anything here. The graph model was added *beside* it, ## Static diagrams are docgen's
not over it.
## The other meanings of "graph" This draws a schema *in the browser*, for exploring. For a rendered SVG — an ER
diagram in a document, a minimap, a site — the schema goes to `atlas2/docgen`,
which reads this same contract and emits from it:
Parked, deliberately: Supabase-style schema diagrams, video pipeline processing ```bash
graphs, local computer-vision graphs. Each is another **source** feeding the one python3 -m docgen.extractors db --schema schema.json -o ir.json
model, and they belong here rather than each growing its own drawing code. None python3 -m docgen.emitters erd ir.json -o schema.svg
is built; keeping styling out of the model is the only accommodation they get ```
for now.
## Importing this is cheap
`__init__.py` does not import `api.py`, so pulling in the model does not pull in
FastAPI. `run.py` imports `station.tools.graphgen.api` explicitly when it mounts
the router, and nothing else pays for it.
Connecting to an actual database is **not** here — that is rig-domain work. Two tools, one contract, and neither has to know about the other.

View File

@@ -1,71 +0,0 @@
"""
Worked graphs, so there is something real to draw.
A graph definition is a graph, so it lives here rather than in the exporter.
`docgen` renders these; it does not own them.
`system_overview()` rebuilds soleprint's own architecture — the same structure as
`spr/docs/graphs/system_overview.dot`, which is hand-written DOT with the palette
applied by a `gvpr` theme at render time. Rebuilding it in code is the comparison
worth having: render it under a Lucid-shaped profile and put it beside the
committed `system_overview.lucid.svg`. If they do not read as the same visual
language, the profile is wrong.
**That file is read, never regenerated.** The existing docs are not in scope, and
a sanity check that edits the thing it is checking against is not one.
This is also the honest answer to "does the graph model get built by hand per
demo?" for exactly one demo. It is about forty lines, most of them labels.
"""
from .graph import Graph
def system_overview() -> Graph:
"""Soleprint, one box per system, the way the hand-written source draws it."""
g = Graph("system_overview", title="Soleprint — System Overview", rankdir="TB")
g.node("hub", "soleprint\ncore coordinator\nport 12000", cls="accent")
g.group("core", "Soleprint Hub", ["hub"], cls="accent", style="dashed")
g.node("veins", "Veins\nstateless connectors", cls="artery")
g.node("shunts", "Shunts\nmock connectors", cls="artery")
g.node("pulses", "Pulses\ncomposed flows", cls="artery")
g.group("artery", "Artery — Todo lo vital", ["veins", "shunts", "pulses"],
cls="artery", style="dashed")
g.node("books", "Books\ndocumentation", cls="atlas")
g.node("templates", "Templates\npatterns", cls="atlas")
g.group("atlas", "Atlas — Documentación accionable", ["books", "templates"],
cls="atlas", style="dashed")
g.node("tools", "Tools\ntester · datagen · modelgen", cls="station")
g.node("monitors", "Monitors\ndatabrowse", cls="station")
g.group("station", "Station — Centro de control", ["tools", "monitors"],
cls="station", style="dashed")
g.node("jira", "Jira")
g.node("google", "Google")
g.node("slack", "Slack")
g.group("external", "External APIs", ["jira", "google", "slack"], style="dashed")
g.node("app_fe", "Frontend")
g.node("app_be", "Backend")
g.node("app_db", "Database", shape="cylinder") # a cylinder is a datastore
g.group("managed", "Managed App", ["app_fe", "app_be", "app_db"], style="dashed")
g.edge("hub", "veins", "routes", cls="artery")
g.edge("hub", "books", "routes", cls="atlas")
g.edge("hub", "tools", "routes", cls="station")
g.edge("veins", "jira", "API")
g.edge("veins", "google", "OAuth")
g.edge("veins", "slack", "API")
g.edge("veins", "pulses", "compose")
# dashed is a weaker relationship, and the profile composes with it
g.edge("tools", "app_be", "test", style="dashed")
g.edge("monitors", "app_db", "browse", style="dashed")
g.edge("hub", "app_fe", "sidebar\ninjection", cls="accent", style="dashed")
return g

View File

@@ -1,185 +0,0 @@
"""
A graph, with no opinion about how it looks.
This is what a graph *is*. Nodes, edges, groups, and the meaning carried on
them — nothing about colour, font or layout engine. Drawing one is `docgen`'s
job, and this module has never heard of it.
The whole design rests on one line: **the model carries meaning, the profile
carries the look.** A node says `cls="artery"`, never `fillcolor="#c0ffee"`.
Change the profile and every diagram re-renders; nothing in the model is edited,
because nothing in the model was ever about colour.
## Why this lives in graphgen
graphgen is where graphs come from. It already turns a `schema.json` or a
modelgen `schema/` folder into `{models, relationships, source}` and serves it
at `/station/tools/graphgen/api/schema` — a *source* of graphs, in the
DB-schema sense. The other senses parked for later (video pipeline processing
graphs, local computer-vision graphs) are more sources, and they belong beside
that one rather than inside an exporter.
So: **graphgen owns what a graph is, docgen owns how one is drawn.** The
dependency runs one way and only one way — docgen reads this shape structurally
and imports nothing from here, so neither folder needs the other to be present.
See `docgen/shape.py`, which writes that contract down.
Nothing in this module imports FastAPI, so `import graphgen` stays cheap for a
caller that only wants the model. `api.py` is a separate import on purpose.
The class vocabulary is the one `spr/docs/graphs/README.md` already documents,
so a graph built here and a `.dot` written by hand mean the same thing by the
same word:
accent the emphasised thing
accent-text an emphasised *label*, not a box
ok live, working
artery belongs to that system (and atlas, station)
muted present, but not the point
Anything untagged gets the profile's neutral treatment. That is the default and
most nodes should stay there — a diagram where everything is emphasised has
emphasised nothing.
## What a profile may not touch
Three fields are structure wearing a visual field's clothing, and a profile that
overrode them would be destroying meaning rather than restyling it. Same three
`docs/graphs/README.md` names, for the same reasons:
shape a cylinder is a datastore, not a decoration
invis layout scaffolding — filling it in would draw it
dashed a weaker relationship. The profile *composes* with it
(`filled,rounded,dashed`); it never replaces it
They live on the model, and `dot.py` treats them as untouchable.
"""
from dataclasses import dataclass, field
# Not enforced — an unknown class is a class the profile has no opinion about,
# which renders neutral and is a perfectly reasonable thing to want. Listed so
# the vocabulary has one place to be read, and so `Profile.validate` can warn
# about a profile defining a class no graph uses.
CLASSES = ("accent", "accent-text", "ok", "artery", "atlas", "station", "muted")
@dataclass
class Node:
"""One box.
`shape` and `style` are structural: a cylinder is a datastore, and
`style="invis"` is a spacer holding a rank open. Neither is styling and
neither is overridden at emit time.
"""
id: str
label: str = ""
cls: str | None = None
shape: str | None = None
style: str | None = None
def __post_init__(self):
if not self.label:
self.label = self.id
@dataclass
class Edge:
"""One arrow.
`style="dashed"` means a weaker relationship and survives emit. `arrowhead`
is here rather than in the profile because "this edge has no arrowhead" is a
statement about the relationship, not about the house look.
"""
src: str
dst: str
label: str = ""
cls: str | None = None
style: str | None = None
arrowhead: str | None = None
@dataclass
class Group:
"""A container — Lucid's grouping box, DOT's `cluster_*`.
`nodes` holds ids, not Node objects, so a node can be declared once and the
grouping rearranged without touching it. Emit checks the ids resolve.
"""
id: str
label: str = ""
cls: str | None = None
style: str | None = None
nodes: list[str] = field(default_factory=list)
@dataclass
class Graph:
"""The whole thing. `rankdir` and `title` are content, not style."""
name: str = "G"
title: str = ""
rankdir: str = "TB"
nodes: list[Node] = field(default_factory=list)
edges: list[Edge] = field(default_factory=list)
groups: list[Group] = field(default_factory=list)
# -- building ---------------------------------------------------------
def node(self, id: str, label: str = "", **kw) -> "Node":
n = Node(id, label, **kw)
self.nodes.append(n)
return n
def edge(self, src: str, dst: str, label: str = "", **kw) -> "Edge":
e = Edge(src, dst, label, **kw)
self.edges.append(e)
return e
def group(self, id: str, label: str = "", nodes: list[str] | None = None, **kw) -> "Group":
g = Group(id, label, nodes=list(nodes or []), **kw)
self.groups.append(g)
return g
# -- checking ---------------------------------------------------------
def validate(self) -> list[str]:
"""Every problem, as a list. Empty means fine.
Returned rather than raised on the first one: a graph with four dangling
edges should report four, not make you re-run it four times.
"""
problems = []
ids = [n.id for n in self.nodes]
known = set(ids)
for dup in {i for i in ids if ids.count(i) > 1}:
problems.append(f"node {dup!r} declared more than once")
for e in self.edges:
for end in (e.src, e.dst):
if end not in known:
problems.append(f"edge {e.src}->{e.dst} names unknown node {end!r}")
seen_in_group: dict[str, str] = {}
for g in self.groups:
for nid in g.nodes:
if nid not in known:
problems.append(f"group {g.id!r} names unknown node {nid!r}")
elif nid in seen_in_group:
# DOT puts the node in whichever cluster claims it first and
# says nothing. That is a layout you did not ask for.
problems.append(
f"node {nid!r} is in two groups ({seen_in_group[nid]!r} and {g.id!r})"
)
else:
seen_in_group[nid] = g.id
return problems
def grouped(self) -> set[str]:
"""Ids that belong to some group, so emit knows what is left over."""
return {nid for g in self.groups for nid in g.nodes}

View File

@@ -1,131 +0,0 @@
"""
Prove the graph model, on fixtures it builds itself.
python3 selftest.py # from graphgen/
Stdlib only, no network, no graphviz, no FastAPI. What is tested here is what a
graph *is* — that it catches the ways one can be malformed, and that the fields
carrying meaning rather than decoration are present and distinguishable. How a
graph is *drawn* is `docgen`'s, and `docgen/selftest.py` tests that separately.
These checks arrived with the model when it moved out of docgen. Tests move with
the code they test; leaving them behind is how a module ends up with no owner.
**A check that only ever proves things work is not worth running.** So the
malformed cases carry equal weight, and `validate()` is asserted to report *all*
the problems rather than the first — a graph with four dangling edges should
say four, not make you run it four times.
"""
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE.parent))
PKG = HERE.name
_mod = __import__(f"{PKG}.graph", fromlist=["*"])
Graph, Node, Edge, Group, CLASSES = (
_mod.Graph, _mod.Node, _mod.Edge, _mod.Group, _mod.CLASSES
)
PASS, FAIL = [], []
def check(name, condition, detail=""):
(PASS if condition else FAIL).append(name)
print(f" {'ok ' if condition else 'FAIL'} {name}" +
(f"\n {detail}" if detail and not condition else ""))
print("\n1. a well-formed graph")
g = Graph("selftest", title="Selftest", rankdir="LR")
g.node("api", "API", cls="station")
g.node("store", "Store", shape="cylinder")
g.node("plain", "Plain")
g.node("spacer", style="invis")
g.edge("api", "store", "writes")
g.edge("api", "plain", "weakly", style="dashed")
g.edge("spacer", "api", style="invis")
g.group("core", "Core", ["api", "store"])
check("it validates", g.validate() == [], str(g.validate()))
check("a node defaults its label to its id", Node("x").label == "x")
check("an explicit label is kept", Node("x", "Something").label == "Something")
check("grouped() reports membership", g.grouped() == {"api", "store"})
check("ungrouped nodes are not claimed", "plain" not in g.grouped())
print("\n2. the fields that carry meaning")
# These three are structure wearing a visual field's clothing. A style profile
# must not be able to override them, which starts with the model keeping them.
store = next(n for n in g.nodes if n.id == "store")
spacer = next(n for n in g.nodes if n.id == "spacer")
weak = next(e for e in g.edges if e.label == "weakly")
check("shape is carried (a cylinder is a datastore)", store.shape == "cylinder")
check("invis is carried (scaffolding, not decoration)", spacer.style == "invis")
check("dashed is carried (a weaker relationship)", weak.style == "dashed")
check("an untagged node has no class", next(n for n in g.nodes if n.id == "plain").cls is None)
check("the class vocabulary is the documented one",
set(CLASSES) == {"accent", "accent-text", "ok", "artery", "atlas", "station", "muted"},
f"got {sorted(CLASSES)}")
# Nothing on the model may be a visual value. This is the model's half of the
# rule docgen's selftest enforces from the other side.
fields = set(Node.__dataclass_fields__) | set(Edge.__dataclass_fields__) | set(Group.__dataclass_fields__)
visual = fields & {"fill", "fillcolor", "color", "stroke", "penwidth",
"fontname", "fontsize", "fontcolor", "arrowsize"}
check("no visual value is a model field", not visual,
f"{sorted(visual)} — the profile is no longer the only place style lives")
print("\n3. the ways a graph is malformed")
bad = Graph("bad")
bad.node("a")
bad.node("a") # declared twice
bad.edge("a", "ghost") # dangling
bad.edge("nowhere", "a") # dangling the other way
bad.group("g1", "One", ["a", "missing"])
bad.group("g2", "Two", ["a"]) # 'a' claimed by two groups
problems = bad.validate()
check("a duplicate node is caught", any("more than once" in p for p in problems), str(problems))
check("a dangling target is caught", any("ghost" in p for p in problems), str(problems))
check("a dangling source is caught", any("nowhere" in p for p in problems), str(problems))
check("an unknown group member is caught", any("missing" in p for p in problems), str(problems))
check("a node in two groups is caught",
any("two groups" in p for p in problems), str(problems))
check("every problem is reported, not just the first", len(problems) >= 5,
f"only {len(problems)}: {problems}")
print("\n4. the builders return what they made")
g2 = Graph("b")
n = g2.node("n", "N", cls="ok")
e = g2.edge("n", "n", "self")
grp = g2.group("g", "G", ["n"])
check("node() returns the Node", isinstance(n, Node) and n.cls == "ok")
check("edge() returns the Edge", isinstance(e, Edge) and e.label == "self")
check("group() returns the Group", isinstance(grp, Group) and grp.nodes == ["n"])
shared = ["n"]
g3 = Graph("c")
g3.node("n")
g3.group("g", "G", shared)
shared.append("mutated")
check("a group copies the id list it was given", g3.groups[0].nodes == ["n"],
"the caller's list is aliased — mutating it changes the graph")
print()
print(f"{len(PASS)} passed, {len(FAIL)} failed")
if FAIL:
print("\nfailed:")
for name in FAIL:
print(f" {name}")
sys.exit(1 if FAIL else 0)