Skip to content

Commit d9685f7

Browse files
Add support for namespace package
1 parent 298f265 commit d9685f7

8 files changed

Lines changed: 308 additions & 43 deletions

File tree

src/core/extractors.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -190,9 +190,10 @@ export function routerExtractor(node: Node): RouterInfo | null {
190190

191191
if (valueNode.type === "call") {
192192
const functionNameNode = valueNode.childForFieldName("function")
193-
if (functionNameNode && functionNameNode.text === "APIRouter") {
193+
const funcName = functionNameNode?.text
194+
if (funcName === "APIRouter" || funcName === "fastapi.APIRouter") {
194195
type = "APIRouter"
195-
} else if (functionNameNode && functionNameNode.text === "FastAPI") {
196+
} else if (funcName === "FastAPI" || funcName === "fastapi.FastAPI") {
196197
type = "FastAPI"
197198
}
198199

src/core/importResolver.ts

Lines changed: 82 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,56 @@
1+
/**
2+
* Python import resolution for static analysis.
3+
*
4+
* Resolves Python import statements to file paths without executing code.
5+
* Handles relative imports, absolute imports, namespace packages (PEP 420),
6+
* and re-exports from __init__.py files.
7+
*
8+
* Resolution order (matching Python):
9+
* 1. module.py (direct file)
10+
* 2. module/__init__.py (package)
11+
* 3. module/ without __init__.py (namespace package)
12+
*/
13+
114
import { existsSync } from "node:fs"
2-
import { dirname } from "node:path"
15+
import { dirname, join } from "node:path"
316
import { analyzeFile } from "./analyzer"
417
import type { ImportInfo } from "./internal"
518
import type { Parser } from "./parser"
619

20+
/**
21+
* Cache for file existence checks to avoid repeated filesystem calls.
22+
* Maps file path -> exists (true/false).
23+
*/
24+
const fileExistsCache = new Map<string, boolean>()
25+
26+
function cachedExistsSync(path: string): boolean {
27+
const cached = fileExistsCache.get(path)
28+
if (cached !== undefined) {
29+
return cached
30+
}
31+
const exists = existsSync(path)
32+
fileExistsCache.set(path, exists)
33+
return exists
34+
}
35+
36+
/** Clears the file existence cache. Call when files may have changed. */
37+
export function clearImportCache(): void {
38+
fileExistsCache.clear()
39+
}
40+
741
/**
842
* Resolves a module path to its Python file.
943
* Checks for direct .py file first, then package __init__.py
1044
* (matching Python's import resolution order).
1145
*/
1246
function resolvePythonModule(basePath: string): string | null {
13-
if (existsSync(`${basePath}.py`)) {
14-
return `${basePath}.py`
47+
const pyPath = `${basePath}.py`
48+
if (cachedExistsSync(pyPath)) {
49+
return pyPath
1550
}
16-
if (existsSync(`${basePath}/__init__.py`)) {
17-
return `${basePath}/__init__.py`
51+
const initPath = join(basePath, "__init__.py")
52+
if (cachedExistsSync(initPath)) {
53+
return initPath
1854
}
1955
return null
2056
}
@@ -41,30 +77,50 @@ function findImportByExportedName(
4177
}
4278

4379
/**
44-
* Base resolution of a module import to its file path.
45-
* Handles both relative and absolute imports.
80+
* Converts a Python module path to a filesystem directory path.
81+
*
82+
* Examples (modulePath, relativeDots → result):
83+
* Absolute: ("app.api.routes", 0) from projectRoot="/project" → "/project/app/api/routes"
84+
* Relative: ("routes", 1) from "/project/app/api/main.py" → "/project/app/api/routes"
85+
* Relative: ("routes", 2) from "/project/app/api/main.py" → "/project/app/routes"
4686
*/
47-
export function resolveImport(
87+
function modulePathToDir(
4888
importInfo: Pick<ImportInfo, "modulePath" | "isRelative" | "relativeDots">,
4989
currentFilePath: string,
5090
projectRoot: string,
51-
): string | null {
52-
let resolvedPath: string
53-
91+
): string {
92+
let baseDir: string
5493
if (importInfo.isRelative) {
5594
// For relative imports, go up 'relativeDots' directories from current file
56-
let currentDir = dirname(currentFilePath)
95+
baseDir = dirname(currentFilePath)
5796
for (let i = 1; i < importInfo.relativeDots; i++) {
58-
currentDir = dirname(currentDir)
97+
baseDir = dirname(baseDir)
5998
}
60-
resolvedPath = importInfo.modulePath
61-
? `${currentDir}/${importInfo.modulePath.replace(/\./g, "/")}`
62-
: currentDir
63-
// Absolute import
6499
} else {
65-
resolvedPath = `${projectRoot}/${importInfo.modulePath.replace(/\./g, "/")}`
100+
baseDir = projectRoot
101+
}
102+
103+
if (importInfo.modulePath) {
104+
return join(baseDir, ...importInfo.modulePath.split("."))
66105
}
106+
return baseDir
107+
}
67108

109+
/**
110+
* Resolves a module import to its file path.
111+
*
112+
* Examples:
113+
* "from app.api import routes" → "/project/app/api/routes.py" or "/project/app/api/routes/__init__.py"
114+
* "from .routes import users" → "/project/app/api/routes.py" or "/project/app/api/routes/__init__.py"
115+
*
116+
* Returns null if the module doesn't exist (may be a namespace package).
117+
*/
118+
export function resolveImport(
119+
importInfo: Pick<ImportInfo, "modulePath" | "isRelative" | "relativeDots">,
120+
currentFilePath: string,
121+
projectRoot: string,
122+
): string | null {
123+
const resolvedPath = modulePathToDir(importInfo, currentFilePath, projectRoot)
68124
return resolvePythonModule(resolvedPath)
69125
}
70126

@@ -83,22 +139,24 @@ export function resolveNamedImport(
83139
parser?: Parser,
84140
): string | null {
85141
const basePath = resolveImport(importInfo, currentFilePath, projectRoot)
86-
if (!basePath) {
87-
return null
88-
}
89142

90-
const baseDir = dirname(basePath)
143+
// Calculate base directory for named import resolution.
144+
// For namespace packages (directories without __init__.py), basePath will be null,
145+
// so we compute the directory path directly from the module path.
146+
const baseDir = basePath
147+
? dirname(basePath)
148+
: modulePathToDir(importInfo, currentFilePath, projectRoot)
91149

92150
for (const name of importInfo.names) {
93151
// Try direct file: from .routes import users -> routes/users.py
94-
const namedPath = `${baseDir}/${name.replace(/\./g, "/")}`
152+
const namedPath = join(baseDir, ...name.split("."))
95153
const resolved = resolvePythonModule(namedPath)
96154
if (resolved) {
97155
return resolved
98156
}
99157

100158
// Try re-exports: from .routes import users where routes/__init__.py re-exports users
101-
if (basePath.endsWith("__init__.py") && parser) {
159+
if (basePath?.endsWith("__init__.py") && parser) {
102160
const analysis = analyzeFile(basePath, parser)
103161
const imp = analysis && findImportByExportedName(analysis.imports, name)
104162
if (imp) {

src/core/pathUtils.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import { existsSync } from "node:fs"
2+
import { dirname, join, relative, sep } from "node:path"
3+
14
/**
25
* Strips leading dynamic segments (like {settings.API_V1_STR}) from a path.
36
* These are runtime variables, not URL path parameters.
@@ -12,6 +15,44 @@ export function stripLeadingDynamicSegments(path: string): string {
1215
return path.replace(/^(\{[^}]+\})+/, "") || "/"
1316
}
1417

18+
/**
19+
* Checks if a path is within or equal to a base directory.
20+
* Uses relative path calculation to avoid false positives from string prefix matching.
21+
*/
22+
export function isWithinDirectory(filePath: string, baseDir: string): boolean {
23+
const rel = relative(baseDir, filePath)
24+
// If relative path starts with "..", the path is outside baseDir
25+
return !rel.startsWith("..") && !rel.startsWith(sep)
26+
}
27+
28+
/**
29+
* Finds the Python project root by walking up from the entry file
30+
* until we find a directory without __init__.py (or hit the workspace root).
31+
* This is the directory from which absolute imports are resolved.
32+
*/
33+
export function findProjectRoot(
34+
entryPath: string,
35+
workspaceRoot: string,
36+
): string {
37+
let dir = dirname(entryPath)
38+
39+
// If the entry file's directory doesn't have __init__.py, it's a top-level script
40+
if (!existsSync(join(dir, "__init__.py"))) {
41+
return dir
42+
}
43+
44+
// Walk up until we find a directory whose parent doesn't have __init__.py
45+
while (isWithinDirectory(dir, workspaceRoot) && dir !== workspaceRoot) {
46+
const parent = dirname(dir)
47+
if (!existsSync(join(parent, "__init__.py"))) {
48+
return parent
49+
}
50+
dir = parent
51+
}
52+
53+
return workspaceRoot
54+
}
55+
1556
/**
1657
* Gets the first N segments of a path.
1758
*

src/extension.ts

Lines changed: 33 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@
22
* VSCode extension entry point for FastAPI endpoint discovery.
33
*/
44

5+
import { sep } from "node:path"
56
import * as vscode from "vscode"
7+
import { clearImportCache } from "./core/importResolver"
68
import { Parser } from "./core/parser"
7-
import { stripLeadingDynamicSegments } from "./core/pathUtils"
9+
import { findProjectRoot, stripLeadingDynamicSegments } from "./core/pathUtils"
810
import { buildRouterGraph } from "./core/routerResolver"
911
import { routerNodeToAppDefinition } from "./core/transformer"
1012
import type { AppDefinition, SourceLocation } from "./core/types"
@@ -21,25 +23,38 @@ async function discoverFastAPIApps(parser: Parser): Promise<AppDefinition[]> {
2123
return apps
2224
}
2325

24-
const defaultPatterns = [
25-
"main.py",
26-
"app/main.py",
27-
"api/main.py",
28-
"src/main.py",
29-
"backend/app/main.py",
30-
]
31-
3226
for (const folder of workspaceFolders) {
3327
const config = vscode.workspace.getConfiguration("fastapi", folder.uri)
3428
const customEntryPoint = config.get<string>("entryPoint")
35-
const patterns = customEntryPoint ? [customEntryPoint] : defaultPatterns
36-
37-
for (const pattern of patterns) {
38-
// Handle both relative patterns and absolute paths
39-
const entryPath = pattern.startsWith("/")
40-
? pattern
41-
: vscode.Uri.joinPath(folder.uri, pattern).fsPath
42-
const projectRoot = entryPath.split("/").slice(0, -2).join("/")
29+
30+
let candidates: string[] = []
31+
32+
if (customEntryPoint) {
33+
// Use custom entry point if specified
34+
const entryPath = customEntryPoint.startsWith("/")
35+
? customEntryPoint
36+
: vscode.Uri.joinPath(folder.uri, customEntryPoint).fsPath
37+
candidates = [entryPath]
38+
} else {
39+
// Scan for main.py and __init__.py files (likely FastAPI entry points)
40+
const mainFiles = await vscode.workspace.findFiles(
41+
new vscode.RelativePattern(folder, "**/main.py"),
42+
undefined,
43+
20,
44+
)
45+
const initFiles = await vscode.workspace.findFiles(
46+
new vscode.RelativePattern(folder, "**/__init__.py"),
47+
undefined,
48+
20,
49+
)
50+
// Prefer main.py, then __init__.py, sorted by path depth (shallower first)
51+
candidates = [...mainFiles, ...initFiles]
52+
.map((uri) => uri.fsPath)
53+
.sort((a, b) => a.split(sep).length - b.split(sep).length)
54+
}
55+
56+
for (const entryPath of candidates) {
57+
const projectRoot = findProjectRoot(entryPath, folder.uri.fsPath)
4358
const routerNode = buildRouterGraph(entryPath, parser, projectRoot)
4459

4560
if (routerNode) {
@@ -95,6 +110,7 @@ export async function activate(context: vscode.ExtensionContext) {
95110
vscode.commands.registerCommand(
96111
"fastapi-vscode.refreshEndpoints",
97112
async () => {
113+
clearImportCache()
98114
const newApps = await discoverFastAPIApps(parserService)
99115
endpointProvider.setApps(newApps)
100116
},

src/test/extractors.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,29 @@ def handler():
279279

280280
assert.strictEqual(result, null)
281281
})
282+
283+
test("extracts qualified fastapi.FastAPI() call", () => {
284+
const code = "app = fastapi.FastAPI()"
285+
const tree = parse(code)
286+
const assignments = findNodesByType(tree.rootNode, "assignment")
287+
const result = routerExtractor(assignments[0])
288+
289+
assert.ok(result)
290+
assert.strictEqual(result.variableName, "app")
291+
assert.strictEqual(result.type, "FastAPI")
292+
})
293+
294+
test("extracts qualified fastapi.APIRouter() call", () => {
295+
const code = "router = fastapi.APIRouter(prefix='/api')"
296+
const tree = parse(code)
297+
const assignments = findNodesByType(tree.rootNode, "assignment")
298+
const result = routerExtractor(assignments[0])
299+
300+
assert.ok(result)
301+
assert.strictEqual(result.variableName, "router")
302+
assert.strictEqual(result.type, "APIRouter")
303+
assert.strictEqual(result.prefix, "/api")
304+
})
282305
})
283306

284307
suite("importExtractor", () => {
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
from fastapi import APIRouter
2+
3+
router = APIRouter()
4+
5+
@router.get("/items")
6+
def list_items():
7+
pass

src/test/importResolver.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,5 +183,47 @@ suite("importResolver", () => {
183183
result.endsWith("routes/__init__.py") || result.endsWith("routes.py"),
184184
)
185185
})
186+
187+
test("resolves relative named import from namespace package (no __init__.py)", () => {
188+
const currentFile = path.join(fixturesPath, "app", "api", "main.py")
189+
const projectRoot = fixturesPath
190+
191+
// namespace_routes has no __init__.py, but api_routes.py exists
192+
const result = resolveNamedImport(
193+
{
194+
modulePath: "namespace_routes",
195+
names: ["api_routes"],
196+
isRelative: true,
197+
relativeDots: 1,
198+
},
199+
currentFile,
200+
projectRoot,
201+
parser,
202+
)
203+
204+
assert.ok(result)
205+
assert.ok(result.endsWith("api_routes.py"))
206+
})
207+
208+
test("resolves absolute named import from namespace package (no __init__.py)", () => {
209+
const currentFile = path.join(fixturesPath, "main.py")
210+
const projectRoot = fixturesPath
211+
212+
// app.api.namespace_routes has no __init__.py, but api_routes.py exists
213+
const result = resolveNamedImport(
214+
{
215+
modulePath: "app.api.namespace_routes",
216+
names: ["api_routes"],
217+
isRelative: false,
218+
relativeDots: 0,
219+
},
220+
currentFile,
221+
projectRoot,
222+
parser,
223+
)
224+
225+
assert.ok(result)
226+
assert.ok(result.endsWith("api_routes.py"))
227+
})
186228
})
187229
})

0 commit comments

Comments
 (0)