attackmap-analyzer-python 0.2.0__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,358 @@
1
+ """Litestar / Starlite route handlers, controllers and routers (#2).
2
+
3
+ - ``@get("/x")`` / ``@post()`` (no path → ``/``) / ``@get(path=["/a", "/b"])``
4
+ / ``@route("/x", http_method=["GET", "POST"])`` / ``@websocket("/ws")``,
5
+ at any indentation — module level, inside a ``Controller``, or inside a
6
+ factory function.
7
+ - ``class UserController(Controller): path = "/users"`` joins the controller
8
+ path onto each handler path.
9
+ - ``Router(path="/api", route_handlers=[UserController, health])`` joins the
10
+ router path onto what it lists; nested routers chain.
11
+ - Route auth (#256): ``guards=[...]`` on the handler, controller or router →
12
+ ``required``; ``exclude_from_auth=True`` (or ``opt={"exclude_from_auth":
13
+ True}``) with no guard → ``anonymous``. Anything else stays ``unknown``.
14
+
15
+ Decorators only count when their name is imported from ``litestar``/``starlite``
16
+ (so a project's own ``get`` decorator never fires).
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import ast
22
+ from dataclasses import dataclass, field
23
+
24
+ from ._pyast import call_name, dotted, expr_names, keyword, safe_parse, str_const, str_list
25
+
26
+ _HANDLER_KINDS = {
27
+ "get": ["GET"],
28
+ "post": ["POST"],
29
+ "put": ["PUT"],
30
+ "patch": ["PATCH"],
31
+ "delete": ["DELETE"],
32
+ "head": ["HEAD"],
33
+ "route": None, # methods come from http_method=
34
+ "websocket": ["WS"],
35
+ "websocket_listener": ["WS"],
36
+ }
37
+ _LITESTAR_MODULES = ("litestar", "starlite")
38
+ _MAX_NEST = 32
39
+ _MAX_ROUTER_DEPTH = 8
40
+ _MAX_PREFIXES = 32
41
+
42
+
43
+ @dataclass
44
+ class Handler:
45
+ file: str
46
+ line: int
47
+ name: str
48
+ paths: list[str]
49
+ methods: list[str]
50
+ module_level: bool
51
+ guards: list[str] = field(default_factory=list)
52
+ excluded: bool = False
53
+
54
+
55
+ @dataclass
56
+ class Controller:
57
+ file: str
58
+ name: str
59
+ path: str
60
+ guards: list[str]
61
+ handlers: list[Handler] = field(default_factory=list)
62
+
63
+
64
+ @dataclass
65
+ class Router:
66
+ file: str
67
+ var: str | None
68
+ path: str
69
+ members: list[str]
70
+ guards: list[str]
71
+
72
+
73
+ @dataclass
74
+ class LitestarFile:
75
+ rel: str
76
+ handlers: list[Handler] = field(default_factory=list) # not inside a controller
77
+ controllers: list[Controller] = field(default_factory=list)
78
+ routers: list[Router] = field(default_factory=list)
79
+
80
+
81
+ @dataclass
82
+ class LitestarRoute:
83
+ path: str
84
+ method: str
85
+ file: str
86
+ line: int
87
+ auth: str = "unknown"
88
+ guards: list[str] = field(default_factory=list)
89
+ evidence: str | None = None
90
+
91
+
92
+ def _is_litestar_module(module: str | None) -> bool:
93
+ return bool(module) and module.split(".")[0] in _LITESTAR_MODULES
94
+
95
+
96
+ def parse_litestar(content: str, rel: str) -> LitestarFile | None:
97
+ """The file's handlers/controllers/routers; ``None`` only when it can't be
98
+ parsed (the caller then falls back to the decorator regex)."""
99
+ tree = safe_parse(content)
100
+ if tree is None:
101
+ return None
102
+ aliases: dict[str, str] = {}
103
+ module_aliases: set[str] = set()
104
+ router_names: set[str] = set()
105
+ for node in ast.walk(tree):
106
+ if isinstance(node, ast.ImportFrom) and _is_litestar_module(node.module):
107
+ for alias in node.names:
108
+ bound = alias.asname or alias.name
109
+ if alias.name in _HANDLER_KINDS:
110
+ aliases[bound] = alias.name
111
+ elif alias.name in {"Router", "Litestar", "Starlite"}:
112
+ router_names.add(bound)
113
+ elif isinstance(node, ast.Import):
114
+ for alias in node.names:
115
+ if _is_litestar_module(alias.name):
116
+ module_aliases.add(alias.asname or alias.name.split(".")[0])
117
+ out = LitestarFile(rel=rel)
118
+ if not aliases and not module_aliases and not router_names:
119
+ # Mentions litestar but imports no handler decorator from it — e.g. a
120
+ # test using `unittest.mock.patch("...")`, which is not a route.
121
+ return out
122
+ try:
123
+ _visit(tree.body, None, True, out, aliases, module_aliases, 0)
124
+ _collect_routers(tree, out, router_names, module_aliases)
125
+ except RecursionError:
126
+ return None
127
+ return out
128
+
129
+
130
+ def _decorator_kind(dec: ast.expr, aliases: dict[str, str], module_aliases: set[str]) -> str | None:
131
+ if not isinstance(dec, ast.Call):
132
+ return None
133
+ func = dec.func
134
+ if isinstance(func, ast.Name):
135
+ return aliases.get(func.id)
136
+ if isinstance(func, ast.Attribute) and func.attr in _HANDLER_KINDS:
137
+ base = dotted(func.value)
138
+ if base and base.split(".")[0] in module_aliases:
139
+ return func.attr
140
+ return None
141
+
142
+
143
+ def _handler_from(fn: ast.FunctionDef | ast.AsyncFunctionDef, dec: ast.Call, kind: str, rel: str, module_level: bool) -> Handler | None:
144
+ path_node = dec.args[0] if dec.args else keyword(dec, "path")
145
+ if path_node is None:
146
+ paths = ["/"]
147
+ else:
148
+ single = str_const(path_node)
149
+ paths = [single] if single is not None else (str_list(path_node) or [])
150
+ if not paths:
151
+ return None # a computed path: nothing reliable to report
152
+ methods = _HANDLER_KINDS[kind]
153
+ if methods is None:
154
+ methods = _http_methods(keyword(dec, "http_method")) or ["ANY"]
155
+ guards = expr_names(keyword(dec, "guards"))
156
+ return Handler(
157
+ file=rel,
158
+ line=dec.lineno,
159
+ name=fn.name,
160
+ paths=paths,
161
+ methods=list(methods),
162
+ module_level=module_level,
163
+ guards=guards,
164
+ excluded=_excluded_from_auth(dec),
165
+ )
166
+
167
+
168
+ def _http_methods(node: ast.expr | None) -> list[str]:
169
+ if node is None:
170
+ return []
171
+ elts = node.elts if isinstance(node, (ast.List, ast.Tuple, ast.Set)) else [node]
172
+ out: list[str] = []
173
+ for elt in elts[:16]:
174
+ value = str_const(elt)
175
+ if value is None and isinstance(elt, ast.Attribute):
176
+ value = elt.attr # HttpMethod.GET
177
+ if value and value.isalpha():
178
+ out.append(value.upper())
179
+ return out
180
+
181
+
182
+ def _excluded_from_auth(dec: ast.Call) -> bool:
183
+ flag = keyword(dec, "exclude_from_auth")
184
+ if isinstance(flag, ast.Constant) and flag.value is True:
185
+ return True
186
+ opt = keyword(dec, "opt")
187
+ if isinstance(opt, ast.Dict):
188
+ for key, value in zip(opt.keys, opt.values):
189
+ if str_const(key) == "exclude_from_auth" and isinstance(value, ast.Constant) and value.value is True:
190
+ return True
191
+ return False
192
+
193
+
194
+ def _visit(body, controller: Controller | None, module_level: bool, out: LitestarFile, aliases, module_aliases, depth: int) -> None:
195
+ if depth > _MAX_NEST:
196
+ return
197
+ for stmt in body:
198
+ if isinstance(stmt, ast.ClassDef):
199
+ ctrl = None
200
+ if any((dotted(base) or "").endswith("Controller") for base in stmt.bases):
201
+ ctrl = Controller(file=out.rel, name=stmt.name, path="", guards=[])
202
+ for item in stmt.body:
203
+ if isinstance(item, (ast.Assign, ast.AnnAssign)):
204
+ targets = item.targets if isinstance(item, ast.Assign) else [item.target]
205
+ for target in targets:
206
+ if isinstance(target, ast.Name) and target.id == "path":
207
+ ctrl.path = str_const(item.value) or ""
208
+ elif isinstance(target, ast.Name) and target.id == "guards":
209
+ ctrl.guards = expr_names(item.value)
210
+ out.controllers.append(ctrl)
211
+ _visit(stmt.body, ctrl, False, out, aliases, module_aliases, depth + 1)
212
+ elif isinstance(stmt, (ast.FunctionDef, ast.AsyncFunctionDef)):
213
+ for dec in stmt.decorator_list:
214
+ kind = _decorator_kind(dec, aliases, module_aliases)
215
+ if kind is None:
216
+ continue
217
+ handler = _handler_from(stmt, dec, kind, out.rel, module_level)
218
+ if handler is None:
219
+ continue
220
+ if controller is not None:
221
+ controller.handlers.append(handler)
222
+ else:
223
+ out.handlers.append(handler)
224
+ _visit(stmt.body, None, False, out, aliases, module_aliases, depth + 1)
225
+ elif isinstance(stmt, (ast.If, ast.With, ast.Try, ast.For, ast.While)) or type(stmt).__name__ == "TryStar":
226
+ for block in ("body", "orelse", "finalbody"):
227
+ _visit(getattr(stmt, block, []) or [], controller, module_level, out, aliases, module_aliases, depth + 1)
228
+ for handler in getattr(stmt, "handlers", []) or []:
229
+ _visit(handler.body, controller, module_level, out, aliases, module_aliases, depth + 1)
230
+
231
+
232
+ def _collect_routers(tree: ast.Module, out: LitestarFile, router_names: set[str], module_aliases: set[str]) -> None:
233
+ assigned: dict[int, str] = {}
234
+ for node in ast.walk(tree):
235
+ if isinstance(node, ast.Assign) and isinstance(node.value, ast.Call):
236
+ for target in node.targets:
237
+ if isinstance(target, ast.Name):
238
+ assigned[id(node.value)] = target.id
239
+ for node in ast.walk(tree):
240
+ if not isinstance(node, ast.Call):
241
+ continue
242
+ func = node.func
243
+ if isinstance(func, ast.Name) and func.id in router_names:
244
+ pass
245
+ elif isinstance(func, ast.Attribute) and func.attr in {"Router", "Litestar", "Starlite"} and (dotted(func.value) or "").split(".")[0] in module_aliases:
246
+ pass
247
+ else:
248
+ continue
249
+ is_app = call_name(node) in {"Litestar", "Starlite"}
250
+ path_node = None if is_app else (node.args[0] if node.args else keyword(node, "path"))
251
+ path = str_const(path_node) or "" if path_node is not None else ""
252
+ members_node = keyword(node, "route_handlers")
253
+ if members_node is None and not is_app and len(node.args) > 1:
254
+ members_node = node.args[1]
255
+ if members_node is None and is_app and node.args:
256
+ members_node = node.args[0]
257
+ members = [name.split(".")[-1] for name in expr_names(members_node, limit=256)]
258
+ out.routers.append(
259
+ Router(file=out.rel, var=assigned.get(id(node)), path=path, members=members, guards=expr_names(keyword(node, "guards")))
260
+ )
261
+
262
+
263
+ def _join(*parts: str) -> str:
264
+ pieces = [p.strip("/") for p in parts if p and p.strip("/")]
265
+ return "/" + "/".join(pieces) if pieces else "/"
266
+
267
+
268
+ def resolve_litestar(files: list[LitestarFile]) -> list[LitestarRoute]:
269
+ """Join router and controller paths onto every handler, repo-wide."""
270
+ routers = [r for f in files for r in f.routers]
271
+ # member name → routers that list it
272
+ listed_by: dict[str, list[Router]] = {}
273
+ for router in routers:
274
+ for member in router.members:
275
+ listed_by.setdefault(member, []).append(router)
276
+ defined: dict[str, set[str]] = {}
277
+ for f in files:
278
+ for ctrl in f.controllers:
279
+ defined.setdefault(ctrl.name, set()).add(f.rel)
280
+ for handler in f.handlers:
281
+ defined.setdefault(handler.name, set()).add(f.rel)
282
+ for router in f.routers:
283
+ if router.var:
284
+ defined.setdefault(router.var, set()).add(f.rel)
285
+
286
+ def owners(name: str, file: str) -> list[Router]:
287
+ # A router listing `name` counts when it's in the same file or the name
288
+ # is defined exactly once in the repo (an unambiguous import).
289
+ unique = len(defined.get(name, ())) == 1
290
+ return [r for r in listed_by.get(name, []) if r.file == file or unique]
291
+
292
+ memo: dict[tuple[str, str], list[tuple[str, list[str]]]] = {}
293
+
294
+ def mounts(name: str, file: str, stack: frozenset = frozenset()) -> list[tuple[str, list[str]]]:
295
+ """(prefix, inherited guards) for each way `name` is mounted. Memoized;
296
+ a router cycle or a chain deeper than _MAX_ROUTER_DEPTH stops there."""
297
+ key = (name, file)
298
+ if key in memo:
299
+ return memo[key]
300
+ if key in stack or len(stack) >= _MAX_ROUTER_DEPTH:
301
+ return [("", [])]
302
+ result: list[tuple[str, list[str]]] = []
303
+ for router in owners(name, file):
304
+ parents = mounts(router.var, router.file, stack | {key}) if router.var else [("", [])]
305
+ for parent_prefix, parent_guards in parents:
306
+ result.append((_join(parent_prefix, router.path), parent_guards + router.guards))
307
+ if len(result) >= _MAX_PREFIXES:
308
+ break
309
+ if len(result) >= _MAX_PREFIXES:
310
+ break
311
+ memo[key] = result = result or [("", [])]
312
+ return result
313
+
314
+ routes: list[LitestarRoute] = []
315
+
316
+ def emit(handler: Handler, prefix: str, ctrl_path: str, inherited: list[str]) -> None:
317
+ guards = inherited + handler.guards
318
+ if guards:
319
+ auth, evidence = "required", f"guards=[{', '.join(guards)}]"
320
+ elif handler.excluded:
321
+ auth, evidence = "anonymous", "exclude_from_auth=True"
322
+ else:
323
+ auth, evidence = "unknown", None
324
+ for hpath in handler.paths:
325
+ for method in handler.methods:
326
+ routes.append(
327
+ LitestarRoute(
328
+ path=_join(prefix, ctrl_path, hpath),
329
+ method=method,
330
+ file=handler.file,
331
+ line=handler.line,
332
+ auth=auth,
333
+ guards=list(guards),
334
+ evidence=evidence,
335
+ )
336
+ )
337
+
338
+ for f in files:
339
+ for ctrl in f.controllers:
340
+ for prefix, inherited in mounts(ctrl.name, f.rel):
341
+ for handler in ctrl.handlers:
342
+ emit(handler, prefix, ctrl.path, inherited + ctrl.guards)
343
+ for handler in f.handlers:
344
+ for prefix, inherited in (mounts(handler.name, f.rel) if handler.module_level else [("", [])]):
345
+ emit(handler, prefix, "", inherited)
346
+ # One handler mounted twice at the same path with different auth (a
347
+ # guarded and an unguarded router) collapses to one route downstream, so
348
+ # its auth isn't known.
349
+ auth_by_key: dict[tuple[str, str, str], set[str]] = {}
350
+ for route in routes:
351
+ auth_by_key.setdefault((route.path, route.method, route.file), set()).add(route.auth)
352
+ for route in routes:
353
+ if len(auth_by_key[(route.path, route.method, route.file)]) > 1:
354
+ route.auth, route.guards, route.evidence = "unknown", [], None
355
+ return routes
356
+
357
+
358
+ __all__ = ["LitestarFile", "LitestarRoute", "parse_litestar", "resolve_litestar"]
@@ -0,0 +1,77 @@
1
+ """Sanic ``@app.route(...)`` / ``@bp.websocket(...)`` decorators (#2).
2
+
3
+ The verb decorators (``@app.get``) stay with the regex extractor. ``route``
4
+ takes its verbs from ``methods=[...]`` and defaults to ``GET`` (Sanic's own
5
+ default, and what core's ``@x.route`` pattern assumes, so the two analyzers'
6
+ routes merge instead of duplicating). The receiver must be ``app``/``bp``/
7
+ ``blueprint`` or a name assigned from ``Sanic(...)`` / ``Blueprint(...)``.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import ast
13
+
14
+ from ._pyast import call_name, keyword, safe_parse, str_const
15
+
16
+ _RECEIVERS = {"app", "bp", "blueprint"}
17
+
18
+
19
+ def _normalize(path: str) -> str:
20
+ """Leading slash, no `//` runs: the shape core gives the same decorator."""
21
+ path = "/" + path.strip().lstrip("/")
22
+ while "//" in path:
23
+ path = path.replace("//", "/")
24
+ return path
25
+
26
+
27
+ def _methods(node: ast.expr | None) -> list[str]:
28
+ """`methods=["GET", "post"]`, a tuple/set, or `HTTPMethod.DELETE` enums."""
29
+ if node is None:
30
+ return []
31
+ elts = node.elts if isinstance(node, (ast.List, ast.Tuple, ast.Set)) else [node]
32
+ out: list[str] = []
33
+ for elt in elts[:16]:
34
+ value = str_const(elt)
35
+ if value is None and isinstance(elt, ast.Attribute):
36
+ value = elt.attr # HTTPMethod.DELETE
37
+ if value and value.isalpha():
38
+ out.append(value.upper())
39
+ return out
40
+
41
+
42
+ def sanic_routes(content: str) -> list[tuple[str, str, int]] | None:
43
+ """[(path, method, line)], or ``None`` when the file can't be parsed."""
44
+ tree = safe_parse(content)
45
+ if tree is None:
46
+ return None
47
+ receivers = set(_RECEIVERS)
48
+ for node in ast.walk(tree):
49
+ if isinstance(node, ast.Assign) and call_name(node.value) in {"Sanic", "Blueprint"}:
50
+ receivers.update(t.id for t in node.targets if isinstance(t, ast.Name))
51
+ out: list[tuple[str, str, int]] = []
52
+ for node in ast.walk(tree):
53
+ if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
54
+ continue
55
+ for dec in node.decorator_list:
56
+ if not (isinstance(dec, ast.Call) and isinstance(dec.func, ast.Attribute)):
57
+ continue
58
+ owner = dec.func.value
59
+ if not (isinstance(owner, ast.Name) and owner.id in receivers):
60
+ continue
61
+ kind = dec.func.attr
62
+ if kind not in {"route", "websocket"}:
63
+ continue
64
+ path = str_const(dec.args[0] if dec.args else keyword(dec, "uri"))
65
+ if path is None:
66
+ continue
67
+ path = _normalize(path)
68
+ if kind == "websocket":
69
+ out.append((path, "WS", dec.lineno))
70
+ continue
71
+ methods = _methods(keyword(dec, "methods")) or ["GET"]
72
+ for method in dict.fromkeys(methods):
73
+ out.append((path, method, dec.lineno))
74
+ return out
75
+
76
+
77
+ __all__ = ["sanic_routes"]
@@ -0,0 +1,182 @@
1
+ """Tornado URLSpec routing paired with RequestHandler methods (#2).
2
+
3
+ ``tornado.web.Application([(r"/users/(\\d+)", UserHandler), url(r"/x", X)])``
4
+ (or a ``handlers = [...]`` list handed to it, or ``add_handlers``) maps a
5
+ regex to a handler class; the HTTP methods are the ``get``/``post``/... the
6
+ class defines. A ``WebSocketHandler`` subclass is a ``WS`` route. A handler
7
+ that defines no verb itself (inherits them) is ``ANY``.
8
+
9
+ A URL spec counts only when it sits in a list/tuple literal, its pattern
10
+ starts with ``/``, and its handler is a known ``*Handler`` class (anywhere in
11
+ the repo) or at least named like one. Route auth (#256): a verb method
12
+ decorated with ``@tornado.web.authenticated`` is ``required``.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import ast
18
+ from dataclasses import dataclass, field
19
+
20
+ from ._pyast import call_name, dotted, safe_parse, str_const
21
+
22
+ _VERBS = ("get", "post", "put", "patch", "delete", "head", "options")
23
+ _MAX_INHERIT = 3
24
+ _MAX_SPECS = 5_000
25
+
26
+
27
+ @dataclass
28
+ class HandlerClass:
29
+ file: str
30
+ name: str
31
+ bases: list[str]
32
+ methods: dict[str, bool] = field(default_factory=dict) # VERB → @authenticated
33
+ websocket: bool = False
34
+ direct: bool = False # a base is named *Handler
35
+
36
+
37
+ @dataclass
38
+ class UrlSpec:
39
+ file: str
40
+ line: int
41
+ pattern: str
42
+ handler: str # last dotted component of the handler expression
43
+
44
+
45
+ @dataclass
46
+ class TornadoFile:
47
+ rel: str
48
+ classes: list[HandlerClass] = field(default_factory=list)
49
+ specs: list[UrlSpec] = field(default_factory=list)
50
+
51
+
52
+ @dataclass
53
+ class TornadoRoute:
54
+ path: str
55
+ method: str
56
+ file: str
57
+ line: int
58
+ auth: str = "unknown"
59
+ guards: list[str] = field(default_factory=list)
60
+ evidence: str | None = None
61
+
62
+
63
+ def parse_tornado(content: str, rel: str) -> TornadoFile | None:
64
+ tree = safe_parse(content)
65
+ if tree is None:
66
+ return None
67
+ out = TornadoFile(rel=rel)
68
+ for node in ast.walk(tree):
69
+ if isinstance(node, ast.ClassDef):
70
+ bases = [dotted(b) or "" for b in node.bases]
71
+ # Every class is recorded: one that subclasses RequestHandler only
72
+ # through a project base (`class Api(BaseView)`) is resolved later.
73
+ cls = HandlerClass(
74
+ file=rel,
75
+ name=node.name,
76
+ bases=[b.split(".")[-1] for b in bases if b],
77
+ websocket=any("WebSocket" in b for b in bases),
78
+ direct=any(b.endswith("Handler") for b in bases),
79
+ )
80
+ for item in node.body:
81
+ if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)) and item.name in _VERBS:
82
+ cls.methods[item.name.upper()] = any(
83
+ (dotted(d) or "").split(".")[-1] == "authenticated" for d in item.decorator_list
84
+ )
85
+ out.classes.append(cls)
86
+ elif isinstance(node, (ast.List, ast.Tuple)) and len(out.specs) < _MAX_SPECS:
87
+ for elt in node.elts:
88
+ spec = _spec(elt, rel)
89
+ if spec is not None:
90
+ out.specs.append(spec)
91
+ return out
92
+
93
+
94
+ def _spec(node: ast.expr, rel: str) -> UrlSpec | None:
95
+ if isinstance(node, ast.Tuple) and len(node.elts) >= 2:
96
+ pattern, handler = node.elts[0], node.elts[1]
97
+ elif isinstance(node, ast.Call) and call_name(node) in {"url", "URLSpec"} and len(node.args) >= 2:
98
+ pattern, handler = node.args[0], node.args[1]
99
+ else:
100
+ return None
101
+ text = str_const(pattern)
102
+ name = dotted(handler)
103
+ if text is None or name is None or not text.lstrip("^").startswith("/"):
104
+ return None
105
+ return UrlSpec(file=rel, line=node.lineno, pattern=text, handler=name.split(".")[-1])
106
+
107
+
108
+ def _path(pattern: str) -> str:
109
+ path = pattern.lstrip("^")
110
+ while path.endswith("$") and not path.endswith("\\$"):
111
+ path = path[:-1]
112
+ return path or "/"
113
+
114
+
115
+ def resolve_tornado(files: list[TornadoFile]) -> list[TornadoRoute]:
116
+ by_name: dict[str, list[HandlerClass]] = {}
117
+ for f in files:
118
+ for cls in f.classes:
119
+ by_name.setdefault(cls.name, []).append(cls)
120
+
121
+ def lookup(name: str, file: str) -> HandlerClass | None:
122
+ found = by_name.get(name, [])
123
+ same = [c for c in found if c.file == file]
124
+ if same:
125
+ return same[0]
126
+ return found[0] if len(found) == 1 else None
127
+
128
+ def verbs(cls: HandlerClass) -> tuple[dict[str, bool], bool, bool]:
129
+ """(verb → authenticated, websocket, is a handler) through up to
130
+ _MAX_INHERIT levels of bases."""
131
+ methods: dict[str, bool] = {}
132
+ websocket = cls.websocket
133
+ handler = cls.direct
134
+ seen: set[int] = set()
135
+ current: list[HandlerClass] = [cls]
136
+ for _ in range(_MAX_INHERIT + 1):
137
+ nxt: list[HandlerClass] = []
138
+ for c in current:
139
+ if id(c) in seen:
140
+ continue
141
+ seen.add(id(c))
142
+ websocket = websocket or c.websocket
143
+ handler = handler or c.direct
144
+ for verb, authed in c.methods.items():
145
+ methods.setdefault(verb, authed)
146
+ for base in c.bases:
147
+ parent = lookup(base, c.file)
148
+ if parent is not None:
149
+ nxt.append(parent)
150
+ current = nxt
151
+ return methods, websocket, handler
152
+
153
+ routes: list[TornadoRoute] = []
154
+ for f in files:
155
+ for spec in f.specs:
156
+ cls = lookup(spec.handler, spec.file)
157
+ methods, websocket, handler = verbs(cls) if cls is not None else ({}, False, False)
158
+ if not handler and not spec.handler.endswith("Handler"):
159
+ continue
160
+ path = _path(spec.pattern)
161
+ if websocket:
162
+ routes.append(TornadoRoute(path, "WS", spec.file, spec.line))
163
+ continue
164
+ if not methods:
165
+ routes.append(TornadoRoute(path, "ANY", spec.file, spec.line))
166
+ continue
167
+ for verb in sorted(methods):
168
+ if methods[verb]:
169
+ routes.append(
170
+ TornadoRoute(
171
+ path, verb, spec.file, spec.line,
172
+ auth="required",
173
+ guards=["@tornado.web.authenticated"],
174
+ evidence=f"@tornado.web.authenticated on {spec.handler}.{verb.lower()}",
175
+ )
176
+ )
177
+ else:
178
+ routes.append(TornadoRoute(path, verb, spec.file, spec.line))
179
+ return routes
180
+
181
+
182
+ __all__ = ["TornadoFile", "TornadoRoute", "parse_tornado", "resolve_tornado"]