diff --git a/src/mapper.test.ts b/src/mapper.test.ts index 9b324b4..4314175 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -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"); diff --git a/src/mappers/python.ts b/src/mappers/python.ts index 1276dc1..63f0e5a 100644 --- a/src/mappers/python.ts +++ b/src/mappers/python.ts @@ -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")) { @@ -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; @@ -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 { + const prefixes = new Map(); + 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 { + const prefixes = new Map(); + 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 { + const prefixes = new Map(); + 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) {