diff --git a/pymage/e2e/e2e_test.go b/pymage/e2e/e2e_test.go index 78f4feb..f36d4d9 100644 --- a/pymage/e2e/e2e_test.go +++ b/pymage/e2e/e2e_test.go @@ -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") diff --git a/pymage/internal/build/build_test.go b/pymage/internal/build/build_test.go index c5aeedf..f563b1f 100644 --- a/pymage/internal/build/build_test.go +++ b/pymage/internal/build/build_test.go @@ -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) } } diff --git a/pymage/internal/build/resolve.go b/pymage/internal/build/resolve.go index 32cf43a..555c70c 100644 --- a/pymage/internal/build/resolve.go +++ b/pymage/internal/build/resolve.go @@ -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. diff --git a/pymage/internal/cache/cache.go b/pymage/internal/cache/cache.go index 69f8478..27feecb 100644 --- a/pymage/internal/cache/cache.go +++ b/pymage/internal/cache/cache.go @@ -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 { diff --git a/pymage/internal/cache/cache_test.go b/pymage/internal/cache/cache_test.go index 16cbe21..59061b0 100644 --- a/pymage/internal/cache/cache_test.go +++ b/pymage/internal/cache/cache_test.go @@ -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 { diff --git a/pymage/internal/cli/cli.go b/pymage/internal/cli/cli.go index 50996de..5ec235c 100644 --- a/pymage/internal/cli/cli.go +++ b/pymage/internal/cli/cli.go @@ -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 diff --git a/pymage/internal/cli/cli_test.go b/pymage/internal/cli/cli_test.go index 4679f7b..31c964e 100644 --- a/pymage/internal/cli/cli_test.go +++ b/pymage/internal/cli/cli_test.go @@ -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) } } diff --git a/pymage/internal/cli/defaults.go b/pymage/internal/cli/defaults.go index 21f511b..c90928e 100644 --- a/pymage/internal/cli/defaults.go +++ b/pymage/internal/cli/defaults.go @@ -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)") diff --git a/pymage/internal/project/project.go b/pymage/internal/project/project.go index 59f57d4..78ce2f9 100644 --- a/pymage/internal/project/project.go +++ b/pymage/internal/project/project.go @@ -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 }