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.
Files changed (43) hide show
  1. silco/__init__.py +15 -0
  2. silco/core/__init__.py +31 -0
  3. silco/core/kernel.py +209 -0
  4. silco/core/models/__init__.py +12 -0
  5. silco/core/models/edge.py +30 -0
  6. silco/core/models/flow.py +6 -0
  7. silco/core/models/group.py +24 -0
  8. silco/core/models/node.py +42 -0
  9. silco/core/renderers/__init__.py +35 -0
  10. silco/core/renderers/base/__init__.py +0 -0
  11. silco/core/renderers/base/config.py +20 -0
  12. silco/core/renderers/base/diagram.py +179 -0
  13. silco/core/renderers/base/graphics.py +88 -0
  14. silco/core/renderers/base/layout.py +239 -0
  15. silco/core/renderers/base/positioned_node.py +11 -0
  16. silco/core/renderers/diagrams_backend.py +278 -0
  17. silco/core/renderers/exporter/__init__.py +0 -0
  18. silco/core/renderers/exporter/svg.py +254 -0
  19. silco/core/renderers/style.py +3 -0
  20. silco/core/renderers/svg_common.py +206 -0
  21. silco/core/templates/shapes/actor.png +0 -0
  22. silco/core/templates/shapes/actor.svg +9 -0
  23. silco/core/templates/shapes/cache.svg +7 -0
  24. silco/core/templates/shapes/component.svg +7 -0
  25. silco/core/templates/shapes/database.svg +107 -0
  26. silco/core/templates/shapes/external.svg +5 -0
  27. silco/core/templates/shapes/group.svg +3 -0
  28. silco/core/templates/shapes/queue.svg +5 -0
  29. silco/core/templates/shapes/storage.svg +8 -0
  30. silco/core/utils/__init__.py +0 -0
  31. silco/plugins/__init__.py +8 -0
  32. silco/plugins/ipython/__init__.py +60 -0
  33. silco/plugins/pdf/__init__.py +54 -0
  34. silco/plugins/renderers/__init__.py +10 -0
  35. silco/plugins/renderers/mermaid.py +31 -0
  36. silco/plugins/renderers/styles/__init__.py +5 -0
  37. silco/plugins/renderers/styles/modern.py +59 -0
  38. silco/plugins/renderers/styles/uml.py +59 -0
  39. silco-0.1.0.dist-info/METADATA +78 -0
  40. silco-0.1.0.dist-info/RECORD +43 -0
  41. silco-0.1.0.dist-info/WHEEL +5 -0
  42. silco-0.1.0.dist-info/entry_points.txt +3 -0
  43. 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,12 @@
1
+ from silco.core.models.edge import Edge
2
+ from silco.core.models.flow import Flow
3
+ from silco.core.models.group import Group
4
+ from silco.core.models.node import Node, NodeKind
5
+
6
+ __all__ = [
7
+ "Edge",
8
+ "Flow",
9
+ "Group",
10
+ "Node",
11
+ "NodeKind",
12
+ ]
@@ -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,6 @@
1
+ from silco.core.models.edge import Edge
2
+
3
+
4
+ class Flow(Edge):
5
+ """A directed flow between two nodes."""
6
+
@@ -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)