fix(run): run Python modules with relative imports with -m (#1338)

Run or Debug on a Python file started it as a script
(`python src/tasks/cli.py`), so a module of a package that imports
relatively (`from .models import TaskList`) always failed with
"attempted relative import with no known parent package".

A file with relative imports now runs as `python -m package.module`
from the directory above its top package (the enclosing directories
with `__init__.py`, read once and re-read when one is added or
removed), with a venv-relative interpreter resolved against the project
root. Files without relative imports still run as scripts.
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-11-05 02:01:35 +08:00
1 джерело fd69664ca7
коміт 70a7c6dd2c
5 files changed
+104 -1

No files matched your search

+4 -1
Переглянути файл
@@ -29,9 +29,11 @@ import {
scriptLanguage,
type ContextInput,
type Produced,
hasRelativeImports,
} from "./producers";
import { runWithExecutor } from "./executors";
import { goModulePath, loadGoModules } from "./goModules";
import { isPythonPackage, loadPythonPackages } from "./pythonPackages";
import { remoteDebugBlocked } from "./remote";
import { toolEnv } from "./runtimes";
import { adoptTemporary, loadConfigs, modifyTemporary, startRun, useRun } from "./store";
@@ -88,7 +90,8 @@ export async function produceResolved(input: ContextInput, dir = false): Promise
const configs = useRun.getState().configs;
if (dir || /\.go$/.test(input.path)) await loadGoModules(input.root, configs);
if (dir) return produceDirectory({ ...input, goModule: goModulePath });
const fresh = { ...input, configs, goModule: goModulePath };
if (/\.py$/.test(input.path) && hasRelativeImports(input.content)) await loadPythonPackages(input.path);
const fresh = { ...input, configs, goModule: goModulePath, pythonPackage: isPythonPackage };
const named = produceContext(fresh, OPTIMISTIC);
if (!named) return null;
// Project configurations (tests, cargo, gradle…) need no runtime lookup.
+2
Переглянути файл
@@ -38,6 +38,7 @@ import { EditConfigsOverlay } from "./EditConfigsDialog";
import { openImport } from "./importConfigs";
import { registerGoActions } from "./goActions";
import { invalidateGoModules } from "./goModules";
import { invalidatePythonPackages } from "./pythonPackages";
import { RunWidget } from "./RunWidget";
import { runConfigsRunAnything } from "./runAnything";
import { cleanupRemovedProject, recordRun, runServiceProvider } from "./services";
@@ -574,6 +575,7 @@ export function activate(): () => void {
onIdeEvent("fsChanged", (e) => {
const paths = [...e.created, ...e.modified, ...e.removed];
if (paths.some((p) => /(?:^|\/)go\.(?:mod|work)$/.test(p))) invalidateGoModules();
if (paths.some((p) => p.endsWith("/__init__.py"))) invalidatePythonPackages();
if (paths.some((p) => CONFIG_FILES.test(p))) scheduleReload();
}),
onIdeEvent("runExited", (e) => {
+25
Переглянути файл
@@ -162,6 +162,31 @@ describe("single-file producers (scratches)", () => {
expect(produceContext(scratch("hi.py"), env({}))!.missing!.title).toBe("Cannot run 'hi.py'");
});
it("Python: a module with relative imports runs with -m from above its top package", () => {
const venv = [cfg({ id: "pytest", kind: "pytest", command: ".venv/bin/pytest" })];
const packages = new Set([`${ROOT}/src/tasks`, `${ROOT}/src/tasks/sub`]);
const at = (path: string, content: string) =>
run({ path, root: ROOT, configs: venv, content, pythonPackage: (d) => packages.has(d) });
const relative = "from .models import TaskList\n";
expect(at(`${ROOT}/src/tasks/cli.py`, relative)).toMatchObject({
command: `${ROOT}/.venv/bin/python`,
args: ["-m", "tasks.cli"],
cwd: "src",
});
expect(at(`${ROOT}/src/tasks/sub/__main__.py`, "from ..models import X\n")).toMatchObject({
args: ["-m", "tasks.sub"],
cwd: "src",
});
// A directory without `__init__.py` is still the module's package; a top-level one runs from the root.
expect(at(`${ROOT}/scripts/gen.py`, relative)).toMatchObject({ args: ["-m", "scripts.gen"], cwd: null });
// Without relative imports, the file runs as a script.
expect(at(`${ROOT}/src/tasks/cli.py`, "import argparse\n")).toMatchObject({
command: ".venv/bin/python",
args: ["src/tasks/cli.py"],
cwd: null,
});
});
it("shell scripts: the shell of the #! line, else by extension; extensionless files by #!", () => {
expect(run(scratch("a.sh", "#!/usr/bin/env bash\necho hi"))).toMatchObject({
command: "/bin/bash",
+43
Переглянути файл
@@ -55,6 +55,8 @@ export interface ContextInput {
content?: string;
/** Go: the module path of a module directory (`go.mod`), when known. */
goModule?: (dir: string) => string | null | undefined;
/** Python: whether a directory is a package (`__init__.py`), when known. */
pythonPackage?: (dir: string) => boolean | undefined;
/** 0-based caret line: the element at the caret wins over the file. */
line?: number;
/**
@@ -409,6 +411,31 @@ function fileConfig(
};
}
const PYTHON_RELATIVE_IMPORT = /^\s*from\s+\.+[\w.]*\s+import\b/m;
/** Whether Python source imports relatively (`from .models import X`): it only runs as a module. */
export function hasRelativeImports(content: string | undefined): boolean {
return !!content && PYTHON_RELATIVE_IMPORT.test(content);
}
/**
* A module with relative imports, run as `python -m package.module` from the directory above
* its top package (its own directory is a package for the import, `__init__.py` or not).
*/
function pythonModule(input: ContextInput): { name: string; dir: string } | null {
if (!hasRelativeImports(input.content)) return null;
let top = dirName(input.path);
for (let up = dirName(top); up && up !== top && input.pythonPackage?.(up); up = dirName(up)) top = up;
const dir = dirName(top);
if (!dir || dir === top) return null;
const parts = input.path
.slice(dir === "/" ? 1 : dir.length + 1)
.replace(/\.py$/, "")
.split("/");
if (parts.length > 1 && (parts.at(-1) === "__init__" || parts.at(-1) === "__main__")) parts.pop();
return { name: parts.join("."), dir };
}
/** Python: the interpreter from settings, the detected project interpreter, then `python3` on the PATH. */
function pythonCommand(input: ContextInput, env: ToolEnv, s: FileSite): { command: string; prefix: string[] } | null {
if (env.python) return { command: env.python, prefix: [] };
@@ -655,6 +682,22 @@ export function produceFile(input: ContextInput, env: ToolEnv): Produced | null
case "python": {
const py = pythonCommand(input, env, s);
if (!py) return { name: s.file, scope: "file", missing: missing("python", s.file, ["python3", "python"]) };
const module = pythonModule(input);
if (module) {
// It runs in another directory: a venv-relative interpreter is relative to the project root.
const command =
input.root && py.command.includes("/") && !py.command.startsWith("/")
? `${input.root}/${py.command}`
: py.command;
return {
name: s.file,
scope: "file",
config: {
...fileConfig(s, "python", command, [...py.prefix, "-m", module.name], "debugpy"),
cwd: module.dir === input.root ? null : relTo(module.dir, input.root),
},
};
}
return {
name: s.file,
scope: "file",
+30
Переглянути файл
@@ -0,0 +1,30 @@
/**
* Which directories are Python packages (`__init__.py`), for running a module with relative
* imports as `python -m package.module`. Read once per directory.
*/
import { call, commands, isTauri } from "@/platform/ipc";
const known = new Map<string, boolean>();
export function invalidatePythonPackages() {
known.clear();
}
/** Whether `dir` is a package, when already read. */
export function isPythonPackage(dir: string): boolean | undefined {
return known.get(dir);
}
const parent = (p: string) => (p.includes("/") ? p.slice(0, p.lastIndexOf("/")) || "/" : "");
/** Reads the packages enclosing `path`'s directory, up to the first directory that isn't one. */
export async function loadPythonPackages(path: string): Promise<void> {
if (!isTauri()) return;
for (let dir = parent(parent(path)); dir; dir = parent(dir)) {
if (!known.has(dir)) {
const kind = await call(commands.fsEntryKind(`${dir}/__init__.py`)).catch(() => null);
known.set(dir, kind === "file");
}
if (!known.get(dir) || dir === "/") return;
}
}