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

fix: runnable entrypoint for app console scripts + keep deps importable

- project: a [project.scripts] entry becomes a direct 'python -c "from mod
  import fn; fn()"' (or 'python -m') invocation instead of the bare script
  name, which pymage never installs as a launcher (fixes 'exec: "example":
  executable file not found')
- build: PYTHONPATH is now additive (site-packages + base + app /app/src) so an
  app PYTHONPATH no longer clobbers the venv site-packages (deps stayed
  unimportable)
- tests for script-target translation and PYTHONPATH accumulation

Co-authored-by: Jason Hall <imjasonh@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-06-10 21:53:36 +00:00
parent bec24b79ca
commit 341914e488
No known key found for this signature in database
4 changed files with 159 additions and 24 deletions

View file

@ -405,28 +405,44 @@ func mergeEnv(base []string, layout wheel.Layout, extra []string) []string {
} }
env[k] = v env[k] = v
} }
for _, kv := range base {
// PYTHONPATH is a path list, so we accumulate contributions (site-packages
// first, then base, then extras such as the app's "/app/src") rather than
// letting a later source overwrite it.
pythonPath := []string{site}
addPyPath := func(v string) {
for _, p := range strings.Split(v, ":") {
if p != "" {
pythonPath = append(pythonPath, p)
}
}
}
apply := func(kv string) {
k, v, _ := strings.Cut(kv, "=") k, v, _ := strings.Cut(kv, "=")
if k == "PYTHONPATH" {
addPyPath(v)
return
}
set(k, v) set(k, v)
} }
for _, kv := range base {
apply(kv)
}
if existing, ok := env["PATH"]; ok && existing != "" { if existing, ok := env["PATH"]; ok && existing != "" {
set("PATH", bin+":"+existing) set("PATH", bin+":"+existing)
} else { } else {
set("PATH", bin+":/usr/local/bin:/usr/bin:/bin") set("PATH", bin+":/usr/local/bin:/usr/bin:/bin")
} }
set("VIRTUAL_ENV", prefix) set("VIRTUAL_ENV", prefix)
if existing, ok := env["PYTHONPATH"]; ok && existing != "" {
set("PYTHONPATH", site+":"+existing)
} else {
set("PYTHONPATH", site)
}
for _, kv := range extra { for _, kv := range extra {
k, v, _ := strings.Cut(kv, "=") apply(kv)
set(k, v)
} }
set("PYTHONPATH", strings.Join(dedupe(pythonPath), ":"))
out := make([]string, 0, len(order)) out := make([]string, 0, len(order))
for _, k := range order { for _, k := range order {
out = append(out, k+"="+env[k]) out = append(out, k+"="+env[k])
@ -434,6 +450,20 @@ func mergeEnv(base []string, layout wheel.Layout, extra []string) []string {
return out return out
} }
// dedupe returns s with duplicate entries removed, preserving first-seen order.
func dedupe(s []string) []string {
seen := map[string]bool{}
out := s[:0]
for _, v := range s {
if seen[v] {
continue
}
seen[v] = true
out = append(out, v)
}
return out
}
func history(createdBy string) v1.History { func history(createdBy string) v1.History {
return v1.History{ return v1.History{
Created: v1.Time{Time: epoch()}, Created: v1.Time{Time: epoch()},

View file

@ -305,6 +305,42 @@ func TestBuildWithCacheIsConsistentAndPopulates(t *testing.T) {
} }
} }
// TestEnvPythonPathAccumulates ensures an extra PYTHONPATH (e.g. the app's
// "/app/src") is appended to the venv site-packages, not substituted for it —
// otherwise installed dependencies would be unimportable.
func TestEnvPythonPathAccumulates(t *testing.T) {
dir := t.TempDir()
wheels := []wheelhouse.ResolvedWheel{mkWheel(t, dir, "alpha", "1.0")}
opts := baseOpts(wheels)
opts.Env = []string{"PYTHONPATH=/app/src"}
img, err := Build(opts)
if err != nil {
t.Fatal(err)
}
cf, err := img.ConfigFile()
if err != nil {
t.Fatal(err)
}
var pythonPath string
for _, kv := range cf.Config.Env {
if k, v, _ := strings.Cut(kv, "="); k == "PYTHONPATH" {
pythonPath = v
}
}
site := "/app/.venv/lib/python3.12/site-packages"
if !strings.Contains(pythonPath, site) {
t.Errorf("PYTHONPATH %q missing site-packages %q", pythonPath, site)
}
if !strings.Contains(pythonPath, "/app/src") {
t.Errorf("PYTHONPATH %q missing app path /app/src", pythonPath)
}
// site-packages must come first so installed deps take precedence.
if !strings.HasPrefix(pythonPath, site) {
t.Errorf("PYTHONPATH %q should start with site-packages", pythonPath)
}
}
func TestInterpreterVersion(t *testing.T) { func TestInterpreterVersion(t *testing.T) {
withEnv := func(env []string) v1.Image { withEnv := func(env []string) v1.Image {
img, err := mutate.ConfigFile(empty.Image, &v1.ConfigFile{Config: v1.Config{Env: env}}) img, err := mutate.ConfigFile(empty.Image, &v1.ConfigFile{Config: v1.Config{Env: env}})

View file

@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"sort"
"strings" "strings"
"github.com/pelletier/go-toml/v2" "github.com/pelletier/go-toml/v2"
@ -105,20 +106,15 @@ func entrypointFromPyproject(pp pyProject, root string) ([]string, []string) {
extraEnv = append(extraEnv, "PYTHONPATH=/app/src") extraEnv = append(extraEnv, "PYTHONPATH=/app/src")
} }
if len(pp.Project.Scripts) == 1 { // A [project.scripts] console script is the preferred entrypoint, but
for name := range pp.Project.Scripts { // pymage copies the app *source* (it doesn't install the project as a
return []string{name}, extraEnv // wheel), so the launcher binary the script name refers to does not exist
} // in the image. Translate the script's "module:attr" target into a direct
} // `python` invocation instead, which works as long as the package is
if len(pp.Project.Scripts) > 1 { // importable (it is: via PYTHONPATH for src layouts, or the workdir).
// Prefer a script matching the project name. if _, target, ok := chooseScript(pp); ok {
if pp.Project.Name != "" { if ep := scriptEntrypoint(target); ep != nil {
if _, ok := pp.Project.Scripts[pp.Project.Name]; ok { return ep, extraEnv
return []string{pp.Project.Name}, extraEnv
}
}
for name := range pp.Project.Scripts {
return []string{name}, extraEnv
} }
} }
@ -132,6 +128,45 @@ func entrypointFromPyproject(pp pyProject, root string) ([]string, []string) {
return nil, extraEnv return nil, extraEnv
} }
// chooseScript selects a [project.scripts] entry deterministically: the one
// matching the project name if present, else the lexicographically first.
func chooseScript(pp pyProject) (name, target string, ok bool) {
scripts := pp.Project.Scripts
if len(scripts) == 0 {
return "", "", false
}
if pp.Project.Name != "" {
if t, ok := scripts[pp.Project.Name]; ok {
return pp.Project.Name, t, true
}
}
names := make([]string, 0, len(scripts))
for n := range scripts {
names = append(names, n)
}
sort.Strings(names)
return names[0], scripts[names[0]], true
}
// scriptEntrypoint turns a console-script target ("module:attr" or "module")
// into an entrypoint that runs without a pre-installed launcher on PATH.
func scriptEntrypoint(target string) []string {
module, attr, hasAttr := strings.Cut(target, ":")
module = strings.TrimSpace(module)
if module == "" {
return nil
}
attr = strings.TrimSpace(attr)
if !hasAttr || attr == "" {
return []string{"python", "-m", module}
}
root := attr
if i := strings.IndexByte(attr, '.'); i >= 0 {
root = attr[:i]
}
return []string{"python", "-c", fmt.Sprintf("import sys; from %s import %s; sys.exit(%s())", module, root, attr)}
}
func moduleDir(root, pkg string) bool { func moduleDir(root, pkg string) bool {
p := filepath.Join(root, strings.ReplaceAll(pkg, ".", string(filepath.Separator))) p := filepath.Join(root, strings.ReplaceAll(pkg, ".", string(filepath.Separator)))
st, err := os.Stat(p) st, err := os.Stat(p)

View file

@ -20,8 +20,17 @@ func TestDiscoverExample(t *testing.T) {
if filepath.Base(info.LockFile) != "uv.lock" { if filepath.Base(info.LockFile) != "uv.lock" {
t.Fatalf("lock = %q", info.LockFile) t.Fatalf("lock = %q", info.LockFile)
} }
if len(info.Entrypoint) != 1 || info.Entrypoint[0] != "example" { // The [project.scripts] "example = example:main" must become a direct
t.Fatalf("entrypoint = %v", info.Entrypoint) // python invocation (not a bare "example" launcher, which pymage never
// installs).
wantEP := []string{"python", "-c", "import sys; from example import main; sys.exit(main())"}
if len(info.Entrypoint) != len(wantEP) {
t.Fatalf("entrypoint = %v, want %v", info.Entrypoint, wantEP)
}
for i := range wantEP {
if info.Entrypoint[i] != wantEP[i] {
t.Fatalf("entrypoint = %v, want %v", info.Entrypoint, wantEP)
}
} }
found := false found := false
for _, e := range info.ExtraEnv { for _, e := range info.ExtraEnv {
@ -66,6 +75,31 @@ find-links = ["wheelhouse", "/abs/wheels"]
} }
} }
func TestScriptEntrypoint(t *testing.T) {
cases := []struct {
target string
want []string
}{
{"pkg.mod:main", []string{"python", "-c", "import sys; from pkg.mod import main; sys.exit(main())"}},
{"pkg:obj.method", []string{"python", "-c", "import sys; from pkg import obj; sys.exit(obj.method())"}},
{"pkg.cli", []string{"python", "-m", "pkg.cli"}}, // no attr -> run as module
{":main", nil}, // no module -> unusable
}
for _, c := range cases {
got := scriptEntrypoint(c.target)
if len(got) != len(c.want) {
t.Errorf("scriptEntrypoint(%q) = %v, want %v", c.target, got, c.want)
continue
}
for i := range c.want {
if got[i] != c.want[i] {
t.Errorf("scriptEntrypoint(%q) = %v, want %v", c.target, got, c.want)
break
}
}
}
}
func mustWrite(t *testing.T, path, contents string) { func mustWrite(t *testing.T, path, contents string) {
t.Helper() t.Helper()
if err := os.WriteFile(path, []byte(contents), 0o644); err != nil { if err := os.WriteFile(path, []byte(contents), 0o644); err != nil {