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 <PACKAGE>` 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.
This commit is contained in:
Zanie Blue 2025-11-11 09:46:01 -06:00 committed by GitHub
parent 5b517bb966
commit 3ccad58166
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 458 additions and 4 deletions

View File

@ -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)]

View File

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

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::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 {

View File

@ -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<WorkspaceMemberReport>,
}
/// Display metadata about the workspace.
pub(crate) async fn metadata(
project_dir: &Path,
preview: Preview,
printer: Printer,
) -> Result<ExitStatus> {
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)
}

View File

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

View File

@ -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<ExitStatus> {
)
.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)

View File

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

View File

@ -141,3 +141,4 @@ mod workflow;
mod extract;
mod workspace;
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,
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,

View File

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

View File

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