165 lines
5.4 KiB
Python
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
|