fastapi-voyager 0.4.4__tar.gz → 0.5.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 (45) hide show
  1. fastapi_voyager-0.5.1/.python-version +1 -0
  2. {fastapi_voyager-0.4.4 → fastapi_voyager-0.5.1}/PKG-INFO +11 -4
  3. {fastapi_voyager-0.4.4 → fastapi_voyager-0.5.1}/README.md +10 -3
  4. {fastapi_voyager-0.4.4 → fastapi_voyager-0.5.1}/src/fastapi_voyager/cli.py +5 -5
  5. {fastapi_voyager-0.4.4 → fastapi_voyager-0.5.1}/src/fastapi_voyager/module.py +20 -0
  6. fastapi_voyager-0.5.1/src/fastapi_voyager/render.py +163 -0
  7. {fastapi_voyager-0.4.4 → fastapi_voyager-0.5.1}/src/fastapi_voyager/server.py +32 -12
  8. {fastapi_voyager-0.4.4 → fastapi_voyager-0.5.1}/src/fastapi_voyager/type.py +20 -5
  9. {fastapi_voyager-0.4.4 → fastapi_voyager-0.5.1}/src/fastapi_voyager/version.py +1 -1
  10. fastapi_voyager-0.4.4/src/fastapi_voyager/graph.py → fastapi_voyager-0.5.1/src/fastapi_voyager/voyager.py +35 -176
  11. fastapi_voyager-0.5.1/src/fastapi_voyager/web/component/render-graph.js +59 -0
  12. {fastapi_voyager-0.4.4 → fastapi_voyager-0.5.1}/src/fastapi_voyager/web/index.html +51 -2
  13. {fastapi_voyager-0.4.4 → fastapi_voyager-0.5.1}/src/fastapi_voyager/web/vue-main.js +98 -9
  14. {fastapi_voyager-0.4.4 → fastapi_voyager-0.5.1}/tests/service/schema.py +1 -1
  15. {fastapi_voyager-0.4.4 → fastapi_voyager-0.5.1}/tests/test_analysis.py +2 -2
  16. {fastapi_voyager-0.4.4 → fastapi_voyager-0.5.1}/tests/test_module.py +33 -11
  17. {fastapi_voyager-0.4.4 → fastapi_voyager-0.5.1}/.gitignore +0 -0
  18. {fastapi_voyager-0.4.4 → fastapi_voyager-0.5.1}/LICENSE +0 -0
  19. {fastapi_voyager-0.4.4 → fastapi_voyager-0.5.1}/pyproject.toml +0 -0
  20. {fastapi_voyager-0.4.4 → fastapi_voyager-0.5.1}/src/fastapi_voyager/__init__.py +0 -0
  21. {fastapi_voyager-0.4.4 → fastapi_voyager-0.5.1}/src/fastapi_voyager/filter.py +0 -0
  22. {fastapi_voyager-0.4.4 → fastapi_voyager-0.5.1}/src/fastapi_voyager/type_helper.py +0 -0
  23. {fastapi_voyager-0.4.4 → fastapi_voyager-0.5.1}/src/fastapi_voyager/web/component/route-code-display.js +0 -0
  24. {fastapi_voyager-0.4.4 → fastapi_voyager-0.5.1}/src/fastapi_voyager/web/component/schema-code-display.js +0 -0
  25. {fastapi_voyager-0.4.4 → fastapi_voyager-0.5.1}/src/fastapi_voyager/web/component/schema-field-filter.js +0 -0
  26. {fastapi_voyager-0.4.4 → fastapi_voyager-0.5.1}/src/fastapi_voyager/web/graph-ui.js +0 -0
  27. {fastapi_voyager-0.4.4 → fastapi_voyager-0.5.1}/src/fastapi_voyager/web/graphviz.svg.css +0 -0
  28. {fastapi_voyager-0.4.4 → fastapi_voyager-0.5.1}/src/fastapi_voyager/web/graphviz.svg.js +0 -0
  29. {fastapi_voyager-0.4.4 → fastapi_voyager-0.5.1}/src/fastapi_voyager/web/icon/android-chrome-192x192.png +0 -0
  30. {fastapi_voyager-0.4.4 → fastapi_voyager-0.5.1}/src/fastapi_voyager/web/icon/android-chrome-512x512.png +0 -0
  31. {fastapi_voyager-0.4.4 → fastapi_voyager-0.5.1}/src/fastapi_voyager/web/icon/apple-touch-icon.png +0 -0
  32. {fastapi_voyager-0.4.4 → fastapi_voyager-0.5.1}/src/fastapi_voyager/web/icon/favicon-16x16.png +0 -0
  33. {fastapi_voyager-0.4.4 → fastapi_voyager-0.5.1}/src/fastapi_voyager/web/icon/favicon-32x32.png +0 -0
  34. {fastapi_voyager-0.4.4 → fastapi_voyager-0.5.1}/src/fastapi_voyager/web/icon/favicon.ico +0 -0
  35. {fastapi_voyager-0.4.4 → fastapi_voyager-0.5.1}/src/fastapi_voyager/web/icon/site.webmanifest +0 -0
  36. {fastapi_voyager-0.4.4 → fastapi_voyager-0.5.1}/src/fastapi_voyager/web/quasar.min.css +0 -0
  37. {fastapi_voyager-0.4.4 → fastapi_voyager-0.5.1}/src/fastapi_voyager/web/quasar.min.js +0 -0
  38. {fastapi_voyager-0.4.4 → fastapi_voyager-0.5.1}/tests/__init__.py +0 -0
  39. {fastapi_voyager-0.4.4 → fastapi_voyager-0.5.1}/tests/demo.py +0 -0
  40. {fastapi_voyager-0.4.4 → fastapi_voyager-0.5.1}/tests/demo_anno.py +0 -0
  41. {fastapi_voyager-0.4.4 → fastapi_voyager-0.5.1}/tests/service/__init__.py +0 -0
  42. {fastapi_voyager-0.4.4 → fastapi_voyager-0.5.1}/tests/test_import.py +0 -0
  43. {fastapi_voyager-0.4.4 → fastapi_voyager-0.5.1}/tests/test_type_helper.py +0 -0
  44. {fastapi_voyager-0.4.4 → fastapi_voyager-0.5.1}/uv.lock +0 -0
  45. {fastapi_voyager-0.4.4 → fastapi_voyager-0.5.1}/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.4
3
+ Version: 0.5.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
@@ -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:
@@ -41,4 +41,24 @@ def build_module_tree(schema_nodes: list[SchemaNode]) -> list[ModuleNode]:
41
41
  result = list(top_modules.values())
42
42
  if root_level_nodes:
43
43
  result.append(ModuleNode(name="__root__", fullname="__root__", schema_nodes=root_level_nodes, modules=[]))
44
+
45
+ # Collapse pass: if a module has exactly one child module and no schema_nodes,
46
+ # merge it upward (A + B -> A.B). Repeat until fixed point.
47
+ def collapse(node: ModuleNode):
48
+ # Collapse chains at current node
49
+ while len(node.modules) == 1 and len(node.schema_nodes) == 0:
50
+ child = node.modules[0]
51
+ # Merge child's identity into current node
52
+ node.name = f"{node.name}.{child.name}"
53
+ # Prefer child's fullname which already reflects full path
54
+ node.fullname = child.fullname
55
+ node.schema_nodes = child.schema_nodes
56
+ node.modules = child.modules
57
+ # Recurse into children
58
+ for m in node.modules:
59
+ collapse(m)
60
+
61
+ for top in result:
62
+ collapse(top)
63
+
44
64
  return result
@@ -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.4"
2
+ __version__ = "0.5.1"
@@ -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
@@ -186,7 +183,7 @@ class Analytics:
186
183
  self.add_to_node_set(schema)
187
184
 
188
185
  # handle schema inside ensure_subset(schema)
189
- if subset_reference := getattr(schema, ENSURE_SUBSET_REFERENCE, None):
186
+ if subset_reference := getattr(schema, const.ENSURE_SUBSET_REFERENCE, None):
190
187
  if is_inheritance_of_pydantic_base(subset_reference):
191
188
 
192
189
  self.add_to_node_set(subset_reference)
@@ -223,98 +220,34 @@ class Analytics:
223
220
  source_origin=full_class_name(schema),
224
221
  target=self.generate_node_head(full_class_name(anno)),
225
222
  target_origin=full_class_name(anno),
226
- type='internal'):
223
+ type='schema'):
227
224
  self.analysis_schemas(anno)
228
225
 
229
226
 
230
227
  def generate_node_head(self, link_name: str):
231
228
  return f'{link_name}::{PK}'
232
229
 
230
+ def dump_core_data(self):
231
+ _tags, _routes, _nodes, _links = filter_graph(
232
+ schema=self.schema,
233
+ schema_field=self.schema_field,
234
+ tags=self.tags,
235
+ routes=self.routes,
236
+ nodes=self.nodes,
237
+ links=self.links,
238
+ node_set=self.node_set,
239
+ )
240
+ return CoreData(
241
+ tags=_tags,
242
+ routes=_routes,
243
+ nodes=_nodes,
244
+ links=_links,
245
+ show_fields=self.show_fields,
246
+ module_color=self.module_color,
247
+ schema=self.schema
248
+ )
233
249
 
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
-
250
+ def render_dot(self):
318
251
  _tags, _routes, _nodes, _links = filter_graph(
319
252
  schema=self.schema,
320
253
  schema_field=self.schema_field,
@@ -324,79 +257,5 @@ class Analytics:
324
257
  links=self.links,
325
258
  node_set=self.node_set,
326
259
  )
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
260
+ renderer = Renderer(show_fields=self.show_fields, module_color=self.module_color, schema=self.schema)
261
+ return renderer.render_dot(_tags, _routes, _nodes, _links)
@@ -0,0 +1,59 @@
1
+ import { GraphUI } from "../graph-ui.js";
2
+ const { defineComponent, ref, onMounted, watch, nextTick } = window.Vue;
3
+
4
+ // Simple dialog-embeddable component that renders a DOT graph.
5
+ // Props:
6
+ // - dot: String (required) the DOT source to render
7
+ // Emits:
8
+ // - close: when the close button is clicked
9
+ export default defineComponent({
10
+ name: "RenderGraph",
11
+ props: {
12
+ dot: { type: String, required: true },
13
+ },
14
+ emits: ["close"],
15
+ setup(props, { emit }) {
16
+ const containerId = `graph-render-${Math.random().toString(36).slice(2, 9)}`;
17
+ const hasRendered = ref(false);
18
+ let graphInstance = null;
19
+
20
+ async function renderDot() {
21
+ if (!props.dot) return;
22
+ await nextTick();
23
+ if (!graphInstance) {
24
+ graphInstance = new GraphUI(`#${containerId}`);
25
+ }
26
+ await graphInstance.render(props.dot);
27
+ hasRendered.value = true;
28
+ }
29
+
30
+ onMounted(async () => {
31
+ await renderDot();
32
+ });
33
+
34
+ watch(
35
+ () => props.dot,
36
+ async () => {
37
+ await renderDot();
38
+ }
39
+ );
40
+
41
+ function close() {
42
+ emit("close");
43
+ }
44
+
45
+ return { containerId, close, hasRendered };
46
+ },
47
+ template: `
48
+ <div style="height:100%; position:relative; background:#fff;">
49
+ <q-btn
50
+ flat dense round icon="close"
51
+ aria-label="Close"
52
+ @click="close"
53
+ style="position:absolute; top:6px; right:6px; z-index:11; background:rgba(255,255,255,0.85);"
54
+ />
55
+ <div :id="containerId" style="width:100%; height:100%; overflow:auto; background:#fafafa"></div>
56
+ </div>
57
+ `,
58
+ });
59
+
@@ -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 :dot="renderDotString" @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 renderDotString = ref("");
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,71 @@ 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
+ try {
226
+ const res = await fetch("/dot-render-core-data", {
227
+ method: "POST",
228
+ headers: { "Content-Type": "application/json" },
229
+ body: JSON.stringify(payloadObj),
230
+ });
231
+ const dotText = await res.text();
232
+ renderDotString.value = dotText;
233
+ showRenderGraph.value = true;
234
+ showImportDialog.value = false;
235
+ } catch (e) {
236
+ console.error("Import render failed", e);
237
+ }
238
+ }
239
+
164
240
  function showDialog() {
165
241
  schemaFieldFilterSchema.value = null;
166
242
  showSchemaFieldFilter.value = true;
@@ -203,6 +279,18 @@ const app = createApp({
203
279
  showRouteCode,
204
280
  schemaCodeName,
205
281
  routeCodeId,
282
+ // dump/import
283
+ showDumpDialog,
284
+ dumpJson,
285
+ copyDumpJson,
286
+ onDumpData,
287
+ showImportDialog,
288
+ importJsonText,
289
+ openImportDialog,
290
+ onImportConfirm,
291
+ // render graph dialog
292
+ showRenderGraph,
293
+ renderDotString,
206
294
  };
207
295
  },
208
296
  });
@@ -210,4 +298,5 @@ app.use(window.Quasar);
210
298
  app.component("schema-field-filter", SchemaFieldFilter);
211
299
  app.component("schema-code-display", SchemaCodeDisplay);
212
300
  app.component("route-code-display", RouteCodeDisplay);
301
+ app.component("render-graph", RenderGraph);
213
302
  app.mount("#q-app");
@@ -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")
@@ -50,16 +50,11 @@ def test_build_module_tree_basic():
50
50
  assert [sn.name for sn in other.schema_nodes] == ["C"]
51
51
  assert other.modules == []
52
52
 
53
- # x.y.z level
54
- x = _find_top(top_modules, "x")
53
+ # x.y.z chain should collapse to a single module named "x.y.z"
54
+ x = _find_top(top_modules, "x.y.z")
55
55
  assert x is not None
56
- assert x.schema_nodes == []
57
- y = _find_child(x, "y")
58
- assert y is not None
59
- z = _find_child(y, "z")
60
- assert z is not None
61
- assert [sn.name for sn in z.schema_nodes] == ["D"]
62
- assert z.modules == []
56
+ assert [sn.name for sn in x.schema_nodes] == ["D"]
57
+ assert x.modules == []
63
58
 
64
59
 
65
60
  def test_build_module_tree_empty_input():
@@ -82,4 +77,31 @@ def test_build_module_tree_root_level_nodes():
82
77
  assert root is not None
83
78
  assert sorted(sn.name for sn in root.schema_nodes) == ["Root1", "Root2"]
84
79
  pkg = _find_top(top_modules, "pkg")
85
- assert pkg is not None and [sn.name for sn in pkg.schema_nodes] == ["PkgA"]
80
+ assert pkg is not None and [sn.name for sn in pkg.schema_nodes] == ["PkgA"]
81
+
82
+
83
+ def test_collapse_single_child_empty_modules():
84
+ # Construct a deeper chain with empty intermediate modules that should collapse
85
+ schema_nodes = [
86
+ _sn("Deep", "a.b.c.d", "Deep"),
87
+ _sn("Peer", "a.b.x", "Peer"),
88
+ ]
89
+ top_modules = build_module_tree(schema_nodes)
90
+ print(top_modules)
91
+ # 'a' should have one child path 'b', but due to branching at x, only a.b collapses into a.b
92
+ # and below it, 'c.d' should collapse to 'c.d'. Final structure:
93
+ # a
94
+ # └── b
95
+ # ├── c.d (holds Deep)
96
+ # └── x (holds Peer)
97
+ a = _find_top(top_modules, "a.b")
98
+ assert a is not None
99
+ assert a.schema_nodes == []
100
+ # b remains as child of a
101
+ b = _find_child(a, "c.d")
102
+ assert b is not None
103
+ assert [sn.name for sn in b.schema_nodes] == ['Deep']
104
+ # collapsed node under b is named "c.d"
105
+ x = _find_child(a, "x")
106
+ assert x is not None
107
+ assert [sn.name for sn in x.schema_nodes] == ["Peer"]
File without changes
File without changes