2026-06-10 13:30:05 +00:00
// Package cli implements the pymage command line.
2026-06-10 11:03:19 +00:00
package cli
import (
"context"
"fmt"
"os"
2026-06-10 23:55:43 +00:00
"runtime"
2026-06-10 14:07:25 +00:00
"strconv"
2026-06-10 11:03:19 +00:00
"strings"
"github.com/google/go-containerregistry/pkg/authn"
2026-06-10 21:28:10 +00:00
"github.com/google/go-containerregistry/pkg/logs"
2026-06-10 11:03:19 +00:00
"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/layout"
2026-06-10 15:45:36 +00:00
"github.com/google/go-containerregistry/pkg/v1/mutate"
2026-06-10 11:03:19 +00:00
"github.com/google/go-containerregistry/pkg/v1/remote"
"github.com/spf13/cobra"
2026-06-10 13:30:05 +00:00
"github.com/imjasonh/terraform-playground/pymage/internal/build"
2026-06-10 14:07:25 +00:00
"github.com/imjasonh/terraform-playground/pymage/internal/cache"
2026-06-10 13:30:05 +00:00
"github.com/imjasonh/terraform-playground/pymage/internal/lock"
"github.com/imjasonh/terraform-playground/pymage/internal/sbom"
"github.com/imjasonh/terraform-playground/pymage/internal/wheel"
"github.com/imjasonh/terraform-playground/pymage/internal/wheelhouse"
2026-06-10 11:03:19 +00:00
)
// Root returns the root cobra command.
func Root ( ) * cobra . Command {
root := & cobra . Command {
2026-06-10 13:30:05 +00:00
Use : "pymage" ,
2026-06-10 11:03:19 +00:00
Short : "Docker-less, layer-aware Python container image builder" ,
SilenceUsage : true ,
SilenceErrors : true ,
}
root . AddCommand ( buildCmd ( ) )
return root
}
type buildFlags struct {
base string
lockFile string
findLinks [ ] string
source string
2026-06-10 17:05:36 -04:00
repo string
tags [ ] string
2026-06-10 15:45:36 +00:00
platforms [ ] string
2026-06-11 01:30:45 +00:00
extras [ ] string
pkg string
2026-06-10 11:03:19 +00:00
pythonTag string
prefix string
workingDir string
entrypoint [ ] string
cmd [ ] string
env [ ] string
user string
labels [ ] string
ignore [ ] string
strategy string
2026-06-10 23:47:57 +00:00
maxLayers int
maxWheelLayers int
2026-06-10 23:55:43 +00:00
pushJobs int
2026-06-10 23:47:57 +00:00
2026-06-10 11:03:19 +00:00
push bool
ociLayout string
printDigest bool
sbomOut string
requireHash bool
2026-06-10 11:09:36 +00:00
insecure bool
2026-06-10 14:07:25 +00:00
cacheDir string
2026-06-11 00:42:36 +00:00
noCache bool
2026-06-10 11:03:19 +00:00
}
func buildCmd ( ) * cobra . Command {
f := & buildFlags { }
cmd := & cobra . Command {
2026-06-10 17:05:36 -04:00
Use : "build [source]" ,
2026-06-10 11:03:19 +00:00
Short : "Build a Python application image and push it" ,
2026-06-10 17:05:36 -04:00
Long : "Build a Python application image and push it.\n\n" +
"[source] is the uv project directory to build (default: current directory)." ,
Args : cobra . MaximumNArgs ( 1 ) ,
RunE : func ( cmd * cobra . Command , args [ ] string ) error {
if len ( args ) == 1 {
f . source = args [ 0 ]
}
return runBuild ( cmd , f )
2026-06-10 11:03:19 +00:00
} ,
}
fs := cmd . Flags ( )
2026-06-10 17:05:36 -04:00
fs . StringVar ( & f . base , "base" , "" , "base image reference (default: cgr.dev/chainguard/python:latest)" )
fs . StringVar ( & f . lockFile , "lock" , "" , "lock file (default: uv.lock in the source directory)" )
2026-06-11 01:30:45 +00:00
fs . StringArrayVar ( & f . extras , "extra" , nil , "enable a project optional-dependency group from uv.lock (repeatable)" )
fs . StringVar ( & f . pkg , "package" , "" , "build a single uv workspace member (by name)" )
2026-06-10 17:05:36 -04:00
fs . StringArrayVar ( & f . findLinks , "find-links" , nil , "local wheel directory (optional; wheels are downloaded from the lock when omitted)" )
fs . StringVar ( & f . repo , "repo" , "" , "destination repository, pushed by digest, e.g. gcr.io/foo/bar (default: [tool.pymage] repo)" )
fs . StringArrayVarP ( & f . tags , "tag" , "t" , nil , "tag(s) to apply at --repo; tag component only, not a full reference (repeatable; default: [tool.pymage] tags or 'latest')" )
2026-06-10 15:45:36 +00:00
fs . StringSliceVar ( & f . platforms , "platform" , nil , "target platform(s); repeatable or comma-separated, e.g. linux/amd64,linux/arm64 (multiple builds a multi-arch index)" )
2026-06-10 16:24:07 +00:00
fs . StringVar ( & f . pythonTag , "python" , "" , "interpreter version, e.g. python3.12 (default: auto-detected from the base image; if set, must match the base)" )
2026-06-10 11:03:19 +00:00
fs . StringVar ( & f . prefix , "prefix" , "/app/.venv" , "install prefix (venv-like root)" )
fs . StringVar ( & f . workingDir , "workdir" , "/app" , "image working directory and source destination" )
fs . StringArrayVar ( & f . entrypoint , "entrypoint" , nil , "entrypoint argv (repeatable)" )
fs . StringArrayVar ( & f . cmd , "cmd" , nil , "cmd argv (repeatable)" )
fs . StringArrayVar ( & f . env , "env" , nil , "extra KEY=VALUE env (repeatable)" )
fs . StringVar ( & f . user , "user" , "" , "image user, e.g. 65532" )
fs . StringArrayVar ( & f . labels , "label" , nil , "image label KEY=VALUE (repeatable)" )
fs . StringArrayVar ( & f . ignore , "ignore" , nil , "extra source ignore glob (repeatable)" )
2026-06-10 23:47:57 +00:00
fs . StringVar ( & f . strategy , "layer-strategy" , string ( build . Auto ) , "auto | per-wheel | single-deps-layer" )
fs . IntVar ( & f . maxLayers , "max-layers" , build . DefaultMaxLayers , "max total image layers (base + deps + app) for the auto strategy" )
fs . IntVar ( & f . maxWheelLayers , "max-wheel-layers" , 0 , "cap the number of dependency layers directly (overrides --max-layers when > 0)" )
2026-06-10 23:55:43 +00:00
fs . IntVar ( & f . pushJobs , "push-concurrency" , 0 , "max concurrent layer uploads when pushing (0 = auto)" )
2026-06-10 17:05:36 -04:00
fs . BoolVar ( & f . push , "push" , true , "push the image to --repo (by digest)" )
2026-06-10 11:03:19 +00:00
fs . StringVar ( & f . ociLayout , "oci-layout" , "" , "also write the image to this OCI layout directory" )
fs . BoolVar ( & f . printDigest , "print-digest" , false , "print only the resulting image digest" )
fs . StringVar ( & f . sbomOut , "sbom" , "" , "write a CycloneDX SBOM to this path" )
fs . BoolVar ( & f . requireHash , "require-hashes" , true , "require every requirement to carry --hash entries" )
2026-06-10 11:09:36 +00:00
fs . BoolVar ( & f . insecure , "insecure" , false , "use plain HTTP for the registry" )
2026-06-11 00:42:36 +00:00
fs . StringVar ( & f . cacheDir , "cache-dir" , "" , "cache root directory (default: per-user cache dir)" )
fs . BoolVar ( & f . noCache , "no-cache" , false , "disable all build caches (layers, downloaded wheels, interpreter detection)" )
2026-06-10 11:03:19 +00:00
return cmd
}
2026-06-10 17:05:36 -04:00
func runBuild ( cmd * cobra . Command , f * buildFlags ) error {
ctx := cmd . Context ( )
2026-06-10 21:28:10 +00:00
// 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 ( ) )
2026-06-10 17:05:36 -04:00
if err := applyDefaults ( cmd , f ) ; err != nil {
return err
2026-06-10 11:03:19 +00:00
}
2026-06-10 17:05:36 -04:00
if err := validateBuildFlags ( f ) ; err != nil {
return err
2026-06-10 11:03:19 +00:00
}
2026-06-10 14:07:25 +00:00
if _ , err := keyValues ( f . env ) ; err != nil {
return fmt . Errorf ( "--env: %w" , err )
}
labels , err := keyValues ( f . labels )
if err != nil {
return fmt . Errorf ( "--label: %w" , err )
}
2026-06-11 01:30:45 +00:00
lk , err := lock . Load ( f . lockFile )
2026-06-10 11:03:19 +00:00
if err != nil {
return err
}
2026-06-10 15:45:36 +00:00
platforms , err := parsePlatforms ( f . platforms )
2026-06-10 14:07:25 +00:00
if err != nil {
return err
}
2026-06-10 11:09:36 +00:00
var nameOpts [ ] name . Option
if f . insecure {
nameOpts = append ( nameOpts , name . Insecure )
}
2026-06-11 00:34:16 +00:00
// 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
}
2026-06-10 21:39:40 +00:00
// With no platform requested, default to whatever the base image supports
// (a multi-arch base yields a multi-arch build).
if len ( platforms ) == 0 {
2026-06-11 00:34:16 +00:00
platforms = baseSet . Platforms ( )
2026-06-10 21:39:40 +00:00
}
2026-06-11 00:42:36 +00:00
// Caching is on by default (layers, downloaded wheels, and interpreter
// detection); --no-cache disables it.
layerCache , metaCache , wheelCache , cleanup , err := setupCaches ( f )
2026-06-10 17:05:36 -04:00
if err != nil {
return err
}
2026-06-11 00:42:36 +00:00
defer cleanup ( )
2026-06-10 11:03:19 +00:00
2026-06-10 15:45:36 +00:00
// 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
// a plain image manifest.
var (
images [ ] v1 . Image
adds [ ] mutate . IndexAddendum
allDeps [ ] wheelhouse . ResolvedWheel
)
targets := platforms
for i := range targets {
p := targets [ i ]
var pp * v1 . Platform
if p . OS != "" || p . Architecture != "" {
2026-06-10 17:05:36 -04:00
pp = & targets [ i ]
2026-06-10 15:45:36 +00:00
}
2026-06-11 00:34:16 +00:00
base , err := baseSet . Image ( pp )
if err != nil {
return err
}
2026-06-11 01:30:45 +00:00
img , deps , err := buildOne ( ctx , f , lk , pp , base , labels , layerCache , metaCache , wheelCache )
2026-06-10 15:45:36 +00:00
if err != nil {
return err
}
images = append ( images , img )
allDeps = append ( allDeps , deps ... )
desc := v1 . Descriptor { }
if pp != nil {
desc . Platform = pp
}
adds = append ( adds , mutate . IndexAddendum { Add : img , Descriptor : desc } )
}
if f . sbomOut != "" {
data , err := sbom . Generate ( dedupeWheels ( allDeps ) )
if err != nil {
return err
}
if err := os . WriteFile ( f . sbomOut , data , 0 o644 ) ; err != nil {
return err
}
}
if len ( platforms ) > 1 {
idx := mutate . AppendManifests ( empty . Index , adds ... )
2026-06-10 21:28:10 +00:00
return output ( cmd , f , nameOpts , nil , idx )
2026-06-10 15:45:36 +00:00
}
2026-06-10 21:28:10 +00:00
return output ( cmd , f , nameOpts , images [ 0 ] , nil )
2026-06-10 15:45:36 +00:00
}
2026-06-11 00:34:16 +00:00
// buildOne resolves wheels for a single platform and builds the image from the
// already-resolved base, returning the resolved wheels for SBOM aggregation.
2026-06-11 01:30:45 +00:00
func buildOne ( ctx context . Context , f * buildFlags , lk * lock . Lock , platform * v1 . Platform , base v1 . Image , labels map [ string ] string , layerCache , metaCache * cache . Cache , wheelCache string ) ( v1 . Image , [ ] wheelhouse . ResolvedWheel , error ) {
2026-06-10 16:24:07 +00:00
// Determine the interpreter: honor --python (validated against the base) or
// auto-detect it from the base image.
2026-06-11 00:34:16 +00:00
major , minor , pyTag , err := resolveInterpreter ( f , base , metaCache )
2026-06-10 15:45:36 +00:00
if err != nil {
return nil , nil , err
}
2026-06-10 16:24:07 +00:00
target := platformTarget ( platform )
target . PyMajor , target . PyMinor = major , minor
2026-06-11 01:30:45 +00:00
// Resolve the lock for this target (uv.lock runtime closure honors
// --package, --extra, and the target's environment markers).
reqs , err := lk . Resolve ( lock . Options {
Package : f . pkg , Extras : f . extras ,
OS : target . OS , Arch : target . Arch , PyMajor : target . PyMajor , PyMinor : target . PyMinor ,
} )
if err != nil {
return nil , nil , err
}
if err := lock . Validate ( reqs , f . requireHash ) ; err != nil {
return nil , nil , err
}
2026-06-10 17:05:36 -04:00
wheels , err := wheelhouse . ResolveContext ( ctx , reqs , f . findLinks , target , wheelCache )
2026-06-10 15:45:36 +00:00
if err != nil {
return nil , nil , err
}
2026-06-10 11:03:19 +00:00
img , err := build . Build ( build . Options {
2026-06-10 23:47:57 +00:00
Base : base ,
Wheels : wheels ,
Layout : wheel . Layout { Prefix : f . prefix , PythonTag : pyTag } ,
Strategy : build . LayerStrategy ( f . strategy ) ,
MaxLayers : f . maxLayers ,
MaxWheelLayers : f . maxWheelLayers ,
SourceDir : f . source ,
Ignore : f . ignore ,
WorkingDir : f . workingDir ,
Entrypoint : f . entrypoint ,
Cmd : f . cmd ,
Env : f . env ,
User : f . user ,
Labels : labels ,
Cache : layerCache ,
2026-06-10 11:03:19 +00:00
} )
if err != nil {
2026-06-10 15:45:36 +00:00
return nil , nil , err
2026-06-10 11:03:19 +00:00
}
2026-06-10 15:45:36 +00:00
return img , wheels , nil
}
2026-06-10 11:03:19 +00:00
2026-06-10 16:24:07 +00:00
// resolveInterpreter decides the target Python version and the site-packages
// 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.
2026-06-11 00:34:16 +00:00
func resolveInterpreter ( f * buildFlags , base v1 . Image , metaCache * cache . Cache ) ( major , minor int , pyTag string , err error ) {
baseMaj , baseMin , baseOK := cachedInterpreter ( base , metaCache )
2026-06-10 16:24:07 +00:00
if f . pythonTag != "" {
maj , min , err := parsePythonTag ( f . pythonTag )
if err != nil {
return 0 , 0 , "" , err
}
if baseOK && ( baseMaj != maj || baseMin != min ) {
return 0 , 0 , "" , fmt . Errorf ( "base %q provides Python %d.%d but --python is python%d.%d; pass --python python%d.%d (or pin a matching base)" ,
f . base , baseMaj , baseMin , maj , min , baseMaj , baseMin )
}
return maj , min , f . pythonTag , nil
}
if ! baseOK {
return 0 , 0 , "" , fmt . Errorf ( "could not determine the base image's Python version (no PYTHON_VERSION env or python package in /etc/apko.json); pass --python pythonX.Y" )
}
return baseMaj , baseMin , fmt . Sprintf ( "python%d.%d" , baseMaj , baseMin ) , nil
}
2026-06-10 15:45:36 +00:00
// output writes/pushes either a single image or a multi-arch index (exactly one
// of img/idx is non-nil).
2026-06-10 21:28:10 +00:00
//
// 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 )
2026-06-10 11:03:19 +00:00
if err != nil {
return err
}
if f . ociLayout != "" {
2026-06-10 21:28:10 +00:00
if err := writeLayout ( f . ociLayout , img , idx ) ; err != nil {
2026-06-10 11:03:19 +00:00
return err
}
2026-06-10 21:28:10 +00:00
_ , _ = fmt . Fprintf ( stderr , "wrote OCI layout to %s\n" , f . ociLayout )
2026-06-10 11:03:19 +00:00
}
if f . printDigest {
2026-06-10 21:28:10 +00:00
_ , _ = fmt . Fprintln ( stdout , digest . String ( ) )
2026-06-10 11:03:19 +00:00
return nil
}
2026-06-10 21:28:10 +00:00
if ! f . push {
_ , _ = fmt . Fprintln ( stdout , digest . String ( ) )
2026-06-10 11:03:19 +00:00
return nil
}
2026-06-10 21:28:10 +00:00
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" }
}
2026-06-10 23:58:03 +00:00
var artifact remote . Taggable = img
if idx != nil {
artifact = idx
}
// A single Pusher is shared across all tags: it uploads layers (and, for an
// index, child manifests) concurrently, and reuses its per-repo upload state
// so identical blobs are only checked/uploaded once even across tags.
pusher , err := remote . NewPusher (
2026-06-10 21:28:10 +00:00
remote . WithAuthFromKeychain ( authn . DefaultKeychain ) ,
2026-06-10 23:55:43 +00:00
remote . WithJobs ( pushConcurrency ( f . pushJobs ) ) ,
2026-06-10 23:58:03 +00:00
)
if err != nil {
return err
2026-06-10 21:28:10 +00:00
}
// The artifact is pushed by content; each tag is just another pointer to
// the same digest.
for _ , t := range tags {
ref := repo . Tag ( t )
2026-06-10 23:58:03 +00:00
if err := pusher . Push ( ctx , ref , artifact ) ; err != nil {
2026-06-10 21:28:10 +00:00
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 )
2026-06-10 11:03:19 +00:00
return nil
}
2026-06-10 21:28:10 +00:00
func artifactDigest ( img v1 . Image , idx v1 . ImageIndex ) ( v1 . Hash , error ) {
if idx != nil {
return idx . Digest ( )
}
return img . Digest ( )
}
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 )
}
2026-06-11 00:34:16 +00:00
// 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
}
2026-06-10 21:39:40 +00:00
}
2026-06-11 00:34:16 +00:00
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
2026-06-10 21:39:40 +00:00
}
2026-06-10 23:55:43 +00:00
// pushConcurrency resolves the number of concurrent layer uploads. 0 means
// auto: scale with CPU count but never below go-containerregistry's default of
// 4 (layer uploads are network-bound, so some parallelism always helps).
func pushConcurrency ( requested int ) int {
if requested > 0 {
return requested
}
n := runtime . NumCPU ( )
if n < 4 {
n = 4
}
return n
}
2026-06-10 15:45:36 +00:00
// parsePlatforms parses the (comma-split, repeatable) --platform values.
func parsePlatforms ( specs [ ] string ) ( [ ] v1 . Platform , error ) {
var out [ ] v1 . Platform
for _ , s := range specs {
s = strings . TrimSpace ( s )
if s == "" {
continue
}
p , err := v1 . ParsePlatform ( s )
if err != nil {
return nil , err
}
out = append ( out , * p )
}
return out , nil
}
// dedupeWheels removes duplicate wheels (same name+version+sha) so a multi-arch
// SBOM doesn't list shared pure-python wheels multiple times.
func dedupeWheels ( in [ ] wheelhouse . ResolvedWheel ) [ ] wheelhouse . ResolvedWheel {
seen := map [ string ] bool { }
var out [ ] wheelhouse . ResolvedWheel
for _ , w := range in {
k := w . Name + "\x00" + w . Version + "\x00" + w . SHA256
if seen [ k ] {
continue
}
seen [ k ] = true
out = append ( out , w )
}
return out
}
2026-06-10 16:24:07 +00:00
// platformTarget derives the OS/arch part of the wheel-selection target from
// the --platform value, defaulting to linux/amd64. The interpreter fields are
// filled in by the caller.
func platformTarget ( platform * v1 . Platform ) wheel . Target {
2026-06-10 14:07:25 +00:00
t := wheel . Target { OS : "linux" , Arch : "amd64" }
if platform != nil {
if platform . OS != "" {
t . OS = platform . OS
}
if platform . Architecture != "" {
t . Arch = platform . Architecture
}
}
2026-06-10 16:24:07 +00:00
return t
2026-06-10 14:07:25 +00:00
}
2026-06-11 00:01:44 +00:00
// parsePythonTag parses "python3.12" into (3, 12). The tag must start with
// "python" because it is also used verbatim as the site-packages lib directory
// (".../lib/python3.12/site-packages").
2026-06-10 14:07:25 +00:00
func parsePythonTag ( tag string ) ( int , int , error ) {
2026-06-11 00:01:44 +00:00
s , ok := strings . CutPrefix ( tag , "python" )
if ! ok {
return 0 , 0 , fmt . Errorf ( "--python %q must look like pythonX.Y" , tag )
}
2026-06-10 14:07:25 +00:00
majStr , minStr , ok := strings . Cut ( s , "." )
if ! ok {
return 0 , 0 , fmt . Errorf ( "--python %q must look like pythonX.Y" , tag )
}
maj , err := strconv . Atoi ( majStr )
if err != nil {
return 0 , 0 , fmt . Errorf ( "--python %q: bad major version" , tag )
}
min , err := strconv . Atoi ( minStr )
if err != nil {
return 0 , 0 , fmt . Errorf ( "--python %q: bad minor version" , tag )
}
return maj , min , nil
}
2026-06-10 11:03:19 +00:00
func keyValues ( in [ ] string ) ( map [ string ] string , error ) {
if len ( in ) == 0 {
return nil , nil
}
out := map [ string ] string { }
for _ , kv := range in {
k , v , ok := strings . Cut ( kv , "=" )
2026-06-10 14:35:37 +00:00
if ! ok || k == "" {
return nil , fmt . Errorf ( "expected KEY=VALUE with a non-empty key, got %q" , kv )
2026-06-10 11:03:19 +00:00
}
out [ k ] = v
}
return out , nil
}