Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion codeanalyzer/entrypoints/matching.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,8 +104,12 @@ def _methods_of(dec, spec: Optional[Dict[str, Any]], qualified: Optional[str] =
return []
source = spec.get("from")
if source == "match_suffix":
# Only a real verb: `heuristic.http-verb` also matches `.websocket`, and a
# rule may accept any suffix, but `http_methods` is what a consumer filters
# on to enumerate methods -- a value that is not one is worse there than an
# empty list (#213). The dispatch path below already filters the same way.
verb = (qualified or dec.qualified_name or "").rsplit(".", 1)[-1]
return [verb.upper()]
return [verb.upper()] if verb.lower() in _HTTP_VERBS else []
if source == "keyword":
raw = (dec.keyword_arguments or {}).get(spec.get("name", ""))
value = _literal(raw)
Expand Down
61 changes: 61 additions & 0 deletions test/test_entrypoint_decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,3 +85,64 @@ def test_heuristic_rules_match_the_written_spelling_without_a_framework():
("heuristic.http-route", "/x", [], "heuristic", "http.route"),
("heuristic.http-verb", "/y", ["POST"], "heuristic", "router.post"),
]


# --- http_methods carries HTTP methods only (#213) ---

def _probe(qualified: str, rules):
"""One decorator through the shipped rules, returning its entrypoints."""
fn = PyCallable(name="h", path="a.py", signature="a.h")
fn.decorators.append(
PyDecorator(
name=qualified,
qualified_name=qualified,
positional_arguments=["'/ws'"],
)
)
return entrypoints_from_decorators(fn, "heuristic", rules)


def _heuristics():
from codeanalyzer.entrypoints.rules import load_rules

return load_rules().heuristics


def test_a_websocket_suffix_is_not_an_http_method():
"""`heuristic.http-verb` matches `.websocket` so the entrypoint IS recorded --
but WEBSOCKET is not an HTTP method, and `http_methods` is what consumers
filter on to enumerate real verbs (#213)."""
verb_rules = [r for r in _heuristics() if r.id == "heuristic.http-verb"]
(ep,) = _probe("app.websocket", verb_rules)
assert ep.rule == "heuristic.http-verb" # still detected
assert ep.route == "/ws"
assert ep.http_methods == []


def test_a_verb_suffix_still_yields_its_verb():
verb_rules = [r for r in _heuristics() if r.id == "heuristic.http-verb"]
(ep,) = _probe("app.get", verb_rules)
assert ep.http_methods == ["GET"]


def test_no_shipped_rule_can_emit_a_non_http_method():
"""The invariant, not just the one rule that breaks it today: every literal
suffix any shipped `match_suffix` rule accepts either yields an HTTP method or
yields nothing."""
import re

from codeanalyzer.entrypoints.matching import _HTTP_VERBS

emitted = set()
for rule in _heuristics():
spec = rule.methods if isinstance(rule.methods, dict) else None
if not spec or spec.get("from") != "match_suffix":
continue
# The literal alternatives in the pattern's last segment: the suffixes this
# rule actually accepts.
tail = rule.match.rsplit(".", 1)[-1]
for suffix in re.findall(r"[A-Za-z_]+", tail):
for ep in _probe(f"app.{suffix}", [rule]):
emitted.update(ep.http_methods)
assert emitted, "no match_suffix rule exercised -- the test is not testing anything"
assert emitted <= {v.upper() for v in _HTTP_VERBS}, sorted(emitted)