// Package pyenv manages real Python virtual environments on the host: creating // them (uv when available, else python -m venv), installing packages, and // running commands inside them. It backs the runtime's local Python sandbox // (e.g. for MatrixShell). package pyenv import ( "bufio" "bytes" "context" "errors" "io" "os" "os/exec" "path/filepath" "runtime" "strings" ) // Env is a Python virtual environment rooted at Dir. type Env struct { Dir string } func binSubdir() string { if runtime.GOOS == "windows" { return "Scripts" } return "bin" } func exeSuffix() string { if runtime.GOOS == "windows" { return ".exe" } return "" } // Python returns the venv's python interpreter path. func (e *Env) Python() string { return filepath.Join(e.Dir, binSubdir(), "python"+exeSuffix()) } // Bin returns the path to a console script installed in the venv. func (e *Env) Bin(name string) string { return filepath.Join(e.Dir, binSubdir(), name+exeSuffix()) } // Exists reports whether the venv has been created. func (e *Env) Exists() bool { _, err := os.Stat(e.Python()) return err == nil } // HasBin reports whether a console script exists in the venv. func (e *Env) HasBin(name string) bool { _, err := os.Stat(e.Bin(name)) return err == nil } func hasUv() bool { _, err := exec.LookPath("uv"); return err == nil } func hostPython() string { for _, c := range []string{"python3", "python"} { if _, err := exec.LookPath(c); err == nil { return c } } return "python3" } // Ensure creates the venv if it does not exist, streaming command output to // emit (line by line). It prefers uv (fast) and falls back to python -m venv. func Ensure(ctx context.Context, dir string, emit func(string)) (*Env, error) { e := &Env{Dir: dir} if e.Exists() { return e, nil } if err := os.MkdirAll(filepath.Dir(dir), 0o755); err != nil { return nil, err } var cmd *exec.Cmd if hasUv() { cmd = exec.CommandContext(ctx, "uv", "venv", dir) } else { cmd = exec.CommandContext(ctx, hostPython(), "-m", "venv", dir) } if err := runStream(cmd, emit); err != nil { return nil, err } if !e.Exists() { return nil, errors.New("venv creation reported success but interpreter is missing") } return e, nil } // Pip installs packages into the venv (uv pip when available, else venv pip), // streaming output to emit. func (e *Env) Pip(ctx context.Context, emit func(string), args ...string) error { var cmd *exec.Cmd if hasUv() { a := append([]string{"pip", "install", "--python", e.Python()}, args...) cmd = exec.CommandContext(ctx, "uv", a...) } else { a := append([]string{"-m", "pip", "install"}, args...) cmd = exec.CommandContext(ctx, e.Python(), a...) } return runStream(cmd, emit) } // Result is the outcome of a sandbox command. type Result struct { Stdout string `json:"stdout"` Stderr string `json:"stderr"` ExitCode int `json:"exit_code"` } // Shell runs a shell command inside workDir with the venv's bin on PATH and // VIRTUAL_ENV set, capturing stdout/stderr/exit. It uses bash on unix. func (e *Env) Shell(ctx context.Context, workDir, command string) (Result, error) { if err := os.MkdirAll(workDir, 0o755); err != nil { return Result{}, err } var cmd *exec.Cmd if runtime.GOOS == "windows" { cmd = exec.CommandContext(ctx, "cmd", "/C", command) } else { cmd = exec.CommandContext(ctx, "bash", "-lc", command) } cmd.Dir = workDir cmd.Env = append(os.Environ(), "VIRTUAL_ENV="+e.Dir, "PATH="+filepath.Join(e.Dir, binSubdir())+string(os.PathListSeparator)+os.Getenv("PATH"), ) var out, errb bytes.Buffer cmd.Stdout = &out cmd.Stderr = &errb err := cmd.Run() return Result{Stdout: out.String(), Stderr: errb.String(), ExitCode: exitCode(err)}, nil } func exitCode(err error) int { if err == nil { return 0 } var ee *exec.ExitError if errors.As(err, &ee) { return ee.ExitCode() } return -1 } func runStream(cmd *exec.Cmd, emit func(string)) error { pr, pw := io.Pipe() cmd.Stdout = pw cmd.Stderr = pw if err := cmd.Start(); err != nil { _ = pw.Close() return err } done := make(chan struct{}) go func() { sc := bufio.NewScanner(pr) sc.Buffer(make([]byte, 0, 64*1024), 1024*1024) for sc.Scan() { line := strings.TrimRight(sc.Text(), "\r") if emit != nil && line != "" { emit(line) } } close(done) }() err := cmd.Wait() _ = pw.Close() <-done return err }