fastapi-voyager 0.4.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.
@@ -0,0 +1,396 @@
1
+ import inspect
2
+ from typing import Literal
3
+ from fastapi import FastAPI, routing
4
+ from fastapi_voyager.type_helper import (
5
+ get_core_types,
6
+ full_class_name,
7
+ get_bases_fields,
8
+ is_inheritance_of_pydantic_base,
9
+ get_pydantic_fields,
10
+ get_vscode_link,
11
+ get_source
12
+ )
13
+ from pydantic import BaseModel
14
+ from fastapi_voyager.type import Route, SchemaNode, Link, Tag, ModuleNode
15
+ from fastapi_voyager.module import build_module_tree
16
+ from fastapi_voyager.filter import filter_graph
17
+
18
+ # support pydantic-resolve's ensure_subset
19
+ ENSURE_SUBSET_REFERENCE = '__pydantic_resolve_ensure_subset_reference__'
20
+ PK = "PK"
21
+
22
+ class Analytics:
23
+ def __init__(
24
+ self,
25
+ schema: str | None = None,
26
+ schema_field: str | None = None,
27
+ show_fields: Literal['single', 'object', 'all'] = 'single',
28
+ include_tags: list[str] | None = None,
29
+ module_color: dict[str, str] | None = None,
30
+ route_name: str | None = None,
31
+ load_meta: bool = False
32
+ ):
33
+
34
+ self.routes: list[Route] = []
35
+
36
+ self.nodes: list[SchemaNode] = []
37
+ self.node_set: dict[str, SchemaNode] = {}
38
+
39
+ self.link_set: set[tuple[str, str]] = set()
40
+ self.links: list[Link] = []
41
+
42
+ # store Tag by id, and also keep a list for rendering order
43
+ self.tag_set: dict[str, Tag] = {}
44
+ self.tags: list[Tag] = []
45
+
46
+ self.include_tags = include_tags
47
+ self.schema = schema
48
+ self.schema_field = schema_field
49
+ self.show_fields = show_fields if show_fields in ('single','object','all') else 'object'
50
+ self.module_color = module_color or {}
51
+ self.route_name = route_name
52
+ self.load_meta = load_meta
53
+
54
+
55
+ def _get_available_route(self, app: FastAPI):
56
+ for route in app.routes:
57
+ if isinstance(route, routing.APIRoute) and route.response_model:
58
+ yield route
59
+
60
+
61
+ def analysis(self, app: FastAPI):
62
+ """
63
+ 1. get routes which return pydantic schema
64
+ 1.1 collect tags and routes, add links tag-> route
65
+ 1.2 collect response_model and links route -> response_model
66
+
67
+ 2. iterate schemas, construct the schema/model nodes and their links
68
+ """
69
+ schemas: list[type[BaseModel]] = []
70
+
71
+ for route in self._get_available_route(app):
72
+ # check tags
73
+ tags = getattr(route, 'tags', None)
74
+ route_tag = tags[0] if tags else '__default__'
75
+ if self.include_tags and route_tag not in self.include_tags:
76
+ continue
77
+
78
+ # add tag if not exists
79
+ tag_id = f'tag__{route_tag}'
80
+ if tag_id not in self.tag_set:
81
+ tag_obj = Tag(id=tag_id, name=route_tag, routes=[])
82
+ self.tag_set[tag_id] = tag_obj
83
+ self.tags.append(tag_obj)
84
+
85
+ # add route and create links
86
+ route_id = f'{route.endpoint.__name__}_{route.path.replace("/", "_")}'
87
+ route_name = route.endpoint.__name__
88
+
89
+ # filter by route_name (route.id) if provided
90
+ if self.route_name is not None and route_id != self.route_name:
91
+ continue
92
+
93
+ route_obj = Route(
94
+ id=route_id,
95
+ name=route_name,
96
+ vscode_link=get_vscode_link(route.endpoint) if self.load_meta else None,
97
+ source_code=inspect.getsource(route.endpoint) if self.load_meta else None
98
+ )
99
+
100
+ self.routes.append(route_obj)
101
+ # add route into current tag
102
+ self.tag_set[tag_id].routes.append(route_obj)
103
+ self.links.append(Link(
104
+ source=tag_id,
105
+ source_origin=tag_id,
106
+ target=route_id,
107
+ target_origin=route_id,
108
+ type='entry'
109
+ ))
110
+
111
+ # add response_models and create links from route -> response_model
112
+ for schema in get_core_types(route.response_model):
113
+ if schema and issubclass(schema, BaseModel):
114
+ target_name = full_class_name(schema)
115
+ self.links.append(Link(
116
+ source=route_id,
117
+ source_origin=route_id,
118
+ target=self.generate_node_head(target_name),
119
+ target_origin=target_name,
120
+ type='entry'
121
+ ))
122
+
123
+ schemas.append(schema)
124
+
125
+ for s in schemas:
126
+ self.analysis_schemas(s)
127
+
128
+ self.nodes = list(self.node_set.values())
129
+
130
+
131
+ def add_to_node_set(self, schema):
132
+ """
133
+ 1. calc full_path, add to node_set
134
+ 2. if duplicated, do nothing, else insert
135
+ 2. return the full_path
136
+ """
137
+ full_name = full_class_name(schema)
138
+ bases_fields = get_bases_fields([s for s in schema.__bases__ if is_inheritance_of_pydantic_base(s)])
139
+ if full_name not in self.node_set:
140
+ # skip meta info for normal queries
141
+ self.node_set[full_name] = SchemaNode(
142
+ id=full_name,
143
+ module=schema.__module__,
144
+ name=schema.__name__,
145
+ source_code=get_source(schema) if self.load_meta else None,
146
+ vscode_link=get_vscode_link(schema) if self.load_meta else None,
147
+ fields=get_pydantic_fields(schema, bases_fields)
148
+ )
149
+ return full_name
150
+
151
+
152
+ def add_to_link_set(
153
+ self,
154
+ source: str,
155
+ source_origin: str,
156
+ target: str,
157
+ target_origin: str,
158
+ type: Literal['child', 'parent', 'subset']):
159
+ """
160
+ 1. add link to link_set
161
+ 2. if duplicated, do nothing, else insert
162
+ """
163
+ pair = (source, target)
164
+ if result := pair not in self.link_set:
165
+ self.link_set.add(pair)
166
+ self.links.append(Link(
167
+ source=source,
168
+ source_origin=source_origin,
169
+ target=target,
170
+ target_origin=target_origin,
171
+ type=type
172
+ ))
173
+ return result
174
+
175
+
176
+ def analysis_schemas(self, schema: type[BaseModel]):
177
+ """
178
+ 1. cls is the source, add schema
179
+ 2. pydantic fields are targets, if annotation is subclass of BaseMode, add fields and add links
180
+ 3. recursively run walk_schema
181
+ """
182
+
183
+ self.add_to_node_set(schema)
184
+
185
+ # handle schema inside ensure_subset(schema)
186
+ if subset_reference := getattr(schema, ENSURE_SUBSET_REFERENCE, None):
187
+ if is_inheritance_of_pydantic_base(subset_reference):
188
+
189
+ self.add_to_node_set(subset_reference)
190
+ self.add_to_link_set(
191
+ source=self.generate_node_head(full_class_name(schema)),
192
+ source_origin=full_class_name(schema),
193
+ target= self.generate_node_head(full_class_name(subset_reference)),
194
+ target_origin=full_class_name(subset_reference),
195
+ type='subset')
196
+ self.analysis_schemas(subset_reference)
197
+
198
+ # handle bases
199
+ for base_class in schema.__bases__:
200
+ if is_inheritance_of_pydantic_base(base_class):
201
+ self.add_to_node_set(base_class)
202
+ self.add_to_link_set(
203
+ source=self.generate_node_head(full_class_name(schema)),
204
+ source_origin=full_class_name(schema),
205
+ target=self.generate_node_head(full_class_name(base_class)),
206
+ target_origin=full_class_name(base_class),
207
+ type='parent')
208
+ self.analysis_schemas(base_class)
209
+
210
+ # handle fields
211
+ for k, v in schema.model_fields.items():
212
+ annos = get_core_types(v.annotation)
213
+ for anno in annos:
214
+ if anno and is_inheritance_of_pydantic_base(anno):
215
+ self.add_to_node_set(anno)
216
+ # add f prefix to fix highlight issue in vsc graphviz interactive previewer
217
+ source_name = f'{full_class_name(schema)}::f{k}'
218
+ if self.add_to_link_set(
219
+ source=source_name,
220
+ source_origin=full_class_name(schema),
221
+ target=self.generate_node_head(full_class_name(anno)),
222
+ target_origin=full_class_name(anno),
223
+ type='internal'):
224
+ self.analysis_schemas(anno)
225
+
226
+
227
+ def generate_node_head(self, link_name: str):
228
+ return f'{link_name}::{PK}'
229
+
230
+
231
+ def generate_node_label(self, node: SchemaNode):
232
+ has_base_fields = any(f.from_base for f in node.fields)
233
+
234
+ fields = [n for n in node.fields if n.from_base is False]
235
+
236
+ name = node.name
237
+ fields_parts: list[str] = []
238
+
239
+ if self.show_fields == 'all':
240
+ _fields = fields
241
+ if has_base_fields:
242
+ fields_parts.append('<tr><td align="left" cellpadding="8"><font color="#999"> Inherited Fields ... </font></td></tr>')
243
+ elif self.show_fields == 'object':
244
+ _fields = [f for f in fields if f.is_object is True]
245
+
246
+ else: # 'single'
247
+ _fields = []
248
+
249
+ for field in _fields:
250
+ type_name = field.type_name[:25] + '..' if len(field.type_name) > 25 else field.type_name
251
+ display_xml = f'<s align="left">{field.name}: {type_name}</s>' if field.is_exclude else f'{field.name}: {type_name}'
252
+ field_str = f"""<tr><td align="left" port="f{field.name}" cellpadding="8"><font> {display_xml} </font></td></tr>"""
253
+ fields_parts.append(field_str)
254
+
255
+ header_color = 'tomato' if node.id == self.schema else '#009485'
256
+ header = f"""<tr><td cellpadding="1.5" bgcolor="{header_color}" align="center" colspan="1" port="{PK}"> <font color="white"> {name} </font> </td> </tr>"""
257
+ field_content = ''.join(fields_parts) if fields_parts else ''
258
+
259
+ return f"""<<table border="1" cellborder="0" cellpadding="0" bgcolor="white"> {header} {field_content} </table>>"""
260
+
261
+ def generate_dot(self):
262
+
263
+ def get_link_attributes(link: Link):
264
+ if link.type == 'child':
265
+ return 'style = "dashed", label = "", minlen=3'
266
+ elif link.type == 'parent':
267
+ return 'style = "solid", dir="back", minlen=3, taillabel = "< inherit >", color = "purple", tailport="n"'
268
+ elif link.type == 'entry':
269
+ return 'style = "solid", label = "", minlen=3'
270
+ elif link.type == 'subset':
271
+ return 'style = "solid", dir="back", minlen=3, taillabel = "< subset >", color = "orange", tailport="n"'
272
+
273
+ return 'style = "solid", arrowtail="odiamond", dir="back", minlen=3'
274
+
275
+ def render_module(mod: ModuleNode):
276
+ color = self.module_color.get(mod.fullname)
277
+ # render schema nodes inside this module
278
+ inner_nodes = [
279
+ f'''
280
+ "{node.id}" [
281
+ label = {self.generate_node_label(node)}
282
+ shape = "plain"
283
+ margin="0.5,0.1"
284
+ ];''' for node in mod.schema_nodes
285
+ ]
286
+ inner_nodes_str = '\n'.join(inner_nodes)
287
+
288
+ # render child modules recursively
289
+ child_str = '\n'.join(render_module(m) for m in mod.modules)
290
+
291
+ return f'''
292
+ subgraph cluster_module_{mod.fullname.replace('.', '_')} {{
293
+ color = "#666"
294
+ style="rounded"
295
+ label = " {mod.name}"
296
+ labeljust = "l"
297
+ {(f'pencolor = "{color}"' if color else 'pencolor=""')}
298
+ {(f'penwidth = 3' if color else 'penwidth=""')}
299
+ {inner_nodes_str}
300
+ {child_str}
301
+ }}'''
302
+
303
+ def handle_entry(source: str):
304
+ if '::' in source:
305
+ a, b = source.split('::', 1)
306
+ return f'"{a}":{b}'
307
+ return f'"{source}"'
308
+
309
+
310
+ _tags, _routes, _nodes, _links = filter_graph(
311
+ schema=self.schema,
312
+ schema_field=self.schema_field,
313
+ tags=self.tags,
314
+ routes=self.routes,
315
+ nodes=self.nodes,
316
+ links=self.links,
317
+ node_set=self.node_set,
318
+ )
319
+ _modules = build_module_tree(_nodes)
320
+
321
+ tags = [
322
+ f'''
323
+ "{t.id}" [
324
+ label = " {t.name} "
325
+ shape = "record"
326
+ margin="0.5,0.1"
327
+ ];''' for t in _tags]
328
+ tag_str = '\n'.join(tags)
329
+
330
+ routes = [
331
+ f'''
332
+ "{r.id}" [
333
+ label = " {r.name} "
334
+ margin="0.5,0.1"
335
+ shape = "record"
336
+ ];''' for r in _routes]
337
+ route_str = '\n'.join(routes)
338
+
339
+ modules_str = '\n'.join(render_module(m) for m in _modules)
340
+
341
+ links = [
342
+ f'''{handle_entry(link.source)} -> {handle_entry(link.target)} [ {get_link_attributes(link)} ];''' for link in _links
343
+ ]
344
+ link_str = '\n'.join(links)
345
+
346
+ template = f'''
347
+ digraph world {{
348
+ pad="0.5"
349
+ nodesep=0.8
350
+ fontname="Helvetica,Arial,sans-serif"
351
+ node [fontname="Helvetica,Arial,sans-serif"]
352
+ edge [
353
+ fontname="Helvetica,Arial,sans-serif"
354
+ color="gray"
355
+ ]
356
+ graph [
357
+ rankdir = "LR"
358
+ ];
359
+ node [
360
+ fontsize = "16"
361
+ ];
362
+
363
+ subgraph cluster_tags {{
364
+ color = "#aaa"
365
+ margin=18
366
+ style="dashed"
367
+ label = " Tags"
368
+ labeljust = "l"
369
+ fontsize = "20"
370
+ {tag_str}
371
+ }}
372
+
373
+ subgraph cluster_router {{
374
+ color = "#aaa"
375
+ margin=18
376
+ style="dashed"
377
+ label = " Route apis"
378
+ labeljust = "l"
379
+ fontsize = "20"
380
+ {route_str}
381
+ }}
382
+
383
+ subgraph cluster_schema {{
384
+ color = "#aaa"
385
+ margin=18
386
+ style="dashed"
387
+ label=" Schema"
388
+ labeljust="l"
389
+ fontsize="20"
390
+ {modules_str}
391
+ }}
392
+
393
+ {link_str}
394
+ }}
395
+ '''
396
+ return template
@@ -0,0 +1,44 @@
1
+ from fastapi_voyager.type import SchemaNode, ModuleNode
2
+
3
+ def build_module_tree(schema_nodes: list[SchemaNode]) -> list[ModuleNode]:
4
+ """
5
+ 1. the name of module_node comes from schema_node's module field
6
+ 2. split the module_name with '.' to create a tree structure
7
+ 3. group schema_nodes under the correct module_node
8
+ 4. return the top-level module_node list
9
+ """
10
+ # Map from top-level module name to ModuleNode
11
+ top_modules: dict[str, ModuleNode] = {}
12
+ # For nodes without module path, collect separately
13
+ root_level_nodes = []
14
+
15
+ def get_or_create(child_name: str, parent: ModuleNode) -> ModuleNode:
16
+ for m in parent.modules:
17
+ if m.name == child_name:
18
+ return m
19
+ # derive fullname from parent
20
+ parent_full = parent.fullname
21
+ fullname = child_name if not parent_full or parent_full == "__root__" else f"{parent_full}.{child_name}"
22
+ new_node = ModuleNode(name=child_name, fullname=fullname, schema_nodes=[], modules=[])
23
+ parent.modules.append(new_node)
24
+ return new_node
25
+
26
+ for sn in schema_nodes:
27
+ module_path = sn.module or ""
28
+ if not module_path:
29
+ root_level_nodes.append(sn)
30
+ continue
31
+ parts = module_path.split('.')
32
+ top_name = parts[0]
33
+ if top_name not in top_modules:
34
+ top_modules[top_name] = ModuleNode(name=top_name, fullname=top_name, schema_nodes=[], modules=[])
35
+ current = top_modules[top_name]
36
+ for part in parts[1:]:
37
+ current = get_or_create(part, current)
38
+ current.schema_nodes.append(sn)
39
+
40
+ # If there are root-level nodes, add a pseudo-module named "__root__"
41
+ result = list(top_modules.values())
42
+ if root_level_nodes:
43
+ result.append(ModuleNode(name="__root__", fullname="__root__", schema_nodes=root_level_nodes, modules=[]))
44
+ return result
@@ -0,0 +1,107 @@
1
+ from pathlib import Path
2
+ from typing import Optional, Dict
3
+ from fastapi import FastAPI
4
+ from starlette.middleware.gzip import GZipMiddleware
5
+ from pydantic import BaseModel
6
+ from fastapi.responses import HTMLResponse, PlainTextResponse
7
+ from fastapi.staticfiles import StaticFiles
8
+ from fastapi_voyager.graph import Analytics
9
+ from fastapi_voyager.type import Tag, FieldInfo
10
+
11
+
12
+ WEB_DIR = Path(__file__).parent / "web"
13
+ WEB_DIR.mkdir(exist_ok=True)
14
+
15
+ class SchemaType(BaseModel):
16
+ name: str
17
+ fullname: str
18
+ source_code: str
19
+ vscode_link: str
20
+ fields: list[FieldInfo]
21
+
22
+ class OptionParam(BaseModel):
23
+ tags: list[Tag]
24
+ schemas: list[SchemaType]
25
+ dot: str
26
+
27
+ class Payload(BaseModel):
28
+ tags: Optional[list[str]] = None
29
+ schema_name: Optional[str] = None
30
+ schema_field: Optional[str] = None
31
+ route_name: Optional[str] = None
32
+ show_fields: str = 'object'
33
+ show_meta: bool = False
34
+
35
+ def create_app_with_fastapi(
36
+ target_app: FastAPI,
37
+ module_color: dict[str, str] | None = None,
38
+ gzip_minimum_size: int | None = 500,
39
+ ) -> FastAPI:
40
+ """Create a FastAPI server that serves DOT computed via Analytics.
41
+
42
+ This avoids module-level globals by keeping state in closures.
43
+ """
44
+
45
+ app = FastAPI(title="fastapi-voyager demo server")
46
+
47
+ # Enable gzip compression for larger responses (e.g. DOT / schemas payload)
48
+ if gzip_minimum_size is not None and gzip_minimum_size >= 0:
49
+ app.add_middleware(GZipMiddleware, minimum_size=gzip_minimum_size)
50
+
51
+ @app.get("/dot", response_model=OptionParam)
52
+ def get_dot() -> str:
53
+ analytics = Analytics(module_color=module_color, load_meta=True)
54
+ analytics.analysis(target_app)
55
+ dot = analytics.generate_dot()
56
+
57
+ # include tags and their routes
58
+ tags = analytics.tags
59
+
60
+ schemas = [
61
+ SchemaType(
62
+ name=s.name,
63
+ fullname=s.id,
64
+ fields=s.fields,
65
+ source_code=s.source_code,
66
+ vscode_link=s.vscode_link
67
+ ) for s in analytics.nodes
68
+ ]
69
+ schemas.sort(key=lambda s: s.name)
70
+
71
+ return OptionParam(tags=tags, schemas=schemas, dot=dot)
72
+
73
+ @app.post("/dot", response_class=PlainTextResponse)
74
+ def get_filtered_dot(payload: Payload) -> str:
75
+ analytics = Analytics(
76
+ include_tags=payload.tags,
77
+ schema=payload.schema_name,
78
+ schema_field=payload.schema_field,
79
+ show_fields=payload.show_fields,
80
+ module_color=module_color,
81
+ route_name=payload.route_name,
82
+ load_meta=False,
83
+ )
84
+ analytics.analysis(target_app)
85
+ return analytics.generate_dot()
86
+
87
+ @app.get("/", response_class=HTMLResponse)
88
+ def index():
89
+ index_file = WEB_DIR / "index.html"
90
+ if index_file.exists():
91
+ return index_file.read_text(encoding="utf-8")
92
+ # fallback simple page if index.html missing
93
+ return """
94
+ <!doctype html>
95
+ <html>
96
+ <head><meta charset=\"utf-8\"><title>Graphviz Preview</title></head>
97
+ <body>
98
+ <p>index.html not found. Create one under src/fastapi_voyager/web/index.html</p>
99
+ </body>
100
+ </html>
101
+ """
102
+
103
+ # Serve static files under /static
104
+ app.mount("/static", StaticFiles(directory=str(WEB_DIR)), name="static")
105
+
106
+ return app
107
+
@@ -0,0 +1,48 @@
1
+ from dataclasses import dataclass, field
2
+ from typing import Literal
3
+
4
+
5
+ @dataclass
6
+ class FieldInfo:
7
+ name: str
8
+ type_name: str
9
+ from_base: bool = False
10
+ is_object: bool = False
11
+ is_exclude: bool = False
12
+
13
+ @dataclass
14
+ class Tag:
15
+ id: str
16
+ name: str
17
+ routes: list['Route'] # route.id
18
+
19
+ @dataclass
20
+ class Route:
21
+ id: str
22
+ name: str
23
+ source_code: str = ''
24
+ vscode_link: str = '' # optional vscode deep link
25
+
26
+ @dataclass
27
+ class SchemaNode:
28
+ id: str
29
+ module: str
30
+ name: str
31
+ source_code: str = '' # optional for tests / backward compatibility
32
+ vscode_link: str = '' # optional vscode deep link
33
+ fields: list[FieldInfo] = field(default_factory=list)
34
+
35
+ @dataclass
36
+ class ModuleNode:
37
+ name: str
38
+ fullname: str
39
+ schema_nodes: list[SchemaNode]
40
+ modules: list['ModuleNode']
41
+
42
+ @dataclass
43
+ class Link:
44
+ source: str
45
+ source_origin: str # internal relationship
46
+ target: str
47
+ target_origin: str
48
+ type: Literal['child', 'parent', 'entry', 'subset']