Skip to content

Commit ca1a046

Browse files
committed
Allow workspace root sources to override member non-workspace sources for workspace members
When a workspace member (e.g., `app-a`) declares a non-workspace source (Git, path, etc.) for another workspace member (e.g., `lib-x`) in its `tool.uv.sources`, but the workspace root declares `lib-x = { workspace = true }`, fall back to the workspace root's source instead of raising a `NonWorkspaceSource` error. This supports the pattern where members declare Git sources for standalone use outside the workspace, while the workspace root overrides them as workspace sources when developing within the monorepo. The regression was introduced in v0.9.3 when commit b151e0e fixed workspace member glob normalization for `./` prefixed paths. Before that fix, workspaces using `members = ["./packages/..."]` didn't properly discover members from child directories, so the workspace member source check didn't fire. Fixes #18232 https://claude.ai/code/session_01XBDgKsN3dyqoucYic8YNGn
1 parent 6571827 commit ca1a046

2 files changed

Lines changed: 108 additions & 0 deletions

File tree

crates/uv-distribution/src/metadata/lowering.rs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,34 @@ impl LoweredRequirement {
5656
(None, RequirementOrigin::Project)
5757
};
5858

59+
// If the project declares a non-workspace source for a workspace member (e.g., a Git
60+
// source for standalone use), but the workspace root declares it as a workspace source,
61+
// prefer the workspace root's source.
62+
let (sources, origin) = if matches!(origin, RequirementOrigin::Project)
63+
&& workspace.packages().contains_key(&requirement.name)
64+
&& sources.is_some_and(|s| s.iter().any(|s| !matches!(s, Source::Workspace { .. })))
65+
{
66+
if let Some(workspace_source) = workspace.sources().get(&requirement.name) {
67+
if workspace_source.iter().any(|s| {
68+
matches!(
69+
s,
70+
Source::Workspace {
71+
workspace: true,
72+
..
73+
}
74+
)
75+
}) {
76+
(Some(workspace_source), RequirementOrigin::Workspace)
77+
} else {
78+
(sources, origin)
79+
}
80+
} else {
81+
(sources, origin)
82+
}
83+
} else {
84+
(sources, origin)
85+
};
86+
5987
// If the source only applies to a given extra or dependency group, filter it out.
6088
let sources = sources.map(|sources| {
6189
sources

crates/uv/tests/it/lock.rs

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9726,6 +9726,86 @@ fn lock_no_workspace_source() -> Result<()> {
97269726
Ok(())
97279727
}
97289728

9729+
/// When a workspace member declares a non-workspace source (e.g., Git, path) for another workspace
9730+
/// member, but the workspace root declares `workspace = true` for that same member, the workspace
9731+
/// root's source should take precedence. This supports the pattern where members declare
9732+
/// non-workspace sources (e.g., Git, path) for standalone use, while the workspace root overrides
9733+
/// them as workspace sources.
9734+
///
9735+
/// See: <https://github.com/astral-sh/uv/issues/18232>
9736+
#[test]
9737+
fn lock_member_non_workspace_source_with_root_workspace_source() -> Result<()> {
9738+
let context = uv_test::test_context!("3.12");
9739+
9740+
let root_pyproject = context.temp_dir.child("pyproject.toml");
9741+
root_pyproject.write_str(
9742+
r#"
9743+
[project]
9744+
name = "monorepo"
9745+
version = "1.0.0"
9746+
requires-python = ">=3.12"
9747+
dependencies = ["app-a", "lib-x"]
9748+
9749+
[tool.uv.sources]
9750+
app-a = { workspace = true }
9751+
lib-x = { workspace = true }
9752+
9753+
[tool.uv.workspace]
9754+
members = ["packages/app-a", "packages/lib-x"]
9755+
"#,
9756+
)?;
9757+
9758+
let app_a = context.temp_dir.child("packages").child("app-a");
9759+
fs_err::create_dir_all(&app_a)?;
9760+
9761+
let app_a_pyproject = app_a.child("pyproject.toml");
9762+
app_a_pyproject.write_str(
9763+
r#"
9764+
[project]
9765+
name = "app-a"
9766+
version = "0.1.0"
9767+
requires-python = ">=3.12"
9768+
dependencies = ["lib-x"]
9769+
9770+
[tool.uv.sources]
9771+
lib-x = { path = "../../packages/lib-x", editable = false }
9772+
9773+
[build-system]
9774+
requires = ["uv_build>=0.7,<10000"]
9775+
build-backend = "uv_build"
9776+
"#,
9777+
)?;
9778+
9779+
let lib_x = context.temp_dir.child("packages").child("lib-x");
9780+
fs_err::create_dir_all(&lib_x)?;
9781+
9782+
let lib_x_pyproject = lib_x.child("pyproject.toml");
9783+
lib_x_pyproject.write_str(
9784+
r#"
9785+
[project]
9786+
name = "lib-x"
9787+
version = "1.0.0"
9788+
requires-python = ">=3.12"
9789+
dependencies = []
9790+
9791+
[build-system]
9792+
requires = ["uv_build>=0.7,<10000"]
9793+
build-backend = "uv_build"
9794+
"#,
9795+
)?;
9796+
9797+
uv_snapshot!(context.filters(), context.lock(), @r"
9798+
success: true
9799+
exit_code: 0
9800+
----- stdout -----
9801+
9802+
----- stderr -----
9803+
Resolved 3 packages in [TIME]
9804+
");
9805+
9806+
Ok(())
9807+
}
9808+
97299809
/// Lock a workspace with a member that's a peer to the root.
97309810
#[test]
97319811
fn lock_peer_member() -> Result<()> {

0 commit comments

Comments
 (0)