Why: The sandbox hides the host home directory and has no network, so pip install --user cannot supply python-docx, and the service PATH resolved python3 to a linuxbrew path that does not exist inside the sandbox. Evidence: SELF go test of execenv, tools, store, app, server, runmgr, config, and cmd/agent-system on this worktree. CI has not run. Impact: Sessions inherit a read-only base layer. Extra packages are requested through python_env, auto-approved only from the allowlist, and mounted per conversation. Schema 35 adds the request and binding tables. Refs: .task/feature-layered-python-env.md Co-authored-by: Cursor <cursoragent@cursor.com>
222 lines
4.9 KiB
Go
222 lines
4.9 KiB
Go
package execenv
|
|
|
|
import (
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// Wheel 是索引里的一个候选。Kind 为 wheel 或 sdist。
|
|
type Wheel struct {
|
|
Name string
|
|
Version string
|
|
Filename string
|
|
SHA256 string
|
|
Kind string
|
|
Requires []string
|
|
Size int64
|
|
}
|
|
|
|
// Result 是一次解析。Packages 只含相对约束新增的包,不含基础层已有的。
|
|
type Result struct {
|
|
Packages []LockedPkg
|
|
Override []string
|
|
SDist []string
|
|
Missing []string
|
|
Denied []string
|
|
}
|
|
|
|
func (r Result) OK() bool {
|
|
return len(r.Override) == 0 && len(r.SDist) == 0 && len(r.Missing) == 0 && len(r.Denied) == 0
|
|
}
|
|
|
|
// ResolveMem 用内存索引解析。基础层里已有且版本满足的包不会进入 Packages。
|
|
// 版本不满足时记入 Override,不改约束。
|
|
func ResolveMem(index []Wheel, reqs []string, constraints []LockedPkg) Result {
|
|
byName := map[string][]Wheel{}
|
|
for _, w := range index {
|
|
byName[normName(w.Name)] = append(byName[normName(w.Name)], w)
|
|
}
|
|
pinned := map[string]LockedPkg{}
|
|
for _, c := range constraints {
|
|
pinned[normName(c.Name)] = c
|
|
}
|
|
chosen := map[string]LockedPkg{}
|
|
var res Result
|
|
queue := append([]string{}, reqs...)
|
|
seen := map[string]bool{}
|
|
for len(queue) > 0 {
|
|
req := strings.TrimSpace(queue[0])
|
|
queue = queue[1:]
|
|
if req == "" {
|
|
continue
|
|
}
|
|
name, spec := splitReq(req)
|
|
key := normName(name)
|
|
mark := key + "\x00" + spec
|
|
if seen[mark] {
|
|
continue
|
|
}
|
|
seen[mark] = true
|
|
if pin, ok := pinned[key]; ok {
|
|
if spec != "" && !matchSpec(pin.Version, spec) {
|
|
res.Override = append(res.Override, fmt.Sprintf("%s 需要 %s,基础层固定为 %s", name, spec, pin.Version))
|
|
}
|
|
continue
|
|
}
|
|
if prev, ok := chosen[key]; ok {
|
|
if spec != "" && !matchSpec(prev.Version, spec) {
|
|
res.Override = append(res.Override, fmt.Sprintf("%s 需要 %s,已选 %s", name, spec, prev.Version))
|
|
}
|
|
continue
|
|
}
|
|
best, sdist := pickWheel(byName[key], spec)
|
|
if sdist && best == nil {
|
|
res.SDist = append(res.SDist, name)
|
|
continue
|
|
}
|
|
if best == nil {
|
|
res.Missing = append(res.Missing, name)
|
|
continue
|
|
}
|
|
pkg := LockedPkg{
|
|
Name: best.Name, Version: best.Version, Filename: best.Filename,
|
|
SHA256: best.SHA256, Size: best.Size,
|
|
}
|
|
chosen[key] = pkg
|
|
for _, dep := range best.Requires {
|
|
queue = append(queue, dep)
|
|
}
|
|
}
|
|
names := make([]string, 0, len(chosen))
|
|
for n := range chosen {
|
|
names = append(names, n)
|
|
}
|
|
sortStrings(names)
|
|
for _, n := range names {
|
|
res.Packages = append(res.Packages, chosen[n])
|
|
}
|
|
return res
|
|
}
|
|
|
|
func pickWheel(cands []Wheel, spec string) (best *Wheel, sawSDist bool) {
|
|
for i := range cands {
|
|
w := &cands[i]
|
|
if spec != "" && !matchSpec(w.Version, spec) {
|
|
continue
|
|
}
|
|
if w.Kind == "sdist" {
|
|
sawSDist = true
|
|
continue
|
|
}
|
|
if best == nil || cmpVer(w.Version, best.Version) > 0 {
|
|
best = w
|
|
}
|
|
}
|
|
return best, sawSDist
|
|
}
|
|
|
|
func splitReq(req string) (name, spec string) {
|
|
for i, r := range req {
|
|
if r == '=' || r == '<' || r == '>' || r == '!' || r == '~' || r == ' ' {
|
|
return strings.TrimSpace(req[:i]), strings.TrimSpace(req[i:])
|
|
}
|
|
}
|
|
return req, ""
|
|
}
|
|
|
|
func matchSpec(version, spec string) bool {
|
|
for _, part := range strings.Split(spec, ",") {
|
|
part = strings.TrimSpace(part)
|
|
if part == "" || part == "*" {
|
|
continue
|
|
}
|
|
switch {
|
|
case strings.HasPrefix(part, "=="):
|
|
if version != strings.TrimPrefix(part, "==") {
|
|
return false
|
|
}
|
|
case strings.HasPrefix(part, "!="):
|
|
if version == strings.TrimPrefix(part, "!=") {
|
|
return false
|
|
}
|
|
case strings.HasPrefix(part, ">="):
|
|
if cmpVer(version, strings.TrimPrefix(part, ">=")) < 0 {
|
|
return false
|
|
}
|
|
case strings.HasPrefix(part, "<="):
|
|
if cmpVer(version, strings.TrimPrefix(part, "<=")) > 0 {
|
|
return false
|
|
}
|
|
case strings.HasPrefix(part, ">"):
|
|
if cmpVer(version, strings.TrimPrefix(part, ">")) <= 0 {
|
|
return false
|
|
}
|
|
case strings.HasPrefix(part, "<"):
|
|
if cmpVer(version, strings.TrimPrefix(part, "<")) >= 0 {
|
|
return false
|
|
}
|
|
default:
|
|
if version != part {
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func cmpVer(a, b string) int {
|
|
as := splitVer(a)
|
|
bs := splitVer(b)
|
|
n := len(as)
|
|
if len(bs) > n {
|
|
n = len(bs)
|
|
}
|
|
for i := 0; i < n; i++ {
|
|
ai, bi := 0, 0
|
|
if i < len(as) {
|
|
ai = as[i]
|
|
}
|
|
if i < len(bs) {
|
|
bi = bs[i]
|
|
}
|
|
if ai != bi {
|
|
return ai - bi
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func splitVer(v string) []int {
|
|
v = strings.TrimPrefix(v, "v")
|
|
if i := strings.IndexAny(v, "abrc"); i > 0 {
|
|
// 丢掉本地/预发布后缀,比较只看数字段。
|
|
head := v[:i]
|
|
if _, err := strconv.Atoi(string(head[len(head)-1])); err == nil {
|
|
v = head
|
|
}
|
|
}
|
|
var out []int
|
|
for _, p := range strings.Split(v, ".") {
|
|
n := 0
|
|
for _, r := range p {
|
|
if r < '0' || r > '9' {
|
|
break
|
|
}
|
|
n = n*10 + int(r-'0')
|
|
}
|
|
out = append(out, n)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func sortStrings(s []string) {
|
|
for i := 1; i < len(s); i++ {
|
|
j := i
|
|
for j > 0 && s[j] < s[j-1] {
|
|
s[j], s[j-1] = s[j-1], s[j]
|
|
j--
|
|
}
|
|
}
|
|
}
|