fastapi-voyager 0.4.3__py3-none-any.whl → 0.4.5__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/graph.py CHANGED
@@ -12,7 +12,7 @@ from fastapi_voyager.type_helper import (
12
12
  update_forward_refs
13
13
  )
14
14
  from pydantic import BaseModel
15
- from fastapi_voyager.type import Route, SchemaNode, Link, Tag, ModuleNode
15
+ from fastapi_voyager.type import Route, SchemaNode, Link, Tag, ModuleNode, LinkType
16
16
  from fastapi_voyager.module import build_module_tree
17
17
  from fastapi_voyager.filter import filter_graph
18
18
 
@@ -156,7 +156,8 @@ class Analytics:
156
156
  source_origin: str,
157
157
  target: str,
158
158
  target_origin: str,
159
- type: Literal['child', 'parent', 'subset']):
159
+ type: LinkType
160
+ ) -> bool:
160
161
  """
161
162
  1. add link to link_set
162
163
  2. if duplicated, do nothing, else insert
@@ -262,17 +263,22 @@ class Analytics:
262
263
 
263
264
  def generate_dot(self):
264
265
 
266
+ def generate_link(link: Link):
267
+ if link.type == 'internal':
268
+ return f'''{handle_entry(link.source)}:e -> {handle_entry(link.target)}:w [ {get_link_attributes(link)} ];'''
269
+ else:
270
+ return f'''{handle_entry(link.source)} -> {handle_entry(link.target)} [ {get_link_attributes(link)} ];'''
271
+
272
+
265
273
  def get_link_attributes(link: Link):
266
- if link.type == 'child':
267
- return 'style = "dashed", label = "", minlen=3'
268
- elif link.type == 'parent':
269
- return 'style = "solid", dir="back", minlen=3, taillabel = "< inherit >", color = "purple", tailport="n"'
274
+ if link.type == 'parent':
275
+ return 'style = "solid, dashed", dir="back", minlen=3, taillabel = "< inherit >", color = "purple", tailport="n"'
270
276
  elif link.type == 'entry':
271
- return 'style = "solid", label = "", minlen=3'
277
+ return 'style = "solid", label = "", minlen=3, tailport="e", headport="w"'
272
278
  elif link.type == 'subset':
273
- return 'style = "solid", dir="back", minlen=3, taillabel = "< subset >", color = "orange", tailport="n"'
279
+ return 'style = "solid, dashed", dir="back", minlen=3, taillabel = "< subset >", color = "orange", tailport="n"'
274
280
 
275
- return 'style = "solid", arrowtail="odiamond", dir="back", minlen=3'
281
+ return 'style = "solid", arrowtail="odot", dir="back", minlen=3'
276
282
 
277
283
  def render_module(mod: ModuleNode):
278
284
  color = self.module_color.get(mod.fullname)
@@ -296,7 +302,7 @@ class Analytics:
296
302
  style="rounded"
297
303
  label = " {mod.name}"
298
304
  labeljust = "l"
299
- {(f'pencolor = "{color}"' if color else 'pencolor=""')}
305
+ {(f'pencolor = "{color}"' if color else 'pencolor="#ccc"')}
300
306
  {(f'penwidth = 3' if color else 'penwidth=""')}
301
307
  {inner_nodes_str}
302
308
  {child_str}
@@ -340,9 +346,7 @@ class Analytics:
340
346
 
341
347
  modules_str = '\n'.join(render_module(m) for m in _modules)
342
348
 
343
- links = [
344
- f'''{handle_entry(link.source)} -> {handle_entry(link.target)} [ {get_link_attributes(link)} ];''' for link in _links
345
- ]
349
+ links = [ generate_link(link) for link in _links ]
346
350
  link_str = '\n'.join(links)
347
351
 
348
352
  template = f'''
@@ -376,7 +380,7 @@ class Analytics:
376
380
  color = "#aaa"
377
381
  margin=18
378
382
  style="dashed"
379
- label = " Route apis"
383
+ label = " Routes"
380
384
  labeljust = "l"
381
385
  fontsize = "20"
382
386
  {route_str}
fastapi_voyager/module.py CHANGED
@@ -41,4 +41,24 @@ def build_module_tree(schema_nodes: list[SchemaNode]) -> list[ModuleNode]:
41
41
  result = list(top_modules.values())
42
42
  if root_level_nodes:
43
43
  result.append(ModuleNode(name="__root__", fullname="__root__", schema_nodes=root_level_nodes, modules=[]))
44
+
45
+ # Collapse pass: if a module has exactly one child module and no schema_nodes,
46
+ # merge it upward (A + B -> A.B). Repeat until fixed point.
47
+ def collapse(node: ModuleNode):
48
+ # Collapse chains at current node
49
+ while len(node.modules) == 1 and len(node.schema_nodes) == 0:
50
+ child = node.modules[0]
51
+ # Merge child's identity into current node
52
+ node.name = f"{node.name}.{child.name}"
53
+ # Prefer child's fullname which already reflects full path
54
+ node.fullname = child.fullname
55
+ node.schema_nodes = child.schema_nodes
56
+ node.modules = child.modules
57
+ # Recurse into children
58
+ for m in node.modules:
59
+ collapse(m)
60
+
61
+ for top in result:
62
+ collapse(top)
63
+
44
64
  return result
fastapi_voyager/type.py CHANGED
@@ -1,6 +1,10 @@
1
1
  from dataclasses import dataclass, field
2
2
  from typing import Literal
3
3
 
4
+ @dataclass
5
+ class NodeBase:
6
+ id: str
7
+ name: str
4
8
 
5
9
  @dataclass
6
10
  class FieldInfo:
@@ -11,23 +15,17 @@ class FieldInfo:
11
15
  is_exclude: bool = False
12
16
 
13
17
  @dataclass
14
- class Tag:
15
- id: str
16
- name: str
18
+ class Tag(NodeBase):
17
19
  routes: list['Route'] # route.id
18
20
 
19
21
  @dataclass
20
- class Route:
21
- id: str
22
- name: str
22
+ class Route(NodeBase):
23
23
  source_code: str = ''
24
24
  vscode_link: str = '' # optional vscode deep link
25
25
 
26
26
  @dataclass
27
- class SchemaNode:
28
- id: str
27
+ class SchemaNode(NodeBase):
29
28
  module: str
30
- name: str
31
29
  source_code: str = '' # optional for tests / backward compatibility
32
30
  vscode_link: str = '' # optional vscode deep link
33
31
  fields: list[FieldInfo] = field(default_factory=list)
@@ -39,10 +37,21 @@ class ModuleNode:
39
37
  schema_nodes: list[SchemaNode]
40
38
  modules: list['ModuleNode']
41
39
 
40
+
41
+ # type:
42
+ # - entry: tag -> route, route -> response model
43
+ # - subset: schema -> schema (subset)
44
+ # - parent: schema -> schema (inheritance)
45
+ # - internal: schema -> schema (field reference)
46
+ LinkType = Literal['internal', 'parent', 'entry', 'subset']
47
+
42
48
  @dataclass
43
49
  class Link:
50
+ # node + field level links
44
51
  source: str
45
- source_origin: str # internal relationship
46
52
  target: str
53
+
54
+ # node level links
55
+ source_origin: str
47
56
  target_origin: str
48
- type: Literal['child', 'parent', 'entry', 'subset']
57
+ type: LinkType
@@ -15,24 +15,10 @@ except Exception: # pragma: no cover
15
15
  TypeAliasType = _DummyTypeAliasType # type: ignore
16
16
 
17
17
 
18
- def _is_optional(annotation):
19
- origin = get_origin(annotation)
20
- args = get_args(annotation)
21
- if origin is Union and type(None) in args:
22
- return True
23
- return False
24
-
25
-
26
- def _is_list(annotation):
18
+ def is_list(annotation):
27
19
  return getattr(annotation, "__origin__", None) == list
28
20
 
29
21
 
30
- def shelling_type(type):
31
- while _is_optional(type) or _is_list(type):
32
- type = type.__args__[0]
33
- return type
34
-
35
-
36
22
  def full_class_name(cls):
37
23
  return f"{cls.__module__}.{cls.__qualname__}"
38
24
 
@@ -42,12 +28,9 @@ def get_core_types(tp):
42
28
  - get the core type
43
29
  - always return a tuple of core types
44
30
  """
45
- if tp is type(None):
46
- return tuple()
47
-
48
- # Unwrap PEP 695 type aliases (they wrap the actual annotation in __value__)
49
- # Repeat in case of nested aliasing.
31
+ # Helpers
50
32
  def _unwrap_alias(t):
33
+ """Unwrap PEP 695 type aliases by following __value__ repeatedly."""
51
34
  while isinstance(t, TypeAliasType) or (
52
35
  t.__class__.__name__ == 'TypeAliasType' and hasattr(t, '__value__')
53
36
  ):
@@ -57,47 +40,54 @@ def get_core_types(tp):
57
40
  break
58
41
  return t
59
42
 
60
- tp = _unwrap_alias(tp)
43
+ def _enqueue(items, q):
44
+ for it in items:
45
+ if it is not type(None): # skip None in unions
46
+ q.append(it)
61
47
 
62
- # 1. Unwrap list layers
63
- def _shell_list(_tp):
64
- while _is_list(_tp):
65
- args = getattr(_tp, "__args__", ())
66
- if args:
67
- _tp = args[0]
68
- else:
69
- break
70
- return _tp
71
-
72
- tp = _shell_list(tp)
48
+ # Queue-based shelling to reach concrete core types
49
+ queue: list[object] = [tp]
50
+ result: list[object] = []
73
51
 
74
- # Alias could wrap a list element, unwrap again
75
- tp = _unwrap_alias(tp)
52
+ while queue:
53
+ cur = queue.pop(0)
54
+ if cur is type(None):
55
+ continue
76
56
 
77
- if tp is type(None): # check again
78
- return tuple()
57
+ cur = _unwrap_alias(cur)
79
58
 
80
- while True:
81
- orig = get_origin(tp)
59
+ # Handle Annotated[T, ...] as a shell
60
+ if get_origin(cur) is Annotated:
61
+ args = get_args(cur)
62
+ if args:
63
+ queue.append(args[0])
64
+ continue
82
65
 
66
+ # Handle Union / Optional / PEP 604 UnionType
67
+ orig = get_origin(cur)
83
68
  if orig in (Union, UnionType):
84
- args = list(get_args(tp))
85
- non_none = [a for a in args if a is not type(None)] # noqa: E721
86
- has_none = len(non_none) != len(args)
87
- # Optional[T] case -> keep unwrapping (exactly one real type + None)
88
- if has_none and len(non_none) == 1:
89
- tp = non_none[0]
90
- tp = _unwrap_alias(tp)
91
- tp = _shell_list(tp)
92
- continue
93
- # General union: return all non-None members (order preserved)
94
- if non_none:
95
- return tuple(non_none)
96
- return tuple()
97
- break
98
-
99
- # single concrete type
100
- return (tp,)
69
+ args = get_args(cur)
70
+ # push all non-None members back for further shelling
71
+ _enqueue(args, queue)
72
+ continue
73
+
74
+ # Handle list shells
75
+ if is_list(cur):
76
+ args = getattr(cur, "__args__", ())
77
+ if args:
78
+ queue.append(args[0])
79
+ continue
80
+
81
+ # If still an alias-like wrapper, unwrap again and re-process
82
+ _cur2 = _unwrap_alias(cur)
83
+ if _cur2 is not cur:
84
+ queue.append(_cur2)
85
+ continue
86
+
87
+ # Otherwise treat as a concrete core type (could be a class, typing.Final, etc.)
88
+ result.append(cur)
89
+
90
+ return tuple(result)
101
91
 
102
92
 
103
93
  def get_type_name(anno):
@@ -242,6 +232,7 @@ def safe_issubclass(kls, classinfo):
242
232
 
243
233
 
244
234
  def update_forward_refs(kls):
235
+ # TODO: refactor
245
236
  def update_pydantic_forward_refs(kls2: Type[BaseModel]):
246
237
  """
247
238
  recursively update refs.
@@ -1,2 +1,2 @@
1
1
  __all__ = ["__version__"]
2
- __version__ = "0.4.3"
2
+ __version__ = "0.4.5"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastapi-voyager
3
- Version: 0.4.3
3
+ Version: 0.4.5
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
@@ -144,9 +144,9 @@ dot -Tpng router_viz.dot -o router_viz.png
144
144
  or you can open router_viz.dot with vscode extension `graphviz interactive preview`
145
145
 
146
146
 
147
- ## Plan
147
+ ## Plan before v1.0
148
148
 
149
- features:
149
+ features
150
150
  - [x] group schemas by module hierarchy
151
151
  - [x] module-based coloring via Analytics(module_color={...})
152
152
  - [x] view in web browser
@@ -162,18 +162,30 @@ features:
162
162
  - [x] alt+click to show field details
163
163
  - [x] display source code of routes (including response_model)
164
164
  - [x] handle excluded field
165
- - [ ] user can generate nodes/edges manually and connect to generated ones
166
- - [ ] support dataclass
167
165
  - [ ] group routes by module hierarchy
166
+ - [ ] support dataclass
167
+ - [ ] user can generate nodes/edges manually and connect to generated ones
168
+ - [ ] refactor
169
+ - [ ] abstract render module
170
+ - [ ] ui optimization
171
+ - [ ] fixed left/right bar show field information
172
+ - [ ] display standard ER diagram
173
+ - [ ] display potential invalid links
168
174
  - [ ] integration with pydantic-resolve
169
175
  - [ ] show difference between resolve, post fields
170
176
  - [x] strikethrough for excluded fields
171
177
  - [ ] display loader as edges
172
- - [ ] test cases
178
+ - [ ] add tests
173
179
 
174
180
  bugs:
175
181
  - [ ] fix duplicated link from class and parent class, it also break clicking highlight
176
182
 
183
+ ## Using with pydantic-resolve
184
+
185
+ pydantic-resolve's @ensure_subset decorator is helpful to pick fields from `source class` in safe.
186
+
187
+ TODO: ...
188
+
177
189
 
178
190
  ## Credits
179
191
 
@@ -1,12 +1,12 @@
1
1
  fastapi_voyager/__init__.py,sha256=E5WTV_sYs2LK8I6jzA7AuvFU5a8_vjnDseC3DMha0iQ,149
2
2
  fastapi_voyager/cli.py,sha256=FuXllJtlsm33FX6VmDdAFWzzQ5TIraUizBDgvBj3vmY,10489
3
3
  fastapi_voyager/filter.py,sha256=uZrVVMhHG5E7j1wdsiB02RAyoDdF1q8A4J04oCboYAU,4644
4
- fastapi_voyager/graph.py,sha256=sh483yiNUyK-nQVd1iY7HMZe7HrXHTcSWOJRuYitI8g,14550
5
- fastapi_voyager/module.py,sha256=ppiJ46rdyA4yQnQA5IQqsMOHGgWdz5orJ0bEQydHsEk,1885
4
+ fastapi_voyager/graph.py,sha256=0lTU2XiIer35IPXoQBuh2KajvXUGeea3-vuHyPnketA,14734
5
+ fastapi_voyager/module.py,sha256=NyhSpjevhYtIJdlJxpksGAzfbOV5bHZ7PWbVT4x8LIU,2664
6
6
  fastapi_voyager/server.py,sha256=UFj6c0Yx4Rmy56eFXv65n1VdmJeiBX43KsDm6D65U28,2943
7
- fastapi_voyager/type.py,sha256=k8V45obOzS6HQD8RqaBh4xKBQRUsV-mA_UdMZu51KR0,1005
8
- fastapi_voyager/type_helper.py,sha256=qmkOVjIH-Ou16xt0b1ShLjEn7RH_T7RnBDQK4CkQB3E,8546
9
- fastapi_voyager/version.py,sha256=lQMrpT_VNBvYYY2_24iy2Iu8JL-GqGHRIQGplK9hn0U,48
7
+ fastapi_voyager/type.py,sha256=xkvfUL5vmfaerkaoowdSlTRsD11NP5NG1rMOXYPBJO0,1265
8
+ fastapi_voyager/type_helper.py,sha256=j7AiFXsfl4kaxshYtofbsqo08dIXiHvJ190soIzUdLk,8380
9
+ fastapi_voyager/version.py,sha256=LLMiqcKNNqxWTkirwEvOSRsPX9JD6wKMiTXX3MN-uCA,48
10
10
  fastapi_voyager/web/graph-ui.js,sha256=eEjDnJVMvk35LdRoxcqX_fZxLFS9_bUrGAZL6K2O5C0,4176
11
11
  fastapi_voyager/web/graphviz.svg.css,sha256=zDCjjpT0Idufu5YOiZI76PL70-avP3vTyzGPh9M85Do,1563
12
12
  fastapi_voyager/web/graphviz.svg.js,sha256=lvAdbjHc-lMSk4GQp-iqYA2PCFX4RKnW7dFaoe0LUHs,16005
@@ -24,8 +24,8 @@ fastapi_voyager/web/icon/favicon-16x16.png,sha256=JC07jEzfIYxBIoQn_FHXvyHuxESdhW
24
24
  fastapi_voyager/web/icon/favicon-32x32.png,sha256=C7v1h58cfWOsiLp9yOIZtlx-dLasBcq3NqpHVGRmpt4,1859
25
25
  fastapi_voyager/web/icon/favicon.ico,sha256=tZolYIXkkBcFiYl1A8ksaXN2VjGamzcSdes838dLvNc,15406
26
26
  fastapi_voyager/web/icon/site.webmanifest,sha256=ep4Hzh9zhmiZF2At3Fp1dQrYQuYF_3ZPZxc1KcGBvwQ,263
27
- fastapi_voyager-0.4.3.dist-info/METADATA,sha256=Q_HRTdA7aeM-ErLqLElMVaefGa0VCg6qOTSjJzO75Jk,6154
28
- fastapi_voyager-0.4.3.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
29
- fastapi_voyager-0.4.3.dist-info/entry_points.txt,sha256=pEIKoUnIDXEtdMBq8EmXm70m16vELIu1VPz9-TBUFWM,53
30
- fastapi_voyager-0.4.3.dist-info/licenses/LICENSE,sha256=lNVRR3y_bFVoFKuK2JM8N4sFaj3m-7j29kvL3olFi5Y,1067
31
- fastapi_voyager-0.4.3.dist-info/RECORD,,
27
+ fastapi_voyager-0.4.5.dist-info/METADATA,sha256=aSM2jqwKxrbrr05LTzBLMGQ5wpSQC5EqyDxJFKKygZA,6508
28
+ fastapi_voyager-0.4.5.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
29
+ fastapi_voyager-0.4.5.dist-info/entry_points.txt,sha256=pEIKoUnIDXEtdMBq8EmXm70m16vELIu1VPz9-TBUFWM,53
30
+ fastapi_voyager-0.4.5.dist-info/licenses/LICENSE,sha256=lNVRR3y_bFVoFKuK2JM8N4sFaj3m-7j29kvL3olFi5Y,1067
31
+ fastapi_voyager-0.4.5.dist-info/RECORD,,