Files
soleprint/soleprint/atlas2/docgen/emitters/dot.py
2026-09-12 06:42:49 -03:00

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