Skip to content

Commit 8f4d412

Browse files
committed
feat(entrypoints): rules.yml loader with shipped framework pack (#27)
1 parent 715bf66 commit 8f4d412

4 files changed

Lines changed: 228 additions & 0 deletions

File tree

codeanalyzer/entrypoints/rules.py

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
"""Loading and merging of entrypoint rules (#27).
2+
3+
The shipped ``rules.yml`` covers known frameworks; users extend it with
4+
``--entrypoint-rules``. User rules merge additively and may ``disable:`` a
5+
shipped rule by id. A malformed user file is a hard error before analysis
6+
starts -- silently skipping it would let someone ship rules they believe
7+
are live.
8+
"""
9+
from __future__ import annotations
10+
11+
from dataclasses import dataclass, field
12+
from pathlib import Path
13+
from typing import Any, Dict, Iterable, List, Optional
14+
15+
import yaml
16+
17+
_SHIPPED = Path(__file__).with_name("rules.yml")
18+
_CONFIDENCE = {"declared", "certain", "heuristic"}
19+
20+
21+
class RulesError(Exception):
22+
"""Raised for a malformed rules file. Never swallowed."""
23+
24+
25+
@dataclass
26+
class DecoratorRule:
27+
id: str
28+
match: str
29+
confidence: str = "certain"
30+
route: Optional[Dict[str, Any]] = None
31+
methods: Optional[Dict[str, Any]] = None
32+
33+
34+
@dataclass
35+
class BaseRule:
36+
id: str
37+
match: str
38+
confidence: str = "certain"
39+
transitive: bool = False
40+
dispatch: List[str] = field(default_factory=list)
41+
42+
43+
@dataclass
44+
class Framework:
45+
name: str
46+
detect: List[str] = field(default_factory=list)
47+
decorators: List[DecoratorRule] = field(default_factory=list)
48+
bases: List[BaseRule] = field(default_factory=list)
49+
50+
51+
@dataclass
52+
class RuleSet:
53+
frameworks: Dict[str, Framework] = field(default_factory=dict)
54+
rulesets: List[str] = field(default_factory=list)
55+
56+
57+
def load_rules(user_paths: Iterable[Path] = ()) -> RuleSet:
58+
out = RuleSet()
59+
_merge(out, _read(_SHIPPED), "shipped")
60+
for p in user_paths:
61+
_merge(out, _read(Path(p)), f"user:{p}")
62+
return out
63+
64+
65+
def _read(path: Path) -> Dict[str, Any]:
66+
try:
67+
data = yaml.safe_load(path.read_text())
68+
except FileNotFoundError as exc:
69+
raise RulesError(f"rules file not found: {path}") from exc
70+
except yaml.YAMLError as exc:
71+
raise RulesError(f"{path}: invalid YAML: {exc}") from exc
72+
if not isinstance(data, dict):
73+
raise RulesError(f"{path}: top level must be a mapping")
74+
return data
75+
76+
77+
def _merge(out: RuleSet, data: Dict[str, Any], origin: str) -> None:
78+
out.rulesets.append(origin)
79+
disabled = set(data.get("disable") or [])
80+
frameworks = data.get("frameworks") or {}
81+
if not isinstance(frameworks, dict):
82+
raise RulesError(f"{origin}: `frameworks` must be a mapping")
83+
84+
for name, body in frameworks.items():
85+
if not isinstance(body, dict):
86+
raise RulesError(f"{origin}: framework `{name}` must be a mapping")
87+
fw = out.frameworks.setdefault(name, Framework(name=name))
88+
fw.detect = sorted(set(fw.detect) | set(body.get("detect") or []))
89+
for raw in body.get("decorators") or []:
90+
fw.decorators.append(_decorator_rule(raw, origin))
91+
for raw in body.get("bases") or []:
92+
fw.bases.append(_base_rule(raw, origin))
93+
94+
for fw in out.frameworks.values():
95+
fw.decorators = [r for r in fw.decorators if r.id not in disabled]
96+
fw.bases = [r for r in fw.bases if r.id not in disabled]
97+
98+
99+
def _require(raw: Dict[str, Any], key: str, origin: str) -> Any:
100+
if key not in raw:
101+
raise RulesError(f"{origin}: rule {raw!r} is missing `{key}`")
102+
return raw[key]
103+
104+
105+
def _confidence(raw: Dict[str, Any], origin: str) -> str:
106+
c = raw.get("confidence", "certain")
107+
if c not in _CONFIDENCE:
108+
raise RulesError(f"{origin}: confidence must be one of {sorted(_CONFIDENCE)}, got {c!r}")
109+
return c
110+
111+
112+
def _decorator_rule(raw: Dict[str, Any], origin: str) -> DecoratorRule:
113+
return DecoratorRule(
114+
id=_require(raw, "id", origin),
115+
match=_require(raw, "match", origin),
116+
confidence=_confidence(raw, origin),
117+
route=raw.get("route"),
118+
methods=raw.get("methods"),
119+
)
120+
121+
122+
def _base_rule(raw: Dict[str, Any], origin: str) -> BaseRule:
123+
return BaseRule(
124+
id=_require(raw, "id", origin),
125+
match=_require(raw, "match", origin),
126+
confidence=_confidence(raw, origin),
127+
transitive=bool(raw.get("transitive", False)),
128+
dispatch=list(raw.get("dispatch") or []),
129+
)

codeanalyzer/entrypoints/rules.yml

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
version: 1
2+
3+
frameworks:
4+
flask:
5+
detect: [flask]
6+
decorators:
7+
- id: flask.route
8+
match: "flask.Flask.route"
9+
route: {from: positional, index: 0}
10+
methods: {from: keyword, name: methods, default: [GET]}
11+
- id: flask.bp-verb
12+
match: "flask.Blueprint.{get,post,put,delete,patch}"
13+
route: {from: positional, index: 0}
14+
methods: {from: match_suffix}
15+
bases:
16+
- id: flask.methodview
17+
match: "flask.views.MethodView"
18+
transitive: true
19+
dispatch: [get, post, put, delete, patch]
20+
21+
fastapi:
22+
detect: [fastapi]
23+
decorators:
24+
- id: fastapi.verb
25+
match: "fastapi.FastAPI.{get,post,put,delete,patch,head,options}"
26+
route: {from: positional, index: 0}
27+
methods: {from: match_suffix}
28+
- id: fastapi.router-verb
29+
match: "fastapi.APIRouter.{get,post,put,delete,patch}"
30+
route: {from: positional, index: 0}
31+
methods: {from: match_suffix}
32+
- id: fastapi.websocket
33+
match: "fastapi.FastAPI.websocket"
34+
route: {from: positional, index: 0}
35+
36+
celery:
37+
detect: [celery]
38+
decorators:
39+
- id: celery.shared-task
40+
match: "celery.shared_task"
41+
- id: celery.task
42+
match: "celery.Celery.task"
43+
44+
click:
45+
detect: [click, typer]
46+
decorators:
47+
- id: click.command
48+
match: "click.{command,group}"
49+
- id: typer.command
50+
match: "typer.Typer.command"
51+
52+
drf:
53+
detect: [rest_framework]
54+
decorators:
55+
- id: drf.api-view
56+
match: "rest_framework.decorators.api_view"
57+
- id: drf.action
58+
match: "rest_framework.decorators.action"
59+
bases:
60+
- id: drf.apiview
61+
match: "rest_framework.views.APIView"
62+
transitive: true
63+
dispatch: [get, post, put, patch, delete, head, options]
64+
- id: drf.viewset
65+
match: "rest_framework.viewsets.*"
66+
transitive: true
67+
dispatch: [list, retrieve, create, update, partial_update, destroy]

pyproject.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,9 @@ dependencies = [
4747
# (scalpel/SSA/const.py uses astor.to_source). Pure-Python, installs
4848
# everywhere; scalpel pins ~=0.8.1.
4949
"astor>=0.8.1,<0.9.0",
50+
# pyyaml: the entrypoint rules pack (#27) is YAML. Declared explicitly --
51+
# it was previously only reachable as a transitive dep of ray.
52+
"pyyaml>=6.0,<7.0",
5053
]
5154

5255
[project.optional-dependencies]
@@ -56,6 +59,9 @@ neo4j = [
5659
"neo4j>=5.0.0,<6.0.0",
5760
]
5861

62+
[tool.setuptools.package-data]
63+
codeanalyzer = ["entrypoints/*.yml"]
64+
5965
[dependency-groups]
6066
test = [
6167
"pytest>=7.0.0,<8.0.0",

test/test_entrypoint_rules.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import pytest
2+
3+
from codeanalyzer.entrypoints.rules import RulesError, load_rules
4+
5+
6+
def test_shipped_rules_load_and_include_flask():
7+
rs = load_rules()
8+
assert "flask" in rs.frameworks
9+
flask = rs.frameworks["flask"]
10+
assert "flask" in flask.detect
11+
assert any(r.id == "flask.route" for r in flask.decorators)
12+
13+
14+
def test_every_shipped_rule_has_a_stable_id_and_valid_confidence():
15+
rs = load_rules()
16+
for fw in rs.frameworks.values():
17+
for rule in list(fw.decorators) + list(fw.bases):
18+
assert rule.id, "every rule needs a stable id so users can disable it"
19+
assert rule.confidence in {"declared", "certain", "heuristic"}
20+
21+
22+
def test_malformed_user_file_raises_before_analysis(tmp_path):
23+
bad = tmp_path / "bad.yml"
24+
bad.write_text("frameworks: [this is a list not a mapping]\n")
25+
with pytest.raises(RulesError):
26+
load_rules([bad])

0 commit comments

Comments
 (0)