mirror of
https://github.com/imjasonh/terraform-playground
synced 2026-07-07 23:35:16 +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:
parent
bec24b79ca
commit
341914e488
4 changed files with 159 additions and 24 deletions
|
|
@ -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()},
|
||||
|
|
|
|||
|
|
@ -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}})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue