Skip to content

Commit 0a70dff

Browse files
fix(build): build a uv workspace member project, with the interpreter it requires
A service whose project (code/) is a member of a uv workspace keeps its one uv.lock, and its sibling members, at the workspace root in the service. `uv sync` from code/ resolves against that root on the host, which is what the native runner does, but the image recipe copied code/pyproject.toml and code/uv.lock only, so the build failed on a lock that is not in code/. A library whose members name each other `{ workspace = true }` has to be laid out this way: uv honours that source only under an ancestor root. The recipe now detects the enclosing workspace, copies it whole (ignoring virtual environments and caches), and syncs the project from its own directory. A root outside the service, a root without a lock, or a path source leaving the service is refused with the reason. `--no-editable` installs members and path sources into the venv as packages, since the runtime stage carries only the venv. A project whose requires-python the base image's Python does not satisfy gets the interpreter uv installs for it, as on the host; it is now installed where the runtime stage copies it from, so the venv's interpreter exists at runtime. Without that copy the venv's python dangled and the base interpreter ran without the project's packages. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent d7feea0 commit 0a70dff

5 files changed

Lines changed: 441 additions & 8 deletions

File tree

‎builder.go‎

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,15 @@ type DockerTemplating struct {
183183
ServicePrefix string
184184
ProjectWorkdir string
185185
Carried []CarriedSource
186+
187+
// UVWorkspace is set when the project is a member of a uv workspace rooted
188+
// in the service: the recipe copies that workspace and syncs the project
189+
// from MemberWorkdir, its directory in the builder stage.
190+
UVWorkspace *uvWorkspaceMembership
191+
MemberWorkdir string
192+
// IgnorePatterns are the dockerignore entries of a recipe that copies the
193+
// service tree whole.
194+
IgnorePatterns []string
186195
}
187196

188197
func (s *Builder) BuildCapabilities(context.Context, *builderv0.BuildCapabilitiesRequest) (*builderv0.BuildCapabilitiesResponse, error) {
@@ -204,14 +213,29 @@ func (s *Builder) Build(ctx context.Context, req *builderv0.BuildRequest) (*buil
204213
if err != nil {
205214
return s.Base.Builder.BuildError(err)
206215
}
207-
// A `[tool.uv.sources]` path resolves against the project directory, and the
208-
// default context is the service directory and nothing beside it: carry what
209-
// leaves the project into an assembled context so the builder stage resolves
210-
// what the host resolves. A service with no such path assembles nothing.
211-
assembled, err := assembleUVContext(s.Location, s.SourceLocation, outputDir)
216+
// A project that is a member of a uv workspace in the service is built from
217+
// that workspace, copied whole (see enclosingUVWorkspace); its path sources
218+
// must then stay inside the service the context carries.
219+
membership, err := enclosingUVWorkspace(s.Location, s.SourceLocation)
212220
if err != nil {
213221
return s.Base.Builder.BuildError(err)
214222
}
223+
var assembled *assembledContext
224+
if membership != nil {
225+
if err := pathSourcesStayInService(s.Location, s.SourceLocation, membership); err != nil {
226+
return s.Base.Builder.BuildError(err)
227+
}
228+
} else {
229+
// A `[tool.uv.sources]` path resolves against the project directory, and
230+
// the default context is the service directory and nothing beside it:
231+
// carry what leaves the project into an assembled context so the builder
232+
// stage resolves what the host resolves. A service with no such path
233+
// assembles nothing.
234+
assembled, err = assembleUVContext(s.Location, s.SourceLocation, outputDir)
235+
if err != nil {
236+
return s.Base.Builder.BuildError(err)
237+
}
238+
}
215239
components, err := imageComponents(s.SourceLocation)
216240
if err != nil {
217241
return s.Base.Builder.BuildError(err)
@@ -227,6 +251,11 @@ func (s *Builder) Build(ctx context.Context, req *builderv0.BuildRequest) (*buil
227251
docker.ProjectWorkdir = assembled.ProjectWorkdir
228252
docker.Carried = assembled.Carried
229253
}
254+
if membership != nil {
255+
docker.UVWorkspace = membership
256+
docker.MemberWorkdir = path.Join(defaultProjectWorkdir, membership.Root, membership.Member)
257+
docker.IgnorePatterns = ephemeralIgnorePatterns()
258+
}
230259
if err := s.Base.Templates(ctx, docker, services.WithBuilder(builderFS).WithDestination("%s", outputDir)); err != nil {
231260
return s.Base.Builder.BuildError(err)
232261
}

‎templates/builder/Dockerfile.tmpl‎

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,15 @@ FROM {{.Builder}} as builder
1010
COPY --from={{.UVImage}} /uv /usr/local/bin/uv
1111

1212
WORKDIR {{.ProjectWorkdir}}
13+
{{- if .UVWorkspace}}
14+
15+
# The project is a member of the uv workspace rooted at "{{.UVWorkspace.Root}}"
16+
# in the service. uv reads the lock and every member from that root, so the
17+
# workspace is copied whole and the project is synced from its own directory,
18+
# which is how `uv sync` resolves it on the host.
19+
COPY {{.UVWorkspace.Root}} {{.UVWorkspace.Root}}
20+
WORKDIR {{.MemberWorkdir}}
21+
{{- else}}
1322

1423
COPY {{.ServicePrefix}}code/pyproject.toml {{.ServicePrefix}}code/uv.lock ./
1524
{{- if .Carried}}
@@ -21,12 +30,20 @@ COPY {{.ServicePrefix}}code/pyproject.toml {{.ServicePrefix}}code/uv.lock ./
2130
COPY {{.Context}} {{.Image}}
2231
{{- end}}
2332
{{- end}}
33+
{{- end}}
2434

2535
# Install deps to a project-local .venv. --frozen installs the locked resolution
26-
# as it stands instead of re-resolving it at build time.
36+
# as it stands instead of re-resolving it at build time. --no-editable installs
37+
# workspace members and path sources as packages in the venv rather than links
38+
# to builder-stage sources, which the runtime stage does not carry.
39+
#
40+
# A project whose requires-python the base image's Python does not satisfy gets
41+
# the interpreter uv installs for it, as on the host. It is installed where the
42+
# runtime stage copies it from, so the venv's interpreter exists there too.
2743
ENV UV_PROJECT_ENVIRONMENT=/app/.venv \
28-
UV_LINK_MODE=copy
29-
RUN uv sync --frozen --no-dev --no-install-project
44+
UV_LINK_MODE=copy \
45+
UV_PYTHON_INSTALL_DIR=/opt/uv/python
46+
RUN mkdir -p /opt/uv/python && uv sync --frozen --no-dev --no-install-project --no-editable
3047

3148

3249
# Runtime
@@ -42,6 +59,7 @@ USER appuser
4259
ENV VIRTUAL_ENV=/app/.venv \
4360
PATH="/app/.venv/bin:$PATH"
4461

62+
COPY --from=builder /opt/uv/python /opt/uv/python
4563
COPY --from=builder ${VIRTUAL_ENV} ${VIRTUAL_ENV}
4664

4765
COPY --chown=appuser {{.ServicePrefix}}service.codefly.yaml .
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{{- range .IgnorePatterns}}
2+
{{.}}
3+
{{- end}}

‎uv_workspace_recipe_test.go‎

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
package main
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"os/exec"
7+
"path/filepath"
8+
"strings"
9+
"testing"
10+
"time"
11+
12+
"github.com/codefly-dev/core/agents/services"
13+
builderv0 "github.com/codefly-dev/core/generated/go/codefly/services/builder/v0"
14+
"github.com/stretchr/testify/require"
15+
)
16+
17+
// workspaceRequiresPython is outside the Python the base image carries, so the
18+
// image build has to use the interpreter uv installs for the project, as the
19+
// host does.
20+
const workspaceRequiresPython = ">=3.11,<3.12"
21+
22+
// uvWorkspaceService lays a service out the way a uv workspace member project
23+
// is: the service directory is the workspace root and holds the one uv.lock,
24+
// `code/` is a member depending on a library member `{ workspace = true }` —
25+
// which uv honours only under an ancestor root — and `code/` has no lock of
26+
// its own.
27+
func uvWorkspaceService(t *testing.T, serviceDir, requiresPython string) {
28+
t.Helper()
29+
_ = os.Remove(filepath.Join(serviceDir, "code", "uv.lock"))
30+
writeFile(t, filepath.Join(serviceDir, "pyproject.toml"), `[tool.uv.workspace]
31+
members = ["code", "packages/*"]
32+
exclude = ["packages/scratch"]
33+
`)
34+
writeFile(t, filepath.Join(serviceDir, "packages", "greeting", "pyproject.toml"), fmt.Sprintf(`[project]
35+
name = "greeting"
36+
version = "0.1.0"
37+
requires-python = %q
38+
dependencies = []
39+
40+
[build-system]
41+
requires = ["uv_build>=0.8,<0.13"]
42+
build-backend = "uv_build"
43+
`, requiresPython))
44+
writeFile(t, filepath.Join(serviceDir, "packages", "greeting", "src", "greeting", "__init__.py"), "TEXT = \"hello from a workspace member\"\n")
45+
writeFile(t, filepath.Join(serviceDir, "code", "pyproject.toml"), fmt.Sprintf(`[project]
46+
name = "example-service"
47+
version = "0.1.0"
48+
requires-python = %q
49+
dependencies = ["greeting"]
50+
51+
[tool.uv]
52+
package = false
53+
54+
[tool.uv.sources]
55+
greeting = { workspace = true }
56+
`, requiresPython))
57+
}
58+
59+
func lockWorkspace(t *testing.T, serviceDir string) bool {
60+
t.Helper()
61+
if _, err := exec.LookPath("uv"); err != nil {
62+
return false
63+
}
64+
lock := exec.Command("uv", "lock")
65+
lock.Dir = serviceDir
66+
output, err := lock.CombinedOutput()
67+
require.NoError(t, err, "uv lock: %s", output)
68+
return true
69+
}
70+
71+
func TestEnclosingUVWorkspaceFindsTheMembership(t *testing.T) {
72+
service := t.TempDir()
73+
writeFile(t, filepath.Join(service, "module.codefly.yaml"), "name: mod\n")
74+
uvWorkspaceService(t, service, workspaceRequiresPython)
75+
writeFile(t, filepath.Join(service, "uv.lock"), "version = 1\n")
76+
77+
membership, err := enclosingUVWorkspace(service, filepath.Join(service, "code"))
78+
require.NoError(t, err)
79+
require.Equal(t, &uvWorkspaceMembership{Root: ".", Member: "code"}, membership)
80+
}
81+
82+
func TestEnclosingUVWorkspaceLeavesAProjectWithItsOwnLock(t *testing.T) {
83+
service := t.TempDir()
84+
uvWorkspaceService(t, service, workspaceRequiresPython)
85+
writeFile(t, filepath.Join(service, "uv.lock"), "version = 1\n")
86+
writeFile(t, filepath.Join(service, "code", "uv.lock"), "version = 1\n")
87+
88+
membership, err := enclosingUVWorkspace(service, filepath.Join(service, "code"))
89+
require.NoError(t, err)
90+
require.Nil(t, membership, "a project with its own lock is its own root")
91+
}
92+
93+
func TestEnclosingUVWorkspaceHonoursMembersAndExclude(t *testing.T) {
94+
workspace := &uvWorkspaceTable{Members: []string{"code", "packages/*"}, Exclude: []string{"packages/scratch"}}
95+
require.True(t, workspace.lists("code"))
96+
require.True(t, workspace.lists("packages/greeting"))
97+
require.False(t, workspace.lists("packages/scratch"), "an excluded directory is not a member")
98+
require.False(t, workspace.lists("tools"), "an unlisted directory is not a member")
99+
100+
service := t.TempDir()
101+
writeFile(t, filepath.Join(service, "pyproject.toml"), "[tool.uv.workspace]\nmembers = [\"packages/*\"]\n")
102+
writeFile(t, filepath.Join(service, "uv.lock"), "version = 1\n")
103+
writeFile(t, filepath.Join(service, "code", "pyproject.toml"), "[project]\nname = \"x\"\nversion = \"0\"\n")
104+
membership, err := enclosingUVWorkspace(service, filepath.Join(service, "code"))
105+
require.NoError(t, err)
106+
require.Nil(t, membership, "uv treats an unlisted project as standalone")
107+
}
108+
109+
func TestEnclosingUVWorkspaceRefusesAWorkspaceWithoutALock(t *testing.T) {
110+
service := t.TempDir()
111+
uvWorkspaceService(t, service, workspaceRequiresPython)
112+
_, err := enclosingUVWorkspace(service, filepath.Join(service, "code"))
113+
require.ErrorContains(t, err, "has no uv.lock")
114+
}
115+
116+
func TestEnclosingUVWorkspaceRefusesARootOutsideTheService(t *testing.T) {
117+
repository := t.TempDir()
118+
writeFile(t, filepath.Join(repository, "module.codefly.yaml"), "name: mod\n")
119+
writeFile(t, filepath.Join(repository, "pyproject.toml"), "[tool.uv.workspace]\nmembers = [\"services/*/code\"]\n")
120+
writeFile(t, filepath.Join(repository, "uv.lock"), "version = 1\n")
121+
service := filepath.Join(repository, "services", "api")
122+
writeFile(t, filepath.Join(service, "code", "pyproject.toml"), "[project]\nname = \"api\"\nversion = \"0\"\n")
123+
124+
_, err := enclosingUVWorkspace(service, filepath.Join(service, "code"))
125+
require.ErrorContains(t, err, "outside the service")
126+
}
127+
128+
// TestWorkspaceMemberRecipeCopiesTheWorkspace pins the recipe a workspace member
129+
// gets: the workspace copied from its root, the project synced from its own
130+
// directory, the developer's virtual environments and caches ignored, and no
131+
// reference to a `code/uv.lock` that does not exist.
132+
func TestWorkspaceMemberRecipeCopiesTheWorkspace(t *testing.T) {
133+
builder, _ := createdBuilder(t)
134+
uvWorkspaceService(t, builder.Location, workspaceRequiresPython)
135+
if !lockWorkspace(t, builder.Location) {
136+
t.Skip("uv is not installed: the workspace cannot be locked")
137+
}
138+
outputDir := t.TempDir()
139+
plan := emitRecipe(t, builder, outputDir)
140+
require.NoError(t, services.VerifyDockerBuildPlan(outputDir, plan))
141+
require.Equal(t, builderv0.RecipeInventoryScope_RECIPE_INVENTORY_SCOPE_EMITTED, plan.GetScope(),
142+
"the service directory is the context: the recipe assembles nothing")
143+
144+
dockerfile := dockerfileOf(t, outputDir, plan)
145+
require.Contains(t, dockerfile, "COPY . .")
146+
require.Contains(t, dockerfile, "WORKDIR /app/code")
147+
require.NotContains(t, dockerfile, "code/uv.lock")
148+
require.Less(t, strings.Index(dockerfile, "WORKDIR /app/code"), strings.Index(dockerfile, "uv sync --frozen"))
149+
require.Contains(t, dockerfile, "--no-editable")
150+
151+
ignore, err := os.ReadFile(filepath.Join(outputDir, plan.GetRecipes()[0].GetDockerignore()))
152+
require.NoError(t, err)
153+
require.Contains(t, string(ignore), "**/.venv")
154+
}
155+
156+
// TestAStandaloneProjectRecipeIsUnchangedInShape keeps the default recipe what
157+
// it was for a project that owns its lock: its two files copied, not the tree.
158+
func TestAStandaloneProjectRecipeIsUnchangedInShape(t *testing.T) {
159+
builder, _ := createdBuilder(t)
160+
outputDir := t.TempDir()
161+
plan := emitRecipe(t, builder, outputDir)
162+
dockerfile := dockerfileOf(t, outputDir, plan)
163+
require.Contains(t, dockerfile, "COPY code/pyproject.toml code/uv.lock ./")
164+
require.NotContains(t, dockerfile, "COPY . .")
165+
}
166+
167+
// TestWorkspaceMemberBuildsTheImage is the claim end to end: a member project
168+
// whose lock is at the workspace root, and whose requires-python the base
169+
// image's Python does not satisfy, builds, and its image imports the workspace
170+
// library with the interpreter the project asked for.
171+
func TestWorkspaceMemberBuildsTheImage(t *testing.T) {
172+
for _, tool := range []string{"docker", "uv"} {
173+
if _, err := exec.LookPath(tool); err != nil {
174+
t.Skipf("%s is not installed: the workspace image cannot be built", tool)
175+
}
176+
}
177+
builder, _ := createdBuilder(t)
178+
uvWorkspaceService(t, builder.Location, workspaceRequiresPython)
179+
require.True(t, lockWorkspace(t, builder.Location))
180+
// A developer's environment in the tree must not reach the image.
181+
writeFile(t, filepath.Join(builder.Location, ".venv", "pyvenv.cfg"), "home = /nowhere\n")
182+
183+
outputDir := t.TempDir()
184+
plan := emitRecipe(t, builder, outputDir)
185+
recipe := plan.GetRecipes()[0]
186+
tag := fmt.Sprintf("codefly-fastapi-uv-workspace-test:%d", time.Now().UnixMilli())
187+
build := exec.CommandContext(t.Context(), "docker", "build",
188+
"-f", filepath.Join(outputDir, recipe.GetDockerfile()),
189+
"-t", tag, builder.Location)
190+
// The CLI applies the recipe's ignore file to the context; plain docker
191+
// reads <Dockerfile>.dockerignore beside the Dockerfile.
192+
ignore, err := os.ReadFile(filepath.Join(outputDir, recipe.GetDockerignore()))
193+
require.NoError(t, err)
194+
writeFile(t, filepath.Join(outputDir, recipe.GetDockerfile()+".dockerignore"), string(ignore))
195+
if out, err := build.CombinedOutput(); err != nil {
196+
t.Fatalf("the workspace member did not build: %v\n%s", err, out)
197+
}
198+
t.Cleanup(func() { _ = exec.Command("docker", "rmi", "-f", tag).Run() })
199+
200+
out, err := exec.CommandContext(t.Context(), "docker", "run", "--rm", tag,
201+
"python", "-c", "import sys, greeting; print(sys.version_info[:2], greeting.TEXT)").CombinedOutput()
202+
require.NoError(t, err, "%s", out)
203+
require.Contains(t, string(out), "(3, 11)", "the image runs the interpreter the project requires")
204+
require.Contains(t, string(out), "hello from a workspace member", "the image carries the workspace library")
205+
}

0 commit comments

Comments
 (0)