mirror of
https://github.com/imjasonh/terraform-playground
synced 2026-07-19 07:26:50 +00:00
lock: require uv for uv.lock resolution; fail fast instead of falling back
Removes the built-in closure walker (resolveUV/runtimeClosure/ParseUVLockFile/ ParseAny) entirely so there is no second, potentially divergent resolver. uv.lock resolution now goes only through `uv export`; if uv is not on PATH the build fails with an actionable error pointing at requirements.txt + --find-links. uv.lock parsing is kept solely to attach wheel/sdist URLs. Co-authored-by: Jason Hall <imjasonh@users.noreply.github.com>
This commit is contained in:
parent
cb303c1577
commit
a3c9685549
7 changed files with 38 additions and 509 deletions
|
|
@ -32,11 +32,12 @@ because it exploits content-addressed layering:
|
|||
pre-build a wheel out-of-band and supply it via `--find-links`.
|
||||
- Replacing dependency *resolution*. We delegate resolution to existing,
|
||||
correct tools and consume their output: for uv projects we shell out to
|
||||
`uv export --frozen --no-dev` (when uv is available) to get the exact
|
||||
per-target requirement set — closure, extras, groups, and workspace selection
|
||||
are uv's responsibility, not ours. pymage only evaluates the markers uv emits
|
||||
and maps each pin to a wheel. A built-in lock walker remains as a fallback for
|
||||
environments without uv. Likewise, install layout is validated against a real
|
||||
`uv export --frozen --no-dev` to get the exact per-target requirement set —
|
||||
closure, extras, groups, and workspace selection are uv's responsibility, not
|
||||
ours. pymage only evaluates the markers uv emits and maps each pin to a wheel.
|
||||
uv is therefore **required** to build from a uv.lock; we fail fast if it's
|
||||
absent rather than fall back to a second, divergent resolver. Likewise, install
|
||||
layout is validated against a real
|
||||
`uv pip install` in a conformance test (installing a wheel runs no code, so
|
||||
there is no RCE risk in using it as an oracle).
|
||||
- A general-purpose Dockerfile interpreter. This is a focused Python app builder
|
||||
|
|
|
|||
|
|
@ -164,8 +164,11 @@ installed and a `pyproject.toml` is present, resolution is delegated to
|
|||
`uv export --frozen --no-dev` — uv is the source of truth for the closure,
|
||||
extras, groups, and workspace selection, so pymage doesn't reimplement its
|
||||
resolver. pymage then evaluates the environment markers uv emits for the target
|
||||
and attaches wheel URLs from the lock. (Without uv, a built-in closure walker is
|
||||
used as a fallback.) The selectors below map onto `uv export` flags:
|
||||
and attaches wheel URLs from the lock. **`uv` is therefore required to build
|
||||
from a `uv.lock`** — if it isn't installed the build fails (rather than falling
|
||||
back to a second, potentially divergent resolver); as an alternative, export a
|
||||
hashed `requirements.txt` and build with `--lock requirements.txt --find-links`.
|
||||
The selectors below map onto `uv export` flags:
|
||||
|
||||
- `--extra <group>` enables one of the project's own
|
||||
`[project.optional-dependencies]` groups (repeatable). Extras requested *by*
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import (
|
|||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
|
@ -80,21 +81,6 @@ func ParseFile(path string) ([]Requirement, error) {
|
|||
return Parse(f)
|
||||
}
|
||||
|
||||
// ParseAny reads a lock file, choosing the parser from the filename:
|
||||
// uv.lock is parsed as TOML; everything else is treated as requirements.txt.
|
||||
func ParseAny(path string) ([]Requirement, error) {
|
||||
if strings.HasSuffix(strings.ToLower(filepath.Base(path)), ".lock") &&
|
||||
!strings.HasSuffix(strings.ToLower(path), "requirements.lock") {
|
||||
// uv.lock / poetry.lock-style names ending in .lock (not requirements.lock
|
||||
// which is pip-compile output in requirements format).
|
||||
if strings.EqualFold(filepath.Base(path), "uv.lock") {
|
||||
return ParseUVLockFile(path)
|
||||
}
|
||||
}
|
||||
// requirements.lock and requirements.txt both use the pip format.
|
||||
return ParseFile(path)
|
||||
}
|
||||
|
||||
// Lock is a parsed lock file (parsed once) that can be resolved per target.
|
||||
type Lock struct {
|
||||
isUV bool
|
||||
|
|
@ -133,22 +119,22 @@ func Load(path string) (*Lock, error) {
|
|||
// Resolve returns the pinned requirements for the given target.
|
||||
//
|
||||
// For a uv.lock, resolution (the runtime closure, extras, workspace package,
|
||||
// and group selection) is delegated to `uv export` when uv and a pyproject.toml
|
||||
// are available — uv is the source of truth, so we don't reimplement its
|
||||
// resolver. We then evaluate the per-requirement environment markers uv emits
|
||||
// for the target and attach wheel/sdist URLs from the lock. When uv isn't
|
||||
// available (e.g. air-gapped, or a bare uv.lock with no project), we fall back
|
||||
// to a built-in closure. For requirements-format locks the list is already flat
|
||||
// and pinned; we only evaluate any inline markers.
|
||||
// and group selection) is delegated to `uv export` — uv is the source of truth,
|
||||
// so we do not reimplement its resolver. We then evaluate the per-requirement
|
||||
// environment markers uv emits for the target and attach wheel/sdist URLs from
|
||||
// the lock. uv is therefore **required** to build from a uv.lock: if it isn't
|
||||
// available we fail rather than fall back to a divergent resolver. For
|
||||
// requirements-format locks the list is already flat and pinned; we only
|
||||
// evaluate any inline markers.
|
||||
func (l *Lock) Resolve(o Options) ([]Requirement, error) {
|
||||
env := o.markerEnv()
|
||||
if !l.isUV {
|
||||
return filterByMarker(l.reqs, env), nil
|
||||
}
|
||||
if l.path != "" && uvExportUsable(l.path) {
|
||||
return uvExportResolve(l.path, l.uv, o, env)
|
||||
if _, err := exec.LookPath("uv"); err != nil {
|
||||
return nil, fmt.Errorf("resolving %s requires uv on PATH (pymage delegates uv.lock resolution to `uv export`); install uv (https://docs.astral.sh/uv/), or export a hashed requirements.txt and build with --lock requirements.txt --find-links", filepath.Base(l.path))
|
||||
}
|
||||
return resolveUV(l.uv, UVOptions{Package: o.Package, Extras: o.Extras, Env: env})
|
||||
return uvExportResolve(l.path, l.uv, o, env)
|
||||
}
|
||||
|
||||
// markerEnv builds the marker environment for the target, or nil when no target
|
||||
|
|
|
|||
|
|
@ -3,23 +3,11 @@ package lock
|
|||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// uvExportUsable reports whether we should resolve via `uv export`: uv must be
|
||||
// on PATH and the lock must sit next to a pyproject.toml (a real uv project, as
|
||||
// `uv export` requires). Bare/synthetic locks fall back to the built-in walker.
|
||||
func uvExportUsable(lockPath string) bool {
|
||||
if _, err := exec.LookPath("uv"); err != nil {
|
||||
return false
|
||||
}
|
||||
_, err := os.Stat(filepath.Join(filepath.Dir(lockPath), "pyproject.toml"))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// uvExportResolve delegates resolution to `uv export`, which produces the
|
||||
// project's universal runtime requirement set (closure, extras, groups, and
|
||||
// workspace selection all handled by uv) with per-requirement markers and
|
||||
|
|
|
|||
|
|
@ -107,6 +107,14 @@ build-backend = "hatchling.build"
|
|||
}
|
||||
}
|
||||
|
||||
func names(reqs []Requirement) map[string]bool {
|
||||
m := map[string]bool{}
|
||||
for _, r := range reqs {
|
||||
m[NormalizeName(r.Name)] = true
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func writeFile(t *testing.T, path, content string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
|
|
|
|||
|
|
@ -3,34 +3,24 @@ package lock
|
|||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/pelletier/go-toml/v2"
|
||||
)
|
||||
|
||||
// uvLockFile is the subset of uv.lock we read: package name/version plus the
|
||||
// wheel and sdist artifact references. Resolution (which packages to install
|
||||
// for a target) is delegated to `uv export` — see uvexport.go — so we do not
|
||||
// model the dependency graph, extras, groups, or markers here.
|
||||
type uvLockFile struct {
|
||||
Package []uvPackage `toml:"package"`
|
||||
}
|
||||
|
||||
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"`
|
||||
DevDeps map[string][]uvDep `toml:"dev-dependencies"`
|
||||
}
|
||||
|
||||
// uvDep is a dependency edge in uv.lock. Extras pull a package's
|
||||
// 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"`
|
||||
Name string `toml:"name"`
|
||||
Version string `toml:"version"`
|
||||
Sdist uvArtifact `toml:"sdist"`
|
||||
Wheels []uvArtifact `toml:"wheels"`
|
||||
}
|
||||
|
||||
type uvArtifact struct {
|
||||
|
|
@ -38,18 +28,6 @@ type uvArtifact struct {
|
|||
Hash string `toml:"hash"`
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
|
@ -63,169 +41,6 @@ func parseUVLock(path string) (*uvLockFile, error) {
|
|||
return &lf, nil
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
if include != nil && !include[NormalizeName(pkg.Name)] {
|
||||
continue
|
||||
}
|
||||
|
||||
req := Requirement{Name: pkg.Name, Version: pkg.Version}
|
||||
seen := map[string]bool{}
|
||||
for _, w := range pkg.Wheels {
|
||||
sum, err := parseUVHash(w.Hash)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("uv.lock: %s==%s: %w", pkg.Name, pkg.Version, err)
|
||||
}
|
||||
if w.URL == "" {
|
||||
return nil, fmt.Errorf("uv.lock: %s==%s: wheel missing url", pkg.Name, pkg.Version)
|
||||
}
|
||||
if seen[sum] {
|
||||
continue
|
||||
}
|
||||
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)})
|
||||
}
|
||||
|
||||
// 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 (closure empty)")
|
||||
}
|
||||
return reqs, nil
|
||||
}
|
||||
|
||||
// runtimeClosure returns the set of (normalized) package names reachable from
|
||||
// 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 localRoots []*uvPackage
|
||||
for i := range pkgs {
|
||||
p := &pkgs[i]
|
||||
byName[NormalizeName(p.Name)] = p
|
||||
if isLocalSource(p.Source) {
|
||||
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, nil
|
||||
}
|
||||
|
||||
type item struct {
|
||||
name string
|
||||
extras []string
|
||||
}
|
||||
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{}
|
||||
visited := map[string]bool{}
|
||||
for len(queue) > 0 {
|
||||
it := queue[0]
|
||||
queue = queue[1:]
|
||||
key := it.name + "|" + strings.Join(it.extras, ",")
|
||||
if visited[key] {
|
||||
continue
|
||||
}
|
||||
visited[key] = true
|
||||
|
||||
p := byName[it.name]
|
||||
if p == nil {
|
||||
continue
|
||||
}
|
||||
if !isLocalSource(p.Source) {
|
||||
include[it.name] = true
|
||||
}
|
||||
enqueue(p.Dependencies)
|
||||
for _, ex := range it.extras {
|
||||
enqueue(p.OptionalDeps[ex])
|
||||
}
|
||||
}
|
||||
return include, nil
|
||||
}
|
||||
|
||||
func isLocalSource(src map[string]any) bool {
|
||||
if src == nil {
|
||||
return false
|
||||
}
|
||||
for _, k := range []string{"virtual", "editable", "workspace", "directory", "git"} {
|
||||
if _, ok := src[k]; ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func parseUVHash(h string) (string, error) {
|
||||
h = strings.TrimSpace(h)
|
||||
if h == "" {
|
||||
|
|
|
|||
|
|
@ -1,272 +0,0 @@
|
|||
package lock
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// uvLockFixture exercises: a virtual + an editable local package (both
|
||||
// skipped), the runtime closure of the editable project (alpha, its transitive
|
||||
// beta, and extradep pulled by the requested alpha[x] extra), a multi-wheel
|
||||
// package (native), and a dev-only package (linter) that must be excluded.
|
||||
const uvLockFixture = `version = 1
|
||||
requires-python = ">=3.14"
|
||||
|
||||
[[package]]
|
||||
name = "virtual-pkg"
|
||||
version = "0.1.0"
|
||||
source = { virtual = "." }
|
||||
|
||||
[[package]]
|
||||
name = "app"
|
||||
version = "0.1.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "alpha", extra = ["x"] },
|
||||
{ name = "native" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "linter" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "alpha"
|
||||
version = "1.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "beta" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a/alpha-1.0.0-py3-none-any.whl", hash = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
x = [
|
||||
{ name = "extradep" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "beta"
|
||||
version = "1.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
wheels = [
|
||||
{ url = "https://example.com/beta-1.0.0-py3-none-any.whl", hash = "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "extradep"
|
||||
version = "1.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
wheels = [
|
||||
{ url = "https://example.com/extradep-1.0.0-py3-none-any.whl", hash = "sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "native"
|
||||
version = "2.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
wheels = [
|
||||
{ url = "https://example.com/native-2.0.0-py3-none-any.whl", hash = "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" },
|
||||
{ url = "https://example.com/native-2.0.0-cp314-cp314-manylinux2014_x86_64.whl", hash = "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linter"
|
||||
version = "9.9.9"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
wheels = [
|
||||
{ url = "https://example.com/linter-9.9.9-py3-none-any.whl", hash = "sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" },
|
||||
]
|
||||
`
|
||||
|
||||
func TestParseUVLockFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "uv.lock")
|
||||
if err := os.WriteFile(path, []byte(uvLockFixture), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reqs, err := ParseUVLockFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got := map[string]Requirement{}
|
||||
for _, r := range reqs {
|
||||
got[r.Name] = r
|
||||
}
|
||||
// Runtime closure: alpha, its transitive beta, extradep (alpha[x]), native.
|
||||
for _, want := range []string{"alpha", "beta", "extradep", "native"} {
|
||||
if _, ok := got[want]; !ok {
|
||||
t.Errorf("missing runtime dep %q (got %v)", want, keys(got))
|
||||
}
|
||||
}
|
||||
// Local packages skipped; dev-only package excluded.
|
||||
for _, bad := range []string{"app", "virtual-pkg", "linter"} {
|
||||
if _, ok := got[bad]; ok {
|
||||
t.Errorf("%q should not be installed", bad)
|
||||
}
|
||||
}
|
||||
if len(reqs) != 4 {
|
||||
t.Fatalf("got %d reqs, want 4: %v", len(reqs), keys(got))
|
||||
}
|
||||
if len(got["native"].Wheels) != 2 {
|
||||
t.Errorf("native should keep both wheels, got %d", len(got["native"].Wheels))
|
||||
}
|
||||
if got["alpha"].Wheels[0].Filename != "alpha-1.0.0-py3-none-any.whl" {
|
||||
t.Errorf("alpha filename = %q", got["alpha"].Wheels[0].Filename)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAnyPrefersUVLock(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "uv.lock"), []byte(uvLockFixture), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reqs, err := ParseAny(filepath.Join(dir, "uv.lock"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(reqs) != 4 {
|
||||
t.Fatalf("got %d, want 4", len(reqs))
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
out = append(out, k)
|
||||
}
|
||||
return out
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue