From 3723315ee615b1ae7fa9b136ca0df608c32d6092 Mon Sep 17 00:00:00 2001 From: Tomasz Kramkowski Date: Fri, 19 Jun 2026 18:21:46 +0100 Subject: [PATCH] Treat workspace members with an intermediate `pyproject.toml` as standalone (#19926) ## Summary Based on #19924. Handle the case where a `pyproject.toml` sits between a member and its workspace root. This unintentionally supported case regressed when adding workspace caching, which memorises which projects are members of a workspace without requiring re-discovery, ignoring a `pyproject.toml` in the middle. Before caching, workspace discovery for this project starting from the project itself would terminate at the intermediate `pyroject.toml` and treat it as standalone project, which this hack restores. Fixes #19916 ## Test Plan Added a regression test, unlike for Konsti's version of the PR, we have existing coverage for the case where we mess this up so we don't need a second test to cover it. Co-authored-by: konstin --- crates/uv-workspace/src/workspace.rs | 25 ++++++ crates/uv/tests/lock/lock.rs | 113 +++++++++++++++++++++++++++ 2 files changed, 138 insertions(+) diff --git a/crates/uv-workspace/src/workspace.rs b/crates/uv-workspace/src/workspace.rs index 5dc0c533fb..068edcd616 100644 --- a/crates/uv-workspace/src/workspace.rs +++ b/crates/uv-workspace/src/workspace.rs @@ -60,6 +60,13 @@ impl WorkspaceCache { match result { Ok(workspace) => { for package in workspace.packages.values() { + // Historically, upward workspace discovery stopped at an intermediate + // `pyproject.toml`, so don't map this member to the outer workspace in that + // case. + // See: + if has_intermediate_pyproject(&workspace.install_path, &package.root) { + continue; + } self.workspaces .done(package.root.clone(), Ok(workspace.clone())); } @@ -99,6 +106,24 @@ impl WorkspaceCache { } } +/// Returns `true` when a `pyproject.toml` sits between the member project directory and the +/// workspace root. +fn has_intermediate_pyproject(workspace_root: &Path, project_dir: &Path) -> bool { + if project_dir == workspace_root { + return false; + } + + let Ok(_) = project_dir.strip_prefix(workspace_root) else { + return false; + }; + + project_dir + .ancestors() + .skip(1) + .take_while(|ancestor| *ancestor != workspace_root) + .any(|ancestor| ancestor.join("pyproject.toml").is_file()) +} + #[derive(Debug, Clone)] pub struct WorkspaceError(Arc); diff --git a/crates/uv/tests/lock/lock.rs b/crates/uv/tests/lock/lock.rs index 025133a473..75f3863970 100644 --- a/crates/uv/tests/lock/lock.rs +++ b/crates/uv/tests/lock/lock.rs @@ -10228,6 +10228,119 @@ fn lock_no_workspace_source() -> Result<()> { Ok(()) } +/// Regression test for . +/// +/// Lock a workspace with a member that also supports standalone installation via platform-specific +/// path sources. +#[test] +fn lock_workspace_member_with_standalone_path_source() -> Result<()> { + let context = uv_test::test_context!("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [tool.uv.workspace] + members = ["root/app", "root/lib"] + + [tool.uv.sources] + lib = { workspace = true } + "#, + )?; + + let root = context.temp_dir.child("root"); + root.child("pyproject.toml").write_str( + r#" + [project] + name = "root" + version = "0.1.0" + + [tool.uv.sources] + lib = { path = "lib" } + "#, + )?; + + root.child("app").child("pyproject.toml").write_str( + r#" + [project] + name = "app" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["lib"] + + [tool.uv.sources] + lib = [ + { path = "../lib", editable = true, marker = "sys_platform == 'darwin'" }, + { path = "../lib", editable = true, marker = "sys_platform != 'darwin'" }, + ] + "#, + )?; + + root.child("lib").child("pyproject.toml").write_str( + r#" + [project] + name = "lib" + version = "0.1.0" + "#, + )?; + + uv_snapshot!(context.filters(), context.lock(), @" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 2 packages in [TIME] + "); + + let lock = context.read("uv.lock"); + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + lock, @r#" + version = 1 + revision = 3 + requires-python = ">=3.12" + resolution-markers = [ + "sys_platform == 'darwin'", + "sys_platform != 'darwin'", + ] + + [options] + exclude-newer = "2024-03-25T00:00:00Z" + + [manifest] + members = [ + "app", + "lib", + ] + + [[package]] + name = "app" + version = "0.1.0" + source = { virtual = "root/app" } + dependencies = [ + { name = "lib" }, + ] + + [package.metadata] + requires-dist = [ + { name = "lib", marker = "sys_platform != 'darwin'", editable = "root/lib" }, + { name = "lib", marker = "sys_platform == 'darwin'", editable = "root/lib" }, + ] + + [[package]] + name = "lib" + version = "0.1.0" + source = { editable = "root/lib" } + "# + ); + }); + + Ok(()) +} + /// Lock a workspace with a member that's a peer to the root. #[test] fn lock_peer_member() -> Result<()> {