fastapi-voyager 0.5.2__tar.gz → 0.6.1__tar.gz

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 (47) hide show
  1. {fastapi_voyager-0.5.2 → fastapi_voyager-0.6.1}/PKG-INFO +8 -1
  2. {fastapi_voyager-0.5.2 → fastapi_voyager-0.6.1}/README.md +7 -0
  3. {fastapi_voyager-0.5.2 → fastapi_voyager-0.6.1}/src/fastapi_voyager/cli.py +8 -2
  4. fastapi_voyager-0.6.1/src/fastapi_voyager/module.py +99 -0
  5. {fastapi_voyager-0.5.2 → fastapi_voyager-0.6.1}/src/fastapi_voyager/render.py +37 -17
  6. {fastapi_voyager-0.5.2 → fastapi_voyager-0.6.1}/src/fastapi_voyager/server.py +26 -22
  7. {fastapi_voyager-0.5.2 → fastapi_voyager-0.6.1}/src/fastapi_voyager/type.py +8 -0
  8. {fastapi_voyager-0.5.2 → fastapi_voyager-0.6.1}/src/fastapi_voyager/type_helper.py +2 -1
  9. {fastapi_voyager-0.5.2 → fastapi_voyager-0.6.1}/src/fastapi_voyager/version.py +1 -1
  10. {fastapi_voyager-0.5.2 → fastapi_voyager-0.6.1}/src/fastapi_voyager/voyager.py +2 -0
  11. {fastapi_voyager-0.5.2 → fastapi_voyager-0.6.1}/src/fastapi_voyager/web/component/render-graph.js +1 -1
  12. {fastapi_voyager-0.5.2 → fastapi_voyager-0.6.1}/src/fastapi_voyager/web/component/schema-field-filter.js +1 -1
  13. {fastapi_voyager-0.5.2 → fastapi_voyager-0.6.1}/src/fastapi_voyager/web/index.html +10 -10
  14. {fastapi_voyager-0.5.2 → fastapi_voyager-0.6.1}/src/fastapi_voyager/web/vue-main.js +5 -5
  15. {fastapi_voyager-0.5.2 → fastapi_voyager-0.6.1}/tests/demo.py +11 -2
  16. fastapi_voyager-0.6.1/tests/programatic.py +8 -0
  17. {fastapi_voyager-0.5.2 → fastapi_voyager-0.6.1}/tests/test_module.py +5 -5
  18. fastapi_voyager-0.5.2/src/fastapi_voyager/module.py +0 -64
  19. {fastapi_voyager-0.5.2 → fastapi_voyager-0.6.1}/.gitignore +0 -0
  20. {fastapi_voyager-0.5.2 → fastapi_voyager-0.6.1}/.python-version +0 -0
  21. {fastapi_voyager-0.5.2 → fastapi_voyager-0.6.1}/LICENSE +0 -0
  22. {fastapi_voyager-0.5.2 → fastapi_voyager-0.6.1}/pyproject.toml +0 -0
  23. {fastapi_voyager-0.5.2 → fastapi_voyager-0.6.1}/src/fastapi_voyager/__init__.py +0 -0
  24. {fastapi_voyager-0.5.2 → fastapi_voyager-0.6.1}/src/fastapi_voyager/filter.py +0 -0
  25. {fastapi_voyager-0.5.2 → fastapi_voyager-0.6.1}/src/fastapi_voyager/web/component/route-code-display.js +0 -0
  26. {fastapi_voyager-0.5.2 → fastapi_voyager-0.6.1}/src/fastapi_voyager/web/component/schema-code-display.js +0 -0
  27. {fastapi_voyager-0.5.2 → fastapi_voyager-0.6.1}/src/fastapi_voyager/web/graph-ui.js +0 -0
  28. {fastapi_voyager-0.5.2 → fastapi_voyager-0.6.1}/src/fastapi_voyager/web/graphviz.svg.css +0 -0
  29. {fastapi_voyager-0.5.2 → fastapi_voyager-0.6.1}/src/fastapi_voyager/web/graphviz.svg.js +0 -0
  30. {fastapi_voyager-0.5.2 → fastapi_voyager-0.6.1}/src/fastapi_voyager/web/icon/android-chrome-192x192.png +0 -0
  31. {fastapi_voyager-0.5.2 → fastapi_voyager-0.6.1}/src/fastapi_voyager/web/icon/android-chrome-512x512.png +0 -0
  32. {fastapi_voyager-0.5.2 → fastapi_voyager-0.6.1}/src/fastapi_voyager/web/icon/apple-touch-icon.png +0 -0
  33. {fastapi_voyager-0.5.2 → fastapi_voyager-0.6.1}/src/fastapi_voyager/web/icon/favicon-16x16.png +0 -0
  34. {fastapi_voyager-0.5.2 → fastapi_voyager-0.6.1}/src/fastapi_voyager/web/icon/favicon-32x32.png +0 -0
  35. {fastapi_voyager-0.5.2 → fastapi_voyager-0.6.1}/src/fastapi_voyager/web/icon/favicon.ico +0 -0
  36. {fastapi_voyager-0.5.2 → fastapi_voyager-0.6.1}/src/fastapi_voyager/web/icon/site.webmanifest +0 -0
  37. {fastapi_voyager-0.5.2 → fastapi_voyager-0.6.1}/src/fastapi_voyager/web/quasar.min.css +0 -0
  38. {fastapi_voyager-0.5.2 → fastapi_voyager-0.6.1}/src/fastapi_voyager/web/quasar.min.js +0 -0
  39. {fastapi_voyager-0.5.2 → fastapi_voyager-0.6.1}/tests/__init__.py +0 -0
  40. {fastapi_voyager-0.5.2 → fastapi_voyager-0.6.1}/tests/demo_anno.py +0 -0
  41. {fastapi_voyager-0.5.2 → fastapi_voyager-0.6.1}/tests/service/__init__.py +0 -0
  42. {fastapi_voyager-0.5.2 → fastapi_voyager-0.6.1}/tests/service/schema.py +0 -0
  43. {fastapi_voyager-0.5.2 → fastapi_voyager-0.6.1}/tests/test_analysis.py +0 -0
  44. {fastapi_voyager-0.5.2 → fastapi_voyager-0.6.1}/tests/test_import.py +0 -0
  45. {fastapi_voyager-0.5.2 → fastapi_voyager-0.6.1}/tests/test_type_helper.py +0 -0
  46. {fastapi_voyager-0.5.2 → fastapi_voyager-0.6.1}/uv.lock +0 -0
  47. {fastapi_voyager-0.5.2 → fastapi_voyager-0.6.1}/voyager.jpg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastapi-voyager
3
- Version: 0.5.2
3
+ Version: 0.6.1
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
@@ -183,6 +183,13 @@ features
183
183
  - [ ] abstract render module
184
184
  - [ ] export voyager core data into json (for better debugging)
185
185
  - [ ] add api to rebuild core data from json, and render it
186
+ - [ ] fix Generic case
187
+
188
+ ```python
189
+ class Paged(BaseModel, Generic[T]):
190
+ total: int
191
+ items: List[T]
192
+ ```
186
193
 
187
194
  bugs:
188
195
  - [ ] fix duplicated link from class and parent class, it also break clicking highlight
@@ -156,6 +156,13 @@ features
156
156
  - [ ] abstract render module
157
157
  - [ ] export voyager core data into json (for better debugging)
158
158
  - [ ] add api to rebuild core data from json, and render it
159
+ - [ ] fix Generic case
160
+
161
+ ```python
162
+ class Paged(BaseModel, Generic[T]):
163
+ total: int
164
+ items: List[T]
165
+ ```
159
166
 
160
167
  bugs:
161
168
  - [ ] fix duplicated link from class and parent class, it also break clicking highlight
@@ -169,6 +169,12 @@ Examples:
169
169
  default=8000,
170
170
  help="Port for the preview server when --server is used (default: 8000)"
171
171
  )
172
+ parser.add_argument(
173
+ "--host",
174
+ type=str,
175
+ default="127.0.0.1",
176
+ help="Host/IP for the preview server when --server is used (default: 127.0.0.1). Use 0.0.0.0 to listen on all interfaces."
177
+ )
172
178
 
173
179
  parser.add_argument(
174
180
  "--version", "-v",
@@ -267,8 +273,8 @@ Examples:
267
273
  print("Error: uvicorn is required to run the server. Install via 'pip install uvicorn' or 'uv add uvicorn'.")
268
274
  sys.exit(1)
269
275
  app_server = viz_server.create_app_with_fastapi(app, module_color=module_color)
270
- print(f"Starting preview server at http://127.0.0.1:{args.port} ... (Ctrl+C to stop)")
271
- uvicorn.run(app_server, host="127.0.0.1", port=args.port)
276
+ print(f"Starting preview server at http://{args.host}:{args.port} ... (Ctrl+C to stop)")
277
+ uvicorn.run(app_server, host=args.host, port=args.port)
272
278
  else:
273
279
  # Generate and write dot file locally
274
280
  generate_visualization(
@@ -0,0 +1,99 @@
1
+ from typing import Callable, Type, TypeVar, Any
2
+ from fastapi_voyager.type import SchemaNode, ModuleNode, Route, ModuleRoute
3
+
4
+
5
+ N = TypeVar('N') # Node type: ModuleNode or ModuleRoute
6
+ I = TypeVar('I') # Item type: SchemaNode or Route
7
+
8
+
9
+ def _build_module_tree(
10
+ items: list[I],
11
+ *,
12
+ get_module_path: Callable[[I], str | None],
13
+ NodeClass: Type[N],
14
+ item_list_attr: str,
15
+ ) -> list[N]:
16
+ """
17
+ Generic builder that groups items by dotted module path into a tree of NodeClass.
18
+
19
+ NodeClass must accept kwargs: name, fullname, modules(list), and an item list via
20
+ item_list_attr (e.g., 'schema_nodes' or 'routes').
21
+ """
22
+ # Map from top-level module name to node
23
+ top_modules: dict[str, N] = {}
24
+ # Items without module path
25
+ root_level_items: list[I] = []
26
+
27
+ def make_node(name: str, fullname: str) -> N:
28
+ kwargs: dict[str, Any] = {
29
+ 'name': name,
30
+ 'fullname': fullname,
31
+ 'modules': [],
32
+ item_list_attr: [],
33
+ }
34
+ return NodeClass(**kwargs) # type: ignore[arg-type]
35
+
36
+ def get_or_create(child_name: str, parent: N) -> N:
37
+ for m in getattr(parent, 'modules'):
38
+ if m.name == child_name:
39
+ return m
40
+ parent_full = getattr(parent, 'fullname')
41
+ fullname = child_name if not parent_full or parent_full == "__root__" else f"{parent_full}.{child_name}"
42
+ new_node = make_node(child_name, fullname)
43
+ getattr(parent, 'modules').append(new_node)
44
+ return new_node
45
+
46
+ # Build the tree
47
+ for it in items:
48
+ module_path = get_module_path(it) or ""
49
+ if not module_path:
50
+ root_level_items.append(it)
51
+ continue
52
+ parts = module_path.split('.')
53
+ top_name = parts[0]
54
+ if top_name not in top_modules:
55
+ top_modules[top_name] = make_node(top_name, top_name)
56
+ current = top_modules[top_name]
57
+ for part in parts[1:]:
58
+ current = get_or_create(part, current)
59
+ getattr(current, item_list_attr).append(it)
60
+
61
+ result: list[N] = list(top_modules.values())
62
+ if root_level_items:
63
+ result.append(make_node("__root__", "__root__"))
64
+ setattr(result[-1], item_list_attr, root_level_items)
65
+
66
+ # Collapse linear chains: no items on node and exactly one child module
67
+ def collapse(node: N) -> None:
68
+ while len(getattr(node, 'modules')) == 1 and len(getattr(node, item_list_attr)) == 0:
69
+ child = getattr(node, 'modules')[0]
70
+ node.name = f"{node.name}.{child.name}"
71
+ node.fullname = child.fullname
72
+ setattr(node, item_list_attr, getattr(child, item_list_attr))
73
+ setattr(node, 'modules', getattr(child, 'modules'))
74
+ for m in getattr(node, 'modules'):
75
+ collapse(m)
76
+
77
+ for top in result:
78
+ collapse(top)
79
+
80
+ return result
81
+
82
+ def build_module_schema_tree(schema_nodes: list[SchemaNode]) -> list[ModuleNode]:
83
+ """Build a module tree for schema nodes, grouped by their module path."""
84
+ return _build_module_tree(
85
+ schema_nodes,
86
+ get_module_path=lambda sn: sn.module,
87
+ NodeClass=ModuleNode,
88
+ item_list_attr='schema_nodes',
89
+ )
90
+
91
+
92
+ def build_module_route_tree(routes: list[Route]) -> list[ModuleRoute]:
93
+ """Build a module tree for routes, grouped by their module path."""
94
+ return _build_module_tree(
95
+ routes,
96
+ get_module_path=lambda r: r.module,
97
+ NodeClass=ModuleRoute,
98
+ item_list_attr='routes',
99
+ )
@@ -1,5 +1,5 @@
1
- from fastapi_voyager.type import SchemaNode, ModuleNode, Link, Tag, Route, FieldType, PK
2
- from fastapi_voyager.module import build_module_tree
1
+ from fastapi_voyager.type import SchemaNode, ModuleNode, Link, Tag, Route, FieldType, PK, ModuleRoute
2
+ from fastapi_voyager.module import build_module_schema_tree, build_module_route_tree
3
3
 
4
4
 
5
5
  class Renderer:
@@ -51,7 +51,7 @@ class Renderer:
51
51
  if link.type == 'tag_route':
52
52
  return f"""{h(link.source)}:e -> {h(link.target)}:w [style = "solid", minlen=3];"""
53
53
  elif link.type == 'route_to_schema':
54
- return f"""{h(link.source)}:e -> {h(link.target)}:{PK} [style = "solid", dir="back", arrowtail="odot", minlen=3];"""
54
+ return f"""{h(link.source)}:e -> {h(link.target)}:w [style = "solid", dir="back", arrowtail="odot", minlen=3];"""
55
55
  elif link.type == 'schema':
56
56
  return f"""{h(link.source)}:e -> {h(link.target)}:w [style = "solid", label = "", dir="back", minlen=3, arrowtail="odot"];"""
57
57
  elif link.type == 'parent':
@@ -61,7 +61,7 @@ class Renderer:
61
61
  else:
62
62
  raise ValueError(f'Unknown link type: {link.type}')
63
63
 
64
- def render_module(self, mod: ModuleNode) -> str:
64
+ def render_module_schema(self, mod: ModuleNode) -> str:
65
65
  color = self.module_color.get(mod.fullname)
66
66
  inner_nodes = [
67
67
  f'''
@@ -72,7 +72,7 @@ class Renderer:
72
72
  ];''' for node in mod.schema_nodes
73
73
  ]
74
74
  inner_nodes_str = '\n'.join(inner_nodes)
75
- child_str = '\n'.join(self.render_module(m) for m in mod.modules)
75
+ child_str = '\n'.join(self.render_module_schema(m) for m in mod.modules)
76
76
  return f'''
77
77
  subgraph cluster_module_{mod.fullname.replace('.', '_')} {{
78
78
  tooltip="{mod.fullname}"
@@ -85,9 +85,36 @@ class Renderer:
85
85
  {inner_nodes_str}
86
86
  {child_str}
87
87
  }}'''
88
+
89
+ def render_module_route(self, mod: ModuleRoute) -> str:
90
+ color = self.module_color.get(mod.fullname)
91
+ # Inner route nodes, same style as flat route_str
92
+ inner_nodes = [
93
+ f'''
94
+ "{r.id}" [
95
+ label = " {r.name} "
96
+ margin="0.5,0.1"
97
+ shape = "record"
98
+ ];''' for r in mod.routes
99
+ ]
100
+ inner_nodes_str = '\n'.join(inner_nodes)
101
+ child_str = '\n'.join(self.render_module_route(m) for m in mod.modules)
102
+ return f'''
103
+ subgraph cluster_route_module_{mod.fullname.replace('.', '_')} {{
104
+ tooltip="{mod.fullname}"
105
+ color = "#666"
106
+ style="rounded"
107
+ label = " {mod.name}"
108
+ labeljust = "l"
109
+ {(f'pencolor = "{color}"' if color else 'pencolor="#ccc"')}
110
+ {(f'penwidth = 3' if color else 'penwidth=""')}
111
+ {inner_nodes_str}
112
+ {child_str}
113
+ }}'''
88
114
 
89
115
  def render_dot(self, tags: list[Tag], routes: list[Route], nodes: list[SchemaNode], links: list[Link]) -> str:
90
- modules = build_module_tree(nodes)
116
+ module_schemas = build_module_schema_tree(nodes)
117
+ module_routes = build_module_route_tree(routes)
91
118
 
92
119
  tag_str = '\n'.join([
93
120
  f'''
@@ -98,16 +125,9 @@ class Renderer:
98
125
  ];''' for t in tags
99
126
  ])
100
127
 
101
- route_str = '\n'.join([
102
- f'''
103
- "{r.id}" [
104
- label = " {r.name} "
105
- margin="0.5,0.1"
106
- shape = "record"
107
- ];''' for r in routes
108
- ])
109
128
 
110
- modules_str = '\n'.join(self.render_module(m) for m in modules)
129
+ module_schemas_str = '\n'.join(self.render_module_schema(m) for m in module_schemas)
130
+ module_routes_str = '\n'.join(self.render_module_route(m) for m in module_routes)
111
131
  link_str = '\n'.join(self.render_link(link) for link in links)
112
132
 
113
133
  dot_str = f'''
@@ -144,7 +164,7 @@ class Renderer:
144
164
  label = " Routes"
145
165
  labeljust = "l"
146
166
  fontsize = "20"
147
- {route_str}
167
+ {module_routes_str}
148
168
  }}
149
169
 
150
170
  subgraph cluster_schema {{
@@ -154,7 +174,7 @@ class Renderer:
154
174
  label=" Schema"
155
175
  labeljust="l"
156
176
  fontsize="20"
157
- {modules_str}
177
+ {module_schemas_str}
158
178
  }}
159
179
 
160
180
  {link_str}
@@ -1,6 +1,6 @@
1
1
  from pathlib import Path
2
2
  from typing import Optional
3
- from fastapi import FastAPI
3
+ from fastapi import FastAPI, APIRouter
4
4
  from starlette.middleware.gzip import GZipMiddleware
5
5
  from pydantic import BaseModel
6
6
  from fastapi.responses import HTMLResponse, PlainTextResponse, JSONResponse
@@ -33,23 +33,13 @@ class Payload(BaseModel):
33
33
  show_fields: str = 'object'
34
34
  show_meta: bool = False
35
35
 
36
- def create_app_with_fastapi(
36
+ def create_route(
37
37
  target_app: FastAPI,
38
38
  module_color: dict[str, str] | None = None,
39
- gzip_minimum_size: int | None = 500,
40
- ) -> FastAPI:
41
- """Create a FastAPI server that serves DOT computed via Analytics.
42
-
43
- This avoids module-level globals by keeping state in closures.
44
- """
45
-
46
- app = FastAPI(title="fastapi-voyager demo server")
39
+ ):
40
+ router = APIRouter(tags=['fastapi-voyager'])
47
41
 
48
- # Enable gzip compression for larger responses (e.g. DOT / schemas payload)
49
- if gzip_minimum_size is not None and gzip_minimum_size >= 0:
50
- app.add_middleware(GZipMiddleware, minimum_size=gzip_minimum_size)
51
-
52
- @app.get("/dot", response_model=OptionParam)
42
+ @router.get("/dot", response_model=OptionParam)
53
43
  def get_dot() -> str:
54
44
  voyager = Voyager(module_color=module_color, load_meta=True)
55
45
  voyager.analysis(target_app)
@@ -71,7 +61,7 @@ def create_app_with_fastapi(
71
61
 
72
62
  return OptionParam(tags=tags, schemas=schemas, dot=dot)
73
63
 
74
- @app.post("/dot", response_class=PlainTextResponse)
64
+ @router.post("/dot", response_class=PlainTextResponse)
75
65
  def get_filtered_dot(payload: Payload) -> str:
76
66
  voyager = Voyager(
77
67
  include_tags=payload.tags,
@@ -85,7 +75,7 @@ def create_app_with_fastapi(
85
75
  voyager.analysis(target_app)
86
76
  return voyager.render_dot()
87
77
 
88
- @app.post("/dot-core-data", response_model=CoreData)
78
+ @router.post("/dot-core-data", response_model=CoreData)
89
79
  def get_filtered_dot_core_data(payload: Payload) -> str:
90
80
  voyager = Voyager(
91
81
  include_tags=payload.tags,
@@ -98,13 +88,13 @@ def create_app_with_fastapi(
98
88
  )
99
89
  voyager.analysis(target_app)
100
90
  return voyager.dump_core_data()
101
-
102
- @app.post('/dot-render-core-data', response_class=PlainTextResponse)
91
+
92
+ @router.post('/dot-render-core-data', response_class=PlainTextResponse)
103
93
  def render_dot_from_core_data(core_data: CoreData) -> str:
104
94
  renderer = Renderer(show_fields=core_data.show_fields, module_color=core_data.module_color, schema=core_data.schema)
105
95
  return renderer.render_dot(core_data.tags, core_data.routes, core_data.nodes, core_data.links)
106
96
 
107
- @app.get("/", response_class=HTMLResponse)
97
+ @router.get("/", response_class=HTMLResponse)
108
98
  def index():
109
99
  index_file = WEB_DIR / "index.html"
110
100
  if index_file.exists():
@@ -120,8 +110,22 @@ def create_app_with_fastapi(
120
110
  </html>
121
111
  """
122
112
 
123
- # Serve static files under /static
124
- app.mount("/static", StaticFiles(directory=str(WEB_DIR)), name="static")
113
+ return router
114
+
115
+
116
+ def create_app_with_fastapi(
117
+ target_app: FastAPI,
118
+ module_color: dict[str, str] | None = None,
119
+ gzip_minimum_size: int | None = 500,
120
+ ) -> FastAPI:
121
+ router = create_route(target_app, module_color=module_color)
122
+
123
+ app = FastAPI(title="fastapi-voyager demo server")
124
+ if gzip_minimum_size is not None and gzip_minimum_size >= 0:
125
+ app.add_middleware(GZipMiddleware, minimum_size=gzip_minimum_size)
126
+
127
+ app.mount("/fastapi-voyager-static", StaticFiles(directory=str(WEB_DIR)), name="static")
128
+ app.include_router(router)
125
129
 
126
130
  return app
127
131
 
@@ -21,9 +21,17 @@ class Tag(NodeBase):
21
21
 
22
22
  @dataclass
23
23
  class Route(NodeBase):
24
+ module: str
24
25
  source_code: str = ''
25
26
  vscode_link: str = '' # optional vscode deep link
26
27
 
28
+ @dataclass
29
+ class ModuleRoute:
30
+ name: str
31
+ fullname: str
32
+ routes: list[Route]
33
+ modules: list['ModuleRoute']
34
+
27
35
  @dataclass
28
36
  class SchemaNode(NodeBase):
29
37
  module: str
@@ -142,7 +142,7 @@ def get_type_name(anno):
142
142
 
143
143
 
144
144
  def is_inheritance_of_pydantic_base(cls):
145
- return issubclass(cls, BaseModel) and cls is not BaseModel
145
+ return safe_issubclass(cls, BaseModel) and cls is not BaseModel
146
146
 
147
147
 
148
148
  def get_bases_fields(schemas: list[type[BaseModel]]) -> set[str]:
@@ -228,6 +228,7 @@ def safe_issubclass(kls, classinfo):
228
228
  try:
229
229
  return issubclass(kls, classinfo)
230
230
  except TypeError:
231
+ print(kls.__name__, 'is not a class')
231
232
  return False
232
233
 
233
234
 
@@ -1,2 +1,2 @@
1
1
  __all__ = ["__version__"]
2
- __version__ = "0.5.2"
2
+ __version__ = "0.6.1"
@@ -83,6 +83,7 @@ class Voyager:
83
83
  # add route and create links
84
84
  route_id = f'{route.endpoint.__name__}_{route.path.replace("/", "_")}'
85
85
  route_name = route.endpoint.__name__
86
+ route_module = route.endpoint.__module__
86
87
 
87
88
  # filter by route_name (route.id) if provided
88
89
  if self.route_name is not None and route_id != self.route_name:
@@ -91,6 +92,7 @@ class Voyager:
91
92
  route_obj = Route(
92
93
  id=route_id,
93
94
  name=route_name,
95
+ module=route_module,
94
96
  vscode_link=get_vscode_link(route.endpoint) if self.load_meta else '',
95
97
  source_code=inspect.getsource(route.endpoint) if self.load_meta else ''
96
98
  )
@@ -31,7 +31,7 @@ export default defineComponent({
31
31
  if (!props.coreData) return;
32
32
  loading.value = true;
33
33
  try {
34
- const res = await fetch("/dot-render-core-data", {
34
+ const res = await fetch("dot-render-core-data", {
35
35
  method: "POST",
36
36
  headers: { "Content-Type": "application/json" },
37
37
  body: JSON.stringify(props.coreData),
@@ -81,7 +81,7 @@ export default defineComponent({
81
81
  schema_field: state.fieldName || null,
82
82
  show_fields: state.showFields,
83
83
  };
84
- const res = await fetch("/dot", {
84
+ const res = await fetch("dot", {
85
85
  method: "POST",
86
86
  headers: { "Content-Type": "application/json" },
87
87
  body: JSON.stringify(payload),
@@ -2,14 +2,14 @@
2
2
  <head>
3
3
  <title>FastAPI Voyager</title>
4
4
  <meta name="theme-color" content="#ffffff" />
5
- <link rel="stylesheet" href="/static/graphviz.svg.css" />
6
- <link rel="stylesheet" href="/static/quasar.min.css" />
5
+ <link rel="stylesheet" href="fastapi-voyager-static/graphviz.svg.css" />
6
+ <link rel="stylesheet" href="fastapi-voyager-static/quasar.min.css" />
7
7
  <!-- App Icons / Favicons -->
8
- <link rel="apple-touch-icon" sizes="180x180" href="/static/icon/apple-touch-icon.png" />
9
- <link rel="icon" type="image/png" sizes="32x32" href="/static/icon/favicon-32x32.png" />
10
- <link rel="icon" type="image/png" sizes="16x16" href="/static/icon/favicon-16x16.png" />
11
- <link rel="icon" href="/static/icon/favicon.ico" sizes="any" />
12
- <link rel="manifest" href="/static/icon/site.webmanifest" />
8
+ <link rel="apple-touch-icon" sizes="180x180" href="fastapi-voyager-static/icon/apple-touch-icon.png" />
9
+ <link rel="icon" type="image/png" sizes="32x32" href="fastapi-voyager-static/icon/favicon-32x32.png" />
10
+ <link rel="icon" type="image/png" sizes="16x16" href="fastapi-voyager-static/icon/favicon-16x16.png" />
11
+ <link rel="icon" href="fastapi-voyager-static/icon/favicon.ico" sizes="any" />
12
+ <link rel="manifest" href="fastapi-voyager-static/icon/site.webmanifest" />
13
13
  <link
14
14
  href="https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900|Material+Icons"
15
15
  rel="stylesheet"
@@ -265,8 +265,8 @@
265
265
 
266
266
  <!-- Add the following at the end of your body tag -->
267
267
  <script src="https://cdnjs.cloudflare.com/ajax/libs/vue/3.5.22/vue.global.prod.min.js" integrity="sha512-Y9sKU0AwzWRxKSLd2i35LuDpUdHY/E9tJrKG0mxw0qYQ75VVgGYazIUQPwKhFK9vGO3jIgAtxLiSq8GQ7PDfUg==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
268
- <script src="/static/quasar.min.js"></script>
269
- <script src="/static/graphviz.svg.js"></script>
268
+ <script src="fastapi-voyager-static/quasar.min.js"></script>
269
+ <script src="fastapi-voyager-static/graphviz.svg.js"></script>
270
270
  <!-- highlight.js minimal ES module load (python only) -->
271
271
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css" />
272
272
  <script type="module">
@@ -275,6 +275,6 @@
275
275
  hljs.registerLanguage('python', python);
276
276
  window.hljs = hljs;
277
277
  </script>
278
- <script type="module" src="/static/vue-main.js"></script>
278
+ <script type="module" src="fastapi-voyager-static/vue-main.js"></script>
279
279
  </body>
280
280
  </html>
@@ -37,8 +37,8 @@ const app = createApp({
37
37
  const dumpJson = ref("");
38
38
  const showImportDialog = ref(false);
39
39
  const importJsonText = ref("");
40
- const showRenderGraph = ref(false);
41
- const renderCoreData = ref(null);
40
+ const showRenderGraph = ref(false);
41
+ const renderCoreData = ref(null);
42
42
  const schemaName = ref(""); // used by detail dialog
43
43
  const schemaFieldFilterSchema = ref(null); // external schemaName for schema-field-filter
44
44
  const schemaCodeName = ref("");
@@ -94,7 +94,7 @@ const app = createApp({
94
94
  async function loadInitial() {
95
95
  state.initializing = true;
96
96
  try {
97
- const res = await fetch("/dot");
97
+ const res = await fetch("dot");
98
98
  const data = await res.json();
99
99
  state.rawTags = Array.isArray(data.tags) ? data.tags : [];
100
100
  state.rawSchemasFull = Array.isArray(data.schemas) ? data.schemas : [];
@@ -134,7 +134,7 @@ const app = createApp({
134
134
  show_fields: state.showFields,
135
135
  };
136
136
 
137
- const res = await fetch("/dot", {
137
+ const res = await fetch("dot", {
138
138
  method: "POST",
139
139
  headers: { "Content-Type": "application/json" },
140
140
  body: JSON.stringify(payload),
@@ -180,7 +180,7 @@ const app = createApp({
180
180
  route_name: state.routeId || null,
181
181
  show_fields: state.showFields,
182
182
  };
183
- const res = await fetch("/dot-core-data", {
183
+ const res = await fetch("dot-core-data", {
184
184
  method: "POST",
185
185
  headers: { "Content-Type": "application/json" },
186
186
  body: JSON.stringify(payload),
@@ -1,6 +1,6 @@
1
1
  from pydantic import BaseModel, Field
2
2
  from fastapi import FastAPI
3
- from typing import Optional
3
+ from typing import Optional, Generic, TypeVar
4
4
  from pydantic_resolve import ensure_subset, Resolver
5
5
  from tests.service.schema import Story, Task
6
6
  import tests.service.schema as serv
@@ -56,10 +56,19 @@ async def get_page_info():
56
56
  page_overall = PageOverallWrap(content="Page Overall Content", sprints=[]) # focus on schema only
57
57
  return await Resolver().resolve(page_overall)
58
58
 
59
-
60
59
  class PageStories(BaseModel):
61
60
  stories: list[PageStory]
62
61
 
63
62
  @app.get("/page_info/", tags=['for-page'], response_model=PageStories)
64
63
  def get_page_stories():
64
+ return {} # no implementation
65
+
66
+
67
+ T = TypeVar('T')
68
+ class DataModel(BaseModel, Generic[T]):
69
+ data: T
70
+ id: int
71
+
72
+ @app.get("/page_test_1/", tags=['for-page'], response_model=DataModel[PageStory])
73
+ def get_page_test_1():
65
74
  return {} # no implementation
@@ -0,0 +1,8 @@
1
+ from fastapi import FastAPI
2
+ from fastapi_voyager.server import create_app_with_fastapi
3
+ from tests.demo import app as demo_app
4
+
5
+ subapp = create_app_with_fastapi(demo_app)
6
+
7
+ app = FastAPI()
8
+ app.mount("/xxx", subapp)
@@ -1,4 +1,4 @@
1
- from fastapi_voyager.module import build_module_tree
1
+ from fastapi_voyager.module import build_module_schema_tree
2
2
  from fastapi_voyager.type import SchemaNode
3
3
 
4
4
 
@@ -24,7 +24,7 @@ def test_build_module_tree_basic():
24
24
  ]
25
25
 
26
26
  # Act
27
- top_modules = build_module_tree(schema_nodes)
27
+ top_modules = build_module_schema_tree(schema_nodes)
28
28
  from pprint import pprint
29
29
  pprint(top_modules)
30
30
 
@@ -58,7 +58,7 @@ def test_build_module_tree_basic():
58
58
 
59
59
 
60
60
  def test_build_module_tree_empty_input():
61
- top_modules = build_module_tree([])
61
+ top_modules = build_module_schema_tree([])
62
62
  assert top_modules == []
63
63
 
64
64
 
@@ -70,7 +70,7 @@ def test_build_module_tree_root_level_nodes():
70
70
  _sn("PkgA", "pkg", "PkgA"),
71
71
  ]
72
72
 
73
- top_modules = build_module_tree(schema_nodes)
73
+ top_modules = build_module_schema_tree(schema_nodes)
74
74
  names = sorted(m.name for m in top_modules)
75
75
  assert names == ["__root__", "pkg"]
76
76
  root = _find_top(top_modules, "__root__")
@@ -86,7 +86,7 @@ def test_collapse_single_child_empty_modules():
86
86
  _sn("Deep", "a.b.c.d", "Deep"),
87
87
  _sn("Peer", "a.b.x", "Peer"),
88
88
  ]
89
- top_modules = build_module_tree(schema_nodes)
89
+ top_modules = build_module_schema_tree(schema_nodes)
90
90
  print(top_modules)
91
91
  # 'a' should have one child path 'b', but due to branching at x, only a.b collapses into a.b
92
92
  # and below it, 'c.d' should collapse to 'c.d'. Final structure:
@@ -1,64 +0,0 @@
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
-
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
-
64
- return result
File without changes
File without changes