Skip to content

Commit e7a0032

Browse files
authored
[python] Improve python poetry planner to auto-detect main module (#84)
## Summary This PR allows the poetry planner to detect and use `__main__` module. It does this by 1) Using the pypoetry.toml and auto-detect directories to try and find the `__main__.py` 2) It uses an inline python script in the build phase to determine the correct flag to pass to pex. (This part is a bit hacky). I'm not 100% sure this will work in all cases, but it works in more cases than the previous implementation. Unrelated fix: fixed how we combine errors when plan fails. cc: @Lagoja ## How was it tested? * `devbox build` with console script only * `devbox build` with `__main__` module only * `devbox build` without scripts or `__main__` module (failed, showed correct error).
1 parent 8f62edb commit e7a0032

File tree

20 files changed

+196
-27
lines changed

20 files changed

+196
-27
lines changed

devbox_test.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,9 @@ func testIndividualPlan(t *testing.T, testPath string) {
5353
// For now we only compare the DevPackages and RuntimePackages fields:
5454
assert.ElementsMatch(expected.DevPackages, plan.DevPackages, "DevPackages should match")
5555
assert.ElementsMatch(expected.RuntimePackages, plan.RuntimePackages, "RuntimePackages should match")
56+
assert.Equal(expected.InstallStage.GetCommand(), plan.InstallStage.GetCommand(), "Install stage should match")
57+
assert.Equal(expected.BuildStage.GetCommand(), plan.BuildStage.GetCommand(), "Build stage should match")
58+
assert.Equal(expected.StartStage.GetCommand(), plan.StartStage.GetCommand(), "Start stage should match")
5659
})
5760
}
5861

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
from python_poetry.server import run_server
2+
3+
print("Running server using __main__ module")
4+
run_server()

planner/plan.go

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,13 @@ type Stage struct {
5252
InputFiles []string `cue:"[...string]" json:"input_files,omitempty"`
5353
}
5454

55+
func (s *Stage) GetCommand() string {
56+
if s == nil {
57+
return ""
58+
}
59+
return s.Command
60+
}
61+
5562
func (p *Plan) String() string {
5663
b, err := json.MarshalIndent(p, "", " ")
5764
if err != nil {
@@ -85,11 +92,11 @@ func (p *Plan) Error() error {
8592
if len(p.Errors) == 0 {
8693
return nil
8794
}
88-
var err error = p.Errors[0]
89-
for _, err = range p.Errors[1:] {
90-
err = errors.Wrap(err, err.Error())
95+
combined := p.Errors[0].error
96+
for _, err := range p.Errors[1:] {
97+
combined = errors.Wrap(combined, err.Error())
9198
}
92-
return err
99+
return combined
93100
}
94101

95102
func (p *Plan) WithError(err error) *Plan {

planner/python_poetry_planner.go

Lines changed: 69 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,7 @@ func (g *PythonPoetryPlanner) GetPlan(srcDir string) *Plan {
4242
if buildable, err := g.isBuildable(srcDir); !buildable {
4343
return plan.WithError(err)
4444
}
45-
entrypoint, err := g.GetEntrypoint(srcDir)
46-
if err != nil {
47-
return plan.WithError(err)
48-
}
45+
4946
plan.InstallStage = &Stage{
5047
// pex is is incompatible with certain less common python versions,
5148
// but because versions are sometimes expressed open-ended (e.g. ^3.10)
@@ -54,12 +51,8 @@ func (g *PythonPoetryPlanner) GetPlan(srcDir string) *Plan {
5451
Command: "poetry add pex -n --no-ansi && " +
5552
"poetry install --no-dev -n --no-ansi",
5653
}
57-
plan.BuildStage = &Stage{
58-
Command: "PEX_ROOT=/tmp/.pex poetry run pex . -o app.pex --script " + entrypoint,
59-
}
60-
plan.StartStage = &Stage{
61-
Command: "PEX_ROOT=/tmp/.pex python ./app.pex",
62-
}
54+
plan.BuildStage = &Stage{Command: g.buildCommand(srcDir)}
55+
plan.StartStage = &Stage{Command: "python ./app.pex"}
6356
return plan
6457
}
6558

@@ -78,20 +71,25 @@ func (g *PythonPoetryPlanner) PythonVersion(srcDir string) *version {
7871
return defaultVersion
7972
}
8073

81-
func (g *PythonPoetryPlanner) GetEntrypoint(srcDir string) (string, error) {
74+
func (g *PythonPoetryPlanner) buildCommand(srcDir string) string {
8275
project := g.PyProject(srcDir)
8376
// Assume name follows https://peps.python.org/pep-0508/#names
8477
// Do simple replacement "-" -> "_" and check if any script matches name.
8578
// This could be improved.
8679
moduleName := strings.ReplaceAll(project.Tool.Poetry.Name, "-", "_")
8780
if _, ok := project.Tool.Poetry.Scripts[moduleName]; ok {
88-
return moduleName, nil
81+
// return moduleName, nil
82+
return g.formatBuildCommand(moduleName, moduleName)
8983
}
9084
// otherwise use the first script alphabetically
9185
// (go-toml doesn't preserve order, we could parse ourselves)
9286
scripts := maps.Keys(project.Tool.Poetry.Scripts)
9387
slices.Sort(scripts)
94-
return scripts[0], nil
88+
script := ""
89+
if len(scripts) > 0 {
90+
script = scripts[0]
91+
}
92+
return g.formatBuildCommand(moduleName, script)
9593
}
9694

9795
type pyProject struct {
@@ -101,6 +99,10 @@ type pyProject struct {
10199
Dependencies struct {
102100
Python string `toml:"python"`
103101
} `toml:"dependencies"`
102+
Packages []struct {
103+
Include string `toml:"include"`
104+
From string `toml:"from"`
105+
} `toml:"packages"`
104106
Scripts map[string]string `toml:"scripts"`
105107
} `toml:"poetry"`
106108
} `toml:"tool"`
@@ -124,11 +126,62 @@ func (g *PythonPoetryPlanner) isBuildable(srcDir string) (bool, error) {
124126
"application. pyproject.toml is missing and needed to install python " +
125127
"dependencies.")
126128
}
129+
130+
// is this the right way to determine package name?
131+
packageName := strings.ReplaceAll(project.Tool.Poetry.Name, "-", "_")
132+
133+
// First try to find a __main__ module as entry point
134+
if len(project.Tool.Poetry.Packages) > 0 {
135+
// If package has custom directory, check that.
136+
// Using packages disables auto-detection of __main__ module.
137+
for _, pkg := range project.Tool.Poetry.Packages {
138+
if pkg.Include == packageName &&
139+
fileExists(filepath.Join(srcDir, pkg.From, pkg.Include, "__main__.py")) {
140+
return true, nil
141+
}
142+
}
143+
144+
// Use setup tools auto-detect directory structure
145+
} else if fileExists(filepath.Join(srcDir, packageName, "__main__.py")) ||
146+
fileExists(filepath.Join(srcDir, "src", packageName, "__main__.py")) {
147+
148+
return true, nil
149+
}
150+
151+
// Fallback to using poetry scripts
127152
if len(project.Tool.Poetry.Scripts) == 0 {
128153
return false,
129-
usererr.New("Project is not buildable: no scripts found in " +
130-
"pyproject.toml. Please define a script to use as an entrypoint for " +
131-
"your app:\n\n[tool.poetry.scripts]\nmy_app = \"my_app:my_function\"\n")
154+
usererr.New(
155+
"Project is not buildable: no __main__.py file found and " +
156+
"no scripts defined in pyproject.toml",
157+
)
132158
}
133159
return true, nil
134160
}
161+
162+
func (g *PythonPoetryPlanner) formatBuildCommand(module, script string) string {
163+
164+
// If no scripts, just run the module directly always.
165+
if script == "" {
166+
return fmt.Sprintf(
167+
"poetry run pex . -o app.pex -m %s --validate-entry-point",
168+
module,
169+
)
170+
}
171+
172+
entrypointScript := fmt.Sprintf(
173+
`$(poetry run python -c "import pkgutil;
174+
import %[1]s;
175+
modules = [name for _, name, _ in pkgutil.iter_modules(%[1]s.__path__)];
176+
print('-m %[1]s' if '__main__' in modules else '--script %[2]s');")
177+
`,
178+
module,
179+
script,
180+
)
181+
182+
return fmt.Sprintf(
183+
"poetry run pex . -o app.pex %s --validate-entry-point &>/dev/null || "+
184+
"(echo 'Build failed. Could not find entrypoint' && exit 1)",
185+
strings.TrimSpace(strings.ReplaceAll(entrypointScript, "\n", "")),
186+
)
187+
}

testdata/go/go-1.17/plan.json

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,14 @@
22
"dev_packages": [
33
"go_1_17"
44
],
5-
"runtime_packages": []
6-
}
5+
"runtime_packages": [],
6+
"install_stage": {
7+
"command": "go get"
8+
},
9+
"build_stage": {
10+
"command": "CGO_ENABLED=0 go build -o app"
11+
},
12+
"start_stage": {
13+
"command": "./app"
14+
}
15+
}

testdata/go/go-1.18/plan.json

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,14 @@
22
"dev_packages": [
33
"go"
44
],
5-
"runtime_packages": []
6-
}
5+
"runtime_packages": [],
6+
"install_stage": {
7+
"command": "go get"
8+
},
9+
"build_stage": {
10+
"command": "CGO_ENABLED=0 go build -o app"
11+
},
12+
"start_stage": {
13+
"command": "./app"
14+
}
15+
}

testdata/go/go-1.19/plan.json

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,14 @@
22
"dev_packages": [
33
"go_1_19"
44
],
5-
"runtime_packages": []
6-
}
5+
"runtime_packages": [],
6+
"install_stage": {
7+
"command": "go get"
8+
},
9+
"build_stage": {
10+
"command": "CGO_ENABLED=0 go build -o app"
11+
},
12+
"start_stage": {
13+
"command": "./app"
14+
}
15+
}

testdata/python/poetry-no-lock-file/plan.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,4 @@
66
"runtime_packages": [
77
"python310"
88
]
9-
}
9+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"packages": []
3+
}

testdata/python/poetry-with-custom-dir-structure/my_source/some_package/__main__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)