matrixcloud / internal /pyenv /pyenv.go
github-actions[bot]
Deploy from 4dfd54f2
857a91b
Raw
History Blame Contribute Delete
4.4 kB
// 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
}