Coverage for archdocs/mermaid_syntax.py: 100%
50 statements
« prev ^ index » next coverage.py v7.15.3, created at 2026-08-03 00:00 +0000
« prev ^ index » next coverage.py v7.15.3, created at 2026-08-03 00:00 +0000
1import dataclasses
2import enum
3import types
4import typing
6from archdocs import settings
7from archdocs.diagram_model import DiagramEdge, DiagramNode, NodeGroup
10_DOUBLE_QUOTE: typing.Final = '"'
11_GROUP_OPENING_TEMPLATE: typing.Final = 'subgraph group_{group_name}["{group_title}"]'
12_GROUP_CLOSING_LINE: typing.Final = "end"
13_SERVICE_ROW_ID: typing.Final = "service_row"
14_SERVICE_ROW_OPENING: typing.Final = f'subgraph {_SERVICE_ROW_ID}[" "]'
15_SERVICE_ROW_DIRECTION_LINE: typing.Final = "direction LR"
16_SERVICE_ROW_STYLE_LINE: typing.Final = f"style {_SERVICE_ROW_ID} fill:none,stroke:none"
17_ROW_SHIFT_LEFT: typing.Final = settings.SHIFT_LEFT * 2
20@typing.final
21class GroupPlacement(enum.Enum):
22 above_service = "above"
23 left_of_service = "left"
24 right_of_service = "right"
25 below_service = "below"
28PLACEMENT_OF_NODE_GROUP: typing.Final = types.MappingProxyType(
29 {
30 NodeGroup.configuration: GroupPlacement.above_service,
31 NodeGroup.inbound_api: GroupPlacement.left_of_service,
32 NodeGroup.messaging_and_tasks: GroupPlacement.right_of_service,
33 NodeGroup.outbound_calls: GroupPlacement.right_of_service,
34 NodeGroup.data_stores: GroupPlacement.below_service,
35 },
36)
39def render_node_definition(one_node: DiagramNode, /) -> str:
40 return one_node.node_shape.value.format(
41 defined_node_id=one_node.defined_node_id,
42 node_label=one_node.node_label.replace(_DOUBLE_QUOTE, ""),
43 )
46def render_edge(one_edge: DiagramEdge, /) -> str:
47 source_node_id: typing.Final = one_edge.source_node.defined_node_id
48 target_node_id: typing.Final = one_edge.target_node.defined_node_id
49 escaped_label: typing.Final = one_edge.edge_label.replace(_DOUBLE_QUOTE, "")
50 if not escaped_label:
51 return f"{settings.SHIFT_LEFT}{source_node_id} --> {target_node_id}"
52 return f'{settings.SHIFT_LEFT}{source_node_id} --> |"{escaped_label}"| {target_node_id}'
55# Mermaid has no coordinates: the page flows top to bottom, and a borderless row with its own
56# `direction LR` turns the middle band sideways, so a group lands on the side it is written on.
57@typing.final
58@dataclasses.dataclass(frozen=True, slots=True, kw_only=True)
59class MermaidDiagram:
60 service_node: DiagramNode
61 all_edges: tuple[DiagramEdge, ...]
63 def render_every_line(self) -> str:
64 return "\n".join(
65 (
66 *self._render_definition_lines(),
67 *dict.fromkeys(render_edge(one_edge) for one_edge in self.all_edges),
68 ),
69 )
71 def _collect_drawn_nodes(self) -> tuple[DiagramNode, ...]:
72 return tuple(
73 {
74 one_node.defined_node_id: one_node
75 for one_node in (
76 self.service_node,
77 *(
78 one_edge_end
79 for one_edge in self.all_edges
80 for one_edge_end in (one_edge.source_node, one_edge.target_node)
81 ),
82 )
83 }.values(),
84 )
86 def _render_group_lines(
87 self,
88 node_group: NodeGroup,
89 all_nodes: tuple[DiagramNode, ...],
90 group_shift: str,
91 /,
92 ) -> tuple[str, ...]:
93 grouped_nodes: typing.Final = tuple(one_node for one_node in all_nodes if one_node.node_group is node_group)
94 if not grouped_nodes:
95 return ()
96 return (
97 group_shift + _GROUP_OPENING_TEMPLATE.format(group_name=node_group.name, group_title=node_group.value),
98 *(group_shift + settings.SHIFT_LEFT + render_node_definition(one_node) for one_node in grouped_nodes),
99 group_shift + _GROUP_CLOSING_LINE,
100 )
102 def _render_placement_lines(
103 self,
104 group_placement: GroupPlacement,
105 all_nodes: tuple[DiagramNode, ...],
106 group_shift: str,
107 /,
108 ) -> tuple[str, ...]:
109 return tuple(
110 one_group_line
111 for one_node_group in NodeGroup
112 if PLACEMENT_OF_NODE_GROUP[one_node_group] is group_placement
113 for one_group_line in self._render_group_lines(one_node_group, all_nodes, group_shift)
114 )
116 def _render_service_row_lines(self, all_nodes: tuple[DiagramNode, ...], /) -> tuple[str, ...]:
117 sideways_lines: typing.Final = (
118 *self._render_placement_lines(GroupPlacement.left_of_service, all_nodes, _ROW_SHIFT_LEFT),
119 _ROW_SHIFT_LEFT + render_node_definition(self.service_node),
120 *self._render_placement_lines(GroupPlacement.right_of_service, all_nodes, _ROW_SHIFT_LEFT),
121 )
122 return (
123 settings.SHIFT_LEFT + _SERVICE_ROW_OPENING,
124 _ROW_SHIFT_LEFT + _SERVICE_ROW_DIRECTION_LINE,
125 *sideways_lines,
126 settings.SHIFT_LEFT + _GROUP_CLOSING_LINE,
127 settings.SHIFT_LEFT + _SERVICE_ROW_STYLE_LINE,
128 )
130 def _render_definition_lines(self) -> tuple[str, ...]:
131 all_nodes: typing.Final = self._collect_drawn_nodes()
132 return (
133 *(
134 settings.SHIFT_LEFT + render_node_definition(one_node)
135 for one_node in all_nodes
136 if one_node.node_group is None and one_node.defined_node_id != self.service_node.defined_node_id
137 ),
138 *self._render_placement_lines(GroupPlacement.above_service, all_nodes, settings.SHIFT_LEFT),
139 *self._render_service_row_lines(all_nodes),
140 *self._render_placement_lines(GroupPlacement.below_service, all_nodes, settings.SHIFT_LEFT),
141 )