Skip to content

Commit ea950d2

Browse files
fix: ship every top-level package of the project in the image
A service run from source (`uv run uvicorn src.main:app` in code/) can import any package beside src: uvicorn puts code/ on sys.path and uv installs the project. The image installs dependencies only (`uv sync --no-install-project`) and copied code/src alone, so a service with a sibling package passed every local run and died in the container with ModuleNotFoundError. The recipe now copies code/src and every other directory in code/ that holds __init__.py, leaving out the scaffolded tests tree, build outputs and tool caches. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 64c633b commit ea950d2

2 files changed

Lines changed: 110 additions & 1 deletion

File tree

‎build_recipe_test.go‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"os"
88
"os/exec"
99
"path"
10+
"strings"
1011
"testing"
1112
"time"
1213

@@ -152,3 +153,48 @@ func TestBuildRejectsInvalidOutputDirectoryBeforePreparation(t *testing.T) {
152153
})
153154
}
154155
}
156+
157+
// A package beside code/src is importable when the service runs from source —
158+
// uvicorn runs from code/ and uv installs the project — so the image has to
159+
// ship it too, or the service passes every local run and dies in the container
160+
// with ModuleNotFoundError. The test tree, build outputs, tool caches and
161+
// directories that are not packages stay out.
162+
func TestBuildShipsSiblingPackages(t *testing.T) {
163+
builder, _ := createdBuilder(t)
164+
code := builder.Local("code")
165+
for _, file := range []string{
166+
"model_service/__init__.py",
167+
"model_service/hosted.py",
168+
"adapters/__init__.py",
169+
".venv/lib/__init__.py",
170+
"__pycache__/stale/__init__.py",
171+
".hidden/__init__.py",
172+
"fixtures/data.json",
173+
} {
174+
require.NoError(t, os.MkdirAll(path.Dir(path.Join(code, file)), 0o755))
175+
require.NoError(t, os.WriteFile(path.Join(code, file), nil, 0o600))
176+
}
177+
require.FileExists(t, path.Join(code, "tests", "__init__.py"))
178+
179+
outputDir := t.TempDir()
180+
ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second)
181+
defer cancel()
182+
resp, err := recipeClient(t, builder).Build(ctx, recipeBuildRequest(outputDir))
183+
require.NoError(t, err)
184+
require.Equal(t, builderv0.BuildStatus_SUCCESS, resp.GetState().GetState(), resp.GetState().GetMessage())
185+
186+
content, err := os.ReadFile(path.Join(outputDir, "Dockerfile"))
187+
require.NoError(t, err)
188+
dockerfile := string(content)
189+
var copies []string
190+
for _, line := range strings.Split(dockerfile, "\n") {
191+
if strings.HasPrefix(line, "COPY --chown=appuser code/") {
192+
copies = append(copies, line)
193+
}
194+
}
195+
require.Equal(t, []string{
196+
"COPY --chown=appuser code/src code/src",
197+
"COPY --chown=appuser code/adapters code/adapters",
198+
"COPY --chown=appuser code/model_service code/model_service",
199+
}, copies)
200+
}

‎builder.go‎

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ import (
66
"errors"
77
"fmt"
88
"os"
9+
"path"
10+
"path/filepath"
11+
"sort"
12+
"strings"
913

1014
"github.com/codefly-dev/core/agents/communicate"
1115
"github.com/codefly-dev/core/agents/services"
@@ -208,9 +212,13 @@ func (s *Builder) Build(ctx context.Context, req *builderv0.BuildRequest) (*buil
208212
if err != nil {
209213
return s.Base.Builder.BuildError(err)
210214
}
215+
components, err := imageComponents(s.SourceLocation)
216+
if err != nil {
217+
return s.Base.Builder.BuildError(err)
218+
}
211219
docker := DockerTemplating{
212220
Builder: runtimeImage.FullName(),
213-
Components: requirements.All(),
221+
Components: components,
214222
UVImage: uvImage,
215223
ProjectWorkdir: defaultProjectWorkdir,
216224
}
@@ -247,6 +255,61 @@ func (s *Builder) Build(ctx context.Context, req *builderv0.BuildRequest) (*buil
247255
return s.Base.Builder.BuildResponse()
248256
}
249257

258+
// testsPackage is the test tree Create scaffolds beside src. It is importable
259+
// locally but is not part of the application, so the image does not ship it.
260+
const testsPackage = "tests"
261+
262+
// imageComponents is every service-relative source tree the runtime stage
263+
// copies: code/src, which uvicorn imports the app from, and every other
264+
// top-level Python package in the project directory.
265+
//
266+
// Locally the service runs as `uv run uvicorn src.main:app` from code/, so
267+
// every package beside src is importable twice over — from the working
268+
// directory uvicorn puts on sys.path, and from the project uv installs. The
269+
// image installs dependencies only (`uv sync --no-install-project`) and runs
270+
// from /app/code, so a sibling package it does not copy is a service that
271+
// passes every local run and dies in the container with ModuleNotFoundError.
272+
// Copying each package keeps the working directory the one thing that makes
273+
// them importable in both places.
274+
//
275+
// A package is a directory holding __init__.py. Build outputs and tool caches
276+
// are never packages of the application, and neither is the test tree.
277+
func imageComponents(sourceDir string) ([]string, error) {
278+
components := requirements.All()
279+
shipped := map[string]bool{}
280+
for _, component := range components {
281+
shipped[component] = true
282+
}
283+
entries, err := os.ReadDir(sourceDir)
284+
if err != nil {
285+
return nil, fmt.Errorf("cannot list the Python project at %s: %w", sourceDir, err)
286+
}
287+
var packages []string
288+
for _, entry := range entries {
289+
name := entry.Name()
290+
if !entry.IsDir() || name == testsPackage || ephemeralSourceDirectories[name] || strings.HasPrefix(name, ".") {
291+
continue
292+
}
293+
component := path.Join(filepath.Base(sourceDir), name)
294+
if shipped[component] {
295+
continue
296+
}
297+
marker, err := os.Stat(filepath.Join(sourceDir, name, "__init__.py"))
298+
if errors.Is(err, os.ErrNotExist) {
299+
continue
300+
}
301+
if err != nil {
302+
return nil, err
303+
}
304+
if !marker.Mode().IsRegular() {
305+
continue
306+
}
307+
packages = append(packages, component)
308+
}
309+
sort.Strings(packages)
310+
return append(components, packages...), nil
311+
}
312+
250313
// dockerignoreOf names the ignore file only when this build rendered one.
251314
// Probing the destination instead would adopt a stale file: the executor copies
252315
// the referenced ignore to the path buildx discovers, so one left behind by an

0 commit comments

Comments
 (0)