diff --git a/pymage/internal/lock/uvlock.go b/pymage/internal/lock/uvlock.go index 2017560..d0d967d 100644 --- a/pymage/internal/lock/uvlock.go +++ b/pymage/internal/lock/uvlock.go @@ -14,10 +14,20 @@ type uvLockFile struct { } type uvPackage struct { - Name string `toml:"name"` - Version string `toml:"version"` - Source map[string]any `toml:"source"` - Wheels []uvArtifact `toml:"wheels"` + Name string `toml:"name"` + Version string `toml:"version"` + Source map[string]any `toml:"source"` + 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. +type uvDep struct { + Name string `toml:"name"` + Extras []string `toml:"extra"` } type uvArtifact struct { @@ -25,9 +35,15 @@ type uvArtifact struct { Hash string `toml:"hash"` } -// ParseUVLockFile reads a uv.lock and returns pinned registry packages with -// wheel URLs and hashes. Local workspace packages (editable, virtual, etc.) -// are skipped — application code comes from the source directory instead. +// 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) { data, err := os.ReadFile(path) if err != nil { @@ -38,11 +54,18 @@ func ParseUVLockFile(path string) ([]Requirement, error) { return nil, fmt.Errorf("uv.lock: parse %q: %w", path, err) } + include := runtimeClosure(lf.Package) + 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) } @@ -76,6 +99,64 @@ func ParseUVLockFile(path string) ([]Requirement, error) { 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 { + byName := make(map[string]*uvPackage, len(pkgs)) + var roots []*uvPackage + for i := range pkgs { + p := &pkgs[i] + byName[NormalizeName(p.Name)] = p + if isLocalSource(p.Source) { + roots = append(roots, p) + } + } + if len(roots) == 0 { + return nil + } + + type item struct { + name string + extras []string + } + var queue []item + enqueue := func(deps []uvDep) { + for _, d := range deps { + queue = append(queue, item{NormalizeName(d.Name), d.Extras}) + } + } + for _, r := range roots { + enqueue(r.Dependencies) + } + + 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 +} + func isLocalSource(src map[string]any) bool { if src == nil { return false diff --git a/pymage/internal/lock/uvlock_test.go b/pymage/internal/lock/uvlock_test.go index b5ceabb..b5b6a3c 100644 --- a/pymage/internal/lock/uvlock_test.go +++ b/pymage/internal/lock/uvlock_test.go @@ -6,6 +6,10 @@ import ( "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" @@ -18,15 +22,48 @@ source = { virtual = "." } 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" @@ -35,6 +72,14 @@ 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) { @@ -47,17 +92,31 @@ func TestParseUVLockFile(t *testing.T) { if err != nil { t.Fatal(err) } - if len(reqs) != 2 { - t.Fatalf("got %d reqs, want 2 (virtual skipped): %+v", len(reqs), reqs) + + got := map[string]Requirement{} + for _, r := range reqs { + got[r.Name] = r } - if reqs[0].Name != "alpha" || len(reqs[0].Wheels) != 1 { - t.Fatalf("alpha = %+v", reqs[0]) + // 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)) + } } - if reqs[0].Wheels[0].Filename != "alpha-1.0.0-py3-none-any.whl" { - t.Errorf("filename = %q", reqs[0].Wheels[0].Filename) + // 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[1].Wheels) != 2 { - t.Fatalf("native wheels = %+v", reqs[1].Wheels) + 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) } } @@ -70,7 +129,15 @@ func TestParseAnyPrefersUVLock(t *testing.T) { if err != nil { t.Fatal(err) } - if len(reqs) != 2 { - t.Fatalf("got %d", len(reqs)) + if len(reqs) != 4 { + t.Fatalf("got %d, want 4", len(reqs)) } } + +func keys(m map[string]Requirement) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + return out +}