diff --git a/src/features/run/contextRun.ts b/src/features/run/contextRun.ts index be9e39187..c67144c11 100644 --- a/src/features/run/contextRun.ts +++ b/src/features/run/contextRun.ts @@ -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. diff --git a/src/features/run/index.ts b/src/features/run/index.ts index e32a1eec6..a663c368c 100644 --- a/src/features/run/index.ts +++ b/src/features/run/index.ts @@ -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) => { diff --git a/src/features/run/producers.test.ts b/src/features/run/producers.test.ts index 00299b4ce..6a800050c 100644 --- a/src/features/run/producers.test.ts +++ b/src/features/run/producers.test.ts @@ -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", diff --git a/src/features/run/producers.ts b/src/features/run/producers.ts index c0cb58d2e..461a39d9b 100644 --- a/src/features/run/producers.ts +++ b/src/features/run/producers.ts @@ -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", diff --git a/src/features/run/pythonPackages.ts b/src/features/run/pythonPackages.ts new file mode 100644 index 000000000..819e94a35 --- /dev/null +++ b/src/features/run/pythonPackages.ts @@ -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(); + +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 { + 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; + } +}