uv_distribution/metadata/
dependency_groups.rs

1use std::collections::BTreeMap;
2use std::path::{Path, PathBuf};
3use uv_auth::CredentialsCache;
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        credentials_cache: &CredentialsCache,
61    ) -> Result<Self, MetadataError> {
62        // If the `pyproject.toml` doesn't exist, fail early.
63        if !pyproject_path.is_file() {
64            return Err(MetadataError::MissingPyprojectToml(
65                pyproject_path.to_path_buf(),
66            ));
67        }
68
69        let discovery = DiscoveryOptions {
70            stop_discovery_at: git_member.map(|git_member| {
71                git_member
72                    .fetch_root
73                    .parent()
74                    .expect("git checkout has a parent")
75                    .to_path_buf()
76            }),
77            members: match source_strategy {
78                SourceStrategy::Enabled => MemberDiscovery::default(),
79                SourceStrategy::Disabled => MemberDiscovery::None,
80            },
81            ..DiscoveryOptions::default()
82        };
83
84        // The subsequent API takes an absolute path to the dir the pyproject is in
85        let empty = PathBuf::new();
86        let absolute_pyproject_path =
87            std::path::absolute(pyproject_path).map_err(WorkspaceError::Normalize)?;
88        let project_dir = absolute_pyproject_path.parent().unwrap_or(&empty);
89        let project = VirtualProject::discover(project_dir, &discovery, cache).await?;
90
91        // Collect the dependency groups.
92        let dependency_groups =
93            FlatDependencyGroups::from_pyproject_toml(project.root(), project.pyproject_toml())?;
94
95        // If sources/indexes are disabled we can just stop here
96        let SourceStrategy::Enabled = source_strategy else {
97            return Ok(Self {
98                name: project.project_name().cloned(),
99                dependency_groups: dependency_groups
100                    .into_iter()
101                    .map(|(name, group)| {
102                        let requirements = group
103                            .requirements
104                            .into_iter()
105                            .map(Requirement::from)
106                            .collect();
107                        (name, requirements)
108                    })
109                    .collect(),
110            });
111        };
112
113        // Collect any `tool.uv.index` entries.
114        let empty = vec![];
115        let project_indexes = project
116            .pyproject_toml()
117            .tool
118            .as_ref()
119            .and_then(|tool| tool.uv.as_ref())
120            .and_then(|uv| uv.index.as_deref())
121            .unwrap_or(&empty);
122
123        // Collect any `tool.uv.sources` and `tool.uv.dev_dependencies` from `pyproject.toml`.
124        let empty = BTreeMap::default();
125        let project_sources = project
126            .pyproject_toml()
127            .tool
128            .as_ref()
129            .and_then(|tool| tool.uv.as_ref())
130            .and_then(|uv| uv.sources.as_ref())
131            .map(ToolUvSources::inner)
132            .unwrap_or(&empty);
133
134        // Now that we've resolved the dependency groups, we can validate that each source references
135        // a valid extra or group, if present.
136        Self::validate_sources(project_sources, &dependency_groups)?;
137
138        // Lower the dependency groups.
139        let dependency_groups = dependency_groups
140            .into_iter()
141            .map(|(name, group)| {
142                let requirements = group
143                    .requirements
144                    .into_iter()
145                    .flat_map(|requirement| {
146                        let requirement_name = requirement.name.clone();
147                        let group = name.clone();
148                        let extra = None;
149                        LoweredRequirement::from_requirement(
150                            requirement,
151                            project.project_name(),
152                            project.root(),
153                            project_sources,
154                            project_indexes,
155                            extra,
156                            Some(&group),
157                            locations,
158                            project.workspace(),
159                            git_member,
160                            credentials_cache,
161                        )
162                        .map(move |requirement| match requirement {
163                            Ok(requirement) => Ok(requirement.into_inner()),
164                            Err(err) => Err(MetadataError::GroupLoweringError(
165                                group.clone(),
166                                requirement_name.clone(),
167                                Box::new(err),
168                            )),
169                        })
170                    })
171                    .collect::<Result<Box<_>, _>>()?;
172                Ok::<(GroupName, Box<_>), MetadataError>((name, requirements))
173            })
174            .collect::<Result<BTreeMap<_, _>, _>>()?;
175
176        Ok(Self {
177            name: project.project_name().cloned(),
178            dependency_groups,
179        })
180    }
181
182    /// Validate the sources.
183    ///
184    /// If a source is requested with `group`, ensure that the relevant dependency is
185    /// present in the relevant `dependency-groups` section.
186    fn validate_sources(
187        sources: &BTreeMap<PackageName, Sources>,
188        dependency_groups: &FlatDependencyGroups,
189    ) -> Result<(), MetadataError> {
190        for (name, sources) in sources {
191            for source in sources.iter() {
192                if let Some(group) = source.group() {
193                    // If the group doesn't exist at all, error.
194                    let Some(flat_group) = dependency_groups.get(group) else {
195                        return Err(MetadataError::MissingSourceGroup(
196                            name.clone(),
197                            group.clone(),
198                        ));
199                    };
200
201                    // If there is no such requirement with the group, error.
202                    if !flat_group
203                        .requirements
204                        .iter()
205                        .any(|requirement| requirement.name == *name)
206                    {
207                        return Err(MetadataError::IncompleteSourceGroup(
208                            name.clone(),
209                            group.clone(),
210                        ));
211                    }
212                }
213            }
214        }
215
216        Ok(())
217    }
218}