1
0
Fork 0
mirror of https://github.com/imjasonh/terraform-playground synced 2026-07-22 08:01:06 +00:00

cli: stdout is only the image ref; log blob push/skip to stderr

- output() writes the single by-digest reference (repo@sha256:...) to stdout so
  'docker run "$(pymage build)"' works; per-tag pointers and ggcr's per-blob
  progress (pushed/existing/mounted blob) go to stderr via logs.Progress
- adds TestBuildStdoutIsImageRef asserting the stdout contract and that blob
  logs land on stderr; documents it in the README
- completes and removes todo.txt

Co-authored-by: Jason Hall <imjasonh@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-06-10 21:28:10 +00:00
parent d587263759
commit 389bfca56b
No known key found for this signature in database
5 changed files with 172 additions and 60 deletions

View file

@ -44,7 +44,15 @@ pymage build ./example # build a different project directory (positional arg)
The project directory is the first positional argument (default: the current
directory).
`pymage` always publishes **by digest** and prints the resulting `repo@sha256:…`.
`pymage` always publishes **by digest** and prints the resulting `repo@sha256:…`
as the **only** thing on stdout — progress (per-blob `pushed`/`existing`/`mounted`
logs, per-tag pointers, diagnostics) goes to stderr — so you can run the image
directly:
```
docker run "$(pymage build)"
```
`-t/--tag` is the **tag component only**, never a full reference — the repo comes
from `[tool.pymage] repo` (or `--repo`). If no `-t` is given it uses
`[tool.pymage] tags`, defaulting to `latest`.

View file

@ -9,6 +9,7 @@ import (
"strings"
"github.com/google/go-containerregistry/pkg/authn"
"github.com/google/go-containerregistry/pkg/logs"
"github.com/google/go-containerregistry/pkg/name"
v1 "github.com/google/go-containerregistry/pkg/v1"
"github.com/google/go-containerregistry/pkg/v1/empty"
@ -109,6 +110,12 @@ func buildCmd() *cobra.Command {
func runBuild(cmd *cobra.Command, f *buildFlags) error {
ctx := cmd.Context()
// Keep stdout clean (only the final image ref): route go-containerregistry's
// per-blob progress ("existing blob", "mounted blob", "pushed blob") and
// warnings to stderr.
logs.Progress.SetOutput(cmd.ErrOrStderr())
logs.Warn.SetOutput(cmd.ErrOrStderr())
if err := applyDefaults(cmd, f); err != nil {
return err
}
@ -197,9 +204,9 @@ func runBuild(cmd *cobra.Command, f *buildFlags) error {
if len(platforms) > 1 {
idx := mutate.AppendManifests(empty.Index, adds...)
return output(ctx, f, nameOpts, nil, idx)
return output(cmd, f, nameOpts, nil, idx)
}
return output(ctx, f, nameOpts, images[0], nil)
return output(cmd, f, nameOpts, images[0], nil)
}
// buildOne resolves the base and wheels for a single platform and builds the
@ -272,76 +279,92 @@ func resolveInterpreter(f *buildFlags, base v1.Image) (major, minor int, pyTag s
// output writes/pushes either a single image or a multi-arch index (exactly one
// of img/idx is non-nil).
func output(ctx context.Context, f *buildFlags, nameOpts []name.Option, img v1.Image, idx v1.ImageIndex) error {
var (
digest v1.Hash
err error
)
if idx != nil {
digest, err = idx.Digest()
} else {
digest, err = img.Digest()
}
//
// stdout carries exactly one line — the resulting image reference — so callers
// can run `docker run "$(pymage build)"`. All progress/diagnostic output goes
// to stderr.
func output(cmd *cobra.Command, f *buildFlags, nameOpts []name.Option, img v1.Image, idx v1.ImageIndex) error {
ctx := cmd.Context()
stdout := cmd.OutOrStdout()
stderr := cmd.ErrOrStderr()
digest, err := artifactDigest(img, idx)
if err != nil {
return err
}
if f.ociLayout != "" {
p, err := layout.Write(f.ociLayout, empty.Index)
if err != nil {
return err
}
if idx != nil {
err = p.AppendIndex(idx)
} else {
err = p.AppendImage(img)
}
if err != nil {
if err := writeLayout(f.ociLayout, img, idx); err != nil {
return err
}
_, _ = fmt.Fprintf(stderr, "wrote OCI layout to %s\n", f.ociLayout)
}
if f.printDigest {
fmt.Println(digest.String())
_, _ = fmt.Fprintln(stdout, digest.String())
return nil
}
if f.push {
if f.repo == "" {
return fmt.Errorf("no repo configured: set repo in [tool.pymage] or pass --repo (or use --push=false)")
}
tags := f.tags
if len(tags) == 0 {
tags = []string{"latest"}
}
opts := []remote.Option{
remote.WithContext(ctx),
remote.WithAuthFromKeychain(authn.DefaultKeychain),
}
// The image/index is pushed by content; each tag is just another
// pointer to the same digest.
for _, t := range tags {
ref, err := name.NewTag(f.repo+":"+t, nameOpts...)
if err != nil {
return err
}
if idx != nil {
err = remote.WriteIndex(ref, idx, opts...)
} else {
err = remote.Write(ref, img, opts...)
}
if err != nil {
return fmt.Errorf("push %s: %w", ref, err)
}
fmt.Printf("pushed %s@%s (tag %s)\n", ref.Context().Name(), digest, t)
}
if !f.push {
_, _ = fmt.Fprintln(stdout, digest.String())
return nil
}
fmt.Println(digest.String())
if f.repo == "" {
return fmt.Errorf("no repo configured: set repo in [tool.pymage] or pass --repo (or use --push=false)")
}
repo, err := name.NewRepository(f.repo, nameOpts...)
if err != nil {
return err
}
tags := f.tags
if len(tags) == 0 {
tags = []string{"latest"}
}
opts := []remote.Option{
remote.WithContext(ctx),
remote.WithAuthFromKeychain(authn.DefaultKeychain),
}
// The artifact is pushed by content; each tag is just another pointer to
// the same digest.
for _, t := range tags {
ref := repo.Tag(t)
if err := writeArtifact(ref, img, idx, opts...); err != nil {
return fmt.Errorf("push %s: %w", ref, err)
}
_, _ = fmt.Fprintf(stderr, "tagged %s -> %s@%s\n", ref, repo.Name(), digest)
}
// The sole stdout line: the immutable by-digest reference.
_, _ = fmt.Fprintf(stdout, "%s@%s\n", repo.Name(), digest)
return nil
}
func artifactDigest(img v1.Image, idx v1.ImageIndex) (v1.Hash, error) {
if idx != nil {
return idx.Digest()
}
return img.Digest()
}
func writeArtifact(ref name.Reference, img v1.Image, idx v1.ImageIndex, opts ...remote.Option) error {
if idx != nil {
return remote.WriteIndex(ref, idx, opts...)
}
return remote.Write(ref, img, opts...)
}
func writeLayout(dir string, img v1.Image, idx v1.ImageIndex) error {
p, err := layout.Write(dir, empty.Index)
if err != nil {
return err
}
if idx != nil {
return p.AppendIndex(idx)
}
return p.AppendImage(img)
}
// parsePlatforms parses the (comma-split, repeatable) --platform values.
func parsePlatforms(specs []string) ([]v1.Platform, error) {
var out []v1.Platform

View file

@ -1,6 +1,7 @@
package cli
import (
"bytes"
"context"
"fmt"
"net/http/httptest"
@ -282,6 +283,88 @@ func TestPushRequiresRepo(t *testing.T) {
}
}
// TestBuildStdoutIsImageRef checks the contract that `pymage build` prints
// exactly the pullable by-digest reference to stdout (so `docker run
// "$(pymage build)"` works), while progress/diagnostics — including per-blob
// push/skip logs and per-tag lines — go to stderr.
func TestBuildStdoutIsImageRef(t *testing.T) {
ctx := context.Background()
s := httptest.NewServer(registry.New())
t.Cleanup(s.Close)
host := strings.TrimPrefix(s.URL, "http://")
baseRef := host + "/base:latest"
base, err := mutate.ConfigFile(empty.Image, &v1.ConfigFile{
OS: "linux", Architecture: "amd64",
Config: v1.Config{Env: []string{"PYTHON_VERSION=3.12.7", "PATH=/usr/bin"}},
})
if err != nil {
t.Fatal(err)
}
br, _ := name.ParseReference(baseRef)
if err := remote.Write(br, base, remote.WithContext(ctx)); err != nil {
t.Fatal(err)
}
wh := t.TempDir()
_, sha := testwheel.Write(t, wh, testwheel.Spec{Name: "alpha", Version: "1.0", Modules: map[string]string{"alpha/__init__.py": "V='1'\n"}})
reqFile := filepath.Join(t.TempDir(), "requirements.txt")
if err := os.WriteFile(reqFile, []byte(fmt.Sprintf("alpha==1.0 --hash=sha256:%s\n", sha)), 0o644); err != nil {
t.Fatal(err)
}
src := t.TempDir()
if err := os.WriteFile(filepath.Join(src, "app.py"), []byte("print('hi')\n"), 0o644); err != nil {
t.Fatal(err)
}
var stdout, stderr bytes.Buffer
cmd := Root()
cmd.SetArgs([]string{
"build", src,
"--base", baseRef,
"--lock", reqFile,
"--find-links", wh,
"--entrypoint", "python", "--entrypoint", "/app/app.py",
"--repo", host + "/app",
"-t", "v1", "-t", "v2",
})
cmd.SetOut(&stdout)
cmd.SetErr(&stderr)
if err := cmd.Execute(); err != nil {
t.Fatalf("build failed: %v\nstderr:\n%s", err, stderr.String())
}
out := strings.TrimRight(stdout.String(), "\n")
if strings.Contains(out, "\n") {
t.Fatalf("stdout must be a single line, got:\n%q", out)
}
if !strings.HasPrefix(out, host+"/app@sha256:") {
t.Fatalf("stdout = %q, want %q@sha256:...", out, host+"/app")
}
// The printed ref must be pullable and its digest must match the image.
ref, err := name.ParseReference(out)
if err != nil {
t.Fatalf("parse stdout ref %q: %v", out, err)
}
img, err := remote.Image(ref, remote.WithContext(ctx))
if err != nil {
t.Fatalf("pull %q: %v", out, err)
}
d, _ := img.Digest()
if !strings.HasSuffix(out, d.String()) {
t.Errorf("stdout ref %q does not end with image digest %s", out, d)
}
// stderr should carry the per-tag pointers and ggcr's per-blob progress.
se := stderr.String()
for _, want := range []string{"tagged", "v1", "v2", "blob"} {
if !strings.Contains(se, want) {
t.Errorf("stderr missing %q; got:\n%s", want, se)
}
}
}
func TestParsePythonTag(t *testing.T) {
maj, min, err := parsePythonTag("python3.12")
if err != nil || maj != 3 || min != 12 {

View file

@ -14,10 +14,10 @@ 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"`
}
type uvArtifact struct {

View file

@ -1,2 +0,0 @@
- the only stdout should be just the full digest ref of the image, so folks can "docker run $(pymage build)"
- log layers+blobs as they're pushed, or skipped