Basic document handling

This commit is contained in:
2026-07-19 21:26:27 +02:00
parent c5ab3329ae
commit 76bd313f79
32 changed files with 4843 additions and 67 deletions

View File

@@ -0,0 +1,329 @@
from __future__ import annotations
from copy import deepcopy
from dataclasses import dataclass, field
from typing import Any
from uuid import uuid4
@dataclass
class Port:
id: str
name: str
x: float = 0.0
y: float = 0.0
properties: dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
return {
"id": self.id,
"name": self.name,
"position": {"x": self.x, "y": self.y},
"properties": self.properties,
}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "Port":
position = data.get("position", {})
return cls(
id=str(data["id"]),
name=str(data.get("name", data["id"])),
x=float(position.get("x", 0.0)),
y=float(position.get("y", 0.0)),
properties=dict(data.get("properties", {})),
)
@dataclass
class Icon:
shape: str = "rectangle"
fill: str = "#f4f4f4"
border: str = "#303030"
text: str = ""
def to_dict(self) -> dict[str, str]:
return {
"shape": self.shape,
"fill": self.fill,
"border": self.border,
"text": self.text,
}
@classmethod
def from_dict(cls, data: dict[str, Any] | None) -> "Icon":
data = data or {}
return cls(
shape=str(data.get("shape", "rectangle")),
fill=str(data.get("fill", "#f4f4f4")),
border=str(data.get("border", "#303030")),
text=str(data.get("text", "")),
)
@dataclass(frozen=True)
class Endpoint:
block: str | None = None
port: str | None = None
interface: str | None = None
def to_dict(self) -> dict[str, str]:
if self.interface is not None:
return {"interface": self.interface}
if self.block is None or self.port is None:
raise ValueError("A block endpoint requires both block and port")
return {"block": self.block, "port": self.port}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "Endpoint":
if "interface" in data:
return cls(interface=str(data["interface"]))
return cls(block=str(data["block"]), port=str(data["port"]))
@dataclass
class Connection:
id: str
source: Endpoint
target: Endpoint
name: str = ""
properties: dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
return {
"id": self.id,
"source": self.source.to_dict(),
"target": self.target.to_dict(),
"name": self.name,
"properties": self.properties,
}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "Connection":
return cls(
id=str(data["id"]),
source=Endpoint.from_dict(data["source"]),
target=Endpoint.from_dict(data["target"]),
name=str(data.get("name", "")),
properties=dict(data.get("properties", {})),
)
@dataclass
class Graph:
blocks: dict[str, Component] = field(default_factory=dict)
connections: dict[str, Connection] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
return {
"blocks": [block.to_dict() for block in self.blocks.values()],
"connections": [connection.to_dict() for connection in self.connections.values()],
}
@classmethod
def from_dict(cls, data: dict[str, Any] | None) -> "Graph":
data = data or {}
blocks = [Component.from_dict(item) for item in data.get("blocks", [])]
connections = [Connection.from_dict(item) for item in data.get("connections", [])]
if len({block.id for block in blocks}) != len(blocks):
raise ValueError("A graph contains duplicate component IDs")
if len({connection.id for connection in connections}) != len(connections):
raise ValueError("A graph contains duplicate connection IDs")
return cls(
blocks={block.id: block for block in blocks},
connections={connection.id: connection for connection in connections},
)
@dataclass
class Component:
id: str
name: str
x: float = 0.0
y: float = 0.0
rotation: float = 0.0
inputs: list[Port] = field(default_factory=list)
outputs: list[Port] = field(default_factory=list)
icon: Icon = field(default_factory=Icon)
properties: dict[str, Any] = field(default_factory=dict)
implementation_kind: str = "graph"
graph: Graph = field(default_factory=Graph)
source: dict[str, Any] = field(default_factory=dict)
show_subtree_in_library: bool = True
def to_dict(self) -> dict[str, Any]:
implementation = {"kind": self.implementation_kind}
if self.implementation_kind == "text":
implementation["source"] = self.source
else:
implementation["graph"] = self.graph.to_dict()
return {
"id": self.id,
"name": self.name,
"position": {"x": self.x, "y": self.y},
"rotation": self.rotation,
"interface": {
"inputs": [port.to_dict() for port in self.inputs],
"outputs": [port.to_dict() for port in self.outputs],
},
"icon": self.icon.to_dict(),
"properties": self.properties,
"library": {"showSubtree": self.show_subtree_in_library},
"implementation": implementation,
}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "Component":
interface = data.get("interface", {})
position = data.get("position", {})
implementation = data["implementation"]
kind = str(implementation.get("kind", "graph"))
if kind not in {"graph", "text"}:
raise ValueError(f"Unknown component implementation kind: {kind}")
return cls(
id=str(data["id"]),
name=str(data.get("name", "Unnamed")),
x=float(position.get("x", 0.0)),
y=float(position.get("y", 0.0)),
rotation=float(data.get("rotation", 0.0)),
inputs=[Port.from_dict(item) for item in interface.get("inputs", [])],
outputs=[Port.from_dict(item) for item in interface.get("outputs", [])],
icon=Icon.from_dict(data.get("icon")),
properties=dict(data.get("properties", {})),
show_subtree_in_library=bool(data.get("library", {}).get("showSubtree", True)),
implementation_kind=kind,
graph=Graph.from_dict(implementation.get("graph")) if kind == "graph" else Graph(),
source=dict(implementation.get("source", {})) if kind == "text" else {},
)
@dataclass
class GraphDocument:
roots: dict[str, Component] = field(default_factory=dict)
metadata: dict[str, Any] = field(default_factory=dict)
@classmethod
def empty(cls) -> "GraphDocument":
return cls(metadata={"name": "Untitled"})
def to_dict(self) -> dict[str, Any]:
return {
"format": "bedit-document",
"version": 1,
"metadata": self.metadata,
"roots": [root.to_dict() for root in self.roots.values()],
}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "GraphDocument":
if data.get("format") != "bedit-document":
raise ValueError("This is not a BEdit document")
if data.get("version") != 1:
raise ValueError(f"Unsupported BEdit document version: {data.get('version')}")
roots = [Component.from_dict(item) for item in data["roots"]]
if len({root.id for root in roots}) != len(roots):
raise ValueError("The document contains duplicate root IDs")
document = cls(
roots={root.id: root for root in roots},
metadata=dict(data.get("metadata", {})),
)
document.validate()
return document
def all_components(self):
def walk(component: Component):
yield component
if component.implementation_kind == "graph":
for child in component.graph.blocks.values():
yield from walk(child)
def all_roots():
for root in self.roots.values():
yield from walk(root)
return all_roots()
def find_component(self, component_id: str) -> Component | None:
return next(
(component for component in self.all_components() if component.id == component_id),
None,
)
def find_parent(self, component_id: str) -> Component | None:
for component in self.all_components():
if component_id in component.graph.blocks:
return component
return None
def validate(self) -> None:
seen: set[str] = set()
for component in self.all_components():
if component.id in seen:
raise ValueError(f"Duplicate component ID: {component.id}")
seen.add(component.id)
if component.implementation_kind == "text" and component.graph.blocks:
raise ValueError(f"Text component {component.name} cannot contain a graph")
self._validate_graph(component)
@staticmethod
def _validate_graph(owner: Component) -> None:
input_ids = {port.id for port in owner.inputs}
output_ids = {port.id for port in owner.outputs}
for connection in owner.graph.connections.values():
if connection.source.interface is not None:
if connection.source.interface not in input_ids:
raise ValueError(f"Connection {connection.id} uses an unknown interface input")
else:
source = owner.graph.blocks.get(connection.source.block or "")
if source is None or connection.source.port not in {p.id for p in source.outputs}:
raise ValueError(f"Connection {connection.id} uses an unknown block output")
if connection.target.interface is not None:
if connection.target.interface not in output_ids:
raise ValueError(f"Connection {connection.id} uses an unknown interface output")
else:
target = owner.graph.blocks.get(connection.target.block or "")
if target is None or connection.target.port not in {p.id for p in target.inputs}:
raise ValueError(f"Connection {connection.id} uses an unknown block input")
def clone_component(source: Component) -> Component:
"""Deep-copy a component tree and remap every owned object ID."""
def clone(current: Component) -> Component:
child_pairs = [(child, clone(child)) for child in current.graph.blocks.values()]
child_ids = {old.id: new.id for old, new in child_pairs}
def remap(endpoint: Endpoint) -> Endpoint:
if endpoint.interface is not None:
return endpoint
return Endpoint(block=child_ids[endpoint.block or ""], port=endpoint.port)
graph = Graph(
blocks={new.id: new for _old, new in child_pairs},
connections={
new_id: Connection(
new_id,
remap(connection.source),
remap(connection.target),
connection.name,
deepcopy(connection.properties),
)
for connection in current.graph.connections.values()
for new_id in [str(uuid4())]
},
)
return Component(
id=str(uuid4()),
name=current.name,
x=current.x,
y=current.y,
inputs=[Port(port.id, port.name, port.x, port.y, deepcopy(port.properties)) for port in current.inputs],
outputs=[Port(port.id, port.name, port.x, port.y, deepcopy(port.properties)) for port in current.outputs],
icon=Icon(**current.icon.to_dict()),
properties=deepcopy(current.properties),
implementation_kind=current.implementation_kind,
graph=graph if current.implementation_kind == "graph" else Graph(),
source=deepcopy(current.source),
show_subtree_in_library=current.show_subtree_in_library,
)
return clone(source)