Skip to content

Commit 8edc96c

Browse files
committed
test(entrypoints): integration-test shipped rules against real frameworks
Every existing entrypoint test either hand-crafts a qualified_name or uses a local in-repo decorator, so none of them would have caught the shipped rules.yml patterns drifting out of sync with what Jedi actually resolves for flask/fastapi/celery/click. Add flask, fastapi, celery and click to the `test` dependency-group (python_version >= '3.11' only: below that, ray==2.0.0's click<=8.0.4 pin conflicts with celery>=5.3's click>=8.1.2 floor) and a real, CLI-driven integration test per framework, each guarded with pytest.importorskip.
1 parent 1fbe900 commit 8edc96c

2 files changed

Lines changed: 175 additions & 0 deletions

File tree

pyproject.toml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,17 @@ test = [
6868
# Neo4j integration test (opt-in; spins up a real Neo4j via Testcontainers).
6969
"neo4j>=5.0.0,<6.0.0",
7070
"testcontainers[neo4j]>=4.0.0,<5.0.0; python_version >= '3.11'",
71+
# Real frameworks for the entrypoint-detection integration test (#27) --
72+
# rules.yml `match:` patterns are only meaningful against Jedi's actual
73+
# resolution of the installed package, never a guess at its public API path.
74+
# python_version >= '3.11' only: below that, ray==2.0.0 pins click<=8.0.4,
75+
# which conflicts with celery>=5.3's click>=8.1.2 floor. The integration
76+
# test guards each import with pytest.importorskip, so it just skips on
77+
# older interpreters rather than needing a resolvable pin here.
78+
"flask>=3.0.0,<4.0.0; python_version >= '3.11'",
79+
"fastapi>=0.100.0,<1.0.0; python_version >= '3.11'",
80+
"celery>=5.3.0,<6.0.0; python_version >= '3.11'",
81+
"click>=8.0.0,<9.0.0; python_version >= '3.11'",
7182
]
7283
dev = [
7384
"ipdb>=0.13.0,<0.14.0",
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
"""Integration coverage: shipped `rules.yml` decorator patterns against the
2+
REAL frameworks they target (#27, #122 review).
3+
4+
The shipped rules match on ``PyDecorator.qualified_name`` -- Jedi's resolved
5+
DEFINITION path, not the public import path the rules read as if they were
6+
written against (e.g. ``@app.route`` resolves to
7+
``flask.sansio.scaffold.Scaffold.route``, not ``flask.Flask.route``). Every
8+
other entrypoint test either hand-crafts a ``qualified_name`` or uses a local
9+
in-repo decorator, so none of them would notice a real framework's actual
10+
resolution path drifting out from under the shipped patterns. This is the
11+
test that would have caught it: it drives the real CLI over a tiny app built
12+
on the real, installed package.
13+
14+
Each framework is an optional test-only dependency (see the `test`
15+
dependency-group in ``pyproject.toml``); guarded with ``importorskip`` so the
16+
suite still runs where one is absent (e.g. below the ``python_version >=
17+
'3.11'`` floor those pins carry, to dodge a ``ray``/``celery`` click-version
18+
conflict below that -- see the comment in ``pyproject.toml``).
19+
"""
20+
import json
21+
import subprocess
22+
from pathlib import Path
23+
24+
import pytest
25+
26+
27+
def _run(fixture_dir: Path, out_dir: Path) -> dict:
28+
subprocess.run(
29+
[
30+
"uv", "run", "canpy",
31+
"-i", str(fixture_dir),
32+
"-a", "1",
33+
"-o", str(out_dir),
34+
"--no-venv",
35+
# Cache defaults to the input dir; keep it in out_dir so the
36+
# fixture directory is never mutated and each run starts cold.
37+
"--cache-dir", str(out_dir / "cache"),
38+
],
39+
check=True,
40+
)
41+
return json.loads((out_dir / "analysis.json").read_text())
42+
43+
44+
def test_flask_route_and_verb_decorators_are_flagged(tmp_path):
45+
pytest.importorskip("flask")
46+
app_dir = tmp_path / "src"
47+
app_dir.mkdir()
48+
(app_dir / "app.py").write_text(
49+
"from flask import Flask\n"
50+
"\n"
51+
"app = Flask(__name__)\n"
52+
"\n"
53+
"\n"
54+
"@app.route('/products', methods=['POST'])\n"
55+
"def create_product():\n"
56+
" return 'ok'\n"
57+
"\n"
58+
"\n"
59+
"@app.get('/products')\n"
60+
"def list_products():\n"
61+
" return []\n"
62+
)
63+
data = _run(app_dir, tmp_path / "out")
64+
fns = data["application"]["symbol_table"]["app.py"]["functions"]
65+
66+
(ep,) = fns["create_product"]["entrypoints"]
67+
assert ep["framework"] == "flask" and ep["rule"] == "flask.route"
68+
assert ep["route"] == "/products" and ep["http_methods"] == ["POST"]
69+
70+
(ep,) = fns["list_products"]["entrypoints"]
71+
assert ep["framework"] == "flask" and ep["rule"] == "flask.bp-verb"
72+
assert ep["route"] == "/products" and ep["http_methods"] == ["GET"]
73+
74+
assert "flask" in data["application"]["entrypoint_report"]["frameworks_detected"]
75+
assert data["application"]["entrypoint_report"]["errors"] == []
76+
77+
78+
def test_fastapi_get_and_router_post_decorators_are_flagged(tmp_path):
79+
pytest.importorskip("fastapi")
80+
app_dir = tmp_path / "src"
81+
app_dir.mkdir()
82+
(app_dir / "app.py").write_text(
83+
"from fastapi import APIRouter, FastAPI\n"
84+
"\n"
85+
"api = FastAPI()\n"
86+
"router = APIRouter()\n"
87+
"\n"
88+
"\n"
89+
"@api.get('/items')\n"
90+
"def read_items():\n"
91+
" return []\n"
92+
"\n"
93+
"\n"
94+
"@router.post('/items')\n"
95+
"def create_item():\n"
96+
" return {}\n"
97+
)
98+
data = _run(app_dir, tmp_path / "out")
99+
fns = data["application"]["symbol_table"]["app.py"]["functions"]
100+
101+
(ep,) = fns["read_items"]["entrypoints"]
102+
assert ep["framework"] == "fastapi" and ep["rule"] == "fastapi.verb"
103+
assert ep["route"] == "/items" and ep["http_methods"] == ["GET"]
104+
105+
(ep,) = fns["create_item"]["entrypoints"]
106+
assert ep["framework"] == "fastapi" and ep["rule"] == "fastapi.router-verb"
107+
assert ep["route"] == "/items" and ep["http_methods"] == ["POST"]
108+
109+
assert "fastapi" in data["application"]["entrypoint_report"]["frameworks_detected"]
110+
assert data["application"]["entrypoint_report"]["errors"] == []
111+
112+
113+
def test_celery_shared_task_and_app_task_decorators_are_flagged(tmp_path):
114+
pytest.importorskip("celery")
115+
app_dir = tmp_path / "src"
116+
app_dir.mkdir()
117+
(app_dir / "app.py").write_text(
118+
"from celery import Celery, shared_task\n"
119+
"\n"
120+
"cel = Celery('x')\n"
121+
"\n"
122+
"\n"
123+
"@shared_task\n"
124+
"def add(x, y):\n"
125+
" return x + y\n"
126+
"\n"
127+
"\n"
128+
"@cel.task\n"
129+
"def mul(x, y):\n"
130+
" return x * y\n"
131+
)
132+
data = _run(app_dir, tmp_path / "out")
133+
fns = data["application"]["symbol_table"]["app.py"]["functions"]
134+
135+
(ep,) = fns["add"]["entrypoints"]
136+
assert ep["framework"] == "celery" and ep["rule"] == "celery.shared-task"
137+
138+
(ep,) = fns["mul"]["entrypoints"]
139+
assert ep["framework"] == "celery" and ep["rule"] == "celery.task"
140+
141+
assert "celery" in data["application"]["entrypoint_report"]["frameworks_detected"]
142+
assert data["application"]["entrypoint_report"]["errors"] == []
143+
144+
145+
def test_click_command_decorator_is_flagged(tmp_path):
146+
pytest.importorskip("click")
147+
app_dir = tmp_path / "src"
148+
app_dir.mkdir()
149+
(app_dir / "app.py").write_text(
150+
"import click\n"
151+
"\n"
152+
"\n"
153+
"@click.command()\n"
154+
"def cli():\n"
155+
" pass\n"
156+
)
157+
data = _run(app_dir, tmp_path / "out")
158+
fns = data["application"]["symbol_table"]["app.py"]["functions"]
159+
160+
(ep,) = fns["cli"]["entrypoints"]
161+
assert ep["framework"] == "click" and ep["rule"] == "click.command"
162+
163+
assert "click" in data["application"]["entrypoint_report"]["frameworks_detected"]
164+
assert data["application"]["entrypoint_report"]["errors"] == []

0 commit comments

Comments
 (0)