From d0b66f289603d09876a6b0e59e283f4cfcfbdbd7 Mon Sep 17 00:00:00 2001 From: Savannah Ostrowski Date: Fri, 27 Feb 2026 10:43:39 -0800 Subject: [PATCH 1/4] Improve monorepo application discovery --- src/appDiscovery.ts | 51 +++++++++++-------- src/core/pathUtils.ts | 19 +++++-- src/test/core/pathUtils.test.ts | 11 ++++ src/test/core/routerResolver.test.ts | 21 ++++++++ .../fixtures/monorepo/service/myapp/main.py | 7 +++ .../monorepo/service/myapp/users/router.py | 13 +++++ .../fixtures/monorepo/service/pyproject.toml | 0 src/test/testUtils.ts | 5 ++ 8 files changed, 103 insertions(+), 24 deletions(-) create mode 100644 src/test/fixtures/monorepo/service/myapp/main.py create mode 100644 src/test/fixtures/monorepo/service/myapp/users/router.py create mode 100644 src/test/fixtures/monorepo/service/pyproject.toml diff --git a/src/appDiscovery.ts b/src/appDiscovery.ts index 3c4b2c0..a14c1e4 100644 --- a/src/appDiscovery.ts +++ b/src/appDiscovery.ts @@ -80,35 +80,46 @@ async function findAllFastAPIFiles( async function parsePyprojectForEntryPoint( folderUri: vscode.Uri, ): Promise { - const pyprojectUri = vscode.Uri.joinPath(folderUri, "pyproject.toml") + const pyprojecttomlFiles = await vscode.workspace.findFiles( + new vscode.RelativePattern(folderUri, "**/pyproject.toml"), + new vscode.RelativePattern( + folderUri, + "**/{.venv,venv,__pycache__,node_modules,.git,tests,test}/**", + ), + ) - if (!(await vscodeFileSystem.exists(pyprojectUri.toString()))) { + if (pyprojecttomlFiles.length === 0) { return null } - try { - const document = await vscode.workspace.openTextDocument(pyprojectUri) - const contents = toml.parse(document.getText()) as Record + pyprojecttomlFiles.sort( + (a, b) => a.path.split("/").length - b.path.split("/").length, + ) - const entrypoint = (contents.tool as Record | undefined) - ?.fastapi as Record | undefined - const entrypointValue = entrypoint?.entrypoint as string | undefined + for (const fileUri of pyprojecttomlFiles) { + try { + const document = await vscode.workspace.openTextDocument(fileUri) + const contents = toml.parse(document.getText()) as Record - if (!entrypointValue) { - return null - } + const entrypoint = (contents.tool as Record | undefined) + ?.fastapi as Record | undefined + const entrypointValue = entrypoint?.entrypoint as string | undefined - const { relativePath, variableName } = - parseEntrypointString(entrypointValue) - const fullUri = vscode.Uri.joinPath(folderUri, relativePath) + if (!entrypointValue) { + continue + } - return (await vscodeFileSystem.exists(fullUri.toString())) - ? { filePath: fullUri.toString(), variableName } - : null - } catch { - // Invalid TOML syntax - silently fall back to auto-detection - return null + const { relativePath, variableName } = + parseEntrypointString(entrypointValue) + const fullUri = vscode.Uri.joinPath(fileUri, "..", relativePath) + + return (await vscodeFileSystem.exists(fullUri.toString())) + ? { filePath: fullUri.toString(), variableName } + : null + } catch {} } + + return null } /** diff --git a/src/core/pathUtils.ts b/src/core/pathUtils.ts index e139ba6..57e5482 100644 --- a/src/core/pathUtils.ts +++ b/src/core/pathUtils.ts @@ -140,9 +140,8 @@ export function pathMatchesPathOperation( } /** - * Finds the Python project root by walking up from the entry file - * until we find a directory without __init__.py (or hit the workspace root). - * This is the directory from which absolute imports are resolved. + * Finds the Python project root (the directory from which absolute imports + * are resolved) by walking up from the entry file toward the workspace root. */ export async function findProjectRoot( entryUri: string, @@ -154,8 +153,20 @@ export async function findProjectRoot( ): Promise { let dirUri = uriDirname(entryUri) - // If the entry file's directory doesn't have __init__.py, it's a top-level script + // If the entry file's directory doesn't have __init__.py it could be a + // top-level script OR a namespace package (no __init__.py) in a monorepo. + // Walk up toward the workspace root to find a pyproject.toml; if found, + // that directory is the Python project root. Otherwise fall back to the + // entry dir as before. if (!(await fs.exists(fs.joinPath(dirUri, "__init__.py")))) { + let searchDir = dirUri + while (isWithinDirectory(searchDir, workspaceRootUri)) { + if (await fs.exists(fs.joinPath(searchDir, "pyproject.toml"))) { + return searchDir + } + if (uriPath(searchDir) === uriPath(workspaceRootUri)) break + searchDir = uriDirname(searchDir) + } return dirUri } diff --git a/src/test/core/pathUtils.test.ts b/src/test/core/pathUtils.test.ts index d90eaa8..dcc1af8 100644 --- a/src/test/core/pathUtils.test.ts +++ b/src/test/core/pathUtils.test.ts @@ -195,6 +195,17 @@ suite("pathUtils", () => { assert.strictEqual(result, appRootUri) }) + + test("returns pyproject.toml dir for namespace packages in a monorepo", async () => { + // myapp/ has no __init__.py (namespace package), but service/ has pyproject.toml + const result = await findProjectRoot( + fixtures.monorepo.mainPy, + fixtures.monorepo.workspaceRoot, + nodeFileSystem, + ) + + assert.strictEqual(result, fixtures.monorepo.projectRoot) + }) }) suite("pathMatchesPathOperation", () => { diff --git a/src/test/core/routerResolver.test.ts b/src/test/core/routerResolver.test.ts index 7794621..41b1a63 100644 --- a/src/test/core/routerResolver.test.ts +++ b/src/test/core/routerResolver.test.ts @@ -1,5 +1,6 @@ import * as assert from "node:assert" import { Parser } from "../../core/parser" +import { findProjectRoot } from "../../core/pathUtils" import { buildRouterGraph } from "../../core/routerResolver" import { fixtures, @@ -517,5 +518,25 @@ suite("routerResolver", () => { "neon router should have routes", ) }) + + test("resolves imports in a monorepo with pyproject.toml in a subdirectory", async () => { + const projectRoot = await findProjectRoot( + fixtures.monorepo.mainPy, + fixtures.monorepo.workspaceRoot, + nodeFileSystem, + ) + const result = await buildRouterGraph( + fixtures.monorepo.mainPy, + parser, + projectRoot, + nodeFileSystem, + ) + + assert.ok(result) + assert.strictEqual(result.type, "FastAPI") + assert.strictEqual(result.children.length, 1) + assert.strictEqual(result.children[0].router.prefix, "/users") + assert.ok(result.children[0].router.routes.length >= 2) + }) }) }) diff --git a/src/test/fixtures/monorepo/service/myapp/main.py b/src/test/fixtures/monorepo/service/myapp/main.py new file mode 100644 index 0000000..28a202d --- /dev/null +++ b/src/test/fixtures/monorepo/service/myapp/main.py @@ -0,0 +1,7 @@ +from fastapi import FastAPI + +from myapp.users.router import router as users_router + +app = FastAPI() + +app.include_router(users_router) diff --git a/src/test/fixtures/monorepo/service/myapp/users/router.py b/src/test/fixtures/monorepo/service/myapp/users/router.py new file mode 100644 index 0000000..36f8a70 --- /dev/null +++ b/src/test/fixtures/monorepo/service/myapp/users/router.py @@ -0,0 +1,13 @@ +from fastapi import APIRouter + +router = APIRouter(prefix="/users", tags=["users"]) + + +@router.get("/") +def list_users(): + return [] + + +@router.get("/{user_id}") +def get_user(user_id: int): + return {"id": user_id} diff --git a/src/test/fixtures/monorepo/service/pyproject.toml b/src/test/fixtures/monorepo/service/pyproject.toml new file mode 100644 index 0000000..e69de29 diff --git a/src/test/testUtils.ts b/src/test/testUtils.ts index 49ae925..6608a7d 100644 --- a/src/test/testUtils.ts +++ b/src/test/testUtils.ts @@ -78,6 +78,11 @@ export const fixtures = { join(fixturesPath, "nested-router", "app", "routes", "settings.py"), ), }, + monorepo: { + workspaceRoot: uri(join(fixturesPath, "monorepo")), + projectRoot: uri(join(fixturesPath, "monorepo", "service")), + mainPy: uri(join(fixturesPath, "monorepo", "service", "myapp", "main.py")), + }, } /** From f798f2eb70639849a59ee2f3b0e91014ae701b0a Mon Sep 17 00:00:00 2001 From: Savannah Ostrowski Date: Fri, 27 Feb 2026 10:51:21 -0800 Subject: [PATCH 2/4] Add comment --- src/appDiscovery.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/appDiscovery.ts b/src/appDiscovery.ts index a14c1e4..4d0bf5f 100644 --- a/src/appDiscovery.ts +++ b/src/appDiscovery.ts @@ -116,7 +116,9 @@ async function parsePyprojectForEntryPoint( return (await vscodeFileSystem.exists(fullUri.toString())) ? { filePath: fullUri.toString(), variableName } : null - } catch {} + } catch { + // Invalid TOML syntax - silently fall back to next file + } } return null From e77cc01472d26b0359155e1eef8730aaea0ac7ed Mon Sep 17 00:00:00 2001 From: Savannah Ostrowski Date: Fri, 27 Feb 2026 10:52:08 -0800 Subject: [PATCH 3/4] Simplify comment --- src/core/pathUtils.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/core/pathUtils.ts b/src/core/pathUtils.ts index 57e5482..4814fe7 100644 --- a/src/core/pathUtils.ts +++ b/src/core/pathUtils.ts @@ -153,11 +153,9 @@ export async function findProjectRoot( ): Promise { let dirUri = uriDirname(entryUri) - // If the entry file's directory doesn't have __init__.py it could be a - // top-level script OR a namespace package (no __init__.py) in a monorepo. - // Walk up toward the workspace root to find a pyproject.toml; if found, - // that directory is the Python project root. Otherwise fall back to the - // entry dir as before. + // No __init__.py — could be a namespace package. Walk up toward the + // workspace root to find a pyproject.toml; if found, that directory is + // the Python project root. Otherwise fall back to the entry dir. if (!(await fs.exists(fs.joinPath(dirUri, "__init__.py")))) { let searchDir = dirUri while (isWithinDirectory(searchDir, workspaceRootUri)) { From 9024149c615185d11f72115cd87195eaebccbc0c Mon Sep 17 00:00:00 2001 From: Savannah Ostrowski Date: Fri, 27 Feb 2026 11:02:33 -0800 Subject: [PATCH 4/4] Clean up comments and naming --- src/appDiscovery.ts | 11 ++++++----- src/core/pathUtils.ts | 4 +++- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/appDiscovery.ts b/src/appDiscovery.ts index 4d0bf5f..69c7f18 100644 --- a/src/appDiscovery.ts +++ b/src/appDiscovery.ts @@ -80,7 +80,7 @@ async function findAllFastAPIFiles( async function parsePyprojectForEntryPoint( folderUri: vscode.Uri, ): Promise { - const pyprojecttomlFiles = await vscode.workspace.findFiles( + const pyprojectTomlFiles = await vscode.workspace.findFiles( new vscode.RelativePattern(folderUri, "**/pyproject.toml"), new vscode.RelativePattern( folderUri, @@ -88,15 +88,15 @@ async function parsePyprojectForEntryPoint( ), ) - if (pyprojecttomlFiles.length === 0) { + if (pyprojectTomlFiles.length === 0) { return null } - pyprojecttomlFiles.sort( + pyprojectTomlFiles.sort( (a, b) => a.path.split("/").length - b.path.split("/").length, ) - for (const fileUri of pyprojecttomlFiles) { + for (const fileUri of pyprojectTomlFiles) { try { const document = await vscode.workspace.openTextDocument(fileUri) const contents = toml.parse(document.getText()) as Record @@ -111,7 +111,8 @@ async function parsePyprojectForEntryPoint( const { relativePath, variableName } = parseEntrypointString(entrypointValue) - const fullUri = vscode.Uri.joinPath(fileUri, "..", relativePath) + const dirUri = vscode.Uri.joinPath(fileUri, "..") + const fullUri = vscode.Uri.joinPath(dirUri, relativePath) return (await vscodeFileSystem.exists(fullUri.toString())) ? { filePath: fullUri.toString(), variableName } diff --git a/src/core/pathUtils.ts b/src/core/pathUtils.ts index 4814fe7..59afa50 100644 --- a/src/core/pathUtils.ts +++ b/src/core/pathUtils.ts @@ -168,7 +168,9 @@ export async function findProjectRoot( return dirUri } - // Walk up until we find a directory whose parent doesn't have __init__.py + // __init__.py is present, so this is a traditional package. Walk up until + // we find a directory whose parent doesn't have __init__.py — that parent + // is the project root (the directory Python adds to sys.path). while ( isWithinDirectory(dirUri, workspaceRootUri) && uriPath(dirUri) !== uriPath(workspaceRootUri)