Files
weichenandCursor c5a4c6306b
ci / vet-and-test (pull_request) Successful in 1m7s
Write the pip dry-run report to a file
Why: pip --report - mixes Collecting progress and the JSON document on stdout, so the first base-layer resolve failed before any wheel was stored.
Evidence: go test ./internal/execenv/ passed on this worktree (SELF). A live dry-run of python-docx showed the progress text before the JSON object.
Impact: resolve reads report.json and ignores the progress stream. Package selection is unchanged.
Refs: Agent-System-Backend PR 207
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-15 14:38:26 +00:00

190 lines
5.5 KiB
Go

package execenv
import (
"bytes"
"context"
_ "embed"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
)
//go:embed base-requirements.txt
var baseRequirements string
// BaseRequirements 是基础层的包名清单(不带版本;版本由第一次解析锁定)。
func BaseRequirements() []string {
var out []string
for _, line := range strings.Split(baseRequirements, "\n") {
line = strings.TrimSpace(line)
if line != "" && !strings.HasPrefix(line, "#") {
out = append(out, line)
}
}
return out
}
// Pip 用宿主侧的 pip 下载 wheel。它不在沙箱里运行。
type Pip struct {
Python string // 带 pip 的解释器
}
func (p Pip) python() string {
if p.Python != "" {
return p.Python
}
return "python3"
}
// Resolve 以基础层锁为 constraints 做 dry-run。冲突记入 Override,不改锁。
func (p Pip) Resolve(ctx context.Context, reqs []string, base Lock) (Result, error) {
if len(reqs) == 0 {
return Result{}, nil
}
dir, err := os.MkdirTemp("", "execenv-resolve-")
if err != nil {
return Result{}, err
}
defer os.RemoveAll(dir)
cons := filepath.Join(dir, "constraints.txt")
if err := os.WriteFile(cons, []byte(constraintText(base)), 0o644); err != nil {
return Result{}, err
}
// --report 写文件。写到 stdout 时 pip 仍把 Collecting / Would install 打在同一条流上,JSON 解析会失败。
report := filepath.Join(dir, "report.json")
args := []string{"-m", "pip", "install", "--dry-run", "--report", report, "--only-binary=:all:", "--constraint", cons}
args = append(args, reqs...)
cmd := exec.CommandContext(ctx, p.python(), args...)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err = cmd.Run()
text := stderr.String() + stdout.String()
if err != nil {
if strings.Contains(text, "ResolutionImpossible") || strings.Contains(text, "conflicting") {
return Result{Override: []string{firstLine(text)}}, nil
}
if strings.Contains(text, "only-binary") || strings.Contains(text, "No matching distribution") {
return Result{SDist: reqs, Missing: []string{firstLine(text)}}, nil
}
return Result{}, fmt.Errorf("execenv: pip resolve: %v: %s", err, firstLine(text))
}
raw, err := os.ReadFile(report)
if err != nil {
return Result{}, fmt.Errorf("execenv: pip report: %w", err)
}
return parsePipReport(raw, base)
}
func parsePipReport(raw []byte, base Lock) (Result, error) {
var doc struct {
Install []struct {
Metadata struct {
Name string `json:"name"`
Version string `json:"version"`
} `json:"metadata"`
DownloadInfo struct {
ArchiveInfo struct {
Hash string `json:"hash"`
} `json:"archive_info"`
URL string `json:"url"`
} `json:"download_info"`
} `json:"install"`
}
if err := json.Unmarshal(raw, &doc); err != nil {
return Result{}, fmt.Errorf("execenv: pip report: %w", err)
}
pinned := map[string]LockedPkg{}
for _, c := range base.Packages {
pinned[normName(c.Name)] = c
}
var res Result
for _, item := range doc.Install {
name := item.Metadata.Name
if pin, ok := pinned[normName(name)]; ok {
if pin.Version != item.Metadata.Version {
res.Override = append(res.Override, fmt.Sprintf("%s 需要 %s,基础层固定为 %s", name, item.Metadata.Version, pin.Version))
}
continue
}
hash := strings.TrimPrefix(item.DownloadInfo.ArchiveInfo.Hash, "sha256=")
filename := name + "-" + item.Metadata.Version + ".whl"
if item.DownloadInfo.URL != "" {
filename = filepath.Base(strings.Split(item.DownloadInfo.URL, "?")[0])
}
res.Packages = append(res.Packages, LockedPkg{
Name: name, Version: item.Metadata.Version, Filename: filename, SHA256: hash,
})
}
return res, nil
}
// Fetch 把一个包下载进 wheelhouse,并返回落地路径。哈希不符则删除并报错。
func (p Pip) Fetch(ctx context.Context, wheelhouse string, pkg LockedPkg) (string, error) {
if err := os.MkdirAll(wheelhouse, 0o755); err != nil {
return "", err
}
dest := filepath.Join(wheelhouse, pkg.Filename)
if pkg.SHA256 != "" {
if sum, err := fileSHA256(dest); err == nil && strings.EqualFold(sum, pkg.SHA256) {
return dest, nil
}
}
cmd := exec.CommandContext(ctx, p.python(), "-m", "pip", "download",
"--only-binary=:all:", "--no-deps", "-d", wheelhouse, pkg.Name+"=="+pkg.Version)
out, err := cmd.CombinedOutput()
if err != nil {
return "", fmt.Errorf("execenv: pip download %s: %v: %s", pkg.Name, err, firstLine(string(out)))
}
found := dest
if _, err := os.Stat(found); err != nil {
matches, _ := filepath.Glob(filepath.Join(wheelhouse, normName(pkg.Name)+"*.whl"))
if len(matches) == 0 {
matches, _ = filepath.Glob(filepath.Join(wheelhouse, "*.whl"))
}
if len(matches) == 0 {
return "", fmt.Errorf("execenv: download produced no wheel for %s", pkg.Name)
}
found = matches[len(matches)-1]
}
sum, err := fileSHA256(found)
if err != nil {
return "", err
}
if pkg.SHA256 != "" && !strings.EqualFold(sum, pkg.SHA256) {
_ = os.Remove(found)
return "", fmt.Errorf("execenv: hash mismatch for %s", pkg.Name)
}
stable := filepath.Join(wheelhouse, sum+".whl")
if found != stable {
if err := os.Rename(found, stable); err != nil {
return found, nil
}
}
return stable, nil
}
func constraintText(base Lock) string {
var b strings.Builder
for _, p := range base.Packages {
if p.Name != "" && p.Version != "" {
fmt.Fprintf(&b, "%s==%s\n", p.Name, p.Version)
}
}
return b.String()
}
func firstLine(s string) string {
s = strings.TrimSpace(s)
if i := strings.IndexByte(s, '\n'); i >= 0 {
s = s[:i]
}
if len(s) > 400 {
s = s[:400]
}
return s
}