Add support for reading and resolving include-group in dependency groups (#8266)

Part of #8090

Adds the ability to read group inclusions (`include-group = <name>`) 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 <charlie.r.marsh@gmail.com>
This commit is contained in:
Zanie Blue
2024-10-18 11:48:16 -05:00
parent 2dc4fd0a19
commit fc2e79c6ce
6 changed files with 821 additions and 98 deletions
+25 -2
View File
@@ -24,14 +24,37 @@ pub enum MetadataError {
Workspace(#[from] WorkspaceError),
#[error("Failed to parse entry: `{0}`")]
LoweringError(PackageName, #[source] Box<LoweringError>),
#[error("Failed to parse entry in `{0}`: `{1}`")]
#[error("Failed to parse entry in group `{0}`: `{1}`")]
GroupLoweringError(GroupName, PackageName, #[source] Box<LoweringError>),
#[error("Failed to parse entry in `{0}`: `{1}`")]
#[error("Failed to parse entry in group `{0}`: `{1}`")]
GroupParseError(
GroupName,
String,
#[source] Box<Pep508Error<VerbatimParsedUrl>>,
),
#[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<GroupName>);
/// 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)]
@@ -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::<VerbatimParsedUrl>::from_str(
requirement,
) {
Ok(requirement) => Ok(requirement),
Err(err) => Err(MetadataError::GroupParseError(
name.clone(),
requirement.clone(),
Box::new(err),
)),
}
})
.collect::<Result<Vec<_>, _>>(),
)
})
.collect::<BTreeMap<_, _>>();
// 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::<Result<Vec<_>, _>>(),
SourceStrategy::Disabled => Ok(requirements
.into_iter()
.map(uv_pypi_types::Requirement::from)
.collect()),
}?;
Ok::<(GroupName, Vec<uv_pypi_types::Requirement>), MetadataError>((
name,
requirements,
))
})
.collect::<Result<Vec<_>, _>>()?;
dependency_groups.into_iter().collect::<BTreeMap<_, _>>()
// 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<Metadata> for RequiresDist {
}
}
fn apply_source_strategy(
source_strategy: SourceStrategy,
requirements: Vec<uv_pep508::Requirement<VerbatimParsedUrl>>,
metadata: &uv_pypi_types::RequiresDist,
project_sources: &BTreeMap<PackageName, Sources>,
project_indexes: &[uv_distribution_types::Index],
locations: &IndexLocations,
project_workspace: &ProjectWorkspace,
lower_bound: LowerBound,
group_name: &GroupName,
) -> Result<Vec<uv_pypi_types::Requirement>, 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::<Result<Vec<_>, _>>(),
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<DependencyGroupSpecifier>>,
) -> Result<BTreeMap<GroupName, Vec<uv_pep508::Requirement<VerbatimParsedUrl>>>, MetadataError> {
fn resolve_group<'data>(
resolved: &mut BTreeMap<GroupName, Vec<uv_pep508::Requirement<VerbatimParsedUrl>>>,
groups: &'data BTreeMap<&GroupName, &Vec<DependencyGroupSpecifier>>,
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::<VerbatimParsedUrl>::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)]
+73 -5
View File
@@ -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, IndexName};
use uv_fs::{relative_to, PortablePathBuf};
use uv_git::GitReference;
@@ -45,7 +46,7 @@ pub struct PyProjectToml {
/// Tool-specific metadata.
pub tool: Option<Tool>,
/// Non-project dependency groups, as defined in PEP 735.
pub dependency_groups: Option<BTreeMap<GroupName, Vec<String>>>,
pub dependency_groups: Option<BTreeMap<GroupName, Vec<DependencyGroupSpecifier>>>,
/// 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<String, String>),
}
impl<'de> Deserialize<'de> for DependencyGroupSpecifier {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
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<E>(self, value: &str) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
Ok(DependencyGroupSpecifier::Requirement(value.to_owned()))
}
fn visit_map<M>(self, mut map: M) -> Result<Self::Value, M::Error>
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 <https://packaging.python.org/en/latest/specifications/pyproject-toml>.
+24 -6
View File
@@ -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())]
);
}
+432
View File
@@ -16304,3 +16304,435 @@ fn lock_dry_run_noop() -> 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(())
}
+132
View File
@@ -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 <https://github.com/astral-sh/uv/issues/6316>.
///
/// Previously, we would read metadata statically from pyproject.toml and write that to `uv.lock`. In