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.
@@ -0,0 +1,5 @@
1
+ """fastapi_voyager
2
+
3
+ Utilities to introspect a FastAPI application and visualize its routing tree.
4
+ """
5
+ from .version import __version__ # noqa: F401
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