uv_workspace/
dependency_groups.rs

1use std::collections::btree_map::Entry;
2use std::str::FromStr;
3use std::{collections::BTreeMap, path::Path};
4
5use thiserror::Error;
6
7use uv_distribution_types::RequiresPython;
8use uv_fs::Simplified;
9use uv_normalize::{DEV_DEPENDENCIES, GroupName};
10use uv_pep440::VersionSpecifiers;
11use uv_pep508::Pep508Error;
12use uv_pypi_types::{DependencyGroupSpecifier, VerbatimParsedUrl};
13
14use crate::pyproject::{DependencyGroupSettings, PyProjectToml, ToolUvDependencyGroups};
15
16/// PEP 735 dependency groups, with any `include-group` entries resolved.
17#[derive(Debug, Default, Clone)]
18pub struct FlatDependencyGroups(BTreeMap<GroupName, FlatDependencyGroup>);
19
20#[derive(Debug, Default, Clone)]
21pub struct FlatDependencyGroup {
22    pub requirements: Vec<uv_pep508::Requirement<VerbatimParsedUrl>>,
23    pub requires_python: Option<VersionSpecifiers>,
24}
25
26impl FlatDependencyGroups {
27    /// Gather and flatten all the dependency-groups defined in the given pyproject.toml
28    ///
29    /// The path is only used in diagnostics.
30    pub fn from_pyproject_toml(
31        path: &Path,
32        pyproject_toml: &PyProjectToml,
33    ) -> Result<Self, DependencyGroupError> {
34        // First, collect `tool.uv.dev_dependencies`
35        let dev_dependencies = pyproject_toml
36            .tool
37            .as_ref()
38            .and_then(|tool| tool.uv.as_ref())
39            .and_then(|uv| uv.dev_dependencies.as_ref());
40
41        // Then, collect `dependency-groups`
42        let dependency_groups = pyproject_toml
43            .dependency_groups
44            .iter()
45            .flatten()
46            .collect::<BTreeMap<_, _>>();
47
48        // Get additional settings
49        let empty_settings = ToolUvDependencyGroups::default();
50        let group_settings = pyproject_toml
51            .tool
52            .as_ref()
53            .and_then(|tool| tool.uv.as_ref())
54            .and_then(|uv| uv.dependency_groups.as_ref())
55            .unwrap_or(&empty_settings);
56
57        // Flatten the dependency groups.
58        let mut dependency_groups =
59            Self::from_dependency_groups(&dependency_groups, group_settings.inner()).map_err(
60                |err| DependencyGroupError {
61                    package: pyproject_toml
62                        .project
63                        .as_ref()
64                        .map(|project| project.name.to_string())
65                        .unwrap_or_default(),
66                    path: path.user_display().to_string(),
67                    error: err.with_dev_dependencies(dev_dependencies),
68                },
69            )?;
70
71        // Add the `dev` group, if the legacy `dev-dependencies` is defined.
72        //
73        // NOTE: the fact that we do this out here means that nothing can inherit from
74        // the legacy dev-dependencies group (or define a group requires-python for it).
75        // This is intentional, we want groups to be defined in a standard interoperable
76        // way, and letting things include-group a group that isn't defined would be a
77        // mess for other python tools.
78        if let Some(dev_dependencies) = dev_dependencies {
79            dependency_groups
80                .entry(DEV_DEPENDENCIES.clone())
81                .or_insert_with(FlatDependencyGroup::default)
82                .requirements
83                .extend(dev_dependencies.clone());
84        }
85
86        Ok(dependency_groups)
87    }
88
89    /// Resolve the dependency groups (which may contain references to other groups) into concrete
90    /// lists of requirements.
91    fn from_dependency_groups(
92        groups: &BTreeMap<&GroupName, &Vec<DependencyGroupSpecifier>>,
93        settings: &BTreeMap<GroupName, DependencyGroupSettings>,
94    ) -> Result<Self, DependencyGroupErrorInner> {
95        fn resolve_group<'data>(
96            resolved: &mut BTreeMap<GroupName, FlatDependencyGroup>,
97            groups: &'data BTreeMap<&GroupName, &Vec<DependencyGroupSpecifier>>,
98            settings: &BTreeMap<GroupName, DependencyGroupSettings>,
99            name: &'data GroupName,
100            parents: &mut Vec<&'data GroupName>,
101        ) -> Result<(), DependencyGroupErrorInner> {
102            let Some(specifiers) = groups.get(name) else {
103                // Missing group
104                let parent_name = parents
105                    .iter()
106                    .last()
107                    .copied()
108                    .expect("parent when group is missing");
109                return Err(DependencyGroupErrorInner::GroupNotFound(
110                    name.clone(),
111                    parent_name.clone(),
112                ));
113            };
114
115            // "Dependency Group Includes MUST NOT include cycles, and tools SHOULD report an error if they detect a cycle."
116            if parents.contains(&name) {
117                return Err(DependencyGroupErrorInner::DependencyGroupCycle(Cycle(
118                    parents.iter().copied().cloned().collect(),
119                )));
120            }
121
122            // If we already resolved this group, short-circuit.
123            if resolved.contains_key(name) {
124                return Ok(());
125            }
126
127            parents.push(name);
128            let mut requirements = Vec::with_capacity(specifiers.len());
129            let mut requires_python_intersection = VersionSpecifiers::empty();
130            for specifier in *specifiers {
131                match specifier {
132                    DependencyGroupSpecifier::Requirement(requirement) => {
133                        match uv_pep508::Requirement::<VerbatimParsedUrl>::from_str(requirement) {
134                            Ok(requirement) => requirements.push(requirement),
135                            Err(err) => {
136                                return Err(DependencyGroupErrorInner::GroupParseError(
137                                    name.clone(),
138                                    requirement.clone(),
139                                    Box::new(err),
140                                ));
141                            }
142                        }
143                    }
144                    DependencyGroupSpecifier::IncludeGroup { include_group } => {
145                        resolve_group(resolved, groups, settings, include_group, parents)?;
146                        if let Some(included) = resolved.get(include_group) {
147                            requirements.extend(included.requirements.iter().cloned());
148
149                            // Intersect the requires-python for this group with the included group's
150                            requires_python_intersection = requires_python_intersection
151                                .into_iter()
152                                .chain(included.requires_python.clone().into_iter().flatten())
153                                .collect();
154                        }
155                    }
156                    DependencyGroupSpecifier::Object(map) => {
157                        return Err(
158                            DependencyGroupErrorInner::DependencyObjectSpecifierNotSupported(
159                                name.clone(),
160                                map.clone(),
161                            ),
162                        );
163                    }
164                }
165            }
166
167            let empty_settings = DependencyGroupSettings::default();
168            let DependencyGroupSettings { requires_python } =
169                settings.get(name).unwrap_or(&empty_settings);
170            if let Some(requires_python) = requires_python {
171                // Intersect the requires-python for this group to get the final requires-python
172                // that will be used by interpreter discovery and checking.
173                requires_python_intersection = requires_python_intersection
174                    .into_iter()
175                    .chain(requires_python.clone())
176                    .collect();
177
178                // Add the group requires-python as a marker to each requirement
179                // We don't use `requires_python_intersection` because each `include-group`
180                // should already have its markers applied to these.
181                for requirement in &mut requirements {
182                    let extra_markers =
183                        RequiresPython::from_specifiers(requires_python).to_marker_tree();
184                    requirement.marker.and(extra_markers);
185                }
186            }
187
188            parents.pop();
189
190            resolved.insert(
191                name.clone(),
192                FlatDependencyGroup {
193                    requirements,
194                    requires_python: if requires_python_intersection.is_empty() {
195                        None
196                    } else {
197                        Some(requires_python_intersection)
198                    },
199                },
200            );
201            Ok(())
202        }
203
204        // Validate the settings
205        for (group_name, ..) in settings {
206            if !groups.contains_key(group_name) {
207                return Err(DependencyGroupErrorInner::SettingsGroupNotFound(
208                    group_name.clone(),
209                ));
210            }
211        }
212
213        let mut resolved = BTreeMap::new();
214        for name in groups.keys() {
215            let mut parents = Vec::new();
216            resolve_group(&mut resolved, groups, settings, name, &mut parents)?;
217        }
218        Ok(Self(resolved))
219    }
220
221    /// Return the requirements for a given group, if any.
222    pub fn get(&self, group: &GroupName) -> Option<&FlatDependencyGroup> {
223        self.0.get(group)
224    }
225
226    /// Return the entry for a given group, if any.
227    pub fn entry(&mut self, group: GroupName) -> Entry<'_, GroupName, FlatDependencyGroup> {
228        self.0.entry(group)
229    }
230
231    /// Consume the [`FlatDependencyGroups`] and return the inner map.
232    pub fn into_inner(self) -> BTreeMap<GroupName, FlatDependencyGroup> {
233        self.0
234    }
235}
236
237impl FromIterator<(GroupName, FlatDependencyGroup)> for FlatDependencyGroups {
238    fn from_iter<T: IntoIterator<Item = (GroupName, FlatDependencyGroup)>>(iter: T) -> Self {
239        Self(iter.into_iter().collect())
240    }
241}
242
243impl IntoIterator for FlatDependencyGroups {
244    type Item = (GroupName, FlatDependencyGroup);
245    type IntoIter = std::collections::btree_map::IntoIter<GroupName, FlatDependencyGroup>;
246
247    fn into_iter(self) -> Self::IntoIter {
248        self.0.into_iter()
249    }
250}
251
252#[derive(Debug, Error)]
253#[error("{} has malformed dependency groups", if path.is_empty() && package.is_empty() {
254    "Project".to_string()
255} else if path.is_empty() || path == "." {
256    format!("Project `{package}`")
257} else if package.is_empty() {
258    format!("`{path}`")
259} else {
260    format!("Project `{package} @ {path}`")
261})]
262pub struct DependencyGroupError {
263    package: String,
264    path: String,
265    #[source]
266    error: DependencyGroupErrorInner,
267}
268
269#[derive(Debug, Error)]
270pub enum DependencyGroupErrorInner {
271    #[error("Failed to parse entry in group `{0}`: `{1}`")]
272    GroupParseError(
273        GroupName,
274        String,
275        #[source] Box<Pep508Error<VerbatimParsedUrl>>,
276    ),
277    #[error("Failed to find group `{0}` included by `{1}`")]
278    GroupNotFound(GroupName, GroupName),
279    #[error(
280        "Group `{0}` includes the `dev` group (`include = \"dev\"`), but only `tool.uv.dev-dependencies` was found. To reference the `dev` group via an `include`, remove the `tool.uv.dev-dependencies` section and add any development dependencies to the `dev` entry in the `[dependency-groups]` table instead."
281    )]
282    DevGroupInclude(GroupName),
283    #[error("Detected a cycle in `dependency-groups`: {0}")]
284    DependencyGroupCycle(Cycle),
285    #[error("Group `{0}` contains an unknown dependency object specifier: {1:?}")]
286    DependencyObjectSpecifierNotSupported(GroupName, BTreeMap<String, String>),
287    #[error("Failed to find group `{0}` specified in `[tool.uv.dependency-groups]`")]
288    SettingsGroupNotFound(GroupName),
289    #[error(
290        "`[tool.uv.dependency-groups]` specifies the `dev` group, but only `tool.uv.dev-dependencies` was found. To reference the `dev` group, remove the `tool.uv.dev-dependencies` section and add any development dependencies to the `dev` entry in the `[dependency-groups]` table instead."
291    )]
292    SettingsDevGroupInclude,
293}
294
295impl DependencyGroupErrorInner {
296    /// Enrich a [`DependencyGroupError`] with the `tool.uv.dev-dependencies` metadata, if applicable.
297    #[must_use]
298    pub fn with_dev_dependencies(
299        self,
300        dev_dependencies: Option<&Vec<uv_pep508::Requirement<VerbatimParsedUrl>>>,
301    ) -> Self {
302        match self {
303            Self::GroupNotFound(group, parent)
304                if dev_dependencies.is_some() && group == *DEV_DEPENDENCIES =>
305            {
306                Self::DevGroupInclude(parent)
307            }
308            Self::SettingsGroupNotFound(group)
309                if dev_dependencies.is_some() && group == *DEV_DEPENDENCIES =>
310            {
311                Self::SettingsDevGroupInclude
312            }
313            _ => self,
314        }
315    }
316}
317
318/// A cycle in the `dependency-groups` table.
319#[derive(Debug)]
320pub struct Cycle(Vec<GroupName>);
321
322/// Display a cycle, e.g., `a -> b -> c -> a`.
323impl std::fmt::Display for Cycle {
324    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
325        let [first, rest @ ..] = self.0.as_slice() else {
326            return Ok(());
327        };
328        write!(f, "`{first}`")?;
329        for group in rest {
330            write!(f, " -> `{group}`")?;
331        }
332        write!(f, " -> `{first}`")?;
333        Ok(())
334    }
335}