diff --git a/pymage/internal/build/build.go b/pymage/internal/build/build.go index 1369f9a..8db1f73 100644 --- a/pymage/internal/build/build.go +++ b/pymage/internal/build/build.go @@ -405,28 +405,44 @@ func mergeEnv(base []string, layout wheel.Layout, extra []string) []string { } 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, "=") + if k == "PYTHONPATH" { + addPyPath(v) + return + } set(k, v) } + for _, kv := range base { + apply(kv) + } + if existing, ok := env["PATH"]; ok && existing != "" { set("PATH", bin+":"+existing) } else { set("PATH", bin+":/usr/local/bin:/usr/bin:/bin") } set("VIRTUAL_ENV", prefix) - if existing, ok := env["PYTHONPATH"]; ok && existing != "" { - set("PYTHONPATH", site+":"+existing) - } else { - set("PYTHONPATH", site) - } for _, kv := range extra { - k, v, _ := strings.Cut(kv, "=") - set(k, v) + apply(kv) } + set("PYTHONPATH", strings.Join(dedupe(pythonPath), ":")) + out := make([]string, 0, len(order)) for _, k := range order { out = append(out, k+"="+env[k]) @@ -434,6 +450,20 @@ func mergeEnv(base []string, layout wheel.Layout, extra []string) []string { 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 { return v1.History{ Created: v1.Time{Time: epoch()}, diff --git a/pymage/internal/build/build_test.go b/pymage/internal/build/build_test.go index 4c23424..ee9077a 100644 --- a/pymage/internal/build/build_test.go +++ b/pymage/internal/build/build_test.go @@ -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) { withEnv := func(env []string) v1.Image { img, err := mutate.ConfigFile(empty.Image, &v1.ConfigFile{Config: v1.Config{Env: env}}) diff --git a/pymage/internal/project/project.go b/pymage/internal/project/project.go index 1889e84..05ad368 100644 --- a/pymage/internal/project/project.go +++ b/pymage/internal/project/project.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "path/filepath" + "sort" "strings" "github.com/pelletier/go-toml/v2" @@ -105,20 +106,15 @@ func entrypointFromPyproject(pp pyProject, root string) ([]string, []string) { extraEnv = append(extraEnv, "PYTHONPATH=/app/src") } - if len(pp.Project.Scripts) == 1 { - for name := range pp.Project.Scripts { - return []string{name}, extraEnv - } - } - if len(pp.Project.Scripts) > 1 { - // Prefer a script matching the project name. - if pp.Project.Name != "" { - if _, ok := pp.Project.Scripts[pp.Project.Name]; ok { - return []string{pp.Project.Name}, extraEnv - } - } - for name := range pp.Project.Scripts { - return []string{name}, extraEnv + // A [project.scripts] console script is the preferred entrypoint, but + // pymage copies the app *source* (it doesn't install the project as a + // 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 + // importable (it is: via PYTHONPATH for src layouts, or the workdir). + if _, target, ok := chooseScript(pp); ok { + if ep := scriptEntrypoint(target); ep != nil { + return ep, extraEnv } } @@ -132,6 +128,45 @@ func entrypointFromPyproject(pp pyProject, root string) ([]string, []string) { 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 { p := filepath.Join(root, strings.ReplaceAll(pkg, ".", string(filepath.Separator))) st, err := os.Stat(p) diff --git a/pymage/internal/project/project_test.go b/pymage/internal/project/project_test.go index d1be98b..b26c8b3 100644 --- a/pymage/internal/project/project_test.go +++ b/pymage/internal/project/project_test.go @@ -20,8 +20,17 @@ func TestDiscoverExample(t *testing.T) { if filepath.Base(info.LockFile) != "uv.lock" { t.Fatalf("lock = %q", info.LockFile) } - if len(info.Entrypoint) != 1 || info.Entrypoint[0] != "example" { - t.Fatalf("entrypoint = %v", info.Entrypoint) + // The [project.scripts] "example = example:main" must become a direct + // 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 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) { t.Helper() if err := os.WriteFile(path, []byte(contents), 0o644); err != nil {