Skip to content

Commit fb84026

Browse files
Rebase
1 parent 834600d commit fb84026

3 files changed

Lines changed: 77 additions & 47 deletions

File tree

src/appDiscovery.ts

Lines changed: 67 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -3,58 +3,55 @@
33
* Handles finding FastAPI apps via pyproject.toml, VS Code settings, or automatic detection.
44
*/
55

6-
import { existsSync } from "node:fs"
7-
import { isAbsolute, sep } from "node:path"
86
import * as toml from "toml"
97
import * as vscode from "vscode"
108
import type { EntryPoint } from "./core/internal"
119
import type { Parser } from "./core/parser"
12-
import { findProjectRoot } from "./core/pathUtils"
10+
import { findProjectRoot, uriPath } from "./core/pathUtils"
1311
import { buildRouterGraph } from "./core/routerResolver"
1412
import { routerNodeToAppDefinition } from "./core/transformer"
1513
import type { AppDefinition } from "./core/types"
14+
import { vscodeFileSystem } from "./providers/vscodeFileSystem"
15+
import { log } from "./utils/logger"
1616

1717
export type { EntryPoint }
1818

1919
/**
2020
* Scans for common FastAPI entry point files (main.py, __init__.py).
21-
* Returns paths sorted by depth (shallower first).
21+
* Returns URI strings sorted by depth (shallower first).
2222
*/
2323
async function automaticDetectEntryPoints(
24-
folderPath: string,
24+
folder: vscode.WorkspaceFolder,
2525
): Promise<string[]> {
2626
const [mainFiles, initFiles] = await Promise.all([
2727
vscode.workspace.findFiles(
28-
new vscode.RelativePattern(folderPath, "**/main.py"),
28+
new vscode.RelativePattern(folder, "**/main.py"),
2929
),
3030
vscode.workspace.findFiles(
31-
new vscode.RelativePattern(folderPath, "**/__init__.py"),
31+
new vscode.RelativePattern(folder, "**/__init__.py"),
3232
),
3333
])
3434

3535
return [...mainFiles, ...initFiles]
36-
.map((uri) => uri.fsPath)
37-
.sort((a, b) => a.split(sep).length - b.split(sep).length)
36+
.map((uri) => uri.toString())
37+
.sort((a, b) => uriPath(a).split("/").length - uriPath(b).split("/").length)
3838
}
3939

4040
/**
4141
* Parses pyproject.toml to find a defined entrypoint.
4242
* Supports module:variable notation, e.g. "my_app.main:app"
4343
*/
4444
async function parsePyprojectForEntryPoint(
45-
folderPath: string,
45+
folderUri: vscode.Uri,
4646
): Promise<EntryPoint | null> {
47-
const pyprojectPath = vscode.Uri.joinPath(
48-
vscode.Uri.file(folderPath),
49-
"pyproject.toml",
50-
)
47+
const pyprojectUri = vscode.Uri.joinPath(folderUri, "pyproject.toml")
5148

52-
if (!existsSync(pyprojectPath.fsPath)) {
49+
if (!(await vscodeFileSystem.exists(pyprojectUri.toString()))) {
5350
return null
5451
}
5552

5653
try {
57-
const document = await vscode.workspace.openTextDocument(pyprojectPath)
54+
const document = await vscode.workspace.openTextDocument(pyprojectUri)
5855
const contents = toml.parse(document.getText()) as Record<string, unknown>
5956

6057
const entrypoint = (contents.tool as Record<string, unknown> | undefined)
@@ -73,13 +70,12 @@ async function parsePyprojectForEntryPoint(
7370
colonIndex === -1 ? undefined : entrypointValue.slice(colonIndex + 1)
7471

7572
// Convert module path to file path: my_app.main -> my_app/main.py
76-
const relativePath = `${modulePath.replace(/\./g, sep)}.py`
77-
const fullPath = vscode.Uri.joinPath(
78-
vscode.Uri.file(folderPath),
79-
relativePath,
80-
).fsPath
73+
const relativePath = `${modulePath.replace(/\./g, "/")}.py`
74+
const fullUri = vscode.Uri.joinPath(folderUri, relativePath)
8175

82-
return existsSync(fullPath) ? { filePath: fullPath, variableName } : null
76+
return (await vscodeFileSystem.exists(fullUri.toString()))
77+
? { filePath: fullUri.toString(), variableName }
78+
: null
8379
} catch {
8480
// Invalid TOML syntax - silently fall back to auto-detection
8581
return null
@@ -94,7 +90,14 @@ export async function discoverFastAPIApps(
9490
parser: Parser,
9591
): Promise<AppDefinition[]> {
9692
const workspaceFolders = vscode.workspace.workspaceFolders
97-
if (!workspaceFolders) return []
93+
if (!workspaceFolders) {
94+
log("No workspace folders found")
95+
return []
96+
}
97+
98+
log(
99+
`Discovering FastAPI apps in ${workspaceFolders.length} workspace folder(s)...`,
100+
)
98101

99102
const apps: AppDefinition[] = []
100103

@@ -106,53 +109,77 @@ export async function discoverFastAPIApps(
106109

107110
// If user specified an entry point in settings, use that
108111
if (customEntryPoint) {
109-
const entryPath = isAbsolute(customEntryPoint)
110-
? customEntryPoint
111-
: vscode.Uri.joinPath(folder.uri, customEntryPoint).fsPath
112+
const entryUri = customEntryPoint.startsWith("/")
113+
? vscode.Uri.file(customEntryPoint)
114+
: vscode.Uri.joinPath(folder.uri, customEntryPoint)
112115

113-
if (!existsSync(entryPath)) {
116+
if (!(await vscodeFileSystem.exists(entryUri.toString()))) {
117+
log(`Custom entry point not found: ${customEntryPoint}`)
114118
vscode.window.showWarningMessage(
115119
`FastAPI entry point not found: ${customEntryPoint}`,
116120
)
117121
continue
118122
}
119123

120-
candidates = [{ filePath: entryPath }]
124+
log(`Using custom entry point: ${customEntryPoint}`)
125+
candidates = [{ filePath: entryUri.toString() }]
121126
} else {
122127
// Otherwise, check pyproject.toml or auto-detect
123-
const pyprojectEntry = await parsePyprojectForEntryPoint(
124-
folder.uri.fsPath,
125-
)
126-
candidates = pyprojectEntry
127-
? [pyprojectEntry]
128-
: (await automaticDetectEntryPoints(folder.uri.fsPath)).map(
129-
(filePath) => ({ filePath }),
130-
)
128+
const pyprojectEntry = await parsePyprojectForEntryPoint(folder.uri)
129+
if (pyprojectEntry) {
130+
candidates = [pyprojectEntry]
131+
} else {
132+
const detected = await automaticDetectEntryPoints(folder)
133+
candidates = detected.map((filePath) => ({ filePath }))
134+
log(
135+
`Found ${candidates.length} candidate entry file(s) in ${folder.name}`,
136+
)
137+
}
131138

132139
// If no candidates found, try the active editor as a last resort
133140
if (candidates.length === 0) {
134141
const activeEditor = vscode.window.activeTextEditor
135142
if (activeEditor?.document.languageId === "python") {
136-
candidates = [{ filePath: activeEditor.document.uri.fsPath }]
143+
candidates = [{ filePath: activeEditor.document.uri.toString() }]
137144
}
138145
}
139146
}
140147

141148
for (const candidate of candidates) {
142-
const projectRoot = findProjectRoot(candidate.filePath, folder.uri.fsPath)
143-
const routerNode = buildRouterGraph(
149+
const projectRoot = await findProjectRoot(
150+
candidate.filePath,
151+
folder.uri.toString(),
152+
vscodeFileSystem,
153+
)
154+
const routerNode = await buildRouterGraph(
144155
candidate.filePath,
145156
parser,
146157
projectRoot,
158+
vscodeFileSystem,
147159
candidate.variableName,
148160
)
149161

150162
if (routerNode) {
151-
apps.push(routerNodeToAppDefinition(routerNode, folder.uri.fsPath))
163+
const app = routerNodeToAppDefinition(routerNode, folder.uri.fsPath)
164+
// Count all routes: direct routes + routes in all routers (recursively)
165+
const countRoutes = (routers: typeof app.routers): number =>
166+
routers.reduce(
167+
(sum, r) => sum + r.routes.length + countRoutes(r.children),
168+
0,
169+
)
170+
const totalRoutes = app.routes.length + countRoutes(app.routers)
171+
log(
172+
`Found FastAPI app "${app.name}" with ${totalRoutes} route(s) in ${app.routers.length} router(s)`,
173+
)
174+
apps.push(app)
152175
break
153176
}
154177
}
155178
}
156179

180+
if (apps.length === 0) {
181+
log("No FastAPI apps found in workspace")
182+
}
183+
157184
return apps
158185
}

src/test/core/routerResolver.test.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -259,22 +259,24 @@ suite("routerResolver", () => {
259259
)
260260
})
261261

262-
test("selects specific app by targetVariable", () => {
262+
test("selects specific app by targetVariable", async () => {
263263
// Without targetVariable, should pick first FastAPI app (public_app)
264-
const defaultResult = buildRouterGraph(
264+
const defaultResult = await buildRouterGraph(
265265
fixtures.multiApp.mainPy,
266266
parser,
267267
fixtures.multiApp.root,
268+
nodeFileSystem,
268269
)
269270

270271
assert.ok(defaultResult)
271272
assert.strictEqual(defaultResult.variableName, "public_app")
272273

273274
// With targetVariable, should select admin_app
274-
const adminResult = buildRouterGraph(
275+
const adminResult = await buildRouterGraph(
275276
fixtures.multiApp.mainPy,
276277
parser,
277278
fixtures.multiApp.root,
279+
nodeFileSystem,
278280
"admin_app",
279281
)
280282

@@ -290,11 +292,12 @@ suite("routerResolver", () => {
290292
assert.ok(routePaths.includes("/users/{user_id}"))
291293
})
292294

293-
test("returns null for non-existent targetVariable", () => {
294-
const result = buildRouterGraph(
295+
test("returns null for non-existent targetVariable", async () => {
296+
const result = await buildRouterGraph(
295297
fixtures.multiApp.mainPy,
296298
parser,
297299
fixtures.multiApp.root,
300+
nodeFileSystem,
298301
"nonexistent_app",
299302
)
300303

src/test/testUtils.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,8 @@ export const fixtures = {
4646
mainPy: uri(join(fixturesPath, "same-file", "main.py")),
4747
},
4848
multiApp: {
49-
root: join(fixturesPath, "multi-app"),
50-
mainPy: join(fixturesPath, "multi-app", "main.py"),
49+
root: uri(join(fixturesPath, "multi-app")),
50+
mainPy: uri(join(fixturesPath, "multi-app", "main.py")),
5151
},
5252
}
5353

0 commit comments

Comments
 (0)