дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-08 19:02:17 +02:00
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.
Цей коміт міститься в:
1 джерело
fd69664ca7
коміт
70a7c6dd2c
5 files changed
+104
-1
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Заблокувати користувача