Skip to content
Open
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
94 changes: 94 additions & 0 deletions src/mapper.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13007,6 +13007,100 @@ add_executable(headerapp include/headers.hpp)
expect(admin?.trustBoundaries).toContain("auth");
});

it("maps static Flask blueprint url prefixes", async () => {
const root = await fixtureRoot("clawpatch-python-flask-blueprint-prefixes-");
await writeFixture(root, "requirements.txt", "Flask\npytest\n");
await writeFixture(
root,
"web/app.py",
[
"from flask import Blueprint, Flask",
"",
"app = Flask(__name__)",
"API_PREFIX = '/dynamic'",
"api_bp = Blueprint('api', __name__, url_prefix='/api')",
"registered_bp = Blueprint('registered', __name__)",
"dynamic_bp = Blueprint('dynamic', __name__, url_prefix=API_PREFIX)",
"runtime_bp = Blueprint('runtime', __name__)",
"overridden_bp = Blueprint('overridden', __name__, url_prefix='/constructor')",
"none_bp = Blueprint('none', __name__, url_prefix='/kept')",
"none_comment_bp = Blueprint('none_comment', __name__, url_prefix='/kept-comment')",
"constructor_comment_bp = Blueprint(",
" 'constructor_comment',",
" __name__,",
" url_prefix='/constructor-comment' # use constructor literal",
")",
"literal_comment_bp = Blueprint('literal_comment', __name__, url_prefix='/constructor')",
"app.register_blueprint(registered_bp, url_prefix='/registered')",
"app.register_blueprint(runtime_bp, url_prefix=API_PREFIX)",
"app.register_blueprint(overridden_bp, url_prefix=API_PREFIX)",
"app.register_blueprint(none_bp, url_prefix=None)",
"app.register_blueprint(",
" none_comment_bp,",
" url_prefix=None # use constructor default",
")",
"app.register_blueprint(",
" literal_comment_bp,",
" url_prefix='/literal' # use literal override",
")",
"",
"@api_bp.route('/users')",
"def users():",
" return 'users'",
"",
"@registered_bp.route('/reports', methods=['POST'])",
"def reports():",
" return 'reports'",
"",
"@dynamic_bp.route('/metrics')",
"def metrics():",
" return 'metrics'",
"",
"@runtime_bp.route('/events')",
"def events():",
" return 'events'",
"",
"@overridden_bp.route('/health')",
"def health():",
" return 'health'",
"",
"@none_bp.route('/ready')",
"def ready():",
" return 'ready'",
"",
"@none_comment_bp.route('/ready')",
"def ready_with_comment():",
" return 'ready'",
"",
"@constructor_comment_bp.route('/ready')",
"def ready_with_constructor_comment():",
" return 'ready'",
"",
"@literal_comment_bp.route('/ready')",
"def ready_with_literal_comment():",
" return 'ready'",
"",
].join("\n"),
);

const project = await detectProject(root);
const result = await mapFeatures(root, project, []);
const titles = result.features.map((feature) => feature.title);

expect(titles).toContain("Flask route GET /api/users");
expect(titles).toContain("Flask route POST /registered/reports");
expect(titles).toContain("Flask route GET /metrics");
expect(titles).toContain("Flask route GET /events");
expect(titles).toContain("Flask route GET /health");
expect(titles).toContain("Flask route GET /kept/ready");
expect(titles).toContain("Flask route GET /kept-comment/ready");
expect(titles).toContain("Flask route GET /constructor-comment/ready");
expect(titles).toContain("Flask route GET /literal/ready");
expect(titles).not.toContain("Flask route GET /dynamic/metrics");
expect(titles).not.toContain("Flask route GET /dynamic/events");
expect(titles).not.toContain("Flask route GET /constructor/health");
});

it("maps root-level Flask entry files and non-list methods", async () => {
const root = await fixtureRoot("clawpatch-python-flask-root-routes-");
await writeFixture(root, "requirements.txt", "Flask\npytest\n");
Expand Down
141 changes: 134 additions & 7 deletions src/mappers/python.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1221,7 +1221,8 @@ function sourceLooksFlask(source: string): boolean {

function parseFlaskRoutes(filePath: string, source: string): FlaskRoute[] {
const routes: FlaskRoute[] = [];
let pending: Array<{ routePath: string; methods: string[] }> = [];
const prefixes = parseFlaskBlueprintPrefixes(source);
let pending: Array<{ target: string; routePath: string; methods: string[] }> = [];
let decoratorSource: string | null = null;
let decoratorDepth = 0;
for (const line of source.split("\n")) {
Expand Down Expand Up @@ -1257,7 +1258,12 @@ function parseFlaskRoutes(filePath: string, source: string): FlaskRoute[] {
const functionName = /^\s*(?:async\s+)?def\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(/u.exec(line)?.[1];
if (functionName !== undefined && pending.length > 0) {
for (const item of pending) {
routes.push({ filePath, functionName, ...item });
routes.push({
filePath,
functionName,
routePath: combineFastApiPaths(prefixes.get(item.target) ?? "", item.routePath),
methods: item.methods,
});
}
pending = [];
continue;
Expand All @@ -1279,23 +1285,144 @@ function startsFlaskRouteDecorator(line: string): boolean {
return /^@[A-Za-z_][A-Za-z0-9_.]*\.route\(/u.test(line);
}

function parseFlaskRouteDecorator(line: string): { routePath: string; methods: string[] } | null {
const match = /^\s*@[A-Za-z_][A-Za-z0-9_.]*\.route\(\s*(["'])(.*?)\1(.*)\)\s*(?:#.*)?$/u.exec(
function parseFlaskRouteDecorator(
line: string,
): { target: string; routePath: string; methods: string[] } | null {
const match = /^\s*@([A-Za-z_][A-Za-z0-9_.]*)\.route\(\s*(["'])(.*?)\2(.*)\)\s*(?:#.*)?$/u.exec(
line,
);
if (match?.[2] === undefined) {
const target = match?.[1];
const routePath = match?.[3];
if (target === undefined || routePath === undefined) {
return null;
}
const methods = parsePythonRouteMethods(match[3] ?? "");
const methods = parsePythonRouteMethods(match?.[4] ?? "");
if (methods === null) {
return null;
}
return {
routePath: match[2],
target,
routePath,
methods,
};
}

function parseFlaskBlueprintPrefixes(source: string): Map<string, string | null> {
const prefixes = new Map<string, string | null>();
for (const [target, prefix] of parseFlaskBlueprintConstructorPrefixes(source)) {
prefixes.set(target, prefix);
}
for (const [target, prefix] of parseFlaskBlueprintRegistrationPrefixes(source)) {
prefixes.set(target, prefix);
}
return prefixes;
}

function parseFlaskBlueprintConstructorPrefixes(source: string): Map<string, string> {
const prefixes = new Map<string, string>();
const blueprintCallPattern = /\bBlueprint\s*\(/gu;
for (const match of source.matchAll(blueprintCallPattern)) {
const callStart = match.index;
const openParenIndex = source.indexOf("(", callStart);
if (openParenIndex === -1) {
continue;
}
const prefixSegment = source.slice(0, callStart).trimEnd();
const varName =
/(?:^|[\n;])\s*([A-Za-z_][A-Za-z0-9_]*)\s*(?::[^=\n;]+)?=\s*(?:[A-Za-z_][A-Za-z0-9_]*\.)?$/u.exec(
prefixSegment,
)?.[1];
if (varName === undefined) {
continue;
}
const closeParenIndex = findBalancedParenthesis(source, openParenIndex + 1);
if (closeParenIndex === -1) {
continue;
}
const args = splitTopLevelPythonArgs(source.slice(openParenIndex + 1, closeParenIndex));
const prefix = parsePythonKeywordStringArg(args, "url_prefix");
if (prefix !== null) {
prefixes.set(varName, prefix);
}
}
return prefixes;
}

function parseFlaskBlueprintRegistrationPrefixes(source: string): Map<string, string | null> {
const prefixes = new Map<string, string | null>();
const registerCallPattern = /\.register_blueprint\s*\(/gu;
for (const match of source.matchAll(registerCallPattern)) {
const callStart = match.index;
const openParenIndex = source.indexOf("(", callStart);
if (openParenIndex === -1) {
continue;
}
const closeParenIndex = findBalancedParenthesis(source, openParenIndex + 1);
if (closeParenIndex === -1) {
continue;
}
const args = splitTopLevelPythonArgs(source.slice(openParenIndex + 1, closeParenIndex));
const target = args[0]?.trim();
if (target === undefined || !/^[A-Za-z_][A-Za-z0-9_]*$/u.test(target)) {
continue;
}
const prefixValue = parsePythonKeywordArgValue(args, "url_prefix");
if (prefixValue !== undefined) {
const normalizedPrefixValue = stripPythonInlineComment(prefixValue).trim();
if (normalizedPrefixValue === "None") {
continue;
}
prefixes.set(target, pythonStringLiteralValue(normalizedPrefixValue));
}
}
return prefixes;
}

function parsePythonKeywordStringArg(args: string[], name: string): string | null {
const value = parsePythonKeywordArgValue(args, name);
return value === undefined
? null
: pythonStringLiteralValue(stripPythonInlineComment(value).trim());
}

function parsePythonKeywordArgValue(args: string[], name: string): string | undefined {
const pattern = new RegExp(`^\\s*${name}\\s*=\\s*([\\s\\S]*)$`, "u");
return pattern.exec(args.find((arg) => pattern.test(arg)) ?? "")?.[1];
}

function stripPythonInlineComment(source: string): string {
let quote: string | null = null;
let escaped = false;
for (let index = 0; index < source.length; index += 1) {
const char = source[index];
if (char === undefined) {
break;
}
if (quote !== null) {
if (escaped) {
escaped = false;
continue;
}
if (char === "\\") {
escaped = true;
continue;
}
if (char === quote) {
quote = null;
}
continue;
}
if (char === '"' || char === "'") {
quote = char;
continue;
}
if (char === "#") {
return source.slice(0, index);
}
}
return source;
}

function parsePythonRouteMethods(args: string): string[] | null {
const methodsIndex = args.search(/\bmethods\s*=/u);
if (methodsIndex === -1) {
Expand Down