From 3ccad581667ccff928aa5913da3c3cbd8fe26fcc Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Tue, 11 Nov 2025 09:46:01 -0600 Subject: [PATCH] Add `uv workspace metadata` (#16516) This adds the scaffolding for a `uv workspace metadata` command, as an equivalent to `cargo metadata`, for integration with downstream tools. I didn't do much here beyond emit the workspace root path and the paths of the workspace members. I explored doing a bit more in #16638, but I think we're actually going to want to come up with a fairly comprehensive schema like `cargo metadata` has. I've started exploring that too, but I don't have a concrete proposal to share yet. I don't want this to be a top-level command because I think people would expect `uv metadata ` to show metadata about arbitrary packages (this has been requested several times). I also think we can do other things in the workspace namespace to make trivial integrations simpler, like `uv workspace list` (enumerate members) and `uv workspace dir` (show the path to the workspace root). I don't expect this to be stable at all to start. I've both gated it with preview and hidden it from the help. The intent is to merge so we can iterate on it as we figure out what integrations need. --- crates/uv-cli/src/lib.rs | 23 ++ crates/uv-preview/src/lib.rs | 4 +- crates/uv/src/commands/mod.rs | 2 + crates/uv/src/commands/workspace/metadata.rs | 91 ++++++ crates/uv/src/commands/workspace/mod.rs | 1 + crates/uv/src/lib.rs | 8 +- crates/uv/tests/it/common/mod.rs | 8 + crates/uv/tests/it/main.rs | 1 + crates/uv/tests/it/show_settings.rs | 4 +- crates/uv/tests/it/workspace_metadata.rs | 319 +++++++++++++++++++ docs/concepts/preview.md | 1 + 11 files changed, 458 insertions(+), 4 deletions(-) create mode 100644 crates/uv/src/commands/workspace/metadata.rs create mode 100644 crates/uv/src/commands/workspace/mod.rs create mode 100644 crates/uv/tests/it/workspace_metadata.rs diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index d1a9475f2..c93708f5c 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -517,6 +517,13 @@ pub enum Commands { Build(BuildArgs), /// Upload distributions to an index. Publish(PublishArgs), + /// Manage workspaces. + #[command( + after_help = "Use `uv help workspace` for more details.", + after_long_help = "", + hide = true + )] + Workspace(WorkspaceNamespace), /// The implementation of the build backend. /// /// These commands are not directly exposed to the user, instead users invoke their build @@ -6835,6 +6842,22 @@ pub struct PublishArgs { pub dry_run: bool, } +#[derive(Args)] +pub struct WorkspaceNamespace { + #[command(subcommand)] + pub command: WorkspaceCommand, +} + +#[derive(Subcommand)] +pub enum WorkspaceCommand { + /// Display package metadata. + #[command(hide = true)] + Metadata(MetadataArgs), +} + +#[derive(Args, Debug)] +pub struct MetadataArgs; + /// See [PEP 517](https://peps.python.org/pep-0517/) and /// [PEP 660](https://peps.python.org/pep-0660/) for specifications of the parameters. #[derive(Subcommand)] diff --git a/crates/uv-preview/src/lib.rs b/crates/uv-preview/src/lib.rs index 661808df7..a975e4c95 100644 --- a/crates/uv-preview/src/lib.rs +++ b/crates/uv-preview/src/lib.rs @@ -22,6 +22,7 @@ bitflags::bitflags! { const S3_ENDPOINT = 1 << 10; const CACHE_SIZE = 1 << 11; const INIT_PROJECT_FLAG = 1 << 12; + const WORKSPACE_METADATA = 1 << 13; } } @@ -44,6 +45,7 @@ impl PreviewFeatures { Self::S3_ENDPOINT => "s3-endpoint", Self::CACHE_SIZE => "cache-size", Self::INIT_PROJECT_FLAG => "init-project-flag", + Self::WORKSPACE_METADATA => "workspace-metadata", _ => panic!("`flag_as_str` can only be used for exactly one feature flag"), } } @@ -94,12 +96,12 @@ impl FromStr for PreviewFeatures { "s3-endpoint" => Self::S3_ENDPOINT, "cache-size" => Self::CACHE_SIZE, "init-project-flag" => Self::INIT_PROJECT_FLAG, + "workspace-metadata" => Self::WORKSPACE_METADATA, _ => { warn_user_once!("Unknown preview feature: `{part}`"); continue; } }; - flags |= flag; } diff --git a/crates/uv/src/commands/mod.rs b/crates/uv/src/commands/mod.rs index fc371530f..d595c3dfd 100644 --- a/crates/uv/src/commands/mod.rs +++ b/crates/uv/src/commands/mod.rs @@ -67,6 +67,7 @@ use uv_normalize::PackageName; use uv_python::PythonEnvironment; use uv_scripts::Pep723Script; pub(crate) use venv::venv; +pub(crate) use workspace::metadata::metadata; use crate::printer::Printer; @@ -88,6 +89,7 @@ pub(crate) mod reporters; mod self_update; mod tool; mod venv; +mod workspace; #[derive(Copy, Clone)] pub(crate) enum ExitStatus { diff --git a/crates/uv/src/commands/workspace/metadata.rs b/crates/uv/src/commands/workspace/metadata.rs new file mode 100644 index 000000000..6ff6b5f8f --- /dev/null +++ b/crates/uv/src/commands/workspace/metadata.rs @@ -0,0 +1,91 @@ +use std::fmt::Write; +use std::path::Path; + +use anyhow::Result; +use serde::Serialize; + +use uv_fs::PortablePathBuf; +use uv_normalize::PackageName; +use uv_preview::{Preview, PreviewFeatures}; +use uv_warnings::warn_user; +use uv_workspace::{DiscoveryOptions, Workspace, WorkspaceCache}; + +use crate::commands::ExitStatus; +use crate::printer::Printer; + +/// The schema version for the metadata report. +#[derive(Serialize, Debug, Default)] +#[serde(rename_all = "snake_case")] +enum SchemaVersion { + /// An unstable, experimental schema. + #[default] + Preview, +} + +/// The schema metadata for the metadata report. +#[derive(Serialize, Debug, Default)] +struct SchemaReport { + /// The version of the schema. + version: SchemaVersion, +} + +/// 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, +} + +/// The report for a metadata operation. +#[derive(Serialize, Debug)] +struct MetadataReport { + /// The schema of this report. + schema: SchemaReport, + /// The workspace root directory. + workspace_root: PortablePathBuf, + /// The workspace members. + members: Vec, +} + +/// Display metadata about the workspace. +pub(crate) async fn metadata( + project_dir: &Path, + preview: Preview, + printer: Printer, +) -> Result { + if preview.is_enabled(PreviewFeatures::WORKSPACE_METADATA) { + warn_user!( + "The `uv workspace metadata` command is experimental and may change without warning. Pass `--preview-features {}` to disable this warning.", + PreviewFeatures::WORKSPACE_METADATA + ); + } + + let workspace_cache = WorkspaceCache::default(); + let workspace = + Workspace::discover(project_dir, &DiscoveryOptions::default(), &workspace_cache).await?; + + let members = workspace + .packages() + .values() + .map(|package| WorkspaceMemberReport { + name: package.project().name.clone(), + path: PortablePathBuf::from(package.root().as_path()), + }) + .collect(); + + let report = MetadataReport { + schema: SchemaReport::default(), + workspace_root: PortablePathBuf::from(workspace.install_path().as_path()), + members, + }; + + writeln!( + printer.stdout(), + "{}", + serde_json::to_string_pretty(&report)? + )?; + + Ok(ExitStatus::Success) +} diff --git a/crates/uv/src/commands/workspace/mod.rs b/crates/uv/src/commands/workspace/mod.rs new file mode 100644 index 000000000..edc8924b6 --- /dev/null +++ b/crates/uv/src/commands/workspace/mod.rs @@ -0,0 +1 @@ +pub(crate) mod metadata; diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index a42a37e24..67ddec3d3 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -26,7 +26,8 @@ use uv_cli::SelfUpdateArgs; use uv_cli::{ AuthCommand, AuthNamespace, BuildBackendCommand, CacheCommand, CacheNamespace, Cli, Commands, PipCommand, PipNamespace, ProjectCommand, PythonCommand, PythonNamespace, SelfCommand, - SelfNamespace, ToolCommand, ToolNamespace, TopLevelArgs, compat::CompatArgs, + SelfNamespace, ToolCommand, ToolNamespace, TopLevelArgs, WorkspaceCommand, WorkspaceNamespace, + compat::CompatArgs, }; use uv_client::BaseClientBuilder; use uv_configuration::min_stack_size; @@ -1733,6 +1734,11 @@ async fn run(mut cli: Cli) -> Result { ) .await } + Commands::Workspace(WorkspaceNamespace { command }) => match command { + WorkspaceCommand::Metadata(_args) => { + commands::metadata(&project_dir, globals.preview, printer).await + } + }, Commands::BuildBackend { command } => spawn_blocking(move || match command { BuildBackendCommand::BuildSdist { sdist_directory } => { commands::build_backend::build_sdist(&sdist_directory) diff --git a/crates/uv/tests/it/common/mod.rs b/crates/uv/tests/it/common/mod.rs index e3145a7e9..497a6ec58 100644 --- a/crates/uv/tests/it/common/mod.rs +++ b/crates/uv/tests/it/common/mod.rs @@ -1064,6 +1064,14 @@ impl TestContext { command } + /// Create a `uv workspace metadata` command with options shared across scenarios. + pub fn workspace_metadata(&self) -> Command { + let mut command = Self::new_command(); + command.arg("workspace").arg("metadata"); + self.add_shared_options(&mut command, false); + command + } + /// Create a `uv export` command with options shared across scenarios. pub fn export(&self) -> Command { let mut command = Self::new_command(); diff --git a/crates/uv/tests/it/main.rs b/crates/uv/tests/it/main.rs index 38b82c1e0..e463f6b4d 100644 --- a/crates/uv/tests/it/main.rs +++ b/crates/uv/tests/it/main.rs @@ -141,3 +141,4 @@ mod workflow; mod extract; mod workspace; +mod workspace_metadata; diff --git a/crates/uv/tests/it/show_settings.rs b/crates/uv/tests/it/show_settings.rs index 40b32cabd..9c866d255 100644 --- a/crates/uv/tests/it/show_settings.rs +++ b/crates/uv/tests/it/show_settings.rs @@ -7831,7 +7831,7 @@ fn preview_features() { show_settings: true, preview: Preview { flags: PreviewFeatures( - PYTHON_INSTALL_DEFAULT | PYTHON_UPGRADE | JSON_OUTPUT | PYLOCK | ADD_BOUNDS | PACKAGE_CONFLICTS | EXTRA_BUILD_DEPENDENCIES | DETECT_MODULE_CONFLICTS | FORMAT | NATIVE_AUTH | S3_ENDPOINT | CACHE_SIZE | INIT_PROJECT_FLAG, + PYTHON_INSTALL_DEFAULT | PYTHON_UPGRADE | JSON_OUTPUT | PYLOCK | ADD_BOUNDS | PACKAGE_CONFLICTS | EXTRA_BUILD_DEPENDENCIES | DETECT_MODULE_CONFLICTS | FORMAT | NATIVE_AUTH | S3_ENDPOINT | CACHE_SIZE | INIT_PROJECT_FLAG | WORKSPACE_METADATA, ), }, python_preference: Managed, @@ -8059,7 +8059,7 @@ fn preview_features() { show_settings: true, preview: Preview { flags: PreviewFeatures( - PYTHON_INSTALL_DEFAULT | PYTHON_UPGRADE | JSON_OUTPUT | PYLOCK | ADD_BOUNDS | PACKAGE_CONFLICTS | EXTRA_BUILD_DEPENDENCIES | DETECT_MODULE_CONFLICTS | FORMAT | NATIVE_AUTH | S3_ENDPOINT | CACHE_SIZE | INIT_PROJECT_FLAG, + PYTHON_INSTALL_DEFAULT | PYTHON_UPGRADE | JSON_OUTPUT | PYLOCK | ADD_BOUNDS | PACKAGE_CONFLICTS | EXTRA_BUILD_DEPENDENCIES | DETECT_MODULE_CONFLICTS | FORMAT | NATIVE_AUTH | S3_ENDPOINT | CACHE_SIZE | INIT_PROJECT_FLAG | WORKSPACE_METADATA, ), }, python_preference: Managed, diff --git a/crates/uv/tests/it/workspace_metadata.rs b/crates/uv/tests/it/workspace_metadata.rs new file mode 100644 index 000000000..14ae7e3c0 --- /dev/null +++ b/crates/uv/tests/it/workspace_metadata.rs @@ -0,0 +1,319 @@ +use std::env; +use std::path::PathBuf; + +use anyhow::Result; +use assert_cmd::assert::OutputAssertExt; +use assert_fs::fixture::PathChild; + +use crate::common::{TestContext, copy_dir_ignore, uv_snapshot}; + +fn workspaces_dir() -> PathBuf { + env::current_dir() + .unwrap() + .parent() + .unwrap() + .parent() + .unwrap() + .join("scripts") + .join("workspaces") +} + +/// Test basic metadata output for a simple workspace with one member. +#[test] +fn workspace_metadata_simple() { + let context = TestContext::new("3.12"); + + // Initialize a workspace with one member + context.init().arg("foo").assert().success(); + + let workspace = context.temp_dir.child("foo"); + + uv_snapshot!(context.filters(), context.workspace_metadata().current_dir(&workspace), @r###" + success: true + exit_code: 0 + ----- stdout ----- + { + "schema": { + "version": "preview" + }, + "workspace_root": "[TEMP_DIR]/foo", + "members": [ + { + "name": "foo", + "path": "[TEMP_DIR]/foo" + } + ] + } + + ----- stderr ----- + "### + ); +} + +/// Test metadata for a root workspace (workspace with a root package). +#[test] +fn workspace_metadata_root_workspace() -> Result<()> { + let context = TestContext::new("3.12"); + let workspace = context.temp_dir.child("workspace"); + + copy_dir_ignore( + workspaces_dir().join("albatross-root-workspace"), + &workspace, + )?; + + uv_snapshot!(context.filters(), context.workspace_metadata().current_dir(&workspace), @r###" + success: true + exit_code: 0 + ----- stdout ----- + { + "schema": { + "version": "preview" + }, + "workspace_root": "[TEMP_DIR]/workspace", + "members": [ + { + "name": "albatross", + "path": "[TEMP_DIR]/workspace" + }, + { + "name": "bird-feeder", + "path": "[TEMP_DIR]/workspace/packages/bird-feeder" + }, + { + "name": "seeds", + "path": "[TEMP_DIR]/workspace/packages/seeds" + } + ] + } + + ----- stderr ----- + "### + ); + + Ok(()) +} + +/// Test metadata for a virtual workspace (no root package). +#[test] +fn workspace_metadata_virtual_workspace() -> Result<()> { + let context = TestContext::new("3.12"); + let workspace = context.temp_dir.child("workspace"); + + copy_dir_ignore( + workspaces_dir().join("albatross-virtual-workspace"), + &workspace, + )?; + + uv_snapshot!(context.filters(), context.workspace_metadata().current_dir(&workspace), @r###" + success: true + exit_code: 0 + ----- stdout ----- + { + "schema": { + "version": "preview" + }, + "workspace_root": "[TEMP_DIR]/workspace", + "members": [ + { + "name": "albatross", + "path": "[TEMP_DIR]/workspace/packages/albatross" + }, + { + "name": "bird-feeder", + "path": "[TEMP_DIR]/workspace/packages/bird-feeder" + }, + { + "name": "seeds", + "path": "[TEMP_DIR]/workspace/packages/seeds" + } + ] + } + + ----- stderr ----- + "### + ); + + Ok(()) +} + +/// Test metadata when run from a workspace member directory. +#[test] +fn workspace_metadata_from_member() -> Result<()> { + let context = TestContext::new("3.12"); + let workspace = context.temp_dir.child("workspace"); + + copy_dir_ignore( + workspaces_dir().join("albatross-root-workspace"), + &workspace, + )?; + + let member_dir = workspace.join("packages").join("bird-feeder"); + + uv_snapshot!(context.filters(), context.workspace_metadata().current_dir(&member_dir), @r###" + success: true + exit_code: 0 + ----- stdout ----- + { + "schema": { + "version": "preview" + }, + "workspace_root": "[TEMP_DIR]/workspace", + "members": [ + { + "name": "albatross", + "path": "[TEMP_DIR]/workspace" + }, + { + "name": "bird-feeder", + "path": "[TEMP_DIR]/workspace/packages/bird-feeder" + }, + { + "name": "seeds", + "path": "[TEMP_DIR]/workspace/packages/seeds" + } + ] + } + + ----- stderr ----- + "### + ); + + Ok(()) +} + +/// Test metadata for a workspace with multiple packages. +#[test] +fn workspace_metadata_multiple_members() { + let context = TestContext::new("3.12"); + + // Initialize workspace root + context.init().arg("pkg-a").assert().success(); + + let workspace_root = context.temp_dir.child("pkg-a"); + + // Add more members + context + .init() + .arg("pkg-b") + .current_dir(&workspace_root) + .assert() + .success(); + + context + .init() + .arg("pkg-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]/pkg-a", + "members": [ + { + "name": "pkg-a", + "path": "[TEMP_DIR]/pkg-a" + }, + { + "name": "pkg-b", + "path": "[TEMP_DIR]/pkg-a/pkg-b" + }, + { + "name": "pkg-c", + "path": "[TEMP_DIR]/pkg-a/pkg-c" + } + ] + } + + ----- stderr ----- + "### + ); +} + +/// Test metadata for a single project (not a workspace). +#[test] +fn workspace_metadata_single_project() { + let context = TestContext::new("3.12"); + + context.init().arg("my-project").assert().success(); + + let project = context.temp_dir.child("my-project"); + + uv_snapshot!(context.filters(), context.workspace_metadata().current_dir(&project), @r###" + success: true + exit_code: 0 + ----- stdout ----- + { + "schema": { + "version": "preview" + }, + "workspace_root": "[TEMP_DIR]/my-project", + "members": [ + { + "name": "my-project", + "path": "[TEMP_DIR]/my-project" + } + ] + } + + ----- stderr ----- + "### + ); +} + +/// Test metadata with excluded packages. +#[test] +fn workspace_metadata_with_excluded() -> Result<()> { + let context = TestContext::new("3.12"); + let workspace = context.temp_dir.child("workspace"); + + copy_dir_ignore( + workspaces_dir().join("albatross-project-in-excluded"), + &workspace, + )?; + + uv_snapshot!(context.filters(), context.workspace_metadata().current_dir(&workspace), @r###" + success: true + exit_code: 0 + ----- stdout ----- + { + "schema": { + "version": "preview" + }, + "workspace_root": "[TEMP_DIR]/workspace", + "members": [ + { + "name": "albatross", + "path": "[TEMP_DIR]/workspace" + } + ] + } + + ----- stderr ----- + "### + ); + + Ok(()) +} + +/// Test metadata error when not in a project. +#[test] +fn workspace_metadata_no_project() { + let context = TestContext::new("3.12"); + + uv_snapshot!(context.filters(), context.workspace_metadata(), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: No `pyproject.toml` found in current directory or any parent directory + "### + ); +} diff --git a/docs/concepts/preview.md b/docs/concepts/preview.md index effb389a8..1c875b56d 100644 --- a/docs/concepts/preview.md +++ b/docs/concepts/preview.md @@ -72,6 +72,7 @@ The following preview features are available: - `format`: Allows using `uv format`. - `native-auth`: Enables storage of credentials in a [system-native location](../concepts/authentication/http.md#the-uv-credentials-store). +- `workspace-metadata`: Allows using `uv workspace metadata`. ## Disabling preview features