mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00:00
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:
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user