From 00ea29943e1bff372e1470c405253e746f50e8cb Mon Sep 17 00:00:00 2001 From: Rohit Date: Thu, 21 May 2026 00:35:38 +0530 Subject: [PATCH 1/2] feat(mapper): map Flask blueprint prefixes --- src/mapper.test.ts | 49 ++++++++++++++++++++++ src/mappers/python.ts | 98 +++++++++++++++++++++++++++++++++++++++---- 2 files changed, 140 insertions(+), 7 deletions(-) diff --git a/src/mapper.test.ts b/src/mapper.test.ts index 9b324b4..eabda84 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -13007,6 +13007,55 @@ 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__)", + "app.register_blueprint(registered_bp, url_prefix='/registered')", + "app.register_blueprint(runtime_bp, url_prefix=API_PREFIX)", + "", + "@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'", + "", + ].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).not.toContain("Flask route GET /dynamic/metrics"); + expect(titles).not.toContain("Flask route GET /dynamic/events"); + }); + 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..4f77f77 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,101 @@ 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 prefix = parsePythonKeywordStringArg(args, "url_prefix"); + if (prefix !== null) { + prefixes.set(target, prefix); + } + } + return prefixes; +} + +function parsePythonKeywordStringArg(args: string[], name: string): string | null { + const pattern = new RegExp(`^\\s*${name}\\s*=\\s*([\\s\\S]*)$`, "u"); + const value = pattern.exec(args.find((arg) => pattern.test(arg)) ?? "")?.[1]; + return value === undefined ? null : pythonStringLiteralValue(value); +} + function parsePythonRouteMethods(args: string): string[] | null { const methodsIndex = args.search(/\bmethods\s*=/u); if (methodsIndex === -1) { From 5625f6d765d7898f215a14867ac9d70d2fb9fb9c Mon Sep 17 00:00:00 2001 From: Rohit Date: Thu, 21 May 2026 01:13:56 +0530 Subject: [PATCH 2/2] fix(mapper): handle dynamic Flask prefixes --- src/mapper.test.ts | 45 +++++++++++++++++++++++++++++++ src/mappers/python.ts | 61 ++++++++++++++++++++++++++++++++++++------- 2 files changed, 97 insertions(+), 9 deletions(-) diff --git a/src/mapper.test.ts b/src/mapper.test.ts index eabda84..4314175 100644 --- a/src/mapper.test.ts +++ b/src/mapper.test.ts @@ -13022,8 +13022,27 @@ add_executable(headerapp include/headers.hpp) "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():", @@ -13041,6 +13060,26 @@ add_executable(headerapp include/headers.hpp) "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"), ); @@ -13052,8 +13091,14 @@ add_executable(headerapp include/headers.hpp) 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 () => { diff --git a/src/mappers/python.ts b/src/mappers/python.ts index 4f77f77..63f0e5a 100644 --- a/src/mappers/python.ts +++ b/src/mappers/python.ts @@ -1307,8 +1307,8 @@ function parseFlaskRouteDecorator( }; } -function parseFlaskBlueprintPrefixes(source: string): Map { - const prefixes = new Map(); +function parseFlaskBlueprintPrefixes(source: string): Map { + const prefixes = new Map(); for (const [target, prefix] of parseFlaskBlueprintConstructorPrefixes(source)) { prefixes.set(target, prefix); } @@ -1348,8 +1348,8 @@ function parseFlaskBlueprintConstructorPrefixes(source: string): Map { - const prefixes = new Map(); +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; @@ -1366,18 +1366,61 @@ function parseFlaskBlueprintRegistrationPrefixes(source: string): Map pattern.test(arg)) ?? "")?.[1]; - return value === undefined ? null : pythonStringLiteralValue(value); + 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 {