From e4742317a304fc690b5116db8e17504e592473a7 Mon Sep 17 00:00:00 2001 From: danielgafni Date: Fri, 25 Sep 2026 11:50:41 +0300 Subject: [PATCH 1/6] fix(uv): support virtual members and configured module layouts --- .dagger/src/typesafe_daggerverse/main.py | 35 +++++++++++++ uv/src/uv/utils.py | 16 +++--- uv/src/uv/workspace/build.py | 23 ++++----- uv/src/uv/workspace/plan.py | 21 ++++++-- .../uv-build-layouts/app/pyproject.toml | 15 ++++++ .../uv-build-layouts/flat/pyproject.toml | 13 +++++ .../flat/vendor_sdk/__init__.py | 1 + .../_packages/uv-build-layouts/pyproject.toml | 14 ++++++ .../uv-build-layouts/renamed/pyproject.toml | 16 ++++++ .../renamed/python/actual_package/__init__.py | 3 ++ .../renamed/python/compat_package/__init__.py | 3 ++ uv/tests/_packages/uv-build-layouts/uv.lock | 49 +++++++++++++++++++ uv/tests/test_workspace_resolution.py | 35 ++++++++----- 13 files changed, 208 insertions(+), 36 deletions(-) create mode 100644 uv/tests/_packages/uv-build-layouts/app/pyproject.toml create mode 100644 uv/tests/_packages/uv-build-layouts/flat/pyproject.toml create mode 100644 uv/tests/_packages/uv-build-layouts/flat/vendor_sdk/__init__.py create mode 100644 uv/tests/_packages/uv-build-layouts/pyproject.toml create mode 100644 uv/tests/_packages/uv-build-layouts/renamed/pyproject.toml create mode 100644 uv/tests/_packages/uv-build-layouts/renamed/python/actual_package/__init__.py create mode 100644 uv/tests/_packages/uv-build-layouts/renamed/python/compat_package/__init__.py create mode 100644 uv/tests/_packages/uv-build-layouts/uv.lock diff --git a/.dagger/src/typesafe_daggerverse/main.py b/.dagger/src/typesafe_daggerverse/main.py index f7067d8..eb9f424 100644 --- a/.dagger/src/typesafe_daggerverse/main.py +++ b/.dagger/src/typesafe_daggerverse/main.py @@ -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) @@ -452,6 +453,40 @@ 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") + script = ( + "from importlib.metadata import distributions\n" + "import actual_package, compat_package, vendor_sdk\n" + "assert actual_package.VALUE == compat_package.VALUE == vendor_sdk.VALUE == 'real source'\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.""" diff --git a/uv/src/uv/utils.py b/uv/src/uv/utils.py index 10e53ca..ce6f6a3 100644 --- a/uv/src/uv/utils.py +++ b/uv/src/uv/utils.py @@ -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 = {} @@ -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())) @@ -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) @@ -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]) diff --git a/uv/src/uv/workspace/build.py b/uv/src/uv/workspace/build.py index 4ba6d30..4faa4dd 100644 --- a/uv/src/uv/workspace/build.py +++ b/uv/src/uv/workspace/build.py @@ -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", []) @@ -306,16 +303,16 @@ 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 - overlay = overlay.with_directory( - posixpath.join(ctr_base, src_name).lstrip("/"), - self.plan.source_dir.directory(posixpath.join(resolved, src_name)), - ) + metadata = tomllib.loads(pkg.pyproject_contents) + if metadata.get("build-system", {}).get("build-backend") == "uv_build": + root = metadata.get("tool", {}).get("uv", {}).get("build-backend", {}).get("module-root", "src") + paths = [root] if root else pkg.module_paths else: + paths = [pkg.module] if pkg.flat else ["src"] + for module_path in paths: 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 diff --git a/uv/src/uv/workspace/plan.py b/uv/src/uv/workspace/plan.py index b7ff2ee..6f25d51 100644 --- a/uv/src/uv/workspace/plan.py +++ b/uv/src/uv/workspace/plan.py @@ -109,7 +109,7 @@ async def _discover_local_packages( @object_type class LocalPackage: - """A local (editable/directory) package in a uv workspace. + """A local (editable/directory/virtual) package in a uv workspace. ``pyproject_contents`` is captured while resolving the plan so scaffold layers depend on package metadata, not on the whole source directory. @@ -127,6 +127,19 @@ class LocalPackage: Doc("Already-read package metadata used when creating a dependency scaffold"), ] = field() + @property + def module_paths(self) -> list[str]: + """Package-relative import directories used for both stubs and source copies.""" + metadata = tomllib.loads(self.pyproject_contents) + if metadata.get("build-system", {}).get("build-backend") == "uv_build": + backend = metadata.get("tool", {}).get("uv", {}).get("build-backend", {}) + names = backend.get("module-name", self.name.lower().replace("-", "_").replace(".", "_")) + if isinstance(names, str): + names = [names] + root = backend.get("module-root", "src") + return [posixpath.join(root, name.replace(".", "/")) for name in names] + return [self.module if self.flat else posixpath.join("src", self.module)] + @object_type class UvSyncPlan: @@ -159,7 +172,7 @@ class UvSyncPlan: flat_packages: Annotated[ list[str], - Doc("Local packages with no build-system (virtual/deps-only: pyproject scaffolded, source skipped)"), + Doc("Virtual/deps-only packages: pyproject scaffolded, source skipped"), ] = field(default=list) uv_sync_args: Annotated[ @@ -217,7 +230,7 @@ async def create( lock_data, packages, all_packages, default_package, workspace_path, source_dir, ws_dir ) - # A local package with no [build-system] is a virtual (deps-only) project: + # A local package with no [build-system] or with package = false is deps-only: # uv installs its dependencies but never builds the package itself, so its # source must not be scaffolded or copied — only its pyproject.toml (for # dependency resolution). This holds whether the package is a build target @@ -230,7 +243,7 @@ async def create( contents = await source_dir.file(posixpath.join(resolved, "pyproject.toml")).contents() pyproject_contents[name] = contents pkg_toml = tomllib.loads(contents) - if "build-system" not in pkg_toml: + if "build-system" not in pkg_toml or pkg_toml.get("tool", {}).get("uv", {}).get("package") is False: flat_packages.append(name) sync_args = build_uv_sync_args( diff --git a/uv/tests/_packages/uv-build-layouts/app/pyproject.toml b/uv/tests/_packages/uv-build-layouts/app/pyproject.toml new file mode 100644 index 0000000..77fe0e9 --- /dev/null +++ b/uv/tests/_packages/uv-build-layouts/app/pyproject.toml @@ -0,0 +1,15 @@ +[project] +name = "virtual-app" +version = "0.1.0" +requires-python = ">=3.13" +dependencies = ["renamed-dist"] + +[build-system] +requires = ["uv_build>=0.11.11,<0.12"] +build-backend = "uv_build" + +[tool.uv] +package = false + +[tool.uv.sources] +renamed-dist = { workspace = true } diff --git a/uv/tests/_packages/uv-build-layouts/flat/pyproject.toml b/uv/tests/_packages/uv-build-layouts/flat/pyproject.toml new file mode 100644 index 0000000..24c831c --- /dev/null +++ b/uv/tests/_packages/uv-build-layouts/flat/pyproject.toml @@ -0,0 +1,13 @@ +[project] +name = "flat-dist" +version = "0.1.0" +requires-python = ">=3.13" +dependencies = [] + +[build-system] +requires = ["uv_build>=0.11.11,<0.12"] +build-backend = "uv_build" + +[tool.uv.build-backend] +module-name = "vendor_sdk" +module-root = "" diff --git a/uv/tests/_packages/uv-build-layouts/flat/vendor_sdk/__init__.py b/uv/tests/_packages/uv-build-layouts/flat/vendor_sdk/__init__.py new file mode 100644 index 0000000..f896f4e --- /dev/null +++ b/uv/tests/_packages/uv-build-layouts/flat/vendor_sdk/__init__.py @@ -0,0 +1 @@ +VALUE = "real source" diff --git a/uv/tests/_packages/uv-build-layouts/pyproject.toml b/uv/tests/_packages/uv-build-layouts/pyproject.toml new file mode 100644 index 0000000..84cbd31 --- /dev/null +++ b/uv/tests/_packages/uv-build-layouts/pyproject.toml @@ -0,0 +1,14 @@ +[project] +name = "layout-workspace" +version = "0.1.0" +requires-python = ">=3.13" +dependencies = ["virtual-app"] + +[tool.uv] +package = false + +[tool.uv.workspace] +members = ["app", "renamed", "flat"] + +[tool.uv.sources] +virtual-app = { workspace = true } diff --git a/uv/tests/_packages/uv-build-layouts/renamed/pyproject.toml b/uv/tests/_packages/uv-build-layouts/renamed/pyproject.toml new file mode 100644 index 0000000..4de77c0 --- /dev/null +++ b/uv/tests/_packages/uv-build-layouts/renamed/pyproject.toml @@ -0,0 +1,16 @@ +[project] +name = "renamed-dist" +version = "0.1.0" +requires-python = ">=3.13" +dependencies = ["flat-dist"] + +[build-system] +requires = ["uv_build>=0.11.11,<0.12"] +build-backend = "uv_build" + +[tool.uv.build-backend] +module-name = ["actual_package", "compat_package"] +module-root = "python" + +[tool.uv.sources] +flat-dist = { workspace = true } diff --git a/uv/tests/_packages/uv-build-layouts/renamed/python/actual_package/__init__.py b/uv/tests/_packages/uv-build-layouts/renamed/python/actual_package/__init__.py new file mode 100644 index 0000000..fa803e4 --- /dev/null +++ b/uv/tests/_packages/uv-build-layouts/renamed/python/actual_package/__init__.py @@ -0,0 +1,3 @@ +from vendor_sdk import VALUE + +__all__ = ["VALUE"] diff --git a/uv/tests/_packages/uv-build-layouts/renamed/python/compat_package/__init__.py b/uv/tests/_packages/uv-build-layouts/renamed/python/compat_package/__init__.py new file mode 100644 index 0000000..5a6cb9c --- /dev/null +++ b/uv/tests/_packages/uv-build-layouts/renamed/python/compat_package/__init__.py @@ -0,0 +1,3 @@ +from actual_package import VALUE + +__all__ = ["VALUE"] diff --git a/uv/tests/_packages/uv-build-layouts/uv.lock b/uv/tests/_packages/uv-build-layouts/uv.lock new file mode 100644 index 0000000..b6e23c1 --- /dev/null +++ b/uv/tests/_packages/uv-build-layouts/uv.lock @@ -0,0 +1,49 @@ +version = 1 +revision = 3 +requires-python = ">=3.13" + +[manifest] +members = [ + "flat-dist", + "layout-workspace", + "renamed-dist", + "virtual-app", +] + +[[package]] +name = "flat-dist" +version = "0.1.0" +source = { editable = "flat" } + +[[package]] +name = "layout-workspace" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "virtual-app" }, +] + +[package.metadata] +requires-dist = [{ name = "virtual-app", virtual = "app" }] + +[[package]] +name = "renamed-dist" +version = "0.1.0" +source = { editable = "renamed" } +dependencies = [ + { name = "flat-dist" }, +] + +[package.metadata] +requires-dist = [{ name = "flat-dist", editable = "flat" }] + +[[package]] +name = "virtual-app" +version = "0.1.0" +source = { virtual = "app" } +dependencies = [ + { name = "renamed-dist" }, +] + +[package.metadata] +requires-dist = [{ name = "renamed-dist", editable = "renamed" }] diff --git a/uv/tests/test_workspace_resolution.py b/uv/tests/test_workspace_resolution.py index 36a3186..26e9ee5 100644 --- a/uv/tests/test_workspace_resolution.py +++ b/uv/tests/test_workspace_resolution.py @@ -40,6 +40,7 @@ def test_parse_local_packages(self): "my-app": "my-app", "my-lib": "my-lib", "my-core": "my-core", + "test-ws": ".", } def test_find_transitive_from_app(self): @@ -61,10 +62,10 @@ def test_find_transitive_from_leaf(self): result = find_transitive_local_deps(self.lock_data, "my-core") assert result == {"my-core": "my-core"} - def test_skips_virtual_root(self): - """The workspace root (source = virtual) should not appear in local packages.""" + def test_includes_virtual_root(self): + """Virtual metadata must be available when staging the workspace.""" result = parse_local_packages(self.lock_data) - assert "test-ws" not in result + assert result["test-ws"] == "." def test_parse_returns_ordered_dict(self): result = parse_local_packages(self.lock_data) @@ -72,7 +73,7 @@ def test_parse_returns_ordered_dict(self): def test_parse_sorted_order(self): result = parse_local_packages(self.lock_data) - assert list(result.keys()) == ["my-app", "my-core", "my-lib"] + assert list(result.keys()) == ["my-app", "my-core", "my-lib", "test-ws"] def test_transitive_returns_ordered_dict(self): result = find_transitive_local_deps(self.lock_data, "my-app") @@ -114,9 +115,8 @@ def test_transitive_from_my_dep(self): class TestVirtualWorkspaceRoot: """A virtual workspace root (source = virtual) still pulls in its members. - Regression: `find_transitive_local_deps` must traverse the root's deps even - though the root itself isn't an editable/directory (local) package — else a - bare `uv sync` (current-package default) leaves members unscaffolded. + Virtual members must be scaffolded and traversed, including when their + dependencies are reached through another virtual member. """ lock_data: ClassVar = { @@ -126,17 +126,20 @@ class TestVirtualWorkspaceRoot: "source": {"virtual": "."}, "dependencies": [{"name": "alarms"}, {"name": "networking"}], }, - {"name": "alarms", "source": {"editable": "alarms"}, "dependencies": [{"name": "networking"}]}, + {"name": "alarms", "source": {"virtual": "alarms"}, "dependencies": [{"name": "networking"}]}, {"name": "networking", "source": {"editable": "networking"}}, ] } - def test_root_not_local(self): - assert "the-root" not in parse_local_packages(self.lock_data) + def test_virtual_metadata_is_local(self): + assert parse_local_packages(self.lock_data) == {"the-root": ".", "alarms": "alarms", "networking": "networking"} def test_transitive_from_virtual_root(self): result = find_transitive_local_deps(self.lock_data, "the-root") - assert result == {"alarms": "alarms", "networking": "networking"} + assert result == {"the-root": ".", "alarms": "alarms", "networking": "networking"} + + def test_transitive_through_virtual_member(self): + assert find_transitive_local_deps(self.lock_data, "alarms") == {"alarms": "alarms", "networking": "networking"} class TestStandalone: @@ -169,6 +172,7 @@ def test_parse_local_packages(self): "my-app": "my-app", "my-lib": "my-lib", "my-core": "my-core", + "test-ws-app": ".", } def test_directory_source_detected(self): @@ -216,6 +220,8 @@ def test_original_workspace_not_flat(self): lock = _load_lock(ws) local = parse_local_packages(lock) for pkg in local: + if local[pkg] == ".": + continue assert _is_flat_package(ws, local, pkg) is False @@ -231,6 +237,7 @@ def test_parse_local_packages(self): "my-app": "my-app", "my-lib": "my-lib", "my-core": "my-core", + "test-ws-flat": ".", } def test_find_transitive_from_app(self): @@ -245,6 +252,8 @@ def test_flat_layout_detection(self): """my-lib and my-core use flat layout (no src/), my-app uses src layout.""" local = parse_local_packages(self.lock_data) for name, path in local.items(): + if path == ".": + continue module = name.replace("-", "_") src_init = self.ws_root / path / "src" / module / "__init__.py" flat_init = self.ws_root / path / module / "__init__.py" @@ -259,6 +268,8 @@ def test_all_have_build_system(self): """All packages in workspace-flat have [build-system], unlike workspace-app.""" local = parse_local_packages(self.lock_data) for name, path in local.items(): + if path == ".": + continue toml = tomllib.loads((self.ws_root / path / "pyproject.toml").read_text()) assert "build-system" in toml, f"{name} should have build-system" @@ -266,6 +277,8 @@ def test_not_flat_package(self): """No package should be detected as flat-package (no build-system).""" local = parse_local_packages(self.lock_data) for pkg in local: + if local[pkg] == ".": + continue assert _is_flat_package(self.ws_root, local, pkg) is False From 38b2c217cd8ad39c4584ae3cff749f739cb2e021 Mon Sep 17 00:00:00 2001 From: danielgafni Date: Fri, 25 Sep 2026 12:01:32 +0300 Subject: [PATCH 2/6] fix(deps): update anyio to patched version --- .dagger/uv.lock | 6 +++--- github/uv.lock | 6 +++--- ruff/uv.lock | 6 +++--- uv/uv.lock | 6 +++--- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/.dagger/uv.lock b/.dagger/uv.lock index e99a3a3..db36bdd 100644 --- a/.dagger/uv.lock +++ b/.dagger/uv.lock @@ -4,14 +4,14 @@ requires-python = ">=3.13" [[package]] name = "anyio" -version = "4.13.0" +version = "4.14.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, ] [[package]] diff --git a/github/uv.lock b/github/uv.lock index 83dae45..1e08ff8 100644 --- a/github/uv.lock +++ b/github/uv.lock @@ -4,14 +4,14 @@ requires-python = ">=3.13" [[package]] name = "anyio" -version = "4.13.0" +version = "4.14.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, ] [[package]] diff --git a/ruff/uv.lock b/ruff/uv.lock index 2a3732d..1149072 100644 --- a/ruff/uv.lock +++ b/ruff/uv.lock @@ -4,14 +4,14 @@ requires-python = ">=3.13" [[package]] name = "anyio" -version = "4.13.0" +version = "4.14.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, ] [[package]] diff --git a/uv/uv.lock b/uv/uv.lock index dca8ca4..2c4bb9a 100644 --- a/uv/uv.lock +++ b/uv/uv.lock @@ -4,14 +4,14 @@ requires-python = ">=3.13" [[package]] name = "anyio" -version = "4.13.0" +version = "4.14.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, ] [[package]] From 9b3e6fb8e0f4b4ed410843ca8e34d4ac8a5381a5 Mon Sep 17 00:00:00 2001 From: danielgafni Date: Fri, 25 Sep 2026 12:12:10 +0300 Subject: [PATCH 3/6] refactor(uv): isolate and document uv_build layouts --- .dagger/src/typesafe_daggerverse/main.py | 3 + uv/docs/building.md | 58 +++++++++++++- uv/src/uv/workspace/build.py | 8 +- uv/src/uv/workspace/plan.py | 80 ++++++++++++------- uv/src/uv/workspace/uv_build.py | 36 +++++++++ .../uv-build-layouts/flat/pyproject.toml | 2 +- uv/tests/test_uv_build.py | 45 +++++++++++ 7 files changed, 192 insertions(+), 40 deletions(-) create mode 100644 uv/src/uv/workspace/uv_build.py create mode 100644 uv/tests/test_uv_build.py diff --git a/.dagger/src/typesafe_daggerverse/main.py b/.dagger/src/typesafe_daggerverse/main.py index eb9f424..664a79e 100644 --- a/.dagger/src/typesafe_daggerverse/main.py +++ b/.dagger/src/typesafe_daggerverse/main.py @@ -457,10 +457,13 @@ async def uv_no_editable_bakes_local_source(self) -> None: 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" ) diff --git a/uv/docs/building.md b/uv/docs/building.md index adb4154..e987c70 100644 --- a/uv/docs/building.md +++ b/uv/docs/building.md @@ -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" @@ -122,6 +123,61 @@ 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. + +With editable installs, real source is copied after installation. With +`no_editable=True`, it is copied before installation so the wheel contains real code. +Changes inside the selected source paths therefore rebuild non-editable packages; +changes to unrelated root-level files do not invalidate that installation layer. +Callers that need tests or other runtime files can copy them after the install step. + +The resolved settings are stored in `UvBuildLayout`, a Dagger data object shared by +the scaffolding and source-copy stages. The `resolve local package layouts` trace span +records a `package layout` event for each local member, including its backend, +dependency-only status, module paths, and source paths. The existing scaffold, copy, +and install spans measure the work performed by each stage. + +!!! 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 diff --git a/uv/src/uv/workspace/build.py b/uv/src/uv/workspace/build.py index 4faa4dd..3076a34 100644 --- a/uv/src/uv/workspace/build.py +++ b/uv/src/uv/workspace/build.py @@ -303,13 +303,7 @@ 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)) - metadata = tomllib.loads(pkg.pyproject_contents) - if metadata.get("build-system", {}).get("build-backend") == "uv_build": - root = metadata.get("tool", {}).get("uv", {}).get("build-backend", {}).get("module-root", "src") - paths = [root] if root else pkg.module_paths - else: - paths = [pkg.module] if pkg.flat else ["src"] - for module_path in paths: + for module_path in pkg.source_paths: overlay = overlay.with_directory( posixpath.join(ctr_base, module_path).lstrip("/"), self.plan.source_dir.directory(posixpath.join(resolved, module_path)), diff --git a/uv/src/uv/workspace/plan.py b/uv/src/uv/workspace/plan.py index 6f25d51..2ab185b 100644 --- a/uv/src/uv/workspace/plan.py +++ b/uv/src/uv/workspace/plan.py @@ -16,6 +16,7 @@ require_package_selection, ) from uv.workspace._codegen import dagger_codegen as _run_codegen +from uv.workspace.uv_build import UvBuildLayout def _match_reachable( @@ -126,20 +127,25 @@ class LocalPackage: str, Doc("Already-read package metadata used when creating a dependency scaffold"), ] = field() + uv_build_layout: Annotated[ + UvBuildLayout | None, + Doc("Backend-specific layout resolved once from package metadata"), + ] = field(default=None) @property def module_paths(self) -> list[str]: - """Package-relative import directories used for both stubs and source copies.""" - metadata = tomllib.loads(self.pyproject_contents) - if metadata.get("build-system", {}).get("build-backend") == "uv_build": - backend = metadata.get("tool", {}).get("uv", {}).get("build-backend", {}) - names = backend.get("module-name", self.name.lower().replace("-", "_").replace(".", "_")) - if isinstance(names, str): - names = [names] - root = backend.get("module-root", "src") - return [posixpath.join(root, name.replace(".", "/")) for name in names] + """Package-relative import directories used to scaffold modules.""" + if self.uv_build_layout is not None: + return self.uv_build_layout.module_paths return [self.module if self.flat else posixpath.join("src", self.module)] + @property + def source_paths(self) -> list[str]: + """Copy source roots, or only declared modules for a flat layout.""" + if self.uv_build_layout is not None: + return self.uv_build_layout.source_paths + return [self.module] if self.flat else ["src"] + @object_type class UvSyncPlan: @@ -237,14 +243,38 @@ async def create( # or a transitive workspace dependency (e.g. a Pulumi program whose code # lives at the package root, with no src/ or module dir to copy). flat_packages: list[str] = [] - pyproject_contents: dict[str, str] = {} - for name, pkg_path in all_local.items(): - resolved = posixpath.normpath(posixpath.join(workspace_path, pkg_path)) - contents = await source_dir.file(posixpath.join(resolved, "pyproject.toml")).contents() - pyproject_contents[name] = contents - pkg_toml = tomllib.loads(contents) - if "build-system" not in pkg_toml or pkg_toml.get("tool", {}).get("uv", {}).get("package") is False: - flat_packages.append(name) + local_packages: dict[str, LocalPackage] = {} + with get_tracer().start_as_current_span("resolve local package layouts") as span: + span.set_attribute("packages.count", len(all_local)) + for name, pkg_path in all_local.items(): + resolved = posixpath.normpath(posixpath.join(workspace_path, pkg_path)) + contents = await source_dir.file(posixpath.join(resolved, "pyproject.toml")).contents() + metadata = tomllib.loads(contents) + virtual = ( + "build-system" not in metadata or metadata.get("tool", {}).get("uv", {}).get("package") is False + ) + if virtual: + flat_packages.append(name) + pkg = LocalPackage( + name=name, + path=pkg_path, + module=_module_name(name), + flat=flat_flags.get(name, False), + pyproject_contents=contents, + uv_build_layout=UvBuildLayout.from_pyproject(metadata, name) if not virtual else None, + ) + local_packages[name] = pkg + span.add_event( + "package layout", + { + "package.name": name, + "package.path": pkg_path, + "package.backend": metadata.get("build-system", {}).get("build-backend", "none"), + "package.virtual": virtual, + "package.module_paths": [] if virtual else pkg.module_paths, + "package.source_paths": [] if virtual else pkg.source_paths, + }, + ) sync_args = build_uv_sync_args( packages=packages, @@ -256,24 +286,12 @@ async def create( no_editable=no_editable, ) - def to_pkgs(local: OrderedDict[str, str]) -> list[LocalPackage]: - return [ - LocalPackage( - name=n, - path=p, - module=_module_name(n), - flat=flat_flags.get(n, False), - pyproject_contents=pyproject_contents[n], - ) - for n, p in local.items() - ] - return cls( ws_dir=ws_dir, source_dir=source_dir, workspace_path=workspace_path, - all_local=to_pkgs(all_local), - needed_local=to_pkgs(needed_local), + all_local=[local_packages[name] for name in all_local], + needed_local=[local_packages[name] for name in needed_local], flat_packages=flat_packages, uv_sync_args=sync_args, no_editable=no_editable, diff --git a/uv/src/uv/workspace/uv_build.py b/uv/src/uv/workspace/uv_build.py new file mode 100644 index 0000000..ca284a6 --- /dev/null +++ b/uv/src/uv/workspace/uv_build.py @@ -0,0 +1,36 @@ +"""Resolve uv_build's import layout for dependency scaffolding and source copies.""" + +import posixpath +from typing import Annotated, Self + +from dagger import Doc, field, object_type + + +@object_type +class UvBuildLayout: + """Normalized uv_build settings carried between the Dagger build steps.""" + + module_root: Annotated[str, Doc("Source root; an empty string means a flat layout")] = field() + module_names: Annotated[list[str], Doc("Declared Python import names")] = field() + + @classmethod + def from_pyproject(cls, metadata: dict, name: str) -> Self | None: + """Return a layout only for projects explicitly using uv_build.""" + if metadata.get("build-system", {}).get("build-backend") != "uv_build": + return None + backend = metadata.get("tool", {}).get("uv", {}).get("build-backend", {}) + names = backend.get("module-name", name.lower().replace("-", "_").replace(".", "_")) + if isinstance(names, str): + names = [names] + root = posixpath.normpath(backend.get("module-root", "src")) + return cls(module_root="" if root == "." else root, module_names=names) + + @property + def module_paths(self) -> list[str]: + """Import directories to scaffold, relative to the package's pyproject.toml.""" + return [posixpath.join(self.module_root, name.replace(".", "/")) for name in self.module_names] + + @property + def source_paths(self) -> list[str]: + """Copy the source root, or only declared modules for a flat layout.""" + return [self.module_root] if self.module_root else self.module_paths diff --git a/uv/tests/_packages/uv-build-layouts/flat/pyproject.toml b/uv/tests/_packages/uv-build-layouts/flat/pyproject.toml index 24c831c..9297394 100644 --- a/uv/tests/_packages/uv-build-layouts/flat/pyproject.toml +++ b/uv/tests/_packages/uv-build-layouts/flat/pyproject.toml @@ -10,4 +10,4 @@ build-backend = "uv_build" [tool.uv.build-backend] module-name = "vendor_sdk" -module-root = "" +module-root = "." diff --git a/uv/tests/test_uv_build.py b/uv/tests/test_uv_build.py new file mode 100644 index 0000000..a64021e --- /dev/null +++ b/uv/tests/test_uv_build.py @@ -0,0 +1,45 @@ +"""Backend settings determine scaffold paths and the files copied for installation.""" + +import pytest +from uv.workspace.uv_build import UvBuildLayout + + +@pytest.mark.parametrize("root", ["", ".", "./"]) +def test_flat_roots_copy_only_declared_modules(root): + layout = UvBuildLayout.from_pyproject( + { + "build-system": {"build-backend": "uv_build"}, + "tool": { + "uv": {"build-backend": {"module-name": ["actual_package", "compat_package"], "module-root": root}} + }, + }, + "renamed-dist", + ) + assert layout is not None + assert layout.module_root == "" + assert layout.module_paths == layout.source_paths == ["actual_package", "compat_package"] + + +def test_default_module_name_and_source_root(): + layout = UvBuildLayout.from_pyproject({"build-system": {"build-backend": "uv_build"}}, "Example.Dist-Name") + assert layout is not None + assert layout.module_paths == ["src/example_dist_name"] + assert layout.source_paths == ["src"] + + +def test_dotted_module_under_custom_root(): + layout = UvBuildLayout.from_pyproject( + { + "build-system": {"build-backend": "uv_build"}, + "tool": {"uv": {"build-backend": {"module-name": "example.api", "module-root": "./python"}}}, + }, + "example-dist", + ) + assert layout is not None + assert layout.module_paths == ["python/example/api"] + assert layout.source_paths == ["python"] + + +@pytest.mark.parametrize("backend", ["hatchling.build", "setuptools.build_meta"]) +def test_other_backends_use_the_generic_layout(backend): + assert UvBuildLayout.from_pyproject({"build-system": {"build-backend": backend}}, "example") is None From 4b4667daecdbc4f55b8a6813b76ec447faab24e6 Mon Sep 17 00:00:00 2001 From: danielgafni Date: Fri, 25 Sep 2026 12:22:02 +0300 Subject: [PATCH 4/6] docs(uv): clarify caching and remove internal details --- uv/docs/building.md | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/uv/docs/building.md b/uv/docs/building.md index e987c70..3ea9901 100644 --- a/uv/docs/building.md +++ b/uv/docs/building.md @@ -158,17 +158,10 @@ source-copy step. For a non-empty source root such as `src` or `python`, the ent source root is copied. Package metadata and declared `project.license-files` are staged separately. -With editable installs, real source is copied after installation. With -`no_editable=True`, it is copied before installation so the wheel contains real code. -Changes inside the selected source paths therefore rebuild non-editable packages; -changes to unrelated root-level files do not invalidate that installation layer. -Callers that need tests or other runtime files can copy them after the install step. - -The resolved settings are stored in `UvBuildLayout`, a Dagger data object shared by -the scaffolding and source-copy stages. The `resolve local package layouts` trace span -records a `package layout` event for each local member, including its backend, -dependency-only status, module paths, and source paths. The existing scaffold, copy, -and install spans measure the work performed by each stage. +Editable installs (the default) keep `uv sync` cached across source changes. +With `no_editable=True`, source changes rebuild and reinstall local packages; +third-party dependencies remain cached. Copy tests and other runtime files after +installation. !!! warning "Staging limitations" `namespace = true` and type-stub (`-stubs`) packages are not supported by the From c4f7a3875636d75a074d890586985ef83d7736aa Mon Sep 17 00:00:00 2001 From: danielgafni Date: Fri, 25 Sep 2026 12:28:58 +0300 Subject: [PATCH 5/6] docs(uv): warn about non-editable cache invalidation --- uv/docs/building.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/uv/docs/building.md b/uv/docs/building.md index 3ea9901..2e22dcd 100644 --- a/uv/docs/building.md +++ b/uv/docs/building.md @@ -158,10 +158,9 @@ source-copy step. For a non-empty source root such as `src` or `python`, the ent source root is copied. Package metadata and declared `project.license-files` are staged separately. -Editable installs (the default) keep `uv sync` cached across source changes. -With `no_editable=True`, source changes rebuild and reinstall local packages; -third-party dependencies remain cached. Copy tests and other runtime files after -installation. +!!! warning "Non-editable installs" + With `no_editable=True`, source changes rebuild and reinstall local packages. + Third-party dependencies remain cached. !!! warning "Staging limitations" `namespace = true` and type-stub (`-stubs`) packages are not supported by the From b27acf39f20debb7d4b5e491b477c3b78fcf9603 Mon Sep 17 00:00:00 2001 From: danielgafni Date: Fri, 25 Sep 2026 12:30:34 +0300 Subject: [PATCH 6/6] docs(uv): simplify rebuild warning --- uv/docs/building.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uv/docs/building.md b/uv/docs/building.md index 2e22dcd..a770316 100644 --- a/uv/docs/building.md +++ b/uv/docs/building.md @@ -159,7 +159,7 @@ source root is copied. Package metadata and declared `project.license-files` are staged separately. !!! warning "Non-editable installs" - With `no_editable=True`, source changes rebuild and reinstall local packages. + With `no_editable=True`, changes to source code trigger a rebuild of the package. Third-party dependencies remain cached. !!! warning "Staging limitations"