From 68bd835198a8c62076edafd3029873cdc82b3f99 Mon Sep 17 00:00:00 2001 From: juyua9 Date: Thu, 7 May 2026 19:05:22 +0800 Subject: [PATCH] fix(group): detect httpx async consumers --- .../group/extractors/http-patterns/python.ts | 133 ++++++++++++++++++ .../unit/group/http-route-extractor.test.ts | 35 +++++ 2 files changed, 168 insertions(+) diff --git a/gitnexus/src/core/group/extractors/http-patterns/python.ts b/gitnexus/src/core/group/extractors/http-patterns/python.ts index 27ddf6633..ccb7790b3 100644 --- a/gitnexus/src/core/group/extractors/http-patterns/python.ts +++ b/gitnexus/src/core/group/extractors/http-patterns/python.ts @@ -1,3 +1,4 @@ +import type Parser from 'tree-sitter'; import Python from 'tree-sitter-python'; import { compilePatterns, @@ -12,6 +13,7 @@ import type { HttpDetection, HttpLanguagePlugin } from './types.js'; * - FastAPI `@app.get("/path")` provider decorators * - `requests.get/post/...("url")` consumer calls * - Generic `requests.request("METHOD", "url")` consumer calls + * - `httpx.AsyncClient` instances calling `.get/.post/...("url")` */ const FASTAPI_VERBS: Record = { @@ -77,11 +79,103 @@ const REQUESTS_GENERIC_PATTERNS = compilePatterns({ ], } satisfies LanguagePatterns>); + +// ─── Consumer: httpx.AsyncClient assignments ──────────────────────── +const HTTPX_ASYNC_CLIENT_ASSIGN_PATTERNS = compilePatterns({ + name: 'python-httpx-async-client-assign', + language: Python, + patterns: [ + { + meta: {}, + query: ` + (assignment + left: (_) @client + right: (call + function: (attribute + object: (identifier) @module (#eq? @module "httpx") + attribute: (identifier) @client_class (#eq? @client_class "AsyncClient")))) + `, + }, + ], +} satisfies LanguagePatterns>); + +// ─── Consumer: async with httpx.AsyncClient() as client ────────────── +const HTTPX_ASYNC_CLIENT_WITH_ALIAS_PATTERNS = compilePatterns({ + name: 'python-httpx-async-client-with-alias', + language: Python, + patterns: [ + { + meta: {}, + query: ` + (as_pattern + (call + function: (attribute + object: (identifier) @module (#eq? @module "httpx") + attribute: (identifier) @client_class (#eq? @client_class "AsyncClient"))) + (as_pattern_target (identifier) @client)) + `, + }, + ], +} satisfies LanguagePatterns>); + +// ─── Consumer: httpx AsyncClient .get/.post/...("url") ────────────── +const HTTPX_ASYNC_CLIENT_VERB_PATTERNS = compilePatterns({ + name: 'python-httpx-async-client-verb', + language: Python, + patterns: [ + { + meta: {}, + query: ` + (call + function: (attribute + object: (_) @client + attribute: (identifier) @method (#match? @method "^(get|post|put|delete|patch)$")) + arguments: (argument_list . (string) @path)) + `, + }, + ], +} satisfies LanguagePatterns>); + +// ─── Consumer: httpx AsyncClient .request("METHOD", "url") ───────── +const HTTPX_ASYNC_CLIENT_GENERIC_PATTERNS = compilePatterns({ + name: 'python-httpx-async-client-generic', + language: Python, + patterns: [ + { + meta: {}, + query: ` + (call + function: (attribute + object: (_) @client + attribute: (identifier) @method (#eq? @method "request")) + arguments: (argument_list . (string) @http_method (string) @path)) + `, + }, + ], +} satisfies LanguagePatterns>); + +function collectHttpxAsyncClients(tree: Parser.Tree): Set { + const clients = new Set(); + + for (const match of runCompiledPatterns(HTTPX_ASYNC_CLIENT_ASSIGN_PATTERNS, tree)) { + const clientNode = match.captures.client; + if (clientNode) clients.add(clientNode.text); + } + + for (const match of runCompiledPatterns(HTTPX_ASYNC_CLIENT_WITH_ALIAS_PATTERNS, tree)) { + const clientNode = match.captures.client; + if (clientNode) clients.add(clientNode.text); + } + + return clients; +} + export const PYTHON_HTTP_PLUGIN: HttpLanguagePlugin = { name: 'python-http', language: Python, scan(tree) { const out: HttpDetection[] = []; + const httpxAsyncClients = collectHttpxAsyncClients(tree); // Providers: FastAPI for (const match of runCompiledPatterns(FASTAPI_PATTERNS, tree)) { @@ -137,6 +231,45 @@ export const PYTHON_HTTP_PLUGIN: HttpLanguagePlugin = { }); } + // Consumers: httpx.AsyncClient.("url") + for (const match of runCompiledPatterns(HTTPX_ASYNC_CLIENT_VERB_PATTERNS, tree)) { + const clientNode = match.captures.client; + const methodNode = match.captures.method; + const pathNode = match.captures.path; + if (!clientNode || !methodNode || !pathNode) continue; + if (!httpxAsyncClients.has(clientNode.text)) continue; + const path = unquoteLiteral(pathNode.text); + if (path === null) continue; + out.push({ + role: 'consumer', + framework: 'python-httpx', + method: methodNode.text.toUpperCase(), + path, + name: null, + confidence: 0.7, + }); + } + + // Consumers: httpx.AsyncClient.request("METHOD", "url") + for (const match of runCompiledPatterns(HTTPX_ASYNC_CLIENT_GENERIC_PATTERNS, tree)) { + const clientNode = match.captures.client; + const methodNode = match.captures.http_method; + const pathNode = match.captures.path; + if (!clientNode || !methodNode || !pathNode) continue; + if (!httpxAsyncClients.has(clientNode.text)) continue; + const methodRaw = unquoteLiteral(methodNode.text); + const path = unquoteLiteral(pathNode.text); + if (methodRaw === null || path === null) continue; + out.push({ + role: 'consumer', + framework: 'python-httpx', + method: methodRaw.toUpperCase(), + path, + name: null, + confidence: 0.7, + }); + } + return out; }, }; diff --git a/gitnexus/test/unit/group/http-route-extractor.test.ts b/gitnexus/test/unit/group/http-route-extractor.test.ts index 69d3da2fa..063d4768a 100644 --- a/gitnexus/test/unit/group/http-route-extractor.test.ts +++ b/gitnexus/test/unit/group/http-route-extractor.test.ts @@ -428,6 +428,41 @@ def create_order(): consumers.find((c) => c.contractId === 'http::POST::/api/orders/{param}'), ).toBeDefined(); }); + it('extracts Python httpx.AsyncClient calls assigned to attributes or aliases', async () => { + const dir = path.join(tmpDir, 'python-httpx-consumer'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'client.py'), + ` +import httpx + +class TopicClient: + def __init__(self): + self._client = httpx.AsyncClient(base_url="https://svc.local") + + async def list_topics(self): + return await self._client.get("/topic") + + async def publish(self): + return await self._client.request("POST", "/questions/import") + +async def check_duplicate(): + async with httpx.AsyncClient() as client: + return await client.post("https://svc.local/questions/duplicate-check") +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + expect(consumers.find((c) => c.contractId === 'http::GET::/topic')).toBeDefined(); + expect(consumers.find((c) => c.contractId === 'http::POST::/questions/import')).toBeDefined(); + expect( + consumers.find((c) => c.contractId === 'http::POST::/questions/duplicate-check'), + ).toBeDefined(); + expect(consumers.every((c) => c.meta.framework === 'python-httpx')).toBe(true); + }); + it('extracts Java RestTemplate, WebClient and OkHttp calls', async () => { const dir = path.join(tmpDir, 'java-consumer');