Skip to content

Commit de6c68b

Browse files
🐛 Fix import resolution for src layout projects and bare relative imports (#140)
1 parent 2f5ffff commit de6c68b

File tree

10 files changed

+181
-18
lines changed

10 files changed

+181
-18
lines changed

src/core/extractors.ts

Lines changed: 25 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -404,25 +404,33 @@ export function importExtractor(node: Node): ImportInfo | null {
404404
moduleNode?.text ?? "",
405405
)
406406

407-
// Aliased imports (e.g., "router as users_router")
408-
const aliasedImports = getNodesByType(node).get("aliased_import") ?? []
409-
for (const aliased of aliasedImports) {
410-
const nameNode = aliased.childForFieldName("name")
411-
const aliasNode = aliased.childForFieldName("alias")
412-
if (nameNode) {
413-
const alias = aliasNode?.text ?? null
414-
names.push(alias ?? nameNode.text)
415-
namedImports.push({ name: nameNode.text, alias })
407+
// Collect imported names: everything after the "import" keyword.
408+
let afterImport = false
409+
for (let i = 0; i < node.childCount; i++) {
410+
const child = node.child(i)
411+
if (!child) continue
412+
413+
// Phase 1: scan forward looking for the "import" keyword
414+
if (!afterImport) {
415+
if (child.type === "import") afterImport = true
416+
continue // skip this child either way (module path or the keyword itself)
416417
}
417-
}
418418

419-
// Non-aliased imports (skip first dotted_name which is the module path)
420-
const nameNodes = getNodesByType(node).get("dotted_name") ?? []
421-
for (let i = 1; i < nameNodes.length; i++) {
422-
const nameNode = nameNodes[i]
423-
if (!hasAncestor(nameNode, "aliased_import")) {
424-
names.push(nameNode.text)
425-
namedImports.push({ name: nameNode.text, alias: null })
419+
// Phase 2: we're past "import", so each child is an imported name
420+
// (commas and other punctuation are silently skipped by the else branch)
421+
if (child.type === "aliased_import") {
422+
// e.g. "router as users_router"
423+
const nameNode = child.childForFieldName("name")
424+
const aliasNode = child.childForFieldName("alias")
425+
if (nameNode) {
426+
const alias = aliasNode?.text ?? null
427+
names.push(alias ?? nameNode.text)
428+
namedImports.push({ name: nameNode.text, alias })
429+
}
430+
} else if (child.type === "dotted_name") {
431+
// e.g. "users"
432+
names.push(child.text)
433+
namedImports.push({ name: child.text, alias: null })
426434
}
427435
}
428436

src/core/importResolver.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,28 @@ export async function resolveImport(
122122
projectRootUri,
123123
fs,
124124
)
125-
return resolvePythonModule(resolvedUri, fs)
125+
const result = await resolvePythonModule(resolvedUri, fs)
126+
if (result) {
127+
return result
128+
}
129+
130+
// Fallback for src layout: if the module wasn't found under the
131+
// project root, try under projectRoot/src/. This handles projects where
132+
// pyproject.toml is at the root but source lives under src/.
133+
// Only applies to absolute imports — relative imports already resolve
134+
// relative to the current file, not the project root.
135+
if (!importInfo.isRelative) {
136+
const srcRootUri = fs.joinPath(projectRootUri, "src")
137+
const srcResolvedUri = modulePathToDir(
138+
importInfo,
139+
currentFileUri,
140+
srcRootUri,
141+
fs,
142+
)
143+
return resolvePythonModule(srcResolvedUri, fs)
144+
}
145+
146+
return null
126147
}
127148

128149
/**

src/test/core/extractors.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -706,6 +706,23 @@ router = f.APIRouter(prefix="/items")
706706
assert.strictEqual(result.relativeDots, 1)
707707
})
708708

709+
test("extracts bare relative import (from . import X)", () => {
710+
const code = "from . import users"
711+
const tree = parse(code)
712+
const nodesByType = getNodesByType(tree.rootNode)
713+
const imports = nodesByType.get("import_from_statement") ?? []
714+
const result = importExtractor(imports[0])
715+
716+
assert.ok(result)
717+
assert.strictEqual(result.modulePath, "")
718+
assert.strictEqual(result.isRelative, true)
719+
assert.strictEqual(result.relativeDots, 1)
720+
assert.deepStrictEqual(result.names, ["users"])
721+
assert.deepStrictEqual(result.namedImports, [
722+
{ name: "users", alias: null },
723+
])
724+
})
725+
709726
test("extracts relative import with double dot", () => {
710727
const code = "from ..api import router"
711728
const tree = parse(code)

src/test/core/importResolver.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,22 @@ suite("importResolver", () => {
9999
assert.ok(result.endsWith("users.py"))
100100
})
101101

102+
test("falls back to src/ for absolute imports in src layout", async () => {
103+
// Project root is the pyproject.toml dir, but source is under src/
104+
const currentFile = fixtures.srcLayout.mainPy
105+
const projectRoot = fixtures.srcLayout.workspaceRoot
106+
107+
const result = await resolveImport(
108+
{ modulePath: "app.api", isRelative: false, relativeDots: 0 },
109+
currentFile,
110+
projectRoot,
111+
nodeFileSystem,
112+
)
113+
114+
assert.ok(result)
115+
assert.ok(result.endsWith("api/__init__.py"))
116+
})
117+
102118
test("returns null for non-existent module", async () => {
103119
const currentFile = nodeFileSystem.joinPath(standardRoot, "main.py")
104120
const projectRoot = standardRoot
@@ -242,6 +258,28 @@ suite("importResolver", () => {
242258
assert.ok(result.endsWith("api_routes.py"))
243259
})
244260

261+
test("resolves named import via src/ fallback for src layout", async () => {
262+
const currentFile = fixtures.srcLayout.mainPy
263+
const projectRoot = fixtures.srcLayout.workspaceRoot
264+
265+
// "from app.api import api_router" — the actual import from the issue
266+
const result = await resolveNamedImport(
267+
{
268+
modulePath: "app.api",
269+
names: ["api_router"],
270+
isRelative: false,
271+
relativeDots: 0,
272+
},
273+
currentFile,
274+
projectRoot,
275+
nodeFileSystem,
276+
(uri) => analyzeFile(uri, parser, nodeFileSystem),
277+
)
278+
279+
assert.ok(result)
280+
assert.ok(result.endsWith("api/__init__.py"))
281+
})
282+
245283
test("resolves variable import from .py file (not submodule)", async () => {
246284
// This tests "from .neon import router" where router is a variable in neon.py,
247285
// NOT a submodule. Should return neon.py, not look for router.py

src/test/core/routerResolver.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -604,6 +604,47 @@ suite("routerResolver", () => {
604604
assert.strictEqual(result, null)
605605
})
606606

607+
test("resolves absolute imports in src layout projects", async () => {
608+
const projectRoot = await findProjectRoot(
609+
fixtures.srcLayout.mainPy,
610+
fixtures.srcLayout.workspaceRoot,
611+
nodeFileSystem,
612+
)
613+
const result = await buildRouterGraph(
614+
fixtures.srcLayout.mainPy,
615+
parser,
616+
projectRoot,
617+
nodeFileSystem,
618+
)
619+
620+
assert.ok(result)
621+
assert.strictEqual(result.type, "FastAPI")
622+
assert.strictEqual(result.routes.length, 1, "Should have the root route")
623+
assert.strictEqual(result.routes[0].path, "/")
624+
assert.strictEqual(
625+
result.children.length,
626+
1,
627+
"Should have one child router (api_router)",
628+
)
629+
630+
const apiRouter = result.children[0].router
631+
assert.strictEqual(apiRouter.type, "APIRouter")
632+
assert.strictEqual(
633+
apiRouter.children.length,
634+
1,
635+
"api_router should include users router",
636+
)
637+
assert.strictEqual(apiRouter.children[0].prefix, "/api/v1/users")
638+
639+
const usersRouter = apiRouter.children[0].router
640+
assert.strictEqual(usersRouter.routes.length, 2)
641+
const methods = usersRouter.routes
642+
.map((r) => r.method.toLowerCase())
643+
.sort()
644+
assert.deepStrictEqual(methods, ["get", "post"])
645+
assert.ok(usersRouter.routes.every((r) => r.path === "/"))
646+
})
647+
607648
test("resolves custom APIRouter subclass as child router", async () => {
608649
const result = await buildRouterGraph(
609650
fixtures.customSubclass.mainPy,
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
[project]
2+
name = "src-layout-app"
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
from fastapi import APIRouter
2+
from . import users
3+
4+
api_router = APIRouter()
5+
6+
api_router.include_router(users.router, prefix="/api/v1/users", tags=["users"])
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()
4+
5+
6+
@router.get("/")
7+
async def list_users():
8+
return []
9+
10+
11+
@router.post("/")
12+
async def create_user():
13+
return {}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
from fastapi import FastAPI
2+
3+
from app.api import api_router
4+
5+
app = FastAPI()
6+
7+
8+
@app.get("/")
9+
async def root():
10+
return {"message": "Hello"}
11+
12+
13+
app.include_router(api_router)

src/test/testUtils.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,10 @@ export const fixtures = {
9393
join(fixturesPath, "reexport", "app", "integrations", "__init__.py"),
9494
),
9595
},
96+
srcLayout: {
97+
workspaceRoot: uri(join(fixturesPath, "src-layout")),
98+
mainPy: uri(join(fixturesPath, "src-layout", "src", "app", "main.py")),
99+
},
96100
sameFile: {
97101
root: uri(join(fixturesPath, "same-file")),
98102
mainPy: uri(join(fixturesPath, "same-file", "main.py")),

0 commit comments

Comments
 (0)