silco 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- silco/__init__.py +15 -0
- silco/core/__init__.py +31 -0
- silco/core/kernel.py +209 -0
- silco/core/models/__init__.py +12 -0
- silco/core/models/edge.py +30 -0
- silco/core/models/flow.py +6 -0
- silco/core/models/group.py +24 -0
- silco/core/models/node.py +42 -0
- silco/core/renderers/__init__.py +35 -0
- silco/core/renderers/base/__init__.py +0 -0
- silco/core/renderers/base/config.py +20 -0
- silco/core/renderers/base/diagram.py +179 -0
- silco/core/renderers/base/graphics.py +88 -0
- silco/core/renderers/base/layout.py +239 -0
- silco/core/renderers/base/positioned_node.py +11 -0
- silco/core/renderers/diagrams_backend.py +278 -0
- silco/core/renderers/exporter/__init__.py +0 -0
- silco/core/renderers/exporter/svg.py +254 -0
- silco/core/renderers/style.py +3 -0
- silco/core/renderers/svg_common.py +206 -0
- silco/core/templates/shapes/actor.png +0 -0
- silco/core/templates/shapes/actor.svg +9 -0
- silco/core/templates/shapes/cache.svg +7 -0
- silco/core/templates/shapes/component.svg +7 -0
- silco/core/templates/shapes/database.svg +107 -0
- silco/core/templates/shapes/external.svg +5 -0
- silco/core/templates/shapes/group.svg +3 -0
- silco/core/templates/shapes/queue.svg +5 -0
- silco/core/templates/shapes/storage.svg +8 -0
- silco/core/utils/__init__.py +0 -0
- silco/plugins/__init__.py +8 -0
- silco/plugins/ipython/__init__.py +60 -0
- silco/plugins/pdf/__init__.py +54 -0
- silco/plugins/renderers/__init__.py +10 -0
- silco/plugins/renderers/mermaid.py +31 -0
- silco/plugins/renderers/styles/__init__.py +5 -0
- silco/plugins/renderers/styles/modern.py +59 -0
- silco/plugins/renderers/styles/uml.py +59 -0
- silco-0.1.0.dist-info/METADATA +78 -0
- silco-0.1.0.dist-info/RECORD +43 -0
- silco-0.1.0.dist-info/WHEEL +5 -0
- silco-0.1.0.dist-info/entry_points.txt +3 -0
- silco-0.1.0.dist-info/top_level.txt +1 -0
silco/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from silco.core import Canvas, Diagram, Edge, Element, Flow, Group, Node, PluginInfo, RenderConfig, diagram, kernel
|
|
2
|
+
|
|
3
|
+
__all__ = [
|
|
4
|
+
"Canvas",
|
|
5
|
+
"Diagram",
|
|
6
|
+
"Edge",
|
|
7
|
+
"Element",
|
|
8
|
+
"Flow",
|
|
9
|
+
"Group",
|
|
10
|
+
"Node",
|
|
11
|
+
"PluginInfo",
|
|
12
|
+
"RenderConfig",
|
|
13
|
+
"diagram",
|
|
14
|
+
"kernel",
|
|
15
|
+
]
|
silco/core/__init__.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from silco.core.renderers.base.config import RenderConfig
|
|
2
|
+
from silco.core.renderers.base.diagram import Diagram, diagram
|
|
3
|
+
from silco.core.renderers.base.layout import Layout
|
|
4
|
+
from silco.core.renderers.base.positioned_node import PositionedNode
|
|
5
|
+
from silco.core.renderers.base.graphics import Canvas, Element
|
|
6
|
+
from silco.core.kernel import PLUGIN_CATEGORIES, PluginCategory, PluginInfo, SilcoKernel, kernel
|
|
7
|
+
from silco.core.models import Edge, Flow, Group, Node, NodeKind
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"Canvas",
|
|
11
|
+
"Diagram",
|
|
12
|
+
"Edge",
|
|
13
|
+
"Element",
|
|
14
|
+
"Flow",
|
|
15
|
+
"Group",
|
|
16
|
+
"Layout",
|
|
17
|
+
"Node",
|
|
18
|
+
"NodeKind",
|
|
19
|
+
"PLUGIN_CATEGORIES",
|
|
20
|
+
"PluginCategory",
|
|
21
|
+
"PluginInfo",
|
|
22
|
+
"PositionedNode",
|
|
23
|
+
"RenderConfig",
|
|
24
|
+
"SilcoKernel",
|
|
25
|
+
"diagram",
|
|
26
|
+
"kernel",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
# Register built-in layouts/renderers on import.
|
|
30
|
+
# This keeps `Diagram.to_svg()` working out of the box.
|
|
31
|
+
from silco.core import renderers as _renderers # noqa: F401, E402
|
silco/core/kernel.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
import inspect
|
|
5
|
+
import pkgutil
|
|
6
|
+
from collections.abc import Callable, Iterable
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from importlib import metadata
|
|
9
|
+
from types import ModuleType
|
|
10
|
+
from typing import Any, Literal
|
|
11
|
+
|
|
12
|
+
PluginCategory = Literal["shapes", "renderers", "layouts", "presenters", "styles"]
|
|
13
|
+
PluginType = PluginCategory
|
|
14
|
+
PLUGIN_CATEGORIES: tuple[PluginCategory, ...] = ("shapes", "renderers", "layouts", "presenters", "styles")
|
|
15
|
+
_CATEGORY_ALIASES = {
|
|
16
|
+
"shape": "shapes",
|
|
17
|
+
"shapes": "shapes",
|
|
18
|
+
"renderer": "renderers",
|
|
19
|
+
"renderers": "renderers",
|
|
20
|
+
"style": "styles",
|
|
21
|
+
"styles": "styles",
|
|
22
|
+
"layout": "layouts",
|
|
23
|
+
"layouts": "layouts",
|
|
24
|
+
"presenter": "presenters",
|
|
25
|
+
"presenters": "presenters",
|
|
26
|
+
"representation": "presenters",
|
|
27
|
+
"representations": "presenters",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class PluginInfo:
|
|
33
|
+
"""Metadata describing a registered plugin."""
|
|
34
|
+
|
|
35
|
+
category: PluginCategory
|
|
36
|
+
name: str
|
|
37
|
+
plugin: Any
|
|
38
|
+
description: str | None = None
|
|
39
|
+
module: str | None = None
|
|
40
|
+
tags: tuple[str, ...] = field(default_factory=tuple)
|
|
41
|
+
auto_discovered: bool = False
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class SilcoKernel:
|
|
45
|
+
"""Registry and discovery hub for core and optional plugins."""
|
|
46
|
+
|
|
47
|
+
def __init__(self) -> None:
|
|
48
|
+
self.plugins: dict[PluginCategory, dict[str, Any]] = {category: {} for category in PLUGIN_CATEGORIES}
|
|
49
|
+
self._plugin_info: dict[PluginCategory, dict[str, PluginInfo]] = {category: {} for category in PLUGIN_CATEGORIES}
|
|
50
|
+
self._discovered_modules: set[str] = set()
|
|
51
|
+
self._discovered_entry_points: set[str] = set()
|
|
52
|
+
|
|
53
|
+
def register(
|
|
54
|
+
self,
|
|
55
|
+
plugin_type: str,
|
|
56
|
+
name: str,
|
|
57
|
+
plugin: Any,
|
|
58
|
+
*,
|
|
59
|
+
description: str | None = None,
|
|
60
|
+
module: str | None = None,
|
|
61
|
+
tags: Iterable[str] = (),
|
|
62
|
+
auto_discovered: bool = False,
|
|
63
|
+
) -> Any:
|
|
64
|
+
category = self.normalize_category(plugin_type)
|
|
65
|
+
if not name.strip():
|
|
66
|
+
raise ValueError("plugin name cannot be empty")
|
|
67
|
+
plugin_name = name.strip()
|
|
68
|
+
self.plugins[category][plugin_name] = plugin
|
|
69
|
+
self._plugin_info[category][plugin_name] = PluginInfo(
|
|
70
|
+
category=category,
|
|
71
|
+
name=plugin_name,
|
|
72
|
+
plugin=plugin,
|
|
73
|
+
description=description,
|
|
74
|
+
module=module or getattr(plugin, "__module__", None),
|
|
75
|
+
tags=tuple(tags),
|
|
76
|
+
auto_discovered=auto_discovered,
|
|
77
|
+
)
|
|
78
|
+
return plugin
|
|
79
|
+
|
|
80
|
+
def decorator(
|
|
81
|
+
self,
|
|
82
|
+
plugin_type: str,
|
|
83
|
+
name: str,
|
|
84
|
+
*,
|
|
85
|
+
description: str | None = None,
|
|
86
|
+
tags: Iterable[str] = (),
|
|
87
|
+
) -> Callable[[Any], Any]:
|
|
88
|
+
def register_plugin(plugin: Any) -> Any:
|
|
89
|
+
return self.register(plugin_type, name, plugin, description=description, tags=tags)
|
|
90
|
+
|
|
91
|
+
return register_plugin
|
|
92
|
+
|
|
93
|
+
def get(self, plugin_type: str, name: str, default: Any = None) -> Any:
|
|
94
|
+
category = self.normalize_category(plugin_type)
|
|
95
|
+
if default is None:
|
|
96
|
+
return self.plugins[category][name]
|
|
97
|
+
return self.plugins[category].get(name, default)
|
|
98
|
+
|
|
99
|
+
def info(self, plugin_type: str, name: str) -> PluginInfo:
|
|
100
|
+
category = self.normalize_category(plugin_type)
|
|
101
|
+
return self._plugin_info[category][name]
|
|
102
|
+
|
|
103
|
+
def list(self, plugin_type: str | None = None) -> dict[PluginCategory, tuple[PluginInfo, ...]] | tuple[PluginInfo, ...]:
|
|
104
|
+
if plugin_type is not None:
|
|
105
|
+
category = self.normalize_category(plugin_type)
|
|
106
|
+
return tuple(self._plugin_info[category].values())
|
|
107
|
+
return {category: tuple(self._plugin_info[category].values()) for category in PLUGIN_CATEGORIES}
|
|
108
|
+
|
|
109
|
+
def names(self, plugin_type: str) -> tuple[str, ...]:
|
|
110
|
+
category = self.normalize_category(plugin_type)
|
|
111
|
+
return tuple(self.plugins[category])
|
|
112
|
+
|
|
113
|
+
def categories(self) -> tuple[PluginCategory, ...]:
|
|
114
|
+
return PLUGIN_CATEGORIES
|
|
115
|
+
|
|
116
|
+
def normalize_category(self, plugin_type: str) -> PluginCategory:
|
|
117
|
+
try:
|
|
118
|
+
return _CATEGORY_ALIASES[plugin_type]
|
|
119
|
+
except KeyError as exc:
|
|
120
|
+
valid = ", ".join(PLUGIN_CATEGORIES)
|
|
121
|
+
raise ValueError(f"Unknown plugin category: {plugin_type}. Expected one of: {valid}") from exc
|
|
122
|
+
|
|
123
|
+
def discover(
|
|
124
|
+
self,
|
|
125
|
+
*,
|
|
126
|
+
namespace: str = "silco.plugins",
|
|
127
|
+
entry_point_group: str = "silco.plugins",
|
|
128
|
+
) -> tuple[PluginInfo, ...]:
|
|
129
|
+
"""Import plugin modules and entry points, returning newly registered plugins."""
|
|
130
|
+
|
|
131
|
+
before = self._snapshot()
|
|
132
|
+
self.discover_namespace(namespace)
|
|
133
|
+
self.discover_entry_points(entry_point_group)
|
|
134
|
+
return self._new_plugins_since(before)
|
|
135
|
+
|
|
136
|
+
def discover_namespace(self, namespace: str = "silco.plugins") -> tuple[ModuleType, ...]:
|
|
137
|
+
package = importlib.import_module(namespace)
|
|
138
|
+
modules: list[ModuleType] = []
|
|
139
|
+
package_path = getattr(package, "__path__", None)
|
|
140
|
+
if package_path is None:
|
|
141
|
+
return tuple(modules)
|
|
142
|
+
for module_info in pkgutil.iter_modules(package_path, f"{namespace}."):
|
|
143
|
+
if module_info.name in self._discovered_modules:
|
|
144
|
+
continue
|
|
145
|
+
module = importlib.import_module(module_info.name)
|
|
146
|
+
self._discovered_modules.add(module_info.name)
|
|
147
|
+
self._register_from_module(module)
|
|
148
|
+
modules.append(module)
|
|
149
|
+
return tuple(modules)
|
|
150
|
+
|
|
151
|
+
def discover_entry_points(self, group: str = "silco.plugins") -> tuple[Any, ...]:
|
|
152
|
+
loaded: list[Any] = []
|
|
153
|
+
entry_points = metadata.entry_points()
|
|
154
|
+
if hasattr(entry_points, "select"):
|
|
155
|
+
selected = entry_points.select(group=group)
|
|
156
|
+
else: # pragma: no cover - compatibility with old importlib.metadata
|
|
157
|
+
selected = entry_points.get(group, ())
|
|
158
|
+
for entry_point in selected:
|
|
159
|
+
key = f"{entry_point.group}:{entry_point.name}"
|
|
160
|
+
if key in self._discovered_entry_points:
|
|
161
|
+
continue
|
|
162
|
+
plugin = entry_point.load()
|
|
163
|
+
self._discovered_entry_points.add(key)
|
|
164
|
+
self._register_loaded_plugin(entry_point.name, plugin)
|
|
165
|
+
loaded.append(plugin)
|
|
166
|
+
return tuple(loaded)
|
|
167
|
+
|
|
168
|
+
def _register_from_module(self, module: ModuleType) -> None:
|
|
169
|
+
register = getattr(module, "register_plugins", None)
|
|
170
|
+
if callable(register):
|
|
171
|
+
register(self)
|
|
172
|
+
|
|
173
|
+
def _register_loaded_plugin(self, name: str, plugin: Any) -> None:
|
|
174
|
+
if inspect.ismodule(plugin):
|
|
175
|
+
self._register_from_module(plugin)
|
|
176
|
+
return
|
|
177
|
+
register = getattr(plugin, "register_plugins", None)
|
|
178
|
+
if callable(register):
|
|
179
|
+
register(self)
|
|
180
|
+
return
|
|
181
|
+
category = getattr(plugin, "silco_plugin_category", None)
|
|
182
|
+
plugin_name = getattr(plugin, "silco_plugin_name", name)
|
|
183
|
+
if category is None and callable(plugin):
|
|
184
|
+
plugin(self)
|
|
185
|
+
return
|
|
186
|
+
if category is None:
|
|
187
|
+
raise ValueError(f"Discovered plugin {name!r} does not declare a Silco category")
|
|
188
|
+
self.register(
|
|
189
|
+
category,
|
|
190
|
+
plugin_name,
|
|
191
|
+
plugin,
|
|
192
|
+
description=getattr(plugin, "silco_plugin_description", None),
|
|
193
|
+
tags=getattr(plugin, "silco_plugin_tags", ()),
|
|
194
|
+
auto_discovered=True,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
def _snapshot(self) -> set[tuple[PluginCategory, str]]:
|
|
198
|
+
return {(category, name) for category, plugins in self.plugins.items() for name in plugins}
|
|
199
|
+
|
|
200
|
+
def _new_plugins_since(self, snapshot: set[tuple[PluginCategory, str]]) -> tuple[PluginInfo, ...]:
|
|
201
|
+
new_plugins: list[PluginInfo] = []
|
|
202
|
+
for category, infos in self._plugin_info.items():
|
|
203
|
+
for name, info in infos.items():
|
|
204
|
+
if (category, name) not in snapshot:
|
|
205
|
+
new_plugins.append(info)
|
|
206
|
+
return tuple(new_plugins)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
kernel = SilcoKernel()
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class Edge(BaseModel):
|
|
6
|
+
"""A directed relationship between two nodes."""
|
|
7
|
+
|
|
8
|
+
model_config = ConfigDict(extra="forbid")
|
|
9
|
+
|
|
10
|
+
source: str = Field(min_length=1)
|
|
11
|
+
target: str = Field(min_length=1)
|
|
12
|
+
label: str | None = None
|
|
13
|
+
protocol: str | None = None
|
|
14
|
+
bidirectional: bool = False
|
|
15
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
16
|
+
|
|
17
|
+
@field_validator("source", "target")
|
|
18
|
+
@classmethod
|
|
19
|
+
def normalize_endpoint(cls, value: str) -> str:
|
|
20
|
+
value = value.strip()
|
|
21
|
+
if not value:
|
|
22
|
+
raise ValueError("edge endpoint cannot be empty")
|
|
23
|
+
return value
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def display_label(self) -> str | None:
|
|
27
|
+
if self.label and self.protocol:
|
|
28
|
+
return f"{self.label} ({self.protocol})"
|
|
29
|
+
return self.label or self.protocol
|
|
30
|
+
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class Group(BaseModel):
|
|
6
|
+
"""A visual boundary for a set of related nodes."""
|
|
7
|
+
|
|
8
|
+
model_config = ConfigDict(extra="forbid")
|
|
9
|
+
|
|
10
|
+
id: str = Field(min_length=1)
|
|
11
|
+
label: str | None = None
|
|
12
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
13
|
+
|
|
14
|
+
@field_validator("id")
|
|
15
|
+
@classmethod
|
|
16
|
+
def normalize_identifier(cls, value: str) -> str:
|
|
17
|
+
value = value.strip()
|
|
18
|
+
if not value:
|
|
19
|
+
raise ValueError("group id cannot be empty")
|
|
20
|
+
return value
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def display_label(self) -> str:
|
|
24
|
+
return self.label or self.id
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from typing import Any, Literal
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
4
|
+
|
|
5
|
+
NodeKind = Literal[
|
|
6
|
+
"actor",
|
|
7
|
+
"service",
|
|
8
|
+
"database",
|
|
9
|
+
"queue",
|
|
10
|
+
"cache",
|
|
11
|
+
"storage",
|
|
12
|
+
"external",
|
|
13
|
+
"component",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Node(BaseModel):
|
|
18
|
+
"""A system component that can be rendered in a diagram."""
|
|
19
|
+
|
|
20
|
+
model_config = ConfigDict(extra="forbid")
|
|
21
|
+
|
|
22
|
+
id: str = Field(min_length=1)
|
|
23
|
+
label: str | None = None
|
|
24
|
+
kind: NodeKind = "component"
|
|
25
|
+
description: str | None = None
|
|
26
|
+
group: str | None = None
|
|
27
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
28
|
+
|
|
29
|
+
@field_validator("id", "group")
|
|
30
|
+
@classmethod
|
|
31
|
+
def normalize_identifier(cls, value: str | None) -> str | None:
|
|
32
|
+
if value is None:
|
|
33
|
+
return None
|
|
34
|
+
value = value.strip()
|
|
35
|
+
if not value:
|
|
36
|
+
raise ValueError("identifier cannot be empty")
|
|
37
|
+
return value
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def display_label(self) -> str:
|
|
41
|
+
return self.label or self.id
|
|
42
|
+
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from silco.core.renderers.exporter.svg import svg_renderer
|
|
2
|
+
from silco.core.kernel import kernel
|
|
3
|
+
from silco.core.renderers.base.layout import dag_layout, grid_layout
|
|
4
|
+
from silco.core.renderers.svg_common import render_mermaid
|
|
5
|
+
|
|
6
|
+
kernel.register(
|
|
7
|
+
"layouts",
|
|
8
|
+
"dag",
|
|
9
|
+
dag_layout,
|
|
10
|
+
description="Layered directed-acyclic-graph layout for left-to-right system flows.",
|
|
11
|
+
tags=("builtin", "layout"),
|
|
12
|
+
)
|
|
13
|
+
kernel.register(
|
|
14
|
+
"layouts",
|
|
15
|
+
"grid",
|
|
16
|
+
grid_layout,
|
|
17
|
+
description="Simple grid layout for unordered component maps.",
|
|
18
|
+
tags=("builtin", "layout"),
|
|
19
|
+
)
|
|
20
|
+
kernel.register(
|
|
21
|
+
"renderers",
|
|
22
|
+
"svg",
|
|
23
|
+
svg_renderer,
|
|
24
|
+
description="Standalone SVG renderer for notebooks, docs, and exports.",
|
|
25
|
+
tags=("builtin", "renderer", "vector"),
|
|
26
|
+
)
|
|
27
|
+
kernel.register(
|
|
28
|
+
"renderers",
|
|
29
|
+
"mermaid",
|
|
30
|
+
render_mermaid,
|
|
31
|
+
description="Mermaid flowchart text renderer.",
|
|
32
|
+
tags=("builtin", "renderer", "text"),
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
__all__ = ["render_mermaid", "svg_renderer"]
|
|
File without changes
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class RenderConfig(BaseModel):
|
|
7
|
+
"""Rendering knobs shared by built-in renderers."""
|
|
8
|
+
|
|
9
|
+
model_config = ConfigDict(extra="forbid")
|
|
10
|
+
|
|
11
|
+
direction: str = "LR"
|
|
12
|
+
width: int = Field(default=960, ge=320)
|
|
13
|
+
node_width: int = Field(default=168, ge=80)
|
|
14
|
+
node_height: int = Field(default=84, ge=48)
|
|
15
|
+
rank_gap: int = Field(default=72, ge=40)
|
|
16
|
+
node_gap: int = Field(default=48, ge=20)
|
|
17
|
+
margin: int = Field(default=32, ge=0)
|
|
18
|
+
style: str = Field(default="modern", min_length=1)
|
|
19
|
+
font_family: str = "ui-sans-serif, Segoe UI, sans-serif"
|
|
20
|
+
title: bool = True
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
from os import PathLike
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, field_validator, model_validator
|
|
9
|
+
|
|
10
|
+
from silco.core.kernel import kernel
|
|
11
|
+
from silco.core.models.edge import Edge
|
|
12
|
+
from silco.core.models.flow import Flow
|
|
13
|
+
from silco.core.models.group import Group
|
|
14
|
+
from silco.core.models.node import Node
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from silco.core.renderers.base.layout import Layout
|
|
18
|
+
|
|
19
|
+
Direction = Literal["LR", "RL", "TB", "BT"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Diagram(BaseModel):
|
|
23
|
+
"""Mutable system-design diagram model with kernel-backed rendering."""
|
|
24
|
+
|
|
25
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
26
|
+
|
|
27
|
+
title: str | None = None
|
|
28
|
+
direction: Direction = "LR"
|
|
29
|
+
nodes: dict[str, Node] = Field(default_factory=dict)
|
|
30
|
+
edges: list[Edge] = Field(default_factory=list)
|
|
31
|
+
flows: list[Flow] = Field(default_factory=list)
|
|
32
|
+
groups: dict[str, Group] = Field(default_factory=dict)
|
|
33
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
34
|
+
_default_renderer: str = PrivateAttr(default="svg")
|
|
35
|
+
|
|
36
|
+
@field_validator("direction")
|
|
37
|
+
@classmethod
|
|
38
|
+
def validate_direction(cls, value: str) -> str:
|
|
39
|
+
if value not in {"LR", "RL", "TB", "BT"}:
|
|
40
|
+
raise ValueError("direction must be one of LR, RL, TB, BT")
|
|
41
|
+
return value
|
|
42
|
+
|
|
43
|
+
@model_validator(mode="after")
|
|
44
|
+
def validate_relations(self) -> "Diagram":
|
|
45
|
+
for collection_name, relations in (("edges", self.edges), ("flows", self.flows)):
|
|
46
|
+
missing = [f"{relation.source}->{relation.target}" for relation in relations if relation.source not in self.nodes or relation.target not in self.nodes]
|
|
47
|
+
if missing:
|
|
48
|
+
joined = ", ".join(missing)
|
|
49
|
+
raise ValueError(f"{collection_name} reference unknown nodes: {joined}")
|
|
50
|
+
return self
|
|
51
|
+
|
|
52
|
+
def add_node(
|
|
53
|
+
self,
|
|
54
|
+
id: str,
|
|
55
|
+
label: str | None = None,
|
|
56
|
+
*,
|
|
57
|
+
kind: str = "component",
|
|
58
|
+
group: str | None = None,
|
|
59
|
+
description: str | None = None,
|
|
60
|
+
**metadata: Any,
|
|
61
|
+
) -> "Diagram":
|
|
62
|
+
node = Node(id=id, label=label, kind=kind, group=group, description=description, metadata=metadata)
|
|
63
|
+
if node.id in self.nodes:
|
|
64
|
+
raise ValueError(f"node already exists: {node.id}")
|
|
65
|
+
self.nodes[node.id] = node
|
|
66
|
+
if node.group and node.group not in self.groups:
|
|
67
|
+
self.add_group(node.group)
|
|
68
|
+
return self
|
|
69
|
+
|
|
70
|
+
node = add_node
|
|
71
|
+
|
|
72
|
+
def add_group(self, id: str, label: str | None = None, **metadata: Any) -> "Diagram":
|
|
73
|
+
group = Group(id=id, label=label, metadata=metadata)
|
|
74
|
+
current = self.groups.get(group.id)
|
|
75
|
+
if current is None:
|
|
76
|
+
self.groups[group.id] = group
|
|
77
|
+
return self
|
|
78
|
+
if label is not None:
|
|
79
|
+
current.label = label
|
|
80
|
+
current.metadata.update(metadata)
|
|
81
|
+
return self
|
|
82
|
+
|
|
83
|
+
group = add_group
|
|
84
|
+
|
|
85
|
+
def _append_relation(
|
|
86
|
+
self,
|
|
87
|
+
relation_type: type[Edge] | type[Flow],
|
|
88
|
+
source: str,
|
|
89
|
+
target: str,
|
|
90
|
+
label: str | None = None,
|
|
91
|
+
*,
|
|
92
|
+
protocol: str | None = None,
|
|
93
|
+
bidirectional: bool = False,
|
|
94
|
+
**metadata: Any,
|
|
95
|
+
) -> "Diagram":
|
|
96
|
+
if source not in self.nodes:
|
|
97
|
+
raise ValueError(f"unknown source node: {source}")
|
|
98
|
+
if target not in self.nodes:
|
|
99
|
+
raise ValueError(f"unknown target node: {target}")
|
|
100
|
+
relation = relation_type(
|
|
101
|
+
source=source,
|
|
102
|
+
target=target,
|
|
103
|
+
label=label,
|
|
104
|
+
protocol=protocol,
|
|
105
|
+
bidirectional=bidirectional,
|
|
106
|
+
metadata=metadata,
|
|
107
|
+
)
|
|
108
|
+
target_collection = self.flows if relation_type is Flow else self.edges
|
|
109
|
+
target_collection.append(relation)
|
|
110
|
+
return self
|
|
111
|
+
|
|
112
|
+
def add_edge(
|
|
113
|
+
self,
|
|
114
|
+
source: str,
|
|
115
|
+
target: str,
|
|
116
|
+
label: str | None = None,
|
|
117
|
+
*,
|
|
118
|
+
protocol: str | None = None,
|
|
119
|
+
bidirectional: bool = False,
|
|
120
|
+
**metadata: Any,
|
|
121
|
+
) -> "Diagram":
|
|
122
|
+
return self._append_relation(Edge, source, target, label, protocol=protocol, bidirectional=bidirectional, **metadata)
|
|
123
|
+
|
|
124
|
+
connect = add_edge
|
|
125
|
+
|
|
126
|
+
def add_flow(
|
|
127
|
+
self,
|
|
128
|
+
source: str,
|
|
129
|
+
target: str,
|
|
130
|
+
label: str | None = None,
|
|
131
|
+
*,
|
|
132
|
+
protocol: str | None = None,
|
|
133
|
+
bidirectional: bool = False,
|
|
134
|
+
**metadata: Any,
|
|
135
|
+
) -> "Diagram":
|
|
136
|
+
return self._append_relation(Flow, source, target, label, protocol=protocol, bidirectional=bidirectional, **metadata)
|
|
137
|
+
|
|
138
|
+
flow = add_flow
|
|
139
|
+
|
|
140
|
+
def render(self, renderer: str | None = None, **options: Any) -> Any:
|
|
141
|
+
render_name = renderer or self._default_renderer
|
|
142
|
+
return kernel.get("renderers", render_name)(self, **options)
|
|
143
|
+
|
|
144
|
+
def layout(self, layout: str = "dag", **options: Any) -> Layout:
|
|
145
|
+
return kernel.get("layouts", layout)(self, **options)
|
|
146
|
+
|
|
147
|
+
def to_svg(self, **options: Any) -> str:
|
|
148
|
+
return self.render("svg", **options)
|
|
149
|
+
|
|
150
|
+
def to_mermaid(self, **options: Any) -> str:
|
|
151
|
+
return self.render("mermaid", **options)
|
|
152
|
+
|
|
153
|
+
def to_html(self, **options: Any) -> str:
|
|
154
|
+
return f'<div class="silco-diagram">{self.to_svg(**options)}</div>'
|
|
155
|
+
|
|
156
|
+
def to_pdf(self, **options: Any) -> bytes:
|
|
157
|
+
if "pdf" not in kernel.names("renderers"):
|
|
158
|
+
importlib.import_module("silco.plugins.pdf")
|
|
159
|
+
return self.render("pdf", **options)
|
|
160
|
+
|
|
161
|
+
def save_pdf(self, path: str | PathLike[str], **options: Any) -> Path:
|
|
162
|
+
pdf = importlib.import_module("silco.plugins.pdf")
|
|
163
|
+
return pdf.save_pdf(self, path, **options)
|
|
164
|
+
|
|
165
|
+
def _repr_svg_(self) -> str:
|
|
166
|
+
return self.to_svg()
|
|
167
|
+
|
|
168
|
+
def _repr_html_(self) -> str:
|
|
169
|
+
return self.to_html()
|
|
170
|
+
|
|
171
|
+
def __str__(self) -> str:
|
|
172
|
+
name = f" {self.title!r}" if self.title else ""
|
|
173
|
+
return f"<Diagram{name}: {len(self.nodes)} nodes, {len(self.edges) + len(self.flows)} relations>"
|
|
174
|
+
|
|
175
|
+
__repr__ = __str__
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def diagram(title: str | None = None, *, direction: Direction = "LR") -> Diagram:
|
|
179
|
+
return Diagram(title=title, direction=direction)
|