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