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

1import dataclasses 

2import enum 

3import types 

4import typing 

5 

6from archdocs import settings 

7from archdocs.diagram_model import DiagramEdge, DiagramNode, NodeGroup 

8 

9 

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 

18 

19 

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" 

26 

27 

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) 

37 

38 

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 ) 

44 

45 

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

53 

54 

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

62 

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 ) 

70 

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 ) 

85 

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 ) 

101 

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 ) 

115 

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 ) 

129 

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 )