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.
This commit is contained in:
Aria Desires
2026-06-18 15:53:24 -04:00
committed by GitHub
parent 7f3ab0449a
commit f98013ef81
3 changed files with 277 additions and 19 deletions
+103 -6
View File
@@ -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<MetadataScript>,
/// Information about the workspace root, when metadata was requested for a workspace.
#[serde(skip_serializing_if = "Option::is_none", default)]
workspace: Option<MetadataWorkspace>,
/// 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<MetadataGroup>) -> 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<MetadataDependency>,
) -> 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<Item = &'lock Requirement>,
) -> Vec<MetadataDependency> {
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(),
+115 -2
View File
@@ -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": []
}
}
}
+59 -11
View File
@@ -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