From 4d134a4ffe89efaf939997ae20dc4972aefc2964 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Mon, 21 Oct 2024 21:47:57 -0400 Subject: [PATCH] Error on duplicate PEP 735 dependency groups (#8390) ## Summary Part of: https://github.com/astral-sh/uv/pull/8272. --- crates/uv-workspace/src/pyproject.rs | 75 +++++++++++++++++++++++++++- crates/uv/tests/it/lock.rs | 36 +++++++++++++ 2 files changed, 110 insertions(+), 1 deletion(-) diff --git a/crates/uv-workspace/src/pyproject.rs b/crates/uv-workspace/src/pyproject.rs index 12ccfb633..24f2e0cec 100644 --- a/crates/uv-workspace/src/pyproject.rs +++ b/crates/uv-workspace/src/pyproject.rs @@ -46,7 +46,7 @@ pub struct PyProjectToml { /// Tool-specific metadata. pub tool: Option, /// Non-project dependency groups, as defined in PEP 735. - pub dependency_groups: Option>>, + pub dependency_groups: Option, /// The raw unserialized document. #[serde(skip)] pub raw: String, @@ -540,6 +540,79 @@ impl Deref for SerdePattern { } } +#[derive(Debug, Clone, PartialEq)] +#[cfg_attr(test, derive(Serialize))] +pub struct DependencyGroups(BTreeMap>); + +impl DependencyGroups { + /// Returns the names of the dependency groups. + pub fn keys(&self) -> impl Iterator { + self.0.keys() + } + + /// Returns the dependency group with the given name. + pub fn get(&self, group: &GroupName) -> Option<&Vec> { + self.0.get(group) + } + + /// Returns an iterator over the dependency groups. + pub fn iter(&self) -> impl Iterator)> { + self.0.iter() + } +} + +impl<'a> IntoIterator for &'a DependencyGroups { + type Item = (&'a GroupName, &'a Vec); + type IntoIter = std::collections::btree_map::Iter<'a, GroupName, Vec>; + + fn into_iter(self) -> Self::IntoIter { + self.0.iter() + } +} + +/// Ensure that all keys in the TOML table are unique. +impl<'de> serde::de::Deserialize<'de> for DependencyGroups { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct GroupVisitor; + + impl<'de> serde::de::Visitor<'de> for GroupVisitor { + type Value = DependencyGroups; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("a table with unique dependency group names") + } + + fn visit_map(self, mut access: M) -> Result + where + M: serde::de::MapAccess<'de>, + { + let mut sources = BTreeMap::new(); + while let Some((key, value)) = + access.next_entry::>()? + { + match sources.entry(key) { + std::collections::btree_map::Entry::Occupied(entry) => { + return Err(serde::de::Error::custom(format!( + "duplicate dependency group: `{}`", + entry.key() + ))); + } + std::collections::btree_map::Entry::Vacant(entry) => { + entry.insert(value); + } + } + } + Ok(DependencyGroups(sources)) + } + } + + deserializer.deserialize_map(GroupVisitor) + } +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[serde(rename_all = "kebab-case", try_from = "SourcesWire")] diff --git a/crates/uv/tests/it/lock.rs b/crates/uv/tests/it/lock.rs index 7a866072b..2482fece0 100644 --- a/crates/uv/tests/it/lock.rs +++ b/crates/uv/tests/it/lock.rs @@ -16635,6 +16635,42 @@ fn lock_group_invalid_entry_group_name() -> Result<()> { Ok(()) } +#[test] +fn lock_group_invalid_duplicate_group_name() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["typing-extensions"] + + [dependency-groups] + foo-bar = [] + foo_bar = [] + "#, + )?; + + uv_snapshot!(context.filters(), context.lock(), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Failed to parse: `pyproject.toml` + Caused by: TOML parse error at line 8, column 9 + | + 8 | [dependency-groups] + | ^^^^^^^^^^^^^^^^^^^ + duplicate dependency group: `foo-bar` + "###); + + Ok(()) +} + #[test] fn lock_group_invalid_entry_table() -> Result<()> { let context = TestContext::new("3.12");