Files
soleprint/soleprint/atlas2/docgen/ir/model.py
2026-09-14 06:13:22 -03:00

165 lines
5.4 KiB
Python

"""
The IR, as Python. Mirrors `schema.json`, which is the contract.
Stdlib dataclasses, not Pydantic. The IR's whole value is being a plain JSON
document that anything can read — a format that needs a library installed to be
opened is not a format, it is an API. Validation is a function at the boundary
(`validate.py`), not a property of the type.
The duplication between this file and `schema.json` is deliberate and is the one
place it is allowed. `validate.py::check_model_matches_schema` asserts the two
agree, so they cannot drift silently.
ir = Graph(meta=Meta(source="python", root="app/"))
ir.node("app.models.User", "class", "User", parent="app.models")
ir.edge("app.models.User", "app.db.Base", "inherits")
json.dumps(ir.to_dict())
## What must never appear here
No colour, no shape, no size, no position. `shape="cylinder"` is not a field —
it is `kind="datastore"` plus a style rule, which is what lets the same IR render
in a theme that has no cylinders. The previous iteration of this model carried
`cls`, `shape` and `style`, and that is exactly the mistake this replaces.
"""
from copy import deepcopy
from dataclasses import dataclass, field
from typing import Any
SCHEMA_VERSION = "1"
@dataclass
class Meta:
"""Where this graph came from.
`generated_at` defaults to None on purpose. A timestamp makes two
extractions of an unchanged tree differ, which destroys the diff emitter's
only useful property. Set it when the run time is the fact being recorded,
not by habit.
`larder` is what was actually read — see `book/larder.py`. It is optional so
that an IR written by hand, or by an extractor that cannot count its input,
is still valid; what it must never be is *wrong*. Absent means "not
measured", which a reader can act on. A zero would be a claim.
"""
source: str
root: str
schema_version: str = SCHEMA_VERSION
generated_at: str | None = None
larder: dict | None = None
def to_dict(self) -> dict:
out = {
"source": self.source,
"root": self.root,
"schema_version": self.schema_version,
"generated_at": self.generated_at,
}
# Omitted rather than null when unmeasured: `meta.larder` present with a
# null inside is two ways to say the same thing, and consumers end up
# checking for both.
#
# Deep-copied because the rest of this model copies too — `Node.to_dict`
# does `dict(self.attrs)`. A shallow copy is not enough here: `failed` is
# a list of dicts, so aliasing it would let a caller editing a serialised
# document reach back into the live measure.
if self.larder is not None:
out["larder"] = deepcopy(self.larder)
return out
@dataclass
class Node:
"""One thing. `kind` is the only field style and layout may key on."""
id: str
kind: str
label: str = ""
parent: str | None = None
attrs: dict[str, Any] = field(default_factory=dict)
def __post_init__(self):
if not self.label:
self.label = self.id.rsplit(".", 1)[-1]
def to_dict(self) -> dict:
return {
"id": self.id,
"kind": self.kind,
"label": self.label,
"parent": self.parent,
"attrs": dict(self.attrs),
}
@dataclass
class Edge:
"""One relationship. Containment is not one — that is `Node.parent`."""
source: str
target: str
kind: str
attrs: dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> dict:
return {
"source": self.source,
"target": self.target,
"kind": self.kind,
"attrs": dict(self.attrs),
}
@dataclass
class Graph:
"""The whole document."""
meta: Meta
nodes: list[Node] = field(default_factory=list)
edges: list[Edge] = field(default_factory=list)
# -- building ---------------------------------------------------------
def node(self, id: str, kind: str, label: str = "", **kw) -> Node:
n = Node(id, kind, label, **kw)
self.nodes.append(n)
return n
def edge(self, source: str, target: str, kind: str, **kw) -> Edge:
e = Edge(source, target, kind, **kw)
self.edges.append(e)
return e
def has(self, node_id: str) -> bool:
return any(n.id == node_id for n in self.nodes)
# -- serialising ------------------------------------------------------
def to_dict(self) -> dict:
"""Sorted, so the same tree twice produces the same bytes.
Sorting here rather than asking every extractor to emit in order: the
stability guarantee belongs to the format, not to each producer's
traversal order, and an extractor that visits files in directory order
is otherwise at the mercy of the filesystem.
"""
return {
"meta": self.meta.to_dict(),
"nodes": [n.to_dict() for n in sorted(self.nodes, key=lambda n: n.id)],
"edges": [
e.to_dict()
for e in sorted(self.edges, key=lambda e: (e.source, e.target, e.kind))
],
}
@classmethod
def from_dict(cls, data: dict) -> "Graph":
meta = Meta(**data["meta"])
g = cls(meta=meta)
g.nodes = [Node(**n) for n in data["nodes"]]
g.edges = [Edge(**e) for e in data["edges"]]
return g