diff --git a/backend.go b/backend.go index 903aeab..3461993 100644 --- a/backend.go +++ b/backend.go @@ -68,6 +68,65 @@ type Backend interface { PIDAttachArgs(pid int) (map[string]any, error) } +// ResolveVenvPython returns the active virtualenv's python binary if $VIRTUAL_ENV +// is set and contains one, otherwise "". Callers pass this into DebugArgs.Python +// so the daemon (which may have a stale env) uses the correct interpreter. +// Returned path is absolute. +func ResolveVenvPython() string { + venv := os.Getenv("VIRTUAL_ENV") + if venv == "" { + return "" + } + // Windows venvs put the interpreter under Scripts\; POSIX venvs use bin/. + var candidates []string + if runtime.GOOS == "windows" { + candidates = []string{ + filepath.Join(venv, "Scripts", "python.exe"), + filepath.Join(venv, "Scripts", "python3.exe"), + } + } else { + candidates = []string{ + filepath.Join(venv, "bin", "python3"), + filepath.Join(venv, "bin", "python"), + } + } + for _, p := range candidates { + if _, err := os.Stat(p); err == nil { + if abs, err := filepath.Abs(p); err == nil { + return abs + } + return p + } + } + return "" +} + +// ResolvePythonFlag turns a user-supplied --python value into an absolute path +// the daemon can exec directly. Bare names go through exec.LookPath (using the +// caller's PATH, not the daemon's); paths are made absolute. Returns an error +// if the binary cannot be found or doesn't exist. +func ResolvePythonFlag(python string) (string, error) { + if strings.ContainsRune(python, filepath.Separator) || (runtime.GOOS == "windows" && strings.ContainsRune(python, '/')) { + abs, err := filepath.Abs(python) + if err != nil { + return "", fmt.Errorf("resolving --python path: %w", err) + } + if _, err := os.Stat(abs); err != nil { + return "", fmt.Errorf("--python %q: %w", python, err) + } + return abs, nil + } + found, err := exec.LookPath(python) + if err != nil { + return "", fmt.Errorf("--python %q not found on PATH: %w", python, err) + } + abs, err := filepath.Abs(found) + if err != nil { + return found, nil + } + return abs, nil +} + // DetectBackend returns the appropriate backend based on file extension. func DetectBackend(script string) Backend { switch strings.ToLower(filepath.Ext(script)) { @@ -102,12 +161,19 @@ func GetBackendByName(name string) (Backend, error) { // --- debugpy backend (Python) --- -type debugpyBackend struct{} +// debugpyBackend spawns debugpy with python. If python is empty, "python3" from PATH is used. +type debugpyBackend struct { + python string +} func (b *debugpyBackend) Spawn(port string) (*exec.Cmd, string, error) { _, actualPort := normalizePort(port) - cmd := exec.Command("python3", "-m", "debugpy.adapter", "--host", "127.0.0.1", "--port", actualPort, "--log-stderr") + python := b.python + if python == "" { + python = "python3" + } + cmd := exec.Command(python, "-m", "debugpy.adapter", "--host", "127.0.0.1", "--port", actualPort, "--log-stderr") cmd.Stdout = nil stderrPipe, err := cmd.StderrPipe() diff --git a/backend_test.go b/backend_test.go new file mode 100644 index 0000000..d78d5d7 --- /dev/null +++ b/backend_test.go @@ -0,0 +1,186 @@ +package dap + +import ( + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func TestResolveVenvPython_NoEnv(t *testing.T) { + t.Setenv("VIRTUAL_ENV", "") + if got := ResolveVenvPython(); got != "" { + t.Errorf("ResolveVenvPython() = %q, want empty", got) + } +} + +func TestResolveVenvPython_MissingBinary(t *testing.T) { + dir := t.TempDir() + t.Setenv("VIRTUAL_ENV", dir) + if got := ResolveVenvPython(); got != "" { + t.Errorf("ResolveVenvPython() = %q, want empty (no python in venv)", got) + } +} + +// venvLayout returns the (binSubdir, preferredName, fallbackName) for the current +// OS's virtualenv layout. On Windows that's Scripts\python.exe etc. +func venvLayout() (string, string, string) { + if runtime.GOOS == "windows" { + return "Scripts", "python.exe", "python3.exe" + } + return "bin", "python3", "python" +} + +func TestResolveVenvPython_FindsPreferred(t *testing.T) { + dir := t.TempDir() + sub, preferred, _ := venvLayout() + binDir := filepath.Join(dir, sub) + if err := os.MkdirAll(binDir, 0o755); err != nil { + t.Fatal(err) + } + target := filepath.Join(binDir, preferred) + if err := os.WriteFile(target, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("VIRTUAL_ENV", dir) + got := ResolveVenvPython() + wantAbs, _ := filepath.Abs(target) + if got != wantAbs { + t.Errorf("ResolveVenvPython() = %q, want %q", got, wantAbs) + } +} + +func TestResolveVenvPython_FallsBackToSecondary(t *testing.T) { + dir := t.TempDir() + sub, _, fallback := venvLayout() + binDir := filepath.Join(dir, sub) + if err := os.MkdirAll(binDir, 0o755); err != nil { + t.Fatal(err) + } + target := filepath.Join(binDir, fallback) + if err := os.WriteFile(target, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("VIRTUAL_ENV", dir) + got := ResolveVenvPython() + wantAbs, _ := filepath.Abs(target) + if got != wantAbs { + t.Errorf("ResolveVenvPython() = %q, want %q", got, wantAbs) + } +} + +func TestResolveVenvPython_PrefersPreferredOverFallback(t *testing.T) { + dir := t.TempDir() + sub, preferred, fallback := venvLayout() + binDir := filepath.Join(dir, sub) + if err := os.MkdirAll(binDir, 0o755); err != nil { + t.Fatal(err) + } + preferredPath := filepath.Join(binDir, preferred) + fallbackPath := filepath.Join(binDir, fallback) + for _, p := range []string{preferredPath, fallbackPath} { + if err := os.WriteFile(p, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + } + t.Setenv("VIRTUAL_ENV", dir) + got := ResolveVenvPython() + wantAbs, _ := filepath.Abs(preferredPath) + if got != wantAbs { + t.Errorf("ResolveVenvPython() = %q, want preferred %q", got, wantAbs) + } +} + +func TestResolvePythonFlag_AbsolutePath(t *testing.T) { + dir := t.TempDir() + py := filepath.Join(dir, "python3") + if err := os.WriteFile(py, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + got, err := ResolvePythonFlag(py) + if err != nil { + t.Fatalf("ResolvePythonFlag(%q): %v", py, err) + } + if got != py { + t.Errorf("got %q, want %q", got, py) + } +} + +func TestResolvePythonFlag_RelativePath(t *testing.T) { + dir := t.TempDir() + py := filepath.Join(dir, "python3") + if err := os.WriteFile(py, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + // chdir to tmp, pass relative path + wd, _ := os.Getwd() + t.Cleanup(func() { _ = os.Chdir(wd) }) + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + got, err := ResolvePythonFlag("./python3") + if err != nil { + t.Fatalf("ResolvePythonFlag: %v", err) + } + if !filepath.IsAbs(got) { + t.Errorf("expected absolute path, got %q", got) + } +} + +func TestResolvePythonFlag_NonexistentPath(t *testing.T) { + _, err := ResolvePythonFlag("/definitely/not/a/real/python-xyz") + if err == nil { + t.Fatal("expected error for nonexistent path") + } +} + +func TestResolvePythonFlag_BareNameViaLookPath(t *testing.T) { + // Use a name we know is on PATH — "sh" works on all Unix + a lot of Windows dev envs. + if _, err := exec.LookPath("sh"); err != nil { + t.Skip("sh not on PATH") + } + got, err := ResolvePythonFlag("sh") + if err != nil { + t.Fatalf("ResolvePythonFlag(sh): %v", err) + } + if !filepath.IsAbs(got) { + t.Errorf("expected absolute path from LookPath, got %q", got) + } +} + +func TestResolvePythonFlag_BareNameNotFound(t *testing.T) { + _, err := ResolvePythonFlag("definitely-not-a-real-binary-xyz-123") + if err == nil { + t.Fatal("expected error for unknown bare name") + } +} + +// TestDebugpyBackend_SpawnUsesConfiguredPython verifies that debugpyBackend.Python, +// when set, is the binary invoked by Spawn (rather than falling back to "python3" +// on PATH). We pass a stub binary that exits immediately; Spawn should attempt to +// run it, and since it is not a real debugpy, we expect Spawn to fail *after* the +// stub runs. Failure mode diagnoses whether the configured binary was actually +// used. +func TestDebugpyBackend_SpawnUsesConfiguredPython(t *testing.T) { + dir := t.TempDir() + stub := filepath.Join(dir, "my-python") + // Stub exits cleanly without printing "Listening" so waitForReady fails — + // confirming the configured binary was executed (not some other python). + script := "#!/bin/sh\nexit 0\n" + if err := os.WriteFile(stub, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + + b := &debugpyBackend{python: stub} + _, _, err := b.Spawn(":0") + if err == nil { + t.Fatal("Spawn with stub python should fail (no Listening output)") + } + // The error should be about process exiting without reporting listen address, + // proving the configured python was the one that ran. + if want := "process exited without reporting listen address"; !strings.Contains(err.Error(), want) { + t.Errorf("Spawn error = %q, want to contain %q", err.Error(), want) + } +} diff --git a/cli.go b/cli.go index bdc5f1a..9f1a15f 100644 --- a/cli.go +++ b/cli.go @@ -180,6 +180,7 @@ func newDebugCmd() *cobra.Command { backend string stopOnEntry bool exceptionFilters []string + python string ) cmd := &cobra.Command{ @@ -215,6 +216,16 @@ Blocks until the program hits a breakpoint or exits, then returns auto-context.` return err } + var resolvedPython string + if python != "" { + p, err := ResolvePythonFlag(python) + if err != nil { + return err + } + resolvedPython = p + } else { + resolvedPython = ResolveVenvPython() + } debugArgs := DebugArgs{ Breaks: []Breakpoint(breaks), StopOnEntry: stopOnEntry, @@ -223,6 +234,7 @@ Blocks until the program hits a breakpoint or exits, then returns auto-context.` Backend: backend, ExceptionFilters: exceptionFilters, ContextLines: globalFlags.contextLines, + Python: resolvedPython, } if len(args) > 0 { debugArgs.Script = args[0] @@ -254,6 +266,7 @@ Blocks until the program hits a breakpoint or exits, then returns auto-context.` cmd.Flags().IntVar(&pid, "pid", 0, "Attach to a running process by PID (requires --backend)") cmd.Flags().StringVar(&backend, "backend", "", "Debugger backend (debugpy, dlv, js-debug, lldb-dap); auto-detected from file extension") cmd.Flags().BoolVar(&stopOnEntry, "stop-on-entry", false, "Stop at first line") + cmd.Flags().StringVar(&python, "python", "", "Python interpreter for debugpy backend (default: $VIRTUAL_ENV/bin/python, else python3 on PATH)") cmd.Flags().StringArrayVar(&exceptionFilters, "break-on-exception", nil, "Stop on exception; repeatable (e.g. --break-on-exception raised).\n"+ "Filter IDs are backend-specific:\n"+ diff --git a/daemon.go b/daemon.go index d743b99..baddbe9 100644 --- a/daemon.go +++ b/daemon.go @@ -260,8 +260,15 @@ func (d *Daemon) readExpected() (godap.Message, error) { // └─ default ──► DROP (ProcessEvent, ThreadEvent, ModuleEvent, etc.) func (d *Daemon) readLoop() { defer close(d.expectCh) + // Pin the client for this loop's lifetime: stopSession() sets d.client = nil, + // which would race with d.client.ReadMessage() here and panic. The client's + // Close() will unblock ReadMessage() with an error and the loop returns. + client := d.getClient() + if client == nil { + return + } for { - msg, err := d.client.ReadMessage() + msg, err := client.ReadMessage() if err != nil { return } @@ -529,6 +536,9 @@ func (d *Daemon) handleDebug(rawArgs json.RawMessage) *Response { } else { return errResponse("script path, --attach, or --pid required") } + if b, ok := backend.(*debugpyBackend); ok && args.Python != "" { + b.python = args.Python + } d.backend = backend d.stopSession() // clean up any previous session diff --git a/e2e_test.go b/e2e_test.go index 8bd9feb..181b3d1 100644 --- a/e2e_test.go +++ b/e2e_test.go @@ -100,6 +100,15 @@ func (e *e2eEnv) run(args ...string) (string, error) { return string(out), err } +// runEnv is like run but with extra environment variables merged into os.Environ(). +func (e *e2eEnv) runEnv(extraEnv []string, args ...string) (string, error) { + cmd := exec.Command(e.binary, append(args, "--socket", e.socketPath)...) + cmd.Dir = projectRoot(e.t) + cmd.Env = append(os.Environ(), extraEnv...) + out, err := cmd.CombinedOutput() + return string(out), err +} + // --- Python tests --- // TestE2E_DebugPython runs a full debug session: debug → step → eval → continue → stop. @@ -197,6 +206,75 @@ func TestE2E_JSONOutput(t *testing.T) { } } +// TestE2E_DebugPython_ExplicitPython verifies --python uses the specified interpreter. +// Uses `exec.LookPath("python3")` to get a real interpreter, then confirms the flag is +// accepted end-to-end (daemon → debugpyBackend → spawn). +func TestE2E_DebugPython_ExplicitPython(t *testing.T) { + py, err := exec.LookPath("python3") + if err != nil { + t.Skip("python3 not on PATH") + } + if err := exec.Command(py, "-c", "import debugpy").Run(); err != nil { + t.Skip("debugpy not installed") + } + + env := newE2EEnv(t) + scriptPath := filepath.Join(projectRoot(t), "testdata", "python", "simple.py") + + out, err := env.run("debug", scriptPath, "--break", scriptPath+":3", "--python", py) + if err != nil { + t.Fatalf("debug --python failed: %v\n%s", err, out) + } + if !strings.Contains(out, "Stopped: breakpoint") { + t.Errorf("expected breakpoint stop, got:\n%s", out) + } + _, _ = env.run("stop") +} + +// TestE2E_DebugPython_BogusPythonFails verifies --python with a non-existent binary +// produces a clear error (instead of silently falling back to system python3). +func TestE2E_DebugPython_BogusPythonFails(t *testing.T) { + env := newE2EEnv(t) + scriptPath := filepath.Join(projectRoot(t), "testdata", "python", "simple.py") + + bogus := filepath.Join(t.TempDir(), "nope-python") + out, err := env.run("debug", scriptPath, "--python", bogus) + if err == nil { + t.Fatalf("expected failure with bogus --python, got success:\n%s", out) + } +} + +// TestE2E_DebugPython_VenvAutoDetect verifies that $VIRTUAL_ENV is picked up +// automatically: a venv dir with a stub "python3" that is NOT debugpy-capable +// should be used (and fail visibly), proving the venv's interpreter was chosen +// over the system one. +func TestE2E_DebugPython_VenvAutoDetect(t *testing.T) { + if err := exec.Command("python3", "-c", "import debugpy").Run(); err != nil { + t.Skip("debugpy not installed on system python3") + } + + env := newE2EEnv(t) + scriptPath := filepath.Join(projectRoot(t), "testdata", "python", "simple.py") + + // Build a fake venv whose python3 exits immediately (no debugpy). + venv := t.TempDir() + binDir := filepath.Join(venv, "bin") + if err := os.MkdirAll(binDir, 0o755); err != nil { + t.Fatal(err) + } + stub := filepath.Join(binDir, "python3") + if err := os.WriteFile(stub, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + t.Fatal(err) + } + + // With VIRTUAL_ENV pointing at the fake venv, dap should use the stub + // (and fail to start the adapter) rather than falling back to system python3. + out, err := env.runEnv([]string{"VIRTUAL_ENV=" + venv}, "debug", scriptPath, "--break", scriptPath+":3") + if err == nil { + t.Fatalf("expected failure using stub venv python, got success — venv was not picked up:\n%s", out) + } +} + // TestE2E_DebugPython_Scheduler exercises cross-file breakpoints across a // multifile Python app: main.py → runner.py → resolver.py. func TestE2E_DebugPython_Scheduler(t *testing.T) { diff --git a/protocol.go b/protocol.go index 3981cd3..bc6dee4 100644 --- a/protocol.go +++ b/protocol.go @@ -129,6 +129,7 @@ type DebugArgs struct { ProgramArgs []string `json:"program_args,omitempty"` ExceptionFilters []string `json:"exception_filters,omitempty"` // backend-specific filter IDs ContextLines int `json:"context_lines,omitempty"` + Python string `json:"python,omitempty"` // debugpy only: interpreter path; resolved from $VIRTUAL_ENV if empty } // StepArgs are arguments for the "step" command. diff --git a/skills/debugging-code/references/installing-debuggers.md b/skills/debugging-code/references/installing-debuggers.md index 4823635..a616efa 100644 --- a/skills/debugging-code/references/installing-debuggers.md +++ b/skills/debugging-code/references/installing-debuggers.md @@ -10,6 +10,12 @@ **Install:** `pip install debugpy` +**Virtualenv:** `dap` picks up the active venv automatically via `$VIRTUAL_ENV`. +Activate the venv (`source .venv/bin/activate`) before running `dap debug`, or pass +`--python /path/to/venv/bin/python` to override. Without either, `dap` falls back +to `python3` on PATH — which may not have `debugpy` installed or may be the wrong +interpreter version. + --- ## Go — Delve