1
0
Fork 0
mirror of https://github.com/imjasonh/terraform-playground synced 2026-07-07 23:35:16 +00:00
terraform-playground/pymage/internal/cli/defaults.go
Cursor Agent 7e8e2261a7
lock: evaluate PEP 508 markers and support --extra/--package in uv.lock closure
Co-authored-by: Jason Hall <imjasonh@users.noreply.github.com>
2026-06-11 01:30:45 +00:00

195 lines
5.8 KiB
Go

package cli
import (
"fmt"
"os"
"path"
"path/filepath"
"sort"
"strings"
"github.com/spf13/cobra"
"github.com/imjasonh/terraform-playground/pymage/internal/build"
"github.com/imjasonh/terraform-playground/pymage/internal/cache"
"github.com/imjasonh/terraform-playground/pymage/internal/project"
)
// applyDefaults fills omitted flags from the uv project in the source directory
// (the positional arg, default "."). Precedence is: explicit CLI flag >
// [tool.pymage] in pyproject.toml >
// built-in default. A flag counts as "explicit" only when the user actually
// passed it, which we detect via cobra's Changed() so flags with non-empty
// defaults (e.g. --workdir) can still be overridden by config.
func applyDefaults(cmd *cobra.Command, f *buildFlags) error {
if f.source == "" {
f.source = "."
}
proj, discoverErr := project.Discover(f.source)
cfg := proj.Config
changed := func(name string) bool { return cmd.Flags().Changed(name) }
if f.lockFile == "" {
if proj.LockFile == "" {
if discoverErr != nil {
return discoverErr
}
return fmt.Errorf("no lock file found (expected uv.lock in the source directory, or pass --lock)")
}
f.lockFile = proj.LockFile
}
if !changed("repo") && cfg.Repo != "" {
f.repo = cfg.Repo
}
if !changed("tag") && len(cfg.Tags) > 0 {
f.tags = cfg.Tags
}
if !changed("base") && cfg.Base != "" {
f.base = cfg.Base
}
if f.base == "" {
f.base = project.DefaultBase()
}
if !changed("platform") && len(cfg.Platforms) > 0 {
f.platforms = cfg.Platforms
}
if !changed("layer-strategy") && cfg.LayerStrategy != "" {
f.strategy = cfg.LayerStrategy
}
if !changed("max-layers") && cfg.MaxLayers != 0 {
f.maxLayers = cfg.MaxLayers
}
if !changed("max-wheel-layers") && cfg.MaxWheelLayers != 0 {
f.maxWheelLayers = cfg.MaxWheelLayers
}
if !changed("push-concurrency") && cfg.PushJobs != 0 {
f.pushJobs = cfg.PushJobs
}
if !changed("no-cache") && cfg.NoCache {
f.noCache = true
}
if !changed("extra") && len(cfg.Extras) > 0 {
f.extras = cfg.Extras
}
if !changed("package") && cfg.Package != "" {
f.pkg = cfg.Package
}
if !changed("python") && cfg.Python != "" {
f.pythonTag = cfg.Python
}
if !changed("prefix") && cfg.Prefix != "" {
f.prefix = cfg.Prefix
}
if !changed("workdir") && cfg.Workdir != "" {
f.workingDir = cfg.Workdir
}
if !changed("user") && cfg.User != "" {
f.user = cfg.User
}
if !changed("find-links") && len(cfg.FindLinks) > 0 {
f.findLinks = cfg.FindLinks
}
if !changed("cmd") && len(cfg.Cmd) > 0 {
f.cmd = cfg.Cmd
}
// Entrypoint: explicit flag > config > auto-detected console script.
if len(f.entrypoint) == 0 {
switch {
case len(cfg.Entrypoint) > 0:
f.entrypoint = cfg.Entrypoint
case len(proj.Entrypoint) > 0:
f.entrypoint = proj.Entrypoint
}
}
// Env is additive: auto-detected (PYTHONPATH) first, then config, then any
// --env flags last so they win on duplicate keys. The src-layout PYTHONPATH
// is derived from the resolved workdir, since the source is copied there.
var autoEnv []string
if proj.SrcLayout {
autoEnv = append(autoEnv, "PYTHONPATH="+path.Join(f.workingDir, "src"))
}
f.env = append(append(autoEnv, cfg.Env...), f.env...)
// Labels: config labels first, --label flags appended so they override.
if len(cfg.Labels) > 0 {
keys := make([]string, 0, len(cfg.Labels))
for k := range cfg.Labels {
keys = append(keys, k)
}
sort.Strings(keys)
merged := make([]string, 0, len(cfg.Labels)+len(f.labels))
for _, k := range keys {
merged = append(merged, k+"="+cfg.Labels[k])
}
f.labels = append(merged, f.labels...)
}
return nil
}
// setupCaches resolves the build caches. Caching is on by default; --no-cache
// disables persistent caching (downloads still work, via an ephemeral temp dir
// that the returned cleanup removes). The layer cache also serves the small
// metadata cache (interpreter detection), so they share one directory.
func setupCaches(f *buildFlags) (layer, meta *cache.Cache, wheelDir string, cleanup func(), err error) {
noop := func() {}
if f.noCache {
tmp, err := os.MkdirTemp("", "pymage-wheels-")
if err != nil {
return nil, nil, "", noop, err
}
return nil, nil, tmp, func() { _ = os.RemoveAll(tmp) }, nil
}
root := f.cacheDir
if root == "" {
root, err = project.DefaultCacheDir()
if err != nil {
return nil, nil, "", noop, err
}
}
c, err := cache.New(root)
if err != nil {
return nil, nil, "", noop, err
}
return c, c, filepath.Join(root, "wheels"), noop, nil
}
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)")
}
// "" is the zero value meaning the default (Auto); a 0 budget likewise means
// "use the default", so only reject genuinely invalid values.
switch f.strategy {
case "", string(build.Auto), string(build.PerWheel), string(build.SingleDepsLayer):
default:
return fmt.Errorf("invalid --layer-strategy %q (want auto, per-wheel, or single-deps-layer)", f.strategy)
}
if f.maxLayers < 0 {
return fmt.Errorf("--max-layers must be >= 0 (0 means use the default), got %d", f.maxLayers)
}
if f.maxWheelLayers < 0 {
return fmt.Errorf("--max-wheel-layers must be >= 0, got %d", f.maxWheelLayers)
}
if f.pushJobs < 0 {
return fmt.Errorf("--push-concurrency must be >= 0, got %d", f.pushJobs)
}
if len(f.entrypoint) == 0 {
return fmt.Errorf("no entrypoint: set [project.scripts] in pyproject.toml, add entrypoint to [tool.pymage], or pass --entrypoint")
}
if f.push {
if f.repo == "" {
return fmt.Errorf("no repo configured: set repo in [tool.pymage] (pyproject.toml) or pass --repo (or use --push=false)")
}
for _, t := range f.tags {
if strings.ContainsAny(t, "/:@") {
return fmt.Errorf("--tag/-t is a tag only, not a full reference: set the destination with --repo or [tool.pymage] repo (got %q)", t)
}
}
}
return nil
}