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