docgen iter 2
This commit is contained in:
4
soleprint/station/tools/docgen/.gitignore
vendored
Normal file
4
soleprint/station/tools/docgen/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
# Everything this makes. Regenerate with `make notebook`, `make graph`.
|
||||
out/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
88
soleprint/station/tools/docgen/Makefile
Normal file
88
soleprint/station/tools/docgen/Makefile
Normal file
@@ -0,0 +1,88 @@
|
||||
# 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)"
|
||||
256
soleprint/station/tools/docgen/README.md
Normal file
256
soleprint/station/tools/docgen/README.md
Normal file
@@ -0,0 +1,256 @@
|
||||
# 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.
|
||||
60
soleprint/station/tools/docgen/__init__.py
Normal file
60
soleprint/station/tools/docgen/__init__.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""
|
||||
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",
|
||||
]
|
||||
40
soleprint/station/tools/docgen/demo.py
Normal file
40
soleprint/station/tools/docgen/demo.py
Normal file
@@ -0,0 +1,40 @@
|
||||
"""
|
||||
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
|
||||
232
soleprint/station/tools/docgen/dot.py
Normal file
232
soleprint/station/tools/docgen/dot.py
Normal file
@@ -0,0 +1,232 @@
|
||||
"""
|
||||
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"
|
||||
6
soleprint/station/tools/docgen/export/__init__.py
Normal file
6
soleprint/station/tools/docgen/export/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""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"]
|
||||
71
soleprint/station/tools/docgen/export/doc.py
Normal file
71
soleprint/station/tools/docgen/export/doc.py
Normal file
@@ -0,0 +1,71 @@
|
||||
"""
|
||||
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]
|
||||
102
soleprint/station/tools/docgen/export/notebook.py
Normal file
102
soleprint/station/tools/docgen/export/notebook.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""
|
||||
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]
|
||||
1
soleprint/station/tools/docgen/export/specs/__init__.py
Normal file
1
soleprint/station/tools/docgen/export/specs/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Notebook contents. One module per notebook."""
|
||||
292
soleprint/station/tools/docgen/export/specs/vanilla.py
Normal file
292
soleprint/station/tools/docgen/export/specs/vanilla.py
Normal file
@@ -0,0 +1,292 @@
|
||||
"""
|
||||
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
|
||||
176
soleprint/station/tools/docgen/profile.py
Normal file
176
soleprint/station/tools/docgen/profile.py
Normal file
@@ -0,0 +1,176 @@
|
||||
"""
|
||||
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
|
||||
56
soleprint/station/tools/docgen/profiles/default.json
Normal file
56
soleprint/station/tools/docgen/profiles/default.json
Normal file
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"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"}
|
||||
}
|
||||
}
|
||||
56
soleprint/station/tools/docgen/profiles/lucid.json
Normal file
56
soleprint/station/tools/docgen/profiles/lucid.json
Normal file
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"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"}
|
||||
}
|
||||
}
|
||||
107
soleprint/station/tools/docgen/render.py
Normal file
107
soleprint/station/tools/docgen/render.py
Normal file
@@ -0,0 +1,107 @@
|
||||
"""
|
||||
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()
|
||||
585
soleprint/station/tools/docgen/selftest.py
Normal file
585
soleprint/station/tools/docgen/selftest.py
Normal file
@@ -0,0 +1,585 @@
|
||||
"""
|
||||
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)
|
||||
131
soleprint/station/tools/docgen/shape.py
Normal file
131
soleprint/station/tools/docgen/shape.py
Normal file
@@ -0,0 +1,131 @@
|
||||
"""
|
||||
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)]
|
||||
7
soleprint/station/tools/docgen/style/__init__.py
Normal file
7
soleprint/station/tools/docgen/style/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""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"]
|
||||
221
soleprint/station/tools/docgen/style/extract.py
Normal file
221
soleprint/station/tools/docgen/style/extract.py
Normal file
@@ -0,0 +1,221 @@
|
||||
"""
|
||||
A folder of exported diagrams -> the style vocabulary they use.
|
||||
|
||||
The premise, from `spr/def/prompts/lucid` §2a: style values are *visual*
|
||||
metadata — hex codes, stroke widths, corner radii, font stacks. None of that
|
||||
requires reading what the diagram says, and none of it requires sending the file
|
||||
anywhere. So for confidential diagrams this is the path: run it locally, skip
|
||||
both the Lucid API and any assistant.
|
||||
|
||||
Two rules, and they are not stylistic preferences:
|
||||
|
||||
1. **Text content is never read.** `<text>` elements are visited for their style
|
||||
attributes and nothing else; `.text` and `.tail` are never touched anywhere in
|
||||
this module. The semantics of a diagram are not needed to derive a palette,
|
||||
so they are not looked at. `selftest.py` asserts a label from a known fixture
|
||||
appears nowhere in the output.
|
||||
|
||||
2. **Fully offline.** No network import in this file, and nothing here opens a
|
||||
socket. Confidential source diagrams stay off any network path, and off the
|
||||
Lucid API path.
|
||||
|
||||
## Why lxml and not grep
|
||||
|
||||
`prompts/lucid` §2a gives a grep recipe and then says to do this instead, which
|
||||
is the right call: `fill` appears as a presentation attribute (`fill="#fff"`),
|
||||
inside an inline style (`style="fill:#fff"`), and inside a `<style>` block that
|
||||
applies to elements that carry none of it. Grep sees three unrelated strings and
|
||||
counts a CSS rule once no matter how many shapes it paints. Parsing sees one
|
||||
vocabulary and counts what is actually drawn.
|
||||
|
||||
`lxml` is imported inside the function, so the rest of docgen works without it.
|
||||
|
||||
## Frequency is the whole point
|
||||
|
||||
The output is sorted by count, 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. A list of every colour in the file, unsorted, is not
|
||||
usable — real exports carry dozens of one-off values from shadows, gradients and
|
||||
whatever someone recoloured once.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
|
||||
# The vocabulary worth harvesting, from prompt 35.5.
|
||||
PROPERTIES = ("fill", "stroke", "stroke-width", "font-family", "font-size", "rx")
|
||||
|
||||
# Lucid's idiom for "no fill". Mapping it to black is the obvious wrong answer
|
||||
# and would poison the palette with a colour the diagram does not contain.
|
||||
TRANSPARENT = "#00000000"
|
||||
|
||||
# Values that are the absence of a value. Counted separately rather than dropped,
|
||||
# because "most shapes have no stroke" is itself a fact about the house style.
|
||||
NULLISH = {"none", "transparent", "currentColor", "inherit"}
|
||||
|
||||
|
||||
def _values_from(el) -> dict:
|
||||
"""One element's style vocabulary. Attributes and inline style, never text.
|
||||
|
||||
The inline `style="..."` wins over the presentation attribute, which is what
|
||||
the SVG spec says and what browsers do.
|
||||
"""
|
||||
found = {p: el.get(p) for p in PROPERTIES if el.get(p)}
|
||||
inline = el.get("style")
|
||||
if inline:
|
||||
for decl in inline.split(";"):
|
||||
if ":" not in decl:
|
||||
continue
|
||||
name, _, value = decl.partition(":")
|
||||
name, value = name.strip(), value.strip()
|
||||
if name in PROPERTIES and value:
|
||||
found[name] = value
|
||||
return found
|
||||
|
||||
|
||||
def _normalise(prop: str, value: str) -> str | None:
|
||||
"""One spelling per value, so `#FFF` and `#ffffff` are not two palette entries."""
|
||||
value = value.strip()
|
||||
if not value or value in NULLISH:
|
||||
return value if value in NULLISH else None
|
||||
|
||||
if prop in ("fill", "stroke"):
|
||||
if value.startswith("url("):
|
||||
return None # a gradient or pattern reference, not a colour
|
||||
if value == TRANSPARENT:
|
||||
return "transparent"
|
||||
if value.startswith("#"):
|
||||
hexv = value[1:].lower()
|
||||
if len(hexv) in (3, 4): # #abc -> #aabbcc
|
||||
hexv = "".join(c * 2 for c in hexv)
|
||||
if len(hexv) == 8 and hexv[6:] == "ff":
|
||||
hexv = hexv[:6] # fully opaque; the alpha says nothing
|
||||
return "#" + hexv
|
||||
rgb = re.match(r"rgba?\(([^)]+)\)", value)
|
||||
if rgb:
|
||||
parts = [p.strip() for p in rgb.group(1).replace("/", ",").split(",")]
|
||||
try:
|
||||
r, g, b = (int(float(p)) for p in parts[:3])
|
||||
except ValueError:
|
||||
return value.lower()
|
||||
return f"#{r:02x}{g:02x}{b:02x}"
|
||||
return value.lower()
|
||||
|
||||
if prop in ("stroke-width", "font-size", "rx"):
|
||||
# `8pt`, `8px`, `8` — the number is the value; the unit is noise for
|
||||
# font-size (DOT's fontsize is already points) and for stroke width.
|
||||
num = re.match(r"(-?[\d.]+)", value)
|
||||
if not num:
|
||||
return None
|
||||
# `11.00` and `11` are the same size and must not be two entries in the
|
||||
# frequency count. DOT takes either; the tidy one is what lands in a
|
||||
# profile a person will read.
|
||||
text = num.group(1)
|
||||
return text.rstrip("0").rstrip(".") if "." in text else text
|
||||
|
||||
if prop == "font-family":
|
||||
# A font stack's first entry is the one that renders where it exists.
|
||||
return value.split(",")[0].strip().strip("'\"")
|
||||
|
||||
return value
|
||||
|
||||
|
||||
def _svg_files(folder: Path, workdir: Path) -> list[Path]:
|
||||
"""Every SVG to read, converting PDFs on the way.
|
||||
|
||||
`pdftocairo -svg` is the conversion `prompts/lucid` §2a names. Converted
|
||||
files land in a temp directory — the target folder is read-only here, the
|
||||
same way `histgen`'s source is.
|
||||
"""
|
||||
files = sorted(folder.rglob("*.svg"))
|
||||
pdfs = sorted(folder.rglob("*.pdf"))
|
||||
if pdfs:
|
||||
if shutil.which("pdftocairo") is None:
|
||||
print(f" {len(pdfs)} PDF(s) skipped — pdftocairo not found "
|
||||
"(install with: sudo apt install poppler-utils)")
|
||||
else:
|
||||
for i, pdf in enumerate(pdfs):
|
||||
out = workdir / f"pdf-{i:03d}-{pdf.stem}.svg"
|
||||
proc = subprocess.run(
|
||||
["pdftocairo", "-svg", str(pdf), str(out)], capture_output=True
|
||||
)
|
||||
if proc.returncode == 0 and out.exists():
|
||||
files.append(out)
|
||||
else:
|
||||
print(f" could not convert {pdf.name}: "
|
||||
f"{proc.stderr.decode('utf-8', 'replace').strip()}")
|
||||
return files
|
||||
|
||||
|
||||
def harvest(folder: Path | str) -> dict:
|
||||
"""Read a target folder, return the frequency-sorted token vocabulary."""
|
||||
try:
|
||||
from lxml import etree
|
||||
except ImportError: # pragma: no cover - depends on the host
|
||||
raise RuntimeError(
|
||||
"extraction needs lxml — pip install lxml\n"
|
||||
"(the rest of docgen does not; this is the only place it is used)"
|
||||
) from None
|
||||
|
||||
folder = Path(folder)
|
||||
if not folder.is_dir():
|
||||
raise NotADirectoryError(f"not a folder: {folder}")
|
||||
|
||||
counters = {p: Counter() for p in PROPERTIES}
|
||||
read, failed = 0, []
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="docgen-extract-") as tmp:
|
||||
files = _svg_files(folder, Path(tmp))
|
||||
for path in files:
|
||||
try:
|
||||
tree = etree.parse(str(path))
|
||||
except Exception as e:
|
||||
failed.append((path.name, str(e).splitlines()[0]))
|
||||
continue
|
||||
read += 1
|
||||
for el in tree.iter():
|
||||
# Style attributes only. `el.text` is never referenced — that is
|
||||
# the "never read text content" rule, and it is one line.
|
||||
for prop, raw in _values_from(el).items():
|
||||
value = _normalise(prop, raw)
|
||||
if value:
|
||||
counters[prop][value] += 1
|
||||
|
||||
return {
|
||||
"source": str(folder),
|
||||
"files_read": read,
|
||||
"files_failed": [{"file": n, "error": e} for n, e in failed],
|
||||
"tokens": {
|
||||
prop: [{"value": v, "count": c} for v, c in counters[prop].most_common()]
|
||||
for prop in PROPERTIES
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def write(folder: Path | str, out: Path | str) -> Path:
|
||||
"""Harvest and write `tokens.json`. Returns the path."""
|
||||
data = harvest(folder)
|
||||
out = Path(out)
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
out.write_text(json.dumps(data, indent=2) + "\n")
|
||||
return out
|
||||
|
||||
|
||||
def summarise(data: dict, top: int = 5) -> str:
|
||||
"""What was found, frequency-sorted — the part a person reads."""
|
||||
lines = [f"{data['files_read']} file(s) read from {data['source']}"]
|
||||
for fail in data["files_failed"]:
|
||||
lines.append(f" could not parse {fail['file']}: {fail['error']}")
|
||||
for prop in PROPERTIES:
|
||||
entries = data["tokens"][prop][:top]
|
||||
if not entries:
|
||||
continue
|
||||
lines.append(f"\n {prop}")
|
||||
for e in entries:
|
||||
lines.append(f" {e['count']:6} {e['value']}")
|
||||
return "\n".join(lines)
|
||||
161
soleprint/station/tools/docgen/style/tokens.py
Normal file
161
soleprint/station/tools/docgen/style/tokens.py
Normal file
@@ -0,0 +1,161 @@
|
||||
"""
|
||||
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}
|
||||
81
soleprint/station/tools/graphgen/README.md
Normal file
81
soleprint/station/tools/graphgen/README.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# graphgen
|
||||
|
||||
What a graph is, and where graphs come from.
|
||||
|
||||
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")
|
||||
```
|
||||
|
||||
```bash
|
||||
python3 selftest.py # the model, on fixtures it builds. No network, no graphviz
|
||||
```
|
||||
|
||||
## Meaning, not appearance
|
||||
|
||||
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
|
||||
them, which starts with the model keeping them distinct:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `shape` | a cylinder is a datastore |
|
||||
| `style="invis"` | layout scaffolding — filling it in would draw it |
|
||||
| `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
|
||||
|
||||
`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
|
||||
it (`generator/jsonschema.py`), `datagen` exposes it (`base.py::schema`),
|
||||
`shuntgen` generates it, and `cfg/amar/.../datagen/amar.py` reads it off disk.
|
||||
`modelgen/tests/test_extractors.py:240,394` assert it.
|
||||
|
||||
It does not change to suit anything here. The graph model was added *beside* it,
|
||||
not over it.
|
||||
|
||||
## The other meanings of "graph"
|
||||
|
||||
Parked, deliberately: Supabase-style schema diagrams, video pipeline processing
|
||||
graphs, local computer-vision graphs. Each is another **source** feeding the one
|
||||
model, and they belong here rather than each growing its own drawing code. None
|
||||
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.
|
||||
@@ -1 +1,43 @@
|
||||
"""Graphgen — interactive DB schema visualization."""
|
||||
"""
|
||||
Graphgen — what a graph is, and where graphs come from.
|
||||
|
||||
Two halves, and they are independent:
|
||||
|
||||
graph.py the model. Nodes, edges, groups, and the meaning carried on
|
||||
them. No colour, no font, no layout engine.
|
||||
|
||||
schema.py the first source. A `schema.json` or a modelgen `schema/`
|
||||
folder becomes `{models, relationships, source}`, which
|
||||
`api.py` serves at /station/tools/graphgen/api/schema and the
|
||||
browser viewer draws.
|
||||
|
||||
`{models, relationships, source}` is a **published contract**, not an internal
|
||||
shape: modelgen emits it (`generator/jsonschema.py`), datagen exposes it
|
||||
(`base.py::schema`), shuntgen generates it, and `cfg/amar` reads it off disk.
|
||||
Two modelgen tests assert it. It does not change to suit anything here.
|
||||
|
||||
The senses of "graph" parked for later — video pipeline processing graphs, local
|
||||
computer-vision graphs, Supabase-style schema diagrams — are more *sources*.
|
||||
They belong beside `schema.py`, feeding the one model, rather than each growing
|
||||
its own drawing code.
|
||||
|
||||
## Drawing one is not this tool's job
|
||||
|
||||
`docgen` takes a graph and produces DOT, SVG, and documents, with the styling
|
||||
supplied as a data profile. The dependency runs 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.
|
||||
|
||||
from graphgen import Graph
|
||||
from docgen import Profile, emit, render # the other half, if present
|
||||
|
||||
## Importing this is cheap
|
||||
|
||||
`api.py` is deliberately not imported here. Pulling in the model must not pull
|
||||
in FastAPI — `run.py` imports `station.tools.graphgen.api` explicitly when it
|
||||
mounts the router, and nothing else should have to pay for that.
|
||||
"""
|
||||
|
||||
from .graph import CLASSES, Edge, Graph, Group, Node
|
||||
|
||||
__all__ = ["Graph", "Node", "Edge", "Group", "CLASSES"]
|
||||
|
||||
71
soleprint/station/tools/graphgen/examples.py
Normal file
71
soleprint/station/tools/graphgen/examples.py
Normal file
@@ -0,0 +1,71 @@
|
||||
"""
|
||||
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
|
||||
185
soleprint/station/tools/graphgen/graph.py
Normal file
185
soleprint/station/tools/graphgen/graph.py
Normal file
@@ -0,0 +1,185 @@
|
||||
"""
|
||||
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}
|
||||
131
soleprint/station/tools/graphgen/selftest.py
Normal file
131
soleprint/station/tools/graphgen/selftest.py
Normal file
@@ -0,0 +1,131 @@
|
||||
"""
|
||||
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)
|
||||
Reference in New Issue
Block a user