Coverage for archdocs/mermaid_syntax.py: 100%
52 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-18 22:03 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-18 22:03 +0000
1import dataclasses
2import enum
3import types
4import typing
6from archdocs import diagram_model, settings
9DIAGRAM_HEADER: typing.Final = "graph TB"
10# A quote closes the label it stands in, and a pipe closes the edge label around it: either one
11# arriving from a topic name or a manifest value breaks the render, and a broken render is not an
12# error message but a page that stays hidden.
13_REMOVED_FROM_NODE_LABEL: typing.Final = str.maketrans("", "", '"')
14_REMOVED_FROM_EDGE_LABEL: typing.Final = str.maketrans("", "", '"|')
15_GROUP_OPENING_TEMPLATE: typing.Final = 'subgraph group_{group_name}["{group_title}"]'
16_GROUP_CLOSING_LINE: typing.Final = "end"
17_SERVICE_ROW_ID: typing.Final = "service_row"
18_SERVICE_ROW_OPENING: typing.Final = f'subgraph {_SERVICE_ROW_ID}[" "]'
19_SERVICE_ROW_DIRECTION_LINE: typing.Final = "direction LR"
20_SERVICE_ROW_STYLE_LINE: typing.Final = f"style {_SERVICE_ROW_ID} fill:none,stroke:none"
21_ROW_INDENT: typing.Final = settings.LINE_INDENT * 2
24@typing.final
25class GroupPlacement(enum.Enum):
26 above_service = "above"
27 left_of_service = "left"
28 right_of_service = "right"
29 below_service = "below"
32PLACEMENT_OF_NODE_GROUP: typing.Final = types.MappingProxyType(
33 {
34 diagram_model.NodeGroup.configuration: GroupPlacement.above_service,
35 diagram_model.NodeGroup.inbound_api: GroupPlacement.left_of_service,
36 diagram_model.NodeGroup.messaging_and_tasks: GroupPlacement.right_of_service,
37 diagram_model.NodeGroup.outbound_calls: GroupPlacement.right_of_service,
38 diagram_model.NodeGroup.data_stores: GroupPlacement.below_service,
39 },
40)
41# The templates live next to the translation tables above: how a shape is written in mermaid and
42# what its label may contain is one piece of knowledge, and the model does not hold any of it.
43_TEMPLATE_OF_NODE_SHAPE: typing.Final = types.MappingProxyType(
44 {
45 diagram_model.NodeShape.plain_node: '{defined_node_id}["{node_label}"]',
46 diagram_model.NodeShape.service_node: '{defined_node_id}{{"{node_label}"}}',
47 },
48)
51def render_node_definition(one_node: diagram_model.DiagramNode, /) -> str:
52 return _TEMPLATE_OF_NODE_SHAPE[one_node.node_shape].format(
53 defined_node_id=one_node.defined_node_id,
54 node_label=one_node.node_label.translate(_REMOVED_FROM_NODE_LABEL),
55 )
58def render_edge(one_edge: diagram_model.DiagramEdge, /) -> str:
59 source_node_id: typing.Final = one_edge.source_node.defined_node_id
60 target_node_id: typing.Final = one_edge.target_node.defined_node_id
61 drawable_label: typing.Final = one_edge.edge_label.translate(_REMOVED_FROM_EDGE_LABEL)
62 if not drawable_label:
63 return f"{settings.LINE_INDENT}{source_node_id} --> {target_node_id}"
64 return f'{settings.LINE_INDENT}{source_node_id} --> |"{drawable_label}"| {target_node_id}'
67# Mermaid has no coordinates: the page flows top to bottom, and a borderless row with its own
68# `direction LR` turns the middle band sideways, so a group lands on the side it is written on.
69@typing.final
70@dataclasses.dataclass(frozen=True, slots=True, kw_only=True)
71class MermaidDiagram:
72 service_node: diagram_model.DiagramNode
73 all_edges: tuple[diagram_model.DiagramEdge, ...]
75 def render_every_line(self) -> str:
76 return "\n".join(
77 (
78 *self._render_definition_lines(),
79 *dict.fromkeys(render_edge(one_edge) for one_edge in self.all_edges),
80 ),
81 )
83 def _collect_drawn_nodes(self) -> tuple[diagram_model.DiagramNode, ...]:
84 return tuple(
85 {
86 one_node.defined_node_id: one_node
87 for one_node in (
88 self.service_node,
89 *(
90 one_edge_end
91 for one_edge in self.all_edges
92 for one_edge_end in (one_edge.source_node, one_edge.target_node)
93 ),
94 )
95 }.values(),
96 )
98 def _render_group_lines(
99 self,
100 node_group: diagram_model.NodeGroup,
101 all_nodes: tuple[diagram_model.DiagramNode, ...],
102 group_shift: str,
103 /,
104 ) -> tuple[str, ...]:
105 grouped_nodes: typing.Final = tuple(one_node for one_node in all_nodes if one_node.node_group is node_group)
106 if not grouped_nodes:
107 return ()
108 return (
109 group_shift + _GROUP_OPENING_TEMPLATE.format(group_name=node_group.name, group_title=node_group.value),
110 *(group_shift + settings.LINE_INDENT + render_node_definition(one_node) for one_node in grouped_nodes),
111 group_shift + _GROUP_CLOSING_LINE,
112 )
114 def _render_placement_lines(
115 self,
116 group_placement: GroupPlacement,
117 all_nodes: tuple[diagram_model.DiagramNode, ...],
118 group_shift: str,
119 /,
120 ) -> tuple[str, ...]:
121 return tuple(
122 one_group_line
123 for one_node_group in diagram_model.NodeGroup
124 if PLACEMENT_OF_NODE_GROUP[one_node_group] is group_placement
125 for one_group_line in self._render_group_lines(one_node_group, all_nodes, group_shift)
126 )
128 def _render_service_row_lines(self, all_nodes: tuple[diagram_model.DiagramNode, ...], /) -> tuple[str, ...]:
129 sideways_lines: typing.Final = (
130 *self._render_placement_lines(GroupPlacement.left_of_service, all_nodes, _ROW_INDENT),
131 _ROW_INDENT + render_node_definition(self.service_node),
132 *self._render_placement_lines(GroupPlacement.right_of_service, all_nodes, _ROW_INDENT),
133 )
134 return (
135 settings.LINE_INDENT + _SERVICE_ROW_OPENING,
136 _ROW_INDENT + _SERVICE_ROW_DIRECTION_LINE,
137 *sideways_lines,
138 settings.LINE_INDENT + _GROUP_CLOSING_LINE,
139 settings.LINE_INDENT + _SERVICE_ROW_STYLE_LINE,
140 )
142 def _render_definition_lines(self) -> tuple[str, ...]:
143 all_nodes: typing.Final = self._collect_drawn_nodes()
144 return (
145 *(
146 settings.LINE_INDENT + render_node_definition(one_node)
147 for one_node in all_nodes
148 if one_node.node_group is None and one_node.defined_node_id != self.service_node.defined_node_id
149 ),
150 *self._render_placement_lines(GroupPlacement.above_service, all_nodes, settings.LINE_INDENT),
151 *self._render_service_row_lines(all_nodes),
152 *self._render_placement_lines(GroupPlacement.below_service, all_nodes, settings.LINE_INDENT),
153 )