Files
soleprint/soleprint/station/tools/docgen/shape.py
2026-09-12 06:42:49 -03:00

132 lines
4.8 KiB
Python

"""
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)]