gabriel / muse public
_framework.py python
731 lines 25.0 KB
Raw
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 132 days ago
1 """Framework entry-point detection for the code-domain symbol graph.
2
3 Problem
4 -------
5 Static call-graph analysis only records *explicit* call edges — ``A()`` inside
6 ``B`` produces edge ``B→A``. Framework entry points (FastAPI route handlers,
7 Flask views, Celery tasks, …) are wired at *runtime* through decorators and DI
8 containers. No explicit call site exists in user code, so they appear dead to
9 :func:`~muse.cli.commands.dead.run` and have empty blast-radius in
10 :func:`~muse.cli.commands.impact.run`.
11
12 Solution
13 --------
14 This module adds a complementary edge type — :class:`ImplicitEntryEdge` — that
15 represents "this symbol is externally reachable via the framework named
16 ``framework_id``." Edges are synthesised by :class:`FrameworkPlugin`
17 implementations, one per framework, during a single linear pass over the
18 manifest. The resulting :data:`ImplicitEdgeGraph` is consumed by the impact
19 and dead-code commands to:
20
21 * Surface entry-point annotations instead of "no callers detected".
22 * Exclude entry-point symbols from dead-code results.
23
24 Adding a new framework
25 ----------------------
26 1. Create a class that satisfies the :class:`FrameworkPlugin` protocol.
27 2. Append an instance to :data:`BUILTIN_PLUGINS`.
28 3. Write tests in ``tests/test_framework_plugins.py``.
29
30 No changes to the core graph, impact, or dead-code modules are required.
31
32 Configuration
33 -------------
34 ``.muse/code_config.toml`` is the single configuration file for all code-domain
35 intelligence. It has two independent sections:
36
37 ``[code]`` — controls which tree-sitter language grammars are active::
38
39 [code]
40 # Restrict to Python + TypeScript + shell; all other grammars become
41 # file-level fallbacks even if the grammar packages are installed.
42 languages = ["python", "typescript", "tsx", "css", "bash"]
43
44 ``[framework_detection]`` — controls entry-point detection::
45
46 [framework_detection]
47 auto_detect = true
48 disabled_plugins = [] # e.g. ["celery"] to silence false positives
49
50 [[framework_detection.custom_entry_points]]
51 language = "Python"
52 kind = "custom-rpc"
53 decorator_names = ["rpc_handler", "grpc_handler"]
54
55 Both sections are optional; omitting either reproduces the zero-config defaults.
56 """
57
58 from __future__ import annotations
59
60 import ast
61 import logging
62 import pathlib
63 import tomllib
64 from dataclasses import dataclass, field
65 from typing import Protocol, runtime_checkable
66
67 from muse.core._types import Manifest
68 from muse.core.paths import code_config_path as _code_config_path
69 from muse.core.object_store import read_object
70 from muse.core.symbol_cache import SymbolCache
71 from muse.core.implicit_edge_cache import ImplicitEdgeCache
72 from muse.core.validation import MAX_AST_BYTES
73 from muse.plugins.code.ast_parser import SymbolTree, parse_symbols
74
75 logger = logging.getLogger(__name__)
76
77 # ---------------------------------------------------------------------------
78 # Core data types
79 # ---------------------------------------------------------------------------
80
81 #: String-keyed metadata dict for per-framework entry-point annotations.
82 #: Keys vary by framework (e.g. "method", "path" for HTTP; "hook" for Flask).
83 type _Metadata = dict[str, str]
84
85
86 @dataclass(frozen=True, slots=True)
87 class ImplicitEntryEdge:
88 """One framework-implied reachability edge for a symbol.
89
90 Attributes:
91 framework_id: Stable lowercase identifier, e.g. ``"fastapi"``.
92 symbol_address: Full symbol address, e.g.
93 ``"app/routers/runs.py::create_run"``.
94 kind: Semantic category of the entry point:
95 ``"http-route"``, ``"task"``, ``"cli-command"``, …
96 metadata: Arbitrary per-framework annotations. Common keys:
97 ``method`` (HTTP verb), ``path`` (URL pattern),
98 ``queue`` (Celery queue name).
99 """
100
101 framework_id: str
102 symbol_address: str
103 kind: str
104 metadata: _Metadata = field(default_factory=dict)
105
106
107 #: Maps ``symbol_address → [ImplicitEntryEdge, …]`` for every framework-wired
108 #: symbol in a snapshot. Analogous to :data:`~._callgraph.ReverseGraph` but
109 #: for implicit framework callers rather than explicit call sites.
110 ImplicitEdgeGraph = dict[str, list[ImplicitEntryEdge]]
111
112
113 # ---------------------------------------------------------------------------
114 # FrameworkPlugin protocol
115 # ---------------------------------------------------------------------------
116
117
118 @runtime_checkable
119 class FrameworkPlugin(Protocol):
120 """Protocol every framework entry-point plugin must satisfy.
121
122 Implementations should be stateless singletons. The contract is:
123
124 * ``language`` — the primary language this plugin targets. Used to skip
125 files whose suffix does not match, avoiding unnecessary parses.
126 * ``framework_id`` — a stable lowercase slug, e.g. ``"fastapi"``.
127 * ``detect_entry_points`` — pure, thread-safe, returns an empty list when
128 the file contains no framework-wired symbols.
129 """
130
131 @property
132 def language(self) -> str:
133 """Primary language, e.g. ``"Python"`` or ``"TypeScript"``."""
134 ...
135
136 @property
137 def framework_id(self) -> str:
138 """Stable lowercase slug, e.g. ``"fastapi"``."""
139 ...
140
141 def detect_entry_points(
142 self,
143 file_path: str,
144 sym_tree: SymbolTree,
145 source: bytes,
146 ) -> list[ImplicitEntryEdge]:
147 """Return implicit entry edges for framework-wired symbols in *file_path*.
148
149 Args:
150 file_path: Workspace-relative POSIX path, e.g.
151 ``"app/routers/runs.py"``.
152 sym_tree: Pre-parsed symbol tree for the file. Plugins may
153 use this to correlate decorator findings with known
154 symbol addresses without re-parsing.
155 source: Raw file bytes. Plugins that need full AST detail
156 (e.g. decorator arguments) must call
157 ``ast.parse(source)`` themselves.
158
159 Returns:
160 A (possibly empty) list of :class:`ImplicitEntryEdge` objects.
161 One edge per framework-wired symbol per entry-point type.
162 """
163 ...
164
165
166 # ---------------------------------------------------------------------------
167 # Configuration
168 # ---------------------------------------------------------------------------
169
170
171 @dataclass
172 class _CustomEntryPointRule:
173 """One user-defined entry-point pattern from ``.muse/code_config.toml``."""
174
175 language: str
176 kind: str
177 decorator_names: list[str] = field(default_factory=list)
178
179
180 @dataclass
181 class FrameworkConfig:
182 """Parsed ``[framework_detection]`` section from ``.muse/code_config.toml``.
183
184 Defaults reflect the "zero-config" happy path: all built-in plugins active,
185 no custom rules.
186 """
187
188 auto_detect: bool = True
189 disabled_plugins: frozenset[str] = field(default_factory=frozenset)
190 custom_entry_points: list[_CustomEntryPointRule] = field(default_factory=list)
191
192
193 def load_framework_config(root: pathlib.Path) -> FrameworkConfig:
194 """Read ``[framework_detection]`` from ``.muse/code_config.toml``.
195
196 Returns default :class:`FrameworkConfig` if the file is absent or the
197 section is missing — callers should not need to handle ``None``.
198 """
199 config_path = _code_config_path(root)
200 if not config_path.exists():
201 return FrameworkConfig()
202
203 try:
204 with open(config_path, "rb") as fh:
205 raw = tomllib.load(fh)
206 except Exception as exc: # noqa: BLE001
207 logger.warning("⚠️ Could not parse .muse/code_config.toml: %s", exc)
208 return FrameworkConfig()
209
210 section = raw.get("framework_detection", {})
211 if not isinstance(section, dict):
212 return FrameworkConfig()
213
214 auto_detect: bool = bool(section.get("auto_detect", True))
215 disabled: list[str] = section.get("disabled_plugins", [])
216 custom: list[_CustomEntryPointRule] = []
217 for rule in section.get("custom_entry_points", []):
218 if not isinstance(rule, dict):
219 continue
220 lang = rule.get("language", "Python")
221 kind = rule.get("kind", "custom")
222 names = rule.get("decorator_names", [])
223 if isinstance(names, list):
224 custom.append(_CustomEntryPointRule(language=lang, kind=kind, decorator_names=names))
225
226 return FrameworkConfig(
227 auto_detect=auto_detect,
228 disabled_plugins=frozenset(disabled),
229 custom_entry_points=custom,
230 )
231
232
233 # ---------------------------------------------------------------------------
234 # Custom pattern plugin (reads user-defined rules from FrameworkConfig)
235 # ---------------------------------------------------------------------------
236
237
238 class _CustomPatternPlugin:
239 """Synthesises entry edges from ``[[framework_detection.custom_entry_points]]`` rules."""
240
241 language = "Python"
242 framework_id = "custom"
243
244 def __init__(self, rules: list[_CustomEntryPointRule]) -> None:
245 self._rules = [r for r in rules if r.language == "Python"]
246
247 def detect_entry_points(
248 self,
249 file_path: str,
250 sym_tree: SymbolTree,
251 source: bytes,
252 ) -> list[ImplicitEntryEdge]:
253 if not self._rules or len(source) > MAX_AST_BYTES:
254 return []
255 try:
256 tree = ast.parse(source)
257 except SyntaxError:
258 return []
259
260 decorator_to_kind = {} # bare_name → kind string
261 for rule in self._rules:
262 for name in rule.decorator_names:
263 decorator_to_kind[name] = rule.kind
264
265 if not decorator_to_kind:
266 return []
267
268 edges: list[ImplicitEntryEdge] = []
269 for node in ast.walk(tree):
270 if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
271 continue
272 for dec in node.decorator_list:
273 dec_name = _decorator_bare_name(dec)
274 if dec_name and dec_name in decorator_to_kind:
275 addr = _resolve_address(file_path, node.name, sym_tree)
276 if addr:
277 edges.append(ImplicitEntryEdge(
278 framework_id=self.framework_id,
279 symbol_address=addr,
280 kind=decorator_to_kind[dec_name],
281 metadata={"decorator": dec_name},
282 ))
283 return edges
284
285
286 # ---------------------------------------------------------------------------
287 # Built-in plugins
288 # ---------------------------------------------------------------------------
289
290
291 class _FastAPIPlugin:
292 """Detects FastAPI / APIRouter route handlers.
293
294 Recognises the decorator forms::
295
296 @app.get("/path")
297 @router.post("/path", ...)
298 @api_router.put("/path")
299
300 Any variable name is accepted as the router/app object; only the HTTP
301 *method attribute* is checked (``get``, ``post``, ``put``, ``delete``,
302 ``patch``, ``head``, ``options``, ``trace``).
303 """
304
305 language = "Python"
306 framework_id = "fastapi"
307
308 _HTTP_METHODS: frozenset[str] = frozenset(
309 {"get", "post", "put", "delete", "patch", "head", "options", "trace"}
310 )
311
312 def detect_entry_points(
313 self,
314 file_path: str,
315 sym_tree: SymbolTree,
316 source: bytes,
317 ) -> list[ImplicitEntryEdge]:
318 if len(source) > MAX_AST_BYTES:
319 return []
320 try:
321 tree = ast.parse(source)
322 except SyntaxError:
323 return []
324
325 edges: list[ImplicitEntryEdge] = []
326 for node in ast.walk(tree):
327 if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
328 continue
329 for dec in node.decorator_list:
330 edge = self._edge_from_decorator(dec, node.name, file_path, sym_tree)
331 if edge is not None:
332 edges.append(edge)
333 return edges
334
335 def _edge_from_decorator(
336 self,
337 dec: ast.expr,
338 func_name: str,
339 file_path: str,
340 sym_tree: SymbolTree,
341 ) -> ImplicitEntryEdge | None:
342 # Must be a Call: @router.get("/path") or @router.get("/path", ...)
343 if not isinstance(dec, ast.Call):
344 return None
345 func = dec.func
346 # Must be an attribute: <anything>.get / .post / etc.
347 if not isinstance(func, ast.Attribute):
348 return None
349 method = func.attr.lower()
350 if method not in self._HTTP_METHODS:
351 return None
352
353 # Extract the path from the first positional argument (optional).
354 path = ""
355 if dec.args and isinstance(dec.args[0], ast.Constant):
356 path = str(dec.args[0].value)
357
358 addr = _resolve_address(file_path, func_name, sym_tree)
359 if addr is None:
360 return None
361
362 return ImplicitEntryEdge(
363 framework_id=self.framework_id,
364 symbol_address=addr,
365 kind="http-route",
366 metadata={"method": method.upper(), "path": path},
367 )
368
369
370 class _FlaskPlugin:
371 """Detects Flask and Blueprint route handlers and lifecycle hooks.
372
373 Recognises::
374
375 @app.route("/path", methods=["GET", "POST"])
376 @bp.route("/path")
377 @app.before_request / @app.after_request / @app.teardown_appcontext
378 @app.errorhandler(404)
379 @app.cli.command()
380 """
381
382 language = "Python"
383 framework_id = "flask"
384
385 _ROUTE_ATTRS: frozenset[str] = frozenset({"route"})
386 _LIFECYCLE_ATTRS: frozenset[str] = frozenset(
387 {"before_request", "after_request", "teardown_appcontext",
388 "before_app_request", "after_app_request"}
389 )
390 _ERROR_ATTRS: frozenset[str] = frozenset({"errorhandler"})
391
392 def detect_entry_points(
393 self,
394 file_path: str,
395 sym_tree: SymbolTree,
396 source: bytes,
397 ) -> list[ImplicitEntryEdge]:
398 if len(source) > MAX_AST_BYTES:
399 return []
400 try:
401 tree = ast.parse(source)
402 except SyntaxError:
403 return []
404
405 edges: list[ImplicitEntryEdge] = []
406 for node in ast.walk(tree):
407 if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
408 continue
409 for dec in node.decorator_list:
410 edge = self._edge_from_decorator(dec, node.name, file_path, sym_tree)
411 if edge is not None:
412 edges.append(edge)
413 return edges
414
415 def _edge_from_decorator(
416 self,
417 dec: ast.expr,
418 func_name: str,
419 file_path: str,
420 sym_tree: SymbolTree,
421 ) -> ImplicitEntryEdge | None:
422 addr = _resolve_address(file_path, func_name, sym_tree)
423 if addr is None:
424 return None
425
426 # @app.route("/path", methods=[...])
427 if isinstance(dec, ast.Call) and isinstance(dec.func, ast.Attribute):
428 attr = dec.func.attr
429 if attr in self._ROUTE_ATTRS:
430 path = ""
431 if dec.args and isinstance(dec.args[0], ast.Constant):
432 path = str(dec.args[0].value)
433 methods = _extract_methods_kwarg(dec) or ["GET"]
434 return ImplicitEntryEdge(
435 framework_id=self.framework_id,
436 symbol_address=addr,
437 kind="http-route",
438 metadata={"method": ",".join(methods), "path": path},
439 )
440 if attr in self._LIFECYCLE_ATTRS:
441 return ImplicitEntryEdge(
442 framework_id=self.framework_id,
443 symbol_address=addr,
444 kind="lifecycle-hook",
445 metadata={"hook": attr},
446 )
447 if attr in self._ERROR_ATTRS:
448 return ImplicitEntryEdge(
449 framework_id=self.framework_id,
450 symbol_address=addr,
451 kind="error-handler",
452 metadata={},
453 )
454
455 # Bare @app.before_request (no call parens)
456 if isinstance(dec, ast.Attribute) and dec.attr in self._LIFECYCLE_ATTRS:
457 return ImplicitEntryEdge(
458 framework_id=self.framework_id,
459 symbol_address=addr,
460 kind="lifecycle-hook",
461 metadata={"hook": dec.attr},
462 )
463
464 return None
465
466
467 class _CeleryPlugin:
468 """Detects Celery task functions.
469
470 Recognises::
471
472 @app.task
473 @celery.task
474 @shared_task
475 @app.task(bind=True)
476 @app.periodic_task(run_every=…)
477 """
478
479 language = "Python"
480 framework_id = "celery"
481
482 _TASK_BARE: frozenset[str] = frozenset({"shared_task"})
483 _TASK_ATTRS: frozenset[str] = frozenset({"task", "periodic_task"})
484
485 def detect_entry_points(
486 self,
487 file_path: str,
488 sym_tree: SymbolTree,
489 source: bytes,
490 ) -> list[ImplicitEntryEdge]:
491 if len(source) > MAX_AST_BYTES:
492 return []
493 try:
494 tree = ast.parse(source)
495 except SyntaxError:
496 return []
497
498 edges: list[ImplicitEntryEdge] = []
499 for node in ast.walk(tree):
500 if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
501 continue
502 for dec in node.decorator_list:
503 edge = self._edge_from_decorator(dec, node.name, file_path, sym_tree)
504 if edge is not None:
505 edges.append(edge)
506 return edges
507
508 def _edge_from_decorator(
509 self,
510 dec: ast.expr,
511 func_name: str,
512 file_path: str,
513 sym_tree: SymbolTree,
514 ) -> ImplicitEntryEdge | None:
515 addr = _resolve_address(file_path, func_name, sym_tree)
516 if addr is None:
517 return None
518
519 # @shared_task (bare Name)
520 if isinstance(dec, ast.Name) and dec.id in self._TASK_BARE:
521 return ImplicitEntryEdge(
522 framework_id=self.framework_id,
523 symbol_address=addr,
524 kind="task",
525 metadata={},
526 )
527
528 # @shared_task(...) (called Name)
529 if isinstance(dec, ast.Call) and isinstance(dec.func, ast.Name):
530 if dec.func.id in self._TASK_BARE:
531 return ImplicitEntryEdge(
532 framework_id=self.framework_id,
533 symbol_address=addr,
534 kind="task",
535 metadata={},
536 )
537
538 # @app.task / @celery.task (bare Attribute)
539 if isinstance(dec, ast.Attribute) and dec.attr in self._TASK_ATTRS:
540 kind = "periodic-task" if dec.attr == "periodic_task" else "task"
541 return ImplicitEntryEdge(
542 framework_id=self.framework_id,
543 symbol_address=addr,
544 kind=kind,
545 metadata={},
546 )
547
548 # @app.task(...) / @app.periodic_task(...) (called Attribute)
549 if isinstance(dec, ast.Call) and isinstance(dec.func, ast.Attribute):
550 if dec.func.attr in self._TASK_ATTRS:
551 kind = "periodic-task" if dec.func.attr == "periodic_task" else "task"
552 return ImplicitEntryEdge(
553 framework_id=self.framework_id,
554 symbol_address=addr,
555 kind=kind,
556 metadata={},
557 )
558
559 return None
560
561
562 #: Default plugin instances — extend this list to add new frameworks.
563 BUILTIN_PLUGINS: list[FrameworkPlugin] = [
564 _FastAPIPlugin(),
565 _FlaskPlugin(),
566 _CeleryPlugin(),
567 ]
568
569
570 # ---------------------------------------------------------------------------
571 # Graph construction
572 # ---------------------------------------------------------------------------
573
574
575 _PY_SUFFIXES: frozenset[str] = frozenset({".py", ".pyi"})
576
577
578 def build_implicit_edge_graph(
579 root: pathlib.Path,
580 manifest: Manifest,
581 cache: SymbolCache | None = None,
582 config: FrameworkConfig | None = None,
583 implicit_cache: ImplicitEdgeCache | None = None,
584 ) -> ImplicitEdgeGraph:
585 """Build the implicit entry-edge graph from *manifest*.
586
587 Runs all active :data:`BUILTIN_PLUGINS` (plus any custom rules from
588 *config*) over every file in the manifest and aggregates the results.
589
590 Two levels of caching are supported:
591
592 * ``implicit_cache`` — fastest path: skips ``read_object``, symbol
593 parsing, and all plugin ``detect_entry_points`` calls for files
594 whose edge list is already cached.
595 * ``cache`` (``SymbolCache``) — skips ``parse_symbols`` on a hit, but
596 ``read_object`` and plugin detection are still required.
597
598 The caller is responsible for calling ``implicit_cache.save()`` after
599 the build if persistence is desired.
600
601 Args:
602 root: Repository root — used to read blobs from the object store.
603 manifest: Snapshot manifest mapping file path → SHA-256 object ID.
604 cache: Optional :class:`~muse.core.symbol_cache.SymbolCache`.
605 config: Framework configuration. When ``None``,
606 :func:`load_framework_config` is called with *root*.
607 implicit_cache: Optional :class:`~muse.core.implicit_edge_cache.ImplicitEdgeCache`.
608 When provided and a file's edge list is cached, the file
609 is skipped entirely — no blob fetch, no plugin run.
610
611 Returns:
612 :data:`ImplicitEdgeGraph` — ``{symbol_address: [ImplicitEntryEdge, …]}``.
613 Symbols with no implicit callers are absent from the mapping.
614 """
615 if config is None:
616 config = load_framework_config(root)
617
618 if not config.auto_detect:
619 return {}
620
621 active_plugins: list[FrameworkPlugin] = [
622 p for p in BUILTIN_PLUGINS
623 if p.framework_id not in config.disabled_plugins
624 ]
625 if config.custom_entry_points:
626 active_plugins.append(_CustomPatternPlugin(config.custom_entry_points))
627
628 if not active_plugins:
629 return {}
630
631 # Partition plugins by language so we only run Python plugins on .py files.
632 py_plugins = [p for p in active_plugins if p.language == "Python"]
633
634 graph: ImplicitEdgeGraph = {}
635
636 for file_path, obj_id in manifest.items():
637 suffix = pathlib.PurePosixPath(file_path).suffix.lower()
638
639 plugins_for_file = py_plugins if suffix in _PY_SUFFIXES else []
640 if not plugins_for_file:
641 continue
642
643 # Fast path: return cached edge list — no read_object, no plugin run.
644 if implicit_cache is not None:
645 cached = implicit_cache.get(obj_id)
646 if cached is not None:
647 for edge in cached:
648 graph.setdefault(edge.symbol_address, []).append(edge)
649 continue
650
651 raw = read_object(root, obj_id)
652 if raw is None or len(raw) > MAX_AST_BYTES:
653 continue
654
655 sym_tree: SymbolTree = (
656 cache.get(obj_id) or parse_symbols(raw, file_path)
657 if cache is not None
658 else parse_symbols(raw, file_path)
659 )
660
661 file_edges: list[ImplicitEntryEdge] = []
662 for plugin in plugins_for_file:
663 try:
664 edges = plugin.detect_entry_points(file_path, sym_tree, raw)
665 except Exception as exc: # noqa: BLE001
666 logger.warning(
667 "⚠️ %s plugin raised on %s: %s",
668 plugin.framework_id, file_path, exc,
669 )
670 continue
671 file_edges.extend(edges)
672
673 if implicit_cache is not None:
674 implicit_cache.put(obj_id, file_edges)
675
676 for edge in file_edges:
677 graph.setdefault(edge.symbol_address, []).append(edge)
678
679 return graph
680
681
682 # ---------------------------------------------------------------------------
683 # Internal helpers
684 # ---------------------------------------------------------------------------
685
686
687 def _resolve_address(
688 file_path: str,
689 func_name: str,
690 sym_tree: SymbolTree,
691 ) -> str | None:
692 """Return the full symbol address for *func_name* within *file_path*.
693
694 Tries exact match first (``file::name``), then a scan for method forms
695 (``file::ClassName.name``). Returns ``None`` if the name is not in the
696 symbol tree — this prevents synthesising edges for lambdas or inline
697 functions that the AST parser does not index.
698 """
699 direct = f"{file_path}::{func_name}"
700 if direct in sym_tree:
701 return direct
702 # Method match: "file::SomeClass.func_name"
703 for addr in sym_tree:
704 if addr.endswith(f".{func_name}") and addr.startswith(f"{file_path}::"):
705 return addr
706 return None
707
708
709 def _decorator_bare_name(dec: ast.expr) -> str | None:
710 """Return the bare decorator name for simple ``@name`` or ``@name(...)`` forms."""
711 if isinstance(dec, ast.Name):
712 return dec.id
713 if isinstance(dec, ast.Call) and isinstance(dec.func, ast.Name):
714 return dec.func.id
715 if isinstance(dec, ast.Attribute):
716 return dec.attr
717 if isinstance(dec, ast.Call) and isinstance(dec.func, ast.Attribute):
718 return dec.func.attr
719 return None
720
721
722 def _extract_methods_kwarg(call_node: ast.Call) -> list[str] | None:
723 """Extract HTTP methods from a ``methods=[...]`` keyword in a decorator call."""
724 for kw in call_node.keywords:
725 if kw.arg == "methods" and isinstance(kw.value, ast.List):
726 methods: list[str] = []
727 for elt in kw.value.elts:
728 if isinstance(elt, ast.Constant):
729 methods.append(str(elt.value).upper())
730 return methods or None
731 return None
File History 3 commits
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 132 days ago
sha256:7f9e2ef5286aedad9c1e6011b4c46ca27f39dbdad6e3409357e36b26e46b3b7c docs: docstring sprint for-each-ref→hotspots — idiomatic ru… Sonnet 4.6 patch 138 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 141 days ago