fastapi-voyager 0.6.1__tar.gz → 0.7.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 (48) hide show
  1. {fastapi_voyager-0.6.1 → fastapi_voyager-0.7.1}/PKG-INFO +21 -19
  2. {fastapi_voyager-0.6.1 → fastapi_voyager-0.7.1}/README.md +20 -18
  3. {fastapi_voyager-0.6.1 → fastapi_voyager-0.7.1}/src/fastapi_voyager/cli.py +14 -1
  4. {fastapi_voyager-0.6.1 → fastapi_voyager-0.7.1}/src/fastapi_voyager/filter.py +84 -1
  5. {fastapi_voyager-0.6.1 → fastapi_voyager-0.7.1}/src/fastapi_voyager/server.py +8 -2
  6. {fastapi_voyager-0.6.1 → fastapi_voyager-0.7.1}/src/fastapi_voyager/type_helper.py +23 -2
  7. {fastapi_voyager-0.6.1 → fastapi_voyager-0.7.1}/src/fastapi_voyager/version.py +1 -1
  8. {fastapi_voyager-0.6.1 → fastapi_voyager-0.7.1}/src/fastapi_voyager/voyager.py +13 -1
  9. {fastapi_voyager-0.6.1 → fastapi_voyager-0.7.1}/src/fastapi_voyager/web/index.html +8 -0
  10. {fastapi_voyager-0.6.1 → fastapi_voyager-0.7.1}/src/fastapi_voyager/web/vue-main.js +4 -0
  11. {fastapi_voyager-0.6.1 → fastapi_voyager-0.7.1}/tests/demo.py +3 -1
  12. fastapi_voyager-0.7.1/tests/test_filter.py +134 -0
  13. fastapi_voyager-0.7.1/tests/test_generic.py +20 -0
  14. {fastapi_voyager-0.6.1 → fastapi_voyager-0.7.1}/.gitignore +0 -0
  15. {fastapi_voyager-0.6.1 → fastapi_voyager-0.7.1}/.python-version +0 -0
  16. {fastapi_voyager-0.6.1 → fastapi_voyager-0.7.1}/LICENSE +0 -0
  17. {fastapi_voyager-0.6.1 → fastapi_voyager-0.7.1}/pyproject.toml +0 -0
  18. {fastapi_voyager-0.6.1 → fastapi_voyager-0.7.1}/src/fastapi_voyager/__init__.py +0 -0
  19. {fastapi_voyager-0.6.1 → fastapi_voyager-0.7.1}/src/fastapi_voyager/module.py +0 -0
  20. {fastapi_voyager-0.6.1 → fastapi_voyager-0.7.1}/src/fastapi_voyager/render.py +0 -0
  21. {fastapi_voyager-0.6.1 → fastapi_voyager-0.7.1}/src/fastapi_voyager/type.py +0 -0
  22. {fastapi_voyager-0.6.1 → fastapi_voyager-0.7.1}/src/fastapi_voyager/web/component/render-graph.js +0 -0
  23. {fastapi_voyager-0.6.1 → fastapi_voyager-0.7.1}/src/fastapi_voyager/web/component/route-code-display.js +0 -0
  24. {fastapi_voyager-0.6.1 → fastapi_voyager-0.7.1}/src/fastapi_voyager/web/component/schema-code-display.js +0 -0
  25. {fastapi_voyager-0.6.1 → fastapi_voyager-0.7.1}/src/fastapi_voyager/web/component/schema-field-filter.js +0 -0
  26. {fastapi_voyager-0.6.1 → fastapi_voyager-0.7.1}/src/fastapi_voyager/web/graph-ui.js +0 -0
  27. {fastapi_voyager-0.6.1 → fastapi_voyager-0.7.1}/src/fastapi_voyager/web/graphviz.svg.css +0 -0
  28. {fastapi_voyager-0.6.1 → fastapi_voyager-0.7.1}/src/fastapi_voyager/web/graphviz.svg.js +0 -0
  29. {fastapi_voyager-0.6.1 → fastapi_voyager-0.7.1}/src/fastapi_voyager/web/icon/android-chrome-192x192.png +0 -0
  30. {fastapi_voyager-0.6.1 → fastapi_voyager-0.7.1}/src/fastapi_voyager/web/icon/android-chrome-512x512.png +0 -0
  31. {fastapi_voyager-0.6.1 → fastapi_voyager-0.7.1}/src/fastapi_voyager/web/icon/apple-touch-icon.png +0 -0
  32. {fastapi_voyager-0.6.1 → fastapi_voyager-0.7.1}/src/fastapi_voyager/web/icon/favicon-16x16.png +0 -0
  33. {fastapi_voyager-0.6.1 → fastapi_voyager-0.7.1}/src/fastapi_voyager/web/icon/favicon-32x32.png +0 -0
  34. {fastapi_voyager-0.6.1 → fastapi_voyager-0.7.1}/src/fastapi_voyager/web/icon/favicon.ico +0 -0
  35. {fastapi_voyager-0.6.1 → fastapi_voyager-0.7.1}/src/fastapi_voyager/web/icon/site.webmanifest +0 -0
  36. {fastapi_voyager-0.6.1 → fastapi_voyager-0.7.1}/src/fastapi_voyager/web/quasar.min.css +0 -0
  37. {fastapi_voyager-0.6.1 → fastapi_voyager-0.7.1}/src/fastapi_voyager/web/quasar.min.js +0 -0
  38. {fastapi_voyager-0.6.1 → fastapi_voyager-0.7.1}/tests/__init__.py +0 -0
  39. {fastapi_voyager-0.6.1 → fastapi_voyager-0.7.1}/tests/demo_anno.py +0 -0
  40. {fastapi_voyager-0.6.1 → fastapi_voyager-0.7.1}/tests/programatic.py +0 -0
  41. {fastapi_voyager-0.6.1 → fastapi_voyager-0.7.1}/tests/service/__init__.py +0 -0
  42. {fastapi_voyager-0.6.1 → fastapi_voyager-0.7.1}/tests/service/schema.py +0 -0
  43. {fastapi_voyager-0.6.1 → fastapi_voyager-0.7.1}/tests/test_analysis.py +0 -0
  44. {fastapi_voyager-0.6.1 → fastapi_voyager-0.7.1}/tests/test_import.py +0 -0
  45. {fastapi_voyager-0.6.1 → fastapi_voyager-0.7.1}/tests/test_module.py +0 -0
  46. {fastapi_voyager-0.6.1 → fastapi_voyager-0.7.1}/tests/test_type_helper.py +0 -0
  47. {fastapi_voyager-0.6.1 → fastapi_voyager-0.7.1}/uv.lock +0 -0
  48. {fastapi_voyager-0.6.1 → fastapi_voyager-0.7.1}/voyager.jpg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastapi-voyager
3
- Version: 0.6.1
3
+ Version: 0.7.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
@@ -146,7 +146,7 @@ or you can open router_viz.dot with vscode extension `graphviz interactive previ
146
146
 
147
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,37 +162,31 @@ 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
- - [ ] add tooltips
166
- - [ ] route
167
- - [ ] group routes by module hierarchy
165
+ - [x] add tooltips
166
+ - [x] route
167
+ - [x] group routes by module hierarchy
168
168
  - [ ] add response_model in route
169
- - [ ] support dataclass
169
+ - [ ] support dataclass (pending)
170
170
  - [ ] click field to highlight links
171
171
  - [ ] user can generate nodes/edges manually and connect to generated ones
172
172
  - [ ] add owner
173
173
  - [ ] ui optimization
174
174
  - [ ] fixed left/right bar show field information
175
- - [ ] display standard ER diagram
175
+ - [ ] display standard ER diagram `difficult`
176
176
  - [ ] display potential invalid links
177
177
  - [ ] integration with pydantic-resolve
178
178
  - [ ] show difference between resolve, post fields
179
179
  - [x] strikethrough for excluded fields
180
180
  - [ ] display loader as edges
181
+ - [x] export voyager core data into json (for better debugging)
182
+ - [x] add api to rebuild core data from json, and render it
183
+ - [x] fix Generic case `test_generic.py`
184
+
185
+ bugs & non feature:
186
+ - [x] fix duplicated link from class and parent class, it also break clicking highlight
181
187
  - [ ] add tests
182
188
  - [ ] refactor
183
189
  - [ ] abstract render module
184
- - [ ] export voyager core data into json (for better debugging)
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
- ```
193
-
194
- bugs:
195
- - [ ] fix duplicated link from class and parent class, it also break clicking highlight
196
190
 
197
191
  ## Using with pydantic-resolve
198
192
 
@@ -205,3 +199,11 @@ TODO: ...
205
199
 
206
200
  - https://apis.guru/graphql-voyager/, for inspiration.
207
201
  - https://github.com/tintinweb/vscode-interactive-graphviz, for web visualization.
202
+
203
+
204
+ ## Changelog
205
+
206
+ - 0.7.1:
207
+ - support brief mode, you can use `--module_prefix tests.service` to show links between routes and filtered schemas, to make the graph less complicated.
208
+ - 0.6.2:
209
+ - fix generic related issue
@@ -119,7 +119,7 @@ or you can open router_viz.dot with vscode extension `graphviz interactive previ
119
119
 
120
120
  ## Plan before v1.0
121
121
 
122
- features
122
+ features:
123
123
  - [x] group schemas by module hierarchy
124
124
  - [x] module-based coloring via Analytics(module_color={...})
125
125
  - [x] view in web browser
@@ -135,37 +135,31 @@ features
135
135
  - [x] alt+click to show field details
136
136
  - [x] display source code of routes (including response_model)
137
137
  - [x] handle excluded field
138
- - [ ] add tooltips
139
- - [ ] route
140
- - [ ] group routes by module hierarchy
138
+ - [x] add tooltips
139
+ - [x] route
140
+ - [x] group routes by module hierarchy
141
141
  - [ ] add response_model in route
142
- - [ ] support dataclass
142
+ - [ ] support dataclass (pending)
143
143
  - [ ] click field to highlight links
144
144
  - [ ] user can generate nodes/edges manually and connect to generated ones
145
145
  - [ ] add owner
146
146
  - [ ] ui optimization
147
147
  - [ ] fixed left/right bar show field information
148
- - [ ] display standard ER diagram
148
+ - [ ] display standard ER diagram `difficult`
149
149
  - [ ] display potential invalid links
150
150
  - [ ] integration with pydantic-resolve
151
151
  - [ ] show difference between resolve, post fields
152
152
  - [x] strikethrough for excluded fields
153
153
  - [ ] display loader as edges
154
+ - [x] export voyager core data into json (for better debugging)
155
+ - [x] add api to rebuild core data from json, and render it
156
+ - [x] fix Generic case `test_generic.py`
157
+
158
+ bugs & non feature:
159
+ - [x] fix duplicated link from class and parent class, it also break clicking highlight
154
160
  - [ ] add tests
155
161
  - [ ] refactor
156
162
  - [ ] abstract render module
157
- - [ ] export voyager core data into json (for better debugging)
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
- ```
166
-
167
- bugs:
168
- - [ ] fix duplicated link from class and parent class, it also break clicking highlight
169
163
 
170
164
  ## Using with pydantic-resolve
171
165
 
@@ -178,3 +172,11 @@ TODO: ...
178
172
 
179
173
  - https://apis.guru/graphql-voyager/, for inspiration.
180
174
  - https://github.com/tintinweb/vscode-interactive-graphviz, for web visualization.
175
+
176
+
177
+ ## Changelog
178
+
179
+ - 0.7.1:
180
+ - support brief mode, you can use `--module_prefix tests.service` to show links between routes and filtered schemas, to make the graph less complicated.
181
+ - 0.6.2:
182
+ - fix generic related issue
@@ -175,6 +175,12 @@ Examples:
175
175
  default="127.0.0.1",
176
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
177
  )
178
+ parser.add_argument(
179
+ "--module_prefix",
180
+ type=str,
181
+ default=None,
182
+ help="Prefix routes with module name when rendering brief view (only valid with --server)"
183
+ )
178
184
 
179
185
  parser.add_argument(
180
186
  "--version", "-v",
@@ -233,6 +239,9 @@ Examples:
233
239
  if not any(mc.startswith(key + ":") for mc in existing):
234
240
  args.module_color = (args.module_color or []) + [d]
235
241
 
242
+ if args.module_prefix and not args.server:
243
+ parser.error("--module_prefix can only be used together with --server")
244
+
236
245
  # Validate required target if not demo
237
246
  if not args.demo and not (args.module_name or args.module):
238
247
  parser.error("You must provide a module file, -m module name, or use --demo")
@@ -272,7 +281,11 @@ Examples:
272
281
  except ImportError:
273
282
  print("Error: uvicorn is required to run the server. Install via 'pip install uvicorn' or 'uv add uvicorn'.")
274
283
  sys.exit(1)
275
- app_server = viz_server.create_app_with_fastapi(app, module_color=module_color)
284
+ app_server = viz_server.create_app_with_fastapi(
285
+ app,
286
+ module_color=module_color,
287
+ module_prefix=args.module_prefix,
288
+ )
276
289
  print(f"Starting preview server at http://{args.host}:{args.port} ... (Ctrl+C to stop)")
277
290
  uvicorn.run(app_server, host=args.host, port=args.port)
278
291
  else:
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
+ from collections import deque
2
3
  from typing import Tuple
3
- from fastapi_voyager.type import Tag, Route, SchemaNode, Link
4
+ from fastapi_voyager.type import Tag, Route, SchemaNode, Link, PK
4
5
 
5
6
 
6
7
  def filter_graph(
@@ -103,3 +104,85 @@ def filter_graph(
103
104
  _routes = [r for r in routes if r.id in included_ids]
104
105
 
105
106
  return _tags, _routes, _nodes, _links
107
+
108
+
109
+ def filter_subgraph(
110
+ *,
111
+ tags: list[Tag],
112
+ routes: list[Route],
113
+ links: list[Link],
114
+ nodes: list[SchemaNode],
115
+ module_prefix: str
116
+ ) -> Tuple[list[Tag], list[Route], list[SchemaNode], list[Link]]:
117
+ """Collapse schema graph so routes link directly to nodes whose module matches ``module_prefix``.
118
+
119
+ The routine keeps tag→route links untouched, prunes schema nodes whose module does not start
120
+ with ``module_prefix``, and merges the remaining schema relationships so each route connects
121
+ directly to the surviving schema nodes. Traversal stops once a qualifying node is reached and
122
+ guards against cycles in the schema graph.
123
+ """
124
+
125
+ if not module_prefix:
126
+ # empty prefix keeps existing graph structure, so simply reuse incoming data
127
+ return tags, routes, nodes, [lk for lk in links if lk.type in ("tag_route", "route_to_schema")]
128
+
129
+ route_links = [lk for lk in links if lk.type == "route_to_schema"]
130
+ schema_links = [lk for lk in links if lk.type in {"schema", "parent", "subset"}]
131
+ tag_route_links = [lk for lk in links if lk.type == "tag_route"]
132
+
133
+ node_lookup: dict[str, SchemaNode] = {node.id: node for node in nodes}
134
+
135
+ filtered_nodes = [node for node in nodes if node_lookup[node.id].module.startswith(module_prefix)]
136
+ filtered_node_ids = {node.id for node in filtered_nodes}
137
+
138
+ adjacency: dict[str, list[str]] = {}
139
+ for link in schema_links:
140
+ if link.source_origin not in node_lookup or link.target_origin not in node_lookup:
141
+ continue
142
+ adjacency.setdefault(link.source_origin, [])
143
+ if link.target_origin not in adjacency[link.source_origin]:
144
+ adjacency[link.source_origin].append(link.target_origin)
145
+
146
+ merged_links: list[Link] = []
147
+ seen_pairs: set[tuple[str, str]] = set()
148
+
149
+ for link in route_links:
150
+ route_id = link.source_origin
151
+ start_node_id = link.target_origin
152
+ if route_id is None or start_node_id is None:
153
+ continue
154
+ if start_node_id not in node_lookup:
155
+ continue
156
+
157
+ visited: set[str] = set()
158
+ queue: deque[str] = deque([start_node_id])
159
+
160
+ while queue:
161
+ current = queue.popleft()
162
+ if current in visited:
163
+ continue
164
+ visited.add(current)
165
+
166
+ if current in filtered_node_ids:
167
+ key = (route_id, current)
168
+ if key not in seen_pairs:
169
+ seen_pairs.add(key)
170
+ merged_links.append(
171
+ Link(
172
+ source=link.source,
173
+ source_origin=route_id,
174
+ target=f"{current}::{PK}",
175
+ target_origin=current,
176
+ type="route_to_schema",
177
+ )
178
+ )
179
+ # stop traversing past a qualifying node
180
+ continue
181
+
182
+ for next_node in adjacency.get(current, () ):
183
+ if next_node not in visited:
184
+ queue.append(next_node)
185
+
186
+ filtered_links = tag_route_links + merged_links
187
+
188
+ return tags, routes, filtered_nodes, filtered_links
@@ -32,10 +32,12 @@ class Payload(BaseModel):
32
32
  route_name: Optional[str] = None
33
33
  show_fields: str = 'object'
34
34
  show_meta: bool = False
35
+ brief: bool = False
35
36
 
36
37
  def create_route(
37
38
  target_app: FastAPI,
38
39
  module_color: dict[str, str] | None = None,
40
+ module_prefix: Optional[str] = None,
39
41
  ):
40
42
  router = APIRouter(tags=['fastapi-voyager'])
41
43
 
@@ -73,7 +75,10 @@ def create_route(
73
75
  load_meta=False,
74
76
  )
75
77
  voyager.analysis(target_app)
76
- return voyager.render_dot()
78
+ if payload.brief:
79
+ return voyager.render_brief_dot(module_prefix=module_prefix)
80
+ else:
81
+ return voyager.render_dot()
77
82
 
78
83
  @router.post("/dot-core-data", response_model=CoreData)
79
84
  def get_filtered_dot_core_data(payload: Payload) -> str:
@@ -117,8 +122,9 @@ def create_app_with_fastapi(
117
122
  target_app: FastAPI,
118
123
  module_color: dict[str, str] | None = None,
119
124
  gzip_minimum_size: int | None = 500,
125
+ module_prefix: Optional[str] = None,
120
126
  ) -> FastAPI:
121
- router = create_route(target_app, module_color=module_color)
127
+ router = create_route(target_app, module_color=module_color, module_prefix=module_prefix)
122
128
 
123
129
  app = FastAPI(title="fastapi-voyager demo server")
124
130
  if gzip_minimum_size is not None and gzip_minimum_size >= 0:
@@ -1,7 +1,7 @@
1
1
  import inspect
2
2
  import os
3
3
  from pydantic import BaseModel
4
- from typing import get_origin, get_args, Union, Annotated, Any, Type
4
+ from typing import get_origin, get_args, Union, Annotated, Any, Type, Generic
5
5
  from fastapi_voyager.type import FieldInfo
6
6
  from types import UnionType
7
7
  import pydantic_resolve.constant as const
@@ -142,7 +142,7 @@ def get_type_name(anno):
142
142
 
143
143
 
144
144
  def is_inheritance_of_pydantic_base(cls):
145
- return safe_issubclass(cls, BaseModel) and cls is not BaseModel
145
+ return safe_issubclass(cls, BaseModel) and cls is not BaseModel and not is_generic_container(cls)
146
146
 
147
147
 
148
148
  def get_bases_fields(schemas: list[type[BaseModel]]) -> set[str]:
@@ -256,6 +256,27 @@ def update_forward_refs(kls):
256
256
  if safe_issubclass(shelled_type, BaseModel):
257
257
  update_pydantic_forward_refs(shelled_type)
258
258
 
259
+
260
+ def is_generic_container(cls):
261
+ """
262
+ T = TypeVar('T')
263
+ class DataModel(BaseModel, Generic[T]):
264
+ data: T
265
+ id: int
266
+
267
+ type DataModelPageStory = DataModel[PageStory]
268
+
269
+ is_generic_container(DataModel) -> True
270
+ is_generic_container(DataModel[PageStory]) -> False
271
+
272
+ DataModel.__parameters__ == (T,)
273
+ DataModelPageStory.__parameters__ == (,)
274
+ """
275
+ try:
276
+ return (hasattr(cls, '__bases__') and Generic in cls.__bases__ and (hasattr(cls, '__parameters__') and bool(cls.__parameters__)))
277
+ except (TypeError, AttributeError):
278
+ return False
279
+
259
280
  if __name__ == "__main__":
260
281
  from tests.demo_anno import PageSprint, PageOverall
261
282
 
@@ -1,2 +1,2 @@
1
1
  __all__ = ["__version__"]
2
- __version__ = "0.6.1"
2
+ __version__ = "0.7.1"
@@ -12,7 +12,7 @@ from fastapi_voyager.type_helper import (
12
12
  )
13
13
  from pydantic import BaseModel
14
14
  from fastapi_voyager.type import Route, SchemaNode, Link, Tag, LinkType, FieldType, PK, CoreData
15
- from fastapi_voyager.filter import filter_graph
15
+ from fastapi_voyager.filter import filter_graph, filter_subgraph
16
16
  from fastapi_voyager.render import Renderer
17
17
  import pydantic_resolve.constant as const
18
18
 
@@ -271,4 +271,16 @@ class Voyager:
271
271
  node_set=self.node_set,
272
272
  )
273
273
  renderer = Renderer(show_fields=self.show_fields, module_color=self.module_color, schema=self.schema)
274
+ return renderer.render_dot(_tags, _routes, _nodes, _links)
275
+
276
+ def render_brief_dot(self, module_prefix: str | None = None):
277
+ print(module_prefix)
278
+ _tags, _routes, _nodes, _links = filter_subgraph(
279
+ module_prefix=module_prefix,
280
+ tags=self.tags,
281
+ routes=self.routes,
282
+ nodes=self.nodes,
283
+ links=self.links,
284
+ )
285
+ renderer = Renderer(show_fields=self.show_fields, module_color=self.module_color, schema=None)
274
286
  return renderer.render_dot(_tags, _routes, _nodes, _links)
@@ -109,6 +109,14 @@
109
109
  </div>
110
110
  </div>
111
111
 
112
+ <div class="col-auto">
113
+ <q-checkbox
114
+ v-model="state.brief"
115
+ label="Brief"
116
+ dense
117
+ />
118
+ </div>
119
+
112
120
  <div class="col-auto">
113
121
  <q-btn-dropdown
114
122
  class="q-ml-md"
@@ -22,6 +22,7 @@ const app = createApp({
22
22
  { label: "Object fields", value: "object" },
23
23
  { label: "All fields", value: "all" },
24
24
  ],
25
+ brief: false,
25
26
  generating: false,
26
27
  rawTags: [], // [{ name, routes: [{ id, name }] }]
27
28
  rawSchemas: [], // [{ name, fullname }]
@@ -132,6 +133,7 @@ const app = createApp({
132
133
  schema_name: state.schemaFullname || null,
133
134
  route_name: state.routeId || null,
134
135
  show_fields: state.showFields,
136
+ brief: state.brief,
135
137
  };
136
138
 
137
139
  const res = await fetch("dot", {
@@ -179,6 +181,7 @@ const app = createApp({
179
181
  schema_name: state.schemaFullname || null,
180
182
  route_name: state.routeId || null,
181
183
  show_fields: state.showFields,
184
+ brief: state.brief,
182
185
  };
183
186
  const res = await fetch("dot-core-data", {
184
187
  method: "POST",
@@ -238,6 +241,7 @@ const app = createApp({
238
241
  state.routeId = "";
239
242
  state.schemaFullname = null;
240
243
  state.showFields = "object";
244
+ state.brief = false;
241
245
  await loadInitial();
242
246
  }
243
247
 
@@ -69,6 +69,8 @@ class DataModel(BaseModel, Generic[T]):
69
69
  data: T
70
70
  id: int
71
71
 
72
- @app.get("/page_test_1/", tags=['for-page'], response_model=DataModel[PageStory])
72
+ type DataModelPageStory = DataModel[PageStory]
73
+
74
+ @app.get("/page_test_1/", tags=['for-page'], response_model=DataModelPageStory)
73
75
  def get_page_test_1():
74
76
  return {} # no implementation
@@ -0,0 +1,134 @@
1
+ from fastapi_voyager.filter import filter_subgraph
2
+ from fastapi_voyager.type import Tag, Route, SchemaNode, Link, PK
3
+
4
+
5
+ def _make_tag_route_link(tag: Tag, route: Route) -> Link:
6
+ return Link(
7
+ source=tag.id,
8
+ source_origin=tag.id,
9
+ target=route.id,
10
+ target_origin=route.id,
11
+ type="tag_route",
12
+ )
13
+
14
+
15
+ def test_filter_subgraph_filters_nodes_and_links():
16
+ tag = Tag(id="tag1", name="Tag 1", routes=[])
17
+ route = Route(id="route1", name="route1", module="api.routes")
18
+ tag.routes.append(route)
19
+
20
+ node_a = SchemaNode(id="pkg.ModelA", name="ModelA", module="pkg.moduleA")
21
+ node_b = SchemaNode(id="pkg.ModelB", name="ModelB", module="target.moduleB")
22
+
23
+ links = [
24
+ _make_tag_route_link(tag, route),
25
+ Link(
26
+ source=route.id,
27
+ source_origin=route.id,
28
+ target=f"{node_a.id}::{PK}",
29
+ target_origin=node_a.id,
30
+ type="route_to_schema",
31
+ ),
32
+ Link(
33
+ source=f"{node_a.id}::ffield",
34
+ source_origin=node_a.id,
35
+ target=f"{node_b.id}::{PK}",
36
+ target_origin=node_b.id,
37
+ type="schema",
38
+ ),
39
+ ]
40
+
41
+ tags = [tag]
42
+ routes = [route]
43
+ nodes = [node_a, node_b]
44
+
45
+ _, _, filtered_nodes, filtered_links = filter_subgraph(
46
+ tags=tags,
47
+ routes=routes,
48
+ links=links,
49
+ nodes=nodes,
50
+ module_prefix="target",
51
+ )
52
+
53
+ assert filtered_nodes == [node_b]
54
+ assert any(
55
+ lk.type == "route_to_schema" and lk.source_origin == route.id and lk.target_origin == node_b.id
56
+ for lk in filtered_links
57
+ )
58
+ assert len(filtered_links) == 2 # tag -> route and merged route -> filtered node
59
+
60
+
61
+
62
+ def test_filter_subgraph_handles_cycles_and_multiple_matches():
63
+ tag = Tag(id="tag-main", name="Tag", routes=[])
64
+ route = Route(id="route-main", name="route", module="api.routes")
65
+ tag.routes.append(route)
66
+
67
+ node_root = SchemaNode(id="pkg.Root", name="Root", module="pkg.root")
68
+ node_mid = SchemaNode(id="pkg.Mid", name="Mid", module="pkg.mid")
69
+ node_target1 = SchemaNode(id="pkg.Target1", name="Target1", module="target.mod.alpha")
70
+ node_target2 = SchemaNode(id="pkg.Target2", name="Target2", module="target.mod.beta")
71
+
72
+ links = [
73
+ _make_tag_route_link(tag, route),
74
+ Link(
75
+ source=route.id,
76
+ source_origin=route.id,
77
+ target=f"{node_root.id}::{PK}",
78
+ target_origin=node_root.id,
79
+ type="route_to_schema",
80
+ ),
81
+ Link(
82
+ source=f"{node_root.id}::ffield",
83
+ source_origin=node_root.id,
84
+ target=f"{node_mid.id}::{PK}",
85
+ target_origin=node_mid.id,
86
+ type="schema",
87
+ ),
88
+ Link(
89
+ source=f"{node_mid.id}::{PK}",
90
+ source_origin=node_mid.id,
91
+ target=f"{node_target1.id}::{PK}",
92
+ target_origin=node_target1.id,
93
+ type="parent",
94
+ ),
95
+ Link(
96
+ source=f"{node_mid.id}::ffield",
97
+ source_origin=node_mid.id,
98
+ target=f"{node_target2.id}::{PK}",
99
+ target_origin=node_target2.id,
100
+ type="subset",
101
+ ),
102
+ Link(
103
+ source=f"{node_target1.id}::ffield",
104
+ source_origin=node_target1.id,
105
+ target=f"{node_root.id}::{PK}",
106
+ target_origin=node_root.id,
107
+ type="schema",
108
+ ),
109
+ ]
110
+
111
+ nodes = [node_root, node_mid, node_target1, node_target2]
112
+
113
+ _, _, filtered_nodes, filtered_links = filter_subgraph(
114
+ tags=[tag],
115
+ routes=[route],
116
+ links=links,
117
+ nodes=nodes,
118
+ module_prefix="target.mod",
119
+ )
120
+
121
+ assert filtered_nodes == [node_target1, node_target2]
122
+
123
+ route_to_schema_targets = {
124
+ (lk.source_origin, lk.target_origin)
125
+ for lk in filtered_links
126
+ if lk.type == "route_to_schema"
127
+ }
128
+ assert route_to_schema_targets == {
129
+ (route.id, node_target1.id),
130
+ (route.id, node_target2.id),
131
+ }
132
+
133
+ assert all(lk.type in {"tag_route", "route_to_schema"} for lk in filtered_links)
134
+ assert len(filtered_links) == 3 # 1 tag_route + 2 merged links
@@ -0,0 +1,20 @@
1
+ from pydantic import BaseModel
2
+ from typing import Generic, TypeVar
3
+ from fastapi_voyager.type_helper import is_generic_container
4
+
5
+ class PageStory(BaseModel):
6
+ id: int
7
+ title: str
8
+
9
+ T = TypeVar('T')
10
+ class DataModel(BaseModel, Generic[T]):
11
+ data: T
12
+ id: int
13
+
14
+ type DataModelPageStory = DataModel[PageStory]
15
+
16
+ def test_is_generic_container():
17
+ print(DataModelPageStory.__value__.__bases__)
18
+ print(DataModelPageStory.__value__.model_fields.items())
19
+ assert is_generic_container(DataModel) is True
20
+ assert is_generic_container(DataModelPageStory) is False
File without changes
File without changes