mirror of
https://github.com/imjasonh/terraform-playground
synced 2026-07-07 23:35:16 +00:00
lock: evaluate PEP 508 markers and support --extra/--package in uv.lock closure
Co-authored-by: Jason Hall <imjasonh@users.noreply.github.com>
This commit is contained in:
parent
b484b02113
commit
7e8e2261a7
8 changed files with 692 additions and 38 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
345
pymage/internal/lock/marker.go
Normal file
345
pymage/internal/lock/marker.go
Normal file
|
|
@ -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
|
||||
}
|
||||
42
pymage/internal/lock/marker_test.go
Normal file
42
pymage/internal/lock/marker_test.go
Normal file
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue