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

1import dataclasses 

2import enum 

3import types 

4import typing 

5 

6from archdocs import diagram_model, settings 

7 

8 

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 

22 

23 

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" 

30 

31 

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) 

49 

50 

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 ) 

56 

57 

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}' 

65 

66 

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, ...] 

74 

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 ) 

82 

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 ) 

97 

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 ) 

113 

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 ) 

127 

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 ) 

141 

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 )