Skip to content

Commit f7f181e

Browse files
committed
feat(entrypoints): inheritance rules and the class/method dispatch split (#27)
1 parent 7167f58 commit f7f181e

3 files changed

Lines changed: 117 additions & 3 deletions

File tree

codeanalyzer/entrypoints/matching.py

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,12 @@
1717

1818
import ast
1919
import re
20-
from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Optional
20+
from typing import TYPE_CHECKING, Any, Callable, Dict, Iterable, List, Optional, Tuple
2121

2222
from codeanalyzer.schema.py_schema import PyEntrypoint
2323

2424
if TYPE_CHECKING:
25-
from codeanalyzer.entrypoints.rules import DecoratorRule
25+
from codeanalyzer.entrypoints.rules import BaseRule, DecoratorRule
2626

2727
# Dispatch names that are HTTP verbs. DRF's ViewSet dispatch names
2828
# (list, retrieve, create, ...) are NOT verbs and must not be emitted as such.
@@ -132,3 +132,52 @@ def entrypoints_from_decorators(
132132
)
133133
)
134134
return out
135+
136+
137+
def entrypoints_from_bases(
138+
cls,
139+
framework: str,
140+
rules: Iterable["BaseRule"],
141+
ruleset: str,
142+
resolve: Callable[[str], Optional[str]],
143+
) -> Tuple[List[PyEntrypoint], Dict[str, List[PyEntrypoint]]]:
144+
"""Records for a routed class and for the methods the framework dispatches.
145+
146+
``resolve`` maps a written base-class name to its resolved qualified name
147+
(identity when already qualified). Dispatch names are intersected with the
148+
methods the class actually defines, so a ``ListView`` with only ``get``
149+
gains no phantom ``post`` entrypoint.
150+
"""
151+
class_eps: List[PyEntrypoint] = []
152+
method_eps: Dict[str, List[PyEntrypoint]] = {}
153+
154+
for rule in rules:
155+
if not any(
156+
match_pattern(rule.match, resolve(b) or b) for b in (cls.base_classes or [])
157+
):
158+
continue
159+
class_eps.append(
160+
PyEntrypoint(
161+
framework=framework,
162+
confidence=rule.confidence,
163+
rule=rule.id,
164+
ruleset=ruleset,
165+
evidence=cls.signature,
166+
)
167+
)
168+
defined = set((cls.callables or {}).keys())
169+
for name in rule.dispatch:
170+
if name not in defined:
171+
continue
172+
method_eps.setdefault(name, []).append(
173+
PyEntrypoint(
174+
framework=framework,
175+
confidence=rule.confidence,
176+
rule=f"{rule.id}.dispatch",
177+
ruleset=ruleset,
178+
evidence=cls.signature,
179+
http_methods=[name.upper()] if name in _HTTP_VERBS else [],
180+
via=cls.id or None,
181+
)
182+
)
183+
return class_eps, method_eps

codeanalyzer/entrypoints/pipeline.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from typing import Iterable, Iterator
1111

1212
from codeanalyzer.entrypoints.detect import detected_frameworks
13+
from codeanalyzer.entrypoints.matching import entrypoints_from_bases, entrypoints_from_decorators
1314
from codeanalyzer.entrypoints.rules import RuleSet, load_rules
1415
from codeanalyzer.schema.py_schema import PyApplication, PyCallable, PyClass
1516
from codeanalyzer.utils import logger
@@ -36,11 +37,29 @@ def detect_entrypoints(
3637

3738

3839
def _run_stages(app: PyApplication, project_dir: Path, rules: RuleSet) -> None:
39-
"""Stages 0-4. Only stage 0 (framework detection) exists so far."""
40+
"""Stages 0-4. Stage 0 (framework detection) and Stage 3 (decorator and
41+
base-class matching) exist so far."""
4042
app.entrypoint_report.rulesets = list(rules.rulesets)
4143
frameworks = detected_frameworks(app, project_dir, rules)
4244
app.entrypoint_report.frameworks_detected = sorted(frameworks)
4345

46+
ruleset_name = rules.rulesets[-1] if len(rules.rulesets) > 1 else "shipped"
47+
for name in sorted(frameworks):
48+
fw = rules.frameworks[name]
49+
for node in _walk(app):
50+
node.entrypoints.extend(
51+
entrypoints_from_decorators(node, name, fw.decorators, ruleset_name)
52+
)
53+
if isinstance(node, PyClass) and fw.bases:
54+
class_eps, method_eps = entrypoints_from_bases(
55+
node, name, fw.bases, ruleset_name, lambda b: b
56+
)
57+
node.entrypoints.extend(class_eps)
58+
for method_name, eps in method_eps.items():
59+
target = (node.callables or {}).get(method_name)
60+
if target is not None:
61+
target.entrypoints.extend(eps)
62+
4463

4564
def _derive_flags(app: PyApplication) -> None:
4665
for node in _walk(app):

test/test_entrypoint_bases.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
from codeanalyzer.entrypoints.matching import entrypoints_from_bases
2+
from codeanalyzer.entrypoints.rules import BaseRule
3+
from codeanalyzer.schema.py_schema import PyCallable, PyClass
4+
5+
RULE = BaseRule(
6+
id="drf.apiview",
7+
match="rest_framework.views.APIView",
8+
transitive=True,
9+
dispatch=["get", "post", "put"],
10+
)
11+
12+
13+
def _cls(*methods: str, bases=("rest_framework.views.APIView",)) -> PyClass:
14+
return PyClass(
15+
name="V",
16+
signature="a.V",
17+
base_classes=list(bases),
18+
callables={m: PyCallable(name=m, path="a.py", signature=f"a.V.{m}") for m in methods},
19+
)
20+
21+
22+
def test_class_is_flagged_and_only_defined_methods_dispatch():
23+
cls = _cls("get") # defines get, not post
24+
class_eps, method_eps = entrypoints_from_bases(cls, "drf", [RULE], "shipped", lambda b: b)
25+
assert len(class_eps) == 1
26+
assert list(method_eps) == ["get"], "no phantom post entrypoint"
27+
28+
29+
def test_methods_point_back_at_the_routed_class_via():
30+
cls = _cls("get")
31+
cls.id = "can://python/app/a.py/V"
32+
_, method_eps = entrypoints_from_bases(cls, "drf", [RULE], "shipped", lambda b: b)
33+
assert method_eps["get"][0].via == "can://python/app/a.py/V"
34+
35+
36+
def test_transitive_base_resolves_one_hop():
37+
cls = _cls("get", bases=("app.BaseView",))
38+
resolve = {"app.BaseView": "rest_framework.views.APIView"}.get
39+
class_eps, _ = entrypoints_from_bases(cls, "drf", [RULE], "shipped", resolve)
40+
assert len(class_eps) == 1
41+
42+
43+
def test_unrelated_class_is_not_flagged():
44+
cls = _cls("get", bases=("object",))
45+
class_eps, method_eps = entrypoints_from_bases(cls, "drf", [RULE], "shipped", lambda b: b)
46+
assert class_eps == [] and method_eps == {}

0 commit comments

Comments
 (0)