workspace dir command (#16678)

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 <mrt@mikayla.codes>
Co-authored-by: Zanie Blue <contact@zanie.dev>
This commit is contained in:
Mikayla Thompson 2025-11-11 12:30:39 -07:00 committed by GitHub
parent 92c2bfcca0
commit b81060674e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 206 additions and 2 deletions

View file

@ -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<PackageName>,
}
/// 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)]

View file

@ -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;

View file

@ -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;

View file

@ -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<PackageName>,
project_dir: &Path,
preview: Preview,
printer: Printer,
) -> Result<ExitStatus> {
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)
}

View file

@ -1 +1,2 @@
pub(crate) mod dir;
pub(crate) mod metadata;

View file

@ -1738,6 +1738,9 @@ async fn run(mut cli: Cli) -> Result<ExitStatus> {
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 } => {

View file

@ -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();

View file

@ -141,4 +141,5 @@ mod workflow;
mod extract;
mod workspace;
mod workspace_dir;
mod workspace_metadata;

View file

@ -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,

View file

@ -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
"###
);
}