fastapi-voyager 0.4.5__tar.gz → 0.5.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 (45) hide show
  1. fastapi_voyager-0.5.2/.python-version +1 -0
  2. {fastapi_voyager-0.4.5 → fastapi_voyager-0.5.2}/PKG-INFO +11 -4
  3. {fastapi_voyager-0.4.5 → fastapi_voyager-0.5.2}/README.md +10 -3
  4. {fastapi_voyager-0.4.5 → fastapi_voyager-0.5.2}/src/fastapi_voyager/cli.py +5 -5
  5. fastapi_voyager-0.5.2/src/fastapi_voyager/render.py +163 -0
  6. {fastapi_voyager-0.4.5 → fastapi_voyager-0.5.2}/src/fastapi_voyager/server.py +32 -12
  7. {fastapi_voyager-0.4.5 → fastapi_voyager-0.5.2}/src/fastapi_voyager/type.py +20 -5
  8. {fastapi_voyager-0.4.5 → fastapi_voyager-0.5.2}/src/fastapi_voyager/version.py +1 -1
  9. fastapi_voyager-0.4.5/src/fastapi_voyager/graph.py → fastapi_voyager-0.5.2/src/fastapi_voyager/voyager.py +46 -176
  10. fastapi_voyager-0.5.2/src/fastapi_voyager/web/component/render-graph.js +87 -0
  11. {fastapi_voyager-0.4.5 → fastapi_voyager-0.5.2}/src/fastapi_voyager/web/index.html +51 -2
  12. {fastapi_voyager-0.4.5 → fastapi_voyager-0.5.2}/src/fastapi_voyager/web/vue-main.js +89 -9
  13. {fastapi_voyager-0.4.5 → fastapi_voyager-0.5.2}/tests/demo.py +4 -2
  14. {fastapi_voyager-0.4.5 → fastapi_voyager-0.5.2}/tests/service/schema.py +1 -1
  15. {fastapi_voyager-0.4.5 → fastapi_voyager-0.5.2}/tests/test_analysis.py +2 -2
  16. {fastapi_voyager-0.4.5 → fastapi_voyager-0.5.2}/tests/test_module.py +6 -10
  17. {fastapi_voyager-0.4.5 → fastapi_voyager-0.5.2}/.gitignore +0 -0
  18. {fastapi_voyager-0.4.5 → fastapi_voyager-0.5.2}/LICENSE +0 -0
  19. {fastapi_voyager-0.4.5 → fastapi_voyager-0.5.2}/pyproject.toml +0 -0
  20. {fastapi_voyager-0.4.5 → fastapi_voyager-0.5.2}/src/fastapi_voyager/__init__.py +0 -0
  21. {fastapi_voyager-0.4.5 → fastapi_voyager-0.5.2}/src/fastapi_voyager/filter.py +0 -0
  22. {fastapi_voyager-0.4.5 → fastapi_voyager-0.5.2}/src/fastapi_voyager/module.py +0 -0
  23. {fastapi_voyager-0.4.5 → fastapi_voyager-0.5.2}/src/fastapi_voyager/type_helper.py +0 -0
  24. {fastapi_voyager-0.4.5 → fastapi_voyager-0.5.2}/src/fastapi_voyager/web/component/route-code-display.js +0 -0
  25. {fastapi_voyager-0.4.5 → fastapi_voyager-0.5.2}/src/fastapi_voyager/web/component/schema-code-display.js +0 -0
  26. {fastapi_voyager-0.4.5 → fastapi_voyager-0.5.2}/src/fastapi_voyager/web/component/schema-field-filter.js +0 -0
  27. {fastapi_voyager-0.4.5 → fastapi_voyager-0.5.2}/src/fastapi_voyager/web/graph-ui.js +0 -0
  28. {fastapi_voyager-0.4.5 → fastapi_voyager-0.5.2}/src/fastapi_voyager/web/graphviz.svg.css +0 -0
  29. {fastapi_voyager-0.4.5 → fastapi_voyager-0.5.2}/src/fastapi_voyager/web/graphviz.svg.js +0 -0
  30. {fastapi_voyager-0.4.5 → fastapi_voyager-0.5.2}/src/fastapi_voyager/web/icon/android-chrome-192x192.png +0 -0
  31. {fastapi_voyager-0.4.5 → fastapi_voyager-0.5.2}/src/fastapi_voyager/web/icon/android-chrome-512x512.png +0 -0
  32. {fastapi_voyager-0.4.5 → fastapi_voyager-0.5.2}/src/fastapi_voyager/web/icon/apple-touch-icon.png +0 -0
  33. {fastapi_voyager-0.4.5 → fastapi_voyager-0.5.2}/src/fastapi_voyager/web/icon/favicon-16x16.png +0 -0
  34. {fastapi_voyager-0.4.5 → fastapi_voyager-0.5.2}/src/fastapi_voyager/web/icon/favicon-32x32.png +0 -0
  35. {fastapi_voyager-0.4.5 → fastapi_voyager-0.5.2}/src/fastapi_voyager/web/icon/favicon.ico +0 -0
  36. {fastapi_voyager-0.4.5 → fastapi_voyager-0.5.2}/src/fastapi_voyager/web/icon/site.webmanifest +0 -0
  37. {fastapi_voyager-0.4.5 → fastapi_voyager-0.5.2}/src/fastapi_voyager/web/quasar.min.css +0 -0
  38. {fastapi_voyager-0.4.5 → fastapi_voyager-0.5.2}/src/fastapi_voyager/web/quasar.min.js +0 -0
  39. {fastapi_voyager-0.4.5 → fastapi_voyager-0.5.2}/tests/__init__.py +0 -0
  40. {fastapi_voyager-0.4.5 → fastapi_voyager-0.5.2}/tests/demo_anno.py +0 -0
  41. {fastapi_voyager-0.4.5 → fastapi_voyager-0.5.2}/tests/service/__init__.py +0 -0
  42. {fastapi_voyager-0.4.5 → fastapi_voyager-0.5.2}/tests/test_import.py +0 -0
  43. {fastapi_voyager-0.4.5 → fastapi_voyager-0.5.2}/tests/test_type_helper.py +0 -0
  44. {fastapi_voyager-0.4.5 → fastapi_voyager-0.5.2}/uv.lock +0 -0
  45. {fastapi_voyager-0.4.5 → fastapi_voyager-0.5.2}/voyager.jpg +0 -0
@@ -0,0 +1 @@
1
+ 3.12
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastapi-voyager
3
- Version: 0.4.5
3
+ Version: 0.5.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
@@ -162,11 +162,14 @@ 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
- - [ ] group routes by module hierarchy
165
+ - [ ] add tooltips
166
+ - [ ] route
167
+ - [ ] group routes by module hierarchy
168
+ - [ ] add response_model in route
166
169
  - [ ] support dataclass
170
+ - [ ] click field to highlight links
167
171
  - [ ] user can generate nodes/edges manually and connect to generated ones
168
- - [ ] refactor
169
- - [ ] abstract render module
172
+ - [ ] add owner
170
173
  - [ ] ui optimization
171
174
  - [ ] fixed left/right bar show field information
172
175
  - [ ] display standard ER diagram
@@ -176,6 +179,10 @@ features
176
179
  - [x] strikethrough for excluded fields
177
180
  - [ ] display loader as edges
178
181
  - [ ] add tests
182
+ - [ ] refactor
183
+ - [ ] 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
179
186
 
180
187
  bugs:
181
188
  - [ ] fix duplicated link from class and parent class, it also break clicking highlight
@@ -135,11 +135,14 @@ 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
- - [ ] group routes by module hierarchy
138
+ - [ ] add tooltips
139
+ - [ ] route
140
+ - [ ] group routes by module hierarchy
141
+ - [ ] add response_model in route
139
142
  - [ ] support dataclass
143
+ - [ ] click field to highlight links
140
144
  - [ ] user can generate nodes/edges manually and connect to generated ones
141
- - [ ] refactor
142
- - [ ] abstract render module
145
+ - [ ] add owner
143
146
  - [ ] ui optimization
144
147
  - [ ] fixed left/right bar show field information
145
148
  - [ ] display standard ER diagram
@@ -149,6 +152,10 @@ features
149
152
  - [x] strikethrough for excluded fields
150
153
  - [ ] display loader as edges
151
154
  - [ ] add tests
155
+ - [ ] refactor
156
+ - [ ] 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
152
159
 
153
160
  bugs:
154
161
  - [ ] fix duplicated link from class and parent class, it also break clicking highlight
@@ -7,7 +7,7 @@ import os
7
7
  from typing import Optional
8
8
 
9
9
  from fastapi import FastAPI
10
- from fastapi_voyager.graph import Analytics
10
+ from fastapi_voyager.voyager import Voyager
11
11
  from fastapi_voyager.version import __version__
12
12
  from fastapi_voyager import server as viz_server
13
13
 
@@ -49,7 +49,7 @@ def load_fastapi_app_from_file(module_path: str, app_name: str = "app") -> Optio
49
49
  def load_fastapi_app_from_module(module_name: str, app_name: str = "app") -> Optional[FastAPI]:
50
50
  """Load FastAPI app from a Python module name."""
51
51
  try:
52
- # 临时将当前工作目录添加到 Python 路径中
52
+ # Temporarily add the current working directory to sys.path
53
53
  current_dir = os.getcwd()
54
54
  if current_dir not in sys.path:
55
55
  sys.path.insert(0, current_dir)
@@ -73,7 +73,7 @@ def load_fastapi_app_from_module(module_name: str, app_name: str = "app") -> Opt
73
73
  print(f"Error: No attribute '{app_name}' found in module '{module_name}'")
74
74
  return None
75
75
  finally:
76
- # 清理:如果我们添加了路径,则移除它
76
+ # Cleanup: if we added the path, remove it
77
77
  if path_added and current_dir in sys.path:
78
78
  sys.path.remove(current_dir)
79
79
 
@@ -95,7 +95,7 @@ def generate_visualization(
95
95
  ):
96
96
 
97
97
  """Generate DOT file for FastAPI router visualization."""
98
- analytics = Analytics(
98
+ analytics = Voyager(
99
99
  include_tags=tags,
100
100
  schema=schema,
101
101
  show_fields=show_fields,
@@ -105,7 +105,7 @@ def generate_visualization(
105
105
 
106
106
  analytics.analysis(app)
107
107
 
108
- dot_content = analytics.generate_dot()
108
+ dot_content = analytics.render_dot()
109
109
 
110
110
  # Optionally write to file
111
111
  with open(output_file, 'w', encoding='utf-8') as f:
@@ -0,0 +1,163 @@
1
+ from fastapi_voyager.type import SchemaNode, ModuleNode, Link, Tag, Route, FieldType, PK
2
+ from fastapi_voyager.module import build_module_tree
3
+
4
+
5
+ class Renderer:
6
+ def __init__(
7
+ self,
8
+ *,
9
+ show_fields: FieldType = 'single',
10
+ module_color: dict[str, str] | None = None,
11
+ schema: str | None = None,
12
+ ) -> None:
13
+ self.show_fields = show_fields if show_fields in ('single', 'object', 'all') else 'single'
14
+ self.module_color = module_color or {}
15
+ self.schema = schema
16
+
17
+ def render_schema_label(self, node: SchemaNode) -> str:
18
+ has_base_fields = any(f.from_base for f in node.fields)
19
+ fields = [n for n in node.fields if n.from_base is False]
20
+
21
+ if self.show_fields == 'all':
22
+ _fields = fields
23
+ elif self.show_fields == 'object':
24
+ _fields = [f for f in fields if f.is_object is True]
25
+ else: # 'single'
26
+ _fields = []
27
+
28
+ fields_parts: list[str] = []
29
+ if self.show_fields == 'all' and has_base_fields:
30
+ fields_parts.append('<tr><td align="left" cellpadding="8"><font color="#999"> Inherited Fields ... </font></td></tr>')
31
+
32
+ for field in _fields:
33
+ type_name = field.type_name[:25] + '..' if len(field.type_name) > 25 else field.type_name
34
+ display_xml = f'<s align="left">{field.name}: {type_name}</s>' if field.is_exclude else f'{field.name}: {type_name}'
35
+ field_str = f"""<tr><td align="left" port="f{field.name}" cellpadding="8"><font> {display_xml} </font></td></tr>"""
36
+ fields_parts.append(field_str)
37
+
38
+ header_color = 'tomato' if node.id == self.schema else '#009485'
39
+ header = f"""<tr><td cellpadding="1.5" bgcolor="{header_color}" align="center" colspan="1" port="{PK}"> <font color="white"> {node.name} </font> </td> </tr>"""
40
+ field_content = ''.join(fields_parts) if fields_parts else ''
41
+ return f"""<<table border="1" cellborder="0" cellpadding="0" bgcolor="white"> {header} {field_content} </table>>"""
42
+
43
+ def _handle_schema_anchor(self, source: str) -> str:
44
+ if '::' in source:
45
+ a, b = source.split('::', 1)
46
+ return f'"{a}":{b}'
47
+ return f'"{source}"'
48
+
49
+ def render_link(self, link: Link) -> str:
50
+ h = self._handle_schema_anchor
51
+ if link.type == 'tag_route':
52
+ return f"""{h(link.source)}:e -> {h(link.target)}:w [style = "solid", minlen=3];"""
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];"""
55
+ elif link.type == 'schema':
56
+ return f"""{h(link.source)}:e -> {h(link.target)}:w [style = "solid", label = "", dir="back", minlen=3, arrowtail="odot"];"""
57
+ elif link.type == 'parent':
58
+ return f"""{h(link.source)}:e -> {h(link.target)}:w [style = "solid, dashed", dir="back", minlen=3, taillabel = "< inherit >", color = "purple", tailport="n"];"""
59
+ elif link.type == 'subset':
60
+ return f"""{h(link.source)}:e -> {h(link.target)}:w [style = "solid, dashed", dir="back", minlen=3, taillabel = "< subset >", color = "orange", tailport="n"];"""
61
+ else:
62
+ raise ValueError(f'Unknown link type: {link.type}')
63
+
64
+ def render_module(self, mod: ModuleNode) -> str:
65
+ color = self.module_color.get(mod.fullname)
66
+ inner_nodes = [
67
+ f'''
68
+ "{node.id}" [
69
+ label = {self.render_schema_label(node)}
70
+ shape = "plain"
71
+ margin="0.5,0.1"
72
+ ];''' for node in mod.schema_nodes
73
+ ]
74
+ inner_nodes_str = '\n'.join(inner_nodes)
75
+ child_str = '\n'.join(self.render_module(m) for m in mod.modules)
76
+ return f'''
77
+ subgraph cluster_module_{mod.fullname.replace('.', '_')} {{
78
+ tooltip="{mod.fullname}"
79
+ color = "#666"
80
+ style="rounded"
81
+ label = " {mod.name}"
82
+ labeljust = "l"
83
+ {(f'pencolor = "{color}"' if color else 'pencolor="#ccc"')}
84
+ {(f'penwidth = 3' if color else 'penwidth=""')}
85
+ {inner_nodes_str}
86
+ {child_str}
87
+ }}'''
88
+
89
+ def render_dot(self, tags: list[Tag], routes: list[Route], nodes: list[SchemaNode], links: list[Link]) -> str:
90
+ modules = build_module_tree(nodes)
91
+
92
+ tag_str = '\n'.join([
93
+ f'''
94
+ "{t.id}" [
95
+ label = " {t.name} "
96
+ shape = "record"
97
+ margin="0.5,0.1"
98
+ ];''' for t in tags
99
+ ])
100
+
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
+
110
+ modules_str = '\n'.join(self.render_module(m) for m in modules)
111
+ link_str = '\n'.join(self.render_link(link) for link in links)
112
+
113
+ dot_str = f'''
114
+ digraph world {{
115
+ pad="0.5"
116
+ nodesep=0.8
117
+ fontname="Helvetica,Arial,sans-serif"
118
+ node [fontname="Helvetica,Arial,sans-serif"]
119
+ edge [
120
+ fontname="Helvetica,Arial,sans-serif"
121
+ color="gray"
122
+ ]
123
+ graph [
124
+ rankdir = "LR"
125
+ ];
126
+ node [
127
+ fontsize = "16"
128
+ ];
129
+
130
+ subgraph cluster_tags {{
131
+ color = "#aaa"
132
+ margin=18
133
+ style="dashed"
134
+ label = " Tags"
135
+ labeljust = "l"
136
+ fontsize = "20"
137
+ {tag_str}
138
+ }}
139
+
140
+ subgraph cluster_router {{
141
+ color = "#aaa"
142
+ margin=18
143
+ style="dashed"
144
+ label = " Routes"
145
+ labeljust = "l"
146
+ fontsize = "20"
147
+ {route_str}
148
+ }}
149
+
150
+ subgraph cluster_schema {{
151
+ color = "#aaa"
152
+ margin=18
153
+ style="dashed"
154
+ label=" Schema"
155
+ labeljust="l"
156
+ fontsize="20"
157
+ {modules_str}
158
+ }}
159
+
160
+ {link_str}
161
+ }}
162
+ '''
163
+ return dot_str
@@ -1,12 +1,13 @@
1
1
  from pathlib import Path
2
- from typing import Optional, Dict
2
+ from typing import Optional
3
3
  from fastapi import FastAPI
4
4
  from starlette.middleware.gzip import GZipMiddleware
5
5
  from pydantic import BaseModel
6
- from fastapi.responses import HTMLResponse, PlainTextResponse
6
+ from fastapi.responses import HTMLResponse, PlainTextResponse, JSONResponse
7
7
  from fastapi.staticfiles import StaticFiles
8
- from fastapi_voyager.graph import Analytics
9
- from fastapi_voyager.type import Tag, FieldInfo
8
+ from fastapi_voyager.voyager import Voyager
9
+ from fastapi_voyager.type import Tag, FieldInfo, CoreData
10
+ from fastapi_voyager.render import Renderer
10
11
 
11
12
 
12
13
  WEB_DIR = Path(__file__).parent / "web"
@@ -50,12 +51,12 @@ def create_app_with_fastapi(
50
51
 
51
52
  @app.get("/dot", response_model=OptionParam)
52
53
  def get_dot() -> str:
53
- analytics = Analytics(module_color=module_color, load_meta=True)
54
- analytics.analysis(target_app)
55
- dot = analytics.generate_dot()
54
+ voyager = Voyager(module_color=module_color, load_meta=True)
55
+ voyager.analysis(target_app)
56
+ dot = voyager.render_dot()
56
57
 
57
58
  # include tags and their routes
58
- tags = analytics.tags
59
+ tags = voyager.tags
59
60
 
60
61
  schemas = [
61
62
  SchemaType(
@@ -64,7 +65,7 @@ def create_app_with_fastapi(
64
65
  fields=s.fields,
65
66
  source_code=s.source_code,
66
67
  vscode_link=s.vscode_link
67
- ) for s in analytics.nodes
68
+ ) for s in voyager.nodes
68
69
  ]
69
70
  schemas.sort(key=lambda s: s.name)
70
71
 
@@ -72,7 +73,7 @@ def create_app_with_fastapi(
72
73
 
73
74
  @app.post("/dot", response_class=PlainTextResponse)
74
75
  def get_filtered_dot(payload: Payload) -> str:
75
- analytics = Analytics(
76
+ voyager = Voyager(
76
77
  include_tags=payload.tags,
77
78
  schema=payload.schema_name,
78
79
  schema_field=payload.schema_field,
@@ -81,8 +82,27 @@ def create_app_with_fastapi(
81
82
  route_name=payload.route_name,
82
83
  load_meta=False,
83
84
  )
84
- analytics.analysis(target_app)
85
- return analytics.generate_dot()
85
+ voyager.analysis(target_app)
86
+ return voyager.render_dot()
87
+
88
+ @app.post("/dot-core-data", response_model=CoreData)
89
+ def get_filtered_dot_core_data(payload: Payload) -> str:
90
+ voyager = Voyager(
91
+ include_tags=payload.tags,
92
+ schema=payload.schema_name,
93
+ schema_field=payload.schema_field,
94
+ show_fields=payload.show_fields,
95
+ module_color=module_color,
96
+ route_name=payload.route_name,
97
+ load_meta=False,
98
+ )
99
+ voyager.analysis(target_app)
100
+ return voyager.dump_core_data()
101
+
102
+ @app.post('/dot-render-core-data', response_class=PlainTextResponse)
103
+ def render_dot_from_core_data(core_data: CoreData) -> str:
104
+ renderer = Renderer(show_fields=core_data.show_fields, module_color=core_data.module_color, schema=core_data.schema)
105
+ return renderer.render_dot(core_data.tags, core_data.routes, core_data.nodes, core_data.links)
86
106
 
87
107
  @app.get("/", response_class=HTMLResponse)
88
108
  def index():
@@ -1,5 +1,6 @@
1
- from dataclasses import dataclass, field
2
- from typing import Literal
1
+ from dataclasses import field
2
+ from pydantic.dataclasses import dataclass
3
+ from typing import Literal, Optional
3
4
 
4
5
  @dataclass
5
6
  class NodeBase:
@@ -39,11 +40,12 @@ class ModuleNode:
39
40
 
40
41
 
41
42
  # type:
42
- # - entry: tag -> route, route -> response model
43
+ # - tag_route: tag -> route
44
+ # - route_to_schema: route -> response model
43
45
  # - subset: schema -> schema (subset)
44
46
  # - parent: schema -> schema (inheritance)
45
- # - internal: schema -> schema (field reference)
46
- LinkType = Literal['internal', 'parent', 'entry', 'subset']
47
+ # - schema: schema -> schema (field reference)
48
+ LinkType = Literal['schema', 'parent', 'tag_route', 'subset', 'route_to_schema']
47
49
 
48
50
  @dataclass
49
51
  class Link:
@@ -55,3 +57,16 @@ class Link:
55
57
  source_origin: str
56
58
  target_origin: str
57
59
  type: LinkType
60
+
61
+ FieldType = Literal['single', 'object', 'all']
62
+ PK = "PK"
63
+
64
+ @dataclass
65
+ class CoreData:
66
+ tags: list[Tag]
67
+ routes: list[Route]
68
+ nodes: list[SchemaNode]
69
+ links: list[Link]
70
+ show_fields: FieldType
71
+ module_color: Optional[dict[str, str]] = None
72
+ schema: Optional[str] = None
@@ -1,2 +1,2 @@
1
1
  __all__ = ["__version__"]
2
- __version__ = "0.4.5"
2
+ __version__ = "0.5.2"
@@ -1,5 +1,4 @@
1
1
  import inspect
2
- from typing import Literal
3
2
  from fastapi import FastAPI, routing
4
3
  from fastapi_voyager.type_helper import (
5
4
  get_core_types,
@@ -12,20 +11,18 @@ from fastapi_voyager.type_helper import (
12
11
  update_forward_refs
13
12
  )
14
13
  from pydantic import BaseModel
15
- from fastapi_voyager.type import Route, SchemaNode, Link, Tag, ModuleNode, LinkType
16
- from fastapi_voyager.module import build_module_tree
14
+ from fastapi_voyager.type import Route, SchemaNode, Link, Tag, LinkType, FieldType, PK, CoreData
17
15
  from fastapi_voyager.filter import filter_graph
16
+ from fastapi_voyager.render import Renderer
17
+ import pydantic_resolve.constant as const
18
18
 
19
- # support pydantic-resolve's ensure_subset
20
- ENSURE_SUBSET_REFERENCE = '__pydantic_resolve_ensure_subset_reference__'
21
- PK = "PK"
22
19
 
23
- class Analytics:
20
+ class Voyager:
24
21
  def __init__(
25
22
  self,
26
23
  schema: str | None = None,
27
24
  schema_field: str | None = None,
28
- show_fields: Literal['single', 'object', 'all'] = 'single',
25
+ show_fields: FieldType = 'single',
29
26
  include_tags: list[str] | None = None,
30
27
  module_color: dict[str, str] | None = None,
31
28
  route_name: str | None = None,
@@ -94,8 +91,8 @@ class Analytics:
94
91
  route_obj = Route(
95
92
  id=route_id,
96
93
  name=route_name,
97
- vscode_link=get_vscode_link(route.endpoint) if self.load_meta else None,
98
- source_code=inspect.getsource(route.endpoint) if self.load_meta else None
94
+ vscode_link=get_vscode_link(route.endpoint) if self.load_meta else '',
95
+ source_code=inspect.getsource(route.endpoint) if self.load_meta else ''
99
96
  )
100
97
 
101
98
  self.routes.append(route_obj)
@@ -106,7 +103,7 @@ class Analytics:
106
103
  source_origin=tag_id,
107
104
  target=route_id,
108
105
  target_origin=route_id,
109
- type='entry'
106
+ type='tag_route'
110
107
  ))
111
108
 
112
109
  # add response_models and create links from route -> response_model
@@ -118,7 +115,7 @@ class Analytics:
118
115
  source_origin=route_id,
119
116
  target=self.generate_node_head(target_name),
120
117
  target_origin=target_name,
121
- type='entry'
118
+ type='route_to_schema'
122
119
  ))
123
120
 
124
121
  schemas.append(schema)
@@ -143,8 +140,8 @@ class Analytics:
143
140
  id=full_name,
144
141
  module=schema.__module__,
145
142
  name=schema.__name__,
146
- source_code=get_source(schema) if self.load_meta else None,
147
- vscode_link=get_vscode_link(schema) if self.load_meta else None,
143
+ source_code=get_source(schema) if self.load_meta else '',
144
+ vscode_link=get_vscode_link(schema) if self.load_meta else '',
148
145
  fields=get_pydantic_fields(schema, bases_fields)
149
146
  )
150
147
  return full_name
@@ -185,8 +182,10 @@ class Analytics:
185
182
  update_forward_refs(schema)
186
183
  self.add_to_node_set(schema)
187
184
 
185
+ base_fields = set()
186
+
188
187
  # handle schema inside ensure_subset(schema)
189
- if subset_reference := getattr(schema, ENSURE_SUBSET_REFERENCE, None):
188
+ if subset_reference := getattr(schema, const.ENSURE_SUBSET_REFERENCE, None):
190
189
  if is_inheritance_of_pydantic_base(subset_reference):
191
190
 
192
191
  self.add_to_node_set(subset_reference)
@@ -201,6 +200,12 @@ class Analytics:
201
200
  # handle bases
202
201
  for base_class in schema.__bases__:
203
202
  if is_inheritance_of_pydantic_base(base_class):
203
+ # collect base class field names to avoid duplicating inherited fields
204
+ try:
205
+ base_fields.update(getattr(base_class, 'model_fields', {}).keys())
206
+ except Exception:
207
+ # be defensive in case of unconventional BaseModel subclasses
208
+ pass
204
209
  self.add_to_node_set(base_class)
205
210
  self.add_to_link_set(
206
211
  source=self.generate_node_head(full_class_name(schema)),
@@ -212,6 +217,9 @@ class Analytics:
212
217
 
213
218
  # handle fields
214
219
  for k, v in schema.model_fields.items():
220
+ # skip fields inherited from base classes
221
+ if k in base_fields:
222
+ continue
215
223
  annos = get_core_types(v.annotation)
216
224
  for anno in annos:
217
225
  if anno and is_inheritance_of_pydantic_base(anno):
@@ -223,98 +231,34 @@ class Analytics:
223
231
  source_origin=full_class_name(schema),
224
232
  target=self.generate_node_head(full_class_name(anno)),
225
233
  target_origin=full_class_name(anno),
226
- type='internal'):
234
+ type='schema'):
227
235
  self.analysis_schemas(anno)
228
236
 
229
237
 
230
238
  def generate_node_head(self, link_name: str):
231
239
  return f'{link_name}::{PK}'
232
240
 
241
+ def dump_core_data(self):
242
+ _tags, _routes, _nodes, _links = filter_graph(
243
+ schema=self.schema,
244
+ schema_field=self.schema_field,
245
+ tags=self.tags,
246
+ routes=self.routes,
247
+ nodes=self.nodes,
248
+ links=self.links,
249
+ node_set=self.node_set,
250
+ )
251
+ return CoreData(
252
+ tags=_tags,
253
+ routes=_routes,
254
+ nodes=_nodes,
255
+ links=_links,
256
+ show_fields=self.show_fields,
257
+ module_color=self.module_color,
258
+ schema=self.schema
259
+ )
233
260
 
234
- def generate_node_label(self, node: SchemaNode):
235
- has_base_fields = any(f.from_base for f in node.fields)
236
-
237
- fields = [n for n in node.fields if n.from_base is False]
238
-
239
- name = node.name
240
- fields_parts: list[str] = []
241
-
242
- if self.show_fields == 'all':
243
- _fields = fields
244
- if has_base_fields:
245
- fields_parts.append('<tr><td align="left" cellpadding="8"><font color="#999"> Inherited Fields ... </font></td></tr>')
246
- elif self.show_fields == 'object':
247
- _fields = [f for f in fields if f.is_object is True]
248
-
249
- else: # 'single'
250
- _fields = []
251
-
252
- for field in _fields:
253
- type_name = field.type_name[:25] + '..' if len(field.type_name) > 25 else field.type_name
254
- display_xml = f'<s align="left">{field.name}: {type_name}</s>' if field.is_exclude else f'{field.name}: {type_name}'
255
- field_str = f"""<tr><td align="left" port="f{field.name}" cellpadding="8"><font> {display_xml} </font></td></tr>"""
256
- fields_parts.append(field_str)
257
-
258
- header_color = 'tomato' if node.id == self.schema else '#009485'
259
- header = f"""<tr><td cellpadding="1.5" bgcolor="{header_color}" align="center" colspan="1" port="{PK}"> <font color="white"> {name} </font> </td> </tr>"""
260
- field_content = ''.join(fields_parts) if fields_parts else ''
261
-
262
- return f"""<<table border="1" cellborder="0" cellpadding="0" bgcolor="white"> {header} {field_content} </table>>"""
263
-
264
- def generate_dot(self):
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
-
273
- def get_link_attributes(link: Link):
274
- if link.type == 'parent':
275
- return 'style = "solid, dashed", dir="back", minlen=3, taillabel = "< inherit >", color = "purple", tailport="n"'
276
- elif link.type == 'entry':
277
- return 'style = "solid", label = "", minlen=3, tailport="e", headport="w"'
278
- elif link.type == 'subset':
279
- return 'style = "solid, dashed", dir="back", minlen=3, taillabel = "< subset >", color = "orange", tailport="n"'
280
-
281
- return 'style = "solid", arrowtail="odot", dir="back", minlen=3'
282
-
283
- def render_module(mod: ModuleNode):
284
- color = self.module_color.get(mod.fullname)
285
- # render schema nodes inside this module
286
- inner_nodes = [
287
- f'''
288
- "{node.id}" [
289
- label = {self.generate_node_label(node)}
290
- shape = "plain"
291
- margin="0.5,0.1"
292
- ];''' for node in mod.schema_nodes
293
- ]
294
- inner_nodes_str = '\n'.join(inner_nodes)
295
-
296
- # render child modules recursively
297
- child_str = '\n'.join(render_module(m) for m in mod.modules)
298
-
299
- return f'''
300
- subgraph cluster_module_{mod.fullname.replace('.', '_')} {{
301
- color = "#666"
302
- style="rounded"
303
- label = " {mod.name}"
304
- labeljust = "l"
305
- {(f'pencolor = "{color}"' if color else 'pencolor="#ccc"')}
306
- {(f'penwidth = 3' if color else 'penwidth=""')}
307
- {inner_nodes_str}
308
- {child_str}
309
- }}'''
310
-
311
- def handle_entry(source: str):
312
- if '::' in source:
313
- a, b = source.split('::', 1)
314
- return f'"{a}":{b}'
315
- return f'"{source}"'
316
-
317
-
261
+ def render_dot(self):
318
262
  _tags, _routes, _nodes, _links = filter_graph(
319
263
  schema=self.schema,
320
264
  schema_field=self.schema_field,
@@ -324,79 +268,5 @@ class Analytics:
324
268
  links=self.links,
325
269
  node_set=self.node_set,
326
270
  )
327
- _modules = build_module_tree(_nodes)
328
-
329
- tags = [
330
- f'''
331
- "{t.id}" [
332
- label = " {t.name} "
333
- shape = "record"
334
- margin="0.5,0.1"
335
- ];''' for t in _tags]
336
- tag_str = '\n'.join(tags)
337
-
338
- routes = [
339
- f'''
340
- "{r.id}" [
341
- label = " {r.name} "
342
- margin="0.5,0.1"
343
- shape = "record"
344
- ];''' for r in _routes]
345
- route_str = '\n'.join(routes)
346
-
347
- modules_str = '\n'.join(render_module(m) for m in _modules)
348
-
349
- links = [ generate_link(link) for link in _links ]
350
- link_str = '\n'.join(links)
351
-
352
- template = f'''
353
- digraph world {{
354
- pad="0.5"
355
- nodesep=0.8
356
- fontname="Helvetica,Arial,sans-serif"
357
- node [fontname="Helvetica,Arial,sans-serif"]
358
- edge [
359
- fontname="Helvetica,Arial,sans-serif"
360
- color="gray"
361
- ]
362
- graph [
363
- rankdir = "LR"
364
- ];
365
- node [
366
- fontsize = "16"
367
- ];
368
-
369
- subgraph cluster_tags {{
370
- color = "#aaa"
371
- margin=18
372
- style="dashed"
373
- label = " Tags"
374
- labeljust = "l"
375
- fontsize = "20"
376
- {tag_str}
377
- }}
378
-
379
- subgraph cluster_router {{
380
- color = "#aaa"
381
- margin=18
382
- style="dashed"
383
- label = " Routes"
384
- labeljust = "l"
385
- fontsize = "20"
386
- {route_str}
387
- }}
388
-
389
- subgraph cluster_schema {{
390
- color = "#aaa"
391
- margin=18
392
- style="dashed"
393
- label=" Schema"
394
- labeljust="l"
395
- fontsize="20"
396
- {modules_str}
397
- }}
398
-
399
- {link_str}
400
- }}
401
- '''
402
- return template
271
+ renderer = Renderer(show_fields=self.show_fields, module_color=self.module_color, schema=self.schema)
272
+ return renderer.render_dot(_tags, _routes, _nodes, _links)
@@ -0,0 +1,87 @@
1
+ import { GraphUI } from "../graph-ui.js";
2
+ const { defineComponent, ref, onMounted, nextTick } = window.Vue;
3
+
4
+ export default defineComponent({
5
+ name: "RenderGraph",
6
+ props: {
7
+ coreData: { type: [Object, Array], required: false, default: null },
8
+ },
9
+ emits: ["close"],
10
+ setup(props, { emit }) {
11
+ const containerId = `graph-render-${Math.random().toString(36).slice(2, 9)}`;
12
+ const hasRendered = ref(false);
13
+ const loading = ref(false);
14
+ let graphInstance = null;
15
+
16
+ async function ensureGraph() {
17
+ await nextTick();
18
+ if (!graphInstance) {
19
+ graphInstance = new GraphUI(`#${containerId}`);
20
+ }
21
+ }
22
+
23
+ async function renderFromDot(dotText) {
24
+ if (!dotText) return;
25
+ await ensureGraph();
26
+ await graphInstance.render(dotText);
27
+ hasRendered.value = true;
28
+ }
29
+
30
+ async function renderFromCoreData() {
31
+ if (!props.coreData) return;
32
+ loading.value = true;
33
+ try {
34
+ const res = await fetch("/dot-render-core-data", {
35
+ method: "POST",
36
+ headers: { "Content-Type": "application/json" },
37
+ body: JSON.stringify(props.coreData),
38
+ });
39
+ const dotText = await res.text();
40
+ await renderFromDot(dotText);
41
+ if (window.Quasar?.Notify) {
42
+ window.Quasar.Notify.create({ type: "positive", message: "Rendered" });
43
+ }
44
+ } catch (e) {
45
+ console.error("Render from core data failed", e);
46
+ if (window.Quasar?.Notify) {
47
+ window.Quasar.Notify.create({ type: "negative", message: "Render failed" });
48
+ }
49
+ } finally {
50
+ loading.value = false;
51
+ }
52
+ }
53
+
54
+ async function reload() {
55
+ await renderFromCoreData();
56
+ }
57
+
58
+ onMounted(async () => {
59
+ await reload();
60
+ });
61
+
62
+ function close() {
63
+ emit("close");
64
+ }
65
+
66
+ return { containerId, close, hasRendered, reload, loading };
67
+ },
68
+ template: `
69
+ <div style="height:100%; position:relative; background:#fff;">
70
+ <q-btn
71
+ flat dense round icon="close"
72
+ aria-label="Close"
73
+ @click="close"
74
+ style="position:absolute; top:6px; right:6px; z-index:11; background:rgba(255,255,255,0.85);"
75
+ />
76
+ <q-btn
77
+ flat dense round icon="refresh"
78
+ aria-label="Reload"
79
+ :loading="loading"
80
+ @click="reload"
81
+ style="position:absolute; top:6px; right:46px; z-index:11; background:rgba(255,255,255,0.85);"
82
+ />
83
+ <div :id="containerId" style="width:100%; height:100%; overflow:auto; background:#fafafa"></div>
84
+ </div>
85
+ `,
86
+ });
87
+
@@ -110,13 +110,23 @@
110
110
  </div>
111
111
 
112
112
  <div class="col-auto">
113
- <q-btn
113
+ <q-btn-dropdown
114
114
  class="q-ml-md"
115
+ split
115
116
  :loading="state.generating"
116
117
  @click="onGenerate"
117
118
  label="Generate"
118
119
  outline
119
- />
120
+ >
121
+ <q-list>
122
+ <q-item clickable v-close-popup @click="onDumpData">
123
+ <q-item-section>Dump data</q-item-section>
124
+ </q-item>
125
+ <q-item clickable v-close-popup @click="openImportDialog">
126
+ <q-item-section>Import data</q-item-section>
127
+ </q-item>
128
+ </q-list>
129
+ </q-btn-dropdown>
120
130
  </div>
121
131
  <div class="col-auto">
122
132
  <q-btn flat @click="onReset" label="Reset" />
@@ -204,6 +214,45 @@
204
214
  @close="showRouteCode = false" />
205
215
  </q-dialog>
206
216
 
217
+ <!-- Dump Core Data Dialog -->
218
+ <q-dialog v-model="showDumpDialog" :maximized="true" :persistent="false">
219
+ <div style="height:100%; position:relative; background:#fff;">
220
+ <q-btn
221
+ flat dense round icon="content_copy"
222
+ aria-label="Copy"
223
+ @click="copyDumpJson"
224
+ style="position:absolute; top:6px; right:62px; z-index:11; background:rgba(255,255,255,0.85);"
225
+ ></q-btn>
226
+ <q-btn
227
+ flat dense round icon="close"
228
+ aria-label="Close"
229
+ @click="showDumpDialog = false"
230
+ style="position:absolute; top:6px; right:6px; z-index:11; background:rgba(255,255,255,0.85);"
231
+ ></q-btn>
232
+ <div>
233
+ <pre style="padding:20px; overflow: auto;"><code>{{ dumpJson }}</code></pre>
234
+ </div>
235
+ </div>
236
+ </q-dialog>
237
+
238
+ <!-- Import Core Data Dialog -->
239
+ <q-dialog v-model="showImportDialog" :persistent="true">
240
+ <q-card style="min-width:70vw; max-width:90vw;">
241
+ <q-card-section class="text-h6">Import core data JSON</q-card-section>
242
+ <q-card-section >
243
+ <q-btn color="primary" label="Render" @click="onImportConfirm" />
244
+ </q-card-section>
245
+ <q-card-section>
246
+ <q-input v-model="importJsonText" type="textarea" autogrow filled label="Paste JSON here" />
247
+ </q-card-section>
248
+ </q-card>
249
+ </q-dialog>
250
+
251
+ <!-- Render Graph Dialog (from imported core data) -->
252
+ <q-dialog v-model="showRenderGraph" :maximized="true" :persistent="false">
253
+ <render-graph :core-data="renderCoreData" @close="showRenderGraph = false" />
254
+ </q-dialog>
255
+
207
256
  <div id="graph" style="width: 100%; flex: 1 1 auto; overflow: auto"></div>
208
257
  </div>
209
258
 
@@ -1,6 +1,7 @@
1
1
  import SchemaFieldFilter from "./component/schema-field-filter.js";
2
2
  import SchemaCodeDisplay from "./component/schema-code-display.js";
3
3
  import RouteCodeDisplay from "./component/route-code-display.js";
4
+ import RenderGraph from "./component/render-graph.js";
4
5
  import { GraphUI } from "./graph-ui.js";
5
6
  const { createApp, reactive, onMounted, watch, ref } = window.Vue;
6
7
 
@@ -25,16 +26,23 @@ const app = createApp({
25
26
  rawTags: [], // [{ name, routes: [{ id, name }] }]
26
27
  rawSchemas: [], // [{ name, fullname }]
27
28
  rawSchemasFull: [], // full objects with source_code & fields
28
- initializing: true,
29
+ initializing: true,
29
30
  });
30
31
  const showDetail = ref(false);
31
32
  const showSchemaFieldFilter = ref(false);
32
- const showSchemaCode = ref(false);
33
- const showRouteCode = ref(false);
33
+ const showSchemaCode = ref(false);
34
+ const showRouteCode = ref(false);
35
+ // Dump/Import dialogs and rendered graph dialog
36
+ const showDumpDialog = ref(false);
37
+ const dumpJson = ref("");
38
+ const showImportDialog = ref(false);
39
+ const importJsonText = ref("");
40
+ const showRenderGraph = ref(false);
41
+ const renderCoreData = ref(null);
34
42
  const schemaName = ref(""); // used by detail dialog
35
43
  const schemaFieldFilterSchema = ref(null); // external schemaName for schema-field-filter
36
- const schemaCodeName = ref("");
37
- const routeCodeId = ref("");
44
+ const schemaCodeName = ref("");
45
+ const routeCodeId = ref("");
38
46
  function openDetail() {
39
47
  showDetail.value = true;
40
48
  }
@@ -94,10 +102,13 @@ const app = createApp({
94
102
  name: s.name,
95
103
  fullname: s.fullname,
96
104
  }));
97
- state.routeItems = data.tags.map((t) => t.routes).flat().reduce((acc, r) => {
98
- acc[r.id] = r;
99
- return acc;
100
- }, {});
105
+ state.routeItems = data.tags
106
+ .map((t) => t.routes)
107
+ .flat()
108
+ .reduce((acc, r) => {
109
+ acc[r.id] = r;
110
+ return acc;
111
+ }, {});
101
112
 
102
113
  state.tagOptions = state.rawTags.map((t) => t.name);
103
114
  state.schemaOptions = state.rawSchemas.map((s) => ({
@@ -161,6 +172,62 @@ const app = createApp({
161
172
  }
162
173
  }
163
174
 
175
+ async function onDumpData() {
176
+ try {
177
+ const payload = {
178
+ tags: state.tag ? [state.tag] : null,
179
+ schema_name: state.schemaFullname || null,
180
+ route_name: state.routeId || null,
181
+ show_fields: state.showFields,
182
+ };
183
+ const res = await fetch("/dot-core-data", {
184
+ method: "POST",
185
+ headers: { "Content-Type": "application/json" },
186
+ body: JSON.stringify(payload),
187
+ });
188
+ const json = await res.json();
189
+ dumpJson.value = JSON.stringify(json, null, 2);
190
+ showDumpDialog.value = true;
191
+ } catch (e) {
192
+ console.error("Dump data failed", e);
193
+ }
194
+ }
195
+
196
+ async function copyDumpJson() {
197
+ try {
198
+ await navigator.clipboard.writeText(dumpJson.value || "");
199
+ if (window.Quasar?.Notify) {
200
+ window.Quasar.Notify.create({ type: "positive", message: "Copied" });
201
+ }
202
+ } catch (e) {
203
+ console.error("Copy failed", e);
204
+ }
205
+ }
206
+
207
+ function openImportDialog() {
208
+ importJsonText.value = "";
209
+ showImportDialog.value = true;
210
+ }
211
+
212
+ async function onImportConfirm() {
213
+ let payloadObj = null;
214
+ try {
215
+ payloadObj = JSON.parse(importJsonText.value || "{}");
216
+ } catch (e) {
217
+ if (window.Quasar?.Notify) {
218
+ window.Quasar.Notify.create({
219
+ type: "negative",
220
+ message: "Invalid JSON",
221
+ });
222
+ }
223
+ return;
224
+ }
225
+ // Move the request into RenderGraph component: pass the parsed object and let the component call /dot-render-core-data
226
+ renderCoreData.value = payloadObj;
227
+ showRenderGraph.value = true;
228
+ showImportDialog.value = false;
229
+ }
230
+
164
231
  function showDialog() {
165
232
  schemaFieldFilterSchema.value = null;
166
233
  showSchemaFieldFilter.value = true;
@@ -203,6 +270,18 @@ const app = createApp({
203
270
  showRouteCode,
204
271
  schemaCodeName,
205
272
  routeCodeId,
273
+ // dump/import
274
+ showDumpDialog,
275
+ dumpJson,
276
+ copyDumpJson,
277
+ onDumpData,
278
+ showImportDialog,
279
+ importJsonText,
280
+ openImportDialog,
281
+ onImportConfirm,
282
+ // render graph dialog
283
+ showRenderGraph,
284
+ renderCoreData,
206
285
  };
207
286
  },
208
287
  });
@@ -210,4 +289,5 @@ app.use(window.Quasar);
210
289
  app.component("schema-field-filter", SchemaFieldFilter);
211
290
  app.component("schema-code-display", SchemaCodeDisplay);
212
291
  app.component("route-code-display", RouteCodeDisplay);
292
+ app.component("render-graph", RenderGraph);
213
293
  app.mount("#q-app");
@@ -48,10 +48,12 @@ class PageSprint(serv.Sprint):
48
48
  class PageOverall(BaseModel):
49
49
  sprints: list[PageSprint]
50
50
 
51
+ class PageOverallWrap(PageOverall):
52
+ content: str
51
53
 
52
- @app.get("/page_overall", tags=['for-page'], response_model=PageOverall)
54
+ @app.get("/page_overall", tags=['for-page'], response_model=PageOverallWrap)
53
55
  async def get_page_info():
54
- page_overall = PageOverall(sprints=[]) # focus on schema only
56
+ page_overall = PageOverallWrap(content="Page Overall Content", sprints=[]) # focus on schema only
55
57
  return await Resolver().resolve(page_overall)
56
58
 
57
59
 
@@ -1,4 +1,4 @@
1
- from fastapi_voyager.graph import Analytics
1
+ from fastapi_voyager.voyager import Voyager
2
2
  from pydantic import BaseModel
3
3
  from pydantic_resolve import ensure_subset
4
4
 
@@ -1,4 +1,4 @@
1
- from fastapi_voyager.graph import Analytics
1
+ from fastapi_voyager.voyager import Voyager
2
2
  from pydantic import BaseModel
3
3
  from fastapi import FastAPI
4
4
  from typing import Optional
@@ -30,7 +30,7 @@ def test_analysis():
30
30
  def home2():
31
31
  return None
32
32
 
33
- analytics = Analytics()
33
+ analytics = Voyager()
34
34
  analytics.analysis(app)
35
35
  assert len(analytics.nodes) == 3
36
36
  assert len(analytics.links) == 6
@@ -30,7 +30,7 @@ def test_build_module_tree_basic():
30
30
 
31
31
  # Assert: top-level modules
32
32
  names = sorted(m.name for m in top_modules)
33
- assert names == ["pkg", "x"]
33
+ assert names == ["pkg", "x.y.z"]
34
34
 
35
35
  # pkg level
36
36
  pkg = _find_top(top_modules, "pkg")
@@ -87,25 +87,21 @@ def test_collapse_single_child_empty_modules():
87
87
  _sn("Peer", "a.b.x", "Peer"),
88
88
  ]
89
89
  top_modules = build_module_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:
93
93
  # a
94
94
  # └── b
95
95
  # ├── c.d (holds Deep)
96
96
  # └── x (holds Peer)
97
- a = _find_top(top_modules, "a")
97
+ a = _find_top(top_modules, "a.b")
98
98
  assert a is not None
99
99
  assert a.schema_nodes == []
100
100
  # b remains as child of a
101
- b = _find_child(a, "b")
101
+ b = _find_child(a, "c.d")
102
102
  assert b is not None
103
- assert b.schema_nodes == []
103
+ assert [sn.name for sn in b.schema_nodes] == ['Deep']
104
104
  # collapsed node under b is named "c.d"
105
- cd = _find_child(b, "c.d")
106
- assert cd is not None
107
- assert [sn.name for sn in cd.schema_nodes] == ["Deep"]
108
- # sibling x remains
109
- x = _find_child(b, "x")
105
+ x = _find_child(a, "x")
110
106
  assert x is not None
111
107
  assert [sn.name for sn in x.schema_nodes] == ["Peer"]
File without changes
File without changes