From 60002358bef313b0af32dc08e02c99efe9df0e6e Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Fri, 18 Oct 2024 11:48:16 -0500 Subject: [PATCH] Add support for reading and resolving `include-group` in dependency groups (#8266) Part of #8090 Adds the ability to read group inclusions (`include-group = `) in the `pyproject.toml`. Resolves groups into concrete dependencies for resolution. See https://github.com/astral-sh/uv/pull/8110 for a bit more commentary on deferred work. --------- Co-authored-by: Charlie Marsh --- crates/uv-distribution/src/metadata/mod.rs | 27 +- .../src/metadata/requires_dist.rs | 220 +++++---- crates/uv-workspace/src/pyproject.rs | 78 +++- crates/uv-workspace/src/workspace/tests.rs | 30 +- crates/uv/tests/it/lock.rs | 432 ++++++++++++++++++ crates/uv/tests/it/sync.rs | 132 ++++++ 6 files changed, 821 insertions(+), 98 deletions(-) diff --git a/crates/uv-distribution/src/metadata/mod.rs b/crates/uv-distribution/src/metadata/mod.rs index f40dcec29477..e95a100a657a 100644 --- a/crates/uv-distribution/src/metadata/mod.rs +++ b/crates/uv-distribution/src/metadata/mod.rs @@ -24,14 +24,37 @@ pub enum MetadataError { Workspace(#[from] WorkspaceError), #[error("Failed to parse entry: `{0}`")] LoweringError(PackageName, #[source] Box), - #[error("Failed to parse entry in `{0}`: `{1}`")] + #[error("Failed to parse entry in group `{0}`: `{1}`")] GroupLoweringError(GroupName, PackageName, #[source] Box), - #[error("Failed to parse entry in `{0}`: `{1}`")] + #[error("Failed to parse entry in group `{0}`: `{1}`")] GroupParseError( GroupName, String, #[source] Box>, ), + #[error("Failed to find group `{0}` included by `{1}`")] + GroupNotFound(GroupName, GroupName), + #[error("Detected a cycle in `dependency-groups`: {0}")] + DependencyGroupCycle(Cycle), +} + +/// A cycle in the `dependency-groups` table. +#[derive(Debug)] +pub struct Cycle(Vec); + +/// Display a cycle, e.g., `a -> b -> c -> a`. +impl std::fmt::Display for Cycle { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let [first, rest @ ..] = self.0.as_slice() else { + return Ok(()); + }; + write!(f, "`{first}`")?; + for group in rest { + write!(f, " -> `{group}`")?; + } + write!(f, " -> `{first}`")?; + Ok(()) + } } #[derive(Debug, Clone)] diff --git a/crates/uv-distribution/src/metadata/requires_dist.rs b/crates/uv-distribution/src/metadata/requires_dist.rs index ba57b35792f8..e655c0e29103 100644 --- a/crates/uv-distribution/src/metadata/requires_dist.rs +++ b/crates/uv-distribution/src/metadata/requires_dist.rs @@ -1,17 +1,19 @@ -use crate::metadata::{LoweredRequirement, MetadataError}; -use crate::Metadata; - use std::collections::BTreeMap; use std::path::Path; use std::str::FromStr; +use tracing::warn; + use uv_configuration::{LowerBound, SourceStrategy}; use uv_distribution_types::IndexLocations; use uv_normalize::{ExtraName, GroupName, PackageName, DEV_DEPENDENCIES}; use uv_pypi_types::VerbatimParsedUrl; -use uv_workspace::pyproject::{Sources, ToolUvSources}; +use uv_workspace::pyproject::{DependencyGroupSpecifier, ToolUvSources}; use uv_workspace::{DiscoveryOptions, ProjectWorkspace}; +use crate::metadata::{Cycle, LoweredRequirement, MetadataError}; +use crate::Metadata; + #[derive(Debug, Clone)] pub struct RequiresDist { pub name: PackageName, @@ -116,55 +118,71 @@ impl RequiresDist { .dependency_groups .iter() .flatten() - .map(|(name, requirements)| { - ( - name.clone(), - requirements - .iter() - .map(|requirement| { - match uv_pep508::Requirement::::from_str( - requirement, - ) { - Ok(requirement) => Ok(requirement), - Err(err) => Err(MetadataError::GroupParseError( - name.clone(), - requirement.clone(), - Box::new(err), - )), - } - }) - .collect::, _>>(), - ) - }) + .collect::>(); + + // Resolve any `include-group` entries in `dependency-groups`. + let dependency_groups = resolve_dependency_groups(&dependency_groups)? + .into_iter() .chain( - // Only add the `dev` group if `dev-dependencies` is defined + // Only add the `dev` group if `dev-dependencies` is defined. dev_dependencies .into_iter() - .map(|requirements| (DEV_DEPENDENCIES.clone(), Ok(requirements.clone()))), + .map(|requirements| (DEV_DEPENDENCIES.clone(), requirements.clone())), ) .map(|(name, requirements)| { - // Apply sources to the requirements - match requirements { - Ok(requirements) => match apply_source_strategy( - source_strategy, - requirements, - &metadata, - project_sources, - project_indexes, - locations, - project_workspace, - lower_bound, - &name, - ) { - Ok(requirements) => Ok((name, requirements)), - Err(err) => Err(err), - }, - Err(err) => Err(err), - } + let requirements = match source_strategy { + SourceStrategy::Enabled => requirements + .into_iter() + .flat_map(|requirement| { + let group_name = name.clone(); + let requirement_name = requirement.name.clone(); + LoweredRequirement::from_requirement( + requirement, + &metadata.name, + project_workspace.project_root(), + project_sources, + project_indexes, + locations, + project_workspace.workspace(), + lower_bound, + ) + .map(move |requirement| { + match requirement { + Ok(requirement) => Ok(requirement.into_inner()), + Err(err) => Err(MetadataError::GroupLoweringError( + group_name.clone(), + requirement_name.clone(), + Box::new(err), + )), + } + }) + }) + .collect::, _>>(), + SourceStrategy::Disabled => Ok(requirements + .into_iter() + .map(uv_pypi_types::Requirement::from) + .collect()), + }?; + Ok::<(GroupName, Vec), MetadataError>(( + name, + requirements, + )) }) .collect::, _>>()?; - dependency_groups.into_iter().collect::>() + // Merge any overlapping groups. + let mut map = BTreeMap::new(); + for (name, dependencies) in dependency_groups { + match map.entry(name) { + std::collections::btree_map::Entry::Vacant(entry) => { + entry.insert(dependencies); + } + std::collections::btree_map::Entry::Occupied(mut entry) => { + entry.get_mut().extend(dependencies); + } + } + } + map }; let requires_dist = metadata.requires_dist.into_iter(); @@ -217,47 +235,79 @@ impl From for RequiresDist { } } -fn apply_source_strategy( - source_strategy: SourceStrategy, - requirements: Vec>, - metadata: &uv_pypi_types::RequiresDist, - project_sources: &BTreeMap, - project_indexes: &[uv_distribution_types::Index], - locations: &IndexLocations, - project_workspace: &ProjectWorkspace, - lower_bound: LowerBound, - group_name: &GroupName, -) -> Result, MetadataError> { - match source_strategy { - SourceStrategy::Enabled => requirements - .into_iter() - .flat_map(|requirement| { - let requirement_name = requirement.name.clone(); - LoweredRequirement::from_requirement( - requirement, - &metadata.name, - project_workspace.project_root(), - project_sources, - project_indexes, - locations, - project_workspace.workspace(), - lower_bound, - ) - .map(move |requirement| match requirement { - Ok(requirement) => Ok(requirement.into_inner()), - Err(err) => Err(MetadataError::GroupLoweringError( - group_name.clone(), - requirement_name.clone(), - Box::new(err), - )), - }) - }) - .collect::, _>>(), - SourceStrategy::Disabled => Ok(requirements - .into_iter() - .map(uv_pypi_types::Requirement::from) - .collect()), +/// Resolve the dependency groups (which may contain references to other groups) into concrete +/// lists of requirements. +fn resolve_dependency_groups( + groups: &BTreeMap<&GroupName, &Vec>, +) -> Result>>, MetadataError> { + fn resolve_group<'data>( + resolved: &mut BTreeMap>>, + groups: &'data BTreeMap<&GroupName, &Vec>, + name: &'data GroupName, + parents: &mut Vec<&'data GroupName>, + ) -> Result<(), MetadataError> { + let Some(specifiers) = groups.get(name) else { + // Missing group + let parent_name = parents + .iter() + .last() + .copied() + .expect("parent when group is missing"); + return Err(MetadataError::GroupNotFound( + name.clone(), + parent_name.clone(), + )); + }; + + // "Dependency Group Includes MUST NOT include cycles, and tools SHOULD report an error if they detect a cycle." + if parents.contains(&name) { + return Err(MetadataError::DependencyGroupCycle(Cycle( + parents.iter().copied().cloned().collect(), + ))); + } + + // If we already resolved this group, short-circuit. + if resolved.contains_key(name) { + return Ok(()); + } + + parents.push(name); + let mut requirements = Vec::with_capacity(specifiers.len()); + for specifier in *specifiers { + match specifier { + DependencyGroupSpecifier::Requirement(requirement) => { + match uv_pep508::Requirement::::from_str(requirement) { + Ok(requirement) => requirements.push(requirement), + Err(err) => { + return Err(MetadataError::GroupParseError( + name.clone(), + requirement.clone(), + Box::new(err), + )); + } + } + } + DependencyGroupSpecifier::IncludeGroup { include_group } => { + resolve_group(resolved, groups, include_group, parents)?; + requirements.extend(resolved.get(include_group).into_iter().flatten().cloned()); + } + DependencyGroupSpecifier::Object(map) => { + warn!("Ignoring Dependency Object Specifier referenced by `{name}`: {map:?}"); + } + } + } + parents.pop(); + + resolved.insert(name.clone(), requirements); + Ok(()) + } + + let mut resolved = BTreeMap::new(); + for name in groups.keys() { + let mut parents = Vec::new(); + resolve_group(&mut resolved, groups, name, &mut parents)?; } + Ok(resolved) } #[cfg(test)] diff --git a/crates/uv-workspace/src/pyproject.rs b/crates/uv-workspace/src/pyproject.rs index df1bdc7097ef..8536beec6307 100644 --- a/crates/uv-workspace/src/pyproject.rs +++ b/crates/uv-workspace/src/pyproject.rs @@ -6,16 +6,17 @@ //! //! Then lowers them into a dependency specification. -use glob::Pattern; -use owo_colors::OwoColorize; -use serde::de::SeqAccess; -use serde::{de::IntoDeserializer, Deserialize, Deserializer, Serialize}; use std::ops::Deref; use std::path::{Path, PathBuf}; use std::str::FromStr; use std::{collections::BTreeMap, mem}; + +use glob::Pattern; +use owo_colors::OwoColorize; +use serde::{de::IntoDeserializer, de::SeqAccess, Deserialize, Deserializer, Serialize}; use thiserror::Error; use url::Url; + use uv_distribution_types::Index; use uv_fs::{relative_to, PortablePathBuf}; use uv_git::GitReference; @@ -45,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, @@ -114,6 +115,73 @@ impl AsRef<[u8]> for PyProjectToml { } } +/// A specifier item in a [PEP 735](https://peps.python.org/pep-0735/) Dependency Group. +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(test, derive(Serialize))] +pub enum DependencyGroupSpecifier { + /// A PEP 508-compatible requirement string. + Requirement(String), + /// A reference to another dependency group. + IncludeGroup { + /// The name of the group to include. + include_group: GroupName, + }, + /// A Dependency Object Specifier. + Object(BTreeMap), +} + +impl<'de> Deserialize<'de> for DependencyGroupSpecifier { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct Visitor; + + impl<'de> serde::de::Visitor<'de> for Visitor { + type Value = DependencyGroupSpecifier; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("a string or a map with the `include-group` key") + } + + fn visit_str(self, value: &str) -> Result + where + E: serde::de::Error, + { + Ok(DependencyGroupSpecifier::Requirement(value.to_owned())) + } + + fn visit_map(self, mut map: M) -> Result + where + M: serde::de::MapAccess<'de>, + { + let mut map_data = BTreeMap::new(); + while let Some((key, value)) = map.next_entry()? { + map_data.insert(key, value); + } + + if map_data.is_empty() { + return Err(serde::de::Error::custom("missing field `include-group`")); + } + + if let Some(include_group) = map_data + .get("include-group") + .map(String::as_str) + .map(GroupName::from_str) + .transpose() + .map_err(serde::de::Error::custom)? + { + Ok(DependencyGroupSpecifier::IncludeGroup { include_group }) + } else { + Ok(DependencyGroupSpecifier::Object(map_data)) + } + } + } + + deserializer.deserialize_any(Visitor) + } +} + /// PEP 621 project metadata (`project`). /// /// See . diff --git a/crates/uv-workspace/src/workspace/tests.rs b/crates/uv-workspace/src/workspace/tests.rs index cd3cbbf4c7b5..ae45900e01df 100644 --- a/crates/uv-workspace/src/workspace/tests.rs +++ b/crates/uv-workspace/src/workspace/tests.rs @@ -9,7 +9,7 @@ use insta::assert_json_snapshot; use uv_normalize::GroupName; -use crate::pyproject::PyProjectToml; +use crate::pyproject::{DependencyGroupSpecifier, PyProjectToml}; use crate::workspace::{DiscoveryOptions, ProjectWorkspace}; async fn workspace_test(folder: &str) -> (ProjectWorkspace, String) { @@ -857,16 +857,34 @@ async fn exclude_package() -> Result<()> { fn read_dependency_groups() { let toml = r#" [dependency-groups] -test = ["a"] +foo = ["a", {include-group = "bar"}] +bar = ["b"] "#; let result = PyProjectToml::from_string(toml.to_string()).expect("Deserialization should succeed"); + let groups = result .dependency_groups .expect("`dependency-groups` should be present"); - let test = groups - .get(&GroupName::from_str("test").unwrap()) - .expect("Group `test` should be present"); - assert_eq!(test, &["a".to_string()]); + let foo = groups + .get(&GroupName::from_str("foo").unwrap()) + .expect("Group `foo` should be present"); + assert_eq!( + foo, + &[ + DependencyGroupSpecifier::Requirement("a".to_string()), + DependencyGroupSpecifier::IncludeGroup { + include_group: GroupName::from_str("bar").unwrap(), + } + ] + ); + + let bar = groups + .get(&GroupName::from_str("bar").unwrap()) + .expect("Group `bar` should be present"); + assert_eq!( + bar, + &[DependencyGroupSpecifier::Requirement("b".to_string())] + ); } diff --git a/crates/uv/tests/it/lock.rs b/crates/uv/tests/it/lock.rs index 22b8e33847f2..4f4a644bd639 100644 --- a/crates/uv/tests/it/lock.rs +++ b/crates/uv/tests/it/lock.rs @@ -15902,3 +15902,435 @@ fn lock_multiple_sources_extra() -> Result<()> { Ok(()) } + +#[test] +fn lock_group_include() -> 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 = ["anyio", {include-group = "bar"}] + bar = ["trio"] + "#, + )?; + + uv_snapshot!(context.filters(), context.lock(), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 11 packages in [TIME] + "###); + + let lock = context.read("uv.lock"); + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + lock, @r###" + version = 1 + requires-python = ">=3.12" + + [options] + exclude-newer = "2024-03-25T00:00:00Z" + + [[package]] + name = "anyio" + version = "4.3.0" + source = { registry = "https://pypi.org/simple" } + dependencies = [ + { name = "idna" }, + { name = "sniffio" }, + ] + sdist = { url = "https://files.pythonhosted.org/packages/db/4d/3970183622f0330d3c23d9b8a5f52e365e50381fd484d08e3285104333d3/anyio-4.3.0.tar.gz", hash = "sha256:f75253795a87df48568485fd18cdd2a3fa5c4f7c5be8e5e36637733fce06fed6", size = 159642 } + wheels = [ + { url = "https://files.pythonhosted.org/packages/14/fd/2f20c40b45e4fb4324834aea24bd4afdf1143390242c0b33774da0e2e34f/anyio-4.3.0-py3-none-any.whl", hash = "sha256:048e05d0f6caeed70d731f3db756d35dcc1f35747c8c403364a8332c630441b8", size = 85584 }, + ] + + [[package]] + name = "attrs" + version = "23.2.0" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/e3/fc/f800d51204003fa8ae392c4e8278f256206e7a919b708eef054f5f4b650d/attrs-23.2.0.tar.gz", hash = "sha256:935dc3b529c262f6cf76e50877d35a4bd3c1de194fd41f47a2b7ae8f19971f30", size = 780820 } + wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/44/827b2a91a5816512fcaf3cc4ebc465ccd5d598c45cefa6703fcf4a79018f/attrs-23.2.0-py3-none-any.whl", hash = "sha256:99b87a485a5820b23b879f04c2305b44b951b502fd64be915879d77a7e8fc6f1", size = 60752 }, + ] + + [[package]] + name = "cffi" + version = "1.16.0" + source = { registry = "https://pypi.org/simple" } + dependencies = [ + { name = "pycparser" }, + ] + sdist = { url = "https://files.pythonhosted.org/packages/68/ce/95b0bae7968c65473e1298efb042e10cafc7bafc14d9e4f154008241c91d/cffi-1.16.0.tar.gz", hash = "sha256:bcb3ef43e58665bbda2fb198698fcae6776483e0c4a631aa5647806c25e02cc0", size = 512873 } + wheels = [ + { url = "https://files.pythonhosted.org/packages/c9/6e/751437067affe7ac0944b1ad4856ec11650da77f0dd8f305fae1117ef7bb/cffi-1.16.0-cp312-cp312-win32.whl", hash = "sha256:b2ca4e77f9f47c55c194982e10f058db063937845bb2b7a86c84a6cfe0aefa8b", size = 173564 }, + { url = "https://files.pythonhosted.org/packages/e9/63/e285470a4880a4f36edabe4810057bd4b562c6ddcc165eacf9c3c7210b40/cffi-1.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:68678abf380b42ce21a5f2abde8efee05c114c2fdb2e9eef2efdb0257fba1235", size = 181956 }, + ] + + [[package]] + name = "idna" + version = "3.6" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/bf/3f/ea4b9117521a1e9c50344b909be7886dd00a519552724809bb1f486986c2/idna-3.6.tar.gz", hash = "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca", size = 175426 } + wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/e7/a82b05cf63a603df6e68d59ae6a68bf5064484a0718ea5033660af4b54a9/idna-3.6-py3-none-any.whl", hash = "sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f", size = 61567 }, + ] + + [[package]] + name = "outcome" + version = "1.3.0.post0" + source = { registry = "https://pypi.org/simple" } + dependencies = [ + { name = "attrs" }, + ] + sdist = { url = "https://files.pythonhosted.org/packages/98/df/77698abfac98571e65ffeb0c1fba8ffd692ab8458d617a0eed7d9a8d38f2/outcome-1.3.0.post0.tar.gz", hash = "sha256:9dcf02e65f2971b80047b377468e72a268e15c0af3cf1238e6ff14f7f91143b8", size = 21060 } + wheels = [ + { url = "https://files.pythonhosted.org/packages/55/8b/5ab7257531a5d830fc8000c476e63c935488d74609b50f9384a643ec0a62/outcome-1.3.0.post0-py2.py3-none-any.whl", hash = "sha256:e771c5ce06d1415e356078d3bdd68523f284b4ce5419828922b6871e65eda82b", size = 10692 }, + ] + + [[package]] + name = "project" + version = "0.1.0" + source = { virtual = "." } + dependencies = [ + { name = "typing-extensions" }, + ] + + [package.dev-dependencies] + bar = [ + { name = "trio" }, + ] + foo = [ + { name = "anyio" }, + { name = "trio" }, + ] + + [package.metadata] + requires-dist = [{ name = "typing-extensions" }] + + [package.metadata.requires-dev] + bar = [{ name = "trio" }] + foo = [ + { name = "anyio" }, + { name = "trio" }, + ] + + [[package]] + name = "pycparser" + version = "2.21" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/5e/0b/95d387f5f4433cb0f53ff7ad859bd2c6051051cebbb564f139a999ab46de/pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206", size = 170877 } + wheels = [ + { url = "https://files.pythonhosted.org/packages/62/d5/5f610ebe421e85889f2e55e33b7f9a6795bd982198517d912eb1c76e1a53/pycparser-2.21-py2.py3-none-any.whl", hash = "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9", size = 118697 }, + ] + + [[package]] + name = "sniffio" + version = "1.3.1" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 } + wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, + ] + + [[package]] + name = "sortedcontainers" + version = "2.4.0" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594 } + wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575 }, + ] + + [[package]] + name = "trio" + version = "0.25.0" + source = { registry = "https://pypi.org/simple" } + dependencies = [ + { name = "attrs" }, + { name = "cffi", marker = "implementation_name != 'pypy' and os_name == 'nt'" }, + { name = "idna" }, + { name = "outcome" }, + { name = "sniffio" }, + { name = "sortedcontainers" }, + ] + sdist = { url = "https://files.pythonhosted.org/packages/b4/51/4f5ae37ec58768b9c30e5bc5b89431a7baf3fa9d0dda98983af6ef55eb47/trio-0.25.0.tar.gz", hash = "sha256:9b41f5993ad2c0e5f62d0acca320ec657fdb6b2a2c22b8c7aed6caf154475c4e", size = 551863 } + wheels = [ + { url = "https://files.pythonhosted.org/packages/17/c9/f86f89f14d52f9f2f652ce24cb2f60141a51d087db1563f3fba94ba07346/trio-0.25.0-py3-none-any.whl", hash = "sha256:e6458efe29cc543e557a91e614e2b51710eba2961669329ce9c862d50c6e8e81", size = 467161 }, + ] + + [[package]] + name = "typing-extensions" + version = "4.10.0" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/16/3a/0d26ce356c7465a19c9ea8814b960f8a36c3b0d07c323176620b7b483e44/typing_extensions-4.10.0.tar.gz", hash = "sha256:b0abd7c89e8fb96f98db18d86106ff1d90ab692004eb746cf6eda2682f91b3cb", size = 77558 } + wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/de/dc04a3ea60b22624b51c703a84bbe0184abcd1d0b9bc8074b5d6b7ab90bb/typing_extensions-4.10.0-py3-none-any.whl", hash = "sha256:69b1a937c3a517342112fb4c6df7e72fc39a38e7891a5730ed4985b5214b5475", size = 33926 }, + ] + "### + ); + }); + + Ok(()) +} + +#[test] +fn lock_group_include_cycle() -> 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 = ["anyio", {include-group = "bar"}] + bar = [{include-group = "foobar"}] + foobar = [{include-group = "foo"}] + "#, + )?; + + uv_snapshot!(context.filters(), context.lock(), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Failed to build: `project @ file://[TEMP_DIR]/` + Caused by: Detected a cycle in `dependency-groups`: `bar` -> `foobar` -> `foo` -> `bar` + "###); + + Ok(()) +} + +#[test] +fn lock_group_include_missing() -> 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 = ["anyio", {include-group = "bar"}] + "#, + )?; + + uv_snapshot!(context.filters(), context.lock(), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Failed to build: `project @ file://[TEMP_DIR]/` + Caused by: Failed to find group `bar` included by `foo` + "###); + + Ok(()) +} + +#[test] +fn lock_group_invalid_entry_package() -> 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 = ["invalid!"] + "#, + )?; + + uv_snapshot!(context.filters(), context.lock(), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Failed to build: `project @ file://[TEMP_DIR]/` + Caused by: Failed to parse entry in group `foo`: `invalid!` + Caused by: no such comparison operator "!", must be one of ~= == != <= >= < > === + invalid! + ^ + "###); + + uv_snapshot!(context.filters(), context.sync().arg("--group").arg("foo"), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Failed to build: `project @ file://[TEMP_DIR]/` + Caused by: Failed to parse entry in group `foo`: `invalid!` + Caused by: no such comparison operator "!", must be one of ~= == != <= >= < > === + invalid! + ^ + "###); + + Ok(()) +} + +#[test] +fn lock_group_invalid_entry_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 = [{include-group = "invalid!"}] + "#, + )?; + + 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 9, column 16 + | + 9 | foo = [{include-group = "invalid!"}] + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + Not a valid package or extra name: "invalid!". Names must start and end with a letter or digit and may only contain -, _, ., and alphanumeric characters. + + "###); + + Ok(()) +} + +#[test] +fn lock_group_invalid_entry_table() -> 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 = "unknown"}] + "#, + )?; + + uv_snapshot!(context.filters(), context.lock(), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 2 packages in [TIME] + "###); + + Ok(()) +} + +#[test] +fn lock_group_invalid_entry_type() -> 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 = [{include-group = true}] + "#, + )?; + + 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 9, column 33 + | + 9 | foo = [{include-group = true}] + | ^^^^ + invalid type: boolean `true`, expected a string + + "###); + + Ok(()) +} + +#[test] +fn lock_group_empty_entry_table() -> 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 = [{}] + "#, + )?; + + 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 9, column 16 + | + 9 | foo = [{}] + | ^^ + missing field `include-group` + + "###); + + Ok(()) +} diff --git a/crates/uv/tests/it/sync.rs b/crates/uv/tests/it/sync.rs index e61e391c1e36..2b8fb22f495b 100644 --- a/crates/uv/tests/it/sync.rs +++ b/crates/uv/tests/it/sync.rs @@ -1094,6 +1094,138 @@ fn sync_group() -> Result<()> { Ok(()) } +#[test] +fn sync_include_group() -> 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 = ["anyio", {include-group = "bar"}] + bar = ["iniconfig"] + "#, + )?; + + context.lock().assert().success(); + + uv_snapshot!(context.filters(), context.sync(), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 6 packages in [TIME] + Prepared 1 package in [TIME] + Installed 1 package in [TIME] + + typing-extensions==4.10.0 + "###); + + uv_snapshot!(context.filters(), context.sync().arg("--group").arg("foo"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 6 packages in [TIME] + Prepared 4 packages in [TIME] + Installed 4 packages in [TIME] + + anyio==4.3.0 + + idna==3.6 + + iniconfig==2.0.0 + + sniffio==1.3.1 + "###); + + uv_snapshot!(context.filters(), context.sync().arg("--only-group").arg("bar"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 6 packages in [TIME] + Uninstalled 4 packages in [TIME] + - anyio==4.3.0 + - idna==3.6 + - sniffio==1.3.1 + - typing-extensions==4.10.0 + "###); + + uv_snapshot!(context.filters(), context.sync().arg("--group").arg("foo").arg("--group").arg("bar"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 6 packages in [TIME] + Installed 4 packages in [TIME] + + anyio==4.3.0 + + idna==3.6 + + sniffio==1.3.1 + + typing-extensions==4.10.0 + "###); + + uv_snapshot!(context.filters(), context.sync().arg("--only-group").arg("foo"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 6 packages in [TIME] + Uninstalled 1 package in [TIME] + - typing-extensions==4.10.0 + "###); + + Ok(()) +} + +#[test] +fn sync_dev_group() -> 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"] + + [tool.uv] + dev-dependencies = ["anyio"] + + [dependency-groups] + dev = ["iniconfig"] + "#, + )?; + + context.lock().assert().success(); + + uv_snapshot!(context.filters(), context.sync(), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 6 packages in [TIME] + Prepared 5 packages in [TIME] + Installed 5 packages in [TIME] + + anyio==4.3.0 + + idna==3.6 + + iniconfig==2.0.0 + + sniffio==1.3.1 + + typing-extensions==4.10.0 + "###); + + Ok(()) +} + /// Regression test for . /// /// Previously, we would read metadata statically from pyproject.toml and write that to `uv.lock`. In