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