diff --git a/src/features/run/configs.test.ts b/src/features/run/configs.test.ts index d27eb265..1a02731b 100644 --- a/src/features/run/configs.test.ts +++ b/src/features/run/configs.test.ts @@ -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", diff --git a/src/features/run/context.ts b/src/features/run/context.ts index 69e2050e..fe8c40b9 100644 --- a/src/features/run/context.ts +++ b/src/features/run/context.ts @@ -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 .` 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; }