Show workspace dependencies in `uv workspace metadata`

This commit is contained in:
Zanie Blue 2025-11-07 11:53:38 -05:00
parent 052b89d733
commit c0801e35ae
2 changed files with 687 additions and 33 deletions

View File

@ -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<ExtraName>,
/// The dependency group that requires this dependency, if any.
#[serde(skip_serializing_if = "Option::is_none")]
group: Option<GroupName>,
}
/// 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<DependencyReport>,
}
/// The report for a metadata operation.
@ -49,6 +69,68 @@ struct MetadataReport {
members: Vec<WorkspaceMemberReport>,
}
/// 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<PackageName>,
) -> Vec<DependencyReport> {
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::<VerbatimParsedUrl>::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::<VerbatimParsedUrl>::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::<VerbatimParsedUrl>::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<PackageName> = 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();

View File

@ -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",
"path": "[TEMP_DIR]/workspace/packages/seeds"
"workspace": true
}
]
},
{
"name": "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",
"path": "[TEMP_DIR]/workspace/packages/seeds"
"workspace": true
}
]
},
{
"name": "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",
"path": "[TEMP_DIR]/workspace/packages/seeds"
"workspace": true
}
]
},
{
"name": "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 -----
"#
);
}