diff --git a/crates/uv/src/commands/workspace/metadata.rs b/crates/uv/src/commands/workspace/metadata.rs index be549e708..c7afbcb19 100644 --- a/crates/uv/src/commands/workspace/metadata.rs +++ b/crates/uv/src/commands/workspace/metadata.rs @@ -1,12 +1,15 @@ +use std::collections::BTreeSet; use std::fmt::Write; -use std::path::Path; +use std::path::{Path, PathBuf}; +use std::str::FromStr; use anyhow::Result; use serde::Serialize; use uv_fs::PortablePathBuf; -use uv_normalize::PackageName; +use uv_normalize::{ExtraName, GroupName, PackageName}; use uv_preview::{Preview, PreviewFeatures}; +use uv_pypi_types::VerbatimParsedUrl; use uv_warnings::warn_user; use uv_workspace::{DiscoveryOptions, Workspace, WorkspaceCache}; @@ -29,13 +32,30 @@ struct SchemaReport { version: SchemaVersion, } +/// A dependency of a workspace member. +#[derive(Serialize, Debug)] +struct DependencyReport { + /// The name of the dependency. + name: PackageName, + /// Whether this dependency is another workspace member. + workspace: bool, + /// The extra that requires this dependency, if any. + #[serde(skip_serializing_if = "Option::is_none")] + extra: Option, + /// The dependency group that requires this dependency, if any. + #[serde(skip_serializing_if = "Option::is_none")] + group: Option, +} + /// Report for a single workspace member. #[derive(Serialize, Debug)] struct WorkspaceMemberReport { /// The name of the workspace member. name: PackageName, /// The path to the workspace member's root directory. - path: PortablePathBuf, + path: PathBuf, + /// All dependencies of this workspace member. + dependencies: Vec, } /// The report for a metadata operation. @@ -49,6 +69,68 @@ struct MetadataReport { members: Vec, } +/// Extract all dependencies from a workspace member. +/// +/// This function examines the member's regular dependencies, optional dependencies (extras), +/// and dependency groups, marking which dependencies are workspace members. +fn extract_dependencies( + package: &uv_workspace::WorkspaceMember, + workspace_names: &BTreeSet, +) -> Vec { + let mut dependencies = Vec::new(); + + // Extract regular dependencies + if let Some(deps) = package.project().dependencies.as_ref() { + for dep in deps { + if let Ok(req) = uv_pep508::Requirement::::from_str(dep) { + dependencies.push(DependencyReport { + name: req.name.clone(), + workspace: workspace_names.contains(&req.name), + extra: None, + group: None, + }); + } + } + } + + // Extract optional dependencies (extras) + if let Some(optional_dependencies) = package.project().optional_dependencies.as_ref() { + for (extra_name, deps) in optional_dependencies { + for dep in deps { + if let Ok(req) = uv_pep508::Requirement::::from_str(dep) { + dependencies.push(DependencyReport { + name: req.name.clone(), + workspace: workspace_names.contains(&req.name), + extra: Some(extra_name.clone()), + group: None, + }); + } + } + } + } + + // Extract dependency groups + if let Some(dependency_groups) = package.pyproject_toml().dependency_groups.as_ref() { + for (group_name, deps) in dependency_groups { + for dep_spec in deps { + // Only process Requirement variants, skip IncludeGroup + if let uv_pypi_types::DependencyGroupSpecifier::Requirement(dep) = dep_spec { + if let Ok(req) = uv_pep508::Requirement::::from_str(dep) { + dependencies.push(DependencyReport { + name: req.name.clone(), + workspace: workspace_names.contains(&req.name), + extra: None, + group: Some(group_name.clone()), + }); + } + } + } + } + } + + dependencies +} + /// Display package metadata. pub(crate) async fn metadata( project_dir: &Path, @@ -66,12 +148,20 @@ pub(crate) async fn metadata( let workspace = Workspace::discover(project_dir, &DiscoveryOptions::default(), &workspace_cache).await?; + // Collect all workspace member names for filtering + let workspace_names: BTreeSet = workspace + .packages() + .values() + .map(|package| package.project().name.clone()) + .collect(); + let members = workspace .packages() .values() .map(|package| WorkspaceMemberReport { name: package.project().name.clone(), - path: PortablePathBuf::from(package.root().as_path()), + path: package.root().clone(), + dependencies: extract_dependencies(package, &workspace_names), }) .collect(); diff --git a/crates/uv/tests/it/workspace_metadata.rs b/crates/uv/tests/it/workspace_metadata.rs index 14ae7e3c0..bd3a0b8ae 100644 --- a/crates/uv/tests/it/workspace_metadata.rs +++ b/crates/uv/tests/it/workspace_metadata.rs @@ -28,7 +28,7 @@ fn workspace_metadata_simple() { let workspace = context.temp_dir.child("foo"); - uv_snapshot!(context.filters(), context.workspace_metadata().current_dir(&workspace), @r###" + uv_snapshot!(context.filters(), context.workspace_metadata().current_dir(&workspace), @r#" success: true exit_code: 0 ----- stdout ----- @@ -40,13 +40,14 @@ fn workspace_metadata_simple() { "members": [ { "name": "foo", - "path": "[TEMP_DIR]/foo" + "path": "[TEMP_DIR]/foo", + "dependencies": [] } ] } ----- stderr ----- - "### + "# ); } @@ -61,7 +62,7 @@ fn workspace_metadata_root_workspace() -> Result<()> { &workspace, )?; - uv_snapshot!(context.filters(), context.workspace_metadata().current_dir(&workspace), @r###" + uv_snapshot!(context.filters(), context.workspace_metadata().current_dir(&workspace), @r#" success: true exit_code: 0 ----- stdout ----- @@ -73,21 +74,47 @@ fn workspace_metadata_root_workspace() -> Result<()> { "members": [ { "name": "albatross", - "path": "[TEMP_DIR]/workspace" + "path": "[TEMP_DIR]/workspace", + "dependencies": [ + { + "name": "bird-feeder", + "workspace": true + }, + { + "name": "iniconfig", + "workspace": false + } + ] }, { "name": "bird-feeder", - "path": "[TEMP_DIR]/workspace/packages/bird-feeder" + "path": "[TEMP_DIR]/workspace/packages/bird-feeder", + "dependencies": [ + { + "name": "iniconfig", + "workspace": false + }, + { + "name": "seeds", + "workspace": true + } + ] }, { "name": "seeds", - "path": "[TEMP_DIR]/workspace/packages/seeds" + "path": "[TEMP_DIR]/workspace/packages/seeds", + "dependencies": [ + { + "name": "idna", + "workspace": false + } + ] } ] } ----- stderr ----- - "### + "# ); Ok(()) @@ -104,7 +131,7 @@ fn workspace_metadata_virtual_workspace() -> Result<()> { &workspace, )?; - uv_snapshot!(context.filters(), context.workspace_metadata().current_dir(&workspace), @r###" + uv_snapshot!(context.filters(), context.workspace_metadata().current_dir(&workspace), @r#" success: true exit_code: 0 ----- stdout ----- @@ -116,21 +143,47 @@ fn workspace_metadata_virtual_workspace() -> Result<()> { "members": [ { "name": "albatross", - "path": "[TEMP_DIR]/workspace/packages/albatross" + "path": "[TEMP_DIR]/workspace/packages/albatross", + "dependencies": [ + { + "name": "bird-feeder", + "workspace": true + }, + { + "name": "iniconfig", + "workspace": false + } + ] }, { "name": "bird-feeder", - "path": "[TEMP_DIR]/workspace/packages/bird-feeder" + "path": "[TEMP_DIR]/workspace/packages/bird-feeder", + "dependencies": [ + { + "name": "anyio", + "workspace": false + }, + { + "name": "seeds", + "workspace": true + } + ] }, { "name": "seeds", - "path": "[TEMP_DIR]/workspace/packages/seeds" + "path": "[TEMP_DIR]/workspace/packages/seeds", + "dependencies": [ + { + "name": "idna", + "workspace": false + } + ] } ] } ----- stderr ----- - "### + "# ); Ok(()) @@ -149,7 +202,7 @@ fn workspace_metadata_from_member() -> Result<()> { let member_dir = workspace.join("packages").join("bird-feeder"); - uv_snapshot!(context.filters(), context.workspace_metadata().current_dir(&member_dir), @r###" + uv_snapshot!(context.filters(), context.workspace_metadata().current_dir(&member_dir), @r#" success: true exit_code: 0 ----- stdout ----- @@ -161,21 +214,47 @@ fn workspace_metadata_from_member() -> Result<()> { "members": [ { "name": "albatross", - "path": "[TEMP_DIR]/workspace" + "path": "[TEMP_DIR]/workspace", + "dependencies": [ + { + "name": "bird-feeder", + "workspace": true + }, + { + "name": "iniconfig", + "workspace": false + } + ] }, { "name": "bird-feeder", - "path": "[TEMP_DIR]/workspace/packages/bird-feeder" + "path": "[TEMP_DIR]/workspace/packages/bird-feeder", + "dependencies": [ + { + "name": "iniconfig", + "workspace": false + }, + { + "name": "seeds", + "workspace": true + } + ] }, { "name": "seeds", - "path": "[TEMP_DIR]/workspace/packages/seeds" + "path": "[TEMP_DIR]/workspace/packages/seeds", + "dependencies": [ + { + "name": "idna", + "workspace": false + } + ] } ] } ----- stderr ----- - "### + "# ); Ok(()) @@ -206,7 +285,7 @@ fn workspace_metadata_multiple_members() { .assert() .success(); - uv_snapshot!(context.filters(), context.workspace_metadata().current_dir(&workspace_root), @r###" + uv_snapshot!(context.filters(), context.workspace_metadata().current_dir(&workspace_root), @r#" success: true exit_code: 0 ----- stdout ----- @@ -218,21 +297,24 @@ fn workspace_metadata_multiple_members() { "members": [ { "name": "pkg-a", - "path": "[TEMP_DIR]/pkg-a" + "path": "[TEMP_DIR]/pkg-a", + "dependencies": [] }, { "name": "pkg-b", - "path": "[TEMP_DIR]/pkg-a/pkg-b" + "path": "[TEMP_DIR]/pkg-a/pkg-b", + "dependencies": [] }, { "name": "pkg-c", - "path": "[TEMP_DIR]/pkg-a/pkg-c" + "path": "[TEMP_DIR]/pkg-a/pkg-c", + "dependencies": [] } ] } ----- stderr ----- - "### + "# ); } @@ -245,7 +327,7 @@ fn workspace_metadata_single_project() { let project = context.temp_dir.child("my-project"); - uv_snapshot!(context.filters(), context.workspace_metadata().current_dir(&project), @r###" + uv_snapshot!(context.filters(), context.workspace_metadata().current_dir(&project), @r#" success: true exit_code: 0 ----- stdout ----- @@ -257,13 +339,14 @@ fn workspace_metadata_single_project() { "members": [ { "name": "my-project", - "path": "[TEMP_DIR]/my-project" + "path": "[TEMP_DIR]/my-project", + "dependencies": [] } ] } ----- stderr ----- - "### + "# ); } @@ -278,7 +361,7 @@ fn workspace_metadata_with_excluded() -> Result<()> { &workspace, )?; - uv_snapshot!(context.filters(), context.workspace_metadata().current_dir(&workspace), @r###" + uv_snapshot!(context.filters(), context.workspace_metadata().current_dir(&workspace), @r#" success: true exit_code: 0 ----- stdout ----- @@ -290,13 +373,19 @@ fn workspace_metadata_with_excluded() -> Result<()> { "members": [ { "name": "albatross", - "path": "[TEMP_DIR]/workspace" + "path": "[TEMP_DIR]/workspace", + "dependencies": [ + { + "name": "iniconfig", + "workspace": false + } + ] } ] } ----- stderr ----- - "### + "# ); Ok(()) @@ -317,3 +406,478 @@ fn workspace_metadata_no_project() { "### ); } + +/// Test metadata with regular workspace dependencies. +#[test] +fn workspace_metadata_with_regular_dependencies() { + let context = TestContext::new("3.12"); + + // Create workspace with multiple members + context.init().arg("workspace-root").assert().success(); + + let workspace_root = context.temp_dir.child("workspace-root"); + + // Create a library package + context + .init() + .arg("lib-a") + .current_dir(&workspace_root) + .assert() + .success(); + + // Create another package that depends on lib-a + context + .init() + .arg("app-b") + .current_dir(&workspace_root) + .assert() + .success(); + + // Add lib-a as a dependency to app-b + context + .add() + .arg("lib-a") + .current_dir(workspace_root.child("app-b")) + .assert() + .success(); + + uv_snapshot!(context.filters(), context.workspace_metadata().current_dir(&workspace_root), @r#" + success: true + exit_code: 0 + ----- stdout ----- + { + "schema": { + "version": "preview" + }, + "workspace_root": "[TEMP_DIR]/workspace-root", + "members": [ + { + "name": "app-b", + "path": "[TEMP_DIR]/workspace-root/app-b", + "dependencies": [ + { + "name": "lib-a", + "workspace": true + } + ] + }, + { + "name": "lib-a", + "path": "[TEMP_DIR]/workspace-root/lib-a", + "dependencies": [] + }, + { + "name": "workspace-root", + "path": "[TEMP_DIR]/workspace-root", + "dependencies": [] + } + ] + } + + ----- stderr ----- + "# + ); +} + +/// Test metadata with optional dependencies (extras) on workspace members. +#[test] +fn workspace_metadata_with_extras() { + let context = TestContext::new("3.12"); + + // Create workspace with multiple members + context.init().arg("workspace-root").assert().success(); + + let workspace_root = context.temp_dir.child("workspace-root"); + + // Create library packages + context + .init() + .arg("lib-a") + .current_dir(&workspace_root) + .assert() + .success(); + + context + .init() + .arg("lib-b") + .current_dir(&workspace_root) + .assert() + .success(); + + // Create app with optional dependencies on lib-a and lib-b + context + .init() + .arg("app") + .current_dir(&workspace_root) + .assert() + .success(); + + // Add optional dependencies + let app_dir = workspace_root.child("app"); + context + .add() + .arg("lib-a") + .arg("--optional") + .arg("extra-a") + .current_dir(&app_dir) + .assert() + .success(); + + context + .add() + .arg("lib-b") + .arg("--optional") + .arg("extra-b") + .current_dir(&app_dir) + .assert() + .success(); + + uv_snapshot!(context.filters(), context.workspace_metadata().current_dir(&workspace_root), @r#" + success: true + exit_code: 0 + ----- stdout ----- + { + "schema": { + "version": "preview" + }, + "workspace_root": "[TEMP_DIR]/workspace-root", + "members": [ + { + "name": "app", + "path": "[TEMP_DIR]/workspace-root/app", + "dependencies": [ + { + "name": "lib-a", + "workspace": true, + "extra": "extra-a" + }, + { + "name": "lib-b", + "workspace": true, + "extra": "extra-b" + } + ] + }, + { + "name": "lib-a", + "path": "[TEMP_DIR]/workspace-root/lib-a", + "dependencies": [] + }, + { + "name": "lib-b", + "path": "[TEMP_DIR]/workspace-root/lib-b", + "dependencies": [] + }, + { + "name": "workspace-root", + "path": "[TEMP_DIR]/workspace-root", + "dependencies": [] + } + ] + } + + ----- stderr ----- + "# + ); +} + +/// Test metadata with dependency groups containing workspace members. +#[test] +fn workspace_metadata_with_dependency_groups() { + let context = TestContext::new("3.12"); + + // Create workspace with multiple members + context.init().arg("workspace-root").assert().success(); + + let workspace_root = context.temp_dir.child("workspace-root"); + + // Create library packages + context + .init() + .arg("test-utils") + .current_dir(&workspace_root) + .assert() + .success(); + + context + .init() + .arg("dev-tools") + .current_dir(&workspace_root) + .assert() + .success(); + + // Create app with dependency groups + context + .init() + .arg("app") + .current_dir(&workspace_root) + .assert() + .success(); + + // Add dependency groups + let app_dir = workspace_root.child("app"); + context + .add() + .arg("test-utils") + .arg("--group") + .arg("test") + .current_dir(&app_dir) + .assert() + .success(); + + context + .add() + .arg("dev-tools") + .arg("--group") + .arg("dev") + .current_dir(&app_dir) + .assert() + .success(); + + uv_snapshot!(context.filters(), context.workspace_metadata().current_dir(&workspace_root), @r#" + success: true + exit_code: 0 + ----- stdout ----- + { + "schema": { + "version": "preview" + }, + "workspace_root": "[TEMP_DIR]/workspace-root", + "members": [ + { + "name": "app", + "path": "[TEMP_DIR]/workspace-root/app", + "dependencies": [ + { + "name": "dev-tools", + "workspace": true, + "group": "dev" + }, + { + "name": "test-utils", + "workspace": true, + "group": "test" + } + ] + }, + { + "name": "dev-tools", + "path": "[TEMP_DIR]/workspace-root/dev-tools", + "dependencies": [] + }, + { + "name": "test-utils", + "path": "[TEMP_DIR]/workspace-root/test-utils", + "dependencies": [] + }, + { + "name": "workspace-root", + "path": "[TEMP_DIR]/workspace-root", + "dependencies": [] + } + ] + } + + ----- stderr ----- + "# + ); +} + +/// Test metadata with workspace members that have no dependencies on each other. +#[test] +fn workspace_metadata_no_workspace_dependencies() { + let context = TestContext::new("3.12"); + + // Create workspace with multiple members that don't depend on each other + context.init().arg("workspace-root").assert().success(); + + let workspace_root = context.temp_dir.child("workspace-root"); + + // Create independent packages + context + .init() + .arg("package-a") + .current_dir(&workspace_root) + .assert() + .success(); + + context + .init() + .arg("package-b") + .current_dir(&workspace_root) + .assert() + .success(); + + context + .init() + .arg("package-c") + .current_dir(&workspace_root) + .assert() + .success(); + + uv_snapshot!(context.filters(), context.workspace_metadata().current_dir(&workspace_root), @r#" + success: true + exit_code: 0 + ----- stdout ----- + { + "schema": { + "version": "preview" + }, + "workspace_root": "[TEMP_DIR]/workspace-root", + "members": [ + { + "name": "package-a", + "path": "[TEMP_DIR]/workspace-root/package-a", + "dependencies": [] + }, + { + "name": "package-b", + "path": "[TEMP_DIR]/workspace-root/package-b", + "dependencies": [] + }, + { + "name": "package-c", + "path": "[TEMP_DIR]/workspace-root/package-c", + "dependencies": [] + }, + { + "name": "workspace-root", + "path": "[TEMP_DIR]/workspace-root", + "dependencies": [] + } + ] + } + + ----- stderr ----- + "# + ); +} + +/// Test metadata with mixed workspace dependencies (regular, extras, and groups). +#[test] +fn workspace_metadata_mixed_dependencies() { + let context = TestContext::new("3.12"); + + // Create workspace with multiple members + context.init().arg("workspace-root").assert().success(); + + let workspace_root = context.temp_dir.child("workspace-root"); + + // Create library packages + context + .init() + .arg("core") + .current_dir(&workspace_root) + .assert() + .success(); + + context + .init() + .arg("utils") + .current_dir(&workspace_root) + .assert() + .success(); + + context + .init() + .arg("testing") + .current_dir(&workspace_root) + .assert() + .success(); + + // Create app with all types of dependencies + context + .init() + .arg("app") + .current_dir(&workspace_root) + .assert() + .success(); + + // Add regular dependency, optional dependency, and dependency group + let app_dir = workspace_root.child("app"); + + // Add regular dependency + context + .add() + .arg("core") + .current_dir(&app_dir) + .assert() + .success(); + + // Add optional dependency + context + .add() + .arg("utils") + .arg("--optional") + .arg("utils") + .current_dir(&app_dir) + .assert() + .success(); + + // Add dependency group + context + .add() + .arg("testing") + .arg("--group") + .arg("test") + .current_dir(&app_dir) + .assert() + .success(); + + uv_snapshot!(context.filters(), context.workspace_metadata().current_dir(&workspace_root), @r#" + success: true + exit_code: 0 + ----- stdout ----- + { + "schema": { + "version": "preview" + }, + "workspace_root": "[TEMP_DIR]/workspace-root", + "members": [ + { + "name": "app", + "path": "[TEMP_DIR]/workspace-root/app", + "dependencies": [ + { + "name": "core", + "workspace": true + }, + { + "name": "utils", + "workspace": true, + "extra": "utils" + }, + { + "name": "testing", + "workspace": true, + "group": "test" + } + ] + }, + { + "name": "core", + "path": "[TEMP_DIR]/workspace-root/core", + "dependencies": [] + }, + { + "name": "testing", + "path": "[TEMP_DIR]/workspace-root/testing", + "dependencies": [] + }, + { + "name": "utils", + "path": "[TEMP_DIR]/workspace-root/utils", + "dependencies": [] + }, + { + "name": "workspace-root", + "path": "[TEMP_DIR]/workspace-root", + "dependencies": [] + } + ] + } + + ----- stderr ----- + "# + ); +}