292 lines
11 KiB
Python
292 lines
11 KiB
Python
"""
|
|
IR + style -> DOT -> SVG.
|
|
|
|
This module has never heard of Python, `ast` or SQL. It walks nodes, looks up a
|
|
rule by `kind`, and writes attributes. That is deliberately the whole algorithm:
|
|
if it starts making decisions about what something *is*, the decision belongs in
|
|
an extractor, and if it starts making decisions about what something *looks
|
|
like*, it belongs in a style file.
|
|
|
|
from docgen.emitters.dot import emit, render
|
|
svg = render(emit(ir, Style.load("lucid")))
|
|
|
|
## Containment becomes clusters
|
|
|
|
A node with children is a `subgraph cluster_*`; a leaf is a node. That is the
|
|
only structural interpretation made here, and it follows from `parent` meaning
|
|
containment and nothing else.
|
|
|
|
## The SVG is addressable
|
|
|
|
`id` and `kind` are written through to the SVG as the element's `id` and
|
|
`class`, and `attrs.file`/`attrs.line` become an `href`. So a front end can
|
|
attach behaviour to a box, and a box can link to the line it came from, without
|
|
this emitter knowing anything about either.
|
|
|
|
## Known limits
|
|
|
|
Recorded in `style/lucid.json` under `limits` and reachable as `Style.limits()`.
|
|
DOT is used until it genuinely cannot express a rule, and then it stops rather
|
|
than growing machinery — an HTML-like label table for header bars, a post-pass
|
|
for badge circles. Those mark where a richer emitter begins.
|
|
"""
|
|
|
|
import shutil
|
|
import subprocess
|
|
|
|
|
|
class RenderError(RuntimeError):
|
|
"""Graphviz is absent, or refused the graph."""
|
|
|
|
|
|
def _esc(text: str) -> str:
|
|
return str(text).replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n")
|
|
|
|
|
|
def _attrs(pairs: dict) -> str:
|
|
inner = " ".join(f'{k}="{_esc(v)}"' for k, v in pairs.items() if v not in (None, "", []))
|
|
return f" [{inner}]" if inner else ""
|
|
|
|
|
|
def _style_words(rule: dict, *, filled: bool = True) -> str:
|
|
words = ["filled"] if filled else []
|
|
# `record` and `plaintext` ignore rounding; asking for it warns and changes
|
|
# nothing, which is noise in the build output.
|
|
if rule.get("rounded") and rule.get("shape") not in ("record", "Mrecord", "plaintext"):
|
|
words.append("rounded")
|
|
if rule.get("dashed"):
|
|
words.append("dashed")
|
|
return ",".join(words)
|
|
|
|
|
|
def _node_attrs(node, rule: dict, style) -> dict:
|
|
a = {
|
|
"label": node.get("label") or node["id"],
|
|
"shape": rule.get("shape", "box"),
|
|
"style": _style_words(rule),
|
|
"fillcolor": rule.get("fill"),
|
|
"color": rule.get("border"),
|
|
"fontcolor": rule.get("text"),
|
|
"penwidth": style.geom("hairline"),
|
|
"fontname": style.geom("font-bold" if rule.get("bold") else "font"),
|
|
"fontsize": rule.get("font-size") or style.geom("font-size-base"),
|
|
"margin": style.geom("padding"),
|
|
# Addressability: through to the SVG, for whatever reads it later.
|
|
"id": node["id"],
|
|
"class": node["kind"],
|
|
}
|
|
attrs = node.get("attrs") or {}
|
|
if attrs.get("file"):
|
|
line = attrs.get("line")
|
|
a["href"] = f"{attrs['file']}#L{line}" if line else attrs["file"]
|
|
a["tooltip"] = attrs.get("doc") or node["id"]
|
|
elif attrs.get("doc"):
|
|
a["tooltip"] = attrs["doc"]
|
|
return a
|
|
|
|
|
|
def emit(ir: dict, style, *, max_depth: int | None = None,
|
|
rankdir: str | None = None) -> str:
|
|
"""IR document (a dict) + Style -> DOT text."""
|
|
nodes = {n["id"]: n for n in ir["nodes"]}
|
|
children: dict[str | None, list[str]] = {}
|
|
for n in ir["nodes"]:
|
|
children.setdefault(n.get("parent"), []).append(n["id"])
|
|
|
|
g = style.graph()
|
|
out = [
|
|
"digraph ir {",
|
|
f' bgcolor="{g.get("bgcolor", "transparent")}"',
|
|
f' rankdir={rankdir or g.get("rankdir", "TB")}',
|
|
f' nodesep="{g.get("nodesep", 0.5)}"',
|
|
f' ranksep="{g.get("ranksep", 0.6)}"',
|
|
f' pad="{g.get("pad", 0.3)}"',
|
|
f' fontname="{style.geom("font")}"',
|
|
" compound=true",
|
|
"",
|
|
]
|
|
|
|
# Deterministic: groups are numbered by sorted id, so the rotation of
|
|
# domain colours is the same on every run.
|
|
group_index = {
|
|
nid: i for i, nid in enumerate(sorted(k for k in children if k is not None))
|
|
}
|
|
|
|
def write(node_id: str, depth: int) -> None:
|
|
node = nodes[node_id]
|
|
kids = sorted(children.get(node_id, []))
|
|
too_deep = max_depth is not None and depth >= max_depth
|
|
pad = " " * (depth + 1)
|
|
|
|
if not kids or too_deep:
|
|
out.append(f"{pad}{_q(node_id)}{_attrs(_node_attrs(node, style.node(node['kind']), style))}")
|
|
return
|
|
|
|
rule = style.group(node["kind"])
|
|
domain = (node.get("attrs") or {}).get("domain")
|
|
border = rule.get("border") or style.slot(
|
|
style.domain_slot(domain, group_index.get(node_id, 0))
|
|
)
|
|
out.append(f"{pad}subgraph cluster_{_safe(node_id)} {{")
|
|
out.append(f'{pad} label="{_esc(node.get("label") or node_id)}"')
|
|
out.append(f'{pad} style="{_style_words(rule)}"')
|
|
out.append(f'{pad} color="{border}"')
|
|
out.append(f'{pad} fillcolor="{rule.get("fill", "transparent")}"')
|
|
out.append(f'{pad} fontcolor="{rule.get("text", "")}"')
|
|
out.append(f'{pad} fontname="{style.geom("font-bold" if rule.get("bold") else "font")}"')
|
|
out.append(f'{pad} fontsize="{style.geom("font-size-header")}"')
|
|
out.append(f'{pad} labeljust=l')
|
|
out.append(f'{pad} id="{_esc(node_id)}"')
|
|
out.append(f'{pad} class="{node["kind"]}"')
|
|
for kid in kids:
|
|
write(kid, depth + 1)
|
|
out.append(f"{pad}}}")
|
|
|
|
for root in sorted(children.get(None, [])):
|
|
write(root, 0)
|
|
out.append("")
|
|
|
|
# DOT cannot use a cluster as an edge endpoint. The native answer is
|
|
# `compound=true` plus lhead/ltail: draw between a representative leaf
|
|
# inside each cluster and clip the line at the cluster boundary. Without
|
|
# this, every module-to-module import silently disappears — which is most of
|
|
# the graph a Python extractor produces.
|
|
endpoint = _endpoints(nodes, children, max_depth)
|
|
for e in ir["edges"]:
|
|
src, dst = endpoint.get(e["source"]), endpoint.get(e["target"])
|
|
if not src or not dst or src[0] == dst[0]:
|
|
continue
|
|
if src[1] and src[1] == dst[1]:
|
|
continue # both collapsed into the same cluster
|
|
# A package importing its own submodule gives an edge whose head sits
|
|
# inside its tail's cluster. Graphviz warns and draws it oddly; clipping
|
|
# to the enclosing boundary is meaningless there, so drop that side's
|
|
# clip and let the line run to the box.
|
|
ltail, lhead = src[1], dst[1]
|
|
if ltail and _within(ltail, dst[0], nodes):
|
|
ltail = None
|
|
if lhead and _within(lhead, src[0], nodes):
|
|
lhead = None
|
|
rule = style.edge(e["kind"])
|
|
out.append(
|
|
f" {_q(src[0])} -> {_q(dst[0])}"
|
|
+ _attrs(
|
|
{
|
|
"color": rule.get("color"),
|
|
"fontcolor": rule.get("text"),
|
|
"penwidth": style.geom("hairline"),
|
|
"arrowhead": rule.get("arrowhead", "normal"),
|
|
"arrowsize": rule.get("arrowsize", 0.7),
|
|
"style": "dashed" if rule.get("dashed") else None,
|
|
"fontname": style.geom("font"),
|
|
"fontsize": style.geom("font-size-sm"),
|
|
"label": (e.get("attrs") or {}).get("label"),
|
|
"ltail": ltail,
|
|
"lhead": lhead,
|
|
"class": e["kind"],
|
|
}
|
|
)
|
|
)
|
|
|
|
out.append("}")
|
|
return "\n".join(out) + "\n"
|
|
|
|
|
|
def _within(cluster_name: str, node_id: str, nodes) -> bool:
|
|
"""Is `node_id` inside the cluster named `cluster_name`?"""
|
|
cur = node_id
|
|
while cur:
|
|
if f"cluster_{_safe(cur)}" == cluster_name:
|
|
return True
|
|
cur = nodes.get(cur, {}).get("parent")
|
|
return False
|
|
|
|
|
|
def _endpoints(nodes, children, max_depth):
|
|
"""id -> (leaf to draw from, cluster to clip to or None).
|
|
|
|
A leaf is its own endpoint. A node that became a cluster is represented by
|
|
its first leaf descendant in sorted order — deterministic, so the same graph
|
|
twice produces the same DOT — with `ltail`/`lhead` naming the cluster so the
|
|
line stops at its border instead of burrowing to the inner box.
|
|
"""
|
|
depth_of: dict[str, int] = {}
|
|
|
|
def walk(nid, depth):
|
|
depth_of[nid] = depth
|
|
for kid in children.get(nid, []):
|
|
walk(kid, depth + 1)
|
|
|
|
for root in children.get(None, []):
|
|
walk(root, 0)
|
|
|
|
def collapsed(nid: str) -> bool:
|
|
return max_depth is not None and depth_of.get(nid, 0) >= max_depth
|
|
|
|
def first_leaf(nid: str) -> str:
|
|
while children.get(nid) and not collapsed(nid):
|
|
nid = sorted(children[nid])[0]
|
|
return nid
|
|
|
|
out: dict[str, tuple[str, str | None]] = {}
|
|
for nid in nodes:
|
|
cur = nid
|
|
# Anything past the depth limit is represented by the ancestor that
|
|
# survived it.
|
|
while max_depth is not None and depth_of.get(cur, 0) > max_depth:
|
|
parent = nodes[cur].get("parent")
|
|
if not parent:
|
|
break
|
|
cur = parent
|
|
if children.get(cur) and not collapsed(cur):
|
|
out[nid] = (first_leaf(cur), f"cluster_{_safe(cur)}")
|
|
else:
|
|
out[nid] = (cur, None)
|
|
return out
|
|
|
|
|
|
def _safe(text: str) -> str:
|
|
return "".join(c if c.isalnum() else "_" for c in text)
|
|
|
|
|
|
def _q(text: str) -> str:
|
|
return f'"{_esc(text)}"'
|
|
|
|
|
|
# -- render ----------------------------------------------------------------
|
|
|
|
|
|
def have_graphviz(engine: str = "dot") -> bool:
|
|
return shutil.which(engine) is not None
|
|
|
|
|
|
def render(dot_text: str, fmt: str = "svg", engine: str = "dot") -> bytes:
|
|
"""DOT -> bytes, via the graphviz binary.
|
|
|
|
The binary, not a wrapper library: it is what the render hosts have and what
|
|
`docs/graphs/render.sh` already shells out to.
|
|
|
|
Note for anything reading geometry back out: Graphviz is y-up in points and
|
|
the SVG backend flips it with a wrapper `<g transform="...">`, and layout
|
|
measures label text with the host's fonts — so the same graph on a machine
|
|
with different fontconfig produces different coordinates. Pin golden tests
|
|
to the IR, never to the SVG.
|
|
"""
|
|
if not have_graphviz(engine):
|
|
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()
|
|
)
|
|
if proc.stderr.strip():
|
|
# Graphviz warns and still renders — a missing font, an ignored
|
|
# attribute. Worth seeing, not worth failing on.
|
|
for line in proc.stderr.decode("utf-8", "replace").strip().splitlines():
|
|
print(f" graphviz: {line}")
|
|
return proc.stdout
|