Skip to content

fix(build): build a uv workspace member project, with the interpreter it requires - #73

Merged
antoinetoussaint-byte merged 1 commit into
mainfrom
fix/uv-workspace-member-image
Sep 27, 2026
Merged

antoinetoussaint-byte merged 1 commit into
mainfrom
fix/uv-workspace-member-image

Conversation

@antoinetoussaint-byte

Copy link
Copy Markdown
Contributor

Why

A composed module's python-fastapi service keeps its project in code/ as a member of a uv workspace rooted at the service directory. The one uv.lock and the sibling members live at that root. The layout is required, not a preference: the vendored packages name each other { workspace = true }, which uv honours only under an ancestor root.

uv sync from code/ resolves it on the host, and the agent's native runner does exactly that. The image recipe copied code/pyproject.toml code/uv.lock and nothing else, so the image build failed on a lock that isn't in code/. The recipe did not build what the host resolves, contradicting the rule this agent states for [tool.uv.sources] ("resolve in the builder stage exactly as it resolves on the host").

Which side is wrong: the agent. The layout is valid uv, the agent's own runtime path honours it, and only the build recipe didn't.

Building that project exposed a second defect in the same recipe. The project requires Python >=3.11,<3.12, and the base image carries 3.13. uv installs 3.11 for it in the builder stage, as it does on the host, but under /root, which the runtime stage never copied. So the venv's python dangled, and the base interpreter ran without the project's packages.

Change

  • enclosingUVWorkspace: when code/ has no uv.lock, it walks up (within the repository) to the first [tool.uv.workspace] and checks the members/exclude globs. A listed member of a root inside the service is built as a workspace member. Refused, with the reason:
    • a root outside the service;
    • a root without uv.lock;
    • a path source leaving the service.
  • Template, workspace member: COPY <root> <root>, WORKDIR /app/<root>/<member>, and a dockerignore excluding the ephemeral directories the assembled context already skips (.venv, caches, .git, …). A standalone project's recipe keeps its shape.
  • uv sync ... --no-editable: members and path sources become packages in the venv rather than links to builder-stage sources.
  • UV_PYTHON_INSTALL_DIR=/opt/uv/python, copied into the runtime stage. For a project the base Python satisfies, the directory is empty.

Classification: fix, at the owner (the agent's image recipe).

Verified

  • New tests:
    • membership detection (member, own lock, exclude, unlisted, no lock, root outside the service);
    • the recipe's shape for a workspace member, and that a standalone project's recipe is unchanged;
    • an image build: a workspace member with a { workspace = true } library and requires-python >=3.11,<3.12 builds, and its image runs Python 3.11 and imports the library.
  • Control: with the interpreter copy removed, that image test fails (ModuleNotFoundError, because the base 3.13 ran).
  • go test -race ./... passes (193s).
  • The real service: I emitted its recipe through the agent and built it natively (linux/arm64). The workspace resolves, CPython 3.11 (aarch64-musl) is installed, and every workspace member builds.

Not fixed here: the next failure for that service, and its root cause

The same build then stops on a third-party dependency, headroom-ai==0.37.0. It publishes only manylinux (glibc) wheels, and this agent's base image is the Alpine (musl) companion. So uv has to compile it from source (maturin, which needs Rust and a C toolchain), and the companion deliberately carries no compiler. Its Dockerfile says services needing native builds should override docker-image:. But Build renders FROM {{.Builder}} from the package-level runtimeImage and ignores that override (only the runtime path resolves it), so a service cannot choose a base image with wheels or a toolchain for its image build. That is a platform decision (a glibc companion, or honouring the override for the build image), not part of this fix.

🤖 Generated with Claude Code

… 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>
@antoinetoussaint-byte
antoinetoussaint-byte merged commit 5f9c11d into main Sep 27, 2026
3 checks passed
@antoinetoussaint-byte
antoinetoussaint-byte deleted the fix/uv-workspace-member-image branch October 25, 2026 17:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant