Include conflicts defined in virtual workspace root (#18886)
## Summary Closes https://github.com/astral-sh/uv/issues/18879.
This commit is contained in:
@@ -5,6 +5,7 @@ use petgraph::{
|
||||
use rustc_hash::{FxHashMap, FxHashSet};
|
||||
#[cfg(feature = "schemars")]
|
||||
use std::borrow::Cow;
|
||||
use std::fmt;
|
||||
use std::{collections::BTreeSet, hash::Hash, rc::Rc};
|
||||
use uv_normalize::{ExtraName, GroupName, PackageName};
|
||||
|
||||
@@ -542,6 +543,33 @@ impl hashbrown::Equivalent<ConflictKind> for ConflictKindRef<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ConflictEntry {
|
||||
package: Option<PackageName>,
|
||||
extra: Option<ExtraName>,
|
||||
group: Option<GroupName>,
|
||||
}
|
||||
|
||||
impl fmt::Display for ConflictEntry {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let mut parts = vec![];
|
||||
if let Some(package) = &self.package {
|
||||
parts.push(format!("package = \"{package}\""));
|
||||
}
|
||||
if let Some(extra) = &self.extra {
|
||||
parts.push(format!("extra = \"{extra}\""));
|
||||
}
|
||||
if let Some(group) = &self.group {
|
||||
parts.push(format!("group = \"{group}\""));
|
||||
}
|
||||
if parts.is_empty() {
|
||||
write!(f, "{{}}")
|
||||
} else {
|
||||
write!(f, "{{ {} }}", parts.join(", "))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An error that occurs when the given conflicting set is invalid somehow.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ConflictError {
|
||||
@@ -556,8 +584,8 @@ pub enum ConflictError {
|
||||
/// (This is only applicable when deserializing from the lock file.
|
||||
/// When deserializing from `pyproject.toml`, the `package` field is
|
||||
/// optional.)
|
||||
#[error("Expected `package` field in conflicting entry")]
|
||||
MissingPackage,
|
||||
#[error("Expected `package` field in conflicting entry: {0}")]
|
||||
MissingPackage(ConflictEntry),
|
||||
/// An error that occurs when all of `package`, `extra` and `group` are missing.
|
||||
#[error("Expected `package`, `extra` or `group` field in conflicting entry")]
|
||||
MissingPackageAndExtraAndGroup,
|
||||
@@ -582,6 +610,41 @@ pub enum ConflictError {
|
||||
pub struct SchemaConflicts(Vec<SchemaConflictSet>);
|
||||
|
||||
impl SchemaConflicts {
|
||||
fn to_conflicts_with_default_package(
|
||||
&self,
|
||||
package: Option<&PackageName>,
|
||||
) -> Result<Conflicts, ConflictError> {
|
||||
let mut conflicting = Conflicts::empty();
|
||||
for tool_uv_set in &self.0 {
|
||||
let mut set = vec![];
|
||||
for item in &tool_uv_set.0 {
|
||||
let package = item
|
||||
.package
|
||||
.as_ref()
|
||||
.or(package)
|
||||
.ok_or_else(|| ConflictError::MissingPackage(ConflictEntry::from(item)))?
|
||||
.clone();
|
||||
set.push(ConflictItem {
|
||||
package,
|
||||
kind: item.kind.clone(),
|
||||
});
|
||||
}
|
||||
// OK because we guarantee that `SchemaConflictSet` is valid and
|
||||
// there aren't any new errors that can occur here.
|
||||
let set = ConflictSet::try_from(set).unwrap();
|
||||
conflicting.push(set);
|
||||
}
|
||||
Ok(conflicting)
|
||||
}
|
||||
|
||||
/// Convert the public schema "conflicting" type to our internal fully
|
||||
/// resolved type.
|
||||
///
|
||||
/// Every conflict item must define `package` explicitly.
|
||||
pub fn to_conflicts(&self) -> Result<Conflicts, ConflictError> {
|
||||
self.to_conflicts_with_default_package(None)
|
||||
}
|
||||
|
||||
/// Convert the public schema "conflicting" type to our internal fully
|
||||
/// resolved type. Effectively, this pairs the corresponding package name
|
||||
/// with each conflict.
|
||||
@@ -590,23 +653,8 @@ impl SchemaConflicts {
|
||||
/// then that takes precedence over the given package name, which is only
|
||||
/// used when there is no explicit package name written.
|
||||
pub fn to_conflicts_with_package_name(&self, package: &PackageName) -> Conflicts {
|
||||
let mut conflicting = Conflicts::empty();
|
||||
for tool_uv_set in &self.0 {
|
||||
let mut set = vec![];
|
||||
for item in &tool_uv_set.0 {
|
||||
let package = item.package.clone().unwrap_or_else(|| package.clone());
|
||||
set.push(ConflictItem {
|
||||
package: package.clone(),
|
||||
kind: item.kind.clone(),
|
||||
});
|
||||
}
|
||||
// OK because we guarantee that
|
||||
// `SchemaConflictingGroupList` is valid and there aren't
|
||||
// any new errors that can occur here.
|
||||
let set = ConflictSet::try_from(set).unwrap();
|
||||
conflicting.push(set);
|
||||
}
|
||||
conflicting
|
||||
self.to_conflicts_with_default_package(Some(package))
|
||||
.expect("default package name should satisfy schema conflicts")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -689,12 +737,44 @@ struct ConflictItemWire {
|
||||
group: Option<GroupName>,
|
||||
}
|
||||
|
||||
impl From<&SchemaConflictItem> for ConflictEntry {
|
||||
fn from(item: &SchemaConflictItem) -> Self {
|
||||
match &item.kind {
|
||||
ConflictKind::Project => Self {
|
||||
package: item.package.clone(),
|
||||
extra: None,
|
||||
group: None,
|
||||
},
|
||||
ConflictKind::Extra(extra) => Self {
|
||||
package: item.package.clone(),
|
||||
extra: Some(extra.clone()),
|
||||
group: None,
|
||||
},
|
||||
ConflictKind::Group(group) => Self {
|
||||
package: item.package.clone(),
|
||||
extra: None,
|
||||
group: Some(group.clone()),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&ConflictItemWire> for ConflictEntry {
|
||||
fn from(item: &ConflictItemWire) -> Self {
|
||||
Self {
|
||||
package: item.package.clone(),
|
||||
extra: item.extra.clone(),
|
||||
group: item.group.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<ConflictItemWire> for ConflictItem {
|
||||
type Error = ConflictError;
|
||||
|
||||
fn try_from(wire: ConflictItemWire) -> Result<Self, ConflictError> {
|
||||
let Some(package) = wire.package else {
|
||||
return Err(ConflictError::MissingPackage);
|
||||
return Err(ConflictError::MissingPackage(ConflictEntry::from(&wire)));
|
||||
};
|
||||
match (wire.extra, wire.group) {
|
||||
(Some(_), Some(_)) => Err(ConflictError::FoundExtraAndGroup),
|
||||
|
||||
@@ -15,7 +15,7 @@ use uv_fs::{CWD, Simplified};
|
||||
use uv_normalize::{DEV_DEPENDENCIES, GroupName, PackageName};
|
||||
use uv_pep440::VersionSpecifiers;
|
||||
use uv_pep508::{MarkerTree, VerbatimUrl};
|
||||
use uv_pypi_types::{Conflicts, SupportedEnvironments, VerbatimParsedUrl};
|
||||
use uv_pypi_types::{ConflictError, Conflicts, SupportedEnvironments, VerbatimParsedUrl};
|
||||
use uv_static::EnvVars;
|
||||
use uv_warnings::warn_user_once;
|
||||
|
||||
@@ -81,6 +81,8 @@ pub enum WorkspaceError {
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("Failed to parse: `{}`", _0.user_display())]
|
||||
Toml(PathBuf, #[source] Box<PyprojectTomlError>),
|
||||
#[error(transparent)]
|
||||
Conflicts(#[from] ConflictError),
|
||||
#[error("Failed to normalize workspace member path")]
|
||||
Normalize(#[source] std::io::Error),
|
||||
}
|
||||
@@ -548,12 +550,24 @@ impl Workspace {
|
||||
}
|
||||
|
||||
/// Returns the set of conflicts for the workspace.
|
||||
pub fn conflicts(&self) -> Conflicts {
|
||||
pub fn conflicts(&self) -> Result<Conflicts, WorkspaceError> {
|
||||
let mut conflicting = Conflicts::empty();
|
||||
if self.is_non_project() {
|
||||
if let Some(root_conflicts) = self
|
||||
.pyproject_toml
|
||||
.tool
|
||||
.as_ref()
|
||||
.and_then(|tool| tool.uv.as_ref())
|
||||
.and_then(|uv| uv.conflicts.as_ref())
|
||||
{
|
||||
let mut root_conflicts = root_conflicts.to_conflicts()?;
|
||||
conflicting.append(&mut root_conflicts);
|
||||
}
|
||||
}
|
||||
for member in self.packages.values() {
|
||||
conflicting.append(&mut member.pyproject_toml.conflicts());
|
||||
}
|
||||
conflicting
|
||||
Ok(conflicting)
|
||||
}
|
||||
|
||||
/// Returns an iterator over the `requires-python` values for each member of the workspace.
|
||||
|
||||
@@ -541,7 +541,7 @@ async fn do_lock(
|
||||
.collect::<Result<BTreeMap<_, _>, ProjectError>>()?;
|
||||
|
||||
// Collect the conflicts.
|
||||
let mut conflicts = target.conflicts();
|
||||
let mut conflicts = target.conflicts()?;
|
||||
if let LockTarget::Workspace(workspace) = target {
|
||||
if let Some(groups) = &workspace.pyproject_toml().dependency_groups {
|
||||
if let Some(project) = &workspace.pyproject_toml().project {
|
||||
|
||||
@@ -207,10 +207,10 @@ impl<'lock> LockTarget<'lock> {
|
||||
}
|
||||
|
||||
/// Returns the set of conflicts for the [`LockTarget`].
|
||||
pub(crate) fn conflicts(self) -> Conflicts {
|
||||
pub(crate) fn conflicts(self) -> Result<Conflicts, ProjectError> {
|
||||
match self {
|
||||
Self::Workspace(workspace) => workspace.conflicts(),
|
||||
Self::Script(_) => Conflicts::empty(),
|
||||
Self::Workspace(workspace) => Ok(workspace.conflicts()?),
|
||||
Self::Script(_) => Ok(Conflicts::empty()),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18317,6 +18317,226 @@ fn lock_non_project_sources() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Lock a non-project workspace root with conflicts declared between members.
|
||||
#[test]
|
||||
fn lock_non_project_member_conflicts() -> Result<()> {
|
||||
let context = uv_test::test_context!("3.12");
|
||||
|
||||
let pyproject_toml = context.temp_dir.child("pyproject.toml");
|
||||
pyproject_toml.write_str(
|
||||
r#"
|
||||
[tool.uv.workspace]
|
||||
members = ["member-a", "member-b"]
|
||||
"#,
|
||||
)?;
|
||||
|
||||
let member_a = context.temp_dir.child("member-a");
|
||||
member_a.create_dir_all()?;
|
||||
member_a.child("pyproject.toml").write_str(
|
||||
r#"
|
||||
[project]
|
||||
name = "member-a"
|
||||
version = "0.1.0"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = ["sortedcontainers==2.3.0"]
|
||||
|
||||
[build-system]
|
||||
requires = ["uv_build>=0.7,<10000"]
|
||||
build-backend = "uv_build"
|
||||
"#,
|
||||
)?;
|
||||
|
||||
let member_b = context.temp_dir.child("member-b");
|
||||
member_b.create_dir_all()?;
|
||||
member_b.child("pyproject.toml").write_str(
|
||||
r#"
|
||||
[project]
|
||||
name = "member-b"
|
||||
version = "0.1.0"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = ["sortedcontainers==2.4.0"]
|
||||
|
||||
[build-system]
|
||||
requires = ["uv_build>=0.7,<10000"]
|
||||
build-backend = "uv_build"
|
||||
"#,
|
||||
)?;
|
||||
|
||||
uv_snapshot!(context.filters(), context.lock(), @"
|
||||
success: false
|
||||
exit_code: 1
|
||||
----- stdout -----
|
||||
|
||||
----- stderr -----
|
||||
× No solution found when resolving dependencies:
|
||||
╰─▶ Because member-a depends on sortedcontainers==2.3.0 and member-b depends on sortedcontainers==2.4.0, we can conclude that member-a and member-b are incompatible.
|
||||
And because your workspace requires member-a and member-b, we can conclude that your workspace's requirements are unsatisfiable.
|
||||
");
|
||||
|
||||
pyproject_toml.write_str(
|
||||
r#"
|
||||
[tool.uv.workspace]
|
||||
members = ["member-a", "member-b"]
|
||||
|
||||
[tool.uv]
|
||||
conflicts = [
|
||||
[
|
||||
{ package = "member-a" },
|
||||
{ package = "member-b" },
|
||||
],
|
||||
]
|
||||
"#,
|
||||
)?;
|
||||
|
||||
uv_snapshot!(context.filters(), context.lock(), @"
|
||||
success: true
|
||||
exit_code: 0
|
||||
----- stdout -----
|
||||
|
||||
----- stderr -----
|
||||
warning: Declaring conflicts for packages (`package = ...`) is experimental and may change without warning. Pass `--preview-features package-conflicts` to disable this warning.
|
||||
Resolved 4 packages in [TIME]
|
||||
");
|
||||
|
||||
let lock = context.read("uv.lock");
|
||||
|
||||
insta::with_settings!({
|
||||
filters => context.filters(),
|
||||
}, {
|
||||
assert_snapshot!(
|
||||
lock, @r#"
|
||||
version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.12"
|
||||
conflicts = [[
|
||||
{ package = "member-a" },
|
||||
{ package = "member-b" },
|
||||
]]
|
||||
|
||||
[options]
|
||||
exclude-newer = "2024-03-25T00:00:00Z"
|
||||
|
||||
[manifest]
|
||||
members = [
|
||||
"member-a",
|
||||
"member-b",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "member-a"
|
||||
version = "0.1.0"
|
||||
source = { editable = "member-a" }
|
||||
dependencies = [
|
||||
{ name = "sortedcontainers", version = "2.3.0", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'project-8-member-a'" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [{ name = "sortedcontainers", specifier = "==2.3.0" }]
|
||||
|
||||
[[package]]
|
||||
name = "member-b"
|
||||
version = "0.1.0"
|
||||
source = { editable = "member-b" }
|
||||
dependencies = [
|
||||
{ name = "sortedcontainers", version = "2.4.0", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'project-8-member-b'" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [{ name = "sortedcontainers", specifier = "==2.4.0" }]
|
||||
|
||||
[[package]]
|
||||
name = "sortedcontainers"
|
||||
version = "2.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/14/10/6a9481890bae97da9edd6e737c9c3dec6aea3fc2fa53b0934037b35c89ea/sortedcontainers-2.3.0.tar.gz", hash = "sha256:59cc937650cf60d677c16775597c89a960658a09cf7c1a668f86e1e4464b10a1", size = 30509, upload-time = "2020-11-09T00:03:52.258Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/20/4d/a7046ae1a1a4cc4e9bbed194c387086f06b25038be596543d026946330c9/sortedcontainers-2.3.0-py2.py3-none-any.whl", hash = "sha256:37257a32add0a3ee490bb170b599e93095eed89a55da91fa9f48753ea12fd73f", size = 29479, upload-time = "2020-11-09T00:03:50.723Z" },
|
||||
]
|
||||
|
||||
[[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, upload-time = "2021-05-16T22:03:42.897Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" },
|
||||
]
|
||||
"#
|
||||
);
|
||||
});
|
||||
|
||||
uv_snapshot!(context.filters(), context.lock().arg("--locked"), @"
|
||||
success: true
|
||||
exit_code: 0
|
||||
----- stdout -----
|
||||
|
||||
----- stderr -----
|
||||
warning: Declaring conflicts for packages (`package = ...`) is experimental and may change without warning. Pass `--preview-features package-conflicts` to disable this warning.
|
||||
Resolved 4 packages in [TIME]
|
||||
");
|
||||
|
||||
uv_snapshot!(context.filters(), context.sync().arg("--frozen").arg("--all-packages"), @"
|
||||
success: false
|
||||
exit_code: 2
|
||||
----- stdout -----
|
||||
|
||||
----- stderr -----
|
||||
error: Package `member-a` and package `member-b` are incompatible with the declared conflicts: {member-a, member-b}
|
||||
");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Locking a non-project workspace root should reject conflicts that omit
|
||||
/// `package = ...`, since there is no root project name to infer.
|
||||
#[test]
|
||||
fn lock_non_project_member_conflicts_missing_package() -> Result<()> {
|
||||
let context = uv_test::test_context!("3.12");
|
||||
|
||||
let pyproject_toml = context.temp_dir.child("pyproject.toml");
|
||||
pyproject_toml.write_str(
|
||||
r#"
|
||||
[tool.uv.workspace]
|
||||
members = ["member-a"]
|
||||
|
||||
[tool.uv]
|
||||
conflicts = [
|
||||
[
|
||||
{ extra = "foo" },
|
||||
{ package = "member-a" },
|
||||
],
|
||||
]
|
||||
"#,
|
||||
)?;
|
||||
|
||||
let member_a = context.temp_dir.child("member-a");
|
||||
member_a.create_dir_all()?;
|
||||
member_a.child("pyproject.toml").write_str(
|
||||
r#"
|
||||
[project]
|
||||
name = "member-a"
|
||||
version = "0.1.0"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = []
|
||||
|
||||
[build-system]
|
||||
requires = ["uv_build>=0.7,<10000"]
|
||||
build-backend = "uv_build"
|
||||
"#,
|
||||
)?;
|
||||
|
||||
uv_snapshot!(context.filters(), context.lock(), @r#"
|
||||
success: false
|
||||
exit_code: 2
|
||||
----- stdout -----
|
||||
|
||||
----- stderr -----
|
||||
error: Expected `package` field in conflicting entry: { extra = "foo" }
|
||||
"#);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `coverage` defines a `toml` extra, but it doesn't enable any dependencies after Python 3.11.
|
||||
#[test]
|
||||
fn lock_dropped_dev_extra() -> Result<()> {
|
||||
|
||||
Reference in New Issue
Block a user