diff --git a/src/appDiscovery.ts b/src/appDiscovery.ts index 3c4b2c0..69c7f18 100644 --- a/src/appDiscovery.ts +++ b/src/appDiscovery.ts @@ -80,35 +80,49 @@ 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 dirUri = vscode.Uri.joinPath(fileUri, "..") + const fullUri = vscode.Uri.joinPath(dirUri, relativePath) + + return (await vscodeFileSystem.exists(fullUri.toString())) + ? { filePath: fullUri.toString(), variableName } + : null + } catch { + // Invalid TOML syntax - silently fall back to next file + } } + + return null } /** diff --git a/src/core/pathUtils.ts b/src/core/pathUtils.ts index e139ba6..59afa50 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,12 +153,24 @@ 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 + // 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)) { + if (await fs.exists(fs.joinPath(searchDir, "pyproject.toml"))) { + return searchDir + } + if (uriPath(searchDir) === uriPath(workspaceRootUri)) break + searchDir = uriDirname(searchDir) + } 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) 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")), + }, } /**