fastapi-voyager 0.14.1__py3-none-any.whl → 0.15.1__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 (35) hide show
  1. fastapi_voyager/er_diagram.py +57 -109
  2. fastapi_voyager/render.py +433 -203
  3. fastapi_voyager/render_style.py +105 -0
  4. fastapi_voyager/server.py +1 -0
  5. fastapi_voyager/templates/dot/cluster.j2 +10 -0
  6. fastapi_voyager/templates/dot/cluster_container.j2 +9 -0
  7. fastapi_voyager/templates/dot/digraph.j2 +25 -0
  8. fastapi_voyager/templates/dot/er_diagram.j2 +29 -0
  9. fastapi_voyager/templates/dot/link.j2 +1 -0
  10. fastapi_voyager/templates/dot/route_node.j2 +5 -0
  11. fastapi_voyager/templates/dot/schema_node.j2 +5 -0
  12. fastapi_voyager/templates/dot/tag_node.j2 +5 -0
  13. fastapi_voyager/templates/html/colored_text.j2 +1 -0
  14. fastapi_voyager/templates/html/pydantic_meta.j2 +1 -0
  15. fastapi_voyager/templates/html/schema_field_row.j2 +1 -0
  16. fastapi_voyager/templates/html/schema_header.j2 +2 -0
  17. fastapi_voyager/templates/html/schema_table.j2 +4 -0
  18. fastapi_voyager/version.py +1 -1
  19. fastapi_voyager/web/component/demo.js +5 -5
  20. fastapi_voyager/web/component/render-graph.js +60 -61
  21. fastapi_voyager/web/component/route-code-display.js +35 -37
  22. fastapi_voyager/web/component/schema-code-display.js +50 -53
  23. fastapi_voyager/web/graph-ui.js +90 -101
  24. fastapi_voyager/web/graphviz.svg.css +10 -10
  25. fastapi_voyager/web/graphviz.svg.js +306 -316
  26. fastapi_voyager/web/icon/site.webmanifest +11 -1
  27. fastapi_voyager/web/index.html +225 -109
  28. fastapi_voyager/web/store.js +107 -111
  29. fastapi_voyager/web/vue-main.js +287 -258
  30. {fastapi_voyager-0.14.1.dist-info → fastapi_voyager-0.15.1.dist-info}/METADATA +18 -5
  31. fastapi_voyager-0.15.1.dist-info/RECORD +50 -0
  32. fastapi_voyager-0.14.1.dist-info/RECORD +0 -36
  33. {fastapi_voyager-0.14.1.dist-info → fastapi_voyager-0.15.1.dist-info}/WHEEL +0 -0
  34. {fastapi_voyager-0.14.1.dist-info → fastapi_voyager-0.15.1.dist-info}/entry_points.txt +0 -0
  35. {fastapi_voyager-0.14.1.dist-info → fastapi_voyager-0.15.1.dist-info}/licenses/LICENSE +0 -0
fastapi_voyager/render.py CHANGED
@@ -1,11 +1,18 @@
1
+ """
2
+ Render FastAPI application structure to DOT format using Jinja2 templates.
3
+ """
1
4
  from logging import getLogger
5
+ from pathlib import Path
6
+
7
+ from jinja2 import Environment, FileSystemLoader, select_autoescape
2
8
 
3
9
  from fastapi_voyager.module import build_module_route_tree, build_module_schema_tree
10
+ from fastapi_voyager.render_style import RenderConfig
4
11
  from fastapi_voyager.type import (
5
12
  PK,
6
13
  FieldType,
7
- Link,
8
14
  FieldInfo,
15
+ Link,
9
16
  ModuleNode,
10
17
  ModuleRoute,
11
18
  Route,
@@ -15,8 +22,38 @@ from fastapi_voyager.type import (
15
22
 
16
23
  logger = getLogger(__name__)
17
24
 
25
+ # Get the template directory relative to this file
26
+ TEMPLATE_DIR = Path(__file__).parent / "templates"
27
+
28
+
29
+ class TemplateRenderer:
30
+ """
31
+ Jinja2-based template renderer for DOT and HTML templates.
32
+ """
33
+
34
+ def __init__(self, template_dir: Path = TEMPLATE_DIR):
35
+ # Initialize Jinja2 environment
36
+ self.env = Environment(
37
+ loader=FileSystemLoader(template_dir),
38
+ autoescape=select_autoescape(),
39
+ trim_blocks=True,
40
+ lstrip_blocks=True,
41
+ )
42
+
43
+ def render_template(self, template_name: str, **context) -> str:
44
+ """Render a template with the given context."""
45
+ template = self.env.get_template(template_name)
46
+ return template.render(**context)
47
+
18
48
 
19
49
  class Renderer:
50
+ """
51
+ Render FastAPI application structure to DOT format.
52
+
53
+ This class handles the conversion of tags, routes, schemas, and links
54
+ into Graphviz DOT format, with support for custom styling and filtering.
55
+ """
56
+
20
57
  def __init__(
21
58
  self,
22
59
  *,
@@ -25,6 +62,7 @@ class Renderer:
25
62
  schema: str | None = None,
26
63
  show_module: bool = True,
27
64
  show_pydantic_resolve_meta: bool = False,
65
+ config: RenderConfig | None = None,
28
66
  ) -> None:
29
67
  self.show_fields = show_fields if show_fields in ('single', 'object', 'all') else 'single'
30
68
  self.module_color = module_color or {}
@@ -32,252 +70,444 @@ class Renderer:
32
70
  self.show_module = show_module
33
71
  self.show_pydantic_resolve_meta = show_pydantic_resolve_meta
34
72
 
73
+ # Use provided config or create default
74
+ self.config = config or RenderConfig()
75
+ self.colors = self.config.colors
76
+ self.style = self.config.style
77
+
78
+ # Initialize template renderer
79
+ self.template_renderer = TemplateRenderer()
80
+
35
81
  logger.info(f'show_module: {self.show_module}')
36
82
  logger.info(f'module_color: {self.module_color}')
37
-
38
- def render_pydantic_related_markup(self, field: FieldInfo):
39
- if self.show_pydantic_resolve_meta is False:
40
- return ''
41
83
 
42
- parts: list[str] = []
84
+ def _render_pydantic_meta_parts(self, field: FieldInfo) -> list[str]:
85
+ """Render pydantic-resolve metadata as HTML parts."""
86
+ if not self.show_pydantic_resolve_meta:
87
+ return []
88
+
89
+ parts = []
43
90
  if field.is_resolve:
44
- parts.append('<font color="#47a80f"> ● resolve</font>')
91
+ parts.append(
92
+ self.template_renderer.render_template(
93
+ 'html/colored_text.j2',
94
+ text='● resolve',
95
+ color=self.colors.resolve
96
+ )
97
+ )
45
98
  if field.is_post:
46
- parts.append('<font color="#427fa4"> ● post</font>')
99
+ parts.append(
100
+ self.template_renderer.render_template(
101
+ 'html/colored_text.j2',
102
+ text='● post',
103
+ color=self.colors.post
104
+ )
105
+ )
47
106
  if field.expose_as_info:
48
- parts.append(f'<font color="#895cb9"> ● expose as: {field.expose_as_info}</font>')
107
+ parts.append(
108
+ self.template_renderer.render_template(
109
+ 'html/colored_text.j2',
110
+ text=f'● expose as: {field.expose_as_info}',
111
+ color=self.colors.expose_as
112
+ )
113
+ )
49
114
  if field.send_to_info:
50
115
  to_collectors = ', '.join(field.send_to_info)
51
- parts.append(f'<font color="#ca6d6d"> ● send to: {to_collectors}</font>')
116
+ parts.append(
117
+ self.template_renderer.render_template(
118
+ 'html/colored_text.j2',
119
+ text=f'● send to: {to_collectors}',
120
+ color=self.colors.send_to
121
+ )
122
+ )
52
123
  if field.collect_info:
53
124
  defined_collectors = ', '.join(field.collect_info)
54
- parts.append(f'<font color="#777"> ● collectors: {defined_collectors}</font>')
55
-
56
- if not parts:
57
- return ''
58
-
59
- return '<br align="left"/><br align="left"/>' + '<br align="left"/>'.join(parts) + '<br align="left"/>'
60
-
61
- def render_schema_label(self, node: SchemaNode, color: str | None=None) -> str:
62
- """
63
- TODO: should improve the logic with show_pydantic_resolve_meta
64
- """
125
+ parts.append(
126
+ self.template_renderer.render_template(
127
+ 'html/colored_text.j2',
128
+ text=f'● collectors: {defined_collectors}',
129
+ color=self.colors.collector
130
+ )
131
+ )
65
132
 
66
- has_base_fields = any(f.from_base for f in node.fields)
133
+ return parts
67
134
 
68
- # if self.show_pydantic_resolve_meta, show all fields with resolve/post/expose/collector info
135
+ def _render_schema_field(
136
+ self,
137
+ field: FieldInfo,
138
+ max_type_length: int | None = None
139
+ ) -> str:
140
+ """Render a single schema field."""
141
+ max_len = max_type_length or self.config.max_type_length
142
+
143
+ # Truncate type name if too long
144
+ type_name = field.type_name
145
+ if len(type_name) > max_len:
146
+ type_name = type_name[:max_len] + self.config.type_suffix
147
+
148
+ # Format field display
149
+ field_text = f'{field.name}: {type_name}'
150
+
151
+ # Render pydantic metadata
152
+ meta_parts = self._render_pydantic_meta_parts(field)
153
+ meta_html = self.template_renderer.render_template(
154
+ 'html/pydantic_meta.j2',
155
+ meta_parts=meta_parts
156
+ )
157
+
158
+ # Render field text (with strikethrough if excluded)
159
+ text_html = self.template_renderer.render_template(
160
+ 'html/colored_text.j2',
161
+ text=field_text,
162
+ color='#000', # Default color
163
+ strikethrough=field.is_exclude
164
+ )
165
+
166
+ # Combine field text and metadata
167
+ content = f'<font> {text_html} </font> {meta_html}'
168
+
169
+ # Render the table row
170
+ return self.template_renderer.render_template(
171
+ 'html/schema_field_row.j2',
172
+ port=field.name,
173
+ align='left',
174
+ content=content
175
+ )
176
+
177
+ def _get_filtered_fields(self, node: SchemaNode) -> list[FieldInfo]:
178
+ """Get fields filtered by show_fields and show_pydantic_resolve_meta settings."""
179
+
180
+ # Filter fields based on pydantic-resolve meta setting
69
181
  if self.show_pydantic_resolve_meta:
70
- fields = [n for n in node.fields if n.has_pydantic_resolve_meta is True or n.from_base is False]
182
+ fields = [n for n in node.fields if n.has_pydantic_resolve_meta or not n.from_base]
71
183
  else:
72
- fields = [n for n in node.fields if n.from_base is False]
184
+ fields = [n for n in node.fields if not n.from_base]
73
185
 
186
+ # Further filter by show_fields setting
74
187
  if self.show_fields == 'all':
75
- _fields = fields
188
+ return fields
76
189
  elif self.show_fields == 'object':
77
190
  if self.show_pydantic_resolve_meta:
78
- # to better display resolve meta info
79
- _fields = [f for f in fields if f.is_object is True or f.has_pydantic_resolve_meta is True]
191
+ # Show object fields or fields with pydantic-resolve metadata
192
+ return [f for f in fields if f.is_object or f.has_pydantic_resolve_meta]
80
193
  else:
81
- _fields = [f for f in fields if f.is_object is True]
194
+ # Show only object fields
195
+ return [f for f in fields if f.is_object]
82
196
  else: # 'single'
83
- _fields = []
197
+ return []
84
198
 
85
- fields_parts: list[str] = []
86
- if self.show_fields == 'all' and has_base_fields:
87
- fields_parts.append('<tr><td align="left" cellpadding="8"><font color="#999"> Inherited Fields ... </font></td></tr>')
199
+ def render_schema_label(self, node: SchemaNode, color: str | None = None) -> str:
200
+ """
201
+ Render a schema node's label as an HTML table.
88
202
 
89
- for field in _fields:
90
- type_name = field.type_name[:25] + '..' if len(field.type_name) > 25 else field.type_name
91
- display_xml = f'<s align="left">{field.name}: {type_name} </s>' if field.is_exclude else f'{field.name}: {type_name}'
92
- field_str = f"""<tr><td align="left" port="f{field.name}" cellpadding="8"><font> {display_xml} </font> {self.render_pydantic_related_markup(field)} </td></tr>"""
93
- fields_parts.append(field_str)
203
+ TODO: Improve logic with show_pydantic_resolve_meta
204
+ """
205
+ fields = self._get_filtered_fields(node)
206
+
207
+ # Render field rows
208
+ rows = []
209
+ has_base_fields = any(f.from_base for f in node.fields)
94
210
 
95
- default_color = '#009485' if color is None else color
96
- header_color = 'tomato' if node.id == self.schema else default_color
97
- header = f"""<tr><td cellpadding="6" bgcolor="{header_color}" align="center" colspan="1" port="{PK}"> <font color="white"> {node.name} </font></td> </tr>"""
98
- field_content = ''.join(fields_parts) if fields_parts else ''
99
- return f"""<<table border="0" cellborder="1" cellpadding="0" cellspacing="0" bgcolor="white"> {header} {field_content} </table>>"""
211
+ # Add inherited fields notice if needed
212
+ if self.show_fields == 'all' and has_base_fields:
213
+ notice = self.template_renderer.render_template(
214
+ 'html/colored_text.j2',
215
+ text=' Inherited Fields ... ',
216
+ color=self.colors.text_gray
217
+ )
218
+ rows.append(
219
+ self.template_renderer.render_template(
220
+ 'html/schema_field_row.j2',
221
+ content=notice,
222
+ align='left'
223
+ )
224
+ )
225
+
226
+ # Render each field
227
+ for field in fields:
228
+ rows.append(self._render_schema_field(field))
229
+
230
+ # Determine header color
231
+ default_color = self.colors.primary if color is None else color
232
+ header_color = self.colors.highlight if node.id == self.schema else default_color
233
+
234
+ # Render header
235
+ header = self.template_renderer.render_template(
236
+ 'html/schema_header.j2',
237
+ text=node.name,
238
+ bg_color=header_color,
239
+ port=PK
240
+ )
241
+
242
+ # Render complete table
243
+ return self.template_renderer.render_template(
244
+ 'html/schema_table.j2',
245
+ header=header,
246
+ rows=''.join(rows)
247
+ )
100
248
 
101
249
  def _handle_schema_anchor(self, source: str) -> str:
250
+ """Handle schema anchor for DOT links."""
102
251
  if '::' in source:
103
252
  a, b = source.split('::', 1)
104
253
  return f'"{a}":{b}'
105
254
  return f'"{source}"'
106
255
 
256
+ def _format_link_attributes(self, attrs: dict) -> str:
257
+ """Format link attributes for DOT format."""
258
+ return ', '.join(f'{k}="{v}"' for k, v in attrs.items())
259
+
107
260
  def render_link(self, link: Link) -> str:
108
- h = self._handle_schema_anchor
109
- if link.type == 'tag_route':
110
- return f"""{h(link.source)}:e -> {h(link.target)}:w [style = "solid", minlen=3];"""
111
- elif link.type == 'route_to_schema':
112
- return f"""{h(link.source)}:e -> {h(link.target)}:w [style = "solid", dir="back", arrowtail="odot", minlen=3];"""
113
- elif link.type == 'schema':
114
- return f"""{h(link.source)}:e -> {h(link.target)}:w [style = "solid", label = "", dir="back", minlen=3, arrowtail="odot"];"""
115
- elif link.type == 'parent':
116
- return f"""{h(link.source)}:e -> {h(link.target)}:w [style = "solid, dashed", dir="back", minlen=3, taillabel = "< inherit >", color = "purple", tailport="n"];"""
117
- elif link.type == 'subset':
118
- return f"""{h(link.source)}:e -> {h(link.target)}:w [style = "solid, dashed", dir="back", minlen=3, taillabel = "< subset >", color = "orange", tailport="n"];"""
119
- elif link.type == 'tag_to_schema':
120
- return f"""{h(link.source)}:e -> {h(link.target)}:w [style = "solid", minlen=3];"""
261
+ """Render a link in DOT format."""
262
+ source = self._handle_schema_anchor(link.source)
263
+ target = self._handle_schema_anchor(link.target)
264
+
265
+ # Build link attributes
266
+ # If link.style is explicitly set (e.g., 'solid, dashed' for ER diagrams), use it
267
+ # Otherwise, get default style from configuration based on link.type
268
+ if link.style is not None:
269
+ attrs = {'style': link.style}
270
+ if link.label:
271
+ attrs['label'] = link.label
272
+ # attrs['minlen'] = 3
121
273
  else:
122
- raise ValueError(f'Unknown link type: {link.type}')
123
-
124
- def render_module_schema_content(self, nodes: list[SchemaNode]) -> str:
125
- def render_node(node: SchemaNode, color: str | None=None) -> str:
126
- return f'''
127
- "{node.id}" [
128
- label = {self.render_schema_label(node, color)}
129
- shape = "plain"
130
- margin="0.5,0.1"
131
- ];'''
132
-
133
- def render_module_schema(mod: ModuleNode, inherit_color: str | None=None, show_cluster:bool=True) -> str:
134
- color: str | None = inherit_color
135
- cluster_color: str | None = None
136
-
137
-
138
- # recursively vist module from short to long: 'a', 'a.b', 'a.b.c'
139
- # color_flag: {'a', 'a.b.c'}
140
- # at first 'a', match 'a' -> color, remove 'a' from color_flag
141
- # at 'a.b', no match
142
- # at 'a.b.c', match 'a.b.c' -> color, remove 'a.b.c' from color_flag
143
- for k in module_color_flag:
144
- if mod.fullname.startswith(k):
145
- module_color_flag.remove(k)
146
- color = self.module_color[k]
147
- cluster_color = color if color != inherit_color else None
148
- break
149
-
150
- inner_nodes = [ render_node(node, color) for node in mod.schema_nodes ]
151
- inner_nodes_str = '\n'.join(inner_nodes)
152
- child_str = '\n'.join(render_module_schema(mod=m, inherit_color=color, show_cluster=show_cluster) for m in mod.modules)
153
-
154
- if show_cluster:
155
- return f'''
156
- subgraph cluster_module_{mod.fullname.replace('.', '_')} {{
157
- tooltip="{mod.fullname}"
158
- color = "#666"
159
- style="rounded"
160
- label = " {mod.name}"
161
- labeljust = "l"
162
- {(f'pencolor = "{cluster_color}"' if cluster_color else 'pencolor="#ccc"')}
163
- {('penwidth = 3' if color else 'penwidth=""')}
164
- {inner_nodes_str}
165
- {child_str}
166
- }}'''
274
+ attrs = self.style.get_link_attributes(link.type)
275
+ if link.label:
276
+ attrs['label'] = link.label
277
+
278
+ return self.template_renderer.render_template(
279
+ 'dot/link.j2',
280
+ source=source,
281
+ target=target,
282
+ attributes=self._format_link_attributes(attrs)
283
+ )
284
+
285
+ def render_schema_node(self, node: SchemaNode, color: str | None = None) -> str:
286
+ """Render a schema node in DOT format."""
287
+ label = self.render_schema_label(node, color)
288
+
289
+ return self.template_renderer.render_template(
290
+ 'dot/schema_node.j2',
291
+ id=node.id,
292
+ label=label,
293
+ margin=self.style.node_margin
294
+ )
295
+
296
+ def render_tag_node(self, tag: Tag) -> str:
297
+ """Render a tag node in DOT format."""
298
+ return self.template_renderer.render_template(
299
+ 'dot/tag_node.j2',
300
+ id=tag.id,
301
+ name=tag.name,
302
+ margin=self.style.node_margin
303
+ )
304
+
305
+ def render_route_node(self, route: Route) -> str:
306
+ """Render a route node in DOT format."""
307
+ # Truncate response schema if too long
308
+ response_schema = route.response_schema
309
+ if len(response_schema) > self.config.max_type_length:
310
+ response_schema = response_schema[:self.config.max_type_length] + self.config.type_suffix
311
+
312
+ return self.template_renderer.render_template(
313
+ 'dot/route_node.j2',
314
+ id=route.id,
315
+ name=route.name,
316
+ response_schema=response_schema,
317
+ margin=self.style.node_margin
318
+ )
319
+
320
+ def _render_module_schema(
321
+ self,
322
+ mod: ModuleNode,
323
+ module_color_flag: set[str],
324
+ inherit_color: str | None = None,
325
+ show_cluster: bool = True
326
+ ) -> str:
327
+ """Render a module schema tree."""
328
+ color = inherit_color
329
+ cluster_color: str | None = None
330
+
331
+ # Check if this module has a custom color
332
+ for k in module_color_flag:
333
+ if mod.fullname.startswith(k):
334
+ module_color_flag.remove(k)
335
+ color = self.module_color[k]
336
+ cluster_color = color if color != inherit_color else None
337
+ break
338
+
339
+ # Render inner schema nodes
340
+ inner_nodes = [
341
+ self.render_schema_node(node, color)
342
+ for node in mod.schema_nodes
343
+ ]
344
+ inner_nodes_str = '\n'.join(inner_nodes)
345
+
346
+ # Recursively render child modules
347
+ child_str = '\n'.join(
348
+ self._render_module_schema(
349
+ m,
350
+ module_color_flag=module_color_flag,
351
+ inherit_color=color,
352
+ show_cluster=show_cluster
353
+ )
354
+ for m in mod.modules
355
+ )
356
+
357
+ if show_cluster:
358
+ # Render as a cluster
359
+ cluster_id = f'module_{mod.fullname.replace(".", "_")}'
360
+ pen_style = ''
361
+
362
+ if cluster_color:
363
+ pen_style = f'pencolor = "{cluster_color}"'
364
+ pen_style += '\n' + f'penwidth = 3' if color else ''
167
365
  else:
168
- return f'''
169
- {inner_nodes_str}
170
- {child_str}
171
- '''
366
+ pen_style = 'pencolor="#ccc"'
367
+
368
+ return self.template_renderer.render_template(
369
+ 'dot/cluster.j2',
370
+ cluster_id=cluster_id,
371
+ label=mod.name,
372
+ tooltip=mod.fullname,
373
+ border_color=self.colors.border,
374
+ pen_color=cluster_color,
375
+ pen_width=3 if color and not cluster_color else None,
376
+ content=f'{inner_nodes_str}\n{child_str}'
377
+ )
378
+ else:
379
+ # Render without cluster
380
+ return f'{inner_nodes_str}\n{child_str}'
172
381
 
173
- # if self.show_module:
382
+ def render_module_schema_content(self, nodes: list[SchemaNode]) -> str:
383
+ """Render all module schemas."""
174
384
  module_schemas = build_module_schema_tree(nodes)
175
385
  module_color_flag = set(self.module_color.keys())
176
- return '\n'.join(render_module_schema(mod=m, show_cluster=self.show_module) for m in module_schemas)
177
386
 
178
-
387
+ return '\n'.join(
388
+ self._render_module_schema(
389
+ m,
390
+ module_color_flag=module_color_flag,
391
+ show_cluster=self.show_module
392
+ )
393
+ for m in module_schemas
394
+ )
395
+
396
+ def _render_module_route(self, mod: ModuleRoute, show_cluster: bool = True) -> str:
397
+ """Render a module route tree."""
398
+ # Render inner route nodes
399
+ inner_nodes = [self.render_route_node(r) for r in mod.routes]
400
+ inner_nodes_str = '\n'.join(inner_nodes)
401
+
402
+ # Recursively render child modules
403
+ child_str = '\n'.join(
404
+ self._render_module_route(m, show_cluster=show_cluster)
405
+ for m in mod.modules
406
+ )
407
+
408
+ if show_cluster:
409
+ cluster_id = f'route_module_{mod.fullname.replace(".", "_")}'
410
+
411
+ return self.template_renderer.render_template(
412
+ 'dot/cluster.j2',
413
+ cluster_id=cluster_id,
414
+ label=mod.name,
415
+ tooltip=mod.fullname,
416
+ border_color=self.colors.border,
417
+ pen_color=None,
418
+ pen_width=None,
419
+ content=f'{inner_nodes_str}\n{child_str}'
420
+ )
421
+ else:
422
+ return f'{inner_nodes_str}\n{child_str}'
423
+
179
424
  def render_module_route_content(self, routes: list[Route]) -> str:
180
- def render_route(route: Route) -> str:
181
- response_schema = route.response_schema[:25] + '..' if len(route.response_schema) > 25 else route.response_schema
182
- return f'''
183
- "{route.id}" [
184
- label = " {route.name} | {response_schema} "
185
- margin="0.5,0.1"
186
- shape = "record"
187
- ];'''
188
-
189
- def render_module_route(mod: ModuleRoute, show_cluster: bool=True) -> str:
190
- # Inner route nodes, same style as flat route_str
191
- inner_nodes = [
192
- render_route(r) for r in mod.routes
193
- ]
194
- inner_nodes_str = '\n'.join(inner_nodes)
195
- child_str = '\n'.join(render_module_route(m, show_cluster=show_cluster) for m in mod.modules)
196
- if show_cluster:
197
- return f'''
198
- subgraph cluster_route_module_{mod.fullname.replace('.', '_')} {{
199
- tooltip="{mod.fullname}"
200
- color = "#666"
201
- style="rounded"
202
- label = " {mod.name}"
203
- labeljust = "l"
204
- {inner_nodes_str}
205
- {child_str}
206
- }}'''
207
- else:
208
- return f'''
209
- {inner_nodes_str}
210
- {child_str}
211
- '''
425
+ """Render all module routes."""
212
426
  module_routes = build_module_route_tree(routes)
213
- module_routes_str = '\n'.join(render_module_route(m, show_cluster=self.show_module) for m in module_routes)
214
- return module_routes_str
215
427
 
428
+ return '\n'.join(
429
+ self._render_module_route(m, show_cluster=self.show_module)
430
+ for m in module_routes
431
+ )
432
+
433
+ def _render_cluster_container(
434
+ self,
435
+ name: str,
436
+ label: str,
437
+ content: str,
438
+ fontsize: str | None = None
439
+ ) -> str:
440
+ """Render a cluster container (for tags, routes, schemas)."""
441
+ return self.template_renderer.render_template(
442
+ 'dot/cluster_container.j2',
443
+ name=name,
444
+ label=label,
445
+ content=content,
446
+ border_color=self.colors.border,
447
+ margin=self.style.cluster_margin,
448
+ fontsize=fontsize or self.style.cluster_fontsize
449
+ )
450
+
451
+ def render_dot(
452
+ self,
453
+ tags: list[Tag],
454
+ routes: list[Route],
455
+ nodes: list[SchemaNode],
456
+ links: list[Link],
457
+ spline_line: bool = False
458
+ ) -> str:
459
+ """
460
+ Render the complete DOT graph.
461
+
462
+ Args:
463
+ tags: List of tags
464
+ routes: List of routes
465
+ nodes: List of schema nodes
466
+ links: List of links
467
+ spline_line: Whether to use spline lines
216
468
 
217
- def render_dot(self, tags: list[Tag], routes: list[Route], nodes: list[SchemaNode], links: list[Link], spline_line=False) -> str:
469
+ Returns:
470
+ Complete DOT graph as a string
471
+ """
472
+ # Render tag nodes
473
+ tag_str = '\n'.join(self.render_tag_node(t) for t in tags)
218
474
 
219
- tag_str = '\n'.join([
220
- f'''
221
- "{t.id}" [
222
- label = " {t.name} "
223
- shape = "record"
224
- margin="0.5,0.1"
225
- ];''' for t in tags
226
- ])
475
+ # Render tags cluster
476
+ tags_cluster = self._render_cluster_container(
477
+ name='tags',
478
+ label='Tags',
479
+ content=tag_str
480
+ )
227
481
 
482
+ # Render routes cluster
228
483
  module_routes_str = self.render_module_route_content(routes)
484
+ routes_cluster = self._render_cluster_container(
485
+ name='router',
486
+ label='Routes',
487
+ content=module_routes_str
488
+ )
489
+
490
+ # Render schemas cluster
229
491
  module_schemas_str = self.render_module_schema_content(nodes)
492
+ schemas_cluster = self._render_cluster_container(
493
+ name='schema',
494
+ label='Schema',
495
+ content=module_schemas_str
496
+ )
497
+
498
+ # Render links
230
499
  link_str = '\n'.join(self.render_link(link) for link in links)
231
500
 
232
- dot_str = f'''
233
- digraph world {{
234
- pad="0.5"
235
- nodesep=0.8
236
- {'splines=line' if spline_line else ''}
237
- fontname="Helvetica,Arial,sans-serif"
238
- node [fontname="Helvetica,Arial,sans-serif"]
239
- edge [
240
- fontname="Helvetica,Arial,sans-serif"
241
- color="gray"
242
- ]
243
- graph [
244
- rankdir = "LR"
245
- ];
246
- node [
247
- fontsize = "16"
248
- ];
249
-
250
- subgraph cluster_tags {{
251
- color = "#aaa"
252
- margin=18
253
- style="dashed"
254
- label = " Tags"
255
- labeljust = "l"
256
- fontsize = "20"
257
- {tag_str}
258
- }}
259
-
260
- subgraph cluster_router {{
261
- color = "#aaa"
262
- margin=18
263
- style="dashed"
264
- label = " Routes"
265
- labeljust = "l"
266
- fontsize = "20"
267
- {module_routes_str}
268
- }}
269
-
270
- subgraph cluster_schema {{
271
- color = "#aaa"
272
- margin=18
273
- style="dashed"
274
- label=" Schema"
275
- labeljust="l"
276
- fontsize="20"
277
- {module_schemas_str}
278
- }}
279
-
280
- {link_str}
281
- }}
282
- '''
283
- return dot_str
501
+ # Render complete digraph
502
+ return self.template_renderer.render_template(
503
+ 'dot/digraph.j2',
504
+ pad=self.style.pad,
505
+ nodesep=self.style.nodesep,
506
+ spline='line' if spline_line else '',
507
+ font=self.style.font,
508
+ node_fontsize=self.style.node_fontsize,
509
+ tags_cluster=tags_cluster,
510
+ routes_cluster=routes_cluster,
511
+ schemas_cluster=schemas_cluster,
512
+ links=link_str
513
+ )