fastapi-voyager 0.4.1__py3-none-any.whl
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.
- fastapi_voyager/__init__.py +5 -0
- fastapi_voyager/cli.py +289 -0
- fastapi_voyager/filter.py +105 -0
- fastapi_voyager/graph.py +396 -0
- fastapi_voyager/module.py +44 -0
- fastapi_voyager/server.py +107 -0
- fastapi_voyager/type.py +48 -0
- fastapi_voyager/type_helper.py +232 -0
- fastapi_voyager/version.py +2 -0
- fastapi_voyager/web/component/route-code-display.js +73 -0
- fastapi_voyager/web/component/schema-code-display.js +152 -0
- fastapi_voyager/web/component/schema-field-filter.js +189 -0
- fastapi_voyager/web/graph-ui.js +137 -0
- fastapi_voyager/web/graphviz.svg.css +42 -0
- fastapi_voyager/web/graphviz.svg.js +580 -0
- fastapi_voyager/web/index.html +224 -0
- fastapi_voyager/web/quasar.min.css +1 -0
- fastapi_voyager/web/quasar.min.js +127 -0
- fastapi_voyager/web/vue-main.js +213 -0
- fastapi_voyager-0.4.1.dist-info/METADATA +175 -0
- fastapi_voyager-0.4.1.dist-info/RECORD +24 -0
- fastapi_voyager-0.4.1.dist-info/WHEEL +4 -0
- fastapi_voyager-0.4.1.dist-info/entry_points.txt +2 -0
- fastapi_voyager-0.4.1.dist-info/licenses/LICENSE +21 -0
fastapi_voyager/cli.py
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
"""Command line interface for fastapi-voyager."""
|
|
2
|
+
import argparse
|
|
3
|
+
import sys
|
|
4
|
+
import importlib.util
|
|
5
|
+
import importlib
|
|
6
|
+
import os
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
from fastapi import FastAPI
|
|
10
|
+
from fastapi_voyager.graph import Analytics
|
|
11
|
+
from fastapi_voyager.version import __version__
|
|
12
|
+
from fastapi_voyager import server as viz_server
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def load_fastapi_app_from_file(module_path: str, app_name: str = "app") -> Optional[FastAPI]:
|
|
16
|
+
"""Load FastAPI app from a Python module file."""
|
|
17
|
+
try:
|
|
18
|
+
# Convert relative path to absolute path
|
|
19
|
+
if not os.path.isabs(module_path):
|
|
20
|
+
module_path = os.path.abspath(module_path)
|
|
21
|
+
|
|
22
|
+
# Load the module
|
|
23
|
+
spec = importlib.util.spec_from_file_location("app_module", module_path)
|
|
24
|
+
if spec is None or spec.loader is None:
|
|
25
|
+
print(f"Error: Could not load module from {module_path}")
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
module = importlib.util.module_from_spec(spec)
|
|
29
|
+
sys.modules["app_module"] = module
|
|
30
|
+
spec.loader.exec_module(module)
|
|
31
|
+
|
|
32
|
+
# Get the FastAPI app instance
|
|
33
|
+
if hasattr(module, app_name):
|
|
34
|
+
app = getattr(module, app_name)
|
|
35
|
+
if isinstance(app, FastAPI):
|
|
36
|
+
return app
|
|
37
|
+
else:
|
|
38
|
+
print(f"Error: '{app_name}' is not a FastAPI instance")
|
|
39
|
+
return None
|
|
40
|
+
else:
|
|
41
|
+
print(f"Error: No attribute '{app_name}' found in the module")
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
except Exception as e:
|
|
45
|
+
print(f"Error loading FastAPI app: {e}")
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def load_fastapi_app_from_module(module_name: str, app_name: str = "app") -> Optional[FastAPI]:
|
|
50
|
+
"""Load FastAPI app from a Python module name."""
|
|
51
|
+
try:
|
|
52
|
+
# 临时将当前工作目录添加到 Python 路径中
|
|
53
|
+
current_dir = os.getcwd()
|
|
54
|
+
if current_dir not in sys.path:
|
|
55
|
+
sys.path.insert(0, current_dir)
|
|
56
|
+
path_added = True
|
|
57
|
+
else:
|
|
58
|
+
path_added = False
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
# Import the module by name
|
|
62
|
+
module = importlib.import_module(module_name)
|
|
63
|
+
|
|
64
|
+
# Get the FastAPI app instance
|
|
65
|
+
if hasattr(module, app_name):
|
|
66
|
+
app = getattr(module, app_name)
|
|
67
|
+
if isinstance(app, FastAPI):
|
|
68
|
+
return app
|
|
69
|
+
else:
|
|
70
|
+
print(f"Error: '{app_name}' is not a FastAPI instance")
|
|
71
|
+
return None
|
|
72
|
+
else:
|
|
73
|
+
print(f"Error: No attribute '{app_name}' found in module '{module_name}'")
|
|
74
|
+
return None
|
|
75
|
+
finally:
|
|
76
|
+
# 清理:如果我们添加了路径,则移除它
|
|
77
|
+
if path_added and current_dir in sys.path:
|
|
78
|
+
sys.path.remove(current_dir)
|
|
79
|
+
|
|
80
|
+
except ImportError as e:
|
|
81
|
+
print(f"Error: Could not import module '{module_name}': {e}")
|
|
82
|
+
return None
|
|
83
|
+
except Exception as e:
|
|
84
|
+
print(f"Error loading FastAPI app from module '{module_name}': {e}")
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def generate_visualization(
|
|
89
|
+
app: FastAPI,
|
|
90
|
+
output_file: str = "router_viz.dot", tags: list[str] | None = None,
|
|
91
|
+
schema: str | None = None,
|
|
92
|
+
show_fields: bool = False,
|
|
93
|
+
module_color: dict[str, str] | None = None,
|
|
94
|
+
route_name: str | None = None,
|
|
95
|
+
):
|
|
96
|
+
|
|
97
|
+
"""Generate DOT file for FastAPI router visualization."""
|
|
98
|
+
analytics = Analytics(
|
|
99
|
+
include_tags=tags,
|
|
100
|
+
schema=schema,
|
|
101
|
+
show_fields=show_fields,
|
|
102
|
+
module_color=module_color,
|
|
103
|
+
route_name=route_name,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
analytics.analysis(app)
|
|
107
|
+
|
|
108
|
+
dot_content = analytics.generate_dot()
|
|
109
|
+
|
|
110
|
+
# Optionally write to file
|
|
111
|
+
with open(output_file, 'w', encoding='utf-8') as f:
|
|
112
|
+
f.write(dot_content)
|
|
113
|
+
print(f"DOT file generated: {output_file}")
|
|
114
|
+
print("To render the graph, use: dot -Tpng router_viz.dot -o router_viz.png")
|
|
115
|
+
print("Or view online: https://dreampuf.github.io/GraphvizOnline/")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def main():
|
|
119
|
+
"""Main CLI entry point."""
|
|
120
|
+
parser = argparse.ArgumentParser(
|
|
121
|
+
description="Visualize FastAPI application's routing tree and dependencies",
|
|
122
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
123
|
+
epilog="""
|
|
124
|
+
Examples:
|
|
125
|
+
voyager app.py # Load 'app' from app.py
|
|
126
|
+
voyager -m tests.demo # Load 'app' from demo module
|
|
127
|
+
voyager -m tests.demo --app=app # Load 'app' from tests.demo
|
|
128
|
+
voyager -m tests.demo --schema=NodeA # [str] filter nodes by schema name
|
|
129
|
+
voyager -m tests.demo --tags=page restful # list[str] filter nodes route's tags
|
|
130
|
+
voyager -m tests.demo --module_color=tests.demo:red --module_color=tests.service:yellow # list[str] filter nodes route's tags
|
|
131
|
+
voyager -m tests.demo -o my_graph.dot # Output to my_graph.dot
|
|
132
|
+
voyager -m tests.demo --server # start a local server to preview
|
|
133
|
+
voyager -m tests.demo --server --port=8001 # start a local server to preview
|
|
134
|
+
"""
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
# Create mutually exclusive group for module loading options
|
|
138
|
+
group = parser.add_mutually_exclusive_group(required=False)
|
|
139
|
+
group.add_argument(
|
|
140
|
+
"module",
|
|
141
|
+
nargs="?",
|
|
142
|
+
help="Python file containing the FastAPI application"
|
|
143
|
+
)
|
|
144
|
+
group.add_argument(
|
|
145
|
+
"-m", "--module",
|
|
146
|
+
dest="module_name",
|
|
147
|
+
help="Python module name containing the FastAPI application (like python -m)"
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
parser.add_argument(
|
|
151
|
+
"--app", "-a",
|
|
152
|
+
default="app",
|
|
153
|
+
help="Name of the FastAPI app variable (default: app)"
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
parser.add_argument(
|
|
157
|
+
"--output", "-o",
|
|
158
|
+
default="router_viz.dot",
|
|
159
|
+
help="Output DOT file name (default: router_viz.dot)"
|
|
160
|
+
)
|
|
161
|
+
parser.add_argument(
|
|
162
|
+
"--server",
|
|
163
|
+
action="store_true",
|
|
164
|
+
help="Start a local server to preview the generated DOT graph"
|
|
165
|
+
)
|
|
166
|
+
parser.add_argument(
|
|
167
|
+
"--port",
|
|
168
|
+
type=int,
|
|
169
|
+
default=8000,
|
|
170
|
+
help="Port for the preview server when --server is used (default: 8000)"
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
parser.add_argument(
|
|
174
|
+
"--version", "-v",
|
|
175
|
+
action="version",
|
|
176
|
+
version=f"fastapi-voyager {__version__}"
|
|
177
|
+
)
|
|
178
|
+
parser.add_argument(
|
|
179
|
+
"--tags",
|
|
180
|
+
nargs="+",
|
|
181
|
+
help="Only include routes whose first tag is in the provided list"
|
|
182
|
+
)
|
|
183
|
+
parser.add_argument(
|
|
184
|
+
"--module_color",
|
|
185
|
+
action="append",
|
|
186
|
+
metavar="KEY:VALUE",
|
|
187
|
+
help="Module color mapping as key1:value1 key2:value2 (module name to Graphviz color)"
|
|
188
|
+
)
|
|
189
|
+
# removed service_prefixes option
|
|
190
|
+
parser.add_argument(
|
|
191
|
+
"--schema",
|
|
192
|
+
default=None,
|
|
193
|
+
help="Filter schemas by name"
|
|
194
|
+
)
|
|
195
|
+
parser.add_argument(
|
|
196
|
+
"--show_fields",
|
|
197
|
+
choices=["single", "object", "all"],
|
|
198
|
+
default="object",
|
|
199
|
+
help="Field display mode: single (no fields), object (only object-like fields), all (all fields). Default: object"
|
|
200
|
+
)
|
|
201
|
+
parser.add_argument(
|
|
202
|
+
"--route_name",
|
|
203
|
+
type=str,
|
|
204
|
+
default=None,
|
|
205
|
+
help="Filter by route id (format: <endpoint>_<path with _>)"
|
|
206
|
+
)
|
|
207
|
+
parser.add_argument(
|
|
208
|
+
"--demo",
|
|
209
|
+
action="store_true",
|
|
210
|
+
help="Run built-in demo (equivalent to: --module tests.demo --server --module_color=tests.service:blue --module_color=tests.demo:tomato)"
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
args = parser.parse_args()
|
|
214
|
+
|
|
215
|
+
# Handle demo mode: override module_name and defaults
|
|
216
|
+
if args.demo:
|
|
217
|
+
# Force module loading path
|
|
218
|
+
args.module_name = "tests.demo"
|
|
219
|
+
# Ensure server mode on
|
|
220
|
+
args.server = True
|
|
221
|
+
# Inject default module colors if absent / merge
|
|
222
|
+
demo_defaults = ["tests.service:blue", "tests.demo:tomato"]
|
|
223
|
+
existing = set(args.module_color or [])
|
|
224
|
+
for d in demo_defaults:
|
|
225
|
+
# only add if same key not already provided
|
|
226
|
+
key = d.split(":", 1)[0]
|
|
227
|
+
if not any(mc.startswith(key + ":") for mc in existing):
|
|
228
|
+
args.module_color = (args.module_color or []) + [d]
|
|
229
|
+
|
|
230
|
+
# Validate required target if not demo
|
|
231
|
+
if not args.demo and not (args.module_name or args.module):
|
|
232
|
+
parser.error("You must provide a module file, -m module name, or use --demo")
|
|
233
|
+
|
|
234
|
+
# Load FastAPI app based on the input method (module_name takes precedence)
|
|
235
|
+
if args.module_name:
|
|
236
|
+
app = load_fastapi_app_from_module(args.module_name, args.app)
|
|
237
|
+
else:
|
|
238
|
+
if not os.path.exists(args.module):
|
|
239
|
+
print(f"Error: File '{args.module}' not found")
|
|
240
|
+
sys.exit(1)
|
|
241
|
+
app = load_fastapi_app_from_file(args.module, args.app)
|
|
242
|
+
|
|
243
|
+
if app is None:
|
|
244
|
+
sys.exit(1)
|
|
245
|
+
|
|
246
|
+
# helper: parse KEY:VALUE pairs into dict
|
|
247
|
+
def parse_kv_pairs(pairs: list[str] | None) -> dict[str, str] | None:
|
|
248
|
+
if not pairs:
|
|
249
|
+
return None
|
|
250
|
+
result: dict[str, str] = {}
|
|
251
|
+
for item in pairs:
|
|
252
|
+
if ":" in item:
|
|
253
|
+
k, v = item.split(":", 1)
|
|
254
|
+
k = k.strip()
|
|
255
|
+
v = v.strip()
|
|
256
|
+
if k:
|
|
257
|
+
result[k] = v
|
|
258
|
+
return result or None
|
|
259
|
+
|
|
260
|
+
try:
|
|
261
|
+
module_color = parse_kv_pairs(args.module_color)
|
|
262
|
+
if args.server:
|
|
263
|
+
# Build a preview server which computes DOT via Analytics using closure state
|
|
264
|
+
try:
|
|
265
|
+
import uvicorn
|
|
266
|
+
except ImportError:
|
|
267
|
+
print("Error: uvicorn is required to run the server. Install via 'pip install uvicorn' or 'uv add uvicorn'.")
|
|
268
|
+
sys.exit(1)
|
|
269
|
+
app_server = viz_server.create_app_with_fastapi(app, module_color=module_color)
|
|
270
|
+
print(f"Starting preview server at http://127.0.0.1:{args.port} ... (Ctrl+C to stop)")
|
|
271
|
+
uvicorn.run(app_server, host="127.0.0.1", port=args.port)
|
|
272
|
+
else:
|
|
273
|
+
# Generate and write dot file locally
|
|
274
|
+
generate_visualization(
|
|
275
|
+
app,
|
|
276
|
+
args.output,
|
|
277
|
+
tags=args.tags,
|
|
278
|
+
schema=args.schema,
|
|
279
|
+
show_fields=args.show_fields,
|
|
280
|
+
module_color=module_color,
|
|
281
|
+
route_name=args.route_name,
|
|
282
|
+
)
|
|
283
|
+
except Exception as e:
|
|
284
|
+
print(f"Error generating visualization: {e}")
|
|
285
|
+
sys.exit(1)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
if __name__ == "__main__":
|
|
289
|
+
main()
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from typing import Tuple
|
|
3
|
+
from fastapi_voyager.type import Tag, Route, SchemaNode, Link
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def filter_graph(
|
|
7
|
+
*,
|
|
8
|
+
schema: str | None,
|
|
9
|
+
schema_field: str | None,
|
|
10
|
+
tags: list[Tag],
|
|
11
|
+
routes: list[Route],
|
|
12
|
+
nodes: list[SchemaNode],
|
|
13
|
+
links: list[Link],
|
|
14
|
+
node_set: dict[str, SchemaNode],
|
|
15
|
+
) -> Tuple[list[Tag], list[Route], list[SchemaNode], list[Link]]:
|
|
16
|
+
"""Filter tags, routes, schema nodes and links based on a target schema and optional field.
|
|
17
|
+
|
|
18
|
+
Behaviour summary (mirrors previous Analytics.filter_nodes_and_schemas_based_on_schemas):
|
|
19
|
+
1. If `schema` is None, return inputs unmodified.
|
|
20
|
+
2. Seed with the schema node id (full id match). If not found, return inputs.
|
|
21
|
+
3. If `schema_field` provided, prune parent/subset links so that only those whose *source* schema
|
|
22
|
+
contains that field and whose *target* is already accepted remain, recursively propagating upward.
|
|
23
|
+
4. Perform two traversals on the (possibly pruned) links set:
|
|
24
|
+
- Upstream: reverse walk (collect nodes that point to current frontier) -> brings in children & entry chain.
|
|
25
|
+
- Downstream: forward walk (collect targets from current frontier) -> brings in ancestors.
|
|
26
|
+
5. Keep only objects (tags, routes, nodes, links) whose origin ids are in the collected set.
|
|
27
|
+
"""
|
|
28
|
+
if schema is None:
|
|
29
|
+
return tags, routes, nodes, links
|
|
30
|
+
|
|
31
|
+
seed_node_ids = {n.id for n in nodes if n.id == schema}
|
|
32
|
+
if not seed_node_ids:
|
|
33
|
+
return tags, routes, nodes, links
|
|
34
|
+
|
|
35
|
+
# Step 1: schema_field pruning logic for parent/subset links
|
|
36
|
+
if schema_field:
|
|
37
|
+
current_targets = set(seed_node_ids)
|
|
38
|
+
accepted_targets = set(seed_node_ids)
|
|
39
|
+
accepted_links: list[Link] = []
|
|
40
|
+
parent_subset_links = [lk for lk in links if lk.type in ("parent", "subset")]
|
|
41
|
+
other_links = [lk for lk in links if lk.type not in ("parent", "subset")]
|
|
42
|
+
|
|
43
|
+
while current_targets:
|
|
44
|
+
next_targets: set[str] = set()
|
|
45
|
+
for lk in parent_subset_links:
|
|
46
|
+
if (
|
|
47
|
+
lk.target_origin in current_targets
|
|
48
|
+
and lk.source_origin not in accepted_targets
|
|
49
|
+
and lk.source_origin in node_set
|
|
50
|
+
and lk.target_origin in node_set
|
|
51
|
+
):
|
|
52
|
+
src_node = node_set.get(lk.source_origin)
|
|
53
|
+
if src_node and any(f.name == schema_field for f in src_node.fields):
|
|
54
|
+
accepted_links.append(lk)
|
|
55
|
+
next_targets.add(lk.source_origin)
|
|
56
|
+
accepted_targets.add(lk.source_origin)
|
|
57
|
+
elif lk.target_origin in current_targets and lk.source_origin in accepted_targets:
|
|
58
|
+
src_node = node_set.get(lk.source_origin)
|
|
59
|
+
if src_node and any(f.name == schema_field for f in src_node.fields):
|
|
60
|
+
if lk not in accepted_links:
|
|
61
|
+
accepted_links.append(lk)
|
|
62
|
+
current_targets = next_targets
|
|
63
|
+
filtered_links = other_links + accepted_links
|
|
64
|
+
else:
|
|
65
|
+
filtered_links = links
|
|
66
|
+
|
|
67
|
+
# Step 2: build adjacency maps
|
|
68
|
+
fwd: dict[str, set[str]] = {}
|
|
69
|
+
rev: dict[str, set[str]] = {}
|
|
70
|
+
for lk in filtered_links:
|
|
71
|
+
fwd.setdefault(lk.source_origin, set()).add(lk.target_origin)
|
|
72
|
+
rev.setdefault(lk.target_origin, set()).add(lk.source_origin)
|
|
73
|
+
|
|
74
|
+
# Upstream (reverse) traversal
|
|
75
|
+
upstream: set[str] = set()
|
|
76
|
+
frontier = set(seed_node_ids)
|
|
77
|
+
while frontier:
|
|
78
|
+
new_layer: set[str] = set()
|
|
79
|
+
for nid in frontier:
|
|
80
|
+
for src in rev.get(nid, ()): # src points to nid
|
|
81
|
+
if src not in upstream and src not in seed_node_ids:
|
|
82
|
+
new_layer.add(src)
|
|
83
|
+
upstream.update(new_layer)
|
|
84
|
+
frontier = new_layer
|
|
85
|
+
|
|
86
|
+
# Downstream (forward) traversal
|
|
87
|
+
downstream: set[str] = set()
|
|
88
|
+
frontier = set(seed_node_ids)
|
|
89
|
+
while frontier:
|
|
90
|
+
new_layer: set[str] = set()
|
|
91
|
+
for nid in frontier:
|
|
92
|
+
for tgt in fwd.get(nid, ()): # nid points to tgt
|
|
93
|
+
if tgt not in downstream and tgt not in seed_node_ids:
|
|
94
|
+
new_layer.add(tgt)
|
|
95
|
+
downstream.update(new_layer)
|
|
96
|
+
frontier = new_layer
|
|
97
|
+
|
|
98
|
+
included_ids: set[str] = set(seed_node_ids) | upstream | downstream
|
|
99
|
+
|
|
100
|
+
_nodes = [n for n in nodes if n.id in included_ids]
|
|
101
|
+
_links = [l for l in filtered_links if l.source_origin in included_ids and l.target_origin in included_ids]
|
|
102
|
+
_tags = [t for t in tags if t.id in included_ids]
|
|
103
|
+
_routes = [r for r in routes if r.id in included_ids]
|
|
104
|
+
|
|
105
|
+
return _tags, _routes, _nodes, _links
|