From b81060674ee592d2f61d78f61ae1839767b3f47a Mon Sep 17 00:00:00 2001 From: Mikayla Thompson Date: Tue, 11 Nov 2025 12:30:39 -0700 Subject: [PATCH] `workspace dir` command (#16678) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses https://github.com/astral-sh/uv/issues/13636 Prints the path to the workspace root by default, and any of the child packages if requested. I looped it into the same preview flag as `workspace metadata`, given how closely related they are. ## Summary ``` ─> uv workspace dir /Users/mikayla/code/uv/dev-envs ─> uv workspace dir --package foo-proj /Users/mikayla/code/uv/dev-envs/foo-proj ─> uv workspace dir --package bar-proj error: Package `bar-proj` not found in workspace. ``` ## Test Plan Unit tests added. --------- Signed-off-by: Mikayla Thompson Co-authored-by: Zanie Blue --- crates/uv-cli/src/lib.rs | 15 +++ crates/uv-preview/src/lib.rs | 3 + crates/uv/src/commands/mod.rs | 1 + crates/uv/src/commands/workspace/dir.rs | 48 +++++++++ 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_dir.rs | 123 ++++++++++++++++++++++++ docs/concepts/preview.md | 1 + 11 files changed, 206 insertions(+), 2 deletions(-) create mode 100644 crates/uv/src/commands/workspace/dir.rs create mode 100644 crates/uv/tests/it/workspace_dir.rs diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index d3694c530..576e1852f 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -6960,11 +6960,26 @@ pub enum WorkspaceCommand { /// Display package metadata. #[command(hide = true)] Metadata(MetadataArgs), + /// Display the path of a workspace member. + /// + /// By default, the path to the workspace root directory is displayed. + /// The `--package` option can be used to display the path to a workspace member instead. + /// + /// If used outside of a workspace, i.e., if a `pyproject.toml` cannot be found, uv will exit with an error. + #[command(hide = true)] + Dir(WorkspaceDirArgs), } #[derive(Args, Debug)] pub struct MetadataArgs; +#[derive(Args, Debug)] +pub struct WorkspaceDirArgs { + /// Display the path to a specific package in the workspace. + #[arg(long)] + pub package: Option, +} + /// 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 a975e4c95..dd849d040 100644 --- a/crates/uv-preview/src/lib.rs +++ b/crates/uv-preview/src/lib.rs @@ -23,6 +23,7 @@ bitflags::bitflags! { const CACHE_SIZE = 1 << 11; const INIT_PROJECT_FLAG = 1 << 12; const WORKSPACE_METADATA = 1 << 13; + const WORKSPACE_DIR = 1 << 14; } } @@ -46,6 +47,7 @@ impl PreviewFeatures { Self::CACHE_SIZE => "cache-size", Self::INIT_PROJECT_FLAG => "init-project-flag", Self::WORKSPACE_METADATA => "workspace-metadata", + Self::WORKSPACE_DIR => "workspace-dir", _ => panic!("`flag_as_str` can only be used for exactly one feature flag"), } } @@ -97,6 +99,7 @@ impl FromStr for PreviewFeatures { "cache-size" => Self::CACHE_SIZE, "init-project-flag" => Self::INIT_PROJECT_FLAG, "workspace-metadata" => Self::WORKSPACE_METADATA, + "workspace-dir" => Self::WORKSPACE_DIR, _ => { 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 d595c3dfd..9e7876b50 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::dir::dir; pub(crate) use workspace::metadata::metadata; use crate::printer::Printer; diff --git a/crates/uv/src/commands/workspace/dir.rs b/crates/uv/src/commands/workspace/dir.rs new file mode 100644 index 000000000..1a2fe581f --- /dev/null +++ b/crates/uv/src/commands/workspace/dir.rs @@ -0,0 +1,48 @@ +use std::fmt::Write; +use std::path::Path; + +use anyhow::{Result, bail}; + +use owo_colors::OwoColorize; +use uv_fs::Simplified; +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; + +/// Print the path to the workspace dir +pub(crate) async fn dir( + package_name: Option, + project_dir: &Path, + preview: Preview, + printer: Printer, +) -> Result { + if preview.is_enabled(PreviewFeatures::WORKSPACE_DIR) { + warn_user!( + "The `uv workspace dir` command is experimental and may change without warning. Pass `--preview-features {}` to disable this warning.", + PreviewFeatures::WORKSPACE_DIR + ); + } + + let workspace_cache = WorkspaceCache::default(); + let workspace = + Workspace::discover(project_dir, &DiscoveryOptions::default(), &workspace_cache).await?; + + let dir: &Path = match package_name { + None => workspace.install_path().as_path(), + Some(package) => { + if let Some(p) = workspace.packages().get(&package) { + p.root().as_path() + } else { + bail!("Package `{package}` not found in workspace.") + } + } + }; + + writeln!(printer.stdout(), "{}", dir.simplified_display().cyan())?; + + Ok(ExitStatus::Success) +} diff --git a/crates/uv/src/commands/workspace/mod.rs b/crates/uv/src/commands/workspace/mod.rs index edc8924b6..99de8246b 100644 --- a/crates/uv/src/commands/workspace/mod.rs +++ b/crates/uv/src/commands/workspace/mod.rs @@ -1 +1,2 @@ +pub(crate) mod dir; pub(crate) mod metadata; diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index 67ddec3d3..a13471e1b 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -1738,6 +1738,9 @@ async fn run(mut cli: Cli) -> Result { WorkspaceCommand::Metadata(_args) => { commands::metadata(&project_dir, globals.preview, printer).await } + WorkspaceCommand::Dir(args) => { + commands::dir(args.package, &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 497a6ec58..d812fab2f 100644 --- a/crates/uv/tests/it/common/mod.rs +++ b/crates/uv/tests/it/common/mod.rs @@ -1072,6 +1072,14 @@ impl TestContext { command } + /// Create a `uv workspace dir` command with options shared across scenarios. + pub fn workspace_dir(&self) -> Command { + let mut command = Self::new_command(); + command.arg("workspace").arg("dir"); + 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 e463f6b4d..20cb3820f 100644 --- a/crates/uv/tests/it/main.rs +++ b/crates/uv/tests/it/main.rs @@ -141,4 +141,5 @@ mod workflow; mod extract; mod workspace; +mod workspace_dir; mod workspace_metadata; diff --git a/crates/uv/tests/it/show_settings.rs b/crates/uv/tests/it/show_settings.rs index 9c866d255..890f693fe 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, + 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_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, + 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_preference: Managed, diff --git a/crates/uv/tests/it/workspace_dir.rs b/crates/uv/tests/it/workspace_dir.rs new file mode 100644 index 000000000..978dc8394 --- /dev/null +++ b/crates/uv/tests/it/workspace_dir.rs @@ -0,0 +1,123 @@ +use std::env; + +use anyhow::Result; +use assert_cmd::assert::OutputAssertExt; +use assert_fs::fixture::PathChild; + +use crate::common::{TestContext, copy_dir_ignore, uv_snapshot}; + +/// Test basic output for a simple workspace with one member. +#[test] +fn workspace_dir_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_dir().current_dir(&workspace), @r###" + success: true + exit_code: 0 + ----- stdout ----- + [TEMP_DIR]/foo + + ----- stderr ----- + "### + ); +} + +// Workspace dir output when run with `--package` +#[test] +fn workspace_dir_specific_package() { + let context = TestContext::new("3.12"); + context.init().arg("foo").assert().success(); + context.init().arg("foo/bar").assert().success(); + let workspace = context.temp_dir.child("foo"); + + // root workspace + uv_snapshot!(context.filters(), context.workspace_dir().current_dir(&workspace), @r###" + success: true + exit_code: 0 + ----- stdout ----- + [TEMP_DIR]/foo + + ----- stderr ----- + "### + ); + + // with --package bar + uv_snapshot!(context.filters(), context.workspace_dir().arg("--package").arg("bar").current_dir(&workspace), @r###" + success: true + exit_code: 0 + ----- stdout ----- + [TEMP_DIR]/foo/bar + + ----- stderr ----- + "### + ); +} + +/// Test output 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"); + + let albatross_workspace = context + .workspace_root + .join("scripts/workspaces/albatross-root-workspace"); + + copy_dir_ignore(albatross_workspace, &workspace)?; + + let member_dir = workspace.join("packages").join("bird-feeder"); + + uv_snapshot!(context.filters(), context.workspace_dir().current_dir(&member_dir), @r###" + success: true + exit_code: 0 + ----- stdout ----- + [TEMP_DIR]/workspace + + ----- stderr ----- + "### + ); + + Ok(()) +} + +/// Test workspace dir error output for a non-existent package. +#[test] +fn workspace_dir_package_doesnt_exist() { + 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_dir().arg("--package").arg("bar").current_dir(&workspace), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Package `bar` not found in workspace. + "### + ); +} + +/// Test workspace dir error output when not in a project. +#[test] +fn workspace_metadata_no_project() { + let context = TestContext::new("3.12"); + + uv_snapshot!(context.filters(), context.workspace_dir(), @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 1c875b56d..b1f222e5a 100644 --- a/docs/concepts/preview.md +++ b/docs/concepts/preview.md @@ -73,6 +73,7 @@ The following preview features are available: - `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`. +- `workspace-dir`: Allows using `uv workspace dir`. ## Disabling preview features