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