From 07e03ee77652b52c8ac4f51199cd384e1e354942 Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Mon, 17 Nov 2025 09:35:50 -0600 Subject: [PATCH] Add `uv workspace list` to list workspace members (#16691) I'm a little wary here, in the sense that it might be silly to have a command that does something so simple that's covered by `uv workspace metadata`? but I think this could be stabilized much faster than `uv workspace metadata` and makes it easier to write scripts against workspace members. --------- Co-authored-by: liam --- crates/uv-cli/src/lib.rs | 8 + crates/uv-preview/src/lib.rs | 3 + crates/uv/src/commands/mod.rs | 1 + crates/uv/src/commands/workspace/list.rs | 36 ++++ crates/uv/src/commands/workspace/mod.rs | 1 + crates/uv/src/lib.rs | 3 + 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_list.rs | 220 +++++++++++++++++++++++ docs/concepts/preview.md | 1 + 11 files changed, 284 insertions(+), 2 deletions(-) create mode 100644 crates/uv/src/commands/workspace/list.rs create mode 100644 crates/uv/tests/it/workspace_list.rs diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index 488ac192b..fb1dc5870 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -7167,6 +7167,11 @@ pub enum WorkspaceCommand { /// /// If used outside of a workspace, i.e., if a `pyproject.toml` cannot be found, uv will exit with an error. Dir(WorkspaceDirArgs), + /// List the members of a workspace. + /// + /// Displays newline separated names of workspace members. + #[command(hide = true)] + List(WorkspaceListArgs), } #[derive(Args, Debug)] @@ -7179,6 +7184,9 @@ pub struct WorkspaceDirArgs { pub package: Option, } +#[derive(Args, Debug)] +pub struct WorkspaceListArgs; + /// 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 dd849d040..c617169a8 100644 --- a/crates/uv-preview/src/lib.rs +++ b/crates/uv-preview/src/lib.rs @@ -24,6 +24,7 @@ bitflags::bitflags! { const INIT_PROJECT_FLAG = 1 << 12; const WORKSPACE_METADATA = 1 << 13; const WORKSPACE_DIR = 1 << 14; + const WORKSPACE_LIST = 1 << 15; } } @@ -48,6 +49,7 @@ impl PreviewFeatures { Self::INIT_PROJECT_FLAG => "init-project-flag", Self::WORKSPACE_METADATA => "workspace-metadata", Self::WORKSPACE_DIR => "workspace-dir", + Self::WORKSPACE_LIST => "workspace-list", _ => panic!("`flag_as_str` can only be used for exactly one feature flag"), } } @@ -100,6 +102,7 @@ impl FromStr for PreviewFeatures { "init-project-flag" => Self::INIT_PROJECT_FLAG, "workspace-metadata" => Self::WORKSPACE_METADATA, "workspace-dir" => Self::WORKSPACE_DIR, + "workspace-list" => Self::WORKSPACE_LIST, _ => { warn_user_once!("Unknown preview feature: `{part}`"); continue; diff --git a/crates/uv/src/commands/mod.rs b/crates/uv/src/commands/mod.rs index b1b1fbbda..252f43faf 100644 --- a/crates/uv/src/commands/mod.rs +++ b/crates/uv/src/commands/mod.rs @@ -69,6 +69,7 @@ use uv_python::PythonEnvironment; use uv_scripts::Pep723Script; pub(crate) use venv::venv; pub(crate) use workspace::dir::dir; +pub(crate) use workspace::list::list; pub(crate) use workspace::metadata::metadata; use crate::printer::Printer; diff --git a/crates/uv/src/commands/workspace/list.rs b/crates/uv/src/commands/workspace/list.rs new file mode 100644 index 000000000..4689d6f38 --- /dev/null +++ b/crates/uv/src/commands/workspace/list.rs @@ -0,0 +1,36 @@ +use std::fmt::Write; +use std::path::Path; + +use anyhow::Result; + +use owo_colors::OwoColorize; +use uv_preview::{Preview, PreviewFeatures}; +use uv_warnings::warn_user; +use uv_workspace::{DiscoveryOptions, Workspace, WorkspaceCache}; + +use crate::commands::ExitStatus; +use crate::printer::Printer; + +/// List workspace members +pub(crate) async fn list( + project_dir: &Path, + preview: Preview, + printer: Printer, +) -> Result { + if !preview.is_enabled(PreviewFeatures::WORKSPACE_LIST) { + warn_user!( + "The `uv workspace list` command is experimental and may change without warning. Pass `--preview-features {}` to disable this warning.", + PreviewFeatures::WORKSPACE_LIST + ); + } + + let workspace_cache = WorkspaceCache::default(); + let workspace = + Workspace::discover(project_dir, &DiscoveryOptions::default(), &workspace_cache).await?; + + for name in workspace.packages().keys() { + writeln!(printer.stdout(), "{}", name.cyan())?; + } + + Ok(ExitStatus::Success) +} diff --git a/crates/uv/src/commands/workspace/mod.rs b/crates/uv/src/commands/workspace/mod.rs index 99de8246b..6ee08d3e2 100644 --- a/crates/uv/src/commands/workspace/mod.rs +++ b/crates/uv/src/commands/workspace/mod.rs @@ -1,2 +1,3 @@ pub(crate) mod dir; +pub(crate) mod list; pub(crate) mod metadata; diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index b1a1a2711..84f6411a7 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -1746,6 +1746,9 @@ async fn run(mut cli: Cli) -> Result { WorkspaceCommand::Dir(args) => { commands::dir(args.package, &project_dir, globals.preview, printer).await } + WorkspaceCommand::List(_args) => { + commands::list(&project_dir, globals.preview, printer).await + } }, Commands::BuildBackend { command } => spawn_blocking(move || match command { BuildBackendCommand::BuildSdist { sdist_directory } => { diff --git a/crates/uv/tests/it/common/mod.rs b/crates/uv/tests/it/common/mod.rs index 40a131e10..a7e975ffc 100644 --- a/crates/uv/tests/it/common/mod.rs +++ b/crates/uv/tests/it/common/mod.rs @@ -1093,6 +1093,14 @@ impl TestContext { command } + /// Create a `uv workspace list` command with options shared across scenarios. + pub fn workspace_list(&self) -> Command { + let mut command = Self::new_command(); + command.arg("workspace").arg("list"); + 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 20cb3820f..489a45228 100644 --- a/crates/uv/tests/it/main.rs +++ b/crates/uv/tests/it/main.rs @@ -142,4 +142,5 @@ mod workflow; mod extract; mod workspace; mod workspace_dir; +mod workspace_list; mod workspace_metadata; diff --git a/crates/uv/tests/it/show_settings.rs b/crates/uv/tests/it/show_settings.rs index 890f693fe..f57510b33 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 | WORKSPACE_METADATA | WORKSPACE_DIR, + 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 | WORKSPACE_DIR | WORKSPACE_LIST, ), }, 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 | WORKSPACE_METADATA | WORKSPACE_DIR, + 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 | WORKSPACE_DIR | WORKSPACE_LIST, ), }, python_preference: Managed, diff --git a/crates/uv/tests/it/workspace_list.rs b/crates/uv/tests/it/workspace_list.rs new file mode 100644 index 000000000..1bccac994 --- /dev/null +++ b/crates/uv/tests/it/workspace_list.rs @@ -0,0 +1,220 @@ +use anyhow::Result; +use assert_cmd::assert::OutputAssertExt; +use assert_fs::fixture::PathChild; + +use crate::common::{TestContext, copy_dir_ignore, uv_snapshot}; + +/// Test basic list output for a simple workspace with one member. +#[test] +fn workspace_list_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_list().current_dir(&workspace), @r" + success: true + exit_code: 0 + ----- stdout ----- + foo + + ----- stderr ----- + warning: The `uv workspace list` command is experimental and may change without warning. Pass `--preview-features workspace-list` to disable this warning. + " + ); +} + +/// Test list output for a root workspace (workspace with a root package). +#[test] +fn workspace_list_root_workspace() -> Result<()> { + let context = TestContext::new("3.12"); + let workspace = context.temp_dir.child("workspace"); + + copy_dir_ignore( + context + .workspace_root + .join("scripts/workspaces/albatross-root-workspace"), + &workspace, + )?; + + uv_snapshot!(context.filters(), context.workspace_list().current_dir(&workspace), @r" + success: true + exit_code: 0 + ----- stdout ----- + albatross + bird-feeder + seeds + + ----- stderr ----- + warning: The `uv workspace list` command is experimental and may change without warning. Pass `--preview-features workspace-list` to disable this warning. + " + ); + + Ok(()) +} + +/// Test list output for a virtual workspace (no root package). +#[test] +fn workspace_list_virtual_workspace() -> Result<()> { + let context = TestContext::new("3.12"); + let workspace = context.temp_dir.child("workspace"); + + copy_dir_ignore( + context + .workspace_root + .join("scripts/workspaces/albatross-virtual-workspace"), + &workspace, + )?; + + uv_snapshot!(context.filters(), context.workspace_list().current_dir(&workspace), @r" + success: true + exit_code: 0 + ----- stdout ----- + albatross + bird-feeder + seeds + + ----- stderr ----- + warning: The `uv workspace list` command is experimental and may change without warning. Pass `--preview-features workspace-list` to disable this warning. + " + ); + + Ok(()) +} + +/// Test list output when run from a workspace member directory. +#[test] +fn workspace_list_from_member() -> Result<()> { + let context = TestContext::new("3.12"); + let workspace = context.temp_dir.child("workspace"); + + copy_dir_ignore( + context + .workspace_root + .join("scripts/workspaces/albatross-root-workspace"), + &workspace, + )?; + + let member_dir = workspace.join("packages").join("bird-feeder"); + + uv_snapshot!(context.filters(), context.workspace_list().current_dir(&member_dir), @r" + success: true + exit_code: 0 + ----- stdout ----- + albatross + bird-feeder + seeds + + ----- stderr ----- + warning: The `uv workspace list` command is experimental and may change without warning. Pass `--preview-features workspace-list` to disable this warning. + " + ); + + Ok(()) +} + +/// Test list output for a workspace with multiple packages. +#[test] +fn workspace_list_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_list().current_dir(&workspace_root), @r" + success: true + exit_code: 0 + ----- stdout ----- + pkg-a + pkg-b + pkg-c + + ----- stderr ----- + warning: The `uv workspace list` command is experimental and may change without warning. Pass `--preview-features workspace-list` to disable this warning. + " + ); +} + +/// Test list output for a single project (not a workspace). +#[test] +fn workspace_list_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_list().current_dir(&project), @r" + success: true + exit_code: 0 + ----- stdout ----- + my-project + + ----- stderr ----- + warning: The `uv workspace list` command is experimental and may change without warning. Pass `--preview-features workspace-list` to disable this warning. + " + ); +} + +/// Test list output with excluded packages. +#[test] +fn workspace_list_with_excluded() -> Result<()> { + let context = TestContext::new("3.12"); + let workspace = context.temp_dir.child("workspace"); + + copy_dir_ignore( + context + .workspace_root + .join("scripts/workspaces/albatross-project-in-excluded"), + &workspace, + )?; + + uv_snapshot!(context.filters(), context.workspace_list().current_dir(&workspace), @r" + success: true + exit_code: 0 + ----- stdout ----- + albatross + + ----- stderr ----- + warning: The `uv workspace list` command is experimental and may change without warning. Pass `--preview-features workspace-list` to disable this warning. + " + ); + + Ok(()) +} + +/// Test list error output when not in a project. +#[test] +fn workspace_list_no_project() { + let context = TestContext::new("3.12"); + + uv_snapshot!(context.filters(), context.workspace_list(), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + warning: The `uv workspace list` command is experimental and may change without warning. Pass `--preview-features workspace-list` to disable this warning. + 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 b1f222e5a..1e411670f 100644 --- a/docs/concepts/preview.md +++ b/docs/concepts/preview.md @@ -74,6 +74,7 @@ The following preview features are available: [system-native location](../concepts/authentication/http.md#the-uv-credentials-store). - `workspace-metadata`: Allows using `uv workspace metadata`. - `workspace-dir`: Allows using `uv workspace dir`. +- `workspace-list`: Allows using `uv workspace list`. ## Disabling preview features