Skip to content
Closed
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
34 changes: 32 additions & 2 deletions crates/uv-distribution/src/metadata/lowering.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,22 @@ impl LoweredRequirement {
editable: bool,
credentials_cache: &'data CredentialsCache,
) -> impl Iterator<Item = Result<Self, LoweringError>> + use<'data> + 'data {
// 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
// memorizes which projects are workspace members without traversing upwards, ignoring the
// `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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// starting from the project itself would terminate at the intermediate `pyroject.toml` and
// starting from the project itself would terminate at the intermediate `pyproject.toml` and

// treat it as standalone project, which this hack restores.
// <https://github.com/astral-sh/uv/issues/19916>
let treat_as_standalone_project =

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given the limitations of this approach, I am not sure if we should use this name.

has_intermediate_pyproject(workspace.install_path(), project_dir);

// Identify the source from the `tool.uv.sources` table.
let (sources, origin) = if let Some(source) = project_sources.get(&requirement.name) {
(Some(source), RequirementOrigin::Project)
} else if let Some(source) = workspace.sources().get(&requirement.name) {
} else if !treat_as_standalone_project
&& let Some(source) = workspace.sources().get(&requirement.name)
{
(Some(source), RequirementOrigin::Workspace)
} else {
(None, RequirementOrigin::Project)
Expand Down Expand Up @@ -86,7 +98,7 @@ impl LoweredRequirement {
});

// If you use a package that's part of the workspace...
if workspace.packages().contains_key(&requirement.name) {
if !treat_as_standalone_project && workspace.packages().contains_key(&requirement.name) {
// And it's not a recursive self-inclusion (extras that activate other extras), e.g.
// `framework[machine_learning]` depends on `framework[cuda]`.
if project_name.is_none_or(|project_name| *project_name != requirement.name) {
Expand Down Expand Up @@ -566,6 +578,24 @@ impl LoweredRequirement {
}
}

/// 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())
Comment on lines +592 to +596

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This I believe is actually broader than it used to be. We'd parse the intermediate files to check for certain qualities, not merely check they exist.

}

/// An error parsing and merging `tool.uv.sources` with
/// `project.{dependencies,optional-dependencies}`.
#[derive(Debug, Error)]
Expand Down
174 changes: 174 additions & 0 deletions crates/uv/tests/lock/lock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10228,6 +10228,180 @@ fn lock_no_workspace_source() -> Result<()> {
Ok(())
}

/// Lock a project-valued workspace root nested below another project.
#[test]
fn lock_no_workspace_source_nested_project() -> Result<()> {
let context = uv_test::test_context!("3.12");

let parent = context.temp_dir.child("parent");
parent.child("pyproject.toml").write_str(
r#"
[project]
name = "parent"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = []
"#,
)?;

let workspace = parent.child("workspace");
workspace.child("pyproject.toml").write_str(
r#"
[project]
name = "project"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = ["child"]

[tool.uv.workspace]
members = ["child"]
"#,
)?;

let child = workspace.child("child");
fs_err::create_dir_all(&child)?;
child.child("pyproject.toml").write_str(
r#"
[project]
name = "child"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = []

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

uv_snapshot!(context.filters(), context.lock().current_dir(&workspace), @"
success: false
exit_code: 1
----- stdout -----

----- stderr -----
Using CPython 3.12.[X] interpreter at: [PYTHON-3.12]
× Failed to build `project @ file://[TEMP_DIR]/parent/workspace`
├─▶ Failed to parse entry: `child`
╰─▶ `child` is included as a workspace member, but is missing an entry in `tool.uv.sources` (e.g., `child = { workspace = true }`)
");

Ok(())
}

/// Regression test for <https://github.com/astral-sh/uv/issues/19916>.
///
/// 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<()> {
Expand Down
Loading