132 lines
4.8 KiB
Python
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)]
|