1
0
Fork 0
mirror of https://github.com/imjasonh/terraform-playground synced 2026-07-07 23:35:16 +00:00

perf: speed up no-op builds (resolve base once, cache interpreter detection)

A no-op build's ~2-3s was almost all registry I/O:
- the base index was fetched 3x (BasePlatforms + remote.Image per platform)
- the base's top layer was downloaded once per platform every build to read
  /etc/apko.json for interpreter detection (Chainguard has no PYTHON_VERSION)

Changes:
- build.BaseSet resolves the base reference once and serves per-platform child
  images memoized (index fetched once; verified by TestBaseSet)
- cache the detected interpreter version by base digest in a default per-user
  metadata cache (cache.GetText/PutText), so repeat builds skip the apko layer
  download
- lazy wheel-cache dir creation; wheelhouse.Resolve defaults to the per-user
  cache dir

Tests: TestBaseSet (index fetched once + per-platform memoized), TestCachedInterpreter
(write + cache-hit short-circuit), cache text round-trip.

Co-authored-by: Jason Hall <imjasonh@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-06-11 00:34:16 +00:00
parent a62051afb8
commit 4100e12b20
No known key found for this signature in database
9 changed files with 334 additions and 97 deletions

View file

@ -156,16 +156,9 @@ func layerDigests(t *testing.T, img v1.Image) []string {
// TestE2EReproducibleAndNoBytesRebuild is the core demonstration.
func TestE2EReproducibleAndNoBytesRebuild(t *testing.T) {
host, counter := startRegistry(t)
ctx := context.Background()
// Push an (empty) base image and resolve it back by reference, exercising
// the docker-less base-by-reference path.
baseRef := host + "/base:latest"
push(t, baseRef, empty.Image)
base, err := build.Base(ctx, baseRef, nil, nil)
if err != nil {
t.Fatalf("resolve base: %v", err)
}
// An empty base keeps the focus on dependency/app layers.
base := empty.Image
dir := t.TempDir()
a := mkWheel(t, dir, "alpha", "1.0")

View file

@ -5,10 +5,12 @@ import (
"context"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"github.com/google/go-containerregistry/pkg/name"
@ -25,10 +27,21 @@ import (
"github.com/imjasonh/terraform-playground/pymage/internal/wheelhouse"
)
func TestBasePlatforms(t *testing.T) {
s := httptest.NewServer(registry.New())
t.Cleanup(s.Close)
host := strings.TrimPrefix(s.URL, "http://")
func TestBaseSet(t *testing.T) {
// Count GETs of the index manifest by tag to prove it's fetched only once.
var mu sync.Mutex
indexGets := 0
reg := registry.New()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet && r.URL.Path == "/v2/multi/manifests/latest" {
mu.Lock()
indexGets++
mu.Unlock()
}
reg.ServeHTTP(w, r)
}))
t.Cleanup(srv.Close)
host := strings.TrimPrefix(srv.URL, "http://")
ctx := context.Background()
mk := func(os, arch string) v1.Image {
@ -39,8 +52,7 @@ func TestBasePlatforms(t *testing.T) {
return img
}
// A multi-arch index with an attestation-style "unknown" entry that must be
// ignored.
// A multi-arch index with an attestation-style "unknown" entry to ignore.
idx := mutate.AppendManifests(empty.Index,
mutate.IndexAddendum{Add: mk("linux", "amd64"), Descriptor: v1.Descriptor{Platform: &v1.Platform{OS: "linux", Architecture: "amd64"}}},
mutate.IndexAddendum{Add: mk("linux", "arm64"), Descriptor: v1.Descriptor{Platform: &v1.Platform{OS: "linux", Architecture: "arm64"}}},
@ -51,29 +63,50 @@ func TestBasePlatforms(t *testing.T) {
t.Fatal(err)
}
plats, err := BasePlatforms(ctx, host+"/multi:latest", nil)
bs, err := ResolveBaseSet(ctx, host+"/multi:latest", nil)
if err != nil {
t.Fatal(err)
}
got := map[string]bool{}
for _, p := range plats {
for _, p := range bs.Platforms() {
got[p.OS+"/"+p.Architecture] = true
}
if len(plats) != 2 || !got["linux/amd64"] || !got["linux/arm64"] {
t.Fatalf("index platforms = %v, want linux/amd64 + linux/arm64 (no unknown)", plats)
if len(bs.Platforms()) != 2 || !got["linux/amd64"] || !got["linux/arm64"] {
t.Fatalf("platforms = %v, want linux/amd64 + linux/arm64 (no unknown)", bs.Platforms())
}
// A plain single-arch image returns its one platform.
// Fetching each platform's image works and does not re-fetch the index.
for _, arch := range []string{"amd64", "arm64", "amd64" /* memoized */} {
img, err := bs.Image(&v1.Platform{OS: "linux", Architecture: arch})
if err != nil {
t.Fatalf("Image(%s): %v", arch, err)
}
cf, err := img.ConfigFile()
if err != nil {
t.Fatal(err)
}
if cf.Architecture != arch {
t.Fatalf("got arch %q, want %q", cf.Architecture, arch)
}
}
mu.Lock()
n := indexGets
mu.Unlock()
if n != 1 {
t.Fatalf("index manifest fetched %d times, want 1 (should be resolved once)", n)
}
// A plain single-arch image yields its one platform.
imgRef, _ := name.ParseReference(host + "/single:latest")
if err := remote.Write(imgRef, mk("linux", "arm64"), remote.WithContext(ctx)); err != nil {
t.Fatal(err)
}
plats, err = BasePlatforms(ctx, host+"/single:latest", nil)
bs2, err := ResolveBaseSet(ctx, host+"/single:latest", nil)
if err != nil {
t.Fatal(err)
}
if len(plats) != 1 || plats[0].OS != "linux" || plats[0].Architecture != "arm64" {
t.Fatalf("single platforms = %v, want [linux/arm64]", plats)
if ps := bs2.Platforms(); len(ps) != 1 || ps[0].Architecture != "arm64" {
t.Fatalf("single platforms = %v, want [linux/arm64]", ps)
}
}

View file

@ -13,34 +13,10 @@ import (
"github.com/google/go-containerregistry/pkg/authn"
"github.com/google/go-containerregistry/pkg/name"
v1 "github.com/google/go-containerregistry/pkg/v1"
"github.com/google/go-containerregistry/pkg/v1/empty"
"github.com/google/go-containerregistry/pkg/v1/remote"
)
// Base resolves a base image reference to a v1.Image for the given platform.
// Only the base manifest and config are fetched; its layer blobs are referenced
// by digest and never downloaded.
func Base(ctx context.Context, ref string, platform *v1.Platform, kc authn.Keychain, nameOpts ...name.Option) (v1.Image, error) {
r, err := name.ParseReference(ref, nameOpts...)
if err != nil {
return nil, fmt.Errorf("build: parse base ref %q: %w", ref, err)
}
if kc == nil {
kc = authn.DefaultKeychain
}
opts := []remote.Option{
remote.WithContext(ctx),
remote.WithAuthFromKeychain(kc),
}
if platform != nil {
opts = append(opts, remote.WithPlatform(*platform))
}
img, err := remote.Image(r, opts...)
if err != nil {
return nil, fmt.Errorf("build: fetch base %q: %w", ref, err)
}
return img, nil
}
// apkoMaxBytes caps how much of the apko.json file we read.
const apkoMaxBytes = 4 << 20
@ -48,11 +24,30 @@ const apkoMaxBytes = 4 << 20
// "-base" suffix), as found in a Chainguard/Wolfi image's apko.json.
var pythonPkgRE = regexp.MustCompile(`^python-(\d+)\.(\d+)(?:-base)?$`)
// BasePlatforms returns the platforms a base reference supports: the entries of
// its image index (minus attestation/"unknown" placeholders), or the single
// platform from a plain image's config. It is used to default the build's
// target platforms to whatever the base provides.
func BasePlatforms(ctx context.Context, ref string, kc authn.Keychain, nameOpts ...name.Option) ([]v1.Platform, error) {
// BaseSet is a base image reference resolved once and reused across platforms.
// It fetches the base index/manifest a single time, then serves per-platform
// child images on demand (memoized), avoiding the redundant index re-fetch that
// a separate remote.Image call per platform would incur.
type BaseSet struct {
ref string
img v1.Image // set for a single-platform base (or scratch)
idx v1.ImageIndex // set for a multi-platform base
platforms []v1.Platform
children map[string]v1.Image
}
// ResolveBaseSet fetches ref once and returns a BaseSet. "scratch" resolves to
// an empty base with a single (registry-default) platform and no network I/O.
func ResolveBaseSet(ctx context.Context, ref string, kc authn.Keychain, nameOpts ...name.Option) (*BaseSet, error) {
if ref == "scratch" {
return &BaseSet{
ref: ref,
img: empty.Image,
platforms: []v1.Platform{{}},
children: map[string]v1.Image{},
}, nil
}
r, err := name.ParseReference(ref, nameOpts...)
if err != nil {
return nil, fmt.Errorf("build: parse base ref %q: %w", ref, err)
@ -65,6 +60,7 @@ func BasePlatforms(ctx context.Context, ref string, kc authn.Keychain, nameOpts
return nil, fmt.Errorf("build: inspect base %q: %w", ref, err)
}
bs := &BaseSet{ref: ref, children: map[string]v1.Image{}}
if desc.MediaType.IsIndex() {
idx, err := desc.ImageIndex()
if err != nil {
@ -74,7 +70,7 @@ func BasePlatforms(ctx context.Context, ref string, kc authn.Keychain, nameOpts
if err != nil {
return nil, err
}
var plats []v1.Platform
bs.idx = idx
seen := map[string]bool{}
for _, m := range im.Manifests {
p := m.Platform
@ -85,17 +81,17 @@ func BasePlatforms(ctx context.Context, ref string, kc authn.Keychain, nameOpts
if p.OS == "unknown" || p.Architecture == "unknown" {
continue
}
key := p.OS + "/" + p.Architecture + "/" + p.Variant
key := platformKey(p)
if seen[key] {
continue
}
seen[key] = true
plats = append(plats, v1.Platform{OS: p.OS, Architecture: p.Architecture, Variant: p.Variant})
bs.platforms = append(bs.platforms, v1.Platform{OS: p.OS, Architecture: p.Architecture, Variant: p.Variant})
}
if len(plats) == 0 {
if len(bs.platforms) == 0 {
return nil, fmt.Errorf("build: base index %q advertises no usable platforms", ref)
}
return plats, nil
return bs, nil
}
img, err := desc.Image()
@ -109,7 +105,55 @@ func BasePlatforms(ctx context.Context, ref string, kc authn.Keychain, nameOpts
if cf.OS == "" || cf.Architecture == "" {
return nil, fmt.Errorf("build: base image %q has no platform in its config", ref)
}
return []v1.Platform{{OS: cf.OS, Architecture: cf.Architecture, Variant: cf.Variant}}, nil
bs.img = img
bs.platforms = []v1.Platform{{OS: cf.OS, Architecture: cf.Architecture, Variant: cf.Variant}}
return bs, nil
}
// Platforms returns the platforms the base supports.
func (b *BaseSet) Platforms() []v1.Platform { return b.platforms }
// Image returns the base image for the given platform (nil platform selects the
// single available image). Child images are fetched at most once.
func (b *BaseSet) Image(platform *v1.Platform) (v1.Image, error) {
if b.img != nil {
return b.img, nil
}
want := &v1.Platform{}
if platform != nil {
want = platform
}
key := platformKey(want)
if img, ok := b.children[key]; ok {
return img, nil
}
im, err := b.idx.IndexManifest()
if err != nil {
return nil, err
}
for _, m := range im.Manifests {
if m.Platform == nil {
continue
}
if m.Platform.OS == want.OS && m.Platform.Architecture == want.Architecture &&
(want.Variant == "" || m.Platform.Variant == want.Variant) {
img, err := b.idx.Image(m.Digest)
if err != nil {
return nil, err
}
b.children[key] = img
return img, nil
}
}
return nil, fmt.Errorf("build: base %q has no image for platform %s", b.ref, key)
}
func platformKey(p *v1.Platform) string {
if p == nil {
return ""
}
return p.OS + "/" + p.Architecture + "/" + p.Variant
}
// InterpreterVersion reports the Python X.Y the base image provides.

View file

@ -50,6 +50,44 @@ func (c *Cache) Get(key string) (v1.Layer, bool) {
return l, true
}
// metaPath returns the on-disk path for a small text value keyed by key.
func (c *Cache) metaPath(key string) string {
sum := sha256.Sum256([]byte(key))
return filepath.Join(c.dir, "meta", hex.EncodeToString(sum[:])+".txt")
}
// GetText returns a small cached text value for key (e.g. a detected base
// interpreter version), or ("", false) on a miss.
func (c *Cache) GetText(key string) (string, bool) {
data, err := os.ReadFile(c.metaPath(key))
if err != nil {
return "", false
}
return string(data), true
}
// PutText stores a small text value for key.
func (c *Cache) PutText(key, val string) error {
p := c.metaPath(key)
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
return err
}
tmp, err := os.CreateTemp(filepath.Dir(p), ".tmp-*")
if err != nil {
return err
}
tmpName := tmp.Name()
defer func() { _ = os.Remove(tmpName) }()
if _, err := tmp.WriteString(val); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmpName, p)
}
// Put stores the layer's compressed blob under key. Writes are atomic (temp
// file + rename) so concurrent builders and interrupted writes are safe.
func (c *Cache) Put(key string, l v1.Layer) error {

View file

@ -6,6 +6,22 @@ import (
"github.com/imjasonh/terraform-playground/pymage/internal/ptar"
)
func TestTextRoundTrip(t *testing.T) {
c, err := New(t.TempDir())
if err != nil {
t.Fatal(err)
}
if _, ok := c.GetText("k"); ok {
t.Fatal("expected miss before PutText")
}
if err := c.PutText("k", "3.14"); err != nil {
t.Fatal(err)
}
if v, ok := c.GetText("k"); !ok || v != "3.14" {
t.Fatalf("GetText = %q,%v; want 3.14,true", v, ok)
}
}
func TestPutGetRoundTrip(t *testing.T) {
c, err := New(t.TempDir())
if err != nil {

View file

@ -157,13 +157,17 @@ func runBuild(cmd *cobra.Command, f *buildFlags) error {
nameOpts = append(nameOpts, name.Insecure)
}
// Resolve the base once (single index/manifest fetch) and reuse it for every
// platform, rather than re-fetching it per platform.
baseSet, err := build.ResolveBaseSet(ctx, f.base, authn.DefaultKeychain, nameOpts...)
if err != nil {
return err
}
// With no platform requested, default to whatever the base image supports
// (a multi-arch base yields a multi-arch build).
if len(platforms) == 0 {
platforms, err = defaultPlatformsFromBase(ctx, f, nameOpts)
if err != nil {
return err
}
platforms = baseSet.Platforms()
}
var layerCache *cache.Cache
@ -177,6 +181,13 @@ func runBuild(cmd *cobra.Command, f *buildFlags) error {
return err
}
}
// A small metadata cache (default per-user) remembers per-base interpreter
// detection so repeat builds don't re-download the base layer holding
// apko.json. Best-effort: a failure here just disables the cache.
var metaCache *cache.Cache
if dir, derr := metaCacheDir(f); derr == nil {
metaCache, _ = cache.New(dir)
}
// Build one image per target platform. With more than one platform we
// assemble them into a multi-arch OCI image index; with zero or one we push
@ -193,7 +204,11 @@ func runBuild(cmd *cobra.Command, f *buildFlags) error {
if p.OS != "" || p.Architecture != "" {
pp = &targets[i]
}
img, deps, err := buildOne(ctx, f, reqs, pp, labels, layerCache, wheelCache, nameOpts)
base, err := baseSet.Image(pp)
if err != nil {
return err
}
img, deps, err := buildOne(ctx, f, reqs, pp, base, labels, layerCache, metaCache, wheelCache)
if err != nil {
return err
}
@ -223,17 +238,12 @@ func runBuild(cmd *cobra.Command, f *buildFlags) error {
return output(cmd, f, nameOpts, images[0], nil)
}
// buildOne resolves the base and wheels for a single platform and builds the
// image, returning the resolved wheels for SBOM aggregation.
func buildOne(ctx context.Context, f *buildFlags, reqs []lock.Requirement, platform *v1.Platform, labels map[string]string, layerCache *cache.Cache, wheelCache string, nameOpts []name.Option) (v1.Image, []wheelhouse.ResolvedWheel, error) {
base, err := resolveBase(ctx, f.base, platform, nameOpts...)
if err != nil {
return nil, nil, err
}
// 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) {
// Determine the interpreter: honor --python (validated against the base) or
// auto-detect it from the base image.
major, minor, pyTag, err := resolveInterpreter(f, base)
major, minor, pyTag, err := resolveInterpreter(f, base, metaCache)
if err != nil {
return nil, nil, err
}
@ -272,8 +282,8 @@ func buildOne(ctx context.Context, f *buildFlags, reqs []lock.Requirement, platf
// directory tag. If --python is set it is authoritative but must match the
// base when the base advertises a version; otherwise the version is detected
// from the base image.
func resolveInterpreter(f *buildFlags, base v1.Image) (major, minor int, pyTag string, err error) {
baseMaj, baseMin, baseOK := build.InterpreterVersion(base)
func resolveInterpreter(f *buildFlags, base v1.Image, metaCache *cache.Cache) (major, minor int, pyTag string, err error) {
baseMaj, baseMin, baseOK := cachedInterpreter(base, metaCache)
if f.pythonTag != "" {
maj, min, err := parsePythonTag(f.pythonTag)
@ -386,14 +396,39 @@ func writeLayout(dir string, img v1.Image, idx v1.ImageIndex) error {
return p.AppendImage(img)
}
// defaultPlatformsFromBase returns the platforms to build for when --platform
// is not given: those advertised by the base image. A scratch base has no
// registry metadata, so it falls back to a single registry-default build.
func defaultPlatformsFromBase(ctx context.Context, f *buildFlags, nameOpts []name.Option) ([]v1.Platform, error) {
if f.base == "scratch" {
return []v1.Platform{{}}, nil
// cachedInterpreter returns the base image's Python version, consulting the
// metadata cache (keyed by base digest) so the apko.json layer isn't
// re-downloaded on repeat builds.
func cachedInterpreter(base v1.Image, metaCache *cache.Cache) (major, minor int, ok bool) {
if metaCache != nil {
if d, err := base.Digest(); err == nil {
key := "interp:v1:" + d.String()
if v, hit := metaCache.GetText(key); hit {
if maj, min, parsed := splitMajorMinor(v); parsed {
return maj, min, true
}
}
maj, min, found := build.InterpreterVersion(base)
if found {
_ = metaCache.PutText(key, fmt.Sprintf("%d.%d", maj, min))
}
return maj, min, found
}
}
return build.BasePlatforms(ctx, f.base, authn.DefaultKeychain, nameOpts...)
return build.InterpreterVersion(base)
}
func splitMajorMinor(s string) (int, int, bool) {
a, b, ok := strings.Cut(strings.TrimSpace(s), ".")
if !ok {
return 0, 0, false
}
maj, e1 := strconv.Atoi(a)
min, e2 := strconv.Atoi(b)
if e1 != nil || e2 != nil {
return 0, 0, false
}
return maj, min, true
}
// pushConcurrency resolves the number of concurrent layer uploads. 0 means
@ -482,13 +517,6 @@ func parsePythonTag(tag string) (int, int, error) {
return maj, min, nil
}
func resolveBase(ctx context.Context, ref string, platform *v1.Platform, nameOpts ...name.Option) (v1.Image, error) {
if ref == "scratch" {
return empty.Image, nil
}
return build.Base(ctx, ref, platform, authn.DefaultKeychain, nameOpts...)
}
func keyValues(in []string) (map[string]string, error) {
if len(in) == 0 {
return nil, nil

View file

@ -1,9 +1,11 @@
package cli
import (
"archive/tar"
"bytes"
"context"
"fmt"
"io"
"net/http/httptest"
"os"
"path/filepath"
@ -16,7 +18,9 @@ import (
"github.com/google/go-containerregistry/pkg/v1/empty"
"github.com/google/go-containerregistry/pkg/v1/mutate"
"github.com/google/go-containerregistry/pkg/v1/remote"
"github.com/google/go-containerregistry/pkg/v1/tarball"
"github.com/imjasonh/terraform-playground/pymage/internal/cache"
"github.com/imjasonh/terraform-playground/pymage/internal/testwheel"
)
@ -494,6 +498,69 @@ func TestSrcLayoutPythonPathFollowsWorkdir(t *testing.T) {
}
}
// apkoBase builds an in-memory base image whose top layer holds /etc/apko.json
// (and which advertises no PYTHON_VERSION), like a Chainguard image.
func apkoBase(t *testing.T, arch, apkoJSON string) v1.Image {
t.Helper()
var buf bytes.Buffer
tw := tar.NewWriter(&buf)
if err := tw.WriteHeader(&tar.Header{Name: "etc/apko.json", Mode: 0o644, Size: int64(len(apkoJSON)), Typeflag: tar.TypeReg}); err != nil {
t.Fatal(err)
}
if _, err := tw.Write([]byte(apkoJSON)); err != nil {
t.Fatal(err)
}
if err := tw.Close(); err != nil {
t.Fatal(err)
}
raw := buf.Bytes()
layer, err := tarball.LayerFromOpener(func() (io.ReadCloser, error) {
return io.NopCloser(bytes.NewReader(raw)), nil
})
if err != nil {
t.Fatal(err)
}
img, err := mutate.ConfigFile(empty.Image, &v1.ConfigFile{OS: "linux", Architecture: arch})
if err != nil {
t.Fatal(err)
}
img, err = mutate.AppendLayers(img, layer)
if err != nil {
t.Fatal(err)
}
return img
}
// TestCachedInterpreter verifies the metadata cache is written on first detect
// and consulted (short-circuiting apko detection) on subsequent calls.
func TestCachedInterpreter(t *testing.T) {
base := apkoBase(t, "amd64", `{"contents":{"packages":["python-3.14=3.14.5-r2"]}}`)
mc, err := cache.New(t.TempDir())
if err != nil {
t.Fatal(err)
}
// First call detects from apko.json and populates the cache.
maj, min, ok := cachedInterpreter(base, mc)
if !ok || maj != 3 || min != 14 {
t.Fatalf("detect: %d.%d ok=%v, want 3.14", maj, min, ok)
}
d, _ := base.Digest()
key := "interp:v1:" + d.String()
if v, hit := mc.GetText(key); !hit || v != "3.14" {
t.Fatalf("cache after detect = %q,%v; want 3.14,true", v, hit)
}
// A cache hit short-circuits detection: seed a different value and confirm
// it's returned instead of re-reading apko.json (which says 3.14).
if err := mc.PutText(key, "3.11"); err != nil {
t.Fatal(err)
}
if maj, min, ok := cachedInterpreter(base, mc); !ok || maj != 3 || min != 11 {
t.Fatalf("cache hit: %d.%d ok=%v, want 3.11 (from cache)", maj, min, ok)
}
}
func TestPushConcurrency(t *testing.T) {
if got := pushConcurrency(7); got != 7 {
t.Errorf("explicit value not honored: got %d, want 7", got)
@ -539,28 +606,28 @@ func TestResolveInterpreter(t *testing.T) {
bareBase := withVersion(nil)
// Auto-detect when --python is omitted.
maj, min, tag, err := resolveInterpreter(&buildFlags{}, base313)
maj, min, tag, err := resolveInterpreter(&buildFlags{}, base313, nil)
if err != nil || maj != 3 || min != 13 || tag != "python3.13" {
t.Fatalf("auto-detect: %d.%d %q err=%v", maj, min, tag, err)
}
// Explicit --python that matches is accepted.
if _, _, tag, err := resolveInterpreter(&buildFlags{pythonTag: "python3.13"}, base313); err != nil || tag != "python3.13" {
if _, _, tag, err := resolveInterpreter(&buildFlags{pythonTag: "python3.13"}, base313, nil); err != nil || tag != "python3.13" {
t.Fatalf("matching --python: %q err=%v", tag, err)
}
// Explicit --python that mismatches the base is rejected.
if _, _, _, err := resolveInterpreter(&buildFlags{pythonTag: "python3.12"}, base313); err == nil {
if _, _, _, err := resolveInterpreter(&buildFlags{pythonTag: "python3.12"}, base313, nil); err == nil {
t.Fatal("expected mismatch error")
}
// No --python and an undetectable base is an error.
if _, _, _, err := resolveInterpreter(&buildFlags{}, bareBase); err == nil {
if _, _, _, err := resolveInterpreter(&buildFlags{}, bareBase, nil); err == nil {
t.Fatal("expected error when base version is unknown and --python is unset")
}
// Explicit --python works even when the base can't be validated.
if _, _, tag, err := resolveInterpreter(&buildFlags{pythonTag: "python3.10"}, bareBase); err != nil || tag != "python3.10" {
if _, _, tag, err := resolveInterpreter(&buildFlags{pythonTag: "python3.10"}, bareBase, nil); err != nil || tag != "python3.10" {
t.Fatalf("explicit on bare base: %q err=%v", tag, err)
}
}

View file

@ -126,6 +126,15 @@ func wheelCacheDir(f *buildFlags) (string, error) {
return project.DefaultWheelCacheDir()
}
// metaCacheDir is where small build metadata (e.g. detected base interpreter
// versions) is cached.
func metaCacheDir(f *buildFlags) (string, error) {
if f.cacheDir != "" {
return f.cacheDir, nil
}
return project.DefaultCacheDir()
}
func validateBuildFlags(f *buildFlags) error {
if f.lockFile == "" {
return fmt.Errorf("no lock file found (expected uv.lock in the source directory, or pass --lock)")

View file

@ -182,11 +182,20 @@ func srcModuleDir(root, pkg string) bool {
return err == nil && st.IsDir()
}
// DefaultWheelCacheDir returns the on-disk wheel download cache location.
func DefaultWheelCacheDir() (string, error) {
// DefaultCacheDir returns pymage's per-user cache root.
func DefaultCacheDir() (string, error) {
dir, err := os.UserCacheDir()
if err != nil {
return "", err
}
return filepath.Join(dir, "pymage", "wheels"), nil
return filepath.Join(dir, "pymage"), nil
}
// DefaultWheelCacheDir returns the on-disk wheel download cache location.
func DefaultWheelCacheDir() (string, error) {
dir, err := DefaultCacheDir()
if err != nil {
return "", err
}
return filepath.Join(dir, "wheels"), nil
}