File size: 4,403 Bytes
857a91b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
// 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
}