progmune-runtime 3.7.9 → 3.7.11

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,269 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Django Framework Structure Extractor — 框架结构扫描(M2)
4
+
5
+ 从 Python AST 提取 Django 结构:
6
+ 1) urlpatterns 解析:url()/path()/re_path() → 视图引用(FBV views.foo /
7
+ CBV Foo.as_view() / include('module.urls') 递归)
8
+ 2) 视图注册表:
9
+ - FBV:函数装饰器(login_required/permission_required/staff_member_required/
10
+ user_passes_test/authentication_decorator 等 *auth* 名装饰器)
11
+ - CBV:基类(APIView/View/generics.*/LoginRequiredMixin/
12
+ PermissionRequiredMixin)、方法(get/post/put/patch/delete)、
13
+ @method_decorator、DRF permission_classes(AllowAny/IsAuthenticated/
14
+ IsAdminUser/其他类名)
15
+ - @api_view 装饰器:methods + permission_classes kwarg
16
+ 3) 每个 urlpattern → 视图解析(按短名匹配注册表)
17
+
18
+ 输出 JSON:{hasDjango, routes:[{pattern,urlname,view,kind,file}],
19
+ views:{name:{file,decorators,methods,permissionClasses,isDrf,isProtected}},
20
+ filesScanned}
21
+
22
+ 用法:python3 extract_framework_django.py <projectRoot> <outJson>
23
+ """
24
+
25
+ import ast
26
+ import json
27
+ import os
28
+ import re
29
+ import sys
30
+
31
+ SKIP_DIRS = {"tests", "test", "deps", "venv", "env", "node_modules", "vendor",
32
+ ".git", "migrations", "__pycache__", "scripts", "docs",
33
+ "staticfiles", "static"}
34
+
35
+ AUTH_DECORATORS = {
36
+ "login_required", "permission_required", "staff_member_required",
37
+ "user_passes_test", "authentication_decorator", "login_required_decorator",
38
+ "require_http_methods",
39
+ }
40
+
41
+ AUTH_MIXINS = {"LoginRequiredMixin", "PermissionRequiredMixin",
42
+ "UserPassesTestMixin", "StaffMemberRequiredMixin"}
43
+
44
+ DRF_BASES = {"APIView", "GenericAPIView", "RetrieveAPIView", "ListAPIView",
45
+ "CreateAPIView", "UpdateAPIView", "DestroyAPIView",
46
+ "RetrieveUpdateAPIView", "ListCreateAPIView",
47
+ "RetrieveUpdateDestroyAPIView", "ViewSet", "ModelViewSet",
48
+ "GenericViewSet", "ReadOnlyModelViewSet"}
49
+
50
+ MUTATION_METHODS = {"post", "put", "patch", "delete"}
51
+
52
+ VIEW_BASES = DRF_BASES | {"View", "TemplateView", "FormView", "CreateView",
53
+ "UpdateView", "DeleteView", "ListView", "DetailView"}
54
+
55
+ OPEN_PERMISSIONS = {"AllowAny"}
56
+
57
+
58
+ def name_of(node):
59
+ if isinstance(node, ast.Name):
60
+ return node.id
61
+ if isinstance(node, ast.Attribute):
62
+ return node.attr
63
+ return None
64
+
65
+
66
+ def short_view_name(ref):
67
+ """views.auth_lab → auth_lab;Foo.as_view() → Foo"""
68
+ if isinstance(ref, ast.Attribute):
69
+ return ref.attr
70
+ if isinstance(ref, ast.Call) and isinstance(ref.func, ast.Attribute):
71
+ return ref.func.value.attr if isinstance(ref.func.value, ast.Attribute) \
72
+ else name_of(ref.func.value)
73
+ if isinstance(ref, ast.Name):
74
+ return ref.id
75
+ return None
76
+
77
+
78
+ class ViewCollector(ast.NodeVisitor):
79
+ """收集全部函数/类的视图特征(与 urlpatterns 解耦)"""
80
+
81
+ def __init__(self, filepath):
82
+ self.file = filepath
83
+ self.fbvs = {} # name -> {decorators, apiView: [methods]}
84
+ self.classes = {} # name -> {bases, methods, decorators, permissionClasses, isDrf, protected}
85
+
86
+ def visit_FunctionDef(self, node):
87
+ self._scan_function(node, async_=False)
88
+ self.generic_visit(node)
89
+
90
+ def visit_AsyncFunctionDef(self, node):
91
+ self._scan_function(node, async_=True)
92
+ self.generic_visit(node)
93
+
94
+ def _scan_function(self, node, async_):
95
+ decorators = []
96
+ api_view_methods = None
97
+ permission_classes = []
98
+ for dec in node.decorator_list:
99
+ if isinstance(dec, ast.Name):
100
+ decorators.append(dec.id)
101
+ elif isinstance(dec, ast.Call) and isinstance(dec.func, ast.Name):
102
+ decorators.append(dec.func.id)
103
+ if dec.func.id == "api_view":
104
+ if dec.args and isinstance(dec.args[0], (ast.List, ast.Tuple)):
105
+ api_view_methods = [
106
+ e.value.lower() for e in dec.args[0].elts
107
+ if isinstance(e, ast.Constant)
108
+ ]
109
+ for kw in dec.keywords:
110
+ if kw.arg == "permission_classes" and isinstance(kw.value, (ast.List, ast.Tuple)):
111
+ permission_classes = [name_of(e) for e in kw.value.elts if name_of(e)]
112
+ elif isinstance(dec, ast.Call) and isinstance(dec.func, ast.Attribute):
113
+ decorators.append(dec.func.attr) # django.views.decorators.login_required
114
+ self.fbvs[node.name] = {
115
+ "file": self.file,
116
+ "decorators": decorators,
117
+ "apiViewMethods": api_view_methods,
118
+ "permissionClasses": permission_classes,
119
+ }
120
+
121
+ def visit_ClassDef(self, node):
122
+ bases = [name_of(b) for b in node.bases if name_of(b)]
123
+ methods = []
124
+ permission_classes = []
125
+ decorators = []
126
+ for item in node.body:
127
+ if isinstance(item, ast.FunctionDef) and item.name in (
128
+ "get", "post", "put", "patch", "delete", "create",
129
+ "update", "destroy", "list", "retrieve"):
130
+ methods.append(item.name)
131
+ if isinstance(item, ast.Assign):
132
+ for t in item.targets:
133
+ if isinstance(t, ast.Name) and t.id == "permission_classes":
134
+ if isinstance(item.value, (ast.List, ast.Tuple)):
135
+ permission_classes = [name_of(e) for e in item.value.elts if name_of(e)]
136
+ elif isinstance(item.value, ast.Name):
137
+ permission_classes = [item.value.id]
138
+ for dec in node.decorator_list:
139
+ if isinstance(dec, ast.Name):
140
+ decorators.append(dec.id)
141
+ elif isinstance(dec, ast.Call) and isinstance(dec.func, ast.Name):
142
+ decorators.append(dec.func.id)
143
+ elif isinstance(dec, ast.Call) and isinstance(dec.func, ast.Attribute):
144
+ decorators.append(dec.func.attr)
145
+ is_drf = any(b in DRF_BASES for b in bases)
146
+ has_mixin = any(b in AUTH_MIXINS for b in bases)
147
+ self.classes[node.name] = {
148
+ "file": self.file,
149
+ "bases": bases,
150
+ "methods": methods,
151
+ "decorators": decorators,
152
+ "permissionClasses": permission_classes,
153
+ "isDrf": is_drf,
154
+ "protectedByMixin": has_mixin,
155
+ }
156
+
157
+
158
+ class UrlCollector(ast.NodeVisitor):
159
+ """收集 urlpatterns 列表里的视图引用"""
160
+
161
+ def __init__(self, filepath):
162
+ self.file = filepath
163
+ self.routes = []
164
+
165
+ def visit_Assign(self, node):
166
+ for t in node.targets:
167
+ if isinstance(t, ast.Name) and t.id == "urlpatterns" and isinstance(node.value, (ast.List, ast.Tuple)):
168
+ for elt in node.value.elts:
169
+ self._scan_entry(elt)
170
+ self.generic_visit(node)
171
+
172
+ def _scan_entry(self, elt):
173
+ if not isinstance(elt, ast.Call):
174
+ return
175
+ fn = name_of(elt.func) # url / path / re_path
176
+ if fn not in ("url", "path", "re_path"):
177
+ return
178
+ pattern = ""
179
+ if elt.args and isinstance(elt.args[0], ast.Constant):
180
+ pattern = str(elt.args[0].value)
181
+ view_ref = elt.args[1] if len(elt.args) > 1 else None
182
+ if view_ref is None:
183
+ return
184
+ kind = "other"
185
+ view_name = None
186
+ if isinstance(view_ref, ast.Call) and isinstance(view_ref.func, ast.Attribute):
187
+ if view_ref.func.attr == "as_view":
188
+ kind = "cbv"
189
+ view_name = short_view_name(view_ref)
190
+ elif view_ref.func.id == "include" if isinstance(view_ref.func, ast.Name) else False:
191
+ kind = "include"
192
+ if view_name is None:
193
+ if isinstance(view_ref, ast.Name):
194
+ kind = "include" if False else "fbv"
195
+ view_name = view_ref.id
196
+ elif isinstance(view_ref, ast.Attribute):
197
+ kind = "fbv"
198
+ view_name = view_ref.attr
199
+ elif isinstance(view_ref, ast.Call) and isinstance(view_ref.func, ast.Name):
200
+ if view_ref.func.id == "include":
201
+ kind = "include"
202
+ else:
203
+ kind = "fbv"
204
+ view_name = view_ref.func.id
205
+ urlname = ""
206
+ for kw in elt.keywords:
207
+ if kw.arg == "name" and isinstance(kw.value, ast.Constant):
208
+ urlname = str(kw.value)
209
+ self.routes.append({
210
+ "pattern": pattern,
211
+ "urlname": urlname,
212
+ "view": view_name,
213
+ "kind": kind,
214
+ "file": self.file,
215
+ })
216
+
217
+
218
+ def walk_py_files(root):
219
+ for dirpath, dirnames, filenames in os.walk(root):
220
+ dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
221
+ for fn in filenames:
222
+ if fn.endswith(".py") and not fn.startswith("test_"):
223
+ yield os.path.join(dirpath, fn)
224
+
225
+
226
+ def main():
227
+ root, out = sys.argv[1], sys.argv[2]
228
+ views, routes, files_scanned = {}, [], 0
229
+ for fp in walk_py_files(root):
230
+ files_scanned += 1
231
+ try:
232
+ with open(fp, "r", encoding="utf-8", errors="replace") as f:
233
+ tree = ast.parse(f.read(), filename=fp)
234
+ except (SyntaxError, UnicodeDecodeError, OSError):
235
+ continue
236
+ vc, uc = ViewCollector(fp), UrlCollector(fp)
237
+ vc.visit(tree)
238
+ uc.visit(tree)
239
+ for name, info in vc.fbvs.items():
240
+ info["kind"] = "fbv"
241
+ views[name] = info
242
+ for name, info in vc.classes.items():
243
+ info["kind"] = "cbv"
244
+ views[name] = info
245
+ routes.extend(uc.routes)
246
+
247
+ # 保护判定(扫描器只算事实,规则在 TS 检测器侧)
248
+ for name, info in views.items():
249
+ auth_dec = [d for d in info.get("decorators", [])
250
+ if d in AUTH_DECORATORS or "auth" in d.lower() or "login" in d.lower()]
251
+ info["authDecorators"] = auth_dec
252
+ if info["kind"] == "cbv":
253
+ pc = info.get("permissionClasses") or []
254
+ info["openPermission"] = (len(pc) == 0) or all(
255
+ p in OPEN_PERMISSIONS for p in pc
256
+ )
257
+
258
+ result = {
259
+ "hasDjango": bool(routes),
260
+ "routes": routes,
261
+ "views": views,
262
+ "filesScanned": files_scanned,
263
+ }
264
+ with open(out, "w", encoding="utf-8") as f:
265
+ json.dump(result, f, ensure_ascii=False, indent=2)
266
+
267
+
268
+ if __name__ == "__main__":
269
+ main()
@@ -0,0 +1,169 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Flask Framework Structure Extractor — 框架结构扫描(第 5 个框架适配)
4
+
5
+ 从 Python AST 提取 Flask 结构:
6
+ 1) 应用与蓝图:app = Flask(...) / bp = Blueprint(...) / register_blueprint
7
+ 2) 路由:@app.route("/x", methods=[...]) / @bp.route(...) → handler 函数
8
+ 3) 认证信号:
9
+ - handler 装饰器:login_required / permission_required / 自定义 *auth* 名
10
+ - 全局守卫:app.before_request(auth_fn) / bp.before_request(auth_fn),
11
+ auth_fn 名命中认证词表
12
+ 4) 路由方法:methods kwarg(缺省 = GET only)
13
+
14
+ 输出 JSON:{hasFlask, apps, blueprints, routes:[{method,path,handler,file,line,
15
+ authDecorators}], beforeRequestAuth:[names], filesScanned}
16
+
17
+ 用法:python3 extract_framework_flask.py <projectRoot> <outJson>
18
+ """
19
+
20
+ import ast
21
+ import json
22
+ import os
23
+ import sys
24
+
25
+ SKIP_DIRS = {"tests", "test", "deps", "venv", "env", "node_modules", "vendor",
26
+ ".git", "migrations", "__pycache__", "scripts", "docs",
27
+ "staticfiles", "static"}
28
+
29
+ AUTH_WORDS = ("auth", "login", "permission", "token", "credential", "session",
30
+ "user")
31
+
32
+ MUTATION_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
33
+
34
+
35
+ def name_of(node):
36
+ if isinstance(node, ast.Name):
37
+ return node.id
38
+ if isinstance(node, ast.Attribute):
39
+ return node.attr
40
+ return None
41
+
42
+
43
+ def is_auth_like(name):
44
+ if not name:
45
+ return False
46
+ ln = name.lower()
47
+ return any(w in ln for w in AUTH_WORDS)
48
+
49
+
50
+ class FlaskCollector(ast.NodeVisitor):
51
+ def __init__(self, filepath):
52
+ self.file = filepath
53
+ self.apps = []
54
+ self.blueprints = []
55
+ self.routes = []
56
+ self.before_requests = [] # 注册的 before_request 函数名
57
+
58
+ def visit_Assign(self, node):
59
+ if len(node.targets) == 1 and isinstance(node.targets[0], ast.Name):
60
+ v = node.value
61
+ if isinstance(v, ast.Call) and isinstance(v.func, ast.Name):
62
+ if v.func.id == "Flask":
63
+ self.apps.append(node.targets[0].id)
64
+ elif v.func.id == "Blueprint":
65
+ self.blueprints.append(node.targets[0].id)
66
+ self.generic_visit(node)
67
+
68
+ def visit_Expr(self, node):
69
+ # app.before_request(auth_fn) / app.register_blueprint(bp) 是表达式语句
70
+ v = node.value
71
+ if isinstance(v, ast.Call) and isinstance(v.func, ast.Attribute):
72
+ attr = v.func.attr
73
+ if attr == "before_request" and v.args:
74
+ fn_name = name_of(v.args[0])
75
+ if fn_name:
76
+ self.before_requests.append(fn_name)
77
+ if attr == "register_blueprint" and v.args:
78
+ bp = name_of(v.args[0])
79
+ if bp and bp not in self.blueprints:
80
+ self.blueprints.append(bp)
81
+ self.generic_visit(node)
82
+
83
+ def visit_FunctionDef(self, node):
84
+ self._scan(node)
85
+ self.generic_visit(node)
86
+
87
+ def visit_AsyncFunctionDef(self, node):
88
+ self._scan(node)
89
+ self.generic_visit(node)
90
+
91
+ def _scan(self, node):
92
+ auth_decorators = []
93
+ for dec in node.decorator_list:
94
+ if isinstance(dec, ast.Call) and isinstance(dec.func, ast.Attribute):
95
+ # @app.route("/x", methods=["POST"]) / @bp.route(...)
96
+ if dec.func.attr == "route":
97
+ target = name_of(dec.func.value)
98
+ path = dec.args[0].value if dec.args and isinstance(dec.args[0], ast.Constant) else ""
99
+ methods = ["GET"]
100
+ for kw in dec.keywords:
101
+ if kw.arg == "methods" and isinstance(kw.value, (ast.List, ast.Tuple)):
102
+ methods = [
103
+ e.value.upper() for e in kw.value.elts
104
+ if isinstance(e, ast.Constant)
105
+ ]
106
+ self.routes.append({
107
+ "methods": methods,
108
+ "path": path,
109
+ "handler": node.name,
110
+ "file": self.file,
111
+ "line": node.lineno,
112
+ "target": target, # app / bp 变量名
113
+ })
114
+ elif isinstance(dec, ast.Name):
115
+ if is_auth_like(dec.id):
116
+ auth_decorators.append(dec.id)
117
+ elif isinstance(dec, ast.Call) and isinstance(dec.func, ast.Name):
118
+ if is_auth_like(dec.func.id):
119
+ auth_decorators.append(dec.func.id)
120
+ elif isinstance(dec, ast.Call) and isinstance(dec.func, ast.Attribute):
121
+ if is_auth_like(dec.func.attr):
122
+ auth_decorators.append(dec.func.attr)
123
+ # 把装饰器信息挂到该函数名对应的 routes(同函数多装饰器顺序不定)
124
+ for r in self.routes:
125
+ if r["handler"] == node.name and r["file"] == self.file:
126
+ r["authDecorators"] = auth_decorators
127
+
128
+
129
+ def walk_py_files(root):
130
+ for dirpath, dirnames, filenames in os.walk(root):
131
+ dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
132
+ for fn in filenames:
133
+ if fn.endswith(".py") and not fn.startswith("test_"):
134
+ yield os.path.join(dirpath, fn)
135
+
136
+
137
+ def main():
138
+ root, out = sys.argv[1], sys.argv[2]
139
+ apps, blueprints, routes, before_requests, files_scanned = [], [], [], [], 0
140
+ for fp in walk_py_files(root):
141
+ files_scanned += 1
142
+ try:
143
+ with open(fp, "r", encoding="utf-8", errors="replace") as f:
144
+ tree = ast.parse(f.read(), filename=fp)
145
+ except (SyntaxError, UnicodeDecodeError, OSError):
146
+ continue
147
+ collector = FlaskCollector(fp)
148
+ collector.visit(tree)
149
+ apps.extend(collector.apps)
150
+ blueprints.extend(collector.blueprints)
151
+ routes.extend(collector.routes)
152
+ before_requests.extend(collector.before_requests)
153
+
154
+ result = {
155
+ "hasFlask": bool(apps or blueprints or routes),
156
+ "apps": sorted(set(apps)),
157
+ "blueprints": sorted(set(blueprints)),
158
+ "routes": routes,
159
+ "beforeRequestAuth": sorted(set(
160
+ fn for fn in before_requests if is_auth_like(fn)
161
+ )),
162
+ "filesScanned": files_scanned,
163
+ }
164
+ with open(out, "w", encoding="utf-8") as f:
165
+ json.dump(result, f, ensure_ascii=False, indent=2)
166
+
167
+
168
+ if __name__ == "__main__":
169
+ main()
@@ -0,0 +1,237 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ FastAPI Framework Structure Extractor — 框架结构扫描(与 extract_ir.py 解耦)
4
+
5
+ Progmune 框架适配 M1:从 Python AST 提取 FastAPI 结构——
6
+ 路由(@app.get/@router.post 等)、处理器依赖注入(Depends()/Security())、
7
+ 认证方案声明(OAuth2PasswordBearer/HTTPBearer/APIKeyHeader)、全局中间件。
8
+
9
+ 输出 JSON:
10
+ {
11
+ "hasFastAPI": bool,
12
+ "apps": [{"name","file"}],
13
+ "routers": [{"name","file"}],
14
+ "authSchemes": [{"name","type"}],
15
+ "globalAuthMiddleware": [names...],
16
+ "routes": [{
17
+ "method","path","handler","file","line",
18
+ "dependencies": [{"name","via","authLike"}],
19
+ }],
20
+ "filesScanned": int
21
+ }
22
+
23
+ 用法:python3 extract_framework_py.py <projectRoot> <outJson>
24
+ """
25
+
26
+ import ast
27
+ import json
28
+ import os
29
+ import sys
30
+
31
+ # 认证词表(依赖函数名命中即视为 auth-like;与 TS 侧 annotation-suggest 词表同源)
32
+ # 注意:扫描器只做「结构提取」,规则判定(豁免词/方法门控)在 TS 检测器侧。
33
+ AUTH_WORDS = (
34
+ "auth", "login", "token", "user", "credential", "session",
35
+ "bearer", "permission", "current_user", "api_key", "oauth",
36
+ )
37
+
38
+ SKIP_DIRS = {"tests", "test", "deps", "venv", "env", "node_modules", "vendor",
39
+ ".git", "migrations", "__pycache__", "scripts", "docs"}
40
+
41
+ ROUTE_DECORATORS = {"get", "post", "put", "delete", "patch", "head", "options",
42
+ "websocket", "api_route"}
43
+
44
+ SCHEME_CLASSES = {
45
+ "OAuth2PasswordBearer": "OAuth2PasswordBearer",
46
+ "OAuth2AuthorizationCodeBearer": "OAuth2AuthorizationCodeBearer",
47
+ "HTTPBearer": "HTTPBearer",
48
+ "HTTPBasic": "HTTPBasic",
49
+ "HTTPDigest": "HTTPDigest",
50
+ "APIKeyHeader": "APIKeyHeader",
51
+ "APIKeyQuery": "APIKeyQuery",
52
+ "APIKeyCookie": "APIKeyCookie",
53
+ "OpenIdConnect": "OpenIdConnect",
54
+ }
55
+
56
+
57
+ def name_of(node):
58
+ """ast 名字解析(Name/Attribute/Subscript 兼容)"""
59
+ if isinstance(node, ast.Name):
60
+ return node.id
61
+ if isinstance(node, ast.Attribute):
62
+ return node.attr
63
+ return None
64
+
65
+
66
+ def lower_name(name):
67
+ return name.lower() if name else ""
68
+
69
+
70
+ def is_auth_like(name, schemes):
71
+ """依赖名是否为认证依赖:命中认证词表,或直接是声明的认证方案"""
72
+ if not name:
73
+ return False
74
+ if name in schemes:
75
+ return True
76
+ ln = lower_name(name)
77
+ return any(w in ln for w in AUTH_WORDS)
78
+
79
+
80
+ def resolve_dep_target(node):
81
+ """Depends(x) 的 x:Name/Attribute 直取;嵌套 Call(如 get_current_user_authorizer())取内层函数名"""
82
+ if isinstance(node, (ast.Name, ast.Attribute)):
83
+ return name_of(node)
84
+ if isinstance(node, ast.Call):
85
+ return name_of(node.func)
86
+ return None
87
+
88
+
89
+ def iter_dependency_calls(node):
90
+ """从签名节点收集 Depends(...)/Security(...) 调用(含 Annotated[...] 订阅)"""
91
+ found = []
92
+ for child in ast.walk(node):
93
+ # Depends(x) / Security(x)
94
+ if isinstance(child, ast.Call) and isinstance(child.func, ast.Name):
95
+ if child.func.id in ("Depends", "Security") and child.args:
96
+ found.append((resolve_dep_target(child.args[0]), child.func.id))
97
+ # Annotated[X, Depends(y)]
98
+ if isinstance(child, ast.Subscript):
99
+ for item in child.slice.elts if isinstance(child.slice, ast.Tuple) else [child.slice]:
100
+ if isinstance(item, ast.Call) and isinstance(item.func, ast.Name):
101
+ if item.func.id in ("Depends", "Security") and item.args:
102
+ found.append((resolve_dep_target(item.args[0]), item.func.id))
103
+ return found
104
+
105
+
106
+ class Scanner(ast.NodeVisitor):
107
+ def __init__(self, filepath):
108
+ self.file = filepath
109
+ self.apps = []
110
+ self.routers = []
111
+ self.schemes = {}
112
+ self.middlewares = []
113
+ self.routes = []
114
+ self._decorator_targets = {} # name -> {method,path} 列表
115
+
116
+ def visit_Assign(self, node):
117
+ if len(node.targets) == 1 and isinstance(node.targets[0], ast.Name):
118
+ v = node.value
119
+ if isinstance(v, ast.Call) and isinstance(v.func, ast.Name):
120
+ fn = v.func.id
121
+ if fn == "FastAPI":
122
+ self.apps.append(node.targets[0].id)
123
+ elif fn == "APIRouter":
124
+ self.routers.append(node.targets[0].id)
125
+ elif fn in SCHEME_CLASSES:
126
+ self.schemes[node.targets[0].id] = SCHEME_CLASSES[fn]
127
+ self.generic_visit(node)
128
+
129
+ def visit_Expr(self, node):
130
+ # app.add_middleware(...) 是 Expr(Call(Attribute(app, add_middleware)))
131
+ v = node.value
132
+ if (isinstance(v, ast.Call) and isinstance(v.func, ast.Attribute)
133
+ and v.func.attr == "add_middleware" and v.args
134
+ and isinstance(v.args[0], ast.Name)):
135
+ self.middlewares.append(v.args[0].id)
136
+ self.generic_visit(node)
137
+
138
+ def visit_FunctionDef(self, node):
139
+ self._scan_decorated(node, is_async=False)
140
+ self.generic_visit(node)
141
+
142
+ def visit_AsyncFunctionDef(self, node):
143
+ self._scan_decorated(node, is_async=True)
144
+ self.generic_visit(node)
145
+
146
+ def _scan_decorated(self, node, is_async):
147
+ for dec in node.decorator_list:
148
+ # @app.get("/path") / @router.post(...) / @r.api_route(...)
149
+ if (isinstance(dec, ast.Call) and isinstance(dec.func, ast.Attribute)
150
+ and dec.func.attr in ROUTE_DECORATORS):
151
+ target = name_of(dec.func.value)
152
+ method = dec.func.attr
153
+ path = dec.args[0].value if dec.args and isinstance(dec.args[0], ast.Constant) else ""
154
+ deps = iter_dependency_calls(node.args)
155
+ # 装饰器级 dependencies=[Depends(...)](realworld 风格:
156
+ # @router.put(..., dependencies=[Depends(check_permissions)])
157
+ for kw in dec.keywords:
158
+ if kw.arg == "dependencies" and isinstance(kw.value, ast.List):
159
+ for item in kw.value.elts:
160
+ if isinstance(item, ast.Call) and isinstance(item.func, ast.Name):
161
+ if item.func.id in ("Depends", "Security") and item.args:
162
+ deps.append((resolve_dep_target(item.args[0]), item.func.id))
163
+ auth_like = [
164
+ {"name": d[0], "via": d[1],
165
+ "authLike": is_auth_like(d[0], self.schemes)}
166
+ for d in deps
167
+ ]
168
+ self.routes.append({
169
+ "method": method,
170
+ "path": path,
171
+ "handler": node.name,
172
+ "file": self.file,
173
+ "line": node.lineno,
174
+ "dependencies": auth_like,
175
+ })
176
+
177
+
178
+ def scan_file(filepath):
179
+ try:
180
+ with open(filepath, "r", encoding="utf-8", errors="replace") as f:
181
+ tree = ast.parse(f.read(), filename=filepath)
182
+ except (SyntaxError, UnicodeDecodeError, OSError):
183
+ return None
184
+ scanner = Scanner(filepath)
185
+ scanner.visit(tree)
186
+ return scanner
187
+
188
+
189
+ def walk_py_files(root):
190
+ for dirpath, dirnames, filenames in os.walk(root):
191
+ dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
192
+ for fn in filenames:
193
+ if fn.endswith(".py") and not fn.startswith("test_"):
194
+ yield os.path.join(dirpath, fn)
195
+
196
+
197
+ def main():
198
+ root = sys.argv[1]
199
+ out = sys.argv[2]
200
+ apps, routers, schemes, middlewares, routes = [], [], {}, [], []
201
+ files_scanned = 0
202
+ for fp in walk_py_files(root):
203
+ files_scanned += 1
204
+ sc = scan_file(fp)
205
+ if not sc:
206
+ continue
207
+ apps.extend(sc.apps)
208
+ routers.extend(sc.routers)
209
+ schemes.update(sc.schemes)
210
+ middlewares.extend(sc.middlewares)
211
+ routes.extend(sc.routes)
212
+
213
+ # 路由处理器名去重(同文件同名函数只记一次)
214
+ seen = set()
215
+ unique_routes = []
216
+ for r in routes:
217
+ key = (r["file"], r["handler"], r["method"], r["path"])
218
+ if key in seen:
219
+ continue
220
+ seen.add(key)
221
+ unique_routes.append(r)
222
+
223
+ result = {
224
+ "hasFastAPI": bool(apps or routers),
225
+ "apps": sorted(set(apps)),
226
+ "routers": sorted(set(routers)),
227
+ "authSchemes": [{"name": n, "type": t} for n, t in schemes.items()],
228
+ "globalAuthMiddleware": sorted(set(middlewares)),
229
+ "routes": unique_routes,
230
+ "filesScanned": files_scanned,
231
+ }
232
+ with open(out, "w", encoding="utf-8") as f:
233
+ json.dump(result, f, ensure_ascii=False, indent=2)
234
+
235
+
236
+ if __name__ == "__main__":
237
+ main()