uv_distribution/metadata/dependency_groups.rs
1use std::collections::BTreeMap;
2use std::path::{Path, PathBuf};
3
4use uv_configuration::SourceStrategy;
5use uv_distribution_types::{IndexLocations, Requirement};
6use uv_normalize::{GroupName, PackageName};
7use uv_workspace::dependency_groups::FlatDependencyGroups;
8use uv_workspace::pyproject::{Sources, ToolUvSources};
9use uv_workspace::{
10 DiscoveryOptions, MemberDiscovery, VirtualProject, WorkspaceCache, WorkspaceError,
11};
12
13use crate::metadata::{GitWorkspaceMember, LoweredRequirement, MetadataError};
14
15/// Like [`crate::RequiresDist`] but only supporting dependency-groups.
16///
17/// PEP 735 says:
18///
19/// > A pyproject.toml file with only `[dependency-groups]` and no other tables is valid.
20///
21/// This is a special carveout to enable users to adopt dependency-groups without having
22/// to learn about projects. It is supported by `pip install --group`, and thus interfaces
23/// like `uv pip install --group` must also support it for interop and conformance.
24///
25/// On paper this is trivial to support because dependency-groups are so self-contained
26/// that they're basically a `requirements.txt` embedded within a pyproject.toml, so it's
27/// fine to just grab that section and handle it independently.
28///
29/// However several uv extensions make this complicated, notably, as of this writing:
30///
31/// * tool.uv.sources
32/// * tool.uv.index
33///
34/// These fields may also be present in the pyproject.toml, and, critically,
35/// may be defined and inherited in a parent workspace pyproject.toml.
36///
37/// Therefore, we need to gracefully degrade from a full workspacey situation all
38/// the way down to one of these stub pyproject.tomls the PEP defines. This is why
39/// we avoid going through `RequiresDist` -- we don't want to muddy up the "compile a package"
40/// logic with support for non-project/workspace pyproject.tomls, and we don't want to
41/// muddy this logic up with setuptools fallback modes that `RequiresDist` wants.
42///
43/// (We used to shove this feature into that path, and then we would see there's no metadata
44/// and try to run setuptools to try to desperately find any metadata, and then error out.)
45#[derive(Debug, Clone)]
46pub struct SourcedDependencyGroups {
47 pub name: Option<PackageName>,
48 pub dependency_groups: BTreeMap<GroupName, Box<[Requirement]>>,
49}
50
51impl SourcedDependencyGroups {
52 /// Lower by considering `tool.uv` in `pyproject.toml` if present, used for Git and directory
53 /// dependencies.
54 pub async fn from_virtual_project(
55 pyproject_path: &Path,
56 git_member: Option<&GitWorkspaceMember<'_>>,
57 locations: &IndexLocations,
58 source_strategy: SourceStrategy,
59 cache: &WorkspaceCache,
60 ) -> Result<Self, MetadataError> {
61 // If the `pyproject.toml` doesn't exist, fail early.
62 if !pyproject_path.is_file() {
63 return Err(MetadataError::MissingPyprojectToml(
64 pyproject_path.to_path_buf(),
65 ));
66 }
67
68 let discovery = DiscoveryOptions {
69 stop_discovery_at: git_member.map(|git_member| {
70 git_member
71 .fetch_root
72 .parent()
73 .expect("git checkout has a parent")
74 .to_path_buf()
75 }),
76 members: match source_strategy {
77 SourceStrategy::Enabled => MemberDiscovery::default(),
78 SourceStrategy::Disabled => MemberDiscovery::None,
79 },
80 ..DiscoveryOptions::default()
81 };
82
83 // The subsequent API takes an absolute path to the dir the pyproject is in
84 let empty = PathBuf::new();
85 let absolute_pyproject_path =
86 std::path::absolute(pyproject_path).map_err(WorkspaceError::Normalize)?;
87 let project_dir = absolute_pyproject_path.parent().unwrap_or(&empty);
88 let project = VirtualProject::discover(project_dir, &discovery, cache).await?;
89
90 // Collect the dependency groups.
91 let dependency_groups =
92 FlatDependencyGroups::from_pyproject_toml(project.root(), project.pyproject_toml())?;
93
94 // If sources/indexes are disabled we can just stop here
95 let SourceStrategy::Enabled = source_strategy else {
96 return Ok(Self {
97 name: project.project_name().cloned(),
98 dependency_groups: dependency_groups
99 .into_iter()
100 .map(|(name, group)| {
101 let requirements = group
102 .requirements
103 .into_iter()
104 .map(Requirement::from)
105 .collect();
106 (name, requirements)
107 })
108 .collect(),
109 });
110 };
111
112 // Collect any `tool.uv.index` entries.
113 let empty = vec![];
114 let project_indexes = project
115 .pyproject_toml()
116 .tool
117 .as_ref()
118 .and_then(|tool| tool.uv.as_ref())
119 .and_then(|uv| uv.index.as_deref())
120 .unwrap_or(&empty);
121
122 // Collect any `tool.uv.sources` and `tool.uv.dev_dependencies` from `pyproject.toml`.
123 let empty = BTreeMap::default();
124 let project_sources = project
125 .pyproject_toml()
126 .tool
127 .as_ref()
128 .and_then(|tool| tool.uv.as_ref())
129 .and_then(|uv| uv.sources.as_ref())
130 .map(ToolUvSources::inner)
131 .unwrap_or(&empty);
132
133 // Now that we've resolved the dependency groups, we can validate that each source references
134 // a valid extra or group, if present.
135 Self::validate_sources(project_sources, &dependency_groups)?;
136
137 // Lower the dependency groups.
138 let dependency_groups = dependency_groups
139 .into_iter()
140 .map(|(name, group)| {
141 let requirements = group
142 .requirements
143 .into_iter()
144 .flat_map(|requirement| {
145 let requirement_name = requirement.name.clone();
146 let group = name.clone();
147 let extra = None;
148 LoweredRequirement::from_requirement(
149 requirement,
150 project.project_name(),
151 project.root(),
152 project_sources,
153 project_indexes,
154 extra,
155 Some(&group),
156 locations,
157 project.workspace(),
158 git_member,
159 )
160 .map(move |requirement| match requirement {
161 Ok(requirement) => Ok(requirement.into_inner()),
162 Err(err) => Err(MetadataError::GroupLoweringError(
163 group.clone(),
164 requirement_name.clone(),
165 Box::new(err),
166 )),
167 })
168 })
169 .collect::<Result<Box<_>, _>>()?;
170 Ok::<(GroupName, Box<_>), MetadataError>((name, requirements))
171 })
172 .collect::<Result<BTreeMap<_, _>, _>>()?;
173
174 Ok(Self {
175 name: project.project_name().cloned(),
176 dependency_groups,
177 })
178 }
179
180 /// Validate the sources.
181 ///
182 /// If a source is requested with `group`, ensure that the relevant dependency is
183 /// present in the relevant `dependency-groups` section.
184 fn validate_sources(
185 sources: &BTreeMap<PackageName, Sources>,
186 dependency_groups: &FlatDependencyGroups,
187 ) -> Result<(), MetadataError> {
188 for (name, sources) in sources {
189 for source in sources.iter() {
190 if let Some(group) = source.group() {
191 // If the group doesn't exist at all, error.
192 let Some(flat_group) = dependency_groups.get(group) else {
193 return Err(MetadataError::MissingSourceGroup(
194 name.clone(),
195 group.clone(),
196 ));
197 };
198
199 // If there is no such requirement with the group, error.
200 if !flat_group
201 .requirements
202 .iter()
203 .any(|requirement| requirement.name == *name)
204 {
205 return Err(MetadataError::IncompleteSourceGroup(
206 name.clone(),
207 group.clone(),
208 ));
209 }
210 }
211 }
212 }
213
214 Ok(())
215 }
216}