Skip to content

Commit 76f0d56

Browse files
authored
Add support for pydevd debugging of modules (#75)
1 parent c398cd9 commit 76f0d56

File tree

2 files changed

+154
-9
lines changed

2 files changed

+154
-9
lines changed

python/helper-image/launcher/launcher.go

Lines changed: 70 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -45,13 +45,33 @@ limitations under the License.
4545
// This launcher will determine the python executable based on the `original-command-line`.
4646
// The launcher will configure the PYTHONPATH to point to the appropriate installation
4747
// of pydevd/debugpy/ptvsd for the corresponding python binary.
48+
//
49+
// debugpy and ptvsd are pretty straightforward translations of the
50+
// launcher command-line `python -m debugpy`.
51+
//
52+
// pydevd is more involved as pydevd does not support loading modules
53+
// from the command-line (e.g., `python -m flask`). This launcher
54+
// instead creates a small module-loader script using runpy.
55+
// So `launcher --mode pydevd --port 5678 -- python -m flask app.py`
56+
// will create a temp file named `skaffold_pydevd_launch.py`:
57+
// ```
58+
// import sys
59+
// import runpy
60+
// runpy.run_module('flask', run_name="__main__",alter_sys=True)
61+
// ```
62+
// and will then invoke:
63+
// ```
64+
// python -m pydevd --server --port 5678 --DEBUG --continue \
65+
// --file /tmp/pydevd716531212/skaffold_pydevd_launch.py
66+
// ```
4867
package main
4968

5069
import (
5170
"context"
5271
"flag"
5372
"fmt"
5473
"io"
74+
"io/ioutil"
5575
"os"
5676
"os/exec"
5777
"path/filepath"
@@ -306,24 +326,24 @@ func (pc *pythonContext) updateCommandLine(ctx context.Context) error {
306326

307327
case ModePydevd, ModePydevdPycharm:
308328
// Appropriate location to resolve pydevd is set in updateEnv
309-
// TODO: check for modules (and fail?)
310329
cmdline = append(cmdline, pc.args[0])
311330
cmdline = append(cmdline, "-m", "pydevd", "--server", "--port", strconv.Itoa(int(pc.port)))
312331
if pc.env["WRAPPER_VERBOSE"] != "" {
313332
cmdline = append(cmdline, "--DEBUG")
314333
}
315-
if pc.debugMode == ModePydevdPycharm {
316-
// From the pydevd source, PyCharm wants multiproc
317-
cmdline = append(cmdline, "--multiproc")
318-
}
319334
if !pc.wait {
320335
cmdline = append(cmdline, "--continue")
321336
}
322-
cmdline = append(cmdline, "--file") // --file is expected as last argument
323-
cmdline = append(cmdline, pc.args[1:]...)
324-
if pc.wait {
325-
logrus.Warn("pydevd does not support wait-for-client")
337+
338+
// --file is expected as last pydev argument, but it must be a file, and so launching with
339+
// a module requires some special handling.
340+
cmdline = append(cmdline, "--file")
341+
file, args, err := handlePydevModule(pc.args[1:])
342+
if err != nil {
343+
return err
326344
}
345+
cmdline = append(cmdline, file)
346+
cmdline = append(cmdline, args...)
327347
pc.args = cmdline
328348
}
329349
return nil
@@ -356,6 +376,47 @@ func determinePythonMajorMinor(ctx context.Context, launcherBin string, env env)
356376
return
357377
}
358378

379+
// handlePydevModule applies special pydevd handling for a python module. When a module is
380+
// found, we write out a python script that uses runpy to invoke the module.
381+
func handlePydevModule(args []string) (string, []string, error) {
382+
switch {
383+
case len(args) == 0:
384+
return "", nil, fmt.Errorf("no python command-line specified") // shouldn't happen
385+
case !strings.HasPrefix(args[0], "-"):
386+
// this is a file
387+
return args[0], args[1:], nil
388+
case !strings.HasPrefix(args[0], "-m"):
389+
// this is some other command-line flag
390+
return "", nil, fmt.Errorf("expected python module: %q", args)
391+
}
392+
module := args[0][2:]
393+
remaining := args[1:]
394+
if module == "" {
395+
if len(args) == 1 {
396+
return "", nil, fmt.Errorf("missing python module: %q", args)
397+
}
398+
module = args[1]
399+
remaining = args[2:]
400+
}
401+
402+
snippet := strings.ReplaceAll(`import sys
403+
import runpy
404+
runpy.run_module('{module}', run_name="__main__",alter_sys=True)
405+
`, `{module}`, module)
406+
407+
// write out the temp location as other locations may not be writable
408+
d, err := ioutil.TempDir("", "pydevd*")
409+
if err != nil {
410+
return "", nil, err
411+
}
412+
// use a skaffold-specific file name to ensure no possibility of it matching a user import
413+
f := filepath.Join(d, "skaffold_pydevd_launch.py")
414+
if err := ioutil.WriteFile(f, []byte(snippet), 0755); err != nil {
415+
return "", nil, err
416+
}
417+
return f, remaining, nil
418+
}
419+
359420
func isEnabled(env env) bool {
360421
v, found := env["WRAPPER_ENABLED"]
361422
return !found || (v != "0" && v != "false" && v != "no")

python/helper-image/launcher/launcher_test.go

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,12 @@ import (
2020
"context"
2121
"fmt"
2222
"io/ioutil"
23+
"os"
2324
"path/filepath"
2425
"testing"
2526

2627
"github.com/google/go-cmp/cmp"
28+
"github.com/google/go-cmp/cmp/cmpopts"
2729
)
2830

2931
func TestValidateDebugMode(t *testing.T) {
@@ -312,3 +314,85 @@ func TestPathExists(t *testing.T) {
312314
t.Error("pathExists failed on real path")
313315
}
314316
}
317+
318+
func TestHandlePydevModule(t *testing.T) {
319+
tmp := os.TempDir()
320+
321+
tests := []struct {
322+
description string
323+
args []string
324+
shouldErr bool
325+
module string
326+
file string
327+
remaining []string
328+
}{
329+
{
330+
description: "plain file",
331+
args: []string{"app.py"},
332+
file: "app.py",
333+
},
334+
{
335+
description: "-mmodule",
336+
args: []string{"-mmodule"},
337+
file: filepath.Join(tmp, "*", "skaffold_pydevd_launch.py"),
338+
},
339+
{
340+
description: "-m module",
341+
args: []string{"-m", "module"},
342+
file: filepath.Join(tmp, "*", "skaffold_pydevd_launch.py"),
343+
},
344+
{
345+
description: "- should error",
346+
args: []string{"-", "module"},
347+
shouldErr: true,
348+
},
349+
{
350+
description: "-x should error",
351+
args: []string{"-x", "module"},
352+
shouldErr: true,
353+
},
354+
{
355+
description: "lone -m should error",
356+
args: []string{"-m"},
357+
shouldErr: true,
358+
},
359+
{
360+
description: "no args should error",
361+
shouldErr: true,
362+
},
363+
}
364+
for _, test := range tests {
365+
t.Run(test.description, func(t *testing.T) {
366+
file, args, err := handlePydevModule(test.args)
367+
if test.shouldErr {
368+
if err == nil {
369+
t.Error("Expected an error")
370+
}
371+
} else {
372+
if !fileMatch(t, test.file, file) {
373+
t.Errorf("Wanted %q but got %q", test.file, file)
374+
}
375+
if diff := cmp.Diff(args, test.remaining, cmpopts.EquateEmpty()); diff != "" {
376+
t.Errorf("remaining args %T differ (-got, +want): %s", test.remaining, diff)
377+
}
378+
}
379+
})
380+
}
381+
}
382+
383+
func fileMatch(t *testing.T, glob, file string) bool {
384+
if file == glob {
385+
return true
386+
}
387+
matches, err := filepath.Glob(glob)
388+
if err != nil {
389+
t.Errorf("Failed to expand globe %q: %v", glob, err)
390+
return false
391+
}
392+
for _, m := range matches {
393+
if file == m {
394+
return true
395+
}
396+
}
397+
return false
398+
}

0 commit comments

Comments
 (0)