mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-18 05:41:18 +08:00
fix(flask): add_url_rule and view_func registrations are routes (#2154)
`bp.add_url_rule('/path', 'name', view_func | positional view, methods=[…])`
and a project's own wrapper that hands a list of paths and a `view_func=`
over (flaskbb's `register_view(auth, routes=[…], view_func=X.as_view(…))`)
now produce route nodes linked to the view. A class-based view is ANY unless
`methods=` says; a function defaults to GET. A call whose path isn't a
string literal (the wrapper's own `add_url_rule(route, …)`) adds nothing.
flaskbb 0 → 103 routes (+104 edges); flask's own tests +27; CTFd, microblog,
bluelog byte-identical.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
### Fixes
|
||||
|
||||
- Flask routes registered with `add_url_rule(…)` are now found and linked to their view function or class-based view. So are routes registered through a project's own helper that passes a list of paths and a `view_func=`. flaskbb, which registers every view that way, went from no routes to over 100.
|
||||
- In Lua and Luau, a call through a standard or host library table (`string`, `table`, `io`, `vim`, `ngx`, busted's `assert`…) or a string method like `line:find(…)` no longer lands on the one project method with the same name. Functions the project itself defines on those tables still link. On kong, the specs' `assert.truthy(…)` and `assert.falsy(…)` used to land on a condition helper over 1,700 times; on telescope, `line:find(…)` on the picker's `find`.
|
||||
- In PHP, a call through a variable nothing types no longer lands on an unrelated class's method just because it's the only method with that name. The variable now needs to be named after the method's class, or after a class that inherits it (`$page->save()` where `Page` extends `Entity`). On guzzle, the tests' `$response->getHeaderLine()` used to land on a test double; on symfony/console, `$e->getMessage()` on a progress bar.
|
||||
- In C#, a bare name like `TestContext`, `Easing` or `Helper()` now resolves only to a member of the class it's written in, its outer classes, their base classes, or a type brought in with `using static`. That includes a `global using static` and a project file's `<Using … Static="true"/>`. On eShop, test classes' `TestContext` used to land on one other test class's property; on Newtonsoft.Json, `TypeNameHandling.All` now reaches the enum rather than a same-named property.
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Flask's imperative registration is a route too: `bp.add_url_rule(…)` with a
|
||||
* `view_func=` (a function, or a class-based view's `X.as_view('name')`), and
|
||||
* a project's own wrapper that hands a list of paths and a `view_func=` over
|
||||
* — flaskbb registers every view through `register_view(bp, routes=[…],
|
||||
* view_func=…)` and had no routes at all.
|
||||
*/
|
||||
import { describe, it, expect, afterAll, beforeAll } from 'vitest';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { CodeGraph } from '../src';
|
||||
|
||||
let root = '';
|
||||
let cg: CodeGraph;
|
||||
|
||||
beforeAll(async () => {
|
||||
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-flask-rules-'));
|
||||
const files: Record<string, string> = {
|
||||
'requirements.txt': 'flask\n',
|
||||
'app/helpers.py': `def register_view(bp_or_app, routes, view_func, **kwargs):
|
||||
for route in routes:
|
||||
bp_or_app.add_url_rule(route, view_func=view_func, **kwargs)
|
||||
`,
|
||||
'app/auth.py': `from flask import Blueprint
|
||||
from flask.views import MethodView
|
||||
from app.helpers import register_view
|
||||
|
||||
auth = Blueprint("auth", __name__)
|
||||
|
||||
|
||||
class Login(MethodView):
|
||||
def get(self):
|
||||
return "form"
|
||||
|
||||
def post(self):
|
||||
return "ok"
|
||||
|
||||
|
||||
def logout():
|
||||
return "bye"
|
||||
|
||||
|
||||
register_view(auth, routes=["/login", "/signin"], view_func=Login.as_view("login"))
|
||||
auth.add_url_rule("/logout", "logout", logout, methods=["POST"])
|
||||
auth.add_url_rule("/health", view_func=logout)
|
||||
`,
|
||||
};
|
||||
for (const [rel, content] of Object.entries(files)) {
|
||||
fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, rel), content);
|
||||
}
|
||||
cg = await CodeGraph.init(root, { index: true });
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
cg?.close();
|
||||
if (root) fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('Flask add_url_rule routes', () => {
|
||||
it('names each path and method, and links the view', () => {
|
||||
const routes = cg.getNodesByKind('route').filter((r) => r.filePath === 'app/auth.py');
|
||||
const handler = (name: string) => {
|
||||
const route = routes.find((r) => r.name === name)!;
|
||||
return cg.getOutgoingEdges(route.id).map((e) => cg.getNode(e.target)!.name);
|
||||
};
|
||||
expect(routes.map((r) => r.name).sort()).toEqual(['ANY /login', 'ANY /signin', 'GET /health', 'POST /logout']);
|
||||
expect(handler('ANY /login')).toContain('Login');
|
||||
expect(handler('POST /logout')).toContain('logout');
|
||||
// The wrapper's own `add_url_rule(route, …)` names no path: no route there.
|
||||
expect(cg.getNodesByKind('route').some((r) => r.filePath === 'app/helpers.py')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -223,9 +223,10 @@ export const flaskResolver: FrameworkResolver = {
|
||||
language: 'python',
|
||||
});
|
||||
const restful = extractFlaskRestful(filePath, safe);
|
||||
const rules = extractFlaskUrlRules(filePath, safe);
|
||||
return {
|
||||
nodes: [...decorator.nodes, ...restful.nodes],
|
||||
references: [...decorator.references, ...restful.references],
|
||||
nodes: [...decorator.nodes, ...restful.nodes, ...rules.nodes],
|
||||
references: [...decorator.references, ...restful.references, ...rules.references],
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -537,6 +538,96 @@ function extractFlaskRestful(filePath: string, safe: string): FrameworkExtractio
|
||||
return { nodes, references };
|
||||
}
|
||||
|
||||
/**
|
||||
* Flask's imperative registration: `bp.add_url_rule('/path', 'name',
|
||||
* view_func=View.as_view('name'), methods=[…])`, and a project's own wrapper
|
||||
* around it — any call handing a list of path strings and a `view_func=`
|
||||
* over, like flaskbb's `register_view(auth, routes=["/login"],
|
||||
* view_func=Login.as_view("login"))`. A class-based view (`X.as_view(…)`)
|
||||
* serves whatever verbs it defines, so its route is ANY unless `methods=`
|
||||
* says; a plain function defaults to GET, as Flask does.
|
||||
*/
|
||||
function extractFlaskUrlRules(filePath: string, safe: string): FrameworkExtractionResult {
|
||||
const nodes: Node[] = [];
|
||||
const references: UnresolvedRef[] = [];
|
||||
const now = Date.now();
|
||||
const seen = new Set<string>();
|
||||
const opener = /(\.add_url_rule|\b[A-Za-z_]\w*)\s*\(/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = opener.exec(safe)) !== null) {
|
||||
const open = m.index + m[0].length;
|
||||
const args = balancedArgs(safe, open);
|
||||
if (args === null || !/\bview_func\s*=|\.add_url_rule/.test(m[1]! + args)) continue;
|
||||
const isRule = m[1] === '.add_url_rule';
|
||||
let paths: string[] = [];
|
||||
if (isRule) {
|
||||
const first = /^\s*(?:rule\s*=\s*)?(['"])([^'"]*)\1/.exec(args);
|
||||
if (first) paths = [first[2]!];
|
||||
} else {
|
||||
const list = /(?:^|,)\s*(?:routes|rules|urls|paths)?\s*=?\s*\[((?:\s*(['"])[^'"]*\2\s*,?)+)\]/.exec(args);
|
||||
if (list) paths = (list[1]!.match(/(['"])([^'"]*)\1/g) ?? []).map((q) => q.slice(1, -1));
|
||||
}
|
||||
paths = paths.filter((p) => p.startsWith('/'));
|
||||
if (paths.length === 0) continue;
|
||||
// `view_func=X` / `view_func=X.as_view(…)`, or add_url_rule's third positional argument.
|
||||
const expr = /\bview_func\s*=\s*([A-Za-z_][\w.]*)/.exec(args)?.[1] ??
|
||||
(isRule ? /^\s*(['"])[^'"]*\1\s*,\s*(?:(['"])[^'"]*\2|None)\s*,\s*([A-Za-z_][\w.]*)/.exec(args)?.[3] : undefined);
|
||||
if (!expr) continue;
|
||||
const classView = expr.endsWith('.as_view');
|
||||
const target = classView ? expr.slice(0, -'.as_view'.length) : expr;
|
||||
const listed = /\bmethods\s*=\s*[[(]([^\])]*)[\])]/.exec(args)?.[1];
|
||||
const methods = listed
|
||||
? (listed.match(/['"](\w+)['"]/g) ?? []).map((q) => q.slice(1, -1).toUpperCase())
|
||||
: [classView ? 'ANY' : 'GET'];
|
||||
const line = safe.slice(0, m.index).split('\n').length;
|
||||
const name = target.split('.').pop()!;
|
||||
for (const routePath of paths) {
|
||||
for (const method of methods) {
|
||||
const id = `route:${filePath}:${line}:${method}:${routePath}`;
|
||||
if (seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
nodes.push({
|
||||
id,
|
||||
kind: 'route',
|
||||
name: `${method} ${routePath}`,
|
||||
qualifiedName: `${filePath}::${method}:${routePath}`,
|
||||
filePath,
|
||||
startLine: line,
|
||||
endLine: line,
|
||||
startColumn: 0,
|
||||
endColumn: 0,
|
||||
language: 'python',
|
||||
updatedAt: now,
|
||||
});
|
||||
references.push({ fromNodeId: id, referenceName: name, referenceKind: 'references', line, column: 0, filePath, language: 'python' });
|
||||
}
|
||||
}
|
||||
opener.lastIndex = open + args.length;
|
||||
}
|
||||
return { nodes, references };
|
||||
}
|
||||
|
||||
/** The text between an opening `(` at `open` and its matching `)`, or null. Strings are skipped. */
|
||||
function balancedArgs(text: string, open: number): string | null {
|
||||
let depth = 1;
|
||||
let quote = '';
|
||||
for (let i = open; i < text.length && i < open + 4000; i++) {
|
||||
const ch = text[i]!;
|
||||
if (quote) {
|
||||
if (ch === '\\') i++;
|
||||
else if (ch === quote) quote = '';
|
||||
continue;
|
||||
}
|
||||
if (ch === '"' || ch === "'") quote = ch;
|
||||
else if (ch === '(' || ch === '[' || ch === '{') depth++;
|
||||
else if (ch === ')' || ch === ']' || ch === '}') {
|
||||
depth--;
|
||||
if (depth === 0) return text.slice(open, i);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Directory patterns
|
||||
const MODEL_DIRS = ['models', 'app/models', 'src/models'];
|
||||
const VIEW_DIRS = ['views', 'app/views', 'src/views', 'api/views'];
|
||||
|
||||
Reference in New Issue
Block a user