diff --git a/crates/ide-dap/src/lib.rs b/crates/ide-dap/src/lib.rs index 36601c40b..f2790ad54 100644 --- a/crates/ide-dap/src/lib.rs +++ b/crates/ide-dap/src/lib.rs @@ -589,11 +589,12 @@ impl DebugManager { if let Some(command) = custom_command(&spec.command) { spec.command = command; } - // debugpy: run the adapter with the debuggee's interpreter (venvs carry their own debugpy). + // debugpy: run the adapter with the debuggee's interpreter (venvs carry their own debugpy), + // or with the launcher's when the debuggee runs through a manager (`uv run python`). if config.adapter == "debugpy" { - let python = match arguments.get("python") { - Some(Value::String(p)) => Some(p.clone()), - Some(Value::Array(a)) => a.first().and_then(Value::as_str).map(str::to_owned), + let python = match (arguments.get("debugLauncherPython"), arguments.get("python")) { + (Some(Value::String(p)), _) | (_, Some(Value::String(p))) => Some(p.clone()), + (_, Some(Value::Array(a))) => a.first().and_then(Value::as_str).map(str::to_owned), _ => None, }; if let Some(python) = python { diff --git a/src/features/debugger/__tests__/launch.test.ts b/src/features/debugger/__tests__/launch.test.ts index d52afa226..baa4e8c95 100644 --- a/src/features/debugger/__tests__/launch.test.ts +++ b/src/features/debugger/__tests__/launch.test.ts @@ -79,6 +79,27 @@ describe("run config → launch config", () => { expect(extra(globalPytest).python).toBe("python3"); }); + it("debugpy: uv / poetry run the debuggee in the project's environment", () => { + const debug = (command: string, args: string[]) => + launchConfigFor(rc({ kind: "python", command, args, debugAdapter: "debugpy" }), "/p")!; + + const script = debug("uv", ["run", "python", "main.py", "-v"]); + expect(script.program).toBe("/p/main.py"); + expect(script.args).toEqual(["-v"]); + expect(extra(script)).toMatchObject({ python: ["uv", "run", "python"], debugLauncherPython: "python3" }); + expect(extra(script).module).toBeUndefined(); + + const mod = debug("poetry", ["run", "python3", "-m", "app"]); + expect(extra(mod)).toMatchObject({ module: "app", python: ["poetry", "run", "python3"] }); + + const tool = debug("uv", ["run", "pytest", "-x"]); + expect(extra(tool)).toMatchObject({ module: "pytest", python: ["uv", "run", "python"] }); + expect(tool.args).toEqual(["-x"]); + + const django = debug("pdm", ["run", "python", "manage.py", "runserver"]); + expect(extra(django)).toMatchObject({ django: true, python: ["pdm", "run", "python"] }); + }); + it("js-debug: npm scripts run through the package manager, node scripts directly", () => { const npm = launchConfigFor( rc({ kind: "npm", command: "pnpm", args: ["run", "dev"], cwd: "web", debugAdapter: "js-debug" }), diff --git a/src/features/debugger/launch.ts b/src/features/debugger/launch.ts index 86b039a6d..a7b667745 100644 --- a/src/features/debugger/launch.ts +++ b/src/features/debugger/launch.ts @@ -58,15 +58,20 @@ function cargoLaunch(rc: RunConfig, cwd: string, adapter: string): LaunchConfig }; } +const PYTHON_NAME = /^python[\d.]*(\.exe)?$/; + function pythonFor(rc: RunConfig, cwd: string): string { const cmd = rc.command; const name = cmd.split("/").pop() ?? cmd; - if (/^python[\d.]*(\.exe)?$/.test(name)) return resolveCommand(cmd, cwd); + if (PYTHON_NAME.test(name)) return resolveCommand(cmd, cwd); // A venv tool such as `.venv/bin/pytest`: use the interpreter next to it. if (cmd.includes("/")) return resolveCommand(cmd.slice(0, cmd.lastIndexOf("/") + 1) + "python", cwd); return "python3"; } +/** Project managers whose `run` executes in the project's environment (`uv run python …`). */ +const PYTHON_RUNNERS = new Set(["uv", "poetry", "pdm", "pipenv", "hatch", "rye"]); + function debugpyLaunch(rc: RunConfig, cwd: string): LaunchConfig { const out = base(rc, "debugpy", cwd); const dbg = workflowSettings().debugger; @@ -78,19 +83,29 @@ function debugpyLaunch(rc: RunConfig, cwd: string): LaunchConfig { redirectOutput: true, }; if (dbg.python.gevent) extra.gevent = true; - const name = rc.command.split("/").pop() ?? rc.command; - const isPython = /^python[\d.]*(\.exe)?$/.test(name); - if (!isPython) { + const name = (rc.command.split("/").pop() ?? rc.command).replace(/\.exe$/, ""); + let args = rc.args; + let tool: string | null = PYTHON_NAME.test(name) ? null : name; + if (PYTHON_RUNNERS.has(name) && args[0] === "run" && args[1]) { + // The debuggee runs through the manager (`uv run python …`) to get the project's + // environment; the adapter and its launcher need a plain interpreter. + const python = PYTHON_NAME.test(args[1]) ? args[1] : "python"; + extra.python = [resolveCommand(rc.command, cwd), "run", python]; + extra.debugLauncherPython = "python3"; + tool = PYTHON_NAME.test(args[1]) ? null : args[1]; + args = args.slice(2); + } + if (tool) { // A console script (`pytest`, `uvicorn`, …): run it as a module. - extra.module = name.replace(/\.exe$/, ""); - out.args = [...rc.args]; - } else if (rc.args[0] === "-m" && rc.args[1]) { - extra.module = rc.args[1]; - out.args = rc.args.slice(2); - } else if (rc.args[0]) { - out.program = join(cwd, rc.args[0]); - out.args = rc.args.slice(1); - if (rc.args[0].endsWith("manage.py")) extra.django = true; + extra.module = tool; + out.args = [...args]; + } else if (args[0] === "-m" && args[1]) { + extra.module = args[1]; + out.args = args.slice(2); + } else if (args[0]) { + out.program = join(cwd, args[0]); + out.args = args.slice(1); + if (args[0].endsWith("manage.py")) extra.django = true; } out.extraJson = JSON.stringify(extra); return out;