Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions .dagger/src/typesafe_daggerverse/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,7 @@ async def _run(name: str, fn) -> None:
tg.start_soon(_run, "workspace_layer_cache", self.uv_workspace_layer_cache)
tg.start_soon(_run, "build_workspace_app", self.uv_workspace_build_workspace_app)
tg.start_soon(_run, "build_workspace_flat", self.uv_workspace_build_workspace_flat)
tg.start_soon(_run, "uv_build_layouts", self.uv_build_layouts)
tg.start_soon(_run, "build_standalone", self.uv_workspace_build_standalone)
tg.start_soon(_run, "standalone_selective_extra", self.uv_workspace_standalone_selective_extra)
tg.start_soon(_run, "standalone_all_extras", self.uv_workspace_standalone_all_extras)
Expand Down Expand Up @@ -452,6 +453,43 @@ async def uv_no_editable_bakes_local_source(self) -> None:
if "NO_EDITABLE_OK" not in out:
raise AssertionError(f"expected non-editable venv to bake real local source, got: {out!r}")

@function
async def uv_build_layouts(self) -> None:
"""Virtual members, renamed modules, multiple modules, and custom roots build together."""
source = self.source.directory("uv/tests/_packages/uv-build-layouts")
source = source.with_new_file("flat/tests/unrelated.py", "Must stay out of the package build layer\n")
script = (
"from importlib.metadata import distributions\n"
"from pathlib import Path\n"
"import actual_package, compat_package, vendor_sdk\n"
"assert actual_package.VALUE == compat_package.VALUE == vendor_sdk.VALUE == 'real source'\n"
"assert not Path('flat/tests/unrelated.py').exists()\n"
"installed = {d.metadata['Name'] for d in distributions()}\n"
"assert not {'layout-workspace', 'virtual-app'} & installed\n"
)
for no_editable in (False, True):
for package in (None, ["virtual-app"]):
await (
dag.uv(source=source)
.workspace()
.build(package=package, no_editable=no_editable, dagger_codegen=False)
.with_remote_dependencies(prune_cache=False)
.with_workspace_files()
.with_local_dependencies()
.with_exec(["uv", "run", "--no-sync", "python", "-c", script])
.sync()
)
# Pulumi installs the entire workspace even when building one member.
await (
dag.uv(source=source)
.workspace()
.build(package=["flat-dist"], dagger_codegen=False)
.with_all_workspace_members()
.container()
.with_exec(["uv", "sync", "--frozen", "--all-packages"])
.sync()
)

@function
async def uv_license_files(self) -> None:
"""Declared license globs are available for editable and non-editable builds."""
Expand Down
6 changes: 3 additions & 3 deletions .dagger/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 3 additions & 3 deletions github/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 3 additions & 3 deletions ruff/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

50 changes: 49 additions & 1 deletion uv/docs/building.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ and installs everything in a cache-friendly order — so you don't hand-curate t
context yourself.

!!! note
Workspace members that declare no build system (also known as applications) are supported as well.
Dependency-only workspace members are supported too, including projects with
no build system and those with `tool.uv.package = false`.


??? abstract "The mental model"
Expand Down Expand Up @@ -122,6 +123,53 @@ If your `base_container` already sets `UV_CACHE_DIR` and doesn't have a director

The cache may be recycled with `uv cache prune --ci` if grown too large. This is enabled by default and the threshold is set to 100 GiB.

## Supported build backends

`uv` installs packages using their declared build backend. This module also needs to
know their import layout so it can create valid stubs and copy the right source files.
Backend support here describes that staging behavior:

| Backend or project type | Layout support |
| --- | --- |
| `uv_build` | Default `src/` layout, custom `module-root`, and explicit `module-name` strings or lists. |
| Other backends, including Hatchling | Conventional single-module flat or `src/` layouts, using the existing module-name heuristic. Backend-specific package mappings and build hooks are not interpreted. |
| Dependency-only projects | Their metadata and transitive local dependencies are staged; the project itself is not installed or copied as a Python package. |

### `uv_build` layouts

The default module name is derived from the distribution name by lowercasing it and
replacing dots and dashes with underscores. Explicit names can differ from the
distribution name; dotted names map to nested import directories. See the
[uv build backend documentation](https://docs.astral.sh/uv/concepts/build-backend/#modules)
for the upstream settings.

For example, this configuration declares two modules at the package root:

```toml
[tool.uv.build-backend]
module-name = ["actual_package", "compat_package"]
module-root = "."
```

The build copies `actual_package/` and `compat_package/`, including files inside those
directories. Both `module-root = "."` and `module-root = ""` mean a flat layout.
Unrelated root-level files such as `tests/` and documentation are excluded from this
source-copy step. For a non-empty source root such as `src` or `python`, the entire
source root is copied. Package metadata and declared `project.license-files` are
staged separately.

!!! warning "Non-editable installs"
With `no_editable=True`, changes to source code trigger a rebuild of the package.
Third-party dependencies remain cached.

!!! warning "Staging limitations"
`namespace = true` and type-stub (`-stubs`) packages are not supported by the
current scaffold, which creates `__init__.py` placeholders. External data
directories, custom include rules, and extra build-hook inputs are not staged
automatically. Supply required inputs before `with_local_dependencies()` when
using such settings; a full project copy after installation is too late for
files the backend needs while building.

## The pipeline — when you need control

`install` is a convenience wrapper. When you need to do something *between* the steps, drive the pipeline yourself. `build` prepares the build without
Expand Down
16 changes: 8 additions & 8 deletions uv/src/uv/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -237,8 +237,9 @@ def require_package_selection(packages: list[str], all_packages: bool, default_p


def parse_local_packages(lock_data: dict) -> OrderedDict[str, str]:
"""Return {package_name: local_path} for all local packages (editable or directory).
"""Return {package_name: local_path} for editable, directory, and virtual packages.

Virtual members still need their metadata staged for workspace resolution.
Results are sorted by package name for deterministic build order.
"""
result = {}
Expand All @@ -248,6 +249,8 @@ def parse_local_packages(lock_data: dict) -> OrderedDict[str, str]:
result[pkg["name"]] = source["editable"]
elif "directory" in source:
result[pkg["name"]] = source["directory"]
elif "virtual" in source:
result[pkg["name"]] = source["virtual"]
return OrderedDict(sorted(result.items()))


Expand All @@ -258,10 +261,8 @@ def find_transitive_local_deps(lock_data: dict, project: str) -> OrderedDict[str
The *project* name is PEP 503-normalised so callers can pass the raw
`[project].name` from `pyproject.toml` (which may use underscores).

`project` need not itself be a local package: a virtual workspace root
(`source = { virtual = ... }` in the lock) isn't editable/directory, but it
still declares dependencies on workspace members. We traverse its dependency
graph regardless, collecting the *local* packages reached.
Virtual roots and members participate in traversal just like installable
packages, including dependencies reached through a virtual member.
"""
locals_ = parse_local_packages(lock_data)
project = normalize_package_name(project)
Expand All @@ -275,9 +276,8 @@ def find_transitive_local_deps(lock_data: dict, project: str) -> OrderedDict[str
dep_graph[name] = sorted(deps)

needed: dict[str, str] = {}
# Seed traversal from `project` even when it isn't local (e.g. a virtual
# workspace root) so we still walk its dependencies; only local packages are
# recorded in `needed`, and traversal continues only through local packages
# Seed traversal from `project` even when it isn't local so we still walk
# its dependencies. Only local packages are recorded in `needed` and traversed
# (a third-party dep's transitive deps are remote, not workspace-local).
visited = {project}
queue: deque[str] = deque([project])
Expand Down
19 changes: 5 additions & 14 deletions uv/src/uv/workspace/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -236,13 +236,10 @@ def _scaffold_package(self, overlay: dagger.Directory, workdir: str, pkg: LocalP
)
if pkg.name in self.plan.flat_packages:
return overlay
src_name = pkg.module
overlay = overlay.with_new_file(posixpath.join(ctr_base, "README.md").lstrip("/"), "")
if pkg.flat:
overlay = overlay.with_new_file(posixpath.join(ctr_base, src_name, "__init__.py").lstrip("/"), "")
else:
for module_path in pkg.module_paths:
overlay = overlay.with_new_file(
posixpath.join(ctr_base, "src", src_name, "__init__.py").lstrip("/"),
posixpath.join(ctr_base, module_path, "__init__.py").lstrip("/"),
"",
)
license_files = tomllib.loads(pkg.pyproject_contents).get("project", {}).get("license-files", [])
Expand Down Expand Up @@ -306,16 +303,10 @@ def _copy_package(self, overlay: dagger.Directory, workdir: str, pkg: LocalPacka
"""Copy a single local package's real source into the container."""
resolved = posixpath.normpath(posixpath.join(self.plan.workspace_path, pkg.path))
ctr_base = posixpath.normpath(posixpath.join(workdir, pkg.path))
if pkg.flat:
src_name = pkg.module
for module_path in pkg.source_paths:
overlay = overlay.with_directory(
posixpath.join(ctr_base, src_name).lstrip("/"),
self.plan.source_dir.directory(posixpath.join(resolved, src_name)),
)
else:
overlay = overlay.with_directory(
posixpath.join(ctr_base, "src").lstrip("/"),
self.plan.source_dir.directory(posixpath.join(resolved, "src")),
posixpath.join(ctr_base, module_path).lstrip("/"),
self.plan.source_dir.directory(posixpath.join(resolved, module_path)),
)
return overlay

Expand Down
Loading
Loading