Skip to content

Commit 1fbe900

Browse files
committed
fix(entrypoints): repoint decorator rules at Jedi's real resolution paths
Every shipped decorator rule matched the framework's public re-export path (flask.Flask.route, fastapi.FastAPI.get, celery.Celery.task, click.command, ...) but PyDecorator.qualified_name is Jedi's DEFINITION path (flask.sansio.scaffold.Scaffold.route, fastapi.applications.FastAPI.get, celery.app.base.Celery.task, click.decorators.command, ...), so none of them ever matched a real app. Verified each pattern against the installed package via jedi.Script.infer(...).full_name. Also: - add a django: block (bases: django.views.generic.* dispatch) so a Django project reports frameworks_detected: ["django"] instead of [] -- base-class matching resolves against the module's own import table, not Jedi's definition path, so the public spelling is correct there. - normalize detect.py's case handling: imported package names are now lowercased alongside the already-lowercased manifest names, and detect: values are lowercased before the membership check, so detect: [Flask] matches a real `import flask`. - reject unknown top-level rules.yml keys (e.g. the not-yet-implemented declared: block) instead of silently ignoring them.
1 parent 9986974 commit 1fbe900

7 files changed

Lines changed: 78 additions & 156 deletions

File tree

codeanalyzer/entrypoints/detect.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,15 @@
2121

2222

2323
def detected_frameworks(app: PyApplication, project_dir: Path, rules: RuleSet) -> Set[str]:
24+
# `present` (imports, manifest names) and `detect:` values are both
25+
# lowercased before comparison -- manifest names were already lowercased
26+
# (PyPI/pip is case-insensitive) but imports and `detect:` were not, so
27+
# a `detect: [Flask]` user rule silently never matched a `flask` import.
2428
present = _imported_packages(app) | _manifest_packages(project_dir)
2529
return {
2630
name
2731
for name, fw in rules.frameworks.items()
28-
if any(pkg in present for pkg in (fw.detect or [name]))
32+
if any(pkg.lower() in present for pkg in (fw.detect or [name]))
2933
}
3034

3135

@@ -38,7 +42,7 @@ def _imported_packages(app: PyApplication) -> Set[str]:
3842
spelling = (getattr(imp, "module", "") or getattr(imp, "name", "") or "")
3943
spelling = spelling.lstrip(".")
4044
if spelling:
41-
out.add(spelling.split(".", 1)[0])
45+
out.add(spelling.split(".", 1)[0].lower())
4246
return out
4347

4448

codeanalyzer/entrypoints/rules.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@
1818

1919
_SHIPPED = Path(__file__).with_name("rules.yml")
2020
_CONFIDENCE = {"declared", "certain", "heuristic"}
21+
# `declared:` (readers) and per-framework routing engines are real spec
22+
# blocks (Units 4-5) not implemented yet; they are deliberately absent here
23+
# rather than accepted-and-ignored, so a user file using them fails loudly
24+
# instead of loading clean and doing nothing.
25+
_TOP_LEVEL_KEYS = {"version", "frameworks", "disable"}
2126

2227

2328
class RulesError(Exception):
@@ -79,6 +84,9 @@ def _read(path: Path) -> Dict[str, Any]:
7984

8085

8186
def _merge(out: RuleSet, data: Dict[str, Any], origin: str) -> None:
87+
unknown = sorted(set(data) - _TOP_LEVEL_KEYS)
88+
if unknown:
89+
raise RulesError(f"{origin}: unknown top-level key(s): {', '.join(unknown)}")
8290
out.rulesets.append(origin)
8391
disabled = set(_disable_list(data, origin))
8492
frameworks = data.get("frameworks") or {}

codeanalyzer/entrypoints/rules.yml

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,16 @@ frameworks:
44
flask:
55
detect: [flask]
66
decorators:
7+
# Flask 3's Flask/Blueprint decorators are all inherited from one base
8+
# (flask.sansio.scaffold.Scaffold); matching is on Jedi's resolved
9+
# DEFINITION path, not the public `flask.Flask`/`flask.Blueprint`
10+
# spelling, so one rule now covers both call sites.
711
- id: flask.route
8-
match: "flask.Flask.route"
12+
match: "flask.sansio.scaffold.Scaffold.route"
913
route: {from: positional, index: 0}
1014
methods: {from: keyword, name: methods, default: [GET]}
1115
- id: flask.bp-verb
12-
match: "flask.Blueprint.{get,post,put,delete,patch}"
16+
match: "flask.sansio.scaffold.Scaffold.{get,post,put,delete,patch}"
1317
route: {from: positional, index: 0}
1418
methods: {from: match_suffix}
1519
bases:
@@ -21,33 +25,37 @@ frameworks:
2125
fastapi:
2226
detect: [fastapi]
2327
decorators:
28+
# FastAPI's own get/post/... are defined directly on the FastAPI class
29+
# (fastapi/applications.py); APIRouter's are a distinct class
30+
# (fastapi/routing.py) -- Jedi resolves each to its own module, so
31+
# these stay two rules.
2432
- id: fastapi.verb
25-
match: "fastapi.FastAPI.{get,post,put,delete,patch,head,options}"
33+
match: "fastapi.applications.FastAPI.{get,post,put,delete,patch,head,options}"
2634
route: {from: positional, index: 0}
2735
methods: {from: match_suffix}
2836
- id: fastapi.router-verb
29-
match: "fastapi.APIRouter.{get,post,put,delete,patch}"
37+
match: "fastapi.routing.APIRouter.{get,post,put,delete,patch}"
3038
route: {from: positional, index: 0}
3139
methods: {from: match_suffix}
3240
- id: fastapi.websocket
33-
match: "fastapi.FastAPI.websocket"
41+
match: "fastapi.applications.FastAPI.websocket"
3442
route: {from: positional, index: 0}
3543

3644
celery:
3745
detect: [celery]
3846
decorators:
3947
- id: celery.shared-task
40-
match: "celery.shared_task"
48+
match: "celery.app.shared_task"
4149
- id: celery.task
42-
match: "celery.Celery.task"
50+
match: "celery.app.base.Celery.task"
4351

4452
click:
4553
detect: [click, typer]
4654
decorators:
4755
- id: click.command
48-
match: "click.{command,group}"
56+
match: "click.decorators.{command,group}"
4957
- id: typer.command
50-
match: "typer.Typer.command"
58+
match: "typer.main.Typer.command"
5159

5260
drf:
5361
detect: [rest_framework]
@@ -65,3 +73,16 @@ frameworks:
6573
match: "rest_framework.viewsets.*"
6674
transitive: true
6775
dispatch: [list, retrieve, create, update, partial_update, destroy]
76+
77+
django:
78+
detect: [django]
79+
bases:
80+
# Base-class matching resolves against the module's own import table
81+
# (the WRITTEN spelling, e.g. `from django.views.generic import
82+
# ListView`), never Jedi's definition path -- so the public
83+
# `django.views.generic.*` path is correct here, unlike the decorator
84+
# rules above.
85+
- id: django.cbv
86+
match: "django.views.generic.*"
87+
transitive: true
88+
dispatch: [get, post, put, patch, delete, head, options]

docs/design/specs/call-site-body-convergence.md

Lines changed: 0 additions & 142 deletions
This file was deleted.

test/test_entrypoint_detect.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,16 @@ def test_absent_framework_is_not_detected(tmp_path: Path):
2727
assert "celery" not in got
2828

2929

30+
def test_detect_value_case_is_normalized_against_a_lowercase_import(tmp_path: Path):
31+
"""A ``detect: [Flask]`` user rule must fire against a real ``import
32+
flask`` -- imports are recorded lowercase, so `detect:` values need the
33+
same normalization or they silently never match (#122 review, MINOR)."""
34+
user = tmp_path / "user.yml"
35+
user.write_text("version: 1\nframeworks:\n myflask:\n detect: [Flask]\n")
36+
got = detected_frameworks(_app("flask"), tmp_path, load_rules([user]))
37+
assert "myflask" in got
38+
39+
3040
def test_manifest_entry_alone_is_sufficient(tmp_path: Path):
3141
(tmp_path / "pyproject.toml").write_text(
3242
'[project]\nname = "x"\ndependencies = ["celery>=5"]\n'

test/test_entrypoint_pipeline.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ def test_derives_is_entrypoint_from_the_list(tmp_path: Path):
3434
from codeanalyzer.schema.py_schema import PyCallable, PyDecorator, PyImport, PyModule
3535

3636
fn = PyCallable(name="f", path="a.py", signature="a.f")
37-
fn.decorators.append(PyDecorator(name="route", qualified_name="flask.Flask.route"))
37+
fn.decorators.append(PyDecorator(name="route", qualified_name="flask.sansio.scaffold.Scaffold.route"))
3838
app = PyApplication(
3939
symbol_table={
4040
"a.py": PyModule(
@@ -80,7 +80,7 @@ def test_ruleset_provenance_distinguishes_shipped_from_user_rules(tmp_path: Path
8080
)
8181

8282
shipped_fn = PyCallable(name="f", path="a.py", signature="a.f")
83-
shipped_fn.decorators.append(PyDecorator(name="app.route", qualified_name="flask.Flask.route"))
83+
shipped_fn.decorators.append(PyDecorator(name="app.route", qualified_name="flask.sansio.scaffold.Scaffold.route"))
8484
user_fn = PyCallable(name="g", path="a.py", signature="a.g")
8585
user_fn.decorators.append(PyDecorator(name="handler", qualified_name="inhouse.app.handler"))
8686

@@ -173,7 +173,7 @@ def test_running_the_pass_twice_does_not_duplicate_entrypoints(tmp_path: Path):
173173
from codeanalyzer.schema.py_schema import PyCallable, PyDecorator, PyImport, PyModule
174174

175175
fn = PyCallable(name="f", path="a.py", signature="a.f")
176-
fn.decorators.append(PyDecorator(name="route", qualified_name="flask.Flask.route"))
176+
fn.decorators.append(PyDecorator(name="route", qualified_name="flask.sansio.scaffold.Scaffold.route"))
177177
app = PyApplication(
178178
symbol_table={
179179
"a.py": PyModule(

test/test_entrypoint_rules.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,27 @@ def test_shipped_rules_load_and_include_flask():
1111
assert any(r.id == "flask.route" for r in flask.decorators)
1212

1313

14+
def test_shipped_rules_include_django_cbv_dispatch():
15+
"""A Django project must report ``frameworks_detected: ["django"]``
16+
distinct from an unsupported project, even with the routing engine
17+
(Unit 5) still absent -- the `bases:` rule works today via the
18+
import-table resolver (#122 review, IMPORTANT 2)."""
19+
rs = load_rules()
20+
assert "django" in rs.frameworks
21+
django = rs.frameworks["django"]
22+
assert "django" in django.detect
23+
cbv = next(r for r in django.bases if r.id == "django.cbv")
24+
assert cbv.match == "django.views.generic.*"
25+
assert set(cbv.dispatch) == {"get", "post", "put", "patch", "delete", "head", "options"}
26+
27+
28+
def test_unknown_top_level_key_is_rejected(tmp_path):
29+
bad = tmp_path / "bad.yml"
30+
bad.write_text("declared:\n - id: pyproject.scripts\n")
31+
with pytest.raises(RulesError, match="declared"):
32+
load_rules([bad])
33+
34+
1435
def test_every_shipped_rule_has_a_stable_id_and_valid_confidence():
1536
rs = load_rules()
1637
for fw in rs.frameworks.values():

0 commit comments

Comments
 (0)