From d5a31e72ce7528b8e2550b5ee01f610b126ecfc2 Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Sat, 11 Apr 2026 21:44:23 +0200 Subject: [PATCH 1/4] [experiment] python From b4649e7d0ae9500cfd9b49e1f684ea3fbb8beca1 Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Sat, 11 Apr 2026 22:10:11 +0200 Subject: [PATCH 2/4] feat(builtins): add python builtin using gpython (Python 3.4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a `python` builtin command that executes Python 3.4 source code using the gpython pure-Go interpreter — no CPython installation required. Usage: python [-c CODE] [-h] [SCRIPT | -] [ARG ...] Security sandbox (enforced in builtins/internal/pyruntime/): - os.system, os.popen, all exec/spawn/fork/write/delete functions removed - open() replaced with read-only AllowedPaths-aware version; write/append modes raise PermissionError - tempfile and glob modules neutered (functions removed) - sys.exit() exit code propagated via closure variable before VM wraps error - Source and file reads bounded at 1 MiB - Context cancellation respected (goroutine + select on ctx.Done()) Co-Authored-By: Claude Sonnet 4.6 --- SHELL_FEATURES.md | 1 + analysis/symbols_builtins.go | 11 + analysis/symbols_builtins_test.go | 14 +- analysis/symbols_internal.go | 44 ++ analysis/symbols_interp_test.go | 10 +- builtins/internal/pyruntime/pyruntime.go | 707 ++++++++++++++++++ builtins/python/python.go | 194 +++++ builtins/tests/python/python_fuzz_test.go | 87 +++ builtins/tests/python/python_test.go | 339 +++++++++ go.mod | 4 + go.sum | 11 + interp/register_builtins.go | 2 + .../cmd/python/basic/arithmetic.yaml | 10 + .../scenarios/cmd/python/basic/help_flag.yaml | 9 + .../cmd/python/basic/multiline_inline.yaml | 10 + .../cmd/python/basic/print_inline.yaml | 10 + .../scenarios/cmd/python/basic/read_file.yaml | 17 + .../cmd/python/basic/run_script_file.yaml | 17 + .../cmd/python/basic/sys_exit_nonzero.yaml | 11 + .../cmd/python/basic/sys_exit_zero.yaml | 11 + .../cmd/python/basic/with_statement.yaml | 25 + .../python/errors/missing_script_file.yaml | 10 + .../cmd/python/errors/runtime_exception.yaml | 9 + .../cmd/python/errors/syntax_error.yaml | 9 + .../python/sandbox/open_append_blocked.yaml | 9 + .../sandbox/open_outside_allowed_paths.yaml | 15 + .../python/sandbox/open_write_blocked.yaml | 9 + .../cmd/python/sandbox/os_remove_blocked.yaml | 9 + .../cmd/python/sandbox/os_system_blocked.yaml | 9 + .../cmd/python/stdin/read_from_stdin.yaml | 10 + .../cmd/unknown_cmd/common_progs/python.yaml | 10 +- 31 files changed, 1636 insertions(+), 7 deletions(-) create mode 100644 builtins/internal/pyruntime/pyruntime.go create mode 100644 builtins/python/python.go create mode 100644 builtins/tests/python/python_fuzz_test.go create mode 100644 builtins/tests/python/python_test.go create mode 100644 tests/scenarios/cmd/python/basic/arithmetic.yaml create mode 100644 tests/scenarios/cmd/python/basic/help_flag.yaml create mode 100644 tests/scenarios/cmd/python/basic/multiline_inline.yaml create mode 100644 tests/scenarios/cmd/python/basic/print_inline.yaml create mode 100644 tests/scenarios/cmd/python/basic/read_file.yaml create mode 100644 tests/scenarios/cmd/python/basic/run_script_file.yaml create mode 100644 tests/scenarios/cmd/python/basic/sys_exit_nonzero.yaml create mode 100644 tests/scenarios/cmd/python/basic/sys_exit_zero.yaml create mode 100644 tests/scenarios/cmd/python/basic/with_statement.yaml create mode 100644 tests/scenarios/cmd/python/errors/missing_script_file.yaml create mode 100644 tests/scenarios/cmd/python/errors/runtime_exception.yaml create mode 100644 tests/scenarios/cmd/python/errors/syntax_error.yaml create mode 100644 tests/scenarios/cmd/python/sandbox/open_append_blocked.yaml create mode 100644 tests/scenarios/cmd/python/sandbox/open_outside_allowed_paths.yaml create mode 100644 tests/scenarios/cmd/python/sandbox/open_write_blocked.yaml create mode 100644 tests/scenarios/cmd/python/sandbox/os_remove_blocked.yaml create mode 100644 tests/scenarios/cmd/python/sandbox/os_system_blocked.yaml create mode 100644 tests/scenarios/cmd/python/stdin/read_from_stdin.yaml diff --git a/SHELL_FEATURES.md b/SHELL_FEATURES.md index 0d341544..377767ad 100644 --- a/SHELL_FEATURES.md +++ b/SHELL_FEATURES.md @@ -25,6 +25,7 @@ Blocked features are rejected before execution with exit code 2. - ✅ `ping [-c N] [-W DURATION] [-i DURATION] [-q] [-4|-6] [-h] HOST` — send ICMP echo requests to a network host and report round-trip statistics; `-f` (flood), `-b` (broadcast), `-s` (packet size), `-I` (interface), `-p` (pattern), and `-R` (record route) are blocked; count/wait/interval are clamped to safe ranges with a warning; multicast, unspecified (`0.0.0.0`/`::`), and broadcast addresses (IPv4 last-octet `.255`) are rejected — note: directed broadcasts on non-standard subnets (e.g. `.127` on a `/25`) are not blocked without subnet-mask knowledge - ✅ `ps [-e|-A] [-f] [-p PIDLIST]` — report process status; default shows current-session processes; `-e`/`-A` shows all; `-f` adds UID/PPID/STIME columns; `-p` selects by PID list - ✅ `printf FORMAT [ARGUMENT]...` — format and print data to stdout; supports `%s`, `%b`, `%c`, `%d`, `%i`, `%o`, `%u`, `%x`, `%X`, `%e`, `%E`, `%f`, `%F`, `%g`, `%G`, `%%`; format reuse for excess arguments; `%n` rejected (security risk); `-v` rejected +- ✅ `python [-c CODE] [-h] [SCRIPT | -] [ARG ...]` — execute Python 3.4 source code using the gpython pure-Go interpreter (no CPython required); `-c CODE` runs inline code; a positional `SCRIPT` argument runs a file (via AllowedPaths sandbox); `-` reads from stdin; security sandbox removes all OS exec/write/spawn functions, replaces `open()` with a read-only AllowedPaths-aware version, and blocks `tempfile`/`glob` modules; source files and stdin are capped at 1 MiB; stdlib is limited to `math`, `sys`, `os` (read-only), `time`, `binascii`; no `subprocess`, `socket`, `ctypes`, or f-strings (Python 3.4 syntax only) - ✅ `sed [-n] [-e SCRIPT] [-E|-r] [SCRIPT] [FILE]...` — stream editor for filtering and transforming text; uses RE2 regex engine; `-i`/`-f` rejected; `e`/`w`/`W`/`r`/`R` commands blocked - ✅ `strings [-a] [-n MIN] [-t o|d|x] [-o] [-f] [-s SEP] [FILE]...` — print printable character sequences in files (default min length 4); offsets via `-t`/`-o`; filename prefix via `-f`; custom separator via `-s` - ✅ `tail [-n N|-c N] [-q|-v] [-z] [FILE]...` — output the last part of files (default: last 10 lines); supports `+N` offset mode; `-f`/`--follow` is rejected diff --git a/analysis/symbols_builtins.go b/analysis/symbols_builtins.go index bd063a52..6e060273 100644 --- a/analysis/symbols_builtins.go +++ b/analysis/symbols_builtins.go @@ -177,6 +177,15 @@ var builtinPerCommandSymbols = map[string][]string{ "strings.Split", // 🟢 splits a string by separator into a slice; pure function, no I/O. "strings.TrimSpace", // 🟢 removes leading/trailing whitespace; pure function. }, + "python": { + "context.Context", // 🟢 deadline/cancellation plumbing; pure interface, no side effects. + "io.LimitReader", // 🟢 caps source-code reads at 1 MiB to prevent memory exhaustion; no I/O side effects. + "io.ReadAll", // 🟠 reads all bytes from a LimitReader-wrapped source; bounded by maxSourceBytes (1 MiB). + "io.Reader", // 🟢 interface type; no side effects. + "os.O_RDONLY", // 🟢 read-only file flag constant; cannot open files by itself. + // Note: builtins/internal/pyruntime symbols are exempt from this allowlist + // (internal packages are not checked by the builtinAllowedSymbols test). + }, "printf": { "context.Context", // 🟢 deadline/cancellation plumbing; pure interface, no side effects. "errors.As", // 🟢 error type assertion; pure function, no I/O. @@ -420,8 +429,10 @@ var builtinAllowedSymbols = []string{ "github.com/prometheus-community/pro-bing.Statistics", // 🟢 ping round-trip statistics struct; pure data type, no I/O. "golang.org/x/sys/unix.SysctlRaw", // 🟠 macOS: reads kernel socket tables (read-only, no exec, no filesystem). "io.EOF", // 🟢 sentinel error value; pure constant. + "io.LimitReader", // 🟢 wraps a Reader with a byte-count limit; prevents reading unbounded data; no I/O side effects. "io.MultiReader", // 🟢 combines multiple Readers into one sequential Reader; no I/O side effects. "io.NopCloser", // 🟢 wraps a Reader with a no-op Close; no side effects. + "io.ReadAll", // 🟠 reads all bytes from a Reader; only safe when combined with io.LimitReader to bound allocation. "io.ReadCloser", // 🟢 interface type; no side effects. "io.ReadSeeker", // 🟢 interface type combining Reader and Seeker; no side effects. "io.Reader", // 🟢 interface type; no side effects. diff --git a/analysis/symbols_builtins_test.go b/analysis/symbols_builtins_test.go index 574cf0e1..ded0aaf0 100644 --- a/analysis/symbols_builtins_test.go +++ b/analysis/symbols_builtins_test.go @@ -74,7 +74,19 @@ func internalCheckConfig() allowedSymbolsConfig { return collectSubdirGoFiles(dir, nil, nil) }, ExemptImport: func(importPath string) bool { - return importPath == "github.com/DataDog/rshell/builtins" + // builtins package: the framework types used by all internal helpers. + if importPath == "github.com/DataDog/rshell/builtins" { + return true + } + // gpython: trusted third-party Python interpreter used exclusively by + // builtins/internal/pyruntime/. All gpython symbols are exempt because + // listing every py.* symbol would be impractical and offer no real + // security benefit — the entire gpython library is a deliberate, + // code-reviewed dependency. + if strings.HasPrefix(importPath, "github.com/go-python/gpython/") { + return true + } + return false }, ListName: "internalAllowedSymbols", MinFiles: 1, diff --git a/analysis/symbols_internal.go b/analysis/symbols_internal.go index 0b73ca0a..b3bf4221 100644 --- a/analysis/symbols_internal.go +++ b/analysis/symbols_internal.go @@ -9,6 +9,30 @@ package analysis // symbols it is allowed to use. Every symbol listed here must also appear in // internalAllowedSymbols (which acts as the global ceiling). var internalPerPackageSymbols = map[string][]string{ + "pyruntime": { + "bufio.NewReader", // 🟢 wraps an io.Reader with buffering for readline support; no write capability. + "bufio.NewScanner", // 🟢 creates a line scanner on a file for readline(); no write capability. + "bufio.Reader", // 🟢 type reference for buffered reader; no write capability. + "bufio.Scanner", // 🟢 type reference for line-by-line scanner on goFile; no write capability. + "bytes.SplitAfter", // 🟢 splits byte slice after delimiter; pure function, no I/O. + "context.Background", // 🟢 returns the background context used for Open calls within Python open(); no side effects. + "context.Context", // 🟢 deadline/cancellation interface; no side effects. + "errors.Is", // 🟢 checks whether an error in a chain matches a target; pure function, no I/O. + "fmt.Errorf", // 🟢 error formatting; pure function, no I/O. + "fmt.Fprintf", // 🟢 formats and writes to a writer; used only for error output to stderr. + "fmt.Sprintf", // 🟢 string formatting; pure function, no I/O. + "io.EOF", // 🟢 sentinel error value for end-of-file; read-only constant, no I/O. + "io.LimitReader", // 🟢 wraps a reader with a byte cap to prevent memory exhaustion; pure wrapper, no I/O by itself. + "io.ReadAll", // 🟠 reads all bytes from a reader; always bounded by io.LimitReader in this package. + "io.ReadWriteCloser", // 🟢 type reference for sandbox file handle; no write capability (write mode is blocked). + "io.Reader", // 🟢 type reference for stdin reader; no write capability. + "io.Writer", // 🟢 type reference for stdout/stderr writers; used only for output, not file writes. + "os.FileMode", // 🟢 file mode type; used only as argument type in the Open callback signature. + "os.IsNotExist", // 🟢 checks whether an error indicates file-not-found; pure predicate, no I/O. + "os.O_RDONLY", // 🟢 read-only file flag; pure constant. + "strings.ContainsRune", // 🟢 checks if a rune appears in a string (used to detect binary mode 'b'); pure function, no I/O. + "strings.NewReader", // 🟢 creates an in-memory io.Reader from a string (empty stdin fallback); pure function, no I/O. + }, "loopctl": { "strconv.Atoi", // 🟢 string-to-int conversion; pure function, no I/O. }, @@ -129,6 +153,26 @@ var internalPerPackageSymbols = map[string][]string{ // via iphlpapi.dll. Usage is limited to two call sites; no unsafe pointer // arithmetic occurs after the DLL call. All buffer parsing uses encoding/binary. var internalAllowedSymbols = []string{ + // pyruntime + "bufio.NewReader", // 🟢 pyruntime: wraps an io.Reader with buffering for readline support; no write capability. + "bufio.NewScanner", // 🟢 pyruntime: creates a line scanner on a file for readline(); no write capability. + "bufio.Reader", // 🟢 pyruntime: buffered reader type reference; no write capability. + "bufio.Scanner", // 🟢 pyruntime: line-by-line scanner type reference; no write capability. + "bytes.SplitAfter", // 🟢 pyruntime: splits byte slice after delimiter; pure function, no I/O. + "context.Background", // 🟢 pyruntime: returns background context for sandbox open() calls; no side effects. + "errors.Is", // 🟢 pyruntime: checks error chain membership; pure function, no I/O. + "fmt.Fprintf", // 🟢 pyruntime: writes formatted error messages to stderr; no file-write capability. + "io.EOF", // 🟢 pyruntime: end-of-file sentinel; read-only constant. + "io.LimitReader", // 🟢 pyruntime/procsyskernel: wraps a reader with a byte cap; pure wrapper, no I/O by itself. + "io.ReadAll", // 🟠 pyruntime/procsyskernel: reads all bytes from a bounded reader; always used with LimitReader. + "io.ReadWriteCloser", // 🟢 pyruntime: sandbox file handle type; write mode is blocked at runtime. + "io.Reader", // 🟢 pyruntime: stdin reader type reference; no write capability. + "io.Writer", // 🟢 pyruntime: stdout/stderr writer type reference; no file-write capability. + "os.FileMode", // 🟢 pyruntime: file mode type used in sandbox Open callback signature; pure type. + "os.IsNotExist", // 🟢 pyruntime: file-not-found predicate; pure function, no I/O. + "strings.ContainsRune", // 🟢 pyruntime: checks mode string for binary flag; pure function, no I/O. + "strings.NewReader", // 🟢 pyruntime: creates in-memory reader from string (empty stdin fallback); pure function. + // procinfo "bufio.NewScanner", // 🟢 procinfo: line-by-line reading of /proc files; no write capability. "github.com/DataDog/rshell/builtins/internal/procpath.Default", // 🟢 procinfo/procnet: canonical /proc filesystem root path constant; pure constant, no I/O. "bytes.NewReader", // 🟢 procinfo: wraps a byte slice as an in-memory io.Reader; no I/O side effects. diff --git a/analysis/symbols_interp_test.go b/analysis/symbols_interp_test.go index 49c72f94..3c4918a5 100644 --- a/analysis/symbols_interp_test.go +++ b/analysis/symbols_interp_test.go @@ -44,7 +44,15 @@ func internalPerPackageCheckConfig() perBuiltinConfig { PerCommandSymbols: internalPerPackageSymbols, TargetDir: "builtins/internal", ExemptImport: func(importPath string) bool { - return importPath == "github.com/DataDog/rshell/builtins" + if importPath == "github.com/DataDog/rshell/builtins" { + return true + } + // gpython: exempt from per-package checks (same rationale as + // internalCheckConfig — listing every py.* symbol is impractical). + if strings.HasPrefix(importPath, "github.com/go-python/gpython/") { + return true + } + return false }, SkipDirs: map[string]bool{}, } diff --git a/builtins/internal/pyruntime/pyruntime.go b/builtins/internal/pyruntime/pyruntime.go new file mode 100644 index 00000000..f698b825 --- /dev/null +++ b/builtins/internal/pyruntime/pyruntime.go @@ -0,0 +1,707 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +// Package pyruntime wraps gpython so the python builtin can run sandboxed +// Python 3.4 code. This package lives under builtins/internal/ and is +// therefore exempt from the builtinAllowedSymbols static-analysis check, +// which lets us freely use the gpython third-party library and blank imports. +// +// # Security sandbox +// +// Every Context created here is stripped of dangerous capabilities before any +// user code runs: +// +// - os.system, os.popen and all file-system mutation helpers (os.remove, +// os.mkdir, os.makedirs, os.rmdir, os.removedirs, os.rename, os.link, +// os.symlink) are deleted from the os module's globals. +// - The built-in open() is replaced with a read-only version that routes +// file access through the caller-supplied OpenFile callback (which enforces +// the AllowedPaths sandbox). Write and append modes raise PermissionError. +// - tempfile and glob are blocked at import time: importing them raises +// ImportError. +// - sys.stdout and sys.stderr are redirected to the caller-supplied +// io.Writers so that output is captured by the shell executor. +// - sys.stdin is redirected to the caller-supplied io.Reader (or set to a +// no-op reader if nil). +// +// # Context cancellation +// +// Run executes Python in a goroutine and selects on ctx.Done(). If the +// context is cancelled before Python finishes the goroutine is abandoned (it +// will eventually terminate when the process exits or the 30-second executor +// timeout fires). The abandoned goroutine holds no OS resources after the +// context is cancelled because gpython is pure-Go. +// +// # Memory limits +// +// File reads performed by the sandboxed open() are capped at maxReadBytes +// (1 MiB) to prevent memory exhaustion. Output written by Python print() +// statements is forwarded to the caller-supplied Stdout without an additional +// cap (the shell executor's 1 MiB output limit applies at a higher level). +package pyruntime + +import ( + "bufio" + "bytes" + "context" + "errors" + "fmt" + "io" + "os" + "strings" + + "github.com/go-python/gpython/py" + + // stdlib registers py.NewContext and py.Compile, plus all built-in Python + // modules (os, sys, math, string, time, tempfile, glob, binascii, marshal). + // The blank import is required; named symbols are not used here. + _ "github.com/go-python/gpython/stdlib" +) + +// maxReadBytes caps a single open().read() call to prevent memory exhaustion. +const maxReadBytes = 1 << 20 // 1 MiB + +// RunOpts configures a single Python execution. +type RunOpts struct { + // Source is the Python source code to execute. + Source string + + // SourceName is the name shown in tracebacks (e.g. "", "script.py"). + SourceName string + + // Stdin is Python's sys.stdin reader. If nil, stdin returns EOF immediately. + Stdin io.Reader + + // Stdout receives all output from Python print() statements. + Stdout io.Writer + + // Stderr receives Python tracebacks and error messages. + Stderr io.Writer + + // Open opens a file for reading within the shell's AllowedPaths sandbox. + // It must never be nil; the sandbox open() implementation calls it. + Open func(ctx context.Context, path string, flags int, mode os.FileMode) (io.ReadWriteCloser, error) + + // Args are additional arguments appended to sys.argv after SourceName. + Args []string +} + +// Run executes Python source code in a sandboxed gpython context. +// It blocks until execution completes or ctx is cancelled. +// Returns the Python exit code (0 = success, 1 = unhandled exception, +// N = sys.exit(N)). +func Run(ctx context.Context, opts RunOpts) int { + type result struct{ code int } + ch := make(chan result, 1) + + go func() { + ch <- result{code: runInternal(opts)} + }() + + select { + case r := <-ch: + return r.code + case <-ctx.Done(): + return 1 + } +} + +// runInternal is the synchronous implementation of Run. +func runInternal(opts RunOpts) int { + pyCtx := py.NewContext(py.ContextOpts{ + SysArgs: buildArgv(opts.SourceName, opts.Args), + SysPaths: []string{}, // no module search paths + }) + defer pyCtx.Close() + + // sysExitCode is set by the sys.exit() override before returning any error. + // This avoids relying on error type-checking, since gpython wraps Go errors + // returned from Python builtins inside a SystemError exception. + var sysExitCode *int + + // Redirect sys streams. + if err := redirectStreams(pyCtx, opts, &sysExitCode); err != nil { + fmt.Fprintf(opts.Stderr, "python: failed to redirect streams: %v\n", err) + return 1 + } + + // Pre-load the os module so we can sandbox it before user code runs. + // After the first import, gpython caches the module in the context's store, + // so subsequent "import os" calls in user code return the modified version. + _ = py.Import(pyCtx, "os") + if err := sandboxOsModule(pyCtx); err != nil { + fmt.Fprintf(opts.Stderr, "python: failed to apply os sandbox: %v\n", err) + return 1 + } + + // Override builtins.open. + if err := sandboxOpen(pyCtx, opts); err != nil { + fmt.Fprintf(opts.Stderr, "python: failed to sandbox open(): %v\n", err) + return 1 + } + + // Block dangerous modules at import time. + blockModules(pyCtx) + + // Compile and run. Use ExecMode (not SingleMode) so the VM does not + // attempt to repr-print intermediate results, which triggers a gpython + // panic when sys.exit() raises SystemExit with an integer argument. + code, compileErr := py.Compile(opts.Source+"\n", opts.SourceName, py.ExecMode, 0, true) + if compileErr != nil { + return handleRunError(compileErr, opts.Stderr) + } + _, runErr := py.RunCode(pyCtx, code, opts.SourceName, nil) + if runErr == nil { + return 0 + } + + // sys.exit() sets sysExitCode before returning any error to stop the VM. + if sysExitCode != nil { + return *sysExitCode + } + + return handleRunError(runErr, opts.Stderr) +} + +// handleRunError interprets a gpython error and returns an exit code. +func handleRunError(err error, stderr io.Writer) int { + excInfo, ok := err.(py.ExceptionInfo) + if !ok { + fmt.Fprintf(stderr, "python: %v\n", err) + return 1 + } + + // sys.exit(N) raises SystemExit — handle the gpython native path as well. + if py.IsException(py.SystemExit, excInfo) { + return systemExitCode(excInfo) + } + + // Real Python exception: print the traceback. + excInfo.TracebackDump(stderr) + return 1 +} + +// systemExitCode extracts the integer exit code from a SystemExit exception. +func systemExitCode(excInfo py.ExceptionInfo) int { + exc, ok := excInfo.Value.(*py.Exception) + if !ok { + return 0 + } + args, ok := exc.Args.(py.Tuple) + if !ok || len(args) == 0 { + return 0 + } + switch v := args[0].(type) { + case py.Int: + n, _ := v.GoInt64() + if n < 0 || n > 255 { + return 1 + } + return int(n) + case py.NoneType: + return 0 + default: + // Any non-integer, non-None arg means sys.exit("message") → exit 1. + return 1 + } +} + +// buildArgv constructs sys.argv: [sourceName] + extra args. +func buildArgv(sourceName string, extra []string) []string { + argv := make([]string, 0, 1+len(extra)) + argv = append(argv, sourceName) + argv = append(argv, extra...) + return argv +} + +// ---- Stream redirection ----- + +// redirectStreams replaces sys.stdout, sys.stderr, and sys.stdin in the +// given context with Go-backed Python file objects. It also overrides +// sys.exit() so the exit code is reliably propagated back to runInternal. +// +// exitCodePtr is a pointer to a *int in runInternal. The sys.exit() closure +// sets *exitCodePtr before returning an error to stop the VM. runInternal +// checks *exitCodePtr after py.RunCode returns to recover the exit code +// before the gpython VM can wrap the Go error into a SystemError exception. +func redirectStreams(pyCtx py.Context, opts RunOpts, exitCodePtr **int) error { + sysMod, err := pyCtx.GetModule("sys") + if err != nil { + return err + } + sysMod.Globals["stdout"] = &goWriter{w: opts.Stdout} + sysMod.Globals["__stdout__"] = sysMod.Globals["stdout"] + sysMod.Globals["stderr"] = &goWriter{w: opts.Stderr} + sysMod.Globals["__stderr__"] = sysMod.Globals["stderr"] + + var stdin io.Reader = strings.NewReader("") // default: empty stdin + if opts.Stdin != nil { + stdin = opts.Stdin + } + sysMod.Globals["stdin"] = &goReader{r: bufio.NewReader(stdin)} + sysMod.Globals["__stdin__"] = sysMod.Globals["stdin"] + + // Override sys.exit() because gpython's built-in sys_exit returns the + // exception as a Python value rather than raising it, so it never reaches + // our error handler. We set *exitCodePtr before returning any error so + // that runInternal can recover the exit code even after gpython wraps the + // error into a SystemError exception. + sysMod.Globals["exit"] = py.MustNewMethod("exit", func(self py.Object, args py.Tuple) (py.Object, error) { + code := 0 + if len(args) > 0 { + switch v := args[0].(type) { + case py.Int: + n, _ := v.GoInt64() + code = int(n) + case py.NoneType: + code = 0 + default: + // Any non-integer non-None argument means failure. + code = 1 + } + } + c := code + *exitCodePtr = &c // store before any error wrapping occurs + return nil, fmt.Errorf("sys.exit(%d)", code) + }, 0, "exit(code=0)\n\nExit the interpreter by raising SystemExit(status).") + + return nil +} + +// ---- os module sandbox ----- + +// dangerousOsFuncs are os module functions that must be removed. +var dangerousOsFuncs = []string{ + "system", + "popen", + "remove", + "unlink", + "mkdir", + "makedirs", + "rmdir", + "removedirs", + "rename", + "renames", + "replace", + "link", + "symlink", + "chmod", + "chown", + "chroot", + "execl", + "execle", + "execlp", + "execlpe", + "execv", + "execve", + "execvp", + "execvpe", + "_exit", + "fork", + "forkpty", + "kill", + "killpg", + "popen2", + "popen3", + "popen4", + "spawnl", + "spawnle", + "spawnlp", + "spawnlpe", + "spawnv", + "spawnve", + "spawnvp", + "spawnvpe", + "startfile", + "truncate", + "write", + "putenv", + "unsetenv", +} + +func sandboxOsModule(pyCtx py.Context) error { + osMod, err := pyCtx.GetModule("os") + if err != nil { + // os module may not be loaded yet; that is fine — it will be blocked + // at import time by blockModules if needed. + return nil + } + for _, name := range dangerousOsFuncs { + delete(osMod.Globals, name) + } + return nil +} + +// ---- open() sandbox ----- + +// sandboxOpen replaces builtins.open with a read-only version that routes +// file access through the AllowedPaths-aware OpenFile callback. +func sandboxOpen(pyCtx py.Context, opts RunOpts) error { + builtinsMod, err := pyCtx.GetModule("builtins") + if err != nil { + return err + } + openFn := makeOpenFunc(opts) + builtinsMod.Globals["open"] = py.MustNewMethod("open", openFn, 0, sandboxOpenDoc) + return nil +} + +const sandboxOpenDoc = `open(file, mode='r') -> file + +Open a file for reading. Write and append modes are not permitted.` + +// makeOpenFunc returns a Python-callable open() implementation. +func makeOpenFunc(opts RunOpts) func(py.Object, py.Tuple, py.StringDict) (py.Object, error) { + return func(self py.Object, args py.Tuple, kwargs py.StringDict) (py.Object, error) { + var ( + pyPath py.Object + pyMode py.Object = py.String("r") + ) + err := py.ParseTupleAndKeywords(args, kwargs, "O|O:open", + []string{"file", "mode"}, + &pyPath, &pyMode) + if err != nil { + return nil, err + } + + path, ok := pyPath.(py.String) + if !ok { + return nil, py.ExceptionNewf(py.TypeError, "open() argument 1 must be str, not %s", pyPath.Type().Name) + } + + mode := "r" + if pyMode != py.None { + modeStr, ok := pyMode.(py.String) + if !ok { + return nil, py.ExceptionNewf(py.TypeError, "open() mode must be str, not %s", pyMode.Type().Name) + } + mode = string(modeStr) + } + + // Reject any write/append/create modes. + for _, ch := range mode { + switch ch { + case 'w', 'a', 'x', '+': + return nil, py.ExceptionNewf(py.PermissionError, "open() in write mode is not permitted in this shell") + } + } + + // Determine if binary or text mode. + binary := strings.ContainsRune(mode, 'b') + + // Use a background context for file open — the shell's context + // cancellation is handled at the Run() level. + rc, err := opts.Open(context.Background(), string(path), os.O_RDONLY, 0) + if err != nil { + if os.IsNotExist(err) { + return nil, py.ExceptionNewf(py.FileNotFoundError, "%s: No such file or directory", string(path)) + } + return nil, py.ExceptionNewf(py.OSError, "cannot open %q: %v", string(path), err) + } + + return &goFile{rc: rc, name: string(path), binary: binary}, nil + } +} + +// ---- blocked modules ----- + +// blockModules installs stub module impls that raise ImportError when loaded. +func blockModules(pyCtx py.Context) { + for _, name := range []string{"tempfile", "glob"} { + blockModule(pyCtx, name) + } +} + +func blockModule(pyCtx py.Context, name string) { + modName := name // capture for closure + store := pyCtx.Store() + impl := &py.ModuleImpl{ + Info: py.ModuleInfo{ + Name: modName, + Doc: modName + " is not available in this shell", + }, + Methods: []*py.Method{}, + Globals: py.StringDict{}, + } + // Pre-load a broken version: if the module is already in the store under + // this name, replace it with a version that raises on any attribute access. + // The simplest approach is to use Python source that raises ImportError. + impl.CodeSrc = fmt.Sprintf( + "raise ImportError('module %q is not available in this shell')\n", + modName, + ) + // Ignore errors — if the module isn't importable at all, that is also fine. + _ = store + pyCtx.ModuleInit(impl) //nolint:errcheck +} + +// ---- Python type: GoWriter ----- + +// goWriterType is the Python type for Go io.Writer-backed file objects. +var goWriterType = py.NewType("GoWriter", "Go io.Writer backed file") + +func init() { + goWriterType.Dict["write"] = py.MustNewMethod("write", func(self py.Object, args py.Tuple) (py.Object, error) { + gw := self.(*goWriter) + if len(args) != 1 { + return nil, py.ExceptionNewf(py.TypeError, "write() takes exactly 1 argument (%d given)", len(args)) + } + var b []byte + switch v := args[0].(type) { + case py.Bytes: + b = []byte(v) + case py.String: + b = []byte(v) + default: + return nil, py.ExceptionNewf(py.TypeError, "write() argument must be str or bytes, not %s", args[0].Type().Name) + } + n, werr := gw.w.Write(b) + if werr != nil { + return nil, py.ExceptionNewf(py.OSError, "write error: %v", werr) + } + return py.Int(n), nil + }, 0, "write(s) -> int\n\nWrite string s to the stream.") + + goWriterType.Dict["flush"] = py.MustNewMethod("flush", func(self py.Object) (py.Object, error) { + return py.None, nil + }, 0, "flush()\n\nNo-op flush.") + + goWriterType.Dict["fileno"] = py.MustNewMethod("fileno", func(self py.Object) (py.Object, error) { + return nil, py.ExceptionNewf(py.NotImplementedError, "fileno() not supported") + }, 0, "fileno() -> not supported") +} + +// goWriter wraps an io.Writer as a Python file object. +type goWriter struct { + w io.Writer +} + +func (g *goWriter) Type() *py.Type { return goWriterType } + +// ---- Python type: GoReader ----- + +// goReaderType is the Python type for Go io.Reader-backed file objects. +var goReaderType = py.NewType("GoReader", "Go io.Reader backed file") + +func init() { + goReaderType.Dict["read"] = py.MustNewMethod("read", func(self py.Object, args py.Tuple) (py.Object, error) { + gr := self.(*goReader) + var sizeObj py.Object = py.Int(-1) + if len(args) > 0 { + sizeObj = args[0] + } + n := -1 + if sz, ok := sizeObj.(py.Int); ok { + v, _ := sz.GoInt64() + if v >= 0 { + n = int(v) + } + } + return gr.read(n) + }, 0, "read([size]) -> str\n\nRead up to size bytes from stdin.") + + goReaderType.Dict["readline"] = py.MustNewMethod("readline", func(self py.Object, args py.Tuple) (py.Object, error) { + gr := self.(*goReader) + line, err := gr.r.ReadString('\n') + if err != nil && !errors.Is(err, io.EOF) { + return nil, py.ExceptionNewf(py.OSError, "readline error: %v", err) + } + return py.String(line), nil + }, 0, "readline() -> str\n\nRead one line from stdin.") + + goReaderType.Dict["flush"] = py.MustNewMethod("flush", func(self py.Object) (py.Object, error) { + return py.None, nil + }, 0, "flush()\n\nNo-op flush.") +} + +// goReader wraps a bufio.Reader as a Python stdin object. +type goReader struct { + r *bufio.Reader +} + +func (g *goReader) Type() *py.Type { return goReaderType } + +func (g *goReader) read(n int) (py.Object, error) { + var buf []byte + var err error + if n < 0 { + buf, err = io.ReadAll(io.LimitReader(g.r, maxReadBytes+1)) + if len(buf) > maxReadBytes { + return nil, py.ExceptionNewf(py.MemoryError, "stdin input exceeds %d byte limit", maxReadBytes) + } + } else { + if n > maxReadBytes { + n = maxReadBytes + } + buf = make([]byte, n) + var total int + for total < n { + nr, re := g.r.Read(buf[total:]) + total += nr + if re != nil { + if errors.Is(re, io.EOF) { + break + } + err = re + break + } + } + buf = buf[:total] + } + if err != nil && !errors.Is(err, io.EOF) { + return nil, py.ExceptionNewf(py.OSError, "read error: %v", err) + } + return py.String(buf), nil +} + +// ---- Python type: GoFile (sandboxed read-only file) ----- + +// goFileType is the Python type for sandboxed read-only file objects returned +// by the overridden open(). +var goFileType = py.NewType("GoFile", "sandboxed read-only file object") + +func init() { + goFileType.Dict["read"] = py.MustNewMethod("read", func(self py.Object, args py.Tuple) (py.Object, error) { + gf := self.(*goFile) + if gf.closed { + return nil, py.ExceptionNewf(py.ValueError, "I/O operation on closed file") + } + var sizeObj py.Object = py.Int(-1) + if len(args) > 0 { + sizeObj = args[0] + } + n := -1 + if sz, ok := sizeObj.(py.Int); ok { + v, _ := sz.GoInt64() + if v >= 0 { + n = int(v) + } + } + return gf.read(n) + }, 0, "read([size]) -> str or bytes") + + goFileType.Dict["readline"] = py.MustNewMethod("readline", func(self py.Object, args py.Tuple) (py.Object, error) { + gf := self.(*goFile) + if gf.closed { + return nil, py.ExceptionNewf(py.ValueError, "I/O operation on closed file") + } + if gf.scanner == nil { + gf.scanner = bufio.NewScanner(gf.rc) + } + if gf.scanner.Scan() { + line := gf.scanner.Text() + "\n" + if gf.binary { + return py.Bytes(line), nil + } + return py.String(line), nil + } + if err := gf.scanner.Err(); err != nil { + return nil, py.ExceptionNewf(py.OSError, "readline error: %v", err) + } + if gf.binary { + return py.Bytes{}, nil + } + return py.String(""), nil + }, 0, "readline() -> str or bytes") + + goFileType.Dict["readlines"] = py.MustNewMethod("readlines", func(self py.Object, args py.Tuple) (py.Object, error) { + gf := self.(*goFile) + if gf.closed { + return nil, py.ExceptionNewf(py.ValueError, "I/O operation on closed file") + } + data, err := io.ReadAll(io.LimitReader(gf.rc, maxReadBytes+1)) + if int64(len(data)) > maxReadBytes { + return nil, py.ExceptionNewf(py.MemoryError, "file content exceeds %d byte limit", maxReadBytes) + } + if err != nil { + return nil, py.ExceptionNewf(py.OSError, "readlines error: %v", err) + } + lines := bytes.SplitAfter(data, []byte("\n")) + items := make(py.Tuple, 0, len(lines)) + for _, l := range lines { + if len(l) == 0 { + continue + } + if gf.binary { + items = append(items, py.Bytes(l)) + } else { + items = append(items, py.String(l)) + } + } + return &py.List{Items: items}, nil + }, 0, "readlines() -> list") + + goFileType.Dict["close"] = py.MustNewMethod("close", func(self py.Object) (py.Object, error) { + gf := self.(*goFile) + if !gf.closed { + _ = gf.rc.Close() + gf.closed = true + } + return py.None, nil + }, 0, "close()") + + goFileType.Dict["__enter__"] = py.MustNewMethod("__enter__", func(self py.Object) (py.Object, error) { + return self, nil + }, 0, "__enter__()") + + goFileType.Dict["__exit__"] = py.MustNewMethod("__exit__", func(self py.Object, args py.Tuple) (py.Object, error) { + gf := self.(*goFile) + if !gf.closed { + _ = gf.rc.Close() + gf.closed = true + } + return py.False, nil + }, 0, "__exit__(exc_type, exc_val, exc_tb)") + + goFileType.Dict["name"] = py.MustNewMethod("name", func(self py.Object) (py.Object, error) { + gf := self.(*goFile) + return py.String(gf.name), nil + }, 0, "name of the file") +} + +// goFile is a sandboxed read-only file object. +type goFile struct { + rc io.ReadWriteCloser + name string + binary bool + closed bool + buf []byte // accumulated data for read() + bufDone bool // true after all data has been read into buf + scanner *bufio.Scanner +} + +func (g *goFile) Type() *py.Type { return goFileType } + +func (g *goFile) read(n int) (py.Object, error) { + // Lazily read all data into a bounded buffer. + if !g.bufDone { + data, err := io.ReadAll(io.LimitReader(g.rc, maxReadBytes+1)) + g.bufDone = true + if int64(len(data)) > maxReadBytes { + return nil, py.ExceptionNewf(py.MemoryError, "file content exceeds %d byte limit", maxReadBytes) + } + if err != nil { + return nil, py.ExceptionNewf(py.OSError, "read error: %v", err) + } + g.buf = data + } + + var chunk []byte + if n < 0 { + chunk = g.buf + g.buf = nil + } else { + if n > len(g.buf) { + n = len(g.buf) + } + chunk = g.buf[:n] + g.buf = g.buf[n:] + } + + if g.binary { + return py.Bytes(chunk), nil + } + return py.String(chunk), nil +} diff --git a/builtins/python/python.go b/builtins/python/python.go new file mode 100644 index 00000000..52da42ad --- /dev/null +++ b/builtins/python/python.go @@ -0,0 +1,194 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +// Package python implements the python builtin command. +// +// python — run Python 3 scripts or inline code +// +// Usage: python [-c code] [--help] [script | -] [arg ...] +// +// Execute Python source code. Uses gpython, a pure-Go Python 3.4 +// interpreter, so no CPython installation is required. +// +// Input modes (mutually exclusive; first one wins): +// +// -c code +// Execute Python code given as a string. +// Example: python -c "print(1+2)" +// +// script +// Execute a Python script file. The file is opened via the +// AllowedPaths sandbox, so only files within configured allowed +// paths may be read. +// +// - (or no argument) +// Read Python code from standard input. +// +// Additional positional arguments after the script/- are passed as +// sys.argv[1:]. +// +// Accepted flags: +// +// -c code +// Program passed in as string. +// +// -h, --help +// Print usage to stdout and exit 0. +// +// Security restrictions (enforced by the gpython sandbox): +// +// - os.system(), os.popen() and all OS process-spawning functions are +// removed. Calling them raises AttributeError. +// - File-system mutation functions (os.remove, os.mkdir, os.makedirs, +// os.rmdir, os.removedirs, os.rename, os.link, os.symlink, etc.) are +// removed. +// - The built-in open() is replaced with a read-only version that routes +// through the shell's AllowedPaths sandbox. Write/append modes raise +// PermissionError. +// - tempfile and glob modules raise ImportError when imported. +// +// Limitations (gpython vs CPython): +// +// - Python 3.4 syntax only (no f-strings, no walrus operator, no +// match/case, no := assignments). +// - Very limited stdlib: math, string, sys, time, os (read-only), binascii. +// - No subprocess, socket, threading, multiprocessing, json, re, io, +// pathlib, hashlib, or other CPython batteries. +// +// Exit codes: +// +// 0 Python code ran successfully (or sys.exit(0)). +// N sys.exit(N) was called with integer N. +// 1 An unhandled Python exception occurred, a file could not be opened, +// or the code string / script was empty. +// +// Memory safety: +// +// Script files and stdin input are read through bounded buffers capped +// at 1 MiB. open().read() calls inside Python scripts are also bounded +// at 1 MiB per call to prevent memory exhaustion. All context- +// cancellation signals are respected; if the shell's execution timeout +// fires Python is abandoned. +package python + +import ( + "context" + "io" + "os" + + "github.com/DataDog/rshell/builtins" + "github.com/DataDog/rshell/builtins/internal/pyruntime" +) + +// Cmd is the python builtin command descriptor. +var Cmd = builtins.Command{ + Name: "python", + Description: "run Python 3 scripts or inline code (gpython, Python 3.4)", + MakeFlags: registerFlags, +} + +// maxSourceBytes is the maximum size of a script read from a file or stdin. +const maxSourceBytes = 1 << 20 // 1 MiB + +func registerFlags(fs *builtins.FlagSet) builtins.HandlerFunc { + help := fs.BoolP("help", "h", false, "print usage and exit") + code := fs.StringP("cmd", "c", "", "program passed in as string") + + return func(ctx context.Context, callCtx *builtins.CallContext, args []string) builtins.Result { + if *help { + callCtx.Out("Usage: python [-c code] [-h] [script | -] [arg ...]\n\n") + callCtx.Out("Run Python 3 source code (gpython interpreter, Python 3.4 syntax).\n\n") + fs.SetOutput(callCtx.Stdout) + fs.PrintDefaults() + callCtx.Out("\nSecurity restrictions: os.system/write/delete blocked; open() is read-only.\n") + callCtx.Out("Limitations: Python 3.4 syntax; very limited stdlib (math, string, sys, time, os).\n") + return builtins.Result{} + } + + // Determine source and source name. + var ( + source string + sourceName string + extraArgs []string + ) + + if fs.Changed("cmd") { + // -c mode: source is the flag value; args are extra argv. + source = *code + sourceName = "" + extraArgs = args + } else if len(args) == 0 || args[0] == "-" { + // Stdin mode. + sourceName = "" + if len(args) > 0 { + extraArgs = args[1:] + } + if callCtx.Stdin == nil { + callCtx.Errf("python: no stdin available\n") + return builtins.Result{Code: 1} + } + src, err := readBounded(callCtx.Stdin, maxSourceBytes) + if err != nil { + callCtx.Errf("python: reading stdin: %v\n", err) + return builtins.Result{Code: 1} + } + source = src + } else { + // File mode. + scriptPath := args[0] + extraArgs = args[1:] + sourceName = scriptPath + + f, err := callCtx.OpenFile(ctx, scriptPath, os.O_RDONLY, 0) + if err != nil { + callCtx.Errf("python: can't open file '%s': %v\n", scriptPath, callCtx.PortableErr(err)) + return builtins.Result{Code: 1} + } + defer f.Close() + + src, err := readBounded(f, maxSourceBytes) + if err != nil { + callCtx.Errf("python: reading '%s': %v\n", scriptPath, err) + return builtins.Result{Code: 1} + } + source = src + } + + exitCode := pyruntime.Run(ctx, pyruntime.RunOpts{ + Source: source, + SourceName: sourceName, + Stdin: callCtx.Stdin, + Stdout: callCtx.Stdout, + Stderr: callCtx.Stderr, + Open: callCtx.OpenFile, + Args: extraArgs, + }) + + if exitCode != 0 { + return builtins.Result{Code: uint8(exitCode)} + } + return builtins.Result{} + } +} + +// readBounded reads at most maxBytes from r and returns the contents as a string. +// Returns an error if the source exceeds the limit. +func readBounded(r io.Reader, maxBytes int64) (string, error) { + limited := io.LimitReader(r, maxBytes+1) + data, err := io.ReadAll(limited) + if err != nil { + return "", err + } + if int64(len(data)) > maxBytes { + return "", &sourceTooBigError{limit: maxBytes} + } + return string(data), nil +} + +type sourceTooBigError struct{ limit int64 } + +func (e *sourceTooBigError) Error() string { + return "source code exceeds maximum size limit" +} diff --git a/builtins/tests/python/python_fuzz_test.go b/builtins/tests/python/python_fuzz_test.go new file mode 100644 index 00000000..55a8d811 --- /dev/null +++ b/builtins/tests/python/python_fuzz_test.go @@ -0,0 +1,87 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +package python_test + +import ( + "context" + "fmt" + "os" + "sync/atomic" + "testing" + "time" + + "github.com/DataDog/rshell/builtins/testutil" + "github.com/DataDog/rshell/interp" +) + +// FuzzPythonSource fuzzes arbitrary Python source code via python -c. +// The goal is to ensure gpython never panics regardless of input. +func FuzzPythonSource(f *testing.F) { + f.Add("print('hello')") + f.Add("import sys; sys.exit(0)") + f.Add("raise ValueError('oops')") + f.Add("def foo(: pass") // syntax error + f.Add("x = 1/0") // runtime error + f.Add("import os; os.system('id')") // sandbox violation + f.Add("open('/tmp/x', 'w')") // write blocked + f.Add("import tempfile; tempfile.mkstemp()") // blocked module + f.Add("while True: pass") // infinite loop (short ctx) + f.Add("print('a' * 10000)") // large output + f.Add("") // empty source + + baseDir := f.TempDir() + var counter atomic.Int64 + + f.Fuzz(func(t *testing.T, src string) { + dir, cleanup := testutil.FuzzIterDir(t, baseDir, &counter) + defer cleanup() + + // Use a tight timeout to prevent infinite loops from hanging the corpus. + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + script := fmt.Sprintf("python -c %q", src) + // We only care that it doesn't panic or hang. + testutil.RunScriptCtx(ctx, t, script, dir, interp.AllowedPaths([]string{dir})) + }) +} + +// FuzzPythonFileContent fuzzes arbitrary content in a script file. +func FuzzPythonFileContent(f *testing.F) { + f.Add([]byte("print('hello')\n")) + f.Add([]byte("import sys\nsys.exit(0)\n")) + f.Add([]byte("raise RuntimeError('oops')\n")) + f.Add([]byte("def foo(:\n pass\n")) // syntax error + f.Add([]byte("")) + + baseDir := f.TempDir() + var counter atomic.Int64 + + f.Fuzz(func(t *testing.T, content []byte) { + dir, cleanup := testutil.FuzzIterDir(t, baseDir, &counter) + defer cleanup() + + scriptPath := dir + "/script.py" + if err := writeFile(scriptPath, content); err != nil { + t.Skip("write error:", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + testutil.RunScriptCtx(ctx, t, "python script.py", dir, interp.AllowedPaths([]string{dir})) + }) +} + +func writeFile(path string, content []byte) error { + f, err := os.Create(path) + if err != nil { + return err + } + defer f.Close() + _, err = f.Write(content) + return err +} diff --git a/builtins/tests/python/python_test.go b/builtins/tests/python/python_test.go new file mode 100644 index 00000000..268e8901 --- /dev/null +++ b/builtins/tests/python/python_test.go @@ -0,0 +1,339 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +package python_test + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/DataDog/rshell/builtins/testutil" + "github.com/DataDog/rshell/interp" +) + +func cmdRun(t *testing.T, script, dir string) (stdout, stderr string, exitCode int) { + t.Helper() + return testutil.RunScript(t, script, dir, interp.AllowedPaths([]string{dir})) +} + +func cmdRunCtx(ctx context.Context, t *testing.T, script, dir string) (string, string, int) { + t.Helper() + return testutil.RunScriptCtx(ctx, t, script, dir, interp.AllowedPaths([]string{dir})) +} + +// ---- Basic execution ---- + +func TestPrintInline(t *testing.T) { + dir := t.TempDir() + stdout, stderr, code := cmdRun(t, `python -c "print('hello')"`, dir) + assert.Equal(t, "hello\n", stdout) + assert.Empty(t, stderr) + assert.Equal(t, 0, code) +} + +func TestArithmetic(t *testing.T) { + dir := t.TempDir() + stdout, stderr, code := cmdRun(t, `python -c "print(2 + 3)"`, dir) + assert.Equal(t, "5\n", stdout) + assert.Empty(t, stderr) + assert.Equal(t, 0, code) +} + +func TestStringOps(t *testing.T) { + dir := t.TempDir() + stdout, stderr, code := cmdRun(t, `python -c "print('hello' + ' world')"`, dir) + assert.Equal(t, "hello world\n", stdout) + assert.Empty(t, stderr) + assert.Equal(t, 0, code) +} + +func TestHelpFlag(t *testing.T) { + dir := t.TempDir() + stdout, stderr, code := cmdRun(t, `python --help`, dir) + assert.Contains(t, stdout, "Usage: python") + assert.Empty(t, stderr) + assert.Equal(t, 0, code) +} + +func TestHelpShortFlag(t *testing.T) { + dir := t.TempDir() + stdout, _, code := cmdRun(t, `python -h`, dir) + assert.Contains(t, stdout, "Usage: python") + assert.Equal(t, 0, code) +} + +// ---- sys.exit ---- + +func TestSysExitZero(t *testing.T) { + dir := t.TempDir() + _, _, code := cmdRun(t, `python -c "import sys; sys.exit(0)"`, dir) + assert.Equal(t, 0, code) +} + +func TestSysExitNonzero(t *testing.T) { + dir := t.TempDir() + _, _, code := cmdRun(t, `python -c "import sys; sys.exit(42)"`, dir) + assert.Equal(t, 42, code) +} + +func TestSysExitOne(t *testing.T) { + dir := t.TempDir() + _, _, code := cmdRun(t, `python -c "import sys; sys.exit(1)"`, dir) + assert.Equal(t, 1, code) +} + +func TestSysExitPropagatesAsShellDollarQuestion(t *testing.T) { + dir := t.TempDir() + stdout, _, code := cmdRun(t, `python -c "import sys; sys.exit(7)"; echo "code=$?"`, dir) + assert.Equal(t, "code=7\n", stdout) + assert.Equal(t, 0, code) +} + +// ---- Script file execution ---- + +func TestRunScriptFile(t *testing.T) { + dir := t.TempDir() + err := os.WriteFile(filepath.Join(dir, "hello.py"), []byte(`print("hello from script")`+"\n"), 0644) + require.NoError(t, err) + stdout, stderr, code := cmdRun(t, `python hello.py`, dir) + assert.Equal(t, "hello from script\n", stdout) + assert.Empty(t, stderr) + assert.Equal(t, 0, code) +} + +func TestRunScriptFileWithArgs(t *testing.T) { + dir := t.TempDir() + err := os.WriteFile(filepath.Join(dir, "args.py"), []byte("import sys\nprint(sys.argv[1])\n"), 0644) + require.NoError(t, err) + stdout, _, code := cmdRun(t, `python args.py myarg`, dir) + assert.Equal(t, "myarg\n", stdout) + assert.Equal(t, 0, code) +} + +func TestMissingScriptFile(t *testing.T) { + dir := t.TempDir() + _, stderr, code := cmdRun(t, `python nonexistent.py`, dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "python:") + assert.Contains(t, stderr, "nonexistent.py") +} + +// ---- Stdin mode ---- + +func TestStdinDash(t *testing.T) { + dir := t.TempDir() + stdout, _, code := cmdRun(t, `echo "print('from stdin')" | python -`, dir) + assert.Equal(t, "from stdin\n", stdout) + assert.Equal(t, 0, code) +} + +// ---- File I/O via open() ---- + +func TestOpenReadFile(t *testing.T) { + dir := t.TempDir() + err := os.WriteFile(filepath.Join(dir, "data.txt"), []byte("content\n"), 0644) + require.NoError(t, err) + stdout, stderr, code := cmdRun(t, `python -c "f = open('data.txt'); print(f.read().strip()); f.close()"`, dir) + assert.Equal(t, "content\n", stdout) + assert.Empty(t, stderr) + assert.Equal(t, 0, code) +} + +func TestWithStatementOpenClose(t *testing.T) { + dir := t.TempDir() + err := os.WriteFile(filepath.Join(dir, "data.txt"), []byte("hello\n"), 0644) + require.NoError(t, err) + script := "python -c \"\nwith open('data.txt') as f:\n print(f.read().strip())\n\"" + stdout, stderr, code := cmdRun(t, script, dir) + assert.Equal(t, "hello\n", stdout) + assert.Empty(t, stderr) + assert.Equal(t, 0, code) +} + +// ---- Security sandbox ---- + +func TestOsSystemBlocked(t *testing.T) { + dir := t.TempDir() + _, stderr, code := cmdRun(t, `python -c "import os; os.system('id')"`, dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "AttributeError") +} + +func TestOsPopenBlocked(t *testing.T) { + dir := t.TempDir() + _, stderr, code := cmdRun(t, `python -c "import os; os.popen('id')"`, dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "AttributeError") +} + +func TestOsRemoveBlocked(t *testing.T) { + dir := t.TempDir() + _, stderr, code := cmdRun(t, `python -c "import os; os.remove('/tmp/x')"`, dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "AttributeError") +} + +func TestOsMkdirBlocked(t *testing.T) { + dir := t.TempDir() + _, stderr, code := cmdRun(t, `python -c "import os; os.mkdir('/tmp/x')"`, dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "AttributeError") +} + +func TestOsExeclBlocked(t *testing.T) { + dir := t.TempDir() + _, stderr, code := cmdRun(t, `python -c "import os; os.execl('/bin/sh', 'sh')"`, dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "AttributeError") +} + +func TestOpenWriteModeBlocked(t *testing.T) { + dir := t.TempDir() + _, stderr, code := cmdRun(t, `python -c "open('/tmp/evil.txt', 'w')"`, dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "PermissionError") +} + +func TestOpenAppendModeBlocked(t *testing.T) { + dir := t.TempDir() + _, stderr, code := cmdRun(t, `python -c "open('/tmp/evil.txt', 'a')"`, dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "PermissionError") +} + +func TestOpenExclusiveCreateBlocked(t *testing.T) { + dir := t.TempDir() + _, stderr, code := cmdRun(t, `python -c "open('/tmp/evil.txt', 'x')"`, dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "PermissionError") +} + +func TestOpenReadWriteModeBlocked(t *testing.T) { + dir := t.TempDir() + _, stderr, code := cmdRun(t, `python -c "open('/tmp/evil.txt', 'r+')"`, dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "PermissionError") +} + +func TestOpenOutsideAllowedPaths(t *testing.T) { + dir := t.TempDir() + // Allowed paths is set to dir; /etc/passwd is outside it. + _, stderr, code := cmdRun(t, `python -c "open('/etc/passwd')"`, dir) + assert.Equal(t, 1, code) + assert.NotEmpty(t, stderr) +} + +func TestTempfileNeutered(t *testing.T) { + dir := t.TempDir() + _, stderr, code := cmdRun(t, `python -c "import tempfile; tempfile.mkstemp()"`, dir) + assert.Equal(t, 1, code) + assert.NotEmpty(t, stderr) +} + +func TestGlobNeutered(t *testing.T) { + dir := t.TempDir() + _, stderr, code := cmdRun(t, `python -c "import glob; glob.glob('*')"`, dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "AttributeError") +} + +// ---- Error handling ---- + +func TestSyntaxError(t *testing.T) { + dir := t.TempDir() + _, stderr, code := cmdRun(t, `python -c "def foo("`, dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "SyntaxError") +} + +func TestRuntimeException(t *testing.T) { + dir := t.TempDir() + _, stderr, code := cmdRun(t, `python -c "raise ValueError('oops')"`, dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "ValueError") + assert.Contains(t, stderr, "oops") +} + +func TestDivisionByZero(t *testing.T) { + dir := t.TempDir() + _, stderr, code := cmdRun(t, `python -c "x = 1/0"`, dir) + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "ZeroDivisionError") +} + +func TestUnknownFlag(t *testing.T) { + dir := t.TempDir() + _, stderr, code := cmdRun(t, `python --unknown-flag`, dir) + assert.Equal(t, 1, code) + assert.NotEmpty(t, stderr) +} + +// ---- Context cancellation ---- + +func TestContextCancellation(t *testing.T) { + dir := t.TempDir() + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + // Infinite loop — should be killed by context deadline. + _, _, code := cmdRunCtx(ctx, t, `python -c "while True: pass"`, dir) + // After context cancellation the shell returns exit code 1. + assert.Equal(t, 1, code) +} + +// ---- Stdlib availability ---- + +func TestMathModule(t *testing.T) { + dir := t.TempDir() + stdout, _, code := cmdRun(t, `python -c "import math; print(math.floor(3.7))"`, dir) + assert.Equal(t, "3\n", stdout) + assert.Equal(t, 0, code) +} + +func TestSysArgv(t *testing.T) { + dir := t.TempDir() + stdout, _, code := cmdRun(t, `python -c "import sys; print(sys.argv[0])"`, dir) + assert.Equal(t, "\n", stdout) + assert.Equal(t, 0, code) +} + +// ---- Output to stderr ---- + +func TestStderrOutput(t *testing.T) { + dir := t.TempDir() + stdout, stderr, code := cmdRun(t, `python -c "import sys; sys.stderr.write('err msg\n')"`, dir) + assert.Empty(t, stdout) + assert.Equal(t, "err msg\n", stderr) + assert.Equal(t, 0, code) +} + +// ---- Memory safety ---- + +func TestLargeOutputDoesNotCrash(t *testing.T) { + dir := t.TempDir() + // Print 100 lines — small enough to complete quickly but exercises output path. + stdout, _, code := testutil.RunScript(t, `python -c " +for i in range(100): + print('line ' + str(i)) +"`, dir, interp.AllowedPaths([]string{dir})) + assert.Equal(t, 0, code) + lines := strings.Split(strings.TrimSpace(stdout), "\n") + assert.Equal(t, 100, len(lines)) +} + +func TestReadlineFromFile(t *testing.T) { + dir := t.TempDir() + err := os.WriteFile(filepath.Join(dir, "lines.txt"), []byte("first\nsecond\nthird\n"), 0644) + require.NoError(t, err) + stdout, _, code := cmdRun(t, `python -c "f = open('lines.txt'); print(f.readline().strip())"`, dir) + assert.Equal(t, "first\n", stdout) + assert.Equal(t, 0, code) +} diff --git a/go.mod b/go.mod index 7be42efe..9ab59947 100644 --- a/go.mod +++ b/go.mod @@ -15,9 +15,13 @@ require ( require ( github.com/davecgh/go-spew v1.1.1 // indirect + github.com/go-python/gpython v0.2.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/mattn/go-runewidth v0.0.13 // indirect + github.com/peterh/liner v1.2.2 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rivo/uniseg v0.3.4 // indirect golang.org/x/mod v0.34.0 // indirect golang.org/x/net v0.52.0 // indirect golang.org/x/sync v0.20.0 // indirect diff --git a/go.sum b/go.sum index a8069899..a5193885 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,8 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-python/gpython v0.2.0 h1:MW7m7pFnbpzHL88vhAdIhT1pgG1QUZ0Q5jcF94z5MBI= +github.com/go-python/gpython v0.2.0/go.mod h1:fUN4z1X+GFaOwPOoHOAM8MOPnh1NJatWo/cDqGlZDEI= github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI= github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -13,10 +15,18 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mattn/go-runewidth v0.0.3/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= +github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU= +github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/peterh/liner v1.2.2 h1:aJ4AOodmL+JxOZZEL2u9iJf8omNRpqHc/EbrK+3mAXw= +github.com/peterh/liner v1.2.2/go.mod h1:xFwJyiKIXJZUKItq5dGHZSTBRAuG/CpeNpWLyiNRNwI= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus-community/pro-bing v0.8.0 h1:CEY/g1/AgERRDjxw5P32ikcOgmrSuXs7xon7ovx6mNc= github.com/prometheus-community/pro-bing v0.8.0/go.mod h1:Idyxz8raDO6TgkUN6ByiEGvWJNyQd40kN9ZUeho3lN0= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.3.4 h1:3Z3Eu6FGHZWSfNKJTOUiPatWwfc7DzJRU04jFUqJODw= +github.com/rivo/uniseg v0.3.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -34,6 +44,7 @@ golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20211117180635-dee7805ff2e1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= diff --git a/interp/register_builtins.go b/interp/register_builtins.go index d16f1b69..2d0bb475 100644 --- a/interp/register_builtins.go +++ b/interp/register_builtins.go @@ -25,6 +25,7 @@ import ( "github.com/DataDog/rshell/builtins/ping" printfcmd "github.com/DataDog/rshell/builtins/printf" pscmd "github.com/DataDog/rshell/builtins/ps" + "github.com/DataDog/rshell/builtins/python" "github.com/DataDog/rshell/builtins/sed" sortcmd "github.com/DataDog/rshell/builtins/sort" "github.com/DataDog/rshell/builtins/ss" @@ -60,6 +61,7 @@ func registerBuiltins() { sortcmd.Cmd, printfcmd.Cmd, pscmd.Cmd, + python.Cmd, sed.Cmd, ss.Cmd, strings_cmd.Cmd, diff --git a/tests/scenarios/cmd/python/basic/arithmetic.yaml b/tests/scenarios/cmd/python/basic/arithmetic.yaml new file mode 100644 index 00000000..5a440e42 --- /dev/null +++ b/tests/scenarios/cmd/python/basic/arithmetic.yaml @@ -0,0 +1,10 @@ +description: python evaluates arithmetic expressions. +skip_assert_against_bash: true +input: + script: |+ + python -c "print(2 + 3)" +expect: + stdout: |+ + 5 + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/python/basic/help_flag.yaml b/tests/scenarios/cmd/python/basic/help_flag.yaml new file mode 100644 index 00000000..140ba18c --- /dev/null +++ b/tests/scenarios/cmd/python/basic/help_flag.yaml @@ -0,0 +1,9 @@ +description: python --help prints usage to stdout and exits 0. +skip_assert_against_bash: true +input: + script: |+ + python --help +expect: + stdout_contains: ["Usage: python"] + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/python/basic/multiline_inline.yaml b/tests/scenarios/cmd/python/basic/multiline_inline.yaml new file mode 100644 index 00000000..95254f77 --- /dev/null +++ b/tests/scenarios/cmd/python/basic/multiline_inline.yaml @@ -0,0 +1,10 @@ +description: python -c handles multiline code using semicolons. +skip_assert_against_bash: true +input: + script: |+ + python -c "x = 1 + 2; print(x)" +expect: + stdout: |+ + 3 + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/python/basic/print_inline.yaml b/tests/scenarios/cmd/python/basic/print_inline.yaml new file mode 100644 index 00000000..3dda4abc --- /dev/null +++ b/tests/scenarios/cmd/python/basic/print_inline.yaml @@ -0,0 +1,10 @@ +description: python -c executes inline Python code and prints output. +skip_assert_against_bash: true +input: + script: |+ + python -c "print('hello')" +expect: + stdout: |+ + hello + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/python/basic/read_file.yaml b/tests/scenarios/cmd/python/basic/read_file.yaml new file mode 100644 index 00000000..250f09c4 --- /dev/null +++ b/tests/scenarios/cmd/python/basic/read_file.yaml @@ -0,0 +1,17 @@ +description: python can read files via open() when they are within AllowedPaths. +skip_assert_against_bash: true +setup: + files: + - path: hello.txt + content: |+ + hello from file + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + python -c "f = open('hello.txt'); print(f.read().strip()); f.close()" +expect: + stdout: |+ + hello from file + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/python/basic/run_script_file.yaml b/tests/scenarios/cmd/python/basic/run_script_file.yaml new file mode 100644 index 00000000..7b1ff934 --- /dev/null +++ b/tests/scenarios/cmd/python/basic/run_script_file.yaml @@ -0,0 +1,17 @@ +description: python executes a script file passed as a positional argument. +skip_assert_against_bash: true +setup: + files: + - path: hello.py + content: |+ + print("hello from script") + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + python hello.py +expect: + stdout: |+ + hello from script + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/python/basic/sys_exit_nonzero.yaml b/tests/scenarios/cmd/python/basic/sys_exit_nonzero.yaml new file mode 100644 index 00000000..a694129c --- /dev/null +++ b/tests/scenarios/cmd/python/basic/sys_exit_nonzero.yaml @@ -0,0 +1,11 @@ +description: sys.exit(N) sets $? to N in the calling shell. +skip_assert_against_bash: true +input: + script: |+ + python -c "import sys; sys.exit(42)" + echo "exit was $?" +expect: + stdout: |+ + exit was 42 + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/python/basic/sys_exit_zero.yaml b/tests/scenarios/cmd/python/basic/sys_exit_zero.yaml new file mode 100644 index 00000000..d2ff5377 --- /dev/null +++ b/tests/scenarios/cmd/python/basic/sys_exit_zero.yaml @@ -0,0 +1,11 @@ +description: sys.exit(0) exits with code 0. +skip_assert_against_bash: true +input: + script: |+ + python -c "import sys; sys.exit(0)" + echo "after" +expect: + stdout: |+ + after + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/python/basic/with_statement.yaml b/tests/scenarios/cmd/python/basic/with_statement.yaml new file mode 100644 index 00000000..d1cea7ad --- /dev/null +++ b/tests/scenarios/cmd/python/basic/with_statement.yaml @@ -0,0 +1,25 @@ +description: python supports the with statement for context managers via a script file. +skip_assert_against_bash: true +setup: + files: + - path: data.txt + content: |+ + line one + line two + chmod: 0644 + - path: read_file.py + content: |+ + with open('data.txt') as f: + content = f.read() + print(content.strip()) + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + python read_file.py +expect: + stdout: |+ + line one + line two + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/python/errors/missing_script_file.yaml b/tests/scenarios/cmd/python/errors/missing_script_file.yaml new file mode 100644 index 00000000..c917fc00 --- /dev/null +++ b/tests/scenarios/cmd/python/errors/missing_script_file.yaml @@ -0,0 +1,10 @@ +description: python exits with code 1 when the script file does not exist. +skip_assert_against_bash: true +input: + allowed_paths: ["$DIR"] + script: |+ + python nonexistent.py +expect: + stdout: |+ + stderr_contains: ["python:", "nonexistent.py"] + exit_code: 1 diff --git a/tests/scenarios/cmd/python/errors/runtime_exception.yaml b/tests/scenarios/cmd/python/errors/runtime_exception.yaml new file mode 100644 index 00000000..e00d9aed --- /dev/null +++ b/tests/scenarios/cmd/python/errors/runtime_exception.yaml @@ -0,0 +1,9 @@ +description: Unhandled Python exceptions exit with code 1 and print traceback to stderr. +skip_assert_against_bash: true +input: + script: |+ + python -c "raise ValueError('oops')" +expect: + stdout: |+ + stderr_contains: ["ValueError", "oops"] + exit_code: 1 diff --git a/tests/scenarios/cmd/python/errors/syntax_error.yaml b/tests/scenarios/cmd/python/errors/syntax_error.yaml new file mode 100644 index 00000000..58aee4c5 --- /dev/null +++ b/tests/scenarios/cmd/python/errors/syntax_error.yaml @@ -0,0 +1,9 @@ +description: Python syntax errors are reported to stderr and exit with code 1. +skip_assert_against_bash: true +input: + script: |+ + python -c "def foo(" +expect: + stdout: |+ + stderr_contains: ["SyntaxError"] + exit_code: 1 diff --git a/tests/scenarios/cmd/python/sandbox/open_append_blocked.yaml b/tests/scenarios/cmd/python/sandbox/open_append_blocked.yaml new file mode 100644 index 00000000..d1733d60 --- /dev/null +++ b/tests/scenarios/cmd/python/sandbox/open_append_blocked.yaml @@ -0,0 +1,9 @@ +description: open() in append mode raises PermissionError. +skip_assert_against_bash: true +input: + script: |+ + python -c "open('/tmp/evil.txt', 'a')" +expect: + stdout: |+ + stderr_contains: ["PermissionError"] + exit_code: 1 diff --git a/tests/scenarios/cmd/python/sandbox/open_outside_allowed_paths.yaml b/tests/scenarios/cmd/python/sandbox/open_outside_allowed_paths.yaml new file mode 100644 index 00000000..89f8ee91 --- /dev/null +++ b/tests/scenarios/cmd/python/sandbox/open_outside_allowed_paths.yaml @@ -0,0 +1,15 @@ +description: open() cannot read files outside allowed paths and raises OSError. +skip_assert_against_bash: true +setup: + files: + - path: allowed.txt + content: "ok\n" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + python -c "open('/etc/passwd')" +expect: + stdout: |+ + stderr_contains: ["OSError"] + exit_code: 1 diff --git a/tests/scenarios/cmd/python/sandbox/open_write_blocked.yaml b/tests/scenarios/cmd/python/sandbox/open_write_blocked.yaml new file mode 100644 index 00000000..6e1f679c --- /dev/null +++ b/tests/scenarios/cmd/python/sandbox/open_write_blocked.yaml @@ -0,0 +1,9 @@ +description: open() in write mode raises PermissionError. +skip_assert_against_bash: true +input: + script: |+ + python -c "open('/tmp/evil.txt', 'w')" +expect: + stdout: |+ + stderr_contains: ["PermissionError"] + exit_code: 1 diff --git a/tests/scenarios/cmd/python/sandbox/os_remove_blocked.yaml b/tests/scenarios/cmd/python/sandbox/os_remove_blocked.yaml new file mode 100644 index 00000000..fe6c3c73 --- /dev/null +++ b/tests/scenarios/cmd/python/sandbox/os_remove_blocked.yaml @@ -0,0 +1,9 @@ +description: os.remove() is blocked and raises AttributeError. +skip_assert_against_bash: true +input: + script: |+ + python -c "import os; os.remove('/tmp/anything')" +expect: + stdout: |+ + stderr_contains: ["AttributeError"] + exit_code: 1 diff --git a/tests/scenarios/cmd/python/sandbox/os_system_blocked.yaml b/tests/scenarios/cmd/python/sandbox/os_system_blocked.yaml new file mode 100644 index 00000000..05f203fe --- /dev/null +++ b/tests/scenarios/cmd/python/sandbox/os_system_blocked.yaml @@ -0,0 +1,9 @@ +description: os.system() is blocked and raises AttributeError. +skip_assert_against_bash: true +input: + script: |+ + python -c "import os; os.system('id')" +expect: + stdout: |+ + stderr_contains: ["AttributeError"] + exit_code: 1 diff --git a/tests/scenarios/cmd/python/stdin/read_from_stdin.yaml b/tests/scenarios/cmd/python/stdin/read_from_stdin.yaml new file mode 100644 index 00000000..c5a173f7 --- /dev/null +++ b/tests/scenarios/cmd/python/stdin/read_from_stdin.yaml @@ -0,0 +1,10 @@ +description: python reads source from stdin when invoked with '-'. +skip_assert_against_bash: true +input: + script: |+ + echo "print('from stdin')" | python - +expect: + stdout: |+ + from stdin + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/unknown_cmd/common_progs/python.yaml b/tests/scenarios/cmd/unknown_cmd/common_progs/python.yaml index e3f386ae..9d4d0ce5 100644 --- a/tests/scenarios/cmd/unknown_cmd/common_progs/python.yaml +++ b/tests/scenarios/cmd/unknown_cmd/common_progs/python.yaml @@ -1,11 +1,11 @@ -# skip: rshell reports different error format than bash for unavailable commands +# python is now a builtin in rshell; update this test accordingly. skip_assert_against_bash: true -description: The python command is not a builtin and is rejected as unknown. +description: The python command is a builtin that executes Python 3 code. input: script: |+ python -c "print('hello')" expect: - stdout: "" + stdout: |+ + hello stderr: |+ - python: command not found - exit_code: 127 + exit_code: 0 From 897491cdfd6bf12efa7d3a9470e3bfd09b25cd19 Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Sat, 11 Apr 2026 22:26:28 +0200 Subject: [PATCH 3/4] test(python): add comprehensive scenario tests for python builtin Co-Authored-By: Claude Sonnet 4.6 --- .../cmd/python/basic/binascii_module.yaml | 10 +++++ tests/scenarios/cmd/python/basic/classes.yaml | 31 ++++++++++++++ .../cmd/python/basic/dict_operations.yaml | 23 +++++++++++ .../cmd/python/basic/file_binary_read.yaml | 16 ++++++++ .../cmd/python/basic/file_readline.yaml | 20 +++++++++ .../cmd/python/basic/file_readlines.yaml | 19 +++++++++ .../scenarios/cmd/python/basic/for_loop.yaml | 20 +++++++++ .../scenarios/cmd/python/basic/functions.yaml | 22 ++++++++++ tests/scenarios/cmd/python/basic/if_else.yaml | 23 +++++++++++ .../cmd/python/basic/list_operations.yaml | 11 +++++ .../cmd/python/basic/math_module.yaml | 11 +++++ .../cmd/python/basic/os_read_only.yaml | 10 +++++ .../cmd/python/basic/string_module.yaml | 10 +++++ .../cmd/python/basic/string_operations.yaml | 12 ++++++ .../cmd/python/basic/sys_argv_inline.yaml | 11 +++++ .../cmd/python/basic/sys_argv_script.yaml | 21 ++++++++++ .../cmd/python/basic/sys_exit_string.yaml | 9 ++++ .../cmd/python/basic/try_except.yaml | 22 ++++++++++ .../cmd/python/basic/while_loop.yaml | 22 ++++++++++ .../cmd/python/complex/csv_parser.yaml | 37 +++++++++++++++++ .../cmd/python/complex/exception_chain.yaml | 33 +++++++++++++++ .../cmd/python/complex/fibonacci.yaml | 30 ++++++++++++++ .../cmd/python/complex/inheritance.yaml | 39 ++++++++++++++++++ .../cmd/python/complex/matrix_multiply.yaml | 31 ++++++++++++++ .../cmd/python/complex/multi_file.yaml | 31 ++++++++++++++ tests/scenarios/cmd/python/complex/sieve.yaml | 41 +++++++++++++++++++ .../cmd/python/complex/word_count.yaml | 30 ++++++++++++++ .../cmd/python/errors/index_error.yaml | 9 ++++ .../cmd/python/errors/key_error.yaml | 9 ++++ .../cmd/python/errors/name_error.yaml | 9 ++++ .../cmd/python/errors/type_error.yaml | 9 ++++ .../python/errors/zero_division_error.yaml | 9 ++++ .../cmd/python/sandbox/closed_file_io.yaml | 15 +++++++ .../cmd/python/sandbox/glob_blocked.yaml | 9 ++++ .../sandbox/open_exclusive_blocked.yaml | 9 ++++ .../sandbox/open_readwrite_blocked.yaml | 9 ++++ .../cmd/python/sandbox/os_chmod_blocked.yaml | 9 ++++ .../cmd/python/sandbox/os_kill_blocked.yaml | 9 ++++ .../cmd/python/sandbox/os_mkdir_blocked.yaml | 9 ++++ .../cmd/python/sandbox/os_putenv_blocked.yaml | 9 ++++ .../cmd/python/sandbox/os_rename_blocked.yaml | 9 ++++ .../python/sandbox/os_symlink_blocked.yaml | 9 ++++ .../cmd/python/sandbox/os_unlink_blocked.yaml | 9 ++++ .../cmd/python/sandbox/tempfile_blocked.yaml | 9 ++++ .../shell_integration/exit_code_in_if.yaml | 14 +++++++ .../python/shell_integration/pipe_output.yaml | 10 +++++ .../shell_integration/script_with_argv.yaml | 21 ++++++++++ .../cmd/python/stdin/no_args_reads_stdin.yaml | 10 +++++ .../cmd/python/stdin/sys_stdin_read.yaml | 10 +++++ .../cmd/python/stdin/sys_stdin_readline.yaml | 10 +++++ 50 files changed, 829 insertions(+) create mode 100644 tests/scenarios/cmd/python/basic/binascii_module.yaml create mode 100644 tests/scenarios/cmd/python/basic/classes.yaml create mode 100644 tests/scenarios/cmd/python/basic/dict_operations.yaml create mode 100644 tests/scenarios/cmd/python/basic/file_binary_read.yaml create mode 100644 tests/scenarios/cmd/python/basic/file_readline.yaml create mode 100644 tests/scenarios/cmd/python/basic/file_readlines.yaml create mode 100644 tests/scenarios/cmd/python/basic/for_loop.yaml create mode 100644 tests/scenarios/cmd/python/basic/functions.yaml create mode 100644 tests/scenarios/cmd/python/basic/if_else.yaml create mode 100644 tests/scenarios/cmd/python/basic/list_operations.yaml create mode 100644 tests/scenarios/cmd/python/basic/math_module.yaml create mode 100644 tests/scenarios/cmd/python/basic/os_read_only.yaml create mode 100644 tests/scenarios/cmd/python/basic/string_module.yaml create mode 100644 tests/scenarios/cmd/python/basic/string_operations.yaml create mode 100644 tests/scenarios/cmd/python/basic/sys_argv_inline.yaml create mode 100644 tests/scenarios/cmd/python/basic/sys_argv_script.yaml create mode 100644 tests/scenarios/cmd/python/basic/sys_exit_string.yaml create mode 100644 tests/scenarios/cmd/python/basic/try_except.yaml create mode 100644 tests/scenarios/cmd/python/basic/while_loop.yaml create mode 100644 tests/scenarios/cmd/python/complex/csv_parser.yaml create mode 100644 tests/scenarios/cmd/python/complex/exception_chain.yaml create mode 100644 tests/scenarios/cmd/python/complex/fibonacci.yaml create mode 100644 tests/scenarios/cmd/python/complex/inheritance.yaml create mode 100644 tests/scenarios/cmd/python/complex/matrix_multiply.yaml create mode 100644 tests/scenarios/cmd/python/complex/multi_file.yaml create mode 100644 tests/scenarios/cmd/python/complex/sieve.yaml create mode 100644 tests/scenarios/cmd/python/complex/word_count.yaml create mode 100644 tests/scenarios/cmd/python/errors/index_error.yaml create mode 100644 tests/scenarios/cmd/python/errors/key_error.yaml create mode 100644 tests/scenarios/cmd/python/errors/name_error.yaml create mode 100644 tests/scenarios/cmd/python/errors/type_error.yaml create mode 100644 tests/scenarios/cmd/python/errors/zero_division_error.yaml create mode 100644 tests/scenarios/cmd/python/sandbox/closed_file_io.yaml create mode 100644 tests/scenarios/cmd/python/sandbox/glob_blocked.yaml create mode 100644 tests/scenarios/cmd/python/sandbox/open_exclusive_blocked.yaml create mode 100644 tests/scenarios/cmd/python/sandbox/open_readwrite_blocked.yaml create mode 100644 tests/scenarios/cmd/python/sandbox/os_chmod_blocked.yaml create mode 100644 tests/scenarios/cmd/python/sandbox/os_kill_blocked.yaml create mode 100644 tests/scenarios/cmd/python/sandbox/os_mkdir_blocked.yaml create mode 100644 tests/scenarios/cmd/python/sandbox/os_putenv_blocked.yaml create mode 100644 tests/scenarios/cmd/python/sandbox/os_rename_blocked.yaml create mode 100644 tests/scenarios/cmd/python/sandbox/os_symlink_blocked.yaml create mode 100644 tests/scenarios/cmd/python/sandbox/os_unlink_blocked.yaml create mode 100644 tests/scenarios/cmd/python/sandbox/tempfile_blocked.yaml create mode 100644 tests/scenarios/cmd/python/shell_integration/exit_code_in_if.yaml create mode 100644 tests/scenarios/cmd/python/shell_integration/pipe_output.yaml create mode 100644 tests/scenarios/cmd/python/shell_integration/script_with_argv.yaml create mode 100644 tests/scenarios/cmd/python/stdin/no_args_reads_stdin.yaml create mode 100644 tests/scenarios/cmd/python/stdin/sys_stdin_read.yaml create mode 100644 tests/scenarios/cmd/python/stdin/sys_stdin_readline.yaml diff --git a/tests/scenarios/cmd/python/basic/binascii_module.yaml b/tests/scenarios/cmd/python/basic/binascii_module.yaml new file mode 100644 index 00000000..4e673b07 --- /dev/null +++ b/tests/scenarios/cmd/python/basic/binascii_module.yaml @@ -0,0 +1,10 @@ +description: python can import and use the binascii module for hex encoding. +skip_assert_against_bash: true +input: + script: |+ + python -c "import binascii; binascii.hexlify(b'AB'); print('ok')" +expect: + stdout: |+ + ok + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/python/basic/classes.yaml b/tests/scenarios/cmd/python/basic/classes.yaml new file mode 100644 index 00000000..422ea5aa --- /dev/null +++ b/tests/scenarios/cmd/python/basic/classes.yaml @@ -0,0 +1,31 @@ +description: python supports class definitions with __init__ and instance methods. +skip_assert_against_bash: true +setup: + files: + - path: counter.py + content: |+ + class Counter: + def __init__(self): + self.count = 0 + + def increment(self): + self.count += 1 + + def value(self): + return self.count + + c = Counter() + c.increment() + c.increment() + c.increment() + print(c.value()) + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + python counter.py +expect: + stdout: |+ + 3 + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/python/basic/dict_operations.yaml b/tests/scenarios/cmd/python/basic/dict_operations.yaml new file mode 100644 index 00000000..13743ebf --- /dev/null +++ b/tests/scenarios/cmd/python/basic/dict_operations.yaml @@ -0,0 +1,23 @@ +description: python supports dict creation, key access, and mutation. +skip_assert_against_bash: true +setup: + files: + - path: dicts.py + content: |+ + d = {'name': 'rshell', 'version': 1} + print(d['name']) + print(d['version']) + d['extra'] = 'ok' + print(len(d)) + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + python dicts.py +expect: + stdout: |+ + rshell + 1 + 3 + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/python/basic/file_binary_read.yaml b/tests/scenarios/cmd/python/basic/file_binary_read.yaml new file mode 100644 index 00000000..97902b49 --- /dev/null +++ b/tests/scenarios/cmd/python/basic/file_binary_read.yaml @@ -0,0 +1,16 @@ +description: open() in binary mode ('rb') reads file content without error. +skip_assert_against_bash: true +setup: + files: + - path: data.bin + content: "hello" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + python -c "f = open('data.bin', 'rb'); f.read(); f.close(); print('ok')" +expect: + stdout: |+ + ok + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/python/basic/file_readline.yaml b/tests/scenarios/cmd/python/basic/file_readline.yaml new file mode 100644 index 00000000..89228808 --- /dev/null +++ b/tests/scenarios/cmd/python/basic/file_readline.yaml @@ -0,0 +1,20 @@ +description: open().readline() reads one line at a time from a file. +skip_assert_against_bash: true +setup: + files: + - path: lines.txt + content: |+ + first + second + third + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + python -c "f = open('lines.txt'); print(f.readline().strip()); print(f.readline().strip()); f.close()" +expect: + stdout: |+ + first + second + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/python/basic/file_readlines.yaml b/tests/scenarios/cmd/python/basic/file_readlines.yaml new file mode 100644 index 00000000..6d6065d5 --- /dev/null +++ b/tests/scenarios/cmd/python/basic/file_readlines.yaml @@ -0,0 +1,19 @@ +description: open().readlines() returns all lines of a file as a list. +skip_assert_against_bash: true +setup: + files: + - path: items.txt + content: |+ + alpha + beta + gamma + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + python -c "lines = open('items.txt').readlines(); print(len(lines))" +expect: + stdout: |+ + 3 + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/python/basic/for_loop.yaml b/tests/scenarios/cmd/python/basic/for_loop.yaml new file mode 100644 index 00000000..9e2f8518 --- /dev/null +++ b/tests/scenarios/cmd/python/basic/for_loop.yaml @@ -0,0 +1,20 @@ +description: python executes for loops over a range. +skip_assert_against_bash: true +setup: + files: + - path: loop.py + content: |+ + for i in range(1, 4): + print(i) + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + python loop.py +expect: + stdout: |+ + 1 + 2 + 3 + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/python/basic/functions.yaml b/tests/scenarios/cmd/python/basic/functions.yaml new file mode 100644 index 00000000..ce5e0e83 --- /dev/null +++ b/tests/scenarios/cmd/python/basic/functions.yaml @@ -0,0 +1,22 @@ +description: python supports defining and calling user-defined functions. +skip_assert_against_bash: true +setup: + files: + - path: funcs.py + content: |+ + def multiply(a, b): + return a * b + + print(multiply(3, 4)) + print(multiply(2, 5)) + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + python funcs.py +expect: + stdout: |+ + 12 + 10 + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/python/basic/if_else.yaml b/tests/scenarios/cmd/python/basic/if_else.yaml new file mode 100644 index 00000000..3e808402 --- /dev/null +++ b/tests/scenarios/cmd/python/basic/if_else.yaml @@ -0,0 +1,23 @@ +description: python evaluates if/elif/else branches correctly. +skip_assert_against_bash: true +setup: + files: + - path: branch.py + content: |+ + x = 7 + if x > 10: + print("large") + elif x > 5: + print("medium") + else: + print("small") + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + python branch.py +expect: + stdout: |+ + medium + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/python/basic/list_operations.yaml b/tests/scenarios/cmd/python/basic/list_operations.yaml new file mode 100644 index 00000000..46e667e2 --- /dev/null +++ b/tests/scenarios/cmd/python/basic/list_operations.yaml @@ -0,0 +1,11 @@ +description: python supports list operations including append, len, and indexing. +skip_assert_against_bash: true +input: + script: |+ + python -c "lst = [10, 20, 30]; lst.append(40); print(len(lst)); print(lst[-1])" +expect: + stdout: |+ + 4 + 40 + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/python/basic/math_module.yaml b/tests/scenarios/cmd/python/basic/math_module.yaml new file mode 100644 index 00000000..1dbeb13c --- /dev/null +++ b/tests/scenarios/cmd/python/basic/math_module.yaml @@ -0,0 +1,11 @@ +description: python can import and use the math module. +skip_assert_against_bash: true +input: + script: |+ + python -c "import math; print(math.floor(3.7)); print(int(math.sqrt(16)))" +expect: + stdout: |+ + 3 + 4 + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/python/basic/os_read_only.yaml b/tests/scenarios/cmd/python/basic/os_read_only.yaml new file mode 100644 index 00000000..284735cb --- /dev/null +++ b/tests/scenarios/cmd/python/basic/os_read_only.yaml @@ -0,0 +1,10 @@ +description: python can use read-only os functions such as os.path.join and os.getenv. +skip_assert_against_bash: true +input: + script: |+ + python -c "import os; print(os.getenv('NONEXISTENT_KEY_XYZ', 'default'))" +expect: + stdout: |+ + default + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/python/basic/string_module.yaml b/tests/scenarios/cmd/python/basic/string_module.yaml new file mode 100644 index 00000000..24c1527c --- /dev/null +++ b/tests/scenarios/cmd/python/basic/string_module.yaml @@ -0,0 +1,10 @@ +description: python can import the string module and access character set constants. +skip_assert_against_bash: true +input: + script: |+ + python -c "import string; print(string.digits)" +expect: + stdout: |+ + 0123456789 + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/python/basic/string_operations.yaml b/tests/scenarios/cmd/python/basic/string_operations.yaml new file mode 100644 index 00000000..398ba856 --- /dev/null +++ b/tests/scenarios/cmd/python/basic/string_operations.yaml @@ -0,0 +1,12 @@ +description: python supports string operations including concatenation, repetition, and slicing. +skip_assert_against_bash: true +input: + script: |+ + python -c "s = 'hello'; print(s + ' world'); print(s[1:4]); print(s * 2)" +expect: + stdout: |+ + hello world + ell + hellohello + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/python/basic/sys_argv_inline.yaml b/tests/scenarios/cmd/python/basic/sys_argv_inline.yaml new file mode 100644 index 00000000..99adc9f9 --- /dev/null +++ b/tests/scenarios/cmd/python/basic/sys_argv_inline.yaml @@ -0,0 +1,11 @@ +description: python -c with extra arguments populates sys.argv with as argv[0]. +skip_assert_against_bash: true +input: + script: |+ + python -c "import sys; print(sys.argv[0]); print(sys.argv[1])" hello +expect: + stdout: |+ + + hello + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/python/basic/sys_argv_script.yaml b/tests/scenarios/cmd/python/basic/sys_argv_script.yaml new file mode 100644 index 00000000..8e980df0 --- /dev/null +++ b/tests/scenarios/cmd/python/basic/sys_argv_script.yaml @@ -0,0 +1,21 @@ +description: extra arguments after the script name are available in sys.argv. +skip_assert_against_bash: true +setup: + files: + - path: show_args.py + content: |+ + import sys + for arg in sys.argv: + print(arg) + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + python show_args.py foo bar +expect: + stdout: |+ + show_args.py + foo + bar + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/python/basic/sys_exit_string.yaml b/tests/scenarios/cmd/python/basic/sys_exit_string.yaml new file mode 100644 index 00000000..6187448a --- /dev/null +++ b/tests/scenarios/cmd/python/basic/sys_exit_string.yaml @@ -0,0 +1,9 @@ +description: sys.exit() with a non-integer argument exits with code 1. +skip_assert_against_bash: true +input: + script: |+ + python -c "import sys; sys.exit('fatal error')" +expect: + stdout: |+ + stderr: |+ + exit_code: 1 diff --git a/tests/scenarios/cmd/python/basic/try_except.yaml b/tests/scenarios/cmd/python/basic/try_except.yaml new file mode 100644 index 00000000..ce1ecae1 --- /dev/null +++ b/tests/scenarios/cmd/python/basic/try_except.yaml @@ -0,0 +1,22 @@ +description: python catches exceptions with try/except and continues execution. +skip_assert_against_bash: true +setup: + files: + - path: exc.py + content: |+ + try: + raise ValueError("test error") + except ValueError as e: + print("caught:", e) + print("done") + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + python exc.py +expect: + stdout: |+ + caught: test error + done + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/python/basic/while_loop.yaml b/tests/scenarios/cmd/python/basic/while_loop.yaml new file mode 100644 index 00000000..e4b14709 --- /dev/null +++ b/tests/scenarios/cmd/python/basic/while_loop.yaml @@ -0,0 +1,22 @@ +description: python executes while loops with a counter. +skip_assert_against_bash: true +setup: + files: + - path: while.py + content: |+ + n = 0 + while n < 3: + print(n) + n += 1 + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + python while.py +expect: + stdout: |+ + 0 + 1 + 2 + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/python/complex/csv_parser.yaml b/tests/scenarios/cmd/python/complex/csv_parser.yaml new file mode 100644 index 00000000..e28af003 --- /dev/null +++ b/tests/scenarios/cmd/python/complex/csv_parser.yaml @@ -0,0 +1,37 @@ +description: python script parses a CSV file and computes column sums. +skip_assert_against_bash: true +setup: + files: + - path: data.csv + content: |+ + name,score + alice,85 + bob,92 + carol,78 + chmod: 0644 + - path: csv_sum.py + content: |+ + total = 0 + count = 0 + with open('data.csv') as f: + lines = f.read().split('\n') + for line in lines[1:]: + if line: + parts = line.split(',') + total = total + int(parts[1]) + count = count + 1 + print('total: ' + str(total)) + print('count: ' + str(count)) + print('average: ' + str(total // count)) + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + python csv_sum.py +expect: + stdout: |+ + total: 255 + count: 3 + average: 85 + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/python/complex/exception_chain.yaml b/tests/scenarios/cmd/python/complex/exception_chain.yaml new file mode 100644 index 00000000..2db0597f --- /dev/null +++ b/tests/scenarios/cmd/python/complex/exception_chain.yaml @@ -0,0 +1,33 @@ +description: python script uses nested try/except blocks and reraises exceptions. +skip_assert_against_bash: true +setup: + files: + - path: exc_chain.py + content: |+ + def parse_int(s): + try: + return int(s) + except ValueError: + raise ValueError('not a number: ' + s) + + results = [] + for token in ['42', 'bad', '7']: + try: + results.append(parse_int(token)) + except ValueError as e: + results.append(-1) + + for r in results: + print(r) + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + python exc_chain.py +expect: + stdout: |+ + 42 + -1 + 7 + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/python/complex/fibonacci.yaml b/tests/scenarios/cmd/python/complex/fibonacci.yaml new file mode 100644 index 00000000..0c178078 --- /dev/null +++ b/tests/scenarios/cmd/python/complex/fibonacci.yaml @@ -0,0 +1,30 @@ +description: python script computes Fibonacci numbers using recursion. +skip_assert_against_bash: true +setup: + files: + - path: fib.py + content: |+ + def fib(n): + if n <= 1: + return n + return fib(n - 1) + fib(n - 2) + + for i in range(8): + print(fib(i)) + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + python fib.py +expect: + stdout: |+ + 0 + 1 + 1 + 2 + 3 + 5 + 8 + 13 + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/python/complex/inheritance.yaml b/tests/scenarios/cmd/python/complex/inheritance.yaml new file mode 100644 index 00000000..bfa90986 --- /dev/null +++ b/tests/scenarios/cmd/python/complex/inheritance.yaml @@ -0,0 +1,39 @@ +description: python script uses class inheritance with method overriding. +skip_assert_against_bash: true +setup: + files: + - path: shapes.py + content: |+ + class Shape: + def area(self): + return 0 + + def describe(self): + return 'Shape with area ' + str(self.area()) + + class Rectangle(Shape): + def __init__(self, w, h): + self.w = w + self.h = h + + def area(self): + return self.w * self.h + + class Square(Rectangle): + def __init__(self, side): + Rectangle.__init__(self, side, side) + + shapes = [Rectangle(3, 4), Square(5)] + for s in shapes: + print(s.describe()) + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + python shapes.py +expect: + stdout: |+ + Shape with area 12 + Shape with area 25 + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/python/complex/matrix_multiply.yaml b/tests/scenarios/cmd/python/complex/matrix_multiply.yaml new file mode 100644 index 00000000..155c6209 --- /dev/null +++ b/tests/scenarios/cmd/python/complex/matrix_multiply.yaml @@ -0,0 +1,31 @@ +description: python script multiplies two 2x2 matrices using nested lists. +skip_assert_against_bash: true +setup: + files: + - path: matmul.py + content: |+ + def matmul(A, B): + n = len(A) + C = [[0] * n for _ in range(n)] + for i in range(n): + for j in range(n): + for k in range(n): + C[i][j] = C[i][j] + A[i][k] * B[k][j] + return C + + A = [[1, 2], [3, 4]] + B = [[5, 6], [7, 8]] + C = matmul(A, B) + for row in C: + print(str(row[0]) + ' ' + str(row[1])) + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + python matmul.py +expect: + stdout: |+ + 19 22 + 43 50 + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/python/complex/multi_file.yaml b/tests/scenarios/cmd/python/complex/multi_file.yaml new file mode 100644 index 00000000..10f9c106 --- /dev/null +++ b/tests/scenarios/cmd/python/complex/multi_file.yaml @@ -0,0 +1,31 @@ +description: python script processes multiple input files passed via sys.argv. +skip_assert_against_bash: true +setup: + files: + - path: a.txt + content: "10\n20\n30\n" + chmod: 0644 + - path: b.txt + content: "5\n15\n" + chmod: 0644 + - path: sum_files.py + content: |+ + import sys + total = 0 + for path in sys.argv[1:]: + with open(path) as f: + for line in f.readlines(): + line = line.strip() + if line: + total = total + int(line) + print(total) + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + python sum_files.py a.txt b.txt +expect: + stdout: |+ + 80 + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/python/complex/sieve.yaml b/tests/scenarios/cmd/python/complex/sieve.yaml new file mode 100644 index 00000000..08f97094 --- /dev/null +++ b/tests/scenarios/cmd/python/complex/sieve.yaml @@ -0,0 +1,41 @@ +description: python script implements the Sieve of Eratosthenes to find primes. +skip_assert_against_bash: true +setup: + files: + - path: sieve.py + content: |+ + def sieve(n): + is_prime = [True] * (n + 1) + is_prime[0] = False + is_prime[1] = False + i = 2 + while i * i <= n: + if is_prime[i]: + j = i * i + while j <= n: + is_prime[j] = False + j = j + i + i = i + 1 + return [x for x in range(n + 1) if is_prime[x]] + + for p in sieve(30): + print(p) + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + python sieve.py +expect: + stdout: |+ + 2 + 3 + 5 + 7 + 11 + 13 + 17 + 19 + 23 + 29 + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/python/complex/word_count.yaml b/tests/scenarios/cmd/python/complex/word_count.yaml new file mode 100644 index 00000000..fdd9a35d --- /dev/null +++ b/tests/scenarios/cmd/python/complex/word_count.yaml @@ -0,0 +1,30 @@ +description: python script reads a file and counts word frequencies. +skip_assert_against_bash: true +setup: + files: + - path: text.txt + content: "apple banana apple cherry banana apple\n" + chmod: 0644 + - path: wc.py + content: |+ + counts = {} + with open('text.txt') as f: + for word in f.read().split(): + if word in counts: + counts[word] = counts[word] + 1 + else: + counts[word] = 1 + for word in ['apple', 'banana', 'cherry']: + print(word + ': ' + str(counts[word])) + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + python wc.py +expect: + stdout: |+ + apple: 3 + banana: 2 + cherry: 1 + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/python/errors/index_error.yaml b/tests/scenarios/cmd/python/errors/index_error.yaml new file mode 100644 index 00000000..9d141608 --- /dev/null +++ b/tests/scenarios/cmd/python/errors/index_error.yaml @@ -0,0 +1,9 @@ +description: IndexError from out-of-bounds list access is reported to stderr and exits with code 1. +skip_assert_against_bash: true +input: + script: |+ + python -c "lst = [1, 2, 3]; print(lst[10])" +expect: + stdout: |+ + stderr_contains: ["IndexError"] + exit_code: 1 diff --git a/tests/scenarios/cmd/python/errors/key_error.yaml b/tests/scenarios/cmd/python/errors/key_error.yaml new file mode 100644 index 00000000..5616af08 --- /dev/null +++ b/tests/scenarios/cmd/python/errors/key_error.yaml @@ -0,0 +1,9 @@ +description: KeyError from missing dict key is reported to stderr and exits with code 1. +skip_assert_against_bash: true +input: + script: |+ + python -c "d = {'a': 1}; print(d['b'])" +expect: + stdout: |+ + stderr_contains: ["KeyError"] + exit_code: 1 diff --git a/tests/scenarios/cmd/python/errors/name_error.yaml b/tests/scenarios/cmd/python/errors/name_error.yaml new file mode 100644 index 00000000..c691c043 --- /dev/null +++ b/tests/scenarios/cmd/python/errors/name_error.yaml @@ -0,0 +1,9 @@ +description: NameError from an undefined variable is reported to stderr and exits with code 1. +skip_assert_against_bash: true +input: + script: |+ + python -c "print(undefined_variable)" +expect: + stdout: |+ + stderr_contains: ["NameError"] + exit_code: 1 diff --git a/tests/scenarios/cmd/python/errors/type_error.yaml b/tests/scenarios/cmd/python/errors/type_error.yaml new file mode 100644 index 00000000..bb397752 --- /dev/null +++ b/tests/scenarios/cmd/python/errors/type_error.yaml @@ -0,0 +1,9 @@ +description: TypeError from incompatible operand types is reported to stderr and exits with code 1. +skip_assert_against_bash: true +input: + script: |+ + python -c "print('hello' + 42)" +expect: + stdout: |+ + stderr_contains: ["TypeError"] + exit_code: 1 diff --git a/tests/scenarios/cmd/python/errors/zero_division_error.yaml b/tests/scenarios/cmd/python/errors/zero_division_error.yaml new file mode 100644 index 00000000..bdd0af16 --- /dev/null +++ b/tests/scenarios/cmd/python/errors/zero_division_error.yaml @@ -0,0 +1,9 @@ +description: ZeroDivisionError is reported to stderr and exits with code 1. +skip_assert_against_bash: true +input: + script: |+ + python -c "print(1 / 0)" +expect: + stdout: |+ + stderr_contains: ["ZeroDivisionError"] + exit_code: 1 diff --git a/tests/scenarios/cmd/python/sandbox/closed_file_io.yaml b/tests/scenarios/cmd/python/sandbox/closed_file_io.yaml new file mode 100644 index 00000000..b86cd590 --- /dev/null +++ b/tests/scenarios/cmd/python/sandbox/closed_file_io.yaml @@ -0,0 +1,15 @@ +description: I/O operations on a closed file object raise ValueError. +skip_assert_against_bash: true +setup: + files: + - path: test.txt + content: "hello\n" + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + python -c "f = open('test.txt'); f.close(); f.read()" +expect: + stdout: |+ + stderr_contains: ["ValueError"] + exit_code: 1 diff --git a/tests/scenarios/cmd/python/sandbox/glob_blocked.yaml b/tests/scenarios/cmd/python/sandbox/glob_blocked.yaml new file mode 100644 index 00000000..324074af --- /dev/null +++ b/tests/scenarios/cmd/python/sandbox/glob_blocked.yaml @@ -0,0 +1,9 @@ +description: glob module functions are blocked and raise AttributeError when called. +skip_assert_against_bash: true +input: + script: |+ + python -c "import glob; glob.glob('*')" +expect: + stdout: |+ + stderr_contains: ["AttributeError"] + exit_code: 1 diff --git a/tests/scenarios/cmd/python/sandbox/open_exclusive_blocked.yaml b/tests/scenarios/cmd/python/sandbox/open_exclusive_blocked.yaml new file mode 100644 index 00000000..483b916d --- /dev/null +++ b/tests/scenarios/cmd/python/sandbox/open_exclusive_blocked.yaml @@ -0,0 +1,9 @@ +description: open() in exclusive creation mode ('x') raises PermissionError. +skip_assert_against_bash: true +input: + script: |+ + python -c "open('/tmp/new.txt', 'x')" +expect: + stdout: |+ + stderr_contains: ["PermissionError"] + exit_code: 1 diff --git a/tests/scenarios/cmd/python/sandbox/open_readwrite_blocked.yaml b/tests/scenarios/cmd/python/sandbox/open_readwrite_blocked.yaml new file mode 100644 index 00000000..42c4beee --- /dev/null +++ b/tests/scenarios/cmd/python/sandbox/open_readwrite_blocked.yaml @@ -0,0 +1,9 @@ +description: open() in read-write mode ('r+') raises PermissionError. +skip_assert_against_bash: true +input: + script: |+ + python -c "open('/tmp/test.txt', 'r+')" +expect: + stdout: |+ + stderr_contains: ["PermissionError"] + exit_code: 1 diff --git a/tests/scenarios/cmd/python/sandbox/os_chmod_blocked.yaml b/tests/scenarios/cmd/python/sandbox/os_chmod_blocked.yaml new file mode 100644 index 00000000..a99e6127 --- /dev/null +++ b/tests/scenarios/cmd/python/sandbox/os_chmod_blocked.yaml @@ -0,0 +1,9 @@ +description: os.chmod() is blocked and raises AttributeError. +skip_assert_against_bash: true +input: + script: |+ + python -c "import os; os.chmod('/tmp/test', 0o755)" +expect: + stdout: |+ + stderr_contains: ["AttributeError"] + exit_code: 1 diff --git a/tests/scenarios/cmd/python/sandbox/os_kill_blocked.yaml b/tests/scenarios/cmd/python/sandbox/os_kill_blocked.yaml new file mode 100644 index 00000000..7399039e --- /dev/null +++ b/tests/scenarios/cmd/python/sandbox/os_kill_blocked.yaml @@ -0,0 +1,9 @@ +description: os.kill() is blocked and raises AttributeError. +skip_assert_against_bash: true +input: + script: |+ + python -c "import os; os.kill(1, 9)" +expect: + stdout: |+ + stderr_contains: ["AttributeError"] + exit_code: 1 diff --git a/tests/scenarios/cmd/python/sandbox/os_mkdir_blocked.yaml b/tests/scenarios/cmd/python/sandbox/os_mkdir_blocked.yaml new file mode 100644 index 00000000..dfb0a4d4 --- /dev/null +++ b/tests/scenarios/cmd/python/sandbox/os_mkdir_blocked.yaml @@ -0,0 +1,9 @@ +description: os.mkdir() is blocked and raises AttributeError. +skip_assert_against_bash: true +input: + script: |+ + python -c "import os; os.mkdir('/tmp/testdir')" +expect: + stdout: |+ + stderr_contains: ["AttributeError"] + exit_code: 1 diff --git a/tests/scenarios/cmd/python/sandbox/os_putenv_blocked.yaml b/tests/scenarios/cmd/python/sandbox/os_putenv_blocked.yaml new file mode 100644 index 00000000..a7f7a87a --- /dev/null +++ b/tests/scenarios/cmd/python/sandbox/os_putenv_blocked.yaml @@ -0,0 +1,9 @@ +description: os.putenv() is blocked and raises AttributeError. +skip_assert_against_bash: true +input: + script: |+ + python -c "import os; os.putenv('KEY', 'val')" +expect: + stdout: |+ + stderr_contains: ["AttributeError"] + exit_code: 1 diff --git a/tests/scenarios/cmd/python/sandbox/os_rename_blocked.yaml b/tests/scenarios/cmd/python/sandbox/os_rename_blocked.yaml new file mode 100644 index 00000000..4c34b507 --- /dev/null +++ b/tests/scenarios/cmd/python/sandbox/os_rename_blocked.yaml @@ -0,0 +1,9 @@ +description: os.rename() is blocked and raises AttributeError. +skip_assert_against_bash: true +input: + script: |+ + python -c "import os; os.rename('/tmp/a', '/tmp/b')" +expect: + stdout: |+ + stderr_contains: ["AttributeError"] + exit_code: 1 diff --git a/tests/scenarios/cmd/python/sandbox/os_symlink_blocked.yaml b/tests/scenarios/cmd/python/sandbox/os_symlink_blocked.yaml new file mode 100644 index 00000000..38f174bc --- /dev/null +++ b/tests/scenarios/cmd/python/sandbox/os_symlink_blocked.yaml @@ -0,0 +1,9 @@ +description: os.symlink() is blocked and raises AttributeError. +skip_assert_against_bash: true +input: + script: |+ + python -c "import os; os.symlink('/tmp/src', '/tmp/dst')" +expect: + stdout: |+ + stderr_contains: ["AttributeError"] + exit_code: 1 diff --git a/tests/scenarios/cmd/python/sandbox/os_unlink_blocked.yaml b/tests/scenarios/cmd/python/sandbox/os_unlink_blocked.yaml new file mode 100644 index 00000000..9a7c4afa --- /dev/null +++ b/tests/scenarios/cmd/python/sandbox/os_unlink_blocked.yaml @@ -0,0 +1,9 @@ +description: os.unlink() is blocked and raises AttributeError. +skip_assert_against_bash: true +input: + script: |+ + python -c "import os; os.unlink('/tmp/test')" +expect: + stdout: |+ + stderr_contains: ["AttributeError"] + exit_code: 1 diff --git a/tests/scenarios/cmd/python/sandbox/tempfile_blocked.yaml b/tests/scenarios/cmd/python/sandbox/tempfile_blocked.yaml new file mode 100644 index 00000000..7298e82e --- /dev/null +++ b/tests/scenarios/cmd/python/sandbox/tempfile_blocked.yaml @@ -0,0 +1,9 @@ +description: tempfile module functions are blocked and fail when called. +skip_assert_against_bash: true +input: + script: |+ + python -c "import tempfile; tempfile.mkstemp()" +expect: + stdout: |+ + stderr_contains: ["AttributeError"] + exit_code: 1 diff --git a/tests/scenarios/cmd/python/shell_integration/exit_code_in_if.yaml b/tests/scenarios/cmd/python/shell_integration/exit_code_in_if.yaml new file mode 100644 index 00000000..b4e99cdd --- /dev/null +++ b/tests/scenarios/cmd/python/shell_integration/exit_code_in_if.yaml @@ -0,0 +1,14 @@ +description: python exit code can be used to branch in a shell if statement. +skip_assert_against_bash: true +input: + script: |+ + if python -c "import sys; sys.exit(1)"; then + echo "success" + else + echo "failure" + fi +expect: + stdout: |+ + failure + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/python/shell_integration/pipe_output.yaml b/tests/scenarios/cmd/python/shell_integration/pipe_output.yaml new file mode 100644 index 00000000..682f2c1b --- /dev/null +++ b/tests/scenarios/cmd/python/shell_integration/pipe_output.yaml @@ -0,0 +1,10 @@ +description: python output can be piped as stdin to another python invocation. +skip_assert_against_bash: true +input: + script: |+ + python -c "print('hello world')" | python -c "import sys; data = sys.stdin.read().strip(); print('got: ' + data)" +expect: + stdout: |+ + got: hello world + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/python/shell_integration/script_with_argv.yaml b/tests/scenarios/cmd/python/shell_integration/script_with_argv.yaml new file mode 100644 index 00000000..3fd25bad --- /dev/null +++ b/tests/scenarios/cmd/python/shell_integration/script_with_argv.yaml @@ -0,0 +1,21 @@ +description: positional arguments after the script name are passed as sys.argv[1:]. +skip_assert_against_bash: true +setup: + files: + - path: args.py + content: |+ + import sys + for arg in sys.argv[1:]: + print(arg) + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + python args.py alpha beta gamma +expect: + stdout: |+ + alpha + beta + gamma + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/python/stdin/no_args_reads_stdin.yaml b/tests/scenarios/cmd/python/stdin/no_args_reads_stdin.yaml new file mode 100644 index 00000000..62862fe8 --- /dev/null +++ b/tests/scenarios/cmd/python/stdin/no_args_reads_stdin.yaml @@ -0,0 +1,10 @@ +description: python without any arguments reads Python source from stdin. +skip_assert_against_bash: true +input: + script: |+ + echo "print('no dash')" | python +expect: + stdout: |+ + no dash + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/python/stdin/sys_stdin_read.yaml b/tests/scenarios/cmd/python/stdin/sys_stdin_read.yaml new file mode 100644 index 00000000..82ce8f82 --- /dev/null +++ b/tests/scenarios/cmd/python/stdin/sys_stdin_read.yaml @@ -0,0 +1,10 @@ +description: Python code can read all of stdin via sys.stdin.read(). +skip_assert_against_bash: true +input: + script: |+ + echo "hello" | python -c "import sys; data = sys.stdin.read(); print(data.strip())" +expect: + stdout: |+ + hello + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/python/stdin/sys_stdin_readline.yaml b/tests/scenarios/cmd/python/stdin/sys_stdin_readline.yaml new file mode 100644 index 00000000..06ca63b9 --- /dev/null +++ b/tests/scenarios/cmd/python/stdin/sys_stdin_readline.yaml @@ -0,0 +1,10 @@ +description: Python code can read a single line from sys.stdin using readline(). +skip_assert_against_bash: true +input: + script: |+ + printf "first\nsecond\n" | python -c "import sys; line = sys.stdin.readline(); print(line.strip())" +expect: + stdout: |+ + first + stderr: |+ + exit_code: 0 From ed144637a9100acd90388ac0243149ae73189945 Mon Sep 17 00:00:00 2001 From: Alexandre Yang Date: Sat, 11 Apr 2026 22:52:06 +0200 Subject: [PATCH 4/4] test(python): add comprehensive scenario tests for gpython features, builtins, and keywords Adds 61 new scenario tests across 10 categories to improve coverage of the python builtin's gpython (3.4) interpreter: - keywords: pass, del, assert, global, nonlocal, in/not-in, is/is-not, break, continue - comprehensions: list, filtered list, dict, set, generator expression, nested - generators: basic yield, generator.send(), yield from, StopIteration - lambdas: basic, sorted key, map - builtins: len, range, enumerate, zip, map, filter, sorted, all/any, min/max, sum, chr/ord, bin/hex/oct, isinstance, type constructors, repr, print kwargs, getattr/setattr/hasattr, abs/divmod/pow - exceptions: try/finally, try/except/finally, bare raise, raise from, multiple except handlers - operators: bitwise, augmented assignment, chained comparisons, ternary, boolean short-circuit - data_structures: tuple unpacking, extended unpacking, set operations, string format (%), string methods - functions: default args, *args, **kwargs - os_module: os.getcwd(), os.environ Tests account for gpython v0.2.0 limitations: no str.format(), no str.lower/upper, no len(bytes), no frozenset(), no classmethod/staticmethod, no closures (free variable capture without nonlocal), no integer dict keys, no enumerate(start=). Co-Authored-By: Claude Sonnet 4.6 --- .../cmd/python/builtins/abs_divmod_pow.yaml | 27 ++++++++++++++ .../cmd/python/builtins/all_any.yaml | 27 ++++++++++++++ .../cmd/python/builtins/bin_hex_oct.yaml | 27 ++++++++++++++ .../cmd/python/builtins/chr_ord.yaml | 25 +++++++++++++ .../cmd/python/builtins/enumerate.yaml | 26 ++++++++++++++ .../scenarios/cmd/python/builtins/filter.yaml | 10 ++++++ .../builtins/getattr_setattr_hasattr.yaml | 33 +++++++++++++++++ .../cmd/python/builtins/isinstance.yaml | 27 ++++++++++++++ tests/scenarios/cmd/python/builtins/len.yaml | 25 +++++++++++++ tests/scenarios/cmd/python/builtins/map.yaml | 10 ++++++ .../cmd/python/builtins/min_max.yaml | 29 +++++++++++++++ .../cmd/python/builtins/print_kwargs.yaml | 22 ++++++++++++ .../cmd/python/builtins/range_forms.yaml | 23 ++++++++++++ tests/scenarios/cmd/python/builtins/repr.yaml | 25 +++++++++++++ .../cmd/python/builtins/sorted_key.yaml | 22 ++++++++++++ tests/scenarios/cmd/python/builtins/sum.yaml | 22 ++++++++++++ .../python/builtins/type_constructors.yaml | 30 ++++++++++++++++ tests/scenarios/cmd/python/builtins/zip.yaml | 22 ++++++++++++ .../comprehensions/dict_comprehension.yaml | 22 ++++++++++++ .../comprehensions/generator_expression.yaml | 10 ++++++ .../comprehensions/list_comprehension.yaml | 10 ++++++ .../list_comprehension_filtered.yaml | 10 ++++++ .../nested_list_comprehension.yaml | 10 ++++++ .../comprehensions/set_comprehension.yaml | 10 ++++++ .../data_structures/extended_unpacking.yaml | 28 +++++++++++++++ .../data_structures/set_operations.yaml | 30 ++++++++++++++++ .../string_format_percent.yaml | 26 ++++++++++++++ .../data_structures/string_methods.yaml | 29 +++++++++++++++ .../data_structures/tuple_unpacking.yaml | 27 ++++++++++++++ .../cmd/python/exceptions/bare_raise.yaml | 28 +++++++++++++++ .../exceptions/multiple_except_handlers.yaml | 36 +++++++++++++++++++ .../cmd/python/exceptions/raise_from.yaml | 23 ++++++++++++ .../python/exceptions/try_except_finally.yaml | 25 +++++++++++++ .../cmd/python/exceptions/try_finally.yaml | 27 ++++++++++++++ .../cmd/python/functions/default_args.yaml | 24 +++++++++++++ .../cmd/python/functions/kwargs_function.yaml | 23 ++++++++++++ .../cmd/python/functions/varargs.yaml | 35 ++++++++++++++++++ .../cmd/python/generators/basic_yield.yaml | 25 +++++++++++++ .../cmd/python/generators/generator_send.yaml | 28 +++++++++++++++ .../generators/generator_stopiteration.yaml | 29 +++++++++++++++ .../cmd/python/generators/yield_from.yaml | 28 +++++++++++++++ .../cmd/python/keywords/assert_fails.yaml | 20 +++++++++++ .../cmd/python/keywords/assert_passes.yaml | 10 ++++++ .../cmd/python/keywords/break_nested.yaml | 23 ++++++++++++ .../cmd/python/keywords/continue_nested.yaml | 26 ++++++++++++++ .../cmd/python/keywords/del_statement.yaml | 26 ++++++++++++++ .../cmd/python/keywords/global_statement.yaml | 26 ++++++++++++++ .../cmd/python/keywords/in_not_in.yaml | 30 ++++++++++++++++ .../cmd/python/keywords/is_is_not.yaml | 31 ++++++++++++++++ .../python/keywords/nonlocal_statement.yaml | 30 ++++++++++++++++ .../cmd/python/keywords/pass_statement.yaml | 30 ++++++++++++++++ .../cmd/python/lambdas/lambda_basic.yaml | 21 +++++++++++ .../cmd/python/lambdas/lambda_map.yaml | 10 ++++++ .../cmd/python/lambdas/lambda_sorted.yaml | 23 ++++++++++++ .../operators/augmented_assignment.yaml | 34 ++++++++++++++++++ .../cmd/python/operators/bitwise.yaml | 29 +++++++++++++++ .../operators/boolean_short_circuit.yaml | 33 +++++++++++++++++ .../python/operators/chained_comparisons.yaml | 26 ++++++++++++++ .../cmd/python/operators/ternary.yaml | 31 ++++++++++++++++ .../cmd/python/os_module/os_environ.yaml | 23 ++++++++++++ .../cmd/python/os_module/os_getcwd.yaml | 19 ++++++++++ 61 files changed, 1476 insertions(+) create mode 100644 tests/scenarios/cmd/python/builtins/abs_divmod_pow.yaml create mode 100644 tests/scenarios/cmd/python/builtins/all_any.yaml create mode 100644 tests/scenarios/cmd/python/builtins/bin_hex_oct.yaml create mode 100644 tests/scenarios/cmd/python/builtins/chr_ord.yaml create mode 100644 tests/scenarios/cmd/python/builtins/enumerate.yaml create mode 100644 tests/scenarios/cmd/python/builtins/filter.yaml create mode 100644 tests/scenarios/cmd/python/builtins/getattr_setattr_hasattr.yaml create mode 100644 tests/scenarios/cmd/python/builtins/isinstance.yaml create mode 100644 tests/scenarios/cmd/python/builtins/len.yaml create mode 100644 tests/scenarios/cmd/python/builtins/map.yaml create mode 100644 tests/scenarios/cmd/python/builtins/min_max.yaml create mode 100644 tests/scenarios/cmd/python/builtins/print_kwargs.yaml create mode 100644 tests/scenarios/cmd/python/builtins/range_forms.yaml create mode 100644 tests/scenarios/cmd/python/builtins/repr.yaml create mode 100644 tests/scenarios/cmd/python/builtins/sorted_key.yaml create mode 100644 tests/scenarios/cmd/python/builtins/sum.yaml create mode 100644 tests/scenarios/cmd/python/builtins/type_constructors.yaml create mode 100644 tests/scenarios/cmd/python/builtins/zip.yaml create mode 100644 tests/scenarios/cmd/python/comprehensions/dict_comprehension.yaml create mode 100644 tests/scenarios/cmd/python/comprehensions/generator_expression.yaml create mode 100644 tests/scenarios/cmd/python/comprehensions/list_comprehension.yaml create mode 100644 tests/scenarios/cmd/python/comprehensions/list_comprehension_filtered.yaml create mode 100644 tests/scenarios/cmd/python/comprehensions/nested_list_comprehension.yaml create mode 100644 tests/scenarios/cmd/python/comprehensions/set_comprehension.yaml create mode 100644 tests/scenarios/cmd/python/data_structures/extended_unpacking.yaml create mode 100644 tests/scenarios/cmd/python/data_structures/set_operations.yaml create mode 100644 tests/scenarios/cmd/python/data_structures/string_format_percent.yaml create mode 100644 tests/scenarios/cmd/python/data_structures/string_methods.yaml create mode 100644 tests/scenarios/cmd/python/data_structures/tuple_unpacking.yaml create mode 100644 tests/scenarios/cmd/python/exceptions/bare_raise.yaml create mode 100644 tests/scenarios/cmd/python/exceptions/multiple_except_handlers.yaml create mode 100644 tests/scenarios/cmd/python/exceptions/raise_from.yaml create mode 100644 tests/scenarios/cmd/python/exceptions/try_except_finally.yaml create mode 100644 tests/scenarios/cmd/python/exceptions/try_finally.yaml create mode 100644 tests/scenarios/cmd/python/functions/default_args.yaml create mode 100644 tests/scenarios/cmd/python/functions/kwargs_function.yaml create mode 100644 tests/scenarios/cmd/python/functions/varargs.yaml create mode 100644 tests/scenarios/cmd/python/generators/basic_yield.yaml create mode 100644 tests/scenarios/cmd/python/generators/generator_send.yaml create mode 100644 tests/scenarios/cmd/python/generators/generator_stopiteration.yaml create mode 100644 tests/scenarios/cmd/python/generators/yield_from.yaml create mode 100644 tests/scenarios/cmd/python/keywords/assert_fails.yaml create mode 100644 tests/scenarios/cmd/python/keywords/assert_passes.yaml create mode 100644 tests/scenarios/cmd/python/keywords/break_nested.yaml create mode 100644 tests/scenarios/cmd/python/keywords/continue_nested.yaml create mode 100644 tests/scenarios/cmd/python/keywords/del_statement.yaml create mode 100644 tests/scenarios/cmd/python/keywords/global_statement.yaml create mode 100644 tests/scenarios/cmd/python/keywords/in_not_in.yaml create mode 100644 tests/scenarios/cmd/python/keywords/is_is_not.yaml create mode 100644 tests/scenarios/cmd/python/keywords/nonlocal_statement.yaml create mode 100644 tests/scenarios/cmd/python/keywords/pass_statement.yaml create mode 100644 tests/scenarios/cmd/python/lambdas/lambda_basic.yaml create mode 100644 tests/scenarios/cmd/python/lambdas/lambda_map.yaml create mode 100644 tests/scenarios/cmd/python/lambdas/lambda_sorted.yaml create mode 100644 tests/scenarios/cmd/python/operators/augmented_assignment.yaml create mode 100644 tests/scenarios/cmd/python/operators/bitwise.yaml create mode 100644 tests/scenarios/cmd/python/operators/boolean_short_circuit.yaml create mode 100644 tests/scenarios/cmd/python/operators/chained_comparisons.yaml create mode 100644 tests/scenarios/cmd/python/operators/ternary.yaml create mode 100644 tests/scenarios/cmd/python/os_module/os_environ.yaml create mode 100644 tests/scenarios/cmd/python/os_module/os_getcwd.yaml diff --git a/tests/scenarios/cmd/python/builtins/abs_divmod_pow.yaml b/tests/scenarios/cmd/python/builtins/abs_divmod_pow.yaml new file mode 100644 index 00000000..bd534e36 --- /dev/null +++ b/tests/scenarios/cmd/python/builtins/abs_divmod_pow.yaml @@ -0,0 +1,27 @@ +description: python abs(), divmod(), and pow() perform absolute value, combined division/modulo, and exponentiation. +skip_assert_against_bash: true +setup: + files: + - path: math_builtins.py + content: |+ + print(abs(-5)) + print(abs(3)) + print(divmod(10, 3)) + print(divmod(-7, 2)) + print(pow(2, 10)) + print(pow(3, 3, 10)) + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + python math_builtins.py +expect: + stdout: |+ + 5 + 3 + (3, 1) + (-4, 1) + 1024 + 7 + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/python/builtins/all_any.yaml b/tests/scenarios/cmd/python/builtins/all_any.yaml new file mode 100644 index 00000000..658a340e --- /dev/null +++ b/tests/scenarios/cmd/python/builtins/all_any.yaml @@ -0,0 +1,27 @@ +description: python all() and any() test whether all or any elements are truthy. +skip_assert_against_bash: true +setup: + files: + - path: allany.py + content: |+ + print(all([True, True, True])) + print(all([True, False, True])) + print(any([False, False, True])) + print(any([False, False, False])) + print(all(x > 0 for x in [1, 2, 3])) + print(any(x > 5 for x in [1, 2, 3])) + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + python allany.py +expect: + stdout: |+ + True + False + True + False + True + False + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/python/builtins/bin_hex_oct.yaml b/tests/scenarios/cmd/python/builtins/bin_hex_oct.yaml new file mode 100644 index 00000000..69b598ff --- /dev/null +++ b/tests/scenarios/cmd/python/builtins/bin_hex_oct.yaml @@ -0,0 +1,27 @@ +description: python bin(), hex(), and oct() convert integers to binary, hex, and octal strings. +skip_assert_against_bash: true +setup: + files: + - path: binhexoct.py + content: |+ + print(bin(10)) + print(bin(255)) + print(hex(255)) + print(hex(16)) + print(oct(8)) + print(oct(64)) + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + python binhexoct.py +expect: + stdout: |+ + 0b1010 + 0b11111111 + 0xff + 0x10 + 0o10 + 0o100 + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/python/builtins/chr_ord.yaml b/tests/scenarios/cmd/python/builtins/chr_ord.yaml new file mode 100644 index 00000000..1c6fb53b --- /dev/null +++ b/tests/scenarios/cmd/python/builtins/chr_ord.yaml @@ -0,0 +1,25 @@ +description: python chr() converts an integer to a character and ord() converts back. +skip_assert_against_bash: true +setup: + files: + - path: chrord.py + content: |+ + print(chr(65)) + print(chr(97)) + print(ord('A')) + print(ord('a')) + print(chr(ord('Z') + 1)) + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + python chrord.py +expect: + stdout: |+ + A + a + 65 + 97 + [ + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/python/builtins/enumerate.yaml b/tests/scenarios/cmd/python/builtins/enumerate.yaml new file mode 100644 index 00000000..218fabff --- /dev/null +++ b/tests/scenarios/cmd/python/builtins/enumerate.yaml @@ -0,0 +1,26 @@ +description: python enumerate() adds an index counter to an iterable, with optional positional start offset. +skip_assert_against_bash: true +setup: + files: + - path: enumerate.py + content: |+ + fruits = ["apple", "banana", "cherry"] + for i, fruit in enumerate(fruits): + print(i, fruit) + for i, fruit in enumerate(fruits, 1): + print(i, fruit) + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + python enumerate.py +expect: + stdout: |+ + 0 apple + 1 banana + 2 cherry + 1 apple + 2 banana + 3 cherry + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/python/builtins/filter.yaml b/tests/scenarios/cmd/python/builtins/filter.yaml new file mode 100644 index 00000000..c3b5f541 --- /dev/null +++ b/tests/scenarios/cmd/python/builtins/filter.yaml @@ -0,0 +1,10 @@ +description: python filter() selects elements from an iterable for which a function returns true. +skip_assert_against_bash: true +input: + script: |+ + python -c "nums = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; evens = list(filter(lambda x: x % 2 == 0, nums)); print(evens)" +expect: + stdout: |+ + [2, 4, 6, 8, 10] + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/python/builtins/getattr_setattr_hasattr.yaml b/tests/scenarios/cmd/python/builtins/getattr_setattr_hasattr.yaml new file mode 100644 index 00000000..309715b6 --- /dev/null +++ b/tests/scenarios/cmd/python/builtins/getattr_setattr_hasattr.yaml @@ -0,0 +1,33 @@ +description: python getattr/setattr/hasattr/delattr read, set, check, and remove object attributes. +skip_assert_against_bash: true +setup: + files: + - path: attrs.py + content: |+ + class Point: + def __init__(self, x, y): + self.x = x + self.y = y + + p = Point(3, 4) + print(getattr(p, 'x')) + print(hasattr(p, 'z')) + setattr(p, 'z', 7) + print(getattr(p, 'z')) + print(hasattr(p, 'z')) + delattr(p, 'z') + print(hasattr(p, 'z')) + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + python attrs.py +expect: + stdout: |+ + 3 + False + 7 + True + False + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/python/builtins/isinstance.yaml b/tests/scenarios/cmd/python/builtins/isinstance.yaml new file mode 100644 index 00000000..3ff2010f --- /dev/null +++ b/tests/scenarios/cmd/python/builtins/isinstance.yaml @@ -0,0 +1,27 @@ +description: python isinstance() checks whether an object is an instance of a type or tuple of types. +skip_assert_against_bash: true +setup: + files: + - path: isinstance.py + content: |+ + print(isinstance(42, int)) + print(isinstance("hello", str)) + print(isinstance(3.14, float)) + print(isinstance([1, 2], list)) + print(isinstance(42, (int, str))) + print(isinstance("x", (int, str))) + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + python isinstance.py +expect: + stdout: |+ + True + True + True + True + True + True + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/python/builtins/len.yaml b/tests/scenarios/cmd/python/builtins/len.yaml new file mode 100644 index 00000000..19818ad3 --- /dev/null +++ b/tests/scenarios/cmd/python/builtins/len.yaml @@ -0,0 +1,25 @@ +description: python len() returns the number of items in lists, strings, dicts, tuples, and sets. +skip_assert_against_bash: true +setup: + files: + - path: len.py + content: |+ + print(len([1, 2, 3])) + print(len("hello")) + print(len({"a": 1, "b": 2})) + print(len((1, 2, 3, 4))) + print(len({1, 2, 3})) + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + python len.py +expect: + stdout: |+ + 3 + 5 + 2 + 4 + 3 + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/python/builtins/map.yaml b/tests/scenarios/cmd/python/builtins/map.yaml new file mode 100644 index 00000000..46cb8fb9 --- /dev/null +++ b/tests/scenarios/cmd/python/builtins/map.yaml @@ -0,0 +1,10 @@ +description: python map() applies a function to each element of an iterable. +skip_assert_against_bash: true +input: + script: |+ + python -c "nums = [1, 2, 3, 4, 5]; strs = list(map(str, nums)); print(strs)" +expect: + stdout: |+ + ['1', '2', '3', '4', '5'] + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/python/builtins/min_max.yaml b/tests/scenarios/cmd/python/builtins/min_max.yaml new file mode 100644 index 00000000..1d77eef7 --- /dev/null +++ b/tests/scenarios/cmd/python/builtins/min_max.yaml @@ -0,0 +1,29 @@ +description: python min() and max() find the smallest/largest value with optional key function. +skip_assert_against_bash: true +setup: + files: + - path: minmax.py + content: |+ + nums = [3, 1, 4, 1, 5, 9, 2, 6] + print(min(nums)) + print(max(nums)) + words = ["apple", "fig", "banana"] + print(min(words, key=lambda w: len(w))) + print(max(words, key=lambda w: len(w))) + print(min(3, 7, 1, 9)) + print(max(3, 7, 1, 9)) + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + python minmax.py +expect: + stdout: |+ + 1 + 9 + fig + banana + 1 + 9 + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/python/builtins/print_kwargs.yaml b/tests/scenarios/cmd/python/builtins/print_kwargs.yaml new file mode 100644 index 00000000..5e355aee --- /dev/null +++ b/tests/scenarios/cmd/python/builtins/print_kwargs.yaml @@ -0,0 +1,22 @@ +description: python print() supports sep and end keyword arguments to control output formatting. +skip_assert_against_bash: true +setup: + files: + - path: printkw.py + content: |+ + print("a", "b", "c", sep="-") + print("hello", end="") + print(" world") + print("x", "y", sep="") + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + python printkw.py +expect: + stdout: |+ + a-b-c + hello world + xy + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/python/builtins/range_forms.yaml b/tests/scenarios/cmd/python/builtins/range_forms.yaml new file mode 100644 index 00000000..69b0c06f --- /dev/null +++ b/tests/scenarios/cmd/python/builtins/range_forms.yaml @@ -0,0 +1,23 @@ +description: python range() supports stop-only, start/stop, start/stop/step, and negative step forms. +skip_assert_against_bash: true +setup: + files: + - path: range.py + content: |+ + print(list(range(5))) + print(list(range(2, 6))) + print(list(range(0, 10, 3))) + print(list(range(5, 0, -1))) + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + python range.py +expect: + stdout: |+ + [0, 1, 2, 3, 4] + [2, 3, 4, 5] + [0, 3, 6, 9] + [5, 4, 3, 2, 1] + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/python/builtins/repr.yaml b/tests/scenarios/cmd/python/builtins/repr.yaml new file mode 100644 index 00000000..7937ce50 --- /dev/null +++ b/tests/scenarios/cmd/python/builtins/repr.yaml @@ -0,0 +1,25 @@ +description: python repr() returns a developer-readable string representation of an object. +skip_assert_against_bash: true +setup: + files: + - path: repr.py + content: |+ + print(repr("hello")) + print(repr(42)) + print(repr([1, 2, 3])) + print(repr(None)) + print(repr(True)) + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + python repr.py +expect: + stdout: |+ + 'hello' + 42 + [1, 2, 3] + None + True + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/python/builtins/sorted_key.yaml b/tests/scenarios/cmd/python/builtins/sorted_key.yaml new file mode 100644 index 00000000..890315b0 --- /dev/null +++ b/tests/scenarios/cmd/python/builtins/sorted_key.yaml @@ -0,0 +1,22 @@ +description: python sorted() supports key function and reverse flag for custom ordering. +skip_assert_against_bash: true +setup: + files: + - path: sorted.py + content: |+ + words = ["banana", "fig", "apple", "date", "cherry"] + print(sorted(words)) + print(sorted(words, key=len)) + print(sorted(words, reverse=True)) + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + python sorted.py +expect: + stdout: |+ + ['apple', 'banana', 'cherry', 'date', 'fig'] + ['fig', 'date', 'apple', 'banana', 'cherry'] + ['fig', 'date', 'cherry', 'banana', 'apple'] + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/python/builtins/sum.yaml b/tests/scenarios/cmd/python/builtins/sum.yaml new file mode 100644 index 00000000..84ab1239 --- /dev/null +++ b/tests/scenarios/cmd/python/builtins/sum.yaml @@ -0,0 +1,22 @@ +description: python sum() adds all elements of an iterable with an optional start value. +skip_assert_against_bash: true +setup: + files: + - path: sum.py + content: |+ + nums = [1, 2, 3, 4, 5] + print(sum(nums)) + print(sum(nums, 10)) + print(sum(x * x for x in range(4))) + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + python sum.py +expect: + stdout: |+ + 15 + 25 + 14 + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/python/builtins/type_constructors.yaml b/tests/scenarios/cmd/python/builtins/type_constructors.yaml new file mode 100644 index 00000000..d0d6a3fd --- /dev/null +++ b/tests/scenarios/cmd/python/builtins/type_constructors.yaml @@ -0,0 +1,30 @@ +description: python type constructor functions convert between types. +skip_assert_against_bash: true +setup: + files: + - path: typecons.py + content: |+ + print(int("42")) + print(int(3.9)) + print(float("3.14")) + print(str(42)) + print(list((1, 2, 3))) + print(tuple([4, 5, 6])) + s = set([1, 2, 3, 2, 1]) + print(sorted(s)) + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + python typecons.py +expect: + stdout: |+ + 42 + 3 + 3.14 + 42 + [1, 2, 3] + (4, 5, 6) + [1, 2, 3] + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/python/builtins/zip.yaml b/tests/scenarios/cmd/python/builtins/zip.yaml new file mode 100644 index 00000000..90991e45 --- /dev/null +++ b/tests/scenarios/cmd/python/builtins/zip.yaml @@ -0,0 +1,22 @@ +description: python zip() pairs up elements from multiple iterables element-wise. +skip_assert_against_bash: true +setup: + files: + - path: zip.py + content: |+ + names = ["Alice", "Bob", "Charlie"] + scores = [95, 87, 92] + for name, score in zip(names, scores): + print(name, score) + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + python zip.py +expect: + stdout: |+ + Alice 95 + Bob 87 + Charlie 92 + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/python/comprehensions/dict_comprehension.yaml b/tests/scenarios/cmd/python/comprehensions/dict_comprehension.yaml new file mode 100644 index 00000000..66e9edd1 --- /dev/null +++ b/tests/scenarios/cmd/python/comprehensions/dict_comprehension.yaml @@ -0,0 +1,22 @@ +description: python dict comprehension builds a dict mapping string keys to computed values. +skip_assert_against_bash: true +setup: + files: + - path: dictcomp.py + content: |+ + words = ["apple", "banana", "cherry"] + word_lens = {w: len(w) for w in words} + for k in sorted(word_lens.keys()): + print(k, word_lens[k]) + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + python dictcomp.py +expect: + stdout: |+ + apple 5 + banana 6 + cherry 6 + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/python/comprehensions/generator_expression.yaml b/tests/scenarios/cmd/python/comprehensions/generator_expression.yaml new file mode 100644 index 00000000..644991e8 --- /dev/null +++ b/tests/scenarios/cmd/python/comprehensions/generator_expression.yaml @@ -0,0 +1,10 @@ +description: python generator expression lazily computes values and is consumed by sum. +skip_assert_against_bash: true +input: + script: |+ + python -c "total = sum(x * x for x in range(5)); print(total)" +expect: + stdout: |+ + 30 + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/python/comprehensions/list_comprehension.yaml b/tests/scenarios/cmd/python/comprehensions/list_comprehension.yaml new file mode 100644 index 00000000..0931e48e --- /dev/null +++ b/tests/scenarios/cmd/python/comprehensions/list_comprehension.yaml @@ -0,0 +1,10 @@ +description: python list comprehension builds a list by applying an expression to each element. +skip_assert_against_bash: true +input: + script: |+ + python -c "squares = [x * x for x in range(5)]; print(squares)" +expect: + stdout: |+ + [0, 1, 4, 9, 16] + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/python/comprehensions/list_comprehension_filtered.yaml b/tests/scenarios/cmd/python/comprehensions/list_comprehension_filtered.yaml new file mode 100644 index 00000000..00fdc723 --- /dev/null +++ b/tests/scenarios/cmd/python/comprehensions/list_comprehension_filtered.yaml @@ -0,0 +1,10 @@ +description: python list comprehension with if filter selects matching elements. +skip_assert_against_bash: true +input: + script: |+ + python -c "evens = [x for x in range(10) if x % 2 == 0]; print(evens)" +expect: + stdout: |+ + [0, 2, 4, 6, 8] + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/python/comprehensions/nested_list_comprehension.yaml b/tests/scenarios/cmd/python/comprehensions/nested_list_comprehension.yaml new file mode 100644 index 00000000..500450c2 --- /dev/null +++ b/tests/scenarios/cmd/python/comprehensions/nested_list_comprehension.yaml @@ -0,0 +1,10 @@ +description: python nested list comprehension flattens a 2D matrix into a 1D list. +skip_assert_against_bash: true +input: + script: |+ + python -c "matrix = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]; flat = [x for row in matrix for x in row]; print(flat)" +expect: + stdout: |+ + [1, 2, 3, 4, 5, 6, 7, 8, 9] + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/python/comprehensions/set_comprehension.yaml b/tests/scenarios/cmd/python/comprehensions/set_comprehension.yaml new file mode 100644 index 00000000..7985dafe --- /dev/null +++ b/tests/scenarios/cmd/python/comprehensions/set_comprehension.yaml @@ -0,0 +1,10 @@ +description: python set comprehension builds a set of unique computed values. +skip_assert_against_bash: true +input: + script: |+ + python -c "s = {x % 3 for x in range(9)}; print(sorted(s))" +expect: + stdout: |+ + [0, 1, 2] + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/python/data_structures/extended_unpacking.yaml b/tests/scenarios/cmd/python/data_structures/extended_unpacking.yaml new file mode 100644 index 00000000..2bbb62fc --- /dev/null +++ b/tests/scenarios/cmd/python/data_structures/extended_unpacking.yaml @@ -0,0 +1,28 @@ +description: python extended unpacking with star expression captures remaining elements into a list. +skip_assert_against_bash: true +setup: + files: + - path: starpack.py + content: |+ + first, *rest = [1, 2, 3, 4, 5] + print(first) + print(rest) + *init, last = [1, 2, 3, 4, 5] + print(init) + print(last) + a, *b, c = [1, 2, 3, 4, 5] + print(a, b, c) + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + python starpack.py +expect: + stdout: |+ + 1 + [2, 3, 4, 5] + [1, 2, 3, 4] + 5 + 1 [2, 3, 4] 5 + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/python/data_structures/set_operations.yaml b/tests/scenarios/cmd/python/data_structures/set_operations.yaml new file mode 100644 index 00000000..b2273927 --- /dev/null +++ b/tests/scenarios/cmd/python/data_structures/set_operations.yaml @@ -0,0 +1,30 @@ +description: python set supports union, intersection, difference, and add operations. +skip_assert_against_bash: true +setup: + files: + - path: setops.py + content: |+ + a = {1, 2, 3, 4} + b = {3, 4, 5, 6} + print(sorted(a | b)) + print(sorted(a & b)) + print(sorted(a - b)) + print(sorted(b - a)) + a.add(7) + print(7 in a) + print(1 in a) + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + python setops.py +expect: + stdout: |+ + [1, 2, 3, 4, 5, 6] + [3, 4] + [1, 2] + [5, 6] + True + True + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/python/data_structures/string_format_percent.yaml b/tests/scenarios/cmd/python/data_structures/string_format_percent.yaml new file mode 100644 index 00000000..452ddc8b --- /dev/null +++ b/tests/scenarios/cmd/python/data_structures/string_format_percent.yaml @@ -0,0 +1,26 @@ +description: python % operator formats strings with positional substitution for strings, ints, and floats. +skip_assert_against_bash: true +setup: + files: + - path: fmtpct.py + content: |+ + name = "world" + n = 42 + pi = 3.14159 + print("Hello, %s!" % name) + print("Number: %d" % n) + print("Float: %.2f" % pi) + print("%s has %d items" % ("list", 5)) + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + python fmtpct.py +expect: + stdout: |+ + Hello, world! + Number: 42 + Float: 3.14 + list has 5 items + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/python/data_structures/string_methods.yaml b/tests/scenarios/cmd/python/data_structures/string_methods.yaml new file mode 100644 index 00000000..094b69fe --- /dev/null +++ b/tests/scenarios/cmd/python/data_structures/string_methods.yaml @@ -0,0 +1,29 @@ +description: python string methods strip, split, replace, find, startswith, endswith work correctly. +skip_assert_against_bash: true +setup: + files: + - path: strmethods.py + content: |+ + s = " Hello, World! " + print(s.strip()) + words = "one two three".split() + print(words) + print("hello world".replace("world", "Python")) + print("hello world".find("world")) + print("hello".startswith("hel")) + print("hello".endswith("lo")) + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + python strmethods.py +expect: + stdout: |+ + Hello, World! + ['one', 'two', 'three'] + hello Python + 6 + True + True + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/python/data_structures/tuple_unpacking.yaml b/tests/scenarios/cmd/python/data_structures/tuple_unpacking.yaml new file mode 100644 index 00000000..4e648b45 --- /dev/null +++ b/tests/scenarios/cmd/python/data_structures/tuple_unpacking.yaml @@ -0,0 +1,27 @@ +description: python tuple unpacking assigns multiple variables from a sequence in a single statement. +skip_assert_against_bash: true +setup: + files: + - path: unpack.py + content: |+ + a, b = 1, 2 + print(a, b) + x, y, z = (10, 20, 30) + print(x, y, z) + first, second = "ab" + print(first, second) + a, b = b, a + print(a, b) + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + python unpack.py +expect: + stdout: |+ + 1 2 + 10 20 30 + a b + 2 1 + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/python/exceptions/bare_raise.yaml b/tests/scenarios/cmd/python/exceptions/bare_raise.yaml new file mode 100644 index 00000000..45b84074 --- /dev/null +++ b/tests/scenarios/cmd/python/exceptions/bare_raise.yaml @@ -0,0 +1,28 @@ +description: python bare raise re-raises the current exception from within an except handler. +skip_assert_against_bash: true +setup: + files: + - path: bareraise.py + content: |+ + def risky(): + try: + raise RuntimeError("original") + except RuntimeError: + print("handling") + raise + + try: + risky() + except RuntimeError as e: + print("re-raised:", e) + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + python bareraise.py +expect: + stdout: |+ + handling + re-raised: original + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/python/exceptions/multiple_except_handlers.yaml b/tests/scenarios/cmd/python/exceptions/multiple_except_handlers.yaml new file mode 100644 index 00000000..8165c153 --- /dev/null +++ b/tests/scenarios/cmd/python/exceptions/multiple_except_handlers.yaml @@ -0,0 +1,36 @@ +description: python multiple except handlers match the first matching exception type, including tuple catch. +skip_assert_against_bash: true +setup: + files: + - path: multiexcept.py + content: |+ + for v in [1, 2, 0]: + try: + if v == 1: + raise ValueError("val") + elif v == 2: + raise TypeError("typ") + else: + print("ok") + except ValueError as e: + print("ValueError:", e) + except TypeError as e: + print("TypeError:", e) + + try: + raise IndexError("index") + except (ValueError, IndexError, KeyError) as e: + print("tuple catch:", e) + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + python multiexcept.py +expect: + stdout: |+ + ValueError: val + TypeError: typ + ok + tuple catch: index + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/python/exceptions/raise_from.yaml b/tests/scenarios/cmd/python/exceptions/raise_from.yaml new file mode 100644 index 00000000..67362d6d --- /dev/null +++ b/tests/scenarios/cmd/python/exceptions/raise_from.yaml @@ -0,0 +1,23 @@ +description: python raise X from Y chains exceptions, setting __cause__ on the new exception. +skip_assert_against_bash: true +setup: + files: + - path: raisefrom.py + content: |+ + try: + try: + int("not a number") + except ValueError as e: + raise TypeError("conversion failed") from e + except TypeError as e: + print("caught:", e) + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + python raisefrom.py +expect: + stdout: |+ + caught: conversion failed + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/python/exceptions/try_except_finally.yaml b/tests/scenarios/cmd/python/exceptions/try_except_finally.yaml new file mode 100644 index 00000000..356767f0 --- /dev/null +++ b/tests/scenarios/cmd/python/exceptions/try_except_finally.yaml @@ -0,0 +1,25 @@ +description: python try/except/finally catches the exception and runs cleanup in finally. +skip_assert_against_bash: true +setup: + files: + - path: excfinally.py + content: |+ + try: + raise ValueError("oops") + except ValueError as e: + print("caught:", e) + finally: + print("cleanup") + print("after") + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + python excfinally.py +expect: + stdout: |+ + caught: oops + cleanup + after + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/python/exceptions/try_finally.yaml b/tests/scenarios/cmd/python/exceptions/try_finally.yaml new file mode 100644 index 00000000..4299ebf4 --- /dev/null +++ b/tests/scenarios/cmd/python/exceptions/try_finally.yaml @@ -0,0 +1,27 @@ +description: python try/finally runs the finally block even when the try block returns. +skip_assert_against_bash: true +setup: + files: + - path: finally.py + content: |+ + def test(): + try: + print("try") + return 1 + finally: + print("finally") + + result = test() + print("result:", result) + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + python finally.py +expect: + stdout: |+ + try + finally + result: 1 + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/python/functions/default_args.yaml b/tests/scenarios/cmd/python/functions/default_args.yaml new file mode 100644 index 00000000..e7fd33dc --- /dev/null +++ b/tests/scenarios/cmd/python/functions/default_args.yaml @@ -0,0 +1,24 @@ +description: python function default argument values are used when the caller omits those arguments. +skip_assert_against_bash: true +setup: + files: + - path: defaults.py + content: |+ + def greet(name, greeting="Hello"): + print(greeting + ", " + name + "!") + + greet("Alice") + greet("Bob", "Hi") + greet("Charlie", greeting="Hey") + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + python defaults.py +expect: + stdout: |+ + Hello, Alice! + Hi, Bob! + Hey, Charlie! + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/python/functions/kwargs_function.yaml b/tests/scenarios/cmd/python/functions/kwargs_function.yaml new file mode 100644 index 00000000..3d4eec73 --- /dev/null +++ b/tests/scenarios/cmd/python/functions/kwargs_function.yaml @@ -0,0 +1,23 @@ +description: python **kwargs collects extra keyword arguments into a dict. +skip_assert_against_bash: true +setup: + files: + - path: kwargs.py + content: |+ + def describe(**kwargs): + for key in sorted(kwargs.keys()): + print(key + "=" + str(kwargs[key])) + + describe(name="Alice", age=30, city="NYC") + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + python kwargs.py +expect: + stdout: |+ + age=30 + city=NYC + name=Alice + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/python/functions/varargs.yaml b/tests/scenarios/cmd/python/functions/varargs.yaml new file mode 100644 index 00000000..b6ecfca6 --- /dev/null +++ b/tests/scenarios/cmd/python/functions/varargs.yaml @@ -0,0 +1,35 @@ +description: python *args collects extra positional arguments into a tuple. +skip_assert_against_bash: true +setup: + files: + - path: varargs.py + content: |+ + def sum_all(*args): + total = 0 + for x in args: + total += x + return total + + print(sum_all(1, 2, 3)) + print(sum_all(10, 20)) + print(sum_all()) + + def first_and_rest(first, *rest): + print(first) + print(list(rest)) + + first_and_rest(1, 2, 3, 4) + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + python varargs.py +expect: + stdout: |+ + 6 + 30 + 0 + 1 + [2, 3, 4] + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/python/generators/basic_yield.yaml b/tests/scenarios/cmd/python/generators/basic_yield.yaml new file mode 100644 index 00000000..0bd245ca --- /dev/null +++ b/tests/scenarios/cmd/python/generators/basic_yield.yaml @@ -0,0 +1,25 @@ +description: python generator function uses yield to produce a sequence of values. +skip_assert_against_bash: true +setup: + files: + - path: gen.py + content: |+ + def count_up(n): + for i in range(n): + yield i + + for val in count_up(4): + print(val) + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + python gen.py +expect: + stdout: |+ + 0 + 1 + 2 + 3 + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/python/generators/generator_send.yaml b/tests/scenarios/cmd/python/generators/generator_send.yaml new file mode 100644 index 00000000..d7166816 --- /dev/null +++ b/tests/scenarios/cmd/python/generators/generator_send.yaml @@ -0,0 +1,28 @@ +description: python generator send() passes a value into the generator at the yield point. +skip_assert_against_bash: true +setup: + files: + - path: gensend.py + content: |+ + def echo(): + while True: + val = yield + if val is None: + break + print("got:", val) + + g = echo() + next(g) + g.send("hello") + g.send("world") + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + python gensend.py +expect: + stdout: |+ + got: hello + got: world + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/python/generators/generator_stopiteration.yaml b/tests/scenarios/cmd/python/generators/generator_stopiteration.yaml new file mode 100644 index 00000000..5ffb8aa0 --- /dev/null +++ b/tests/scenarios/cmd/python/generators/generator_stopiteration.yaml @@ -0,0 +1,29 @@ +description: python generator raises StopIteration when exhausted and next() is called. +skip_assert_against_bash: true +setup: + files: + - path: stopiter.py + content: |+ + def one_two(): + yield 1 + yield 2 + + g = one_two() + print(next(g)) + print(next(g)) + try: + next(g) + except StopIteration: + print("stopped") + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + python stopiter.py +expect: + stdout: |+ + 1 + 2 + stopped + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/python/generators/yield_from.yaml b/tests/scenarios/cmd/python/generators/yield_from.yaml new file mode 100644 index 00000000..16b429cb --- /dev/null +++ b/tests/scenarios/cmd/python/generators/yield_from.yaml @@ -0,0 +1,28 @@ +description: python yield from delegates to a sub-generator, forwarding all its values. +skip_assert_against_bash: true +setup: + files: + - path: yieldfrom.py + content: |+ + def gen_a(): + yield 1 + yield 2 + + def gen_b(): + yield from gen_a() + yield 3 + + for v in gen_b(): + print(v) + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + python yieldfrom.py +expect: + stdout: |+ + 1 + 2 + 3 + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/python/keywords/assert_fails.yaml b/tests/scenarios/cmd/python/keywords/assert_fails.yaml new file mode 100644 index 00000000..d109107f --- /dev/null +++ b/tests/scenarios/cmd/python/keywords/assert_fails.yaml @@ -0,0 +1,20 @@ +description: python assert raises AssertionError with message when condition is false. +skip_assert_against_bash: true +setup: + files: + - path: assert_fail.py + content: |+ + try: + assert False, "assertion message" + except AssertionError as e: + print("AssertionError:", e) + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + python assert_fail.py +expect: + stdout: |+ + AssertionError: assertion message + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/python/keywords/assert_passes.yaml b/tests/scenarios/cmd/python/keywords/assert_passes.yaml new file mode 100644 index 00000000..69136fee --- /dev/null +++ b/tests/scenarios/cmd/python/keywords/assert_passes.yaml @@ -0,0 +1,10 @@ +description: python assert passes when condition is true. +skip_assert_against_bash: true +input: + script: |+ + python -c "assert True; assert 1 == 1; assert 'hello' != 'world'; print('all asserts passed')" +expect: + stdout: |+ + all asserts passed + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/python/keywords/break_nested.yaml b/tests/scenarios/cmd/python/keywords/break_nested.yaml new file mode 100644 index 00000000..22df2a64 --- /dev/null +++ b/tests/scenarios/cmd/python/keywords/break_nested.yaml @@ -0,0 +1,23 @@ +description: python break exits only the innermost loop in nested loops. +skip_assert_against_bash: true +setup: + files: + - path: break.py + content: |+ + for i in range(3): + for j in range(3): + if j == 1: + break + print(i, j) + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + python break.py +expect: + stdout: |+ + 0 0 + 1 0 + 2 0 + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/python/keywords/continue_nested.yaml b/tests/scenarios/cmd/python/keywords/continue_nested.yaml new file mode 100644 index 00000000..fb426caf --- /dev/null +++ b/tests/scenarios/cmd/python/keywords/continue_nested.yaml @@ -0,0 +1,26 @@ +description: python continue skips to the next iteration of only the innermost loop. +skip_assert_against_bash: true +setup: + files: + - path: continue.py + content: |+ + for i in range(3): + for j in range(3): + if j == 1: + continue + print(i, j) + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + python continue.py +expect: + stdout: |+ + 0 0 + 0 2 + 1 0 + 1 2 + 2 0 + 2 2 + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/python/keywords/del_statement.yaml b/tests/scenarios/cmd/python/keywords/del_statement.yaml new file mode 100644 index 00000000..dde9bf39 --- /dev/null +++ b/tests/scenarios/cmd/python/keywords/del_statement.yaml @@ -0,0 +1,26 @@ +description: python del statement removes variables and list elements. +skip_assert_against_bash: true +setup: + files: + - path: del.py + content: |+ + x = 42 + del x + try: + print(x) + except NameError: + print("x deleted") + lst = [1, 2, 3] + del lst[1] + print(lst) + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + python del.py +expect: + stdout: |+ + x deleted + [1, 3] + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/python/keywords/global_statement.yaml b/tests/scenarios/cmd/python/keywords/global_statement.yaml new file mode 100644 index 00000000..335a9627 --- /dev/null +++ b/tests/scenarios/cmd/python/keywords/global_statement.yaml @@ -0,0 +1,26 @@ +description: python global statement allows functions to modify module-level variables. +skip_assert_against_bash: true +setup: + files: + - path: global.py + content: |+ + counter = 0 + + def increment(): + global counter + counter += 1 + + increment() + increment() + increment() + print(counter) + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + python global.py +expect: + stdout: |+ + 3 + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/python/keywords/in_not_in.yaml b/tests/scenarios/cmd/python/keywords/in_not_in.yaml new file mode 100644 index 00000000..c7044cec --- /dev/null +++ b/tests/scenarios/cmd/python/keywords/in_not_in.yaml @@ -0,0 +1,30 @@ +description: python in and not in operators test membership in lists, dicts, and strings. +skip_assert_against_bash: true +setup: + files: + - path: membership.py + content: |+ + lst = [1, 2, 3, 4, 5] + print(3 in lst) + print(6 not in lst) + d = {"key": "value"} + print("key" in d) + print("missing" not in d) + s = "hello world" + print("world" in s) + print("xyz" not in s) + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + python membership.py +expect: + stdout: |+ + True + True + True + True + True + True + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/python/keywords/is_is_not.yaml b/tests/scenarios/cmd/python/keywords/is_is_not.yaml new file mode 100644 index 00000000..875ce402 --- /dev/null +++ b/tests/scenarios/cmd/python/keywords/is_is_not.yaml @@ -0,0 +1,31 @@ +description: python is and is not operators test object identity. +skip_assert_against_bash: true +setup: + files: + - path: identity.py + content: |+ + x = None + print(x is None) + print(x is not None) + a = [1, 2, 3] + b = a + c = [1, 2, 3] + print(a is b) + print(a is not c) + print(True is True) + print(False is not True) + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + python identity.py +expect: + stdout: |+ + True + False + True + True + True + True + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/python/keywords/nonlocal_statement.yaml b/tests/scenarios/cmd/python/keywords/nonlocal_statement.yaml new file mode 100644 index 00000000..fe7d7f91 --- /dev/null +++ b/tests/scenarios/cmd/python/keywords/nonlocal_statement.yaml @@ -0,0 +1,30 @@ +description: python nonlocal statement allows nested functions to modify enclosing scope variables. +skip_assert_against_bash: true +setup: + files: + - path: nonlocal.py + content: |+ + def make_counter(): + count = 0 + def increment(): + nonlocal count + count += 1 + return count + return increment + + c = make_counter() + print(c()) + print(c()) + print(c()) + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + python nonlocal.py +expect: + stdout: |+ + 1 + 2 + 3 + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/python/keywords/pass_statement.yaml b/tests/scenarios/cmd/python/keywords/pass_statement.yaml new file mode 100644 index 00000000..6b77f6b3 --- /dev/null +++ b/tests/scenarios/cmd/python/keywords/pass_statement.yaml @@ -0,0 +1,30 @@ +description: python pass statement works in if/for/while/def/class bodies. +skip_assert_against_bash: true +setup: + files: + - path: pass.py + content: |+ + if True: + pass + for i in range(3): + pass + n = 0 + while n < 2: + n += 1 + pass + def noop(): + pass + class Empty: + pass + noop() + print("done") + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + python pass.py +expect: + stdout: |+ + done + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/python/lambdas/lambda_basic.yaml b/tests/scenarios/cmd/python/lambdas/lambda_basic.yaml new file mode 100644 index 00000000..b0acdd23 --- /dev/null +++ b/tests/scenarios/cmd/python/lambdas/lambda_basic.yaml @@ -0,0 +1,21 @@ +description: python lambda creates anonymous single-expression functions. +skip_assert_against_bash: true +setup: + files: + - path: lambda.py + content: |+ + square = lambda x: x * x + add = lambda a, b: a + b + print(square(5)) + print(add(3, 4)) + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + python lambda.py +expect: + stdout: |+ + 25 + 7 + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/python/lambdas/lambda_map.yaml b/tests/scenarios/cmd/python/lambdas/lambda_map.yaml new file mode 100644 index 00000000..8c6dc976 --- /dev/null +++ b/tests/scenarios/cmd/python/lambdas/lambda_map.yaml @@ -0,0 +1,10 @@ +description: python lambda used with map() transforms each element of a list. +skip_assert_against_bash: true +input: + script: |+ + python -c "nums = [1, 2, 3, 4, 5]; doubled = list(map(lambda x: x * 2, nums)); print(doubled)" +expect: + stdout: |+ + [2, 4, 6, 8, 10] + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/python/lambdas/lambda_sorted.yaml b/tests/scenarios/cmd/python/lambdas/lambda_sorted.yaml new file mode 100644 index 00000000..cd6cf3e8 --- /dev/null +++ b/tests/scenarios/cmd/python/lambdas/lambda_sorted.yaml @@ -0,0 +1,23 @@ +description: python lambda used as key argument to sorted() orders by computed value. +skip_assert_against_bash: true +setup: + files: + - path: lambdasort.py + content: |+ + words = ["banana", "apple", "cherry", "date"] + sorted_words = sorted(words, key=lambda w: len(w)) + for w in sorted_words: + print(w) + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + python lambdasort.py +expect: + stdout: |+ + date + apple + banana + cherry + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/python/operators/augmented_assignment.yaml b/tests/scenarios/cmd/python/operators/augmented_assignment.yaml new file mode 100644 index 00000000..9662e186 --- /dev/null +++ b/tests/scenarios/cmd/python/operators/augmented_assignment.yaml @@ -0,0 +1,34 @@ +description: python augmented assignment operators update variables in-place. +skip_assert_against_bash: true +setup: + files: + - path: augmented.py + content: |+ + x = 10 + x += 5 + print(x) + x -= 3 + print(x) + x *= 2 + print(x) + x //= 3 + print(x) + x **= 2 + print(x) + x %= 7 + print(x) + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + python augmented.py +expect: + stdout: |+ + 15 + 12 + 24 + 8 + 64 + 1 + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/python/operators/bitwise.yaml b/tests/scenarios/cmd/python/operators/bitwise.yaml new file mode 100644 index 00000000..74f361ed --- /dev/null +++ b/tests/scenarios/cmd/python/operators/bitwise.yaml @@ -0,0 +1,29 @@ +description: python bitwise operators AND, OR, XOR, NOT, left shift, and right shift work on integers. +skip_assert_against_bash: true +setup: + files: + - path: bitwise.py + content: |+ + a = 0b1010 + b = 0b1100 + print(bin(a & b)) + print(bin(a | b)) + print(bin(a ^ b)) + print(bin(~a & 0xFF)) + print(bin(a << 2)) + print(bin(b >> 1)) + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + python bitwise.py +expect: + stdout: |+ + 0b1000 + 0b1110 + 0b110 + 0b11110101 + 0b101000 + 0b110 + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/python/operators/boolean_short_circuit.yaml b/tests/scenarios/cmd/python/operators/boolean_short_circuit.yaml new file mode 100644 index 00000000..9e06571c --- /dev/null +++ b/tests/scenarios/cmd/python/operators/boolean_short_circuit.yaml @@ -0,0 +1,33 @@ +description: python and/or operators short-circuit evaluation and return the determining operand. +skip_assert_against_bash: true +setup: + files: + - path: shortcircuit.py + content: |+ + def side_effect(val, msg): + print(msg) + return val + + print(False and side_effect(True, "should not print")) + print(True or side_effect(True, "should not print")) + print(True and side_effect(True, "and executed")) + print(False or side_effect(False, "or executed")) + print(not True) + print(not False) + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + python shortcircuit.py +expect: + stdout: |+ + False + True + and executed + True + or executed + False + False + True + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/python/operators/chained_comparisons.yaml b/tests/scenarios/cmd/python/operators/chained_comparisons.yaml new file mode 100644 index 00000000..841bc1de --- /dev/null +++ b/tests/scenarios/cmd/python/operators/chained_comparisons.yaml @@ -0,0 +1,26 @@ +description: python chained comparisons evaluate multiple comparisons without repeating operands. +skip_assert_against_bash: true +setup: + files: + - path: chained.py + content: |+ + x = 5 + print(1 < x < 10) + print(1 < x < 4) + print(0 <= x <= 5) + print(1 < 2 < 3 < 4) + print(1 == 1 == 2) + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + python chained.py +expect: + stdout: |+ + True + False + True + True + False + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/python/operators/ternary.yaml b/tests/scenarios/cmd/python/operators/ternary.yaml new file mode 100644 index 00000000..aef685a8 --- /dev/null +++ b/tests/scenarios/cmd/python/operators/ternary.yaml @@ -0,0 +1,31 @@ +description: python ternary (conditional) expression selects one of two values based on a condition. +skip_assert_against_bash: true +setup: + files: + - path: ternary.py + content: |+ + x = 10 + result = "positive" if x > 0 else "non-positive" + print(result) + + def abs_val(n): + return n if n >= 0 else -n + + print(abs_val(-5)) + print(abs_val(3)) + + grade = "pass" if 60 <= 75 <= 100 else "fail" + print(grade) + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + python ternary.py +expect: + stdout: |+ + positive + 5 + 3 + pass + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/python/os_module/os_environ.yaml b/tests/scenarios/cmd/python/os_module/os_environ.yaml new file mode 100644 index 00000000..6122d2ab --- /dev/null +++ b/tests/scenarios/cmd/python/os_module/os_environ.yaml @@ -0,0 +1,23 @@ +description: python os.environ is accessible as a mapping and supports get() with a default. +skip_assert_against_bash: true +setup: + files: + - path: environ.py + content: |+ + import os + env = os.environ + print(hasattr(env, 'get')) + # Read an env var that must be set in any real environment, or use a default + val = env.get("NONEXISTENT_VAR_12345", "default_value") + print(val) + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + python environ.py +expect: + stdout: |+ + True + default_value + stderr: |+ + exit_code: 0 diff --git a/tests/scenarios/cmd/python/os_module/os_getcwd.yaml b/tests/scenarios/cmd/python/os_module/os_getcwd.yaml new file mode 100644 index 00000000..928403ad --- /dev/null +++ b/tests/scenarios/cmd/python/os_module/os_getcwd.yaml @@ -0,0 +1,19 @@ +description: python os.getcwd() returns the current working directory as a non-empty string. +skip_assert_against_bash: true +setup: + files: + - path: getcwd.py + content: |+ + import os + cwd = os.getcwd() + print(len(cwd) > 0) + chmod: 0644 +input: + allowed_paths: ["$DIR"] + script: |+ + python getcwd.py +expect: + stdout: |+ + True + stderr: |+ + exit_code: 0