Skip to content

Commit b82b267

Browse files
πŸ› Improve monorepo application discovery (#90)
1 parent a71b888 commit b82b267

8 files changed

Lines changed: 107 additions & 25 deletions

File tree

β€Žsrc/appDiscovery.tsβ€Ž

Lines changed: 34 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -80,35 +80,49 @@ async function findAllFastAPIFiles(
8080
async function parsePyprojectForEntryPoint(
8181
folderUri: vscode.Uri,
8282
): Promise<EntryPoint | null> {
83-
const pyprojectUri = vscode.Uri.joinPath(folderUri, "pyproject.toml")
83+
const pyprojectTomlFiles = await vscode.workspace.findFiles(
84+
new vscode.RelativePattern(folderUri, "**/pyproject.toml"),
85+
new vscode.RelativePattern(
86+
folderUri,
87+
"**/{.venv,venv,__pycache__,node_modules,.git,tests,test}/**",
88+
),
89+
)
8490

85-
if (!(await vscodeFileSystem.exists(pyprojectUri.toString()))) {
91+
if (pyprojectTomlFiles.length === 0) {
8692
return null
8793
}
8894

89-
try {
90-
const document = await vscode.workspace.openTextDocument(pyprojectUri)
91-
const contents = toml.parse(document.getText()) as Record<string, unknown>
95+
pyprojectTomlFiles.sort(
96+
(a, b) => a.path.split("/").length - b.path.split("/").length,
97+
)
9298

93-
const entrypoint = (contents.tool as Record<string, unknown> | undefined)
94-
?.fastapi as Record<string, unknown> | undefined
95-
const entrypointValue = entrypoint?.entrypoint as string | undefined
99+
for (const fileUri of pyprojectTomlFiles) {
100+
try {
101+
const document = await vscode.workspace.openTextDocument(fileUri)
102+
const contents = toml.parse(document.getText()) as Record<string, unknown>
96103

97-
if (!entrypointValue) {
98-
return null
99-
}
104+
const entrypoint = (contents.tool as Record<string, unknown> | undefined)
105+
?.fastapi as Record<string, unknown> | undefined
106+
const entrypointValue = entrypoint?.entrypoint as string | undefined
100107

101-
const { relativePath, variableName } =
102-
parseEntrypointString(entrypointValue)
103-
const fullUri = vscode.Uri.joinPath(folderUri, relativePath)
108+
if (!entrypointValue) {
109+
continue
110+
}
104111

105-
return (await vscodeFileSystem.exists(fullUri.toString()))
106-
? { filePath: fullUri.toString(), variableName }
107-
: null
108-
} catch {
109-
// Invalid TOML syntax - silently fall back to auto-detection
110-
return null
112+
const { relativePath, variableName } =
113+
parseEntrypointString(entrypointValue)
114+
const dirUri = vscode.Uri.joinPath(fileUri, "..")
115+
const fullUri = vscode.Uri.joinPath(dirUri, relativePath)
116+
117+
return (await vscodeFileSystem.exists(fullUri.toString()))
118+
? { filePath: fullUri.toString(), variableName }
119+
: null
120+
} catch {
121+
// Invalid TOML syntax - silently fall back to next file
122+
}
111123
}
124+
125+
return null
112126
}
113127

114128
/**

β€Žsrc/core/pathUtils.tsβ€Ž

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -140,9 +140,8 @@ export function pathMatchesPathOperation(
140140
}
141141

142142
/**
143-
* Finds the Python project root by walking up from the entry file
144-
* until we find a directory without __init__.py (or hit the workspace root).
145-
* This is the directory from which absolute imports are resolved.
143+
* Finds the Python project root (the directory from which absolute imports
144+
* are resolved) by walking up from the entry file toward the workspace root.
146145
*/
147146
export async function findProjectRoot(
148147
entryUri: string,
@@ -154,12 +153,24 @@ export async function findProjectRoot(
154153
): Promise<string> {
155154
let dirUri = uriDirname(entryUri)
156155

157-
// If the entry file's directory doesn't have __init__.py, it's a top-level script
156+
// No __init__.py β€” could be a namespace package. Walk up toward the
157+
// workspace root to find a pyproject.toml; if found, that directory is
158+
// the Python project root. Otherwise fall back to the entry dir.
158159
if (!(await fs.exists(fs.joinPath(dirUri, "__init__.py")))) {
160+
let searchDir = dirUri
161+
while (isWithinDirectory(searchDir, workspaceRootUri)) {
162+
if (await fs.exists(fs.joinPath(searchDir, "pyproject.toml"))) {
163+
return searchDir
164+
}
165+
if (uriPath(searchDir) === uriPath(workspaceRootUri)) break
166+
searchDir = uriDirname(searchDir)
167+
}
159168
return dirUri
160169
}
161170

162-
// Walk up until we find a directory whose parent doesn't have __init__.py
171+
// __init__.py is present, so this is a traditional package. Walk up until
172+
// we find a directory whose parent doesn't have __init__.py β€” that parent
173+
// is the project root (the directory Python adds to sys.path).
163174
while (
164175
isWithinDirectory(dirUri, workspaceRootUri) &&
165176
uriPath(dirUri) !== uriPath(workspaceRootUri)

β€Žsrc/test/core/pathUtils.test.tsβ€Ž

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,17 @@ suite("pathUtils", () => {
195195

196196
assert.strictEqual(result, appRootUri)
197197
})
198+
199+
test("returns pyproject.toml dir for namespace packages in a monorepo", async () => {
200+
// myapp/ has no __init__.py (namespace package), but service/ has pyproject.toml
201+
const result = await findProjectRoot(
202+
fixtures.monorepo.mainPy,
203+
fixtures.monorepo.workspaceRoot,
204+
nodeFileSystem,
205+
)
206+
207+
assert.strictEqual(result, fixtures.monorepo.projectRoot)
208+
})
198209
})
199210

200211
suite("pathMatchesPathOperation", () => {

β€Žsrc/test/core/routerResolver.test.tsβ€Ž

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import * as assert from "node:assert"
22
import { Parser } from "../../core/parser"
3+
import { findProjectRoot } from "../../core/pathUtils"
34
import { buildRouterGraph } from "../../core/routerResolver"
45
import {
56
fixtures,
@@ -517,5 +518,25 @@ suite("routerResolver", () => {
517518
"neon router should have routes",
518519
)
519520
})
521+
522+
test("resolves imports in a monorepo with pyproject.toml in a subdirectory", async () => {
523+
const projectRoot = await findProjectRoot(
524+
fixtures.monorepo.mainPy,
525+
fixtures.monorepo.workspaceRoot,
526+
nodeFileSystem,
527+
)
528+
const result = await buildRouterGraph(
529+
fixtures.monorepo.mainPy,
530+
parser,
531+
projectRoot,
532+
nodeFileSystem,
533+
)
534+
535+
assert.ok(result)
536+
assert.strictEqual(result.type, "FastAPI")
537+
assert.strictEqual(result.children.length, 1)
538+
assert.strictEqual(result.children[0].router.prefix, "/users")
539+
assert.ok(result.children[0].router.routes.length >= 2)
540+
})
520541
})
521542
})
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
from fastapi import FastAPI
2+
3+
from myapp.users.router import router as users_router
4+
5+
app = FastAPI()
6+
7+
app.include_router(users_router)
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
from fastapi import APIRouter
2+
3+
router = APIRouter(prefix="/users", tags=["users"])
4+
5+
6+
@router.get("/")
7+
def list_users():
8+
return []
9+
10+
11+
@router.get("/{user_id}")
12+
def get_user(user_id: int):
13+
return {"id": user_id}

β€Žsrc/test/fixtures/monorepo/service/pyproject.tomlβ€Ž

Whitespace-only changes.

β€Žsrc/test/testUtils.tsβ€Ž

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,11 @@ export const fixtures = {
7878
join(fixturesPath, "nested-router", "app", "routes", "settings.py"),
7979
),
8080
},
81+
monorepo: {
82+
workspaceRoot: uri(join(fixturesPath, "monorepo")),
83+
projectRoot: uri(join(fixturesPath, "monorepo", "service")),
84+
mainPy: uri(join(fixturesPath, "monorepo", "service", "myapp", "main.py")),
85+
},
8186
}
8287

8388
/**

0 commit comments

Comments
Β (0)