fix(run): run a Python module that imports relatively as a module of its package (#1369)

Run or Debug from a Python file's context started it as a script
(`python src/tasks/cli.py`). A module of a package that imports relatively
then fails with "attempted relative import with no known parent package",
and the debugger stops on that exception.

A file with a relative import (`from .x import y`, `from . import x`) now
runs as `python -m <package>.<module>` from the folder its top package sits
in: the last `src` of its path, else the Python project's folder. A
package's `__main__.py` runs as the package. The project's interpreter is
kept (a relative venv path is adjusted to the new working directory), and
the debugger launches it with debugpy's `module`. Files without relative
imports run as scripts as before.

Fixes #1337
This commit is contained in:
l0ng-ai
2026-11-05 07:30:13 +08:00
committed by GitHub
parent e17e59ce7d
commit ebe059e00f
2 changed files with 87 additions and 7 deletions
+31
View File
@@ -256,6 +256,37 @@ describe("context configuration", () => {
expect(contextConfig("docs/readme.md", configs)).toBeNull();
});
it("runs a module that imports relatively as a module of its package", () => {
const cli = "from .models import Priority, TaskList\n\n\ndef main():\n pass\n";
expect(contextConfig("src/tasks/cli.py", configs, cli)).toMatchObject({
kind: "python",
command: "../.venv/bin/python",
args: ["-m", "tasks.cli"],
cwd: "src",
debugAdapter: "debugpy",
});
expect(contextConfig("app/api/routes.py", [], "from . import models\n")).toMatchObject({
command: "python3",
args: ["-m", "app.api.routes"],
cwd: null,
});
expect(contextConfig("src/app/__main__.py", [], "from .cli import main\n")).toMatchObject({
args: ["-m", "app"],
cwd: "src",
});
// Inside a nested Python project: from its folder (the venv command stays relative to the root).
const nested = [cfg({ id: "pytest", kind: "pytest", command: "backend/.venv/bin/pytest", cwd: "backend" })];
expect(contextConfig("backend/app/jobs.py", nested, "from .db import session\n")).toMatchObject({
command: "../backend/.venv/bin/python",
args: ["-m", "app.jobs"],
cwd: "backend",
});
// Absolute imports, a top-level file, a folder that can't be a package: as a script.
expect(contextConfig("src/tasks/cli.py", configs, "import os\n")!.args).toEqual(["src/tasks/cli.py"]);
expect(contextConfig("tool.py", [], "from .x import y\n")!.args).toEqual(["tool.py"]);
expect(contextConfig("my-scripts/run.py", [], "from .x import y\n")!.args).toEqual(["my-scripts/run.py"]);
});
it("runs pytest for a test file even without a detected pytest config", () => {
expect(contextConfig("worker/test_worker.py", [])).toMatchObject({
command: "python3",
+56 -7
View File
@@ -236,7 +236,48 @@ function mainConfigFor(relPath: string, configs: RunConfig[]): RunConfig | null
return null;
}
function scriptConfigFor(relPath: string, configs: RunConfig[], windows = isWindows()): RunConfig | null {
/** `from .models import …`, `from . import x`: the file is a module of a package. */
const RELATIVE_IMPORT = /^[ \t]*from[ \t]+\.+[\w.]*[ \t]+import\b/m;
const IDENTIFIER = /^[A-Za-z_]\w*$/;
/**
* A module that imports relatively runs as `python -m <package>.<module>` from the folder its top
* package sits in (the last `src` of its path, else the Python project's folder): run as a script,
* its relative imports fail. `null` for other files.
*/
export function pythonModuleRun(
relPath: string,
content: string | undefined,
configs: RunConfig[],
): { cwd: string; module: string } | null {
if (!content || !RELATIVE_IMPORT.test(content)) return null;
const project = closest(
configs.filter((c) => c.kind === "python" || c.kind === "pytest"),
relPath,
)?.cwd?.replace(/^\.\/?|\/+$/g, "");
const segs = relPath.replace(/\.py$/, "").split("/");
const first = project && relPath.startsWith(`${project}/`) ? project.split("/").length : 0;
const src = segs.lastIndexOf("src", segs.length - 2);
const start = src >= first ? src + 1 : first;
const parts = segs.slice(start);
// Only a module inside a package can import relatively.
if (parts.length < 2 || !parts.every((p) => IDENTIFIER.test(p))) return null;
if (parts.at(-1) === "__main__" || parts.at(-1) === "__init__") parts.pop();
return { cwd: segs.slice(0, start).join("/"), module: parts.join(".") };
}
/** A command relative to the project root (`.venv/bin/python`), from `cwd` inside it. */
function fromSubfolder(command: string, cwd: string): string {
if (!cwd || !command.includes("/") || command.startsWith("/") || /^[A-Za-z]:/.test(command)) return command;
return "../".repeat(cwd.split("/").length) + command;
}
function scriptConfigFor(
relPath: string,
configs: RunConfig[],
windows = isWindows(),
content?: string,
): RunConfig | null {
const base: RunConfig = {
id: `context:file:${relPath}`,
name: relPath.split("/").pop() ?? relPath,
@@ -251,10 +292,18 @@ function scriptConfigFor(relPath: string, configs: RunConfig[], windows = isWind
};
if (relPath.endsWith(".py")) {
// Reuse the project's interpreter (venv / uv / poetry) when detected.
const py = projectPython(configs);
if (py)
return { ...base, kind: "python", command: py.command, args: [...py.prefix, relPath], debugAdapter: "debugpy" };
return { ...base, kind: "python", command: "python3", args: [relPath], debugAdapter: "debugpy" };
const py = projectPython(configs) ?? { command: "python3", prefix: [] };
const module = pythonModuleRun(relPath, content, configs);
if (module)
return {
...base,
kind: "python",
command: fromSubfolder(py.command, module.cwd),
args: [...py.prefix, "-m", module.module],
cwd: module.cwd || null,
debugAdapter: "debugpy",
};
return { ...base, kind: "python", command: py.command, args: [...py.prefix, relPath], debugAdapter: "debugpy" };
}
if (/\.[cm]?js$/.test(relPath)) return { ...base, command: "node", args: [relPath], debugAdapter: "js-debug" };
if (/\.ts$/.test(relPath)) return { ...base, command: "npx", args: ["tsx", relPath], debugAdapter: "js-debug" };
@@ -278,7 +327,7 @@ export function contextConfig(
): RunConfig | null {
const role = fileRole(relPath, content, windows, hasDjangoTests(configs));
if (role === "test") return testConfigFor(relPath, configs);
if (role === "main") return mainConfigFor(relPath, configs) ?? scriptConfigFor(relPath, configs, windows);
if (role === "script") return scriptConfigFor(relPath, configs, windows);
if (role === "main") return mainConfigFor(relPath, configs) ?? scriptConfigFor(relPath, configs, windows, content);
if (role === "script") return scriptConfigFor(relPath, configs, windows, content);
return null;
}