Selaa lähdekoodia

feat(resolution): Flask-RESTful add_resource + tuple methods + broader detection

Three Flask gaps closed (redash Flask-RESTful 6→77 py routes; flask-realworld 0→19):
- Flask-RESTful: api.add_resource(ResourceClass, '/path') (+ redash's
  add_org_resource) now extracts a route per path referencing the Resource
  class, whose get/post verb methods resolve as the handlers.
- Tuple methods: @x.route('/p', methods=('POST',)) — the method regex only
  accepted a list [...]; now accepts a tuple (...) too, so POST/DELETE routes
  aren't mislabeled GET.
- Detection: detect() only checked root app.py for the literal Flask(__name__);
  broadened to requirements/pyproject/Pipfile/setup.py + any entrypoint file
  (root or subdir, e.g. conduit/app.py) that imports flask and instantiates
  Flask(...). flask-realworld (subdir app-factory) 0→19; django not falsely
  detected.

Tests: tuple methods, add_resource. Suite green (808).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Colby McHenry 1 kuukausi sitten
vanhempi
sitoutus
03e49ab9c4
2 muutettua tiedostoa jossa 92 lisäystä ja 12 poistoa
  1. 21 0
      __tests__/frameworks.test.ts
  2. 71 12
      src/resolution/frameworks/python.ts

+ 21 - 0
__tests__/frameworks.test.ts

@@ -148,6 +148,27 @@ def index():
     expect(nodes.map((n) => n.name)).toEqual(['GET /', 'GET /index']);
     expect(references.map((r) => r.referenceName)).toEqual(['index', 'index']);
   });
+
+  it('extracts the method from a tuple methods=(...) (not just a list)', () => {
+    const src = `
+@blueprint.route('/api/articles', methods=('POST',))
+def make_article():
+    pass
+`;
+    const { nodes, references } = flaskResolver.extract!('views.py', src);
+    expect(nodes[0].name).toBe('POST /api/articles');
+    expect(references[0].referenceName).toBe('make_article');
+  });
+
+  it('extracts Flask-RESTful api.add_resource(Resource, paths) → the Resource class', () => {
+    const src = `
+api.add_resource(TodoResource, '/todos/<id>')
+api.add_org_resource(AlertResource, '/api/alerts/<id>', endpoint='alert')
+`;
+    const { nodes, references } = flaskResolver.extract!('api.py', src);
+    expect(nodes.map((n) => n.name)).toEqual(['ANY /todos/<id>', 'ANY /api/alerts/<id>']);
+    expect(references.map((r) => r.referenceName)).toEqual(['TodoResource', 'AlertResource']);
+  });
 });
 
 describe('fastapiResolver.extract', () => {

+ 71 - 12
src/resolution/frameworks/python.ts

@@ -177,13 +177,20 @@ export const flaskResolver: FrameworkResolver = {
   languages: ['python'],
 
   detect(context) {
-    const requirements = context.readFile('requirements.txt');
-    if (requirements && /\bflask\b/i.test(requirements)) return true;
-    const pyproject = context.readFile('pyproject.toml');
-    if (pyproject && /\bflask\b/i.test(pyproject)) return true;
-    for (const file of ['app.py', 'application.py', 'main.py', '__init__.py']) {
-      const content = context.readFile(file);
-      if (content && content.includes('Flask(__name__)')) return true;
+    for (const f of ['requirements.txt', 'pyproject.toml', 'Pipfile', 'setup.py']) {
+      const c = context.readFile(f);
+      if (c && /\bflask\b/i.test(c)) return true;
+    }
+    // Any app entrypoint (root OR subdir, e.g. conduit/app.py) that imports flask
+    // and instantiates Flask(...) — covers Flask(__name__), Flask(__name__.split…),
+    // and the app-factory pattern. Bounded to entrypoint-named files.
+    const entrypoints = context
+      .getAllFiles()
+      .filter((f) => /(?:^|\/)(app|application|main|wsgi|__init__)\.py$/.test(f))
+      .slice(0, 50);
+    for (const f of entrypoints) {
+      const c = context.readFile(f);
+      if (c && /\bFlask\s*\(/.test(c) && /\bimport\s+flask\b|\bfrom\s+flask\b/.test(c)) return true;
     }
     return false;
   },
@@ -198,17 +205,23 @@ export const flaskResolver: FrameworkResolver = {
 
   extract(filePath, content) {
     if (!filePath.endsWith('.py')) return { nodes: [], references: [] };
-    return extractDecoratorRoutes(filePath, stripCommentsForRegex(content, 'python'), {
-      // Flask: @x.route('/path', methods=[...]) — the handler is the next `def`,
-      // allowing intervening decorators (@login_required, @cache.cached) and
-      // stacked @x.route() lines (one view bound to several URLs).
-      decoratorRegex: /@(\w+)\.route\s*\(\s*['"]([^'"]*)['"](?:\s*,\s*methods\s*=\s*\[([^\]]+)\])?\s*\)/g,
+    const safe = stripCommentsForRegex(content, 'python');
+    const decorator = extractDecoratorRoutes(filePath, safe, {
+      // Flask: @x.route('/path', methods=[...] | (...)) — the handler is the next
+      // `def`, allowing intervening decorators (@login_required) and stacked
+      // @x.route() lines. methods may be a list OR a tuple (methods=('GET',)).
+      decoratorRegex: /@(\w+)\.route\s*\(\s*['"]([^'"]*)['"](?:\s*,\s*methods\s*=\s*[[(]([^\])]+)[\])])?\s*\)/g,
       defaultMethod: 'GET',
       methodFromGroup: 3,
       pathGroup: 2,
       findHandler: true,
       language: 'python',
     });
+    const restful = extractFlaskRestful(filePath, safe);
+    return {
+      nodes: [...decorator.nodes, ...restful.nodes],
+      references: [...decorator.references, ...restful.references],
+    };
   },
 };
 
@@ -320,6 +333,52 @@ function extractDecoratorRoutes(filePath: string, content: string, opts: Decorat
   return { nodes, references };
 }
 
+/**
+ * Flask-RESTful: `api.add_resource(ResourceClass, '/path'[, '/path2'])`
+ * (and variants like redash's `add_org_resource`). The ResourceClass holds the
+ * HTTP-verb methods (get/post/…), so the route references the class — its verb
+ * methods resolve as the handlers via the class. Method is ANY (the class
+ * decides which verbs it serves).
+ */
+function extractFlaskRestful(filePath: string, safe: string): FrameworkExtractionResult {
+  const nodes: Node[] = [];
+  const references: UnresolvedRef[] = [];
+  const now = Date.now();
+  const re = /\.add\w*[Rr]esource\s*\(\s*(\w+)\s*,\s*((?:['"][^'"]+['"]\s*,?\s*)+)/g;
+  let m: RegExpExecArray | null;
+  while ((m = re.exec(safe)) !== null) {
+    const className = m[1]!;
+    const paths = (m[2]!.match(/['"]([^'"]+)['"]/g) || []).map((s) => s.slice(1, -1));
+    const line = safe.slice(0, m.index).split('\n').length;
+    for (const routePath of paths) {
+      const routeNode: Node = {
+        id: `route:${filePath}:${line}:ANY:${routePath}`,
+        kind: 'route',
+        name: `ANY ${routePath}`,
+        qualifiedName: `${filePath}::ANY:${routePath}`,
+        filePath,
+        startLine: line,
+        endLine: line,
+        startColumn: 0,
+        endColumn: 0,
+        language: 'python',
+        updatedAt: now,
+      };
+      nodes.push(routeNode);
+      references.push({
+        fromNodeId: routeNode.id,
+        referenceName: className,
+        referenceKind: 'references',
+        line,
+        column: 0,
+        filePath,
+        language: 'python',
+      });
+    }
+  }
+  return { nodes, references };
+}
+
 // Directory patterns
 const MODEL_DIRS = ['models', 'app/models', 'src/models'];
 const VIEW_DIRS = ['views', 'app/views', 'src/views', 'api/views'];