From b22677e283e5541e8bede0c62aae45175abd08aa Mon Sep 17 00:00:00 2001 From: Savannah Ostrowski Date: Thu, 15 Jan 2026 09:39:00 -0800 Subject: [PATCH 1/2] Add support for pyproject.toml parsing --- package.json | 2 +- src/appDiscovery.ts | 148 +++++++++++++++++++++++++++ src/core/internal.ts | 5 + src/core/routerResolver.ts | 26 ++++- src/extension.ts | 101 ++++-------------- src/test/core/routerResolver.test.ts | 42 ++++++++ src/test/fixtures/multi-app/main.py | 30 ++++++ src/test/testUtils.ts | 4 + 8 files changed, 273 insertions(+), 85 deletions(-) create mode 100644 src/appDiscovery.ts create mode 100644 src/test/fixtures/multi-app/main.py diff --git a/package.json b/package.json index 842c72f..9a452a5 100644 --- a/package.json +++ b/package.json @@ -129,7 +129,7 @@ "package": "vsce package", "publish:marketplace": "vsce publish", "lint": "biome check --write --unsafe --no-errors-on-unmatched --files-ignore-unknown=true src/", - "test": "vscode-test", + "test": "bun run compile && vscode-test", "prepare": "husky" }, "devDependencies": { diff --git a/src/appDiscovery.ts b/src/appDiscovery.ts new file mode 100644 index 0000000..d91c88c --- /dev/null +++ b/src/appDiscovery.ts @@ -0,0 +1,148 @@ +/** + * FastAPI app discovery logic. + * Handles finding FastAPI apps via pyproject.toml, VS Code settings, or automatic detection. + */ + +import { existsSync } from "node:fs" +import { isAbsolute, sep } from "node:path" +import * as toml from "toml" +import * as vscode from "vscode" +import type { EntryPoint } from "./core/internal" +import type { Parser } from "./core/parser" +import { findProjectRoot } from "./core/pathUtils" +import { buildRouterGraph } from "./core/routerResolver" +import { routerNodeToAppDefinition } from "./core/transformer" +import type { AppDefinition } from "./core/types" + +export type { EntryPoint } + +/** + * Scans for common FastAPI entry point files (main.py, __init__.py). + * Returns paths sorted by depth (shallower first). + */ +async function automaticDetectEntryPoints( + folderPath: string, +): Promise { + const [mainFiles, initFiles] = await Promise.all([ + vscode.workspace.findFiles( + new vscode.RelativePattern(folderPath, "**/main.py"), + ), + vscode.workspace.findFiles( + new vscode.RelativePattern(folderPath, "**/__init__.py"), + ), + ]) + + return [...mainFiles, ...initFiles] + .map((uri) => uri.fsPath) + .sort((a, b) => a.split(sep).length - b.split(sep).length) +} + +/** + * Parses pyproject.toml to find a defined entrypoint. + * Supports module:variable notation, e.g. "my_app.main:app" + */ +async function parsePyprojectForEntryPoint( + folderPath: string, +): Promise { + const pyprojectPath = vscode.Uri.joinPath( + vscode.Uri.file(folderPath), + "pyproject.toml", + ) + + if (!existsSync(pyprojectPath.fsPath)) { + return null + } + + try { + const document = await vscode.workspace.openTextDocument(pyprojectPath) + const contents = toml.parse(document.getText()) as Record + + const entrypoint = (contents.tool as Record | undefined) + ?.fastapi as Record | undefined + const entrypointValue = entrypoint?.entrypoint as string | undefined + + if (!entrypointValue) { + return null + } + + // Parse "my_app.main:app" format (variable name after : is optional) + const colonIndex = entrypointValue.indexOf(":") + const modulePath = + colonIndex === -1 ? entrypointValue : entrypointValue.slice(0, colonIndex) + const variableName = + colonIndex === -1 ? undefined : entrypointValue.slice(colonIndex + 1) + + // Convert module path to file path: my_app.main -> my_app/main.py + const relativePath = `${modulePath.replace(/\./g, sep)}.py` + const fullPath = vscode.Uri.joinPath( + vscode.Uri.file(folderPath), + relativePath, + ).fsPath + + return existsSync(fullPath) ? { filePath: fullPath, variableName } : null + } catch { + // Invalid TOML syntax - silently fall back to auto-detection + return null + } +} + +/** + * Discovers FastAPI apps in the workspace. + * Priority: VS Code settings > pyproject.toml > automatic detection + */ +export async function discoverFastAPIApps( + parser: Parser, +): Promise { + const workspaceFolders = vscode.workspace.workspaceFolders + if (!workspaceFolders) return [] + + const apps: AppDefinition[] = [] + + for (const folder of workspaceFolders) { + const config = vscode.workspace.getConfiguration("fastapi", folder.uri) + const customEntryPoint = config.get("entryPoint") + + let candidates: EntryPoint[] + + if (customEntryPoint) { + const entryPath = isAbsolute(customEntryPoint) + ? customEntryPoint + : vscode.Uri.joinPath(folder.uri, customEntryPoint).fsPath + + if (!existsSync(entryPath)) { + vscode.window.showWarningMessage( + `FastAPI entry point not found: ${customEntryPoint}`, + ) + continue + } + + candidates = [{ filePath: entryPath }] + } else { + const pyprojectEntry = await parsePyprojectForEntryPoint( + folder.uri.fsPath, + ) + candidates = pyprojectEntry + ? [pyprojectEntry] + : (await automaticDetectEntryPoints(folder.uri.fsPath)).map( + (filePath) => ({ filePath }), + ) + } + + for (const candidate of candidates) { + const projectRoot = findProjectRoot(candidate.filePath, folder.uri.fsPath) + const routerNode = buildRouterGraph( + candidate.filePath, + parser, + projectRoot, + candidate.variableName, + ) + + if (routerNode) { + apps.push(routerNodeToAppDefinition(routerNode, folder.uri.fsPath)) + break + } + } + } + + return apps +} diff --git a/src/core/internal.ts b/src/core/internal.ts index fce7f17..30f87c2 100644 --- a/src/core/internal.ts +++ b/src/core/internal.ts @@ -102,3 +102,8 @@ export interface RouterNode { }[] children: { router: RouterNode; prefix: string; tags: string[] }[] } + +export interface EntryPoint { + filePath: string + variableName?: string +} diff --git a/src/core/routerResolver.ts b/src/core/routerResolver.ts index 6e6e6c5..9946cb8 100644 --- a/src/core/routerResolver.ts +++ b/src/core/routerResolver.ts @@ -9,9 +9,16 @@ export type { RouterNode } /** * Finds the main FastAPI app or APIRouter in the list of routers. - * We want to prioritize FastAPI apps over APIRouters. + * If targetVariable is specified, only returns the router with that variable name. + * Otherwise, prioritizes FastAPI apps over APIRouters. */ -function findAppRouter(routers: RouterInfo[]): RouterInfo | undefined { +function findAppRouter( + routers: RouterInfo[], + targetVariable?: string, +): RouterInfo | undefined { + if (targetVariable) { + return routers.find((r) => r.variableName === targetVariable) + } return ( routers.find((r) => r.type === "FastAPI") ?? routers.find((r) => r.type === "APIRouter") @@ -20,13 +27,21 @@ function findAppRouter(routers: RouterInfo[]): RouterInfo | undefined { /** * Builds a router graph starting from the given entry file. + * If targetVariable is specified, only that specific app/router will be used. */ export function buildRouterGraph( entryFile: string, parser: Parser, projectRoot: string, + targetVariable?: string, ): RouterNode | null { - return buildRouterGraphInternal(entryFile, parser, projectRoot, new Set()) + return buildRouterGraphInternal( + entryFile, + parser, + projectRoot, + new Set(), + targetVariable, + ) } /** @@ -37,6 +52,7 @@ function buildRouterGraphInternal( parser: Parser, projectRoot: string, visited: Set, + targetVariable?: string, ): RouterNode | null { // Resolve the full path of the entry file if necessary let resolvedEntryFile = entryFile @@ -61,8 +77,8 @@ function buildRouterGraphInternal( return null } - // Find FastAPI instantiation - let appRouter = findAppRouter(analysis.routers) + // Find FastAPI instantiation (filter by targetVariable if specified) + let appRouter = findAppRouter(analysis.routers, targetVariable) // If no FastAPI/APIRouter found and this is an __init__.py, check for re-exports if (!appRouter && resolvedEntryFile.endsWith("__init__.py")) { diff --git a/src/extension.ts b/src/extension.ts index 7e51d5b..281ef59 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -2,76 +2,19 @@ * VSCode extension entry point for FastAPI endpoint discovery. */ -import { existsSync } from "node:fs" -import { sep } from "node:path" import * as vscode from "vscode" +import { discoverFastAPIApps } from "./appDiscovery" import { clearImportCache } from "./core/importResolver" import { Parser } from "./core/parser" -import { findProjectRoot, stripLeadingDynamicSegments } from "./core/pathUtils" -import { buildRouterGraph } from "./core/routerResolver" -import { routerNodeToAppDefinition } from "./core/transformer" -import type { AppDefinition, SourceLocation } from "./core/types" +import { stripLeadingDynamicSegments } from "./core/pathUtils" +import type { SourceLocation } from "./core/types" import { type EndpointTreeItem, EndpointTreeProvider, } from "./providers/EndpointTreeProvider" import { TestCodeLensProvider } from "./providers/TestCodeLensProvider" -async function discoverFastAPIApps(parser: Parser): Promise { - const apps: AppDefinition[] = [] - const workspaceFolders = vscode.workspace.workspaceFolders - - if (!workspaceFolders) { - return apps - } - - for (const folder of workspaceFolders) { - const config = vscode.workspace.getConfiguration("fastapi", folder.uri) - const customEntryPoint = config.get("entryPoint") - - let candidates: string[] = [] - - if (customEntryPoint) { - // Use custom entry point if specified - const entryPath = customEntryPoint.startsWith("/") - ? customEntryPoint - : vscode.Uri.joinPath(folder.uri, customEntryPoint).fsPath - - if (!existsSync(entryPath)) { - vscode.window.showWarningMessage( - `FastAPI entry point not found: ${customEntryPoint}`, - ) - continue - } - - candidates = [entryPath] - } else { - // Scan for main.py and __init__.py files (likely FastAPI entry points) - const mainFiles = await vscode.workspace.findFiles( - new vscode.RelativePattern(folder, "**/main.py"), - ) - const initFiles = await vscode.workspace.findFiles( - new vscode.RelativePattern(folder, "**/__init__.py"), - ) - // Prefer main.py, then __init__.py, sorted by path depth (shallower first) - candidates = [...mainFiles, ...initFiles] - .map((uri) => uri.fsPath) - .sort((a, b) => a.split(sep).length - b.split(sep).length) - } - - for (const entryPath of candidates) { - const projectRoot = findProjectRoot(entryPath, folder.uri.fsPath) - const routerNode = buildRouterGraph(entryPath, parser, projectRoot) - - if (routerNode) { - apps.push(routerNodeToAppDefinition(routerNode, folder.uri.fsPath)) - break - } - } - } - - return apps -} +let parserService: Parser | null = null function navigateToLocation(location: SourceLocation): void { const uri = vscode.Uri.file(location.filePath) @@ -81,9 +24,8 @@ function navigateToLocation(location: SourceLocation): void { }) } -let parserService: Parser | null = null - export async function activate(context: vscode.ExtensionContext) { + // Initialize parser parserService = new Parser() await parserService.init({ core: vscode.Uri.joinPath( @@ -100,60 +42,61 @@ export async function activate(context: vscode.ExtensionContext) { ).fsPath, }) - // Discover FastAPI endpoints from workspace + // Discover apps and create providers const apps = await discoverFastAPIApps(parserService) const endpointProvider = new EndpointTreeProvider(apps) const codeLensProvider = new TestCodeLensProvider(parserService, apps) + // File watcher for auto-refresh let refreshTimeout: NodeJS.Timeout | null = null - const triggerRefresh = () => { - if (refreshTimeout) { - clearTimeout(refreshTimeout) - } + if (refreshTimeout) clearTimeout(refreshTimeout) refreshTimeout = setTimeout(async () => { - if (!parserService) { - return - } + if (!parserService) return const newApps = await discoverFastAPIApps(parserService) endpointProvider.setApps(newApps) codeLensProvider.setApps(newApps) }, 500) } - // Watch for changes in Python files to refresh endpoints const watcher = vscode.workspace.createFileSystemWatcher("**/*.py") watcher.onDidChange(triggerRefresh) watcher.onDidCreate(triggerRefresh) watcher.onDidDelete(triggerRefresh) - context.subscriptions.push(watcher) + // Tree view const treeView = vscode.window.createTreeView("endpoint-explorer", { treeDataProvider: endpointProvider, }) - // Register CodeLens provider for test files + // CodeLens provider (optional) const config = vscode.workspace.getConfiguration("fastapi") if (config.get("showTestCodeLenses", true)) { context.subscriptions.push( vscode.languages.registerCodeLensProvider( - // Covers common test file patterns - // e.g., test_*.py, *_test.py, tests/*.py { language: "python", pattern: "**/*test*.py" }, codeLensProvider, ), ) } + // Register disposables and commands context.subscriptions.push( + watcher, treeView, + registerCommands(endpointProvider, codeLensProvider), + ) +} +function registerCommands( + endpointProvider: EndpointTreeProvider, + codeLensProvider: TestCodeLensProvider, +): vscode.Disposable { + return vscode.Disposable.from( vscode.commands.registerCommand( "fastapi-vscode.refreshEndpoints", async () => { - if (!parserService) { - return - } + if (!parserService) return clearImportCache() const newApps = await discoverFastAPIApps(parserService) endpointProvider.setApps(newApps) diff --git a/src/test/core/routerResolver.test.ts b/src/test/core/routerResolver.test.ts index c751bac..b062971 100644 --- a/src/test/core/routerResolver.test.ts +++ b/src/test/core/routerResolver.test.ts @@ -239,5 +239,47 @@ suite("routerResolver", () => { "Router should have /items route", ) }) + + test("selects specific app by targetVariable", () => { + // Without targetVariable, should pick first FastAPI app (public_app) + const defaultResult = buildRouterGraph( + fixtures.multiApp.mainPy, + parser, + fixtures.multiApp.root, + ) + + assert.ok(defaultResult) + assert.strictEqual(defaultResult.variableName, "public_app") + + // With targetVariable, should select admin_app + const adminResult = buildRouterGraph( + fixtures.multiApp.mainPy, + parser, + fixtures.multiApp.root, + "admin_app", + ) + + assert.ok(adminResult) + assert.strictEqual(adminResult.variableName, "admin_app") + assert.strictEqual(adminResult.type, "FastAPI") + + // admin_app has 3 routes: /, /users, /users/{user_id} + assert.strictEqual(adminResult.routes.length, 3) + const routePaths = adminResult.routes.map((r) => r.path) + assert.ok(routePaths.includes("/")) + assert.ok(routePaths.includes("/users")) + assert.ok(routePaths.includes("/users/{user_id}")) + }) + + test("returns null for non-existent targetVariable", () => { + const result = buildRouterGraph( + fixtures.multiApp.mainPy, + parser, + fixtures.multiApp.root, + "nonexistent_app", + ) + + assert.strictEqual(result, null) + }) }) }) diff --git a/src/test/fixtures/multi-app/main.py b/src/test/fixtures/multi-app/main.py new file mode 100644 index 0000000..93866e1 --- /dev/null +++ b/src/test/fixtures/multi-app/main.py @@ -0,0 +1,30 @@ +from fastapi import FastAPI + +# Multiple FastAPI apps in the same file +public_app = FastAPI(title="Public API") +admin_app = FastAPI(title="Admin API") + + +@public_app.get("/") +def public_root(): + return {"message": "Public API"} + + +@public_app.get("/products") +def list_products(): + return {"products": []} + + +@admin_app.get("/") +def admin_root(): + return {"message": "Admin API"} + + +@admin_app.get("/users") +def list_users(): + return {"users": []} + + +@admin_app.delete("/users/{user_id}") +def delete_user(user_id: int): + return {"deleted": user_id} diff --git a/src/test/testUtils.ts b/src/test/testUtils.ts index fdddf8e..4c1afc9 100644 --- a/src/test/testUtils.ts +++ b/src/test/testUtils.ts @@ -39,4 +39,8 @@ export const fixtures = { root: join(fixturesPath, "same-file"), mainPy: join(fixturesPath, "same-file", "main.py"), }, + multiApp: { + root: join(fixturesPath, "multi-app"), + mainPy: join(fixturesPath, "multi-app", "main.py"), + }, } From bcd2a1b358a702744358a71e8593267fa8121ff0 Mon Sep 17 00:00:00 2001 From: Savannah Ostrowski Date: Thu, 15 Jan 2026 09:52:35 -0800 Subject: [PATCH 2/2] Add toml dep --- bun.lock | 3 +++ package.json | 1 + 2 files changed, 4 insertions(+) diff --git a/bun.lock b/bun.lock index 374562a..60af306 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "": { "name": "fastapi-vscode", "dependencies": { + "toml": "^3.0.0", "web-tree-sitter": "^0.26.3", }, "devDependencies": { @@ -797,6 +798,8 @@ "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + "toml": ["toml@3.0.0", "", {}, "sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w=="], + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "tunnel": ["tunnel@0.0.6", "", {}, "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg=="], diff --git a/package.json b/package.json index 9a452a5..e3877aa 100644 --- a/package.json +++ b/package.json @@ -146,6 +146,7 @@ "typescript": "^5.0.0" }, "dependencies": { + "toml": "^3.0.0", "web-tree-sitter": "^0.26.3" }, "lint-staged": {