mirror of
https://github.com/imjasonh/terraform-playground
synced 2026-07-07 23:35:16 +00:00
uv.lock: install only the runtime dependency closure (exclude dev groups)
Previously pymage installed every package in uv.lock, including dev-dependency groups and the whole resolution universe. On real projects this bloated images badly: ruff alone was 50% of deps in astral-sh/uv-docker-example, and dev/CI tools (ruff, mypy, ty, zizmor, ...) were 63% of deps in fastapi/full-stack-fastapi-template. ParseUVLockFile now computes the runtime closure from the local/project packages' dependencies, transitively following dependencies and expanding requested extras via optional-dependencies, never following dev-dependencies (matching 'uv sync --no-dev'). Locks with no project package still install everything. sdist-only errors now only trigger for packages actually in the closure. Cuts deps 50-63% on the studied projects. Co-authored-by: Jason Hall <imjasonh@users.noreply.github.com>
This commit is contained in:
parent
b721e31fd6
commit
89e64cb3d2
2 changed files with 165 additions and 17 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue