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:
@@ -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)]
|
||||
|
||||
@@ -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>.
|
||||
|
||||
@@ -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())]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user