Skip to content

Commit 7167f58

Browse files
committed
fix(entrypoints): validate match patterns at load time, not mid-match
- reject unbalanced/nested { at load_rules() as RulesError instead of letting _compile raise a bare ValueError mid-analysis - break the rules.py <-> matching.py cycle by deferring the DecoratorRule import to TYPE_CHECKING - * no longer crosses a dot, matching module-member semantics - drop the unused rule param from _methods_of
1 parent b4e1635 commit 7167f58

4 files changed

Lines changed: 77 additions & 8 deletions

File tree

codeanalyzer/entrypoints/matching.py

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,21 +4,51 @@
44
so ``@route`` under ``from flask import route`` hits the same rule as
55
``@app.route``. An unresolved decorator (``qualified_name is None``) never
66
matches: under-approximate rather than guess.
7+
8+
Pattern grammar: ``{a,b}`` alternation (not nested) and a trailing/embedded
9+
``*`` that matches module MEMBERS only -- it does not cross a ``.``, so
10+
``rest_framework.viewsets.*`` matches ``ModelViewSet`` but not
11+
``viewsets.mixins.ListModelMixin``. Everything else is literal.
12+
``validate_pattern`` rejects anything outside this grammar (unbalanced or
13+
nested ``{``) so a typo in a rules file is a load-time ``RulesError``
14+
(enforced by ``rules.py``), never a crash mid-analysis.
715
"""
816
from __future__ import annotations
917

1018
import ast
1119
import re
12-
from typing import Any, Dict, Iterable, List, Optional
20+
from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Optional
1321

14-
from codeanalyzer.entrypoints.rules import DecoratorRule
1522
from codeanalyzer.schema.py_schema import PyEntrypoint
1623

24+
if TYPE_CHECKING:
25+
from codeanalyzer.entrypoints.rules import DecoratorRule
26+
1727
# Dispatch names that are HTTP verbs. DRF's ViewSet dispatch names
1828
# (list, retrieve, create, ...) are NOT verbs and must not be emitted as such.
1929
_HTTP_VERBS = {"get", "post", "put", "patch", "delete", "head", "options"}
2030

2131

32+
class PatternError(ValueError):
33+
"""A ``match`` pattern outside the ``{a,b}`` / ``*`` grammar `_compile` handles."""
34+
35+
36+
def validate_pattern(pattern: str) -> None:
37+
"""Raise ``PatternError`` for unbalanced or nested ``{``."""
38+
depth = 0
39+
for ch in pattern:
40+
if ch == "{":
41+
depth += 1
42+
if depth > 1:
43+
raise PatternError(f"nested '{{' is not supported: {pattern!r}")
44+
elif ch == "}":
45+
depth -= 1
46+
if depth < 0:
47+
raise PatternError(f"unmatched '}}': {pattern!r}")
48+
if depth != 0:
49+
raise PatternError(f"unbalanced '{{': {pattern!r}")
50+
51+
2252
def match_pattern(pattern: str, qualified_name: Optional[str]) -> bool:
2353
"""``{a,b}`` alternation and trailing ``*``; everything else is literal."""
2454
if not qualified_name:
@@ -27,6 +57,7 @@ def match_pattern(pattern: str, qualified_name: Optional[str]) -> bool:
2757

2858

2959
def _compile(pattern: str) -> str:
60+
validate_pattern(pattern)
3061
out, i = [], 0
3162
while i < len(pattern):
3263
ch = pattern[i]
@@ -36,7 +67,7 @@ def _compile(pattern: str) -> str:
3667
out.append("(?:" + "|".join(re.escape(a.strip()) for a in alts) + ")")
3768
i = j + 1
3869
elif ch == "*":
39-
out.append(r"[^\s]*")
70+
out.append(r"[^.\s]*")
4071
i += 1
4172
else:
4273
out.append(re.escape(ch))
@@ -65,7 +96,7 @@ def _route_of(dec, spec: Optional[Dict[str, Any]]) -> Optional[str]:
6596
return value if isinstance(value, str) else None
6697

6798

68-
def _methods_of(dec, rule: DecoratorRule, spec: Optional[Dict[str, Any]]) -> List[str]:
99+
def _methods_of(dec, spec: Optional[Dict[str, Any]]) -> List[str]:
69100
if not spec:
70101
return []
71102
source = spec.get("from")
@@ -82,7 +113,7 @@ def _methods_of(dec, rule: DecoratorRule, spec: Optional[Dict[str, Any]]) -> Lis
82113

83114

84115
def entrypoints_from_decorators(
85-
node, framework: str, rules: Iterable[DecoratorRule], ruleset: str
116+
node, framework: str, rules: Iterable["DecoratorRule"], ruleset: str
86117
) -> List[PyEntrypoint]:
87118
out: List[PyEntrypoint] = []
88119
for dec in getattr(node, "decorators", []) or []:
@@ -97,7 +128,7 @@ def entrypoints_from_decorators(
97128
ruleset=ruleset,
98129
evidence=dec.qualified_name,
99130
route=_route_of(dec, rule.route),
100-
http_methods=_methods_of(dec, rule, rule.methods),
131+
http_methods=_methods_of(dec, rule.methods),
101132
)
102133
)
103134
return out

codeanalyzer/entrypoints/rules.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414

1515
import yaml
1616

17+
from codeanalyzer.entrypoints.matching import PatternError, validate_pattern
18+
1719
_SHIPPED = Path(__file__).with_name("rules.yml")
1820
_CONFIDENCE = {"declared", "certain", "heuristic"}
1921

@@ -116,10 +118,19 @@ def _confidence(raw: Dict[str, Any], origin: str) -> str:
116118
return c
117119

118120

121+
def _match(raw: Dict[str, Any], origin: str) -> str:
122+
match = _require(raw, "match", origin)
123+
try:
124+
validate_pattern(match)
125+
except PatternError as exc:
126+
raise RulesError(f"{origin}: rule {raw.get('id', raw)!r}: {exc}") from exc
127+
return match
128+
129+
119130
def _decorator_rule(raw: Dict[str, Any], origin: str) -> DecoratorRule:
120131
return DecoratorRule(
121132
id=_require(raw, "id", origin),
122-
match=_require(raw, "match", origin),
133+
match=_match(raw, origin),
123134
confidence=_confidence(raw, origin),
124135
route=raw.get("route"),
125136
methods=raw.get("methods"),
@@ -129,7 +140,7 @@ def _decorator_rule(raw: Dict[str, Any], origin: str) -> DecoratorRule:
129140
def _base_rule(raw: Dict[str, Any], origin: str) -> BaseRule:
130141
return BaseRule(
131142
id=_require(raw, "id", origin),
132-
match=_require(raw, "match", origin),
143+
match=_match(raw, origin),
133144
confidence=_confidence(raw, origin),
134145
transitive=bool(raw.get("transitive", False)),
135146
dispatch=list(raw.get("dispatch") or []),

test/test_entrypoint_decorators.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,13 @@ def test_brace_alternation_and_wildcard():
1010
assert not match_pattern("flask.Flask.route", "flask.Flask.routes")
1111

1212

13+
def test_wildcard_does_not_cross_a_dot():
14+
assert match_pattern("rest_framework.viewsets.*", "rest_framework.viewsets.ModelViewSet")
15+
assert not match_pattern(
16+
"rest_framework.viewsets.*", "rest_framework.viewsets.mixins.ListModelMixin"
17+
)
18+
19+
1320
def test_route_and_methods_are_extracted():
1421
fn = PyCallable(name="h", path="a.py", signature="a.h")
1522
fn.decorators.append(

test/test_entrypoint_rules.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,3 +74,23 @@ def test_bad_confidence_value_is_rejected(tmp_path):
7474
)
7575
with pytest.raises(RulesError):
7676
load_rules([bad])
77+
78+
79+
def test_unbalanced_brace_in_match_pattern_is_rejected(tmp_path):
80+
bad = tmp_path / "unbalanced.yml"
81+
bad.write_text(
82+
"version: 1\nframeworks:\n x:\n decorators:\n"
83+
" - id: x.y\n match: 'flask.Flask.{get,post'\n"
84+
)
85+
with pytest.raises(RulesError):
86+
load_rules([bad])
87+
88+
89+
def test_nested_brace_in_match_pattern_is_rejected(tmp_path):
90+
bad = tmp_path / "nested.yml"
91+
bad.write_text(
92+
"version: 1\nframeworks:\n x:\n decorators:\n"
93+
" - id: x.y\n match: 'flask.{a,{b,c}}'\n"
94+
)
95+
with pytest.raises(RulesError):
96+
load_rules([bad])

0 commit comments

Comments
 (0)