From f98013ef81927e5e5b3a73939366c03a75d9509a Mon Sep 17 00:00:00 2001 From: Aria Desires Date: Thu, 18 Jun 2026 15:53:24 -0400 Subject: [PATCH] Add workspace-exclusive dependency groups to `workspace metadata` (#19862) In a similar way that #19860 needs to add a top-level script node to represent dependencies of the script, it's been an outstanding issue that we lack a top-level workspace node to represent dependency-groups that are exclusive to the workspace. --- .../uv-resolver/src/lock/export/metadata.rs | 109 +++++++++++++++- .../uv/tests/workspace/workspace_metadata.rs | 117 +++++++++++++++++- docs/reference/internals/metadata.md | 70 +++++++++-- 3 files changed, 277 insertions(+), 19 deletions(-) diff --git a/crates/uv-resolver/src/lock/export/metadata.rs b/crates/uv-resolver/src/lock/export/metadata.rs index e2a5f23286..a02ae43790 100644 --- a/crates/uv-resolver/src/lock/export/metadata.rs +++ b/crates/uv-resolver/src/lock/export/metadata.rs @@ -3,7 +3,7 @@ use std::fmt::Display; use std::path::Path; use uv_distribution_filename::WheelFilename; -use uv_distribution_types::{Name, RequiresPython, ResolvedDist, UrlString}; +use uv_distribution_types::{Name, Requirement, RequiresPython, ResolvedDist, UrlString}; use uv_fs::PortablePathBuf; use uv_normalize::{ExtraName, GroupName, PackageName}; use uv_pep440::Version; @@ -69,6 +69,9 @@ pub struct Metadata { /// Information about the script root, when metadata was requested for a script. #[serde(skip_serializing_if = "Option::is_none", default)] script: Option, + /// Information about the workspace root, when metadata was requested for a workspace. + #[serde(skip_serializing_if = "Option::is_none", default)] + workspace: Option, /// The version of python required by the workspace /// /// Every `marker` we emit implicitly assumes this constraint to keep things clean @@ -120,6 +123,15 @@ struct MetadataScript { id: MetadataNodeIdFlat, } +/// The workspace entry-point. +#[derive(Debug, serde::Serialize)] +struct MetadataWorkspace { + /// Absolute path to the workspace root. + path: PortablePathBuf, + /// Key for the workspace's node in the `resolution` graph. + id: MetadataNodeIdFlat, +} + /// Info for looking up workspace members, most information is stored in the node behind `id` #[derive(Debug, serde::Serialize)] struct MetadataWorkspaceMember { @@ -238,6 +250,30 @@ impl MetadataNode { node } + fn from_workspace(path: PortablePathBuf, dependency_groups: Vec) -> Self { + let mut node = Self::new(MetadataNodeId::Workspace(MetadataWorkspaceNodeId { + kind: MetadataWorkspaceNodeKind::Workspace, + path, + })); + node.dependency_groups = dependency_groups; + node + } + + fn from_workspace_group( + path: PortablePathBuf, + group: GroupName, + dependencies: Vec, + ) -> Self { + let mut node = Self::new(MetadataNodeId::WorkspaceGroup( + MetadataWorkspaceGroupNodeId { + kind: MetadataWorkspaceGroupNodeKind::Group(group), + path, + }, + )); + node.dependencies = dependencies; + node + } + fn add_dependency(&mut self, workspace_root: &PortablePathBuf, dependency: &Dependency) { let extras = dependency.extra(); if extras.is_empty() { @@ -266,21 +302,33 @@ impl MetadataNode { } } +#[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "snake_case")] +enum MetadataWorkspaceNodeKind { + Workspace, +} + +#[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "snake_case")] +enum MetadataWorkspaceGroupNodeKind { + Group(GroupName), +} #[derive(Debug, Clone, serde::Serialize)] #[serde(rename_all = "snake_case")] enum MetadataScriptNodeKind { Script, } -fn script_root_dependencies( +fn root_dependencies<'lock>( workspace_root: &PortablePathBuf, - lock: &Lock, + lock: &'lock Lock, + requirements: impl IntoIterator, ) -> Vec { let mut dependencies = Vec::new(); // Root requirements retain names, extras, and markers rather than resolved package IDs. Match // them to the locked packages using the same name and fork-marker logic as lock export. - for requirement in lock.requirements() { + for requirement in requirements { for package in lock .packages() .iter() @@ -332,6 +380,8 @@ fn script_root_dependencies( enum MetadataNodeId { Package(MetadataPackageNodeId), Script(MetadataScriptNodeId), + Workspace(MetadataWorkspaceNodeId), + WorkspaceGroup(MetadataWorkspaceGroupNodeId), } /// The unique key for a package-derived node. @@ -358,6 +408,22 @@ struct MetadataScriptNodeId { path: PortablePathBuf, } +/// The unique key for a workspace node. +#[derive(Debug, Clone, serde::Serialize)] +struct MetadataWorkspaceNodeId { + kind: MetadataWorkspaceNodeKind, + /// Absolute path to the workspace root. + path: PortablePathBuf, +} + +/// The unique key for a dependency group defined on the workspace root. +#[derive(Debug, Clone, serde::Serialize)] +struct MetadataWorkspaceGroupNodeId { + kind: MetadataWorkspaceGroupNodeKind, + /// Absolute path to the workspace root. + path: PortablePathBuf, +} + /// This is intended to be an opaque unique id for referring to a node /// /// It's human readable for convenience but parsing it or relying on it is inadvisable. @@ -384,7 +450,7 @@ impl MetadataNodeId { fn as_package(&self) -> Option<&MetadataPackageNodeId> { match self { Self::Package(package) => Some(package), - Self::Script(_) => None, + Self::Script(_) | Self::Workspace(_) | Self::WorkspaceGroup(_) => None, } } @@ -405,6 +471,11 @@ impl Display for MetadataNodeId { None => write!(f, "{}{}@{}", package.name, package.kind, package.source), }, Self::Script(script) => write!(f, "script+{}", script.path), + Self::Workspace(workspace) => write!(f, "workspace+{}", workspace.path), + Self::WorkspaceGroup(group) => { + let MetadataWorkspaceGroupNodeKind::Group(name) = &group.kind; + write!(f, "workspace+{}:{name}", group.path) + } } } } @@ -975,12 +1046,37 @@ impl Metadata { let path = PortablePathBuf::from(path); let node = MetadataNode::from_script( path.clone(), - script_root_dependencies(&workspace_root, lock), + root_dependencies(&workspace_root, lock, lock.requirements()), ); let id = node.id.to_flat(); resolve.insert(id.clone(), node); MetadataScript { path, id } }); + + let workspace_metadata = workspace.map(|_| { + let mut dependency_groups = Vec::new(); + for (group, requirements) in lock.dependency_groups() { + let node = MetadataNode::from_workspace_group( + workspace_root.clone(), + group.clone(), + root_dependencies(&workspace_root, lock, requirements), + ); + let id = node.id.to_flat(); + resolve.insert(id.clone(), node); + dependency_groups.push(MetadataGroup { + name: group.clone(), + id, + }); + } + + let node = MetadataNode::from_workspace(workspace_root.clone(), dependency_groups); + let id = node.id.to_flat(); + resolve.insert(id.clone(), node); + MetadataWorkspace { + path: workspace_root.clone(), + id, + } + }); let conflicts = MetadataConflicts::from_conflicts(&members, &resolve, &lock.conflicts); Self { @@ -990,6 +1086,7 @@ impl Metadata { conflicts, environment: None, script, + workspace: workspace_metadata, module_owners: BTreeMap::new(), workspace_root, requires_python: lock.requires_python.clone(), diff --git a/crates/uv/tests/workspace/workspace_metadata.rs b/crates/uv/tests/workspace/workspace_metadata.rs index 8124fd3075..2ba54aaf3d 100644 --- a/crates/uv/tests/workspace/workspace_metadata.rs +++ b/crates/uv/tests/workspace/workspace_metadata.rs @@ -86,6 +86,10 @@ fn workspace_metadata_simple() { "version": "preview" }, "workspace_root": "[TEMP_DIR]/foo", + "workspace": { + "path": "[TEMP_DIR]/foo", + "id": "workspace+[TEMP_DIR]/foo" + }, "requires_python": ">=3.12", "conflicts": { "sets": [] @@ -106,6 +110,11 @@ fn workspace_metadata_simple() { }, "kind": "package", "dependencies": [] + }, + "workspace+[TEMP_DIR]/foo": { + "kind": "workspace", + "path": "[TEMP_DIR]/foo", + "dependencies": [] } } } @@ -478,6 +487,10 @@ dependencies = [ "environment": { "root": "[VENV]/" }, + "workspace": { + "path": "[TEMP_DIR]/", + "id": "workspace+[TEMP_DIR]/" + }, "requires_python": ">=3.12", "conflicts": { "sets": [] @@ -589,6 +602,11 @@ dependencies = [ "filename": "typing_extensions-0.1.0-py3-none-any.whl" } ] + }, + "workspace+[TEMP_DIR]/": { + "kind": "workspace", + "path": "[TEMP_DIR]/", + "dependencies": [] } } } @@ -781,6 +799,10 @@ fn workspace_metadata_root_workspace() -> Result<()> { "version": "preview" }, "workspace_root": "[TEMP_DIR]/workspace", + "workspace": { + "path": "[TEMP_DIR]/workspace", + "id": "workspace+[TEMP_DIR]/workspace" + }, "requires_python": ">=3.12", "conflicts": { "sets": [] @@ -907,6 +929,11 @@ fn workspace_metadata_root_workspace() -> Result<()> { "id": "idna==3.6@registry+https://pypi.org/simple" } ] + }, + "workspace+[TEMP_DIR]/workspace": { + "kind": "workspace", + "path": "[TEMP_DIR]/workspace", + "dependencies": [] } } } @@ -935,7 +962,13 @@ fn workspace_metadata_virtual_workspace() -> Result<()> { &workspace, )?; - uv_snapshot!(context.filters(), context.workspace_metadata().current_dir(&workspace), @r#" + let mut filters = context.filters(); + filters.push(( + r"(?m)^WARN Ignoring non-directory workspace member: `[^\n]+`\n", + "", + )); + + uv_snapshot!(filters, context.workspace_metadata().current_dir(&workspace), @r#" success: true exit_code: 0 ----- stdout ----- @@ -944,6 +977,10 @@ fn workspace_metadata_virtual_workspace() -> Result<()> { "version": "preview" }, "workspace_root": "[TEMP_DIR]/workspace", + "workspace": { + "path": "[TEMP_DIR]/workspace", + "id": "workspace+[TEMP_DIR]/workspace" + }, "requires_python": ">=3.12", "conflicts": { "sets": [] @@ -1137,6 +1174,11 @@ fn workspace_metadata_virtual_workspace() -> Result<()> { "filename": "sniffio-1.3.1-py3-none-any.whl" } ] + }, + "workspace+[TEMP_DIR]/workspace": { + "kind": "workspace", + "path": "[TEMP_DIR]/workspace", + "dependencies": [] } } } @@ -1176,6 +1218,10 @@ fn workspace_metadata_from_member() -> Result<()> { "version": "preview" }, "workspace_root": "[TEMP_DIR]/workspace", + "workspace": { + "path": "[TEMP_DIR]/workspace", + "id": "workspace+[TEMP_DIR]/workspace" + }, "requires_python": ">=3.12", "conflicts": { "sets": [] @@ -1302,6 +1348,11 @@ fn workspace_metadata_from_member() -> Result<()> { "id": "idna==3.6@registry+https://pypi.org/simple" } ] + }, + "workspace+[TEMP_DIR]/workspace": { + "kind": "workspace", + "path": "[TEMP_DIR]/workspace", + "dependencies": [] } } } @@ -1350,6 +1401,10 @@ fn workspace_metadata_multiple_members() { "version": "preview" }, "workspace_root": "[TEMP_DIR]/pkg-a", + "workspace": { + "path": "[TEMP_DIR]/pkg-a", + "id": "workspace+[TEMP_DIR]/pkg-a" + }, "requires_python": ">=3.12", "conflicts": { "sets": [] @@ -1398,6 +1453,11 @@ fn workspace_metadata_multiple_members() { }, "kind": "package", "dependencies": [] + }, + "workspace+[TEMP_DIR]/pkg-a": { + "kind": "workspace", + "path": "[TEMP_DIR]/pkg-a", + "dependencies": [] } } } @@ -1428,6 +1488,10 @@ fn workspace_metadata_single_project() { "version": "preview" }, "workspace_root": "[TEMP_DIR]/my-project", + "workspace": { + "path": "[TEMP_DIR]/my-project", + "id": "workspace+[TEMP_DIR]/my-project" + }, "requires_python": ">=3.12", "conflicts": { "sets": [] @@ -1448,6 +1512,11 @@ fn workspace_metadata_single_project() { }, "kind": "package", "dependencies": [] + }, + "workspace+[TEMP_DIR]/my-project": { + "kind": "workspace", + "path": "[TEMP_DIR]/my-project", + "dependencies": [] } } } @@ -1483,6 +1552,10 @@ fn workspace_metadata_with_excluded() -> Result<()> { "version": "preview" }, "workspace_root": "[TEMP_DIR]/workspace", + "workspace": { + "path": "[TEMP_DIR]/workspace", + "id": "workspace+[TEMP_DIR]/workspace" + }, "requires_python": ">=3.12", "conflicts": { "sets": [] @@ -1537,6 +1610,11 @@ fn workspace_metadata_with_excluded() -> Result<()> { "filename": "iniconfig-2.0.0-py3-none-any.whl" } ] + }, + "workspace+[TEMP_DIR]/workspace": { + "kind": "workspace", + "path": "[TEMP_DIR]/workspace", + "dependencies": [] } } } @@ -1551,7 +1629,7 @@ fn workspace_metadata_with_excluded() -> Result<()> { Ok(()) } -/// Test metadata with excluded packages. +/// Test metadata for dependency groups defined on a non-package workspace root. #[test] #[cfg(feature = "test-pypi")] fn workspace_metadata_group_only() -> Result<()> { @@ -1574,6 +1652,10 @@ fn workspace_metadata_group_only() -> Result<()> { "version": "preview" }, "workspace_root": "[TEMP_DIR]/workspace", + "workspace": { + "path": "[TEMP_DIR]/workspace", + "id": "workspace+[TEMP_DIR]/workspace" + }, "requires_python": ">=3.12", "conflicts": { "sets": [] @@ -1608,6 +1690,28 @@ fn workspace_metadata_group_only() -> Result<()> { "filename": "iniconfig-2.0.0-py3-none-any.whl" } ] + }, + "workspace+[TEMP_DIR]/workspace": { + "kind": "workspace", + "path": "[TEMP_DIR]/workspace", + "dependencies": [], + "dependency_groups": [ + { + "name": "dev", + "id": "workspace+[TEMP_DIR]/workspace:dev" + } + ] + }, + "workspace+[TEMP_DIR]/workspace:dev": { + "kind": { + "group": "dev" + }, + "path": "[TEMP_DIR]/workspace", + "dependencies": [ + { + "id": "iniconfig==2.0.0@registry+https://pypi.org/simple" + } + ] } } } @@ -1663,6 +1767,10 @@ fn workspace_metadata_various_dependency_rainbow() -> Result<()> { "version": "preview" }, "workspace_root": "[TEMP_DIR]/workspace", + "workspace": { + "path": "[TEMP_DIR]/workspace", + "id": "workspace+[TEMP_DIR]/workspace" + }, "requires_python": ">=3.12", "conflicts": { "sets": [] @@ -1859,6 +1967,11 @@ fn workspace_metadata_various_dependency_rainbow() -> Result<()> { "filename": "sniffio-1.3.1-py3-none-any.whl" } ] + }, + "workspace+[TEMP_DIR]/workspace": { + "kind": "workspace", + "path": "[TEMP_DIR]/workspace", + "dependencies": [] } } } diff --git a/docs/reference/internals/metadata.md b/docs/reference/internals/metadata.md index 07ebe3ade4..44dd7128f4 100644 --- a/docs/reference/internals/metadata.md +++ b/docs/reference/internals/metadata.md @@ -16,14 +16,17 @@ for the node it refers to, and an optional `marker` that (if there is no marker the dependency is always required). Package-derived nodes in the graph are uniquely identified by package `name`, `version`, `source`, -and `kind`. Script nodes are identified by their path. All node ids should be treated as opaque. +and `kind`. Script and workspace nodes are identified by their path. Workspace-root dependency group +nodes are identified by their group name and the workspace path. All node ids should be treated as +opaque. -There are 4 kinds of node in the graph: +There are 5 kinds of node in the graph: - `"script"` -- a PEP 723 script and its direct dependencies +- `"workspace"` -- a workspace root and its workspace-exclusive dependency groups - `"package"` -- the package itself - `{ "extra": "extraname" }` -- an extra the package defines -- `{ "group": "groupname" }` -- a dependency group the package defines +- `{ "group": "groupname" }` -- a dependency group a package or workspace root defines (In the future we will add "build" nodes for the dependencies of [build environments](https://docs.astral.sh/uv/concepts/projects/config/#build-isolation).) @@ -40,6 +43,9 @@ If you want to install the dependency group `mypackage:mygroup` then find the no `"kind": { "group": "mygroup" }` for `mypackage` (this node will _not_ depend on `mypackage`, as dependency groups are just lists of things you might want when working on the package itself). +If the workspace root defines dependency groups but is not itself a package, its `"workspace"` node +provides the corresponding group node ids through `dependency_groups`. + ## Handling multiple versions of a package Two versions of a package cannot be installed into a python environment, but the dependency graph @@ -66,15 +72,16 @@ the graph and get actual resolutions you will likely need to consult `conflicts` understand how to resolve `markers` for a specific platform. The best way to avoid mistakes when working with multiple versions of a package is to keep your -queries into the dependency graph rooted in operations on workspace members or the requested script. -These are the natural entry-points to the graph and can give coherent responses for operations such -as "install `member1` and `member2[extra]`" or "install this script's declared dependencies". +queries into the dependency graph rooted in operations on the workspace root, workspace members, or +the requested script. These are the natural entry-points to the graph and can give coherent +responses for operations such as "install the workspace `dev` group", "install `member1` and +`member2[extra]`", or "install this script's declared dependencies". Another way to put this is that when possible _you should avoid iterating over the `resolution` object to find a node_. Only access `resolution` like a map using ids that were provided by another -part of the metadata. For a workspace, the initial ids are listed in the `members` array. For a -script, the initial id is `script.id`. From there you may recursively discover other packages by -following dependency edges. +part of the metadata. For a workspace, the workspace root is `workspace.id` and package entry points +are listed in the `members` array. For a script, the initial id is `script.id`. From there you may +recursively discover other packages by following dependency edges. So rather than trying to find a node for anyio in the dependency graph directly, you should decide what workspace member(s) you're interested in analyzing as if they were going to be installed. While @@ -93,6 +100,15 @@ group_node = metadata.resolution[group.id] visit(metadata, [group_node]) ``` +For a dependency group defined on the workspace root, look it up through the workspace node: + +```python +workspace_node = metadata.resolution[metadata.workspace.id] +group = find_by_name(workspace_node.dependency_groups, "dev") +group_node = metadata.resolution[group.id] +visit(metadata, [group_node]) +``` + If you wanted to analyze two particular workspace members installed together, it would look something like: @@ -158,13 +174,20 @@ Here is a human-readable annotated example: "root": "/workspace/.venv" }, // Information about the script target, only present with `--script`. - // Workspace metadata uses `members` below as its graph entry-points instead. + // Workspace metadata uses `workspace` and `members` below as graph entry-points instead. "script": { // The absolute path to the script "path": "/workspace/script.py", // The id of the script's node in the `resolution` map below "id": "script+/workspace/script.py" }, + // Information about the workspace target, omitted when `--script` is used. + "workspace": { + // The absolute path to the workspace root + "path": "/workspace", + // The id of the workspace's node in the `resolution` map below + "id": "workspace+/workspace" + }, // Any requirements on the python version this workspace has // // `marker` fields all have this as an implicit constraint that is omitted for cleanliness @@ -211,9 +234,10 @@ Here is a human-readable annotated example: // Resolved information about packages and dependencies. // // Each entry in this map is a node in the dependency graph. There are currently - // 4 kinds of node in the dependency graph, although more are planned in the future. + // 5 kinds of node in the dependency graph, although more are planned in the future. // // * Scripts -- "kind": "script" + // * Workspaces -- "kind": "workspace" // * Packages -- "kind": "package" // * Extras -- "kind": { "extra": "extraname" } // * Groups -- "kind": { "group": "groupname" } @@ -242,6 +266,30 @@ Here is a human-readable annotated example: ] }, + // The workspace node owns metadata defined directly on the workspace root. + "workspace+/workspace": { + "kind": "workspace", + "path": "/workspace", + "dependencies": [], + "dependency_groups": [ + { + "name": "dev", + "id": "workspace+/workspace:dev" + } + ] + }, + + // This node is a dependency group defined on the non-package workspace root. + "workspace+/workspace:dev": { + "kind": { "group": "dev" }, + "path": "/workspace", + "dependencies": [ + { + "id": "iniconfig==2.0.0@registry+https://pypi.org/simple" + } + ] + }, + // This node is a workspace member "mypackage==0.1.0@editable+/workspace/packages/mypackage": { // The name of the package