Skip to main content

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}