Skip to content

Commit b4e1635

Browse files
committed
feat(entrypoints): decorator rule matching with route extraction (#27)
1 parent f24b3a1 commit b4e1635

2 files changed

Lines changed: 159 additions & 0 deletions

File tree

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
"""Stage 3: match rules against decorators and base classes (#27).
2+
3+
Matching is on ``PyDecorator.qualified_name`` -- never the written spelling --
4+
so ``@route`` under ``from flask import route`` hits the same rule as
5+
``@app.route``. An unresolved decorator (``qualified_name is None``) never
6+
matches: under-approximate rather than guess.
7+
"""
8+
from __future__ import annotations
9+
10+
import ast
11+
import re
12+
from typing import Any, Dict, Iterable, List, Optional
13+
14+
from codeanalyzer.entrypoints.rules import DecoratorRule
15+
from codeanalyzer.schema.py_schema import PyEntrypoint
16+
17+
# Dispatch names that are HTTP verbs. DRF's ViewSet dispatch names
18+
# (list, retrieve, create, ...) are NOT verbs and must not be emitted as such.
19+
_HTTP_VERBS = {"get", "post", "put", "patch", "delete", "head", "options"}
20+
21+
22+
def match_pattern(pattern: str, qualified_name: Optional[str]) -> bool:
23+
"""``{a,b}`` alternation and trailing ``*``; everything else is literal."""
24+
if not qualified_name:
25+
return False
26+
return re.fullmatch(_compile(pattern), qualified_name) is not None
27+
28+
29+
def _compile(pattern: str) -> str:
30+
out, i = [], 0
31+
while i < len(pattern):
32+
ch = pattern[i]
33+
if ch == "{":
34+
j = pattern.index("}", i)
35+
alts = pattern[i + 1 : j].split(",")
36+
out.append("(?:" + "|".join(re.escape(a.strip()) for a in alts) + ")")
37+
i = j + 1
38+
elif ch == "*":
39+
out.append(r"[^\s]*")
40+
i += 1
41+
else:
42+
out.append(re.escape(ch))
43+
i += 1
44+
return "".join(out)
45+
46+
47+
def _literal(text: Optional[str]) -> Any:
48+
"""Best-effort: decorator arguments are unparsed source fragments."""
49+
if text is None:
50+
return None
51+
try:
52+
return ast.literal_eval(text)
53+
except (ValueError, SyntaxError):
54+
return None
55+
56+
57+
def _route_of(dec, spec: Optional[Dict[str, Any]]) -> Optional[str]:
58+
if not spec or spec.get("from") != "positional":
59+
return None
60+
args = dec.positional_arguments or []
61+
idx = int(spec.get("index", 0))
62+
if idx >= len(args):
63+
return None
64+
value = _literal(args[idx])
65+
return value if isinstance(value, str) else None
66+
67+
68+
def _methods_of(dec, rule: DecoratorRule, spec: Optional[Dict[str, Any]]) -> List[str]:
69+
if not spec:
70+
return []
71+
source = spec.get("from")
72+
if source == "match_suffix":
73+
verb = (dec.qualified_name or "").rsplit(".", 1)[-1]
74+
return [verb.upper()]
75+
if source == "keyword":
76+
raw = (dec.keyword_arguments or {}).get(spec.get("name", ""))
77+
value = _literal(raw)
78+
if isinstance(value, (list, tuple)):
79+
return [str(v).upper() for v in value]
80+
return [str(v).upper() for v in (spec.get("default") or [])]
81+
return []
82+
83+
84+
def entrypoints_from_decorators(
85+
node, framework: str, rules: Iterable[DecoratorRule], ruleset: str
86+
) -> List[PyEntrypoint]:
87+
out: List[PyEntrypoint] = []
88+
for dec in getattr(node, "decorators", []) or []:
89+
for rule in rules:
90+
if not match_pattern(rule.match, dec.qualified_name):
91+
continue
92+
out.append(
93+
PyEntrypoint(
94+
framework=framework,
95+
confidence=rule.confidence,
96+
rule=rule.id,
97+
ruleset=ruleset,
98+
evidence=dec.qualified_name,
99+
route=_route_of(dec, rule.route),
100+
http_methods=_methods_of(dec, rule, rule.methods),
101+
)
102+
)
103+
return out

test/test_entrypoint_decorators.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
from codeanalyzer.entrypoints.matching import entrypoints_from_decorators, match_pattern
2+
from codeanalyzer.entrypoints.rules import DecoratorRule
3+
from codeanalyzer.schema.py_schema import PyCallable, PyDecorator
4+
5+
6+
def test_brace_alternation_and_wildcard():
7+
assert match_pattern("flask.Blueprint.{get,post}", "flask.Blueprint.get")
8+
assert not match_pattern("flask.Blueprint.{get,post}", "flask.Blueprint.delete")
9+
assert match_pattern("rest_framework.viewsets.*", "rest_framework.viewsets.ModelViewSet")
10+
assert not match_pattern("flask.Flask.route", "flask.Flask.routes")
11+
12+
13+
def test_route_and_methods_are_extracted():
14+
fn = PyCallable(name="h", path="a.py", signature="a.h")
15+
fn.decorators.append(
16+
PyDecorator(
17+
name="app.route",
18+
qualified_name="flask.Flask.route",
19+
positional_arguments=["'/products'"],
20+
keyword_arguments={"methods": "['POST']"},
21+
)
22+
)
23+
rule = DecoratorRule(
24+
id="flask.route",
25+
match="flask.Flask.route",
26+
route={"from": "positional", "index": 0},
27+
methods={"from": "keyword", "name": "methods", "default": ["GET"]},
28+
)
29+
(ep,) = entrypoints_from_decorators(fn, "flask", [rule], "shipped")
30+
assert ep.route == "/products"
31+
assert ep.http_methods == ["POST"]
32+
assert ep.rule == "flask.route" and ep.ruleset == "shipped"
33+
34+
35+
def test_verb_comes_from_the_matched_suffix():
36+
fn = PyCallable(name="h", path="a.py", signature="a.h")
37+
fn.decorators.append(
38+
PyDecorator(name="router.post", qualified_name="fastapi.APIRouter.post",
39+
positional_arguments=["'/x'"])
40+
)
41+
rule = DecoratorRule(
42+
id="fastapi.router-verb",
43+
match="fastapi.APIRouter.{get,post}",
44+
route={"from": "positional", "index": 0},
45+
methods={"from": "match_suffix"},
46+
)
47+
(ep,) = entrypoints_from_decorators(fn, "fastapi", [rule], "shipped")
48+
assert ep.http_methods == ["POST"]
49+
50+
51+
def test_unresolved_decorator_never_matches():
52+
"""qualified_name is None when Jedi could not resolve; must not guess."""
53+
fn = PyCallable(name="h", path="a.py", signature="a.h")
54+
fn.decorators.append(PyDecorator(name="app.route", qualified_name=None))
55+
rule = DecoratorRule(id="flask.route", match="flask.Flask.route")
56+
assert entrypoints_from_decorators(fn, "flask", [rule], "shipped") == []

0 commit comments

Comments
 (0)