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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@

All notable changes are documented here.

## Unreleased

### Fixed

- The Python adapter labeled every same-method route in a module with the
first decorator's path, because it looked the path up by the decorator's
dotted name across the whole file. Each route now reads the decorator call
that sits above its own declaration, so a module with 57 routes stops
collapsing onto four names and capability matching runs on the real paths.

## 0.9.0 - 2026-09-03

### Added
Expand Down
32 changes: 27 additions & 5 deletions adapters/python/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import {
stableId,
templateVariables,
} from "@agent-runtime-map/analysis-kit";
import type { PythonCall, PythonFacts, PythonFile } from "./facts.js";
import type { PythonCall, PythonFacts, PythonFile, PythonFunction } from "./facts.js";

const SOURCE_EXTENSIONS = new Set([".py"]);
const EXCLUDED_FILE_PATTERN = /(^test_.*\.py$|_test\.py$|^conftest\.py$|\.pyi$)/i;
Expand Down Expand Up @@ -157,7 +157,7 @@ function collectFile(
}

for (const declaration of file.functions) {
const route = routeFromDecorators(declaration.decorators, file);
const route = routeFromDecorators(declaration, file);
const classification = classifyDeclaration({
relativeFile,
name: declaration.name,
Expand Down Expand Up @@ -441,19 +441,41 @@ interface RouteConvention {
decorator: string;
}

function routeFromDecorators(decorators: string[], file: PythonFile): RouteConvention | undefined {
for (const decorator of decorators) {
function routeFromDecorators(declaration: PythonFunction, file: PythonFile): RouteConvention | undefined {
for (const decorator of declaration.decorators) {
const match = ROUTE_DECORATOR.exec(decorator);
if (!match) continue;
const owner = match[1]!;
const method = match[2]!.toUpperCase();
const call = file.calls.find((item) => item.callee === decorator && item.stringArguments.length);
const call = decoratorCall(decorator, declaration, file);
const routePath = call?.stringArguments[0] ?? "/";
return { method, path: routePath, framework: frameworkForOwner(owner, file), decorator };
}
return undefined;
}

/**
* The path a decorator registers belongs to the declaration it sits above. Searching
* the file's calls by dotted name alone returns the first `app.post(...)` in the
* module, which labels every same-method route in a file with the first one's path.
* The extractor records a decorator call inside the scope of the function it
* decorates, on a line above the `def`, so the nearest such call above the
* declaration is this route's own registration. `enclosingClass` is deliberately not
* compared: a route defined inside a method carries the class on the call but not on
* the declaration, and the nearest-above rule already separates same-named methods.
*/
function decoratorCall(decorator: string, declaration: PythonFunction, file: PythonFile): PythonCall | undefined {
let nearest: PythonCall | undefined;
for (const item of file.calls) {
if (item.callee !== decorator) continue;
if (item.stringArguments.length === 0) continue;
if (item.enclosingFunction !== declaration.name) continue;
if (item.line > declaration.line) continue;
if (!nearest || item.line > nearest.line) nearest = item;
}
return nearest;
}

function frameworkForOwner(owner: string, file: PythonFile): string {
const built = file.assignments.find((item) => item.name === owner && item.factory && ROUTE_APP_FACTORIES.test(item.factory));
if (!built?.factory) return "python_http";
Expand Down
91 changes: 91 additions & 0 deletions tests/python-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,97 @@ describe("Python adapter", () => {
expect(python.method).toBe(typescript.method);
});

it("labels every route in a module with its own decorator path", async () => {
// One route per file hides this: the file-wide lookup and the per-declaration
// lookup return the same call. It takes two same-method routes to show up.
const root = await mkdtemp(path.join(os.tmpdir(), "logic-map-py-routes-"));
temporaryDirectories.push(root);
await writeFile(path.join(root, "app.py"), [
"from flask import Flask",
"",
"app = Flask(__name__)",
"",
"@app.get(\"/health\")",
"def health():",
" return {\"ok\": True}",
"",
"@app.post(\"/api/first\")",
"def first_handler():",
" return {\"n\": 1}",
"",
"@app.post(\"/api/second\")",
"def second_handler():",
" return {\"n\": 2}",
"",
"@app.post(\"/api/third\")",
"def third_handler():",
" return {\"n\": 3}",
"",
].join("\n"));

const raw = await analyzePythonProject(root);
const routes = new Map(raw.nodes
.filter((node) => node.kind === "route")
.map((node) => [node.qualifiedName, node]));

expect(routes.get("app.py#health")?.metadata?.path).toBe("/health");
expect(routes.get("app.py#first_handler")?.metadata?.path).toBe("/api/first");
expect(routes.get("app.py#second_handler")?.metadata?.path).toBe("/api/second");
expect(routes.get("app.py#third_handler")?.metadata?.path).toBe("/api/third");
// The node name is what the Viewer and the capability matcher read, so it has to
// carry the same path, and four endpoints have to read as four endpoints.
expect(new Set([...routes.values()].map((node) => node.name)).size).toBe(4);
expect(routes.get("app.py#third_handler")?.name).toBe("POST /api/third");
});

it("picks the decorator above the declaration, not the first one that matches", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "logic-map-py-route-scopes-"));
temporaryDirectories.push(root);
await writeFile(path.join(root, "app.py"), [
"from flask import Flask",
"",
"app = Flask(__name__)",
"",
"class Orders:",
" @app.get(\"/api/orders\")",
" def handle(self):",
" return []",
"",
"class Invoices:",
" @app.get(\"/api/invoices\")",
" def handle(self):",
" return []",
"",
"def register():",
" @app.get(\"/api/late\")",
" def late_handler():",
" return []",
"",
" return late_handler",
"",
"class Wiring:",
" def wire(self):",
" @app.get(\"/api/wired\")",
" def wired_handler():",
" return []",
"",
" return wired_handler",
"",
].join("\n"));

const raw = await analyzePythonProject(root);
const paths = raw.nodes
.filter((node) => node.kind === "route")
.map((node) => node.metadata?.path);

// Two methods share a name, so the enclosing scope cannot be the only tie-break.
// The last two are routes declared inside another declaration: the extractor puts
// the class on the decorator call but not on the nested declaration, so comparing
// the two would lose the path rather than sharpen it.
expect(paths).toEqual(expect.arrayContaining(["/api/orders", "/api/invoices", "/api/late", "/api/wired"]));
expect(paths).not.toContain("/");
});

it("reports a file the interpreter cannot parse instead of guessing at it", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "logic-map-py-broken-"));
temporaryDirectories.push(root);
Expand Down
Loading