Skip to content
Draft
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
28 changes: 28 additions & 0 deletions crates/uv-distribution/src/metadata/lowering.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,34 @@ impl LoweredRequirement {
(None, RequirementOrigin::Project)
};

// If the project declares a non-workspace source for a workspace member (e.g., a Git
// source for standalone use), but the workspace root declares it as a workspace source,
// prefer the workspace root's source.
let (sources, origin) = if matches!(origin, RequirementOrigin::Project)
&& workspace.packages().contains_key(&requirement.name)
&& sources.is_some_and(|s| s.iter().any(|s| !matches!(s, Source::Workspace { .. })))
{
if let Some(workspace_source) = workspace.sources().get(&requirement.name) {
if workspace_source.iter().any(|s| {
matches!(
s,
Source::Workspace {
workspace: true,
..
}
)
}) {
(Some(workspace_source), RequirementOrigin::Workspace)
} else {
(sources, origin)
}
} else {
(sources, origin)
}
} else {
(sources, origin)
};

// If the source only applies to a given extra or dependency group, filter it out.
let sources = sources.map(|sources| {
sources
Expand Down
80 changes: 80 additions & 0 deletions crates/uv/tests/it/lock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9726,6 +9726,86 @@ fn lock_no_workspace_source() -> Result<()> {
Ok(())
}

/// When a workspace member declares a non-workspace source (e.g., Git, path) for another workspace
/// member, but the workspace root declares `workspace = true` for that same member, the workspace
/// root's source should take precedence. This supports the pattern where members declare
/// non-workspace sources (e.g., Git, path) for standalone use, while the workspace root overrides
/// them as workspace sources.
///
/// See: <https://github.com/astral-sh/uv/issues/18232>
#[test]
fn lock_member_non_workspace_source_with_root_workspace_source() -> Result<()> {
let context = uv_test::test_context!("3.12");

let root_pyproject = context.temp_dir.child("pyproject.toml");
root_pyproject.write_str(
r#"
[project]
name = "monorepo"
version = "1.0.0"
requires-python = ">=3.12"
dependencies = ["app-a", "lib-x"]

[tool.uv.sources]
app-a = { workspace = true }
lib-x = { workspace = true }

[tool.uv.workspace]
members = ["packages/app-a", "packages/lib-x"]
"#,
)?;

let app_a = context.temp_dir.child("packages").child("app-a");
fs_err::create_dir_all(&app_a)?;

let app_a_pyproject = app_a.child("pyproject.toml");
app_a_pyproject.write_str(
r#"
[project]
name = "app-a"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = ["lib-x"]

[tool.uv.sources]
lib-x = { path = "../../packages/lib-x", editable = false }

[build-system]
requires = ["uv_build>=0.7,<10000"]
build-backend = "uv_build"
"#,
)?;

let lib_x = context.temp_dir.child("packages").child("lib-x");
fs_err::create_dir_all(&lib_x)?;

let lib_x_pyproject = lib_x.child("pyproject.toml");
lib_x_pyproject.write_str(
r#"
[project]
name = "lib-x"
version = "1.0.0"
requires-python = ">=3.12"
dependencies = []

[build-system]
requires = ["uv_build>=0.7,<10000"]
build-backend = "uv_build"
"#,
)?;

uv_snapshot!(context.filters(), context.lock(), @r"
success: true
exit_code: 0
----- stdout -----

----- stderr -----
Resolved 3 packages in [TIME]
");

Ok(())
}

/// Lock a workspace with a member that's a peer to the root.
#[test]
fn lock_peer_member() -> Result<()> {
Expand Down
Loading