fastapi-voyager 0.14.1__py3-none-any.whl → 0.15.0__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.
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,434 @@ 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)
94
206
 
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>>"""
207
+ # Render field rows
208
+ rows = []
209
+ has_base_fields = any(f.from_base for f in node.fields)
210
+
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
+ # Get link style attributes
266
+ attrs = self.style.get_link_attributes(link.type)
267
+
268
+ return self.template_renderer.render_template(
269
+ 'dot/link.j2',
270
+ source=source,
271
+ target=target,
272
+ attributes=self._format_link_attributes(attrs)
273
+ )
274
+
275
+ def render_schema_node(self, node: SchemaNode, color: str | None = None) -> str:
276
+ """Render a schema node in DOT format."""
277
+ label = self.render_schema_label(node, color)
278
+
279
+ return self.template_renderer.render_template(
280
+ 'dot/schema_node.j2',
281
+ id=node.id,
282
+ label=label,
283
+ margin=self.style.node_margin
284
+ )
285
+
286
+ def render_tag_node(self, tag: Tag) -> str:
287
+ """Render a tag node in DOT format."""
288
+ return self.template_renderer.render_template(
289
+ 'dot/tag_node.j2',
290
+ id=tag.id,
291
+ name=tag.name,
292
+ margin=self.style.node_margin
293
+ )
294
+
295
+ def render_route_node(self, route: Route) -> str:
296
+ """Render a route node in DOT format."""
297
+ # Truncate response schema if too long
298
+ response_schema = route.response_schema
299
+ if len(response_schema) > self.config.max_type_length:
300
+ response_schema = response_schema[:self.config.max_type_length] + self.config.type_suffix
301
+
302
+ return self.template_renderer.render_template(
303
+ 'dot/route_node.j2',
304
+ id=route.id,
305
+ name=route.name,
306
+ response_schema=response_schema,
307
+ margin=self.style.node_margin
308
+ )
309
+
310
+ def _render_module_schema(
311
+ self,
312
+ mod: ModuleNode,
313
+ module_color_flag: set[str],
314
+ inherit_color: str | None = None,
315
+ show_cluster: bool = True
316
+ ) -> str:
317
+ """Render a module schema tree."""
318
+ color = inherit_color
319
+ cluster_color: str | None = None
320
+
321
+ # Check if this module has a custom color
322
+ for k in module_color_flag:
323
+ if mod.fullname.startswith(k):
324
+ module_color_flag.remove(k)
325
+ color = self.module_color[k]
326
+ cluster_color = color if color != inherit_color else None
327
+ break
328
+
329
+ # Render inner schema nodes
330
+ inner_nodes = [
331
+ self.render_schema_node(node, color)
332
+ for node in mod.schema_nodes
333
+ ]
334
+ inner_nodes_str = '\n'.join(inner_nodes)
335
+
336
+ # Recursively render child modules
337
+ child_str = '\n'.join(
338
+ self._render_module_schema(
339
+ m,
340
+ module_color_flag=module_color_flag,
341
+ inherit_color=color,
342
+ show_cluster=show_cluster
343
+ )
344
+ for m in mod.modules
345
+ )
346
+
347
+ if show_cluster:
348
+ # Render as a cluster
349
+ cluster_id = f'module_{mod.fullname.replace(".", "_")}'
350
+ pen_style = ''
351
+
352
+ if cluster_color:
353
+ pen_style = f'pencolor = "{cluster_color}"'
354
+ pen_style += '\n' + f'penwidth = 3' if color else ''
355
+ else:
356
+ pen_style = 'pencolor="#ccc"'
357
+
358
+ return self.template_renderer.render_template(
359
+ 'dot/cluster.j2',
360
+ cluster_id=cluster_id,
361
+ label=mod.name,
362
+ tooltip=mod.fullname,
363
+ border_color=self.colors.border,
364
+ pen_color=cluster_color,
365
+ pen_width=3 if color and not cluster_color else None,
366
+ content=f'{inner_nodes_str}\n{child_str}'
367
+ )
121
368
  else:
122
- raise ValueError(f'Unknown link type: {link.type}')
369
+ # Render without cluster
370
+ return f'{inner_nodes_str}\n{child_str}'
123
371
 
124
372
  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
- }}'''
167
- else:
168
- return f'''
169
- {inner_nodes_str}
170
- {child_str}
171
- '''
172
-
173
- # if self.show_module:
373
+ """Render all module schemas."""
174
374
  module_schemas = build_module_schema_tree(nodes)
175
375
  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
376
 
178
-
377
+ return '\n'.join(
378
+ self._render_module_schema(
379
+ m,
380
+ module_color_flag=module_color_flag,
381
+ show_cluster=self.show_module
382
+ )
383
+ for m in module_schemas
384
+ )
385
+
386
+ def _render_module_route(self, mod: ModuleRoute, show_cluster: bool = True) -> str:
387
+ """Render a module route tree."""
388
+ # Render inner route nodes
389
+ inner_nodes = [self.render_route_node(r) for r in mod.routes]
390
+ inner_nodes_str = '\n'.join(inner_nodes)
391
+
392
+ # Recursively render child modules
393
+ child_str = '\n'.join(
394
+ self._render_module_route(m, show_cluster=show_cluster)
395
+ for m in mod.modules
396
+ )
397
+
398
+ if show_cluster:
399
+ cluster_id = f'route_module_{mod.fullname.replace(".", "_")}'
400
+
401
+ return self.template_renderer.render_template(
402
+ 'dot/cluster.j2',
403
+ cluster_id=cluster_id,
404
+ label=mod.name,
405
+ tooltip=mod.fullname,
406
+ border_color=self.colors.border,
407
+ pen_color=None,
408
+ pen_width=None,
409
+ content=f'{inner_nodes_str}\n{child_str}'
410
+ )
411
+ else:
412
+ return f'{inner_nodes_str}\n{child_str}'
413
+
179
414
  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
- '''
415
+ """Render all module routes."""
212
416
  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
417
 
418
+ return '\n'.join(
419
+ self._render_module_route(m, show_cluster=self.show_module)
420
+ for m in module_routes
421
+ )
422
+
423
+ def _render_cluster_container(
424
+ self,
425
+ name: str,
426
+ label: str,
427
+ content: str,
428
+ fontsize: str | None = None
429
+ ) -> str:
430
+ """Render a cluster container (for tags, routes, schemas)."""
431
+ return self.template_renderer.render_template(
432
+ 'dot/cluster_container.j2',
433
+ name=name,
434
+ label=label,
435
+ content=content,
436
+ border_color=self.colors.border,
437
+ margin=self.style.cluster_margin,
438
+ fontsize=fontsize or self.style.cluster_fontsize
439
+ )
440
+
441
+ def render_dot(
442
+ self,
443
+ tags: list[Tag],
444
+ routes: list[Route],
445
+ nodes: list[SchemaNode],
446
+ links: list[Link],
447
+ spline_line: bool = False
448
+ ) -> str:
449
+ """
450
+ Render the complete DOT graph.
451
+
452
+ Args:
453
+ tags: List of tags
454
+ routes: List of routes
455
+ nodes: List of schema nodes
456
+ links: List of links
457
+ spline_line: Whether to use spline lines
216
458
 
217
- def render_dot(self, tags: list[Tag], routes: list[Route], nodes: list[SchemaNode], links: list[Link], spline_line=False) -> str:
459
+ Returns:
460
+ Complete DOT graph as a string
461
+ """
462
+ # Render tag nodes
463
+ tag_str = '\n'.join(self.render_tag_node(t) for t in tags)
218
464
 
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
- ])
465
+ # Render tags cluster
466
+ tags_cluster = self._render_cluster_container(
467
+ name='tags',
468
+ label='Tags',
469
+ content=tag_str
470
+ )
227
471
 
472
+ # Render routes cluster
228
473
  module_routes_str = self.render_module_route_content(routes)
474
+ routes_cluster = self._render_cluster_container(
475
+ name='router',
476
+ label='Routes',
477
+ content=module_routes_str
478
+ )
479
+
480
+ # Render schemas cluster
229
481
  module_schemas_str = self.render_module_schema_content(nodes)
482
+ schemas_cluster = self._render_cluster_container(
483
+ name='schema',
484
+ label='Schema',
485
+ content=module_schemas_str
486
+ )
487
+
488
+ # Render links
230
489
  link_str = '\n'.join(self.render_link(link) for link in links)
231
490
 
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
491
+ # Render complete digraph
492
+ return self.template_renderer.render_template(
493
+ 'dot/digraph.j2',
494
+ pad=self.style.pad,
495
+ nodesep=self.style.nodesep,
496
+ spline='line' if spline_line else '',
497
+ font=self.style.font,
498
+ node_fontsize=self.style.node_fontsize,
499
+ tags_cluster=tags_cluster,
500
+ routes_cluster=routes_cluster,
501
+ schemas_cluster=schemas_cluster,
502
+ links=link_str
503
+ )
@@ -0,0 +1,105 @@
1
+ """
2
+ Style constants and configuration for rendering DOT graphs and HTML tables.
3
+ """
4
+ from dataclasses import dataclass, field
5
+ from typing import Literal
6
+
7
+
8
+ @dataclass
9
+ class ColorScheme:
10
+ """Color scheme for graph visualization."""
11
+
12
+ # Node colors
13
+ primary: str = '#009485'
14
+ highlight: str = 'tomato'
15
+
16
+ # Pydantic-resolve metadata colors
17
+ resolve: str = '#47a80f'
18
+ post: str = '#427fa4'
19
+ expose_as: str = '#895cb9'
20
+ send_to: str = '#ca6d6d'
21
+ collector: str = '#777'
22
+
23
+ # Link colors
24
+ inherit: str = 'purple'
25
+ subset: str = 'orange'
26
+
27
+ # Border colors
28
+ border: str = '#666'
29
+ cluster_border: str = '#ccc'
30
+
31
+ # Text colors
32
+ text_gray: str = '#999'
33
+
34
+
35
+ @dataclass
36
+ class GraphvizStyle:
37
+ """Graphviz DOT style configuration."""
38
+
39
+ # Font settings
40
+ font: str = 'Helvetica,Arial,sans-serif'
41
+ node_fontsize: str = '16'
42
+ cluster_fontsize: str = '20'
43
+
44
+ # Layout settings
45
+ nodesep: str = '0.8'
46
+ pad: str = '0.5'
47
+ node_margin: str = '0.5,0.1'
48
+ cluster_margin: str = '18'
49
+
50
+ # Link styles configuration
51
+ LINK_STYLES: dict[str, dict] = field(default_factory=lambda: {
52
+ 'tag_route': {
53
+ 'style': 'solid',
54
+ 'minlen': 3,
55
+ },
56
+ 'route_to_schema': {
57
+ 'style': 'solid',
58
+ 'dir': 'back',
59
+ 'arrowtail': 'odot',
60
+ 'minlen': 3,
61
+ },
62
+ 'schema': {
63
+ 'style': 'solid',
64
+ 'label': '',
65
+ 'dir': 'back',
66
+ 'minlen': 3,
67
+ 'arrowtail': 'odot',
68
+ },
69
+ 'parent': {
70
+ 'style': 'solid,dashed',
71
+ 'dir': 'back',
72
+ 'minlen': 3,
73
+ 'taillabel': '< inherit >',
74
+ 'color': 'purple',
75
+ 'tailport': 'n',
76
+ },
77
+ 'subset': {
78
+ 'style': 'solid,dashed',
79
+ 'dir': 'back',
80
+ 'minlen': 3,
81
+ 'taillabel': '< subset >',
82
+ 'color': 'orange',
83
+ 'tailport': 'n',
84
+ },
85
+ 'tag_to_schema': {
86
+ 'style': 'solid',
87
+ 'minlen': 3,
88
+ },
89
+ })
90
+
91
+ def get_link_attributes(self, link_type: str) -> dict:
92
+ """Get link style attributes for a given link type."""
93
+ return self.LINK_STYLES.get(link_type, {})
94
+
95
+
96
+ @dataclass
97
+ class RenderConfig:
98
+ """Complete rendering configuration."""
99
+
100
+ colors: ColorScheme = field(default_factory=ColorScheme)
101
+ style: GraphvizStyle = field(default_factory=GraphvizStyle)
102
+
103
+ # Field display settings
104
+ max_type_length: int = 25
105
+ type_suffix: str = '..'
@@ -0,0 +1,10 @@
1
+ subgraph cluster_{{ cluster_id }} {
2
+ tooltip="{{ tooltip }}"
3
+ color = "{{ border_color }}"
4
+ style="rounded"
5
+ label = " {{ label }}"
6
+ labeljust = "l"
7
+ {% if pen_color %}pencolor = "{{ pen_color }}"{% endif %}
8
+ {% if pen_width %}penwidth = {{ pen_width }}{% endif %}
9
+ {{ content }}
10
+ }
@@ -0,0 +1,9 @@
1
+ subgraph cluster_{{ name }} {
2
+ color = "{{ border_color }}"
3
+ margin={{ margin }}
4
+ style="dashed"
5
+ label = " {{ label }}"
6
+ labeljust = "l"
7
+ fontsize = {{ fontsize }}
8
+ {{ content }}
9
+ }
@@ -0,0 +1,25 @@
1
+ digraph world {
2
+ pad="{{ pad }}"
3
+ nodesep={{ nodesep }}
4
+ {% if spline %}splines={{ spline }}{% endif %}
5
+ fontname="{{ font }}"
6
+ node [fontname="{{ font }}"]
7
+ edge [
8
+ fontname="{{ font }}"
9
+ color="gray"
10
+ ]
11
+ graph [
12
+ rankdir = "LR"
13
+ ];
14
+ node [
15
+ fontsize = {{ node_fontsize }}
16
+ ];
17
+
18
+ {{ tags_cluster }}
19
+
20
+ {{ routes_cluster }}
21
+
22
+ {{ schemas_cluster }}
23
+
24
+ {{ links }}
25
+ }
@@ -0,0 +1 @@
1
+ {{ source }}:e -> {{ target }}:w [{{ attributes }}];
@@ -0,0 +1,5 @@
1
+ "{{ id }}" [
2
+ label = " {{ name }} | {{ response_schema }} "
3
+ margin="{{ margin }}"
4
+ shape = "record"
5
+ ];
@@ -0,0 +1,5 @@
1
+ "{{ id }}" [
2
+ label = {{ label }}
3
+ shape = "plain"
4
+ margin="{{ margin }}"
5
+ ];
@@ -0,0 +1,5 @@
1
+ "{{ id }}" [
2
+ label = " {{ name }} "
3
+ shape = "record"
4
+ margin="{{ margin }}"
5
+ ];
@@ -0,0 +1 @@
1
+ <font color="{{ color }}">{% if strikethrough %}<s>{{ text }}</s>{% else %}{{ text }}{% endif %}</font>
@@ -0,0 +1 @@
1
+ {% if meta_parts %}<br align="left"/><br align="left"/>{{ meta_parts | join('<br align="left"/>') }}<br align="left"/>{% endif %}
@@ -0,0 +1 @@
1
+ <tr><td align="{{ align }}" {% if port %}port="f{{ port }}"{% endif %} cellpadding="8">{{ content }}</td></tr>
@@ -0,0 +1,2 @@
1
+ <tr><td cellpadding="6" bgcolor="{{ bg_color }}" align="center" colspan="1" {% if port %}port="{{ port }}"{% endif %}>
2
+ <font color="white"> {{ text }} </font></td></tr>
@@ -0,0 +1,4 @@
1
+ <<table border="0" cellborder="1" cellpadding="0" cellspacing="0" bgcolor="white">
2
+ {{ header }}
3
+ {{ rows }}
4
+ </table>>
@@ -1,2 +1,2 @@
1
1
  __all__ = ["__version__"]
2
- __version__ = "0.14.1"
2
+ __version__ = "0.15.0"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastapi-voyager
3
- Version: 0.14.1
3
+ Version: 0.15.0
4
4
  Summary: Visualize FastAPI application's routing tree and dependencies
5
5
  Project-URL: Homepage, https://github.com/allmonday/fastapi-voyager
6
6
  Project-URL: Source, https://github.com/allmonday/fastapi-voyager
@@ -19,7 +19,8 @@ Classifier: Programming Language :: Python :: 3.13
19
19
  Classifier: Programming Language :: Python :: 3.14
20
20
  Requires-Python: >=3.10
21
21
  Requires-Dist: fastapi>=0.110
22
- Requires-Dist: pydantic-resolve>=2.3.1
22
+ Requires-Dist: jinja2>=3.0.0
23
+ Requires-Dist: pydantic-resolve>=2.4.3
23
24
  Provides-Extra: dev
24
25
  Requires-Dist: pytest; extra == 'dev'
25
26
  Requires-Dist: ruff; extra == 'dev'
@@ -4,12 +4,25 @@ fastapi_voyager/er_diagram.py,sha256=cMiNKk4ufSM147ldvvfdqfv34Q5mj533VcELsp4Gwwc
4
4
  fastapi_voyager/filter.py,sha256=AN_HIu8-DtKisIq5mFt7CnqRHtxKewedNGyyaI82hSY,11529
5
5
  fastapi_voyager/module.py,sha256=h9YR3BpS-CAcJW9WCdVkF4opqwY32w9T67g9GfdLytk,3425
6
6
  fastapi_voyager/pydantic_resolve_util.py,sha256=r4Rq7BtBcFOMV7O2Ab9TwLyRNL1yNDiQlGUVybf-sXs,3524
7
- fastapi_voyager/render.py,sha256=jST413qpp4JEkgc6OftpWXYnjLkjTM4zUagAmZpQkLo,11791
7
+ fastapi_voyager/render.py,sha256=pk8cxPJXgKQo_ZIjZnUEYUhA3bg0bO2D1VMupkRhyFg,16837
8
+ fastapi_voyager/render_style.py,sha256=mPOuChEl71-3agCbPwkMt2sFmax2AEKDI6dK90eFPRc,2552
8
9
  fastapi_voyager/server.py,sha256=DN4KP37lD_lt4ISg7yoJwzVvw3nKn6qr9C6aH75mR-g,8928
9
10
  fastapi_voyager/type.py,sha256=zluWvh5vpnjXJ9aAmyNJTSmXZPjAHCvgRT5oQRAjHrg,2104
10
11
  fastapi_voyager/type_helper.py,sha256=FmfrZAI3Z4uDdh3sH_kH7UGoY6yNVPapneSN86qY_wo,10209
11
- fastapi_voyager/version.py,sha256=MpoZvuQGxW8JYonuGmdFFp029C74ulzgiWGtVznOmBw,49
12
+ fastapi_voyager/version.py,sha256=v16QMf21an7W4R5BTOYtQb6D8lpmZbl1S2DlRw2aFVs,49
12
13
  fastapi_voyager/voyager.py,sha256=4vonmL-xt54C5San-DRBq4mjoV8Q96eoWRy68MJ1IJw,14169
14
+ fastapi_voyager/templates/dot/cluster.j2,sha256=I2z9KkfCzmAtqXe0gXBnxnOfBXUSpdlATs3uf-O8_B8,307
15
+ fastapi_voyager/templates/dot/cluster_container.j2,sha256=2tH1mOJvPoVKE_aHVMR3t06TfH_dYa9OeH6DBqSHt_A,204
16
+ fastapi_voyager/templates/dot/digraph.j2,sha256=wZuiO-vvZ-AJ1FcMQG4BLevUyxk6yA-yEpUa3Us05mE,435
17
+ fastapi_voyager/templates/dot/link.j2,sha256=9XSbqlJXEcqY8r_gXJLqnVl5N_VxOXUfLGaYs3X3qw0,53
18
+ fastapi_voyager/templates/dot/route_node.j2,sha256=HBI-4HE7tZTCt5ST2aEHp9ntOebFniw5xqxXMRFtDNI,120
19
+ fastapi_voyager/templates/dot/schema_node.j2,sha256=xusd_TgO-eeBBd7d0vFMhCq3HmqRx2mOQNHBYA8ARmY,86
20
+ fastapi_voyager/templates/dot/tag_node.j2,sha256=bzqRXpYYAv_PEE0nDfZgczPJEAl-fGflFGkRQ7ncvzc,96
21
+ fastapi_voyager/templates/html/colored_text.j2,sha256=XgRFWKwY0AI6okEFPyOKGEWYz7gSaRRZ8It3qmmRq84,104
22
+ fastapi_voyager/templates/html/pydantic_meta.j2,sha256=_tsSqjucs_QrAlPIVRy9u6I2-lMVdIXb0AkMM6TEJkE,130
23
+ fastapi_voyager/templates/html/schema_field_row.j2,sha256=KfKexHO_QJV-OIJS0eiY_7fqA8031fWpD2g2wTv4BuE,111
24
+ fastapi_voyager/templates/html/schema_header.j2,sha256=9WpuHLy3Zbv5GHG08qqaj5Xf-gaR-79ErBYuANZp7iA,179
25
+ fastapi_voyager/templates/html/schema_table.j2,sha256=rzphiGk1il7uv4Gr2p_HLPHqyLZk63vLrGAmIduTdSE,117
13
26
  fastapi_voyager/web/graph-ui.js,sha256=DlSRHoTCpWMS6EQsW8naLr8yRn6ofF6wIUL-OsvEjvs,6480
14
27
  fastapi_voyager/web/graphviz.svg.css,sha256=zDCjjpT0Idufu5YOiZI76PL70-avP3vTyzGPh9M85Do,1563
15
28
  fastapi_voyager/web/graphviz.svg.js,sha256=wZwz_lBztoXmujEN21P0w-HMpdmbqPwTQQ6Ebxd9rGo,18569
@@ -29,8 +42,8 @@ fastapi_voyager/web/icon/favicon-16x16.png,sha256=JC07jEzfIYxBIoQn_FHXvyHuxESdhW
29
42
  fastapi_voyager/web/icon/favicon-32x32.png,sha256=C7v1h58cfWOsiLp9yOIZtlx-dLasBcq3NqpHVGRmpt4,1859
30
43
  fastapi_voyager/web/icon/favicon.ico,sha256=tZolYIXkkBcFiYl1A8ksaXN2VjGamzcSdes838dLvNc,15406
31
44
  fastapi_voyager/web/icon/site.webmanifest,sha256=ep4Hzh9zhmiZF2At3Fp1dQrYQuYF_3ZPZxc1KcGBvwQ,263
32
- fastapi_voyager-0.14.1.dist-info/METADATA,sha256=jYSRfxQ8vzsbmklJqGbFNJy4k5JeDNQLYyh8ra23I4A,8184
33
- fastapi_voyager-0.14.1.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
34
- fastapi_voyager-0.14.1.dist-info/entry_points.txt,sha256=pEIKoUnIDXEtdMBq8EmXm70m16vELIu1VPz9-TBUFWM,53
35
- fastapi_voyager-0.14.1.dist-info/licenses/LICENSE,sha256=lNVRR3y_bFVoFKuK2JM8N4sFaj3m-7j29kvL3olFi5Y,1067
36
- fastapi_voyager-0.14.1.dist-info/RECORD,,
45
+ fastapi_voyager-0.15.0.dist-info/METADATA,sha256=tHdt2irJCbL27F6cA7P4O2U8XKA-zxOT9jaf-gwl7Mk,8213
46
+ fastapi_voyager-0.15.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
47
+ fastapi_voyager-0.15.0.dist-info/entry_points.txt,sha256=pEIKoUnIDXEtdMBq8EmXm70m16vELIu1VPz9-TBUFWM,53
48
+ fastapi_voyager-0.15.0.dist-info/licenses/LICENSE,sha256=lNVRR3y_bFVoFKuK2JM8N4sFaj3m-7j29kvL3olFi5Y,1067
49
+ fastapi_voyager-0.15.0.dist-info/RECORD,,