fastapi-voyager 0.6.2__tar.gz → 0.7.2__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.2 → fastapi_voyager-0.7.2}/PKG-INFO +21 -15
  2. {fastapi_voyager-0.6.2 → fastapi_voyager-0.7.2}/README.md +20 -14
  3. {fastapi_voyager-0.6.2 → fastapi_voyager-0.7.2}/src/fastapi_voyager/cli.py +14 -1
  4. {fastapi_voyager-0.6.2 → fastapi_voyager-0.7.2}/src/fastapi_voyager/filter.py +91 -1
  5. {fastapi_voyager-0.6.2 → fastapi_voyager-0.7.2}/src/fastapi_voyager/server.py +8 -2
  6. {fastapi_voyager-0.6.2 → fastapi_voyager-0.7.2}/src/fastapi_voyager/version.py +1 -1
  7. {fastapi_voyager-0.6.2 → fastapi_voyager-0.7.2}/src/fastapi_voyager/voyager.py +13 -1
  8. {fastapi_voyager-0.6.2 → fastapi_voyager-0.7.2}/src/fastapi_voyager/web/index.html +8 -0
  9. {fastapi_voyager-0.6.2 → fastapi_voyager-0.7.2}/src/fastapi_voyager/web/vue-main.js +4 -0
  10. {fastapi_voyager-0.6.2 → fastapi_voyager-0.7.2}/tests/demo.py +6 -2
  11. {fastapi_voyager-0.6.2 → fastapi_voyager-0.7.2}/tests/service/schema.py +7 -0
  12. fastapi_voyager-0.7.2/tests/test_filter.py +134 -0
  13. {fastapi_voyager-0.6.2 → fastapi_voyager-0.7.2}/.gitignore +0 -0
  14. {fastapi_voyager-0.6.2 → fastapi_voyager-0.7.2}/.python-version +0 -0
  15. {fastapi_voyager-0.6.2 → fastapi_voyager-0.7.2}/LICENSE +0 -0
  16. {fastapi_voyager-0.6.2 → fastapi_voyager-0.7.2}/pyproject.toml +0 -0
  17. {fastapi_voyager-0.6.2 → fastapi_voyager-0.7.2}/src/fastapi_voyager/__init__.py +0 -0
  18. {fastapi_voyager-0.6.2 → fastapi_voyager-0.7.2}/src/fastapi_voyager/module.py +0 -0
  19. {fastapi_voyager-0.6.2 → fastapi_voyager-0.7.2}/src/fastapi_voyager/render.py +0 -0
  20. {fastapi_voyager-0.6.2 → fastapi_voyager-0.7.2}/src/fastapi_voyager/type.py +0 -0
  21. {fastapi_voyager-0.6.2 → fastapi_voyager-0.7.2}/src/fastapi_voyager/type_helper.py +0 -0
  22. {fastapi_voyager-0.6.2 → fastapi_voyager-0.7.2}/src/fastapi_voyager/web/component/render-graph.js +0 -0
  23. {fastapi_voyager-0.6.2 → fastapi_voyager-0.7.2}/src/fastapi_voyager/web/component/route-code-display.js +0 -0
  24. {fastapi_voyager-0.6.2 → fastapi_voyager-0.7.2}/src/fastapi_voyager/web/component/schema-code-display.js +0 -0
  25. {fastapi_voyager-0.6.2 → fastapi_voyager-0.7.2}/src/fastapi_voyager/web/component/schema-field-filter.js +0 -0
  26. {fastapi_voyager-0.6.2 → fastapi_voyager-0.7.2}/src/fastapi_voyager/web/graph-ui.js +0 -0
  27. {fastapi_voyager-0.6.2 → fastapi_voyager-0.7.2}/src/fastapi_voyager/web/graphviz.svg.css +0 -0
  28. {fastapi_voyager-0.6.2 → fastapi_voyager-0.7.2}/src/fastapi_voyager/web/graphviz.svg.js +0 -0
  29. {fastapi_voyager-0.6.2 → fastapi_voyager-0.7.2}/src/fastapi_voyager/web/icon/android-chrome-192x192.png +0 -0
  30. {fastapi_voyager-0.6.2 → fastapi_voyager-0.7.2}/src/fastapi_voyager/web/icon/android-chrome-512x512.png +0 -0
  31. {fastapi_voyager-0.6.2 → fastapi_voyager-0.7.2}/src/fastapi_voyager/web/icon/apple-touch-icon.png +0 -0
  32. {fastapi_voyager-0.6.2 → fastapi_voyager-0.7.2}/src/fastapi_voyager/web/icon/favicon-16x16.png +0 -0
  33. {fastapi_voyager-0.6.2 → fastapi_voyager-0.7.2}/src/fastapi_voyager/web/icon/favicon-32x32.png +0 -0
  34. {fastapi_voyager-0.6.2 → fastapi_voyager-0.7.2}/src/fastapi_voyager/web/icon/favicon.ico +0 -0
  35. {fastapi_voyager-0.6.2 → fastapi_voyager-0.7.2}/src/fastapi_voyager/web/icon/site.webmanifest +0 -0
  36. {fastapi_voyager-0.6.2 → fastapi_voyager-0.7.2}/src/fastapi_voyager/web/quasar.min.css +0 -0
  37. {fastapi_voyager-0.6.2 → fastapi_voyager-0.7.2}/src/fastapi_voyager/web/quasar.min.js +0 -0
  38. {fastapi_voyager-0.6.2 → fastapi_voyager-0.7.2}/tests/__init__.py +0 -0
  39. {fastapi_voyager-0.6.2 → fastapi_voyager-0.7.2}/tests/demo_anno.py +0 -0
  40. {fastapi_voyager-0.6.2 → fastapi_voyager-0.7.2}/tests/programatic.py +0 -0
  41. {fastapi_voyager-0.6.2 → fastapi_voyager-0.7.2}/tests/service/__init__.py +0 -0
  42. {fastapi_voyager-0.6.2 → fastapi_voyager-0.7.2}/tests/test_analysis.py +0 -0
  43. {fastapi_voyager-0.6.2 → fastapi_voyager-0.7.2}/tests/test_generic.py +0 -0
  44. {fastapi_voyager-0.6.2 → fastapi_voyager-0.7.2}/tests/test_import.py +0 -0
  45. {fastapi_voyager-0.6.2 → fastapi_voyager-0.7.2}/tests/test_module.py +0 -0
  46. {fastapi_voyager-0.6.2 → fastapi_voyager-0.7.2}/tests/test_type_helper.py +0 -0
  47. {fastapi_voyager-0.6.2 → fastapi_voyager-0.7.2}/uv.lock +0 -0
  48. {fastapi_voyager-0.6.2 → fastapi_voyager-0.7.2}/voyager.jpg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastapi-voyager
3
- Version: 0.6.2
3
+ Version: 0.7.2
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,31 +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
- - [x] fix Generic case `test_generic.py`
187
-
188
- bugs:
189
- - [ ] fix duplicated link from class and parent class, it also break clicking highlight
190
190
 
191
191
  ## Using with pydantic-resolve
192
192
 
@@ -203,5 +203,11 @@ TODO: ...
203
203
 
204
204
  ## Changelog
205
205
 
206
- - 0.6.2:
207
- - fix generic related issue
206
+ - 0.7:
207
+ - 0.7.2
208
+ - keep links inside filtered nodes.
209
+ - 0.7.1
210
+ - support brief mode, you can use `--module_prefix tests.service` to show links between routes and filtered schemas, to make the graph less complicated.
211
+ - 0.6:
212
+ - 0.6.2:
213
+ - 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,31 +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
- - [x] fix Generic case `test_generic.py`
160
-
161
- bugs:
162
- - [ ] fix duplicated link from class and parent class, it also break clicking highlight
163
163
 
164
164
  ## Using with pydantic-resolve
165
165
 
@@ -176,5 +176,11 @@ TODO: ...
176
176
 
177
177
  ## Changelog
178
178
 
179
- - 0.6.2:
180
- - fix generic related issue
179
+ - 0.7:
180
+ - 0.7.2
181
+ - keep links inside filtered nodes.
182
+ - 0.7.1
183
+ - support brief mode, you can use `--module_prefix tests.service` to show links between routes and filtered schemas, to make the graph less complicated.
184
+ - 0.6:
185
+ - 0.6.2:
186
+ - 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,92 @@ 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
+ module_prefix_links = [
187
+ lk
188
+ for lk in links
189
+ if (lk.source_origin or "").startswith(module_prefix)
190
+ and (lk.target_origin or "").startswith(module_prefix)
191
+ ]
192
+
193
+ filtered_links = tag_route_links + merged_links + module_prefix_links
194
+
195
+ 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,2 +1,2 @@
1
1
  __all__ = ["__version__"]
2
- __version__ = "0.6.2"
2
+ __version__ = "0.7.2"
@@ -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
 
@@ -2,7 +2,7 @@ from pydantic import BaseModel, Field
2
2
  from fastapi import FastAPI
3
3
  from typing import Optional, Generic, TypeVar
4
4
  from pydantic_resolve import ensure_subset, Resolver
5
- from tests.service.schema import Story, Task
5
+ from tests.service.schema import Story, Task, A
6
6
  import tests.service.schema as serv
7
7
 
8
8
  app = FastAPI(title="Demo API", description="A demo FastAPI application for router visualization")
@@ -73,4 +73,8 @@ type DataModelPageStory = DataModel[PageStory]
73
73
 
74
74
  @app.get("/page_test_1/", tags=['for-page'], response_model=DataModelPageStory)
75
75
  def get_page_test_1():
76
- return {} # no implementation
76
+ return {} # no implementation
77
+
78
+ @app.get("/page_test_2/", tags=['for-page'], response_model=A)
79
+ def get_page_test_2():
80
+ return {}
@@ -23,3 +23,10 @@ class Member(BaseModel):
23
23
  first_name: str
24
24
  last_name: str
25
25
 
26
+
27
+ class B(BaseModel):
28
+ id: int
29
+
30
+ class A(BaseModel):
31
+ id: int
32
+ b: B
@@ -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
File without changes
File without changes