From 7e8e2261a75b738a0f6d1f288d7b37da9bfbb8a4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 11 Jun 2026 01:30:45 +0000 Subject: [PATCH] lock: evaluate PEP 508 markers and support --extra/--package in uv.lock closure Co-authored-by: Jason Hall --- pymage/internal/cli/cli.go | 26 ++- pymage/internal/cli/defaults.go | 6 + pymage/internal/lock/lock.go | 61 ++++- pymage/internal/lock/marker.go | 345 ++++++++++++++++++++++++++++ pymage/internal/lock/marker_test.go | 42 ++++ pymage/internal/lock/uvlock.go | 119 +++++++--- pymage/internal/lock/uvlock_test.go | 129 +++++++++++ pymage/internal/project/project.go | 2 + 8 files changed, 692 insertions(+), 38 deletions(-) create mode 100644 pymage/internal/lock/marker.go create mode 100644 pymage/internal/lock/marker_test.go diff --git a/pymage/internal/cli/cli.go b/pymage/internal/cli/cli.go index 9571abf..a7fa8a2 100644 --- a/pymage/internal/cli/cli.go +++ b/pymage/internal/cli/cli.go @@ -47,6 +47,8 @@ type buildFlags struct { repo string tags []string platforms []string + extras []string + pkg string pythonTag string prefix string workingDir string @@ -90,6 +92,8 @@ func buildCmd() *cobra.Command { fs := cmd.Flags() fs.StringVar(&f.base, "base", "", "base image reference (default: cgr.dev/chainguard/python:latest)") fs.StringVar(&f.lockFile, "lock", "", "lock file (default: uv.lock in the source directory)") + fs.StringArrayVar(&f.extras, "extra", nil, "enable a project optional-dependency group from uv.lock (repeatable)") + fs.StringVar(&f.pkg, "package", "", "build a single uv workspace member (by name)") fs.StringArrayVar(&f.findLinks, "find-links", nil, "local wheel directory (optional; wheels are downloaded from the lock when omitted)") fs.StringVar(&f.repo, "repo", "", "destination repository, pushed by digest, e.g. gcr.io/foo/bar (default: [tool.pymage] repo)") fs.StringArrayVarP(&f.tags, "tag", "t", nil, "tag(s) to apply at --repo; tag component only, not a full reference (repeatable; default: [tool.pymage] tags or 'latest')") @@ -141,13 +145,10 @@ func runBuild(cmd *cobra.Command, f *buildFlags) error { return fmt.Errorf("--label: %w", err) } - reqs, err := lock.ParseAny(f.lockFile) + lk, err := lock.Load(f.lockFile) if err != nil { return err } - if err := lock.Validate(reqs, f.requireHash); err != nil { - return err - } platforms, err := parsePlatforms(f.platforms) if err != nil { @@ -199,7 +200,7 @@ func runBuild(cmd *cobra.Command, f *buildFlags) error { if err != nil { return err } - img, deps, err := buildOne(ctx, f, reqs, pp, base, labels, layerCache, metaCache, wheelCache) + img, deps, err := buildOne(ctx, f, lk, pp, base, labels, layerCache, metaCache, wheelCache) if err != nil { return err } @@ -231,7 +232,7 @@ func runBuild(cmd *cobra.Command, f *buildFlags) error { // buildOne resolves wheels for a single platform and builds the image from the // already-resolved base, returning the resolved wheels for SBOM aggregation. -func buildOne(ctx context.Context, f *buildFlags, reqs []lock.Requirement, platform *v1.Platform, base v1.Image, labels map[string]string, layerCache, metaCache *cache.Cache, wheelCache string) (v1.Image, []wheelhouse.ResolvedWheel, error) { +func buildOne(ctx context.Context, f *buildFlags, lk *lock.Lock, platform *v1.Platform, base v1.Image, labels map[string]string, layerCache, metaCache *cache.Cache, wheelCache string) (v1.Image, []wheelhouse.ResolvedWheel, error) { // Determine the interpreter: honor --python (validated against the base) or // auto-detect it from the base image. major, minor, pyTag, err := resolveInterpreter(f, base, metaCache) @@ -242,6 +243,19 @@ func buildOne(ctx context.Context, f *buildFlags, reqs []lock.Requirement, platf target := platformTarget(platform) target.PyMajor, target.PyMinor = major, minor + // Resolve the lock for this target (uv.lock runtime closure honors + // --package, --extra, and the target's environment markers). + reqs, err := lk.Resolve(lock.Options{ + Package: f.pkg, Extras: f.extras, + OS: target.OS, Arch: target.Arch, PyMajor: target.PyMajor, PyMinor: target.PyMinor, + }) + if err != nil { + return nil, nil, err + } + if err := lock.Validate(reqs, f.requireHash); err != nil { + return nil, nil, err + } + wheels, err := wheelhouse.ResolveContext(ctx, reqs, f.findLinks, target, wheelCache) if err != nil { return nil, nil, err diff --git a/pymage/internal/cli/defaults.go b/pymage/internal/cli/defaults.go index dcc5f44..11f6b9c 100644 --- a/pymage/internal/cli/defaults.go +++ b/pymage/internal/cli/defaults.go @@ -69,6 +69,12 @@ func applyDefaults(cmd *cobra.Command, f *buildFlags) error { if !changed("no-cache") && cfg.NoCache { f.noCache = true } + if !changed("extra") && len(cfg.Extras) > 0 { + f.extras = cfg.Extras + } + if !changed("package") && cfg.Package != "" { + f.pkg = cfg.Package + } if !changed("python") && cfg.Python != "" { f.pythonTag = cfg.Python } diff --git a/pymage/internal/lock/lock.go b/pymage/internal/lock/lock.go index ea9fa2e..f662306 100644 --- a/pymage/internal/lock/lock.go +++ b/pymage/internal/lock/lock.go @@ -24,6 +24,14 @@ type WheelRef struct { Filename string // wheel file name, used for tag parsing } +// SdistRef is a pinned source distribution, used to build a wheel when no +// pre-built wheel is available. +type SdistRef struct { + URL string + SHA256 string // hex digest, no "sha256:" prefix + Filename string +} + // Requirement is a single pinned distribution. type Requirement struct { // Name is the project name as written (normalize with NormalizeName for @@ -35,6 +43,9 @@ type Requirement struct { Hashes []string // Wheels lists known wheel artifacts for this pin (populated from uv.lock). Wheels []WheelRef + // Sdist, when set, is the source distribution to build a wheel from if no + // compatible wheel is available. + Sdist *SdistRef } var ( @@ -81,6 +92,54 @@ func ParseAny(path string) ([]Requirement, error) { return ParseFile(path) } +// Lock is a parsed lock file (parsed once) that can be resolved per target. +type Lock struct { + isUV bool + uv *uvLockFile // uv.lock + reqs []Requirement // requirements format +} + +// Options selects the resolution for a uv.lock: a workspace package, extras to +// enable, and the target environment for marker evaluation. They are ignored +// for requirements-format locks (which are already flat and pinned). +type Options struct { + Package string + Extras []string + OS, Arch string + PyMajor, PyMinor int +} + +// Load reads and parses a lock file once. uv.lock is parsed as TOML; everything +// else is treated as a requirements file. +func Load(path string) (*Lock, error) { + if strings.EqualFold(filepath.Base(path), "uv.lock") { + lf, err := parseUVLock(path) + if err != nil { + return nil, err + } + return &Lock{isUV: true, uv: lf}, nil + } + reqs, err := ParseFile(path) + if err != nil { + return nil, err + } + return &Lock{reqs: reqs}, nil +} + +// Resolve returns the pinned requirements for the given target. For uv.lock +// this is the runtime closure (extras/package/markers applied); for a +// requirements file it is the flat pinned list. +func (l *Lock) Resolve(o Options) ([]Requirement, error) { + if !l.isUV { + return append([]Requirement(nil), l.reqs...), nil + } + var env MarkerEnv + if o.PyMajor != 0 { + env = NewMarkerEnv(o.OS, o.Arch, o.PyMajor, o.PyMinor) + } + return resolveUV(l.uv, UVOptions{Package: o.Package, Extras: o.Extras, Env: env}) +} + // DiscoverLock returns the path to a lock file in dir, preferring uv.lock. func DiscoverLock(dir string) (string, error) { for _, name := range []string{"uv.lock", "requirements.lock", "requirements.txt"} { @@ -187,7 +246,7 @@ func Validate(reqs []Requirement, requireHashes bool) error { if r.Version == "" { return fmt.Errorf("lock: requirement %q is not pinned with ==", r.Name) } - if requireHashes && len(r.Hashes) == 0 { + if requireHashes && len(r.Hashes) == 0 && r.Sdist == nil { return fmt.Errorf("lock: requirement %q has no --hash entries", r.Name) } } diff --git a/pymage/internal/lock/marker.go b/pymage/internal/lock/marker.go new file mode 100644 index 0000000..6208106 --- /dev/null +++ b/pymage/internal/lock/marker.go @@ -0,0 +1,345 @@ +package lock + +import ( + "fmt" + "strconv" + "strings" +) + +// MarkerEnv holds the PEP 508 environment-marker variables for a build target. +type MarkerEnv map[string]string + +// NewMarkerEnv derives marker variables from a target os/arch/python version. +func NewMarkerEnv(os, arch string, pyMajor, pyMinor int) MarkerEnv { + sysPlatform := map[string]string{"linux": "linux", "darwin": "darwin", "windows": "win32"}[os] + if sysPlatform == "" { + sysPlatform = os + } + platSystem := map[string]string{"linux": "Linux", "darwin": "Darwin", "windows": "Windows"}[os] + if platSystem == "" { + platSystem = os + } + osName := "posix" + if os == "windows" { + osName = "nt" + } + machine := map[string]string{ + "amd64": "x86_64", "arm64": "aarch64", "386": "i686", + "arm": "armv7l", "ppc64le": "ppc64le", "s390x": "s390x", + }[arch] + if machine == "" { + machine = arch + } + return MarkerEnv{ + "os_name": osName, + "sys_platform": sysPlatform, + "platform_system": platSystem, + "platform_machine": machine, + "python_version": fmt.Sprintf("%d.%d", pyMajor, pyMinor), + "python_full_version": fmt.Sprintf("%d.%d.0", pyMajor, pyMinor), + "implementation_name": "cpython", + "implementation_version": fmt.Sprintf("%d.%d.0", pyMajor, pyMinor), + "platform_python_implementation": "CPython", + } +} + +// EvalMarker reports whether a PEP 508 environment marker holds for env. An +// empty marker is always true. If the marker can't be parsed it returns true, +// so an unrecognized marker never silently drops a dependency. +func EvalMarker(marker string, env MarkerEnv) bool { + marker = strings.TrimSpace(marker) + if marker == "" { + return true + } + toks, err := tokenizeMarker(marker) + if err != nil { + return true + } + p := &markerParser{toks: toks, env: env} + v, err := p.parseOr() + if err != nil || !p.atEnd() { + return true + } + return v +} + +type mtokKind int + +const ( + mtokString mtokKind = iota + mtokIdent + mtokOp + mtokLParen + mtokRParen +) + +type mtok struct { + kind mtokKind + val string +} + +func tokenizeMarker(s string) ([]mtok, error) { + var toks []mtok + for i := 0; i < len(s); { + c := s[i] + switch { + case c == ' ' || c == '\t' || c == '\n': + i++ + case c == '\'' || c == '"': + j := i + 1 + for j < len(s) && s[j] != c { + j++ + } + if j >= len(s) { + return nil, fmt.Errorf("unterminated string") + } + toks = append(toks, mtok{mtokString, s[i+1 : j]}) + i = j + 1 + case c == '(': + toks = append(toks, mtok{mtokLParen, "("}) + i++ + case c == ')': + toks = append(toks, mtok{mtokRParen, ")"}) + i++ + case c == '=' || c == '!' || c == '<' || c == '>' || c == '~': + j := i + for j < len(s) && strings.ContainsRune("=!<>~", rune(s[j])) { + j++ + } + toks = append(toks, mtok{mtokOp, s[i:j]}) + i = j + case isIdentChar(c): + j := i + for j < len(s) && isIdentChar(s[j]) { + j++ + } + toks = append(toks, mtok{mtokIdent, s[i:j]}) + i = j + default: + return nil, fmt.Errorf("unexpected char %q", c) + } + } + return toks, nil +} + +func isIdentChar(c byte) bool { + return c == '_' || c == '.' || + (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') +} + +type markerParser struct { + toks []mtok + pos int + env MarkerEnv +} + +func (p *markerParser) atEnd() bool { return p.pos >= len(p.toks) } + +func (p *markerParser) peek() (mtok, bool) { + if p.atEnd() { + return mtok{}, false + } + return p.toks[p.pos], true +} + +func (p *markerParser) parseOr() (bool, error) { + v, err := p.parseAnd() + if err != nil { + return false, err + } + for { + t, ok := p.peek() + if !ok || t.kind != mtokIdent || t.val != "or" { + return v, nil + } + p.pos++ + r, err := p.parseAnd() + if err != nil { + return false, err + } + v = v || r + } +} + +func (p *markerParser) parseAnd() (bool, error) { + v, err := p.parseAtom() + if err != nil { + return false, err + } + for { + t, ok := p.peek() + if !ok || t.kind != mtokIdent || t.val != "and" { + return v, nil + } + p.pos++ + r, err := p.parseAtom() + if err != nil { + return false, err + } + v = v && r + } +} + +func (p *markerParser) parseAtom() (bool, error) { + t, ok := p.peek() + if !ok { + return false, fmt.Errorf("unexpected end") + } + if t.kind == mtokLParen { + p.pos++ + v, err := p.parseOr() + if err != nil { + return false, err + } + end, ok := p.peek() + if !ok || end.kind != mtokRParen { + return false, fmt.Errorf("missing )") + } + p.pos++ + return v, nil + } + return p.parseComparison() +} + +func (p *markerParser) parseComparison() (bool, error) { + l, lver, err := p.parseValue() + if err != nil { + return false, err + } + op, err := p.parseOp() + if err != nil { + return false, err + } + r, rver, err := p.parseValue() + if err != nil { + return false, err + } + return compareMarker(l, lver, op, r, rver), nil +} + +func (p *markerParser) parseOp() (string, error) { + t, ok := p.peek() + if !ok { + return "", fmt.Errorf("expected operator") + } + if t.kind == mtokOp { + p.pos++ + return t.val, nil + } + if t.kind == mtokIdent && t.val == "in" { + p.pos++ + return "in", nil + } + if t.kind == mtokIdent && t.val == "not" { + p.pos++ + n, ok := p.peek() + if !ok || n.kind != mtokIdent || n.val != "in" { + return "", fmt.Errorf("expected 'in' after 'not'") + } + p.pos++ + return "not in", nil + } + return "", fmt.Errorf("expected operator, got %q", t.val) +} + +// parseValue returns the value and whether it is a version-valued variable +// (python_version / python_full_version / implementation_version). +func (p *markerParser) parseValue() (string, bool, error) { + t, ok := p.peek() + if !ok { + return "", false, fmt.Errorf("expected value") + } + p.pos++ + switch t.kind { + case mtokString: + return t.val, false, nil + case mtokIdent: + v, known := p.env[t.val] + if !known { + // Unknown variable: treat as empty, non-version. + return "", false, nil + } + isVer := t.val == "python_version" || t.val == "python_full_version" || t.val == "implementation_version" + return v, isVer, nil + default: + return "", false, fmt.Errorf("unexpected token %q", t.val) + } +} + +func compareMarker(l string, lver bool, op string, r string, rver bool) bool { + switch op { + case "in": + return strings.Contains(r, l) + case "not in": + return !strings.Contains(r, l) + } + if lver || rver { + c := compareVersions(l, r) + switch op { + case "==", "===": + return c == 0 + case "!=": + return c != 0 + case "<": + return c < 0 + case "<=": + return c <= 0 + case ">": + return c > 0 + case ">=", "~=": + return c >= 0 + } + } + switch op { + case "==", "===": + return l == r + case "!=": + return l != r + case "<": + return l < r + case "<=": + return l <= r + case ">": + return l > r + case ">=": + return l >= r + } + return false +} + +// compareVersions compares dotted numeric versions (e.g. "3.10" vs "3.9"). +// Non-numeric components compare lexically; missing components are 0. +func compareVersions(a, b string) int { + as, bs := strings.Split(a, "."), strings.Split(b, ".") + n := len(as) + if len(bs) > n { + n = len(bs) + } + for i := 0; i < n; i++ { + var ai, bi string + if i < len(as) { + ai = as[i] + } + if i < len(bs) { + bi = bs[i] + } + an, aerr := strconv.Atoi(ai) + bn, berr := strconv.Atoi(bi) + if aerr == nil && berr == nil { + if an != bn { + if an < bn { + return -1 + } + return 1 + } + continue + } + if ai != bi { + if ai < bi { + return -1 + } + return 1 + } + } + return 0 +} diff --git a/pymage/internal/lock/marker_test.go b/pymage/internal/lock/marker_test.go new file mode 100644 index 0000000..78d521b --- /dev/null +++ b/pymage/internal/lock/marker_test.go @@ -0,0 +1,42 @@ +package lock + +import "testing" + +func TestEvalMarker(t *testing.T) { + linux := NewMarkerEnv("linux", "amd64", 3, 12) + + cases := []struct { + marker string + want bool + }{ + {"", true}, + {"sys_platform == 'win32'", false}, + {"sys_platform == 'linux'", true}, + {"sys_platform != 'win32'", true}, + {"platform_system == 'Linux'", true}, + {"platform_machine == 'x86_64'", true}, + {"os_name == 'posix'", true}, + {"python_version >= '3.11'", true}, + {"python_version < '3.11'", false}, + {"python_full_version >= '3.12'", true}, + {"python_version >= '3.10' and python_version < '3.13'", true}, + {"python_version < '3.10' or sys_platform == 'linux'", true}, + {"python_version < '3.10' or sys_platform == 'win32'", false}, + {"extra == 'foo'", false}, // unknown var -> empty + {"(sys_platform == 'win32') or (os_name == 'posix')", true}, + {"platform_machine in 'x86_64 aarch64'", true}, // substring membership + {"'arm' not in platform_machine", true}, + {"this is not valid pep508 @#$", true}, // unparseable -> include + } + for _, c := range cases { + if got := EvalMarker(c.marker, linux); got != c.want { + t.Errorf("EvalMarker(%q) = %v, want %v", c.marker, got, c.want) + } + } + + // A python_version gate flips on a different target. + py310 := NewMarkerEnv("linux", "amd64", 3, 10) + if EvalMarker("python_version >= '3.11'", py310) { + t.Error("python_version >= 3.11 should be false on 3.10") + } +} diff --git a/pymage/internal/lock/uvlock.go b/pymage/internal/lock/uvlock.go index d0d967d..cc2894c 100644 --- a/pymage/internal/lock/uvlock.go +++ b/pymage/internal/lock/uvlock.go @@ -17,6 +17,7 @@ type uvPackage struct { Name string `toml:"name"` Version string `toml:"version"` Source map[string]any `toml:"source"` + Sdist uvArtifact `toml:"sdist"` Wheels []uvArtifact `toml:"wheels"` Dependencies []uvDep `toml:"dependencies"` OptionalDeps map[string][]uvDep `toml:"optional-dependencies"` @@ -24,10 +25,12 @@ type uvPackage struct { } // uvDep is a dependency edge in uv.lock. Extras pull a package's -// optional-dependencies; dev-dependencies groups are never followed. +// optional-dependencies; dev-dependencies groups are never followed. A marker +// gates the edge on the target environment. type uvDep struct { Name string `toml:"name"` Extras []string `toml:"extra"` + Marker string `toml:"marker"` } type uvArtifact struct { @@ -35,16 +38,20 @@ type uvArtifact struct { Hash string `toml:"hash"` } -// ParseUVLockFile reads a uv.lock and returns the project's runtime dependency -// closure as pinned registry packages with wheel URLs and hashes. -// -// Only packages reachable from the local (project/workspace) packages' runtime -// dependencies — expanding requested extras via optional-dependencies — are -// included. Dev-dependency groups are excluded, matching `uv sync --no-dev`, so -// linters/test tools don't end up in the runtime image. Local packages -// themselves (editable/virtual/workspace) are skipped; application code comes -// from the source directory. -func ParseUVLockFile(path string) ([]Requirement, error) { +// UVOptions selects which part of a uv.lock to install. +type UVOptions struct { + // Package roots the closure at a single workspace member (by name). Empty + // unions the closures of all local (project/workspace) packages. + Package string + // Extras enables the root project's own optional-dependency groups. + Extras []string + // Env, when non-nil, evaluates dependency markers for the target so + // platform/python-gated deps are included only when they apply. + Env MarkerEnv +} + +// parseUVLock reads and unmarshals a uv.lock file. +func parseUVLock(path string) (*uvLockFile, error) { data, err := os.ReadFile(path) if err != nil { return nil, err @@ -53,22 +60,42 @@ func ParseUVLockFile(path string) ([]Requirement, error) { if err := toml.Unmarshal(data, &lf); err != nil { return nil, fmt.Errorf("uv.lock: parse %q: %w", path, err) } + return &lf, nil +} - include := runtimeClosure(lf.Package) +// ParseUVLockFile reads a uv.lock and returns its full runtime closure with +// default options (all local roots, no extras, no marker filtering). Prefer +// Load + Resolve to pass a package/extras/target. +func ParseUVLockFile(path string) ([]Requirement, error) { + lf, err := parseUVLock(path) + if err != nil { + return nil, err + } + return resolveUV(lf, UVOptions{}) +} + +// resolveUV returns the project's runtime dependency closure as pinned +// packages with wheel URLs/hashes (and an sdist fallback when present). +// +// Only packages reachable from the local (project/workspace) packages' runtime +// dependencies — expanding requested extras via optional-dependencies and +// honoring markers — are included. Dev-dependency groups are excluded (like +// `uv sync --no-dev`). Local packages are skipped; app code comes from source. +func resolveUV(lf *uvLockFile, opts UVOptions) ([]Requirement, error) { + include, err := runtimeClosure(lf.Package, opts) + if err != nil { + return nil, err + } var reqs []Requirement for _, pkg := range lf.Package { if isLocalSource(pkg.Source) { continue } - // When a closure was computed, install only its members; otherwise - // (no local/project package found) fall back to every package. if include != nil && !include[NormalizeName(pkg.Name)] { continue } - if len(pkg.Wheels) == 0 { - return nil, fmt.Errorf("uv.lock: %s==%s has no wheels (sdist-only deps are not supported)", pkg.Name, pkg.Version) - } + req := Requirement{Name: pkg.Name, Version: pkg.Version} seen := map[string]bool{} for _, w := range pkg.Wheels { @@ -84,37 +111,60 @@ func ParseUVLockFile(path string) ([]Requirement, error) { } seen[sum] = true req.Hashes = append(req.Hashes, sum) - req.Wheels = append(req.Wheels, WheelRef{ - URL: w.URL, - SHA256: sum, - Filename: filepath.Base(w.URL), - }) + req.Wheels = append(req.Wheels, WheelRef{URL: w.URL, SHA256: sum, Filename: filepath.Base(w.URL)}) + } + + // An sdist provides a wheel-build fallback when no wheel is compatible. + if pkg.Sdist.URL != "" { + sum, err := parseUVHash(pkg.Sdist.Hash) + if err != nil { + return nil, fmt.Errorf("uv.lock: %s==%s sdist: %w", pkg.Name, pkg.Version, err) + } + req.Sdist = &SdistRef{URL: pkg.Sdist.URL, SHA256: sum, Filename: filepath.Base(pkg.Sdist.URL)} + if !seen[sum] { + req.Hashes = append(req.Hashes, sum) // counts as "hashed" + } + } + + if len(req.Wheels) == 0 && req.Sdist == nil { + return nil, fmt.Errorf("uv.lock: %s==%s has neither wheels nor an sdist", pkg.Name, pkg.Version) } reqs = append(reqs, req) } if len(reqs) == 0 { - return nil, fmt.Errorf("uv.lock: no installable packages in %q", path) + return nil, fmt.Errorf("uv.lock: no installable packages (closure empty)") } return reqs, nil } // runtimeClosure returns the set of (normalized) package names reachable from -// the local packages' runtime dependencies, expanding extras. It returns nil -// when the lock has no local/project package (e.g. a bare requirements lock), -// signaling "install everything" for backward compatibility. -func runtimeClosure(pkgs []uvPackage) map[string]bool { +// the selected local packages' runtime dependencies, expanding extras and +// honoring markers. It returns nil when the lock has no local/project package +// (e.g. a bare requirements lock), signaling "install everything". +func runtimeClosure(pkgs []uvPackage, opts UVOptions) (map[string]bool, error) { byName := make(map[string]*uvPackage, len(pkgs)) - var roots []*uvPackage + var localRoots []*uvPackage for i := range pkgs { p := &pkgs[i] byName[NormalizeName(p.Name)] = p if isLocalSource(p.Source) { - roots = append(roots, p) + localRoots = append(localRoots, p) } } + + var roots []*uvPackage + if opts.Package != "" { + p := byName[NormalizeName(opts.Package)] + if p == nil { + return nil, fmt.Errorf("uv.lock: --package %q not found in lock", opts.Package) + } + roots = []*uvPackage{p} + } else { + roots = localRoots + } if len(roots) == 0 { - return nil + return nil, nil } type item struct { @@ -124,11 +174,18 @@ func runtimeClosure(pkgs []uvPackage) map[string]bool { var queue []item enqueue := func(deps []uvDep) { for _, d := range deps { + if opts.Env != nil && !EvalMarker(d.Marker, opts.Env) { + continue + } queue = append(queue, item{NormalizeName(d.Name), d.Extras}) } } for _, r := range roots { enqueue(r.Dependencies) + // Enable the root project's own optional-dependency groups (--extra). + for _, ex := range opts.Extras { + enqueue(r.OptionalDeps[ex]) + } } include := map[string]bool{} @@ -154,7 +211,7 @@ func runtimeClosure(pkgs []uvPackage) map[string]bool { enqueue(p.OptionalDeps[ex]) } } - return include + return include, nil } func isLocalSource(src map[string]any) bool { diff --git a/pymage/internal/lock/uvlock_test.go b/pymage/internal/lock/uvlock_test.go index b5b6a3c..862f22e 100644 --- a/pymage/internal/lock/uvlock_test.go +++ b/pymage/internal/lock/uvlock_test.go @@ -134,6 +134,135 @@ func TestParseAnyPrefersUVLock(t *testing.T) { } } +func names(reqs []Requirement) map[string]bool { + m := map[string]bool{} + for _, r := range reqs { + m[NormalizeName(r.Name)] = true + } + return m +} + +// closureLock exercises extras, workspace --package selection, and markers. +const closureLock = `version = 1 + +[[package]] +name = "svc" +version = "0.1.0" +source = { editable = "members/svc" } +dependencies = [ + { name = "core" }, + { name = "winonly", marker = "sys_platform == 'win32'" }, +] + +[package.optional-dependencies] +gpu = [ + { name = "accel" }, +] + +[[package]] +name = "worker" +version = "0.1.0" +source = { editable = "members/worker" } +dependencies = [ + { name = "worktool" }, +] + +[[package]] +name = "core" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +wheels = [ { url = "https://e/core-1.0.0-py3-none-any.whl", hash = "sha256:1111111111111111111111111111111111111111111111111111111111111111" } ] + +[[package]] +name = "accel" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +wheels = [ { url = "https://e/accel-1.0.0-py3-none-any.whl", hash = "sha256:2222222222222222222222222222222222222222222222222222222222222222" } ] + +[[package]] +name = "winonly" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +wheels = [ { url = "https://e/winonly-1.0.0-py3-none-any.whl", hash = "sha256:3333333333333333333333333333333333333333333333333333333333333333" } ] + +[[package]] +name = "worktool" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +wheels = [ { url = "https://e/worktool-1.0.0-py3-none-any.whl", hash = "sha256:4444444444444444444444444444444444444444444444444444444444444444" } ] +` + +func loadClosure(t *testing.T) *Lock { + t.Helper() + dir := t.TempDir() + p := filepath.Join(dir, "uv.lock") + if err := os.WriteFile(p, []byte(closureLock), 0o644); err != nil { + t.Fatal(err) + } + lk, err := Load(p) + if err != nil { + t.Fatal(err) + } + return lk +} + +func TestUVClosureMarkers(t *testing.T) { + lk := loadClosure(t) + // Linux target: winonly (sys_platform == win32) is excluded. + reqs, err := lk.Resolve(Options{OS: "linux", Arch: "amd64", PyMajor: 3, PyMinor: 12}) + if err != nil { + t.Fatal(err) + } + got := names(reqs) + if !got["core"] || !got["worktool"] { + t.Fatalf("missing runtime deps: %v", got) + } + if got["winonly"] { + t.Error("winonly should be excluded on linux (marker)") + } + if got["accel"] { + t.Error("accel should be excluded without --extra gpu") + } +} + +func TestUVClosureExtras(t *testing.T) { + lk := loadClosure(t) + reqs, err := lk.Resolve(Options{Extras: []string{"gpu"}, OS: "linux", Arch: "amd64", PyMajor: 3, PyMinor: 12}) + if err != nil { + t.Fatal(err) + } + if !names(reqs)["accel"] { + t.Errorf("--extra gpu should include accel: %v", names(reqs)) + } +} + +func TestUVClosurePackage(t *testing.T) { + lk := loadClosure(t) + // --package svc: only svc's closure (core), not worker's worktool. + reqs, err := lk.Resolve(Options{Package: "svc", OS: "linux", Arch: "amd64", PyMajor: 3, PyMinor: 12}) + if err != nil { + t.Fatal(err) + } + got := names(reqs) + if !got["core"] || got["worktool"] { + t.Errorf("--package svc closure = %v, want {core} (no worktool)", got) + } + + // --package worker: only worktool. + reqs, err = lk.Resolve(Options{Package: "worker", OS: "linux", Arch: "amd64", PyMajor: 3, PyMinor: 12}) + if err != nil { + t.Fatal(err) + } + got = names(reqs) + if !got["worktool"] || got["core"] { + t.Errorf("--package worker closure = %v, want {worktool}", got) + } + + if _, err := lk.Resolve(Options{Package: "nope"}); err == nil { + t.Error("expected error for unknown --package") + } +} + func keys(m map[string]Requirement) []string { out := make([]string, 0, len(m)) for k := range m { diff --git a/pymage/internal/project/project.go b/pymage/internal/project/project.go index 25f985a..107269d 100644 --- a/pymage/internal/project/project.go +++ b/pymage/internal/project/project.go @@ -46,6 +46,8 @@ type Config struct { Labels map[string]string `toml:"labels"` FindLinks []string `toml:"find-links"` NoCache bool `toml:"no-cache"` + Extras []string `toml:"extras"` + Package string `toml:"package"` } const defaultBase = "cgr.dev/chainguard/python:latest"