diff --git a/crates/uv-pypi-types/src/conflicting_groups.rs b/crates/uv-pypi-types/src/conflicting_groups.rs index 7ece61c163243..3002d33a9eed6 100644 --- a/crates/uv-pypi-types/src/conflicting_groups.rs +++ b/crates/uv-pypi-types/src/conflicting_groups.rs @@ -38,6 +38,12 @@ impl ConflictingGroupList { pub fn is_empty(&self) -> bool { self.0.is_empty() } + + /// Appends the given list to this one. This drains all elements + /// from the list given, such that after this call, it is empty. + pub fn append(&mut self, other: &mut ConflictingGroupList) { + self.0.append(&mut other.0); + } } /// A single set of package-extra pairs that conflict with one another. @@ -193,3 +199,103 @@ pub enum ConflictingGroupError { #[error("Each set of conflicting groups must have at least two entries, but found only one")] OneGroup, } + +/// Like [`ConflictingGroupList`], but for deserialization in `pyproject.toml`. +/// +/// The schema format is different from the in-memory format. Specifically, the +/// schema format does not allow specifying the package name (or will make it +/// optional in the future), where as the in-memory format needs the package +/// name. +/// +/// N.B. `ConflictingGroupList` is still used for (de)serialization. +/// Specifically, in the lock file, where the package name is required. +#[derive( + Debug, Default, Clone, Eq, PartialEq, serde::Deserialize, serde::Serialize, schemars::JsonSchema, +)] +pub struct SchemaConflictingGroupList(Vec); + +impl SchemaConflictingGroupList { + /// Convert the public schema "conflicting" type to our internal fully + /// resolved type. Effectively, this pairs the corresponding package name + /// with each conflict. + /// + /// If a conflict has an explicit package name (written by the end user), + /// then that takes precedence over the given package name, which is only + /// used when there is no explicit package name written. + pub fn to_conflicting_with_package_name(&self, package: &PackageName) -> ConflictingGroupList { + let mut conflicting = ConflictingGroupList::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(ConflictingGroup::from((package, item.extra.clone()))); + } + // OK because we guarantee that + // `SchemaConflictingGroupList` is valid and there aren't + // any new errors that can occur here. + let set = ConflictingGroups::try_from(set).unwrap(); + conflicting.push(set); + } + conflicting + } +} + +/// Like [`ConflictingGroups`], but for deserialization in `pyproject.toml`. +/// +/// The schema format is different from the in-memory format. Specifically, the +/// schema format does not allow specifying the package name (or will make it +/// optional in the future), where as the in-memory format needs the package +/// name. +#[derive(Debug, Default, Clone, Eq, PartialEq, serde::Serialize, schemars::JsonSchema)] +pub struct SchemaConflictingGroups(Vec); + +/// Like [`ConflictingGroup`], but for deserialization in `pyproject.toml`. +/// +/// The schema format is different from the in-memory format. Specifically, the +/// schema format does not allow specifying the package name (or will make it +/// optional in the future), where as the in-memory format needs the package +/// name. +#[derive( + Debug, + Default, + Clone, + Eq, + Hash, + PartialEq, + PartialOrd, + Ord, + serde::Deserialize, + serde::Serialize, + schemars::JsonSchema, +)] +#[serde(deny_unknown_fields)] +pub struct SchemaConflictingGroup { + #[serde(default)] + package: Option, + extra: ExtraName, +} + +impl<'de> serde::Deserialize<'de> for SchemaConflictingGroups { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let items = Vec::::deserialize(deserializer)?; + Self::try_from(items).map_err(serde::de::Error::custom) + } +} + +impl TryFrom> for SchemaConflictingGroups { + type Error = ConflictingGroupError; + + fn try_from( + items: Vec, + ) -> Result { + match items.len() { + 0 => return Err(ConflictingGroupError::ZeroGroups), + 1 => return Err(ConflictingGroupError::OneGroup), + _ => {} + } + Ok(SchemaConflictingGroups(items)) + } +} diff --git a/crates/uv-settings/src/combine.rs b/crates/uv-settings/src/combine.rs index efd98676d2502..f3cda5b9ea6bf 100644 --- a/crates/uv-settings/src/combine.rs +++ b/crates/uv-settings/src/combine.rs @@ -8,7 +8,7 @@ use uv_configuration::{ }; use uv_distribution_types::{Index, IndexUrl, PipExtraIndex, PipFindLinks, PipIndex}; use uv_install_wheel::linker::LinkMode; -use uv_pypi_types::{ConflictingGroupList, SupportedEnvironments}; +use uv_pypi_types::{SchemaConflictingGroupList, SupportedEnvironments}; use uv_python::{PythonDownloads, PythonPreference, PythonVersion}; use uv_resolver::{AnnotationStyle, ExcludeNewer, PrereleaseMode, ResolutionMode}; @@ -90,7 +90,7 @@ impl_combine_or!(PythonVersion); impl_combine_or!(ResolutionMode); impl_combine_or!(String); impl_combine_or!(SupportedEnvironments); -impl_combine_or!(ConflictingGroupList); +impl_combine_or!(SchemaConflictingGroupList); impl_combine_or!(TargetTriple); impl_combine_or!(TrustedPublishing); impl_combine_or!(Url); diff --git a/crates/uv-settings/src/settings.rs b/crates/uv-settings/src/settings.rs index 82f11758f1abd..470cce6bcc737 100644 --- a/crates/uv-settings/src/settings.rs +++ b/crates/uv-settings/src/settings.rs @@ -11,7 +11,7 @@ use uv_install_wheel::linker::LinkMode; use uv_macros::{CombineOptions, OptionsMetadata}; use uv_normalize::{ExtraName, PackageName}; use uv_pep508::Requirement; -use uv_pypi_types::{ConflictingGroupList, SupportedEnvironments, VerbatimParsedUrl}; +use uv_pypi_types::{SupportedEnvironments, VerbatimParsedUrl}; use uv_python::{PythonDownloads, PythonPreference, PythonVersion}; use uv_resolver::{AnnotationStyle, ExcludeNewer, PrereleaseMode, ResolutionMode}; @@ -97,12 +97,12 @@ pub struct Options { #[cfg_attr(feature = "schemars", schemars(skip))] pub environments: Option, - #[cfg_attr(feature = "schemars", schemars(skip))] - pub conflicting_groups: Option, - // NOTE(charlie): These fields should be kept in-sync with `ToolUv` in // `crates/uv-workspace/src/pyproject.rs`. The documentation lives on that struct. // They're only respected in `pyproject.toml` files, and should be rejected in `uv.toml` files. + #[cfg_attr(feature = "schemars", schemars(skip))] + pub conflicting_groups: Option, + #[cfg_attr(feature = "schemars", schemars(skip))] pub workspace: Option, @@ -1558,11 +1558,11 @@ pub struct OptionsWire { override_dependencies: Option>>, constraint_dependencies: Option>>, environments: Option, - conflicting_groups: Option, // NOTE(charlie): These fields should be kept in-sync with `ToolUv` in // `crates/uv-workspace/src/pyproject.rs`. The documentation lives on that struct. // They're only respected in `pyproject.toml` files, and should be rejected in `uv.toml` files. + conflicting_groups: Option, workspace: Option, sources: Option, managed: Option, diff --git a/crates/uv-workspace/src/pyproject.rs b/crates/uv-workspace/src/pyproject.rs index 3ee40d96872d0..12c8a4aed283e 100644 --- a/crates/uv-workspace/src/pyproject.rs +++ b/crates/uv-workspace/src/pyproject.rs @@ -25,7 +25,8 @@ use uv_normalize::{ExtraName, GroupName, PackageName}; use uv_pep440::{Version, VersionSpecifiers}; use uv_pep508::MarkerTree; use uv_pypi_types::{ - ConflictingGroupList, RequirementSource, SupportedEnvironments, VerbatimParsedUrl, + ConflictingGroupList, RequirementSource, SchemaConflictingGroupList, SupportedEnvironments, + VerbatimParsedUrl, }; #[derive(Error, Debug)] @@ -100,6 +101,24 @@ impl PyProjectToml { false } } + + /// Returns the set of conflicts for the project. + pub fn conflicting_groups(&self) -> ConflictingGroupList { + let empty = ConflictingGroupList::empty(); + let Some(project) = self.project.as_ref() else { + return empty; + }; + let Some(tool) = self.tool.as_ref() else { + return empty; + }; + let Some(tooluv) = tool.uv.as_ref() else { + return empty; + }; + let Some(conflicting) = tooluv.conflicting_groups.as_ref() else { + return empty; + }; + conflicting.to_conflicting_with_package_name(&project.name) + } } // Ignore raw document in comparison. @@ -480,7 +499,7 @@ pub struct ToolUv { ] "# )] - pub conflicting_groups: Option, + pub conflicting_groups: Option, } #[derive(Default, Debug, Clone, PartialEq, Eq)] diff --git a/crates/uv-workspace/src/workspace.rs b/crates/uv-workspace/src/workspace.rs index 4dacb6316951f..9463ef01e2cde 100644 --- a/crates/uv-workspace/src/workspace.rs +++ b/crates/uv-workspace/src/workspace.rs @@ -392,14 +392,13 @@ impl Workspace { .and_then(|uv| uv.environments.as_ref()) } - /// Returns the set of supported environments for the workspace. + /// Returns the set of conflicts for the workspace. pub fn conflicting_groups(&self) -> ConflictingGroupList { - self.pyproject_toml - .tool - .as_ref() - .and_then(|tool| tool.uv.as_ref()) - .and_then(|uv| uv.conflicting_groups.clone()) - .unwrap_or_else(ConflictingGroupList::empty) + let mut conflicting = ConflictingGroupList::empty(); + for member in self.packages.values() { + conflicting.append(&mut member.pyproject_toml.conflicting_groups()); + } + conflicting } /// Returns the set of constraints for the workspace. diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index d075a724ed9ba..52607d5850682 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -24,6 +24,7 @@ use uv_cli::{PythonCommand, PythonNamespace, ToolCommand, ToolNamespace, TopLeve #[cfg(feature = "self-update")] use uv_cli::{SelfCommand, SelfNamespace, SelfUpdateArgs}; use uv_fs::CWD; +use uv_pypi_types::ConflictingGroupList; use uv_requirements::RequirementsSource; use uv_scripts::{Pep723Item, Pep723Metadata, Pep723Script}; use uv_settings::{Combine, FilesystemOptions, Options}; @@ -332,7 +333,7 @@ async fn run(mut cli: Cli) -> Result { args.constraints_from_workspace, args.overrides_from_workspace, args.environments, - args.conflicting_groups, + ConflictingGroupList::empty(), args.settings.extras, args.settings.output_file.as_deref(), args.settings.resolution, diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index cdebd69bbcbb3..1ea29d8172fd0 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -29,7 +29,7 @@ use uv_distribution_types::{DependencyMetadata, Index, IndexLocations, IndexUrl} use uv_install_wheel::linker::LinkMode; use uv_normalize::PackageName; use uv_pep508::{ExtraName, RequirementOrigin}; -use uv_pypi_types::{ConflictingGroupList, Requirement, SupportedEnvironments}; +use uv_pypi_types::{Requirement, SupportedEnvironments}; use uv_python::{Prefix, PythonDownloads, PythonPreference, PythonVersion, Target}; use uv_resolver::{AnnotationStyle, DependencyMode, ExcludeNewer, PrereleaseMode, ResolutionMode}; use uv_settings::{ @@ -1240,7 +1240,6 @@ pub(crate) struct PipCompileSettings { pub(crate) constraints_from_workspace: Vec, pub(crate) overrides_from_workspace: Vec, pub(crate) environments: SupportedEnvironments, - pub(crate) conflicting_groups: ConflictingGroupList, pub(crate) refresh: Refresh, pub(crate) settings: PipSettings, } @@ -1332,12 +1331,6 @@ impl PipCompileSettings { SupportedEnvironments::default() }; - let conflicting_groups = if let Some(configuration) = &filesystem { - configuration.conflicting_groups.clone().unwrap_or_default() - } else { - ConflictingGroupList::empty() - }; - Self { src_file, constraint: constraint @@ -1355,7 +1348,6 @@ impl PipCompileSettings { constraints_from_workspace, overrides_from_workspace, environments, - conflicting_groups, refresh: Refresh::from(refresh), settings: PipSettings::combine( PipOptions { diff --git a/crates/uv/tests/it/lock.rs b/crates/uv/tests/it/lock.rs index 37b5d0727bd5e..7dbb846ea5830 100644 --- a/crates/uv/tests/it/lock.rs +++ b/crates/uv/tests/it/lock.rs @@ -2224,8 +2224,8 @@ fn lock_conflicting_extra_basic() -> Result<()> { [tool.uv] conflicting-groups = [ [ - { package = "project", extra = "project1" }, - { package = "project", extra = "project2" }, + { extra = "project1" }, + { extra = "project2" }, ], ] @@ -2423,9 +2423,9 @@ fn lock_conflicting_extra_basic_three_extras() -> Result<()> { [tool.uv] conflicting-groups = [ [ - { package = "project", extra = "project1" }, - { package = "project", extra = "project2" }, - { package = "project", extra = "project3" }, + { extra = "project1" }, + { extra = "project2" }, + { extra = "project3" }, ], ] @@ -2549,12 +2549,12 @@ fn lock_conflicting_extra_multiple_not_conflicting1() -> Result<()> { [tool.uv] conflicting-groups = [ [ - { package = "project", extra = "project1" }, - { package = "project", extra = "project2" }, + { extra = "project1" }, + { extra = "project2" }, ], [ - { package = "project", extra = "project3" }, - { package = "project", extra = "project4" }, + { extra = "project3" }, + { extra = "project4" }, ], ] @@ -2721,12 +2721,12 @@ fn lock_conflicting_extra_multiple_not_conflicting2() -> Result<()> { [tool.uv] conflicting-groups = [ [ - { package = "project", extra = "project1" }, - { package = "project", extra = "project2" }, + { extra = "project1" }, + { extra = "project2" }, ], [ - { package = "project", extra = "project3" }, - { package = "project", extra = "project4" }, + { extra = "project3" }, + { extra = "project4" }, ], ] @@ -2768,20 +2768,20 @@ fn lock_conflicting_extra_multiple_not_conflicting2() -> Result<()> { [tool.uv] conflicting-groups = [ [ - { package = "project", extra = "project1" }, - { package = "project", extra = "project2" }, + { extra = "project1" }, + { extra = "project2" }, ], [ - { package = "project", extra = "project3" }, - { package = "project", extra = "project4" }, + { extra = "project3" }, + { extra = "project4" }, ], [ - { package = "project", extra = "project1" }, - { package = "project", extra = "project4" }, + { extra = "project1" }, + { extra = "project4" }, ], [ - { package = "project", extra = "project2" }, - { package = "project", extra = "project3" }, + { extra = "project2" }, + { extra = "project3" }, ], ] @@ -2819,10 +2819,10 @@ fn lock_conflicting_extra_multiple_not_conflicting2() -> Result<()> { [tool.uv] conflicting-groups = [ [ - { package = "project", extra = "project1" }, - { package = "project", extra = "project2" }, - { package = "project", extra = "project3" }, - { package = "project", extra = "project4" }, + { extra = "project1" }, + { extra = "project2" }, + { extra = "project3" }, + { extra = "project4" }, ], ] @@ -2902,8 +2902,8 @@ fn lock_conflicting_extra_multiple_independent() -> Result<()> { [tool.uv] conflicting-groups = [ [ - { package = "project", extra = "project3" }, - { package = "project", extra = "project4" }, + { extra = "project3" }, + { extra = "project4" }, ], ] @@ -2942,12 +2942,12 @@ fn lock_conflicting_extra_multiple_independent() -> Result<()> { [tool.uv] conflicting-groups = [ [ - { package = "project", extra = "project1" }, - { package = "project", extra = "project2" }, + { extra = "project1" }, + { extra = "project2" }, ], [ - { package = "project", extra = "project3" }, - { package = "project", extra = "project4" }, + { extra = "project3" }, + { extra = "project4" }, ], ] @@ -3113,8 +3113,8 @@ fn lock_conflicting_extra_config_change_ignore_lockfile() -> Result<()> { [tool.uv] conflicting-groups = [ [ - { package = "project", extra = "project1" }, - { package = "project", extra = "project2" }, + { extra = "project1" }, + { extra = "project2" }, ], ] @@ -3260,21 +3260,15 @@ fn lock_conflicting_extra_unconditional() -> Result<()> { "proxy1[project1,project2]" ] - [tool.uv] - conflicting-groups = [ - [ - { package = "proxy1", extra = "project1" }, - { package = "proxy1", extra = "project2" }, - ], - ] + [tool.uv.workspace] + members = ["proxy1"] [tool.uv.sources] - proxy1 = { path = "./proxy1" } + proxy1 = { workspace = true } [build-system] requires = ["hatchling"] build-backend = "hatchling.build" - "#, )?; @@ -3290,6 +3284,14 @@ fn lock_conflicting_extra_unconditional() -> Result<()> { [project.optional-dependencies] project1 = ["anyio==4.1.0"] project2 = ["anyio==4.2.0"] + + [tool.uv] + conflicting-groups = [ + [ + { extra = "project1" }, + { extra = "project2" }, + ], + ] "#, )?; @@ -3313,21 +3315,15 @@ fn lock_conflicting_extra_unconditional() -> Result<()> { "proxy1[project1]" ] - [tool.uv] - conflicting-groups = [ - [ - { package = "proxy1", extra = "project1" }, - { package = "proxy1", extra = "project2" }, - ], - ] + [tool.uv.workspace] + members = ["proxy1"] [tool.uv.sources] - proxy1 = { path = "./proxy1" } + proxy1 = { workspace = true } [build-system] requires = ["hatchling"] build-backend = "hatchling.build" - "#, )?; uv_snapshot!(context.filters(), context.lock(), @r###" @@ -3350,16 +3346,11 @@ fn lock_conflicting_extra_unconditional() -> Result<()> { "proxy1[project2]" ] - [tool.uv] - conflicting-groups = [ - [ - { package = "proxy1", extra = "project1" }, - { package = "proxy1", extra = "project2" }, - ], - ] + [tool.uv.workspace] + members = ["proxy1"] [tool.uv.sources] - proxy1 = { path = "./proxy1" } + proxy1 = { workspace = true } [build-system] requires = ["hatchling"] diff --git a/uv.schema.json b/uv.schema.json index 91a9b11b8351a..77e66fd407531 100644 --- a/uv.schema.json +++ b/uv.schema.json @@ -1313,6 +1313,33 @@ } ] }, + "SchemaConflictingGroup": { + "description": "Like [`ConflictingGroup`], but for deserialization in `pyproject.toml`.\n\nThe schema format is different from the in-memory format. Specifically, the schema format does not allow specifying the package name (or will make it optional in the future), where as the in-memory format needs the package name.", + "type": "object", + "required": [ + "extra" + ], + "properties": { + "extra": { + "$ref": "#/definitions/ExtraName" + } + }, + "additionalProperties": false + }, + "SchemaConflictingGroupList": { + "description": "Like [`ConflictingGroupList`], but for deserialization in `pyproject.toml`.\n\nThe schema format is different from the in-memory format. Specifically, the schema format does not allow specifying the package name (or will make it optional in the future), where as the in-memory format needs the package name.\n\nN.B. `ConflictingGroupList` is still used for (de)serialization. Specifically, in the lock file, where the package name is required.", + "type": "array", + "items": { + "$ref": "#/definitions/SchemaConflictingGroups" + } + }, + "SchemaConflictingGroups": { + "description": "Like [`ConflictingGroups`], but for deserialization in `pyproject.toml`.\n\nThe schema format is different from the in-memory format. Specifically, the schema format does not allow specifying the package name (or will make it optional in the future), where as the in-memory format needs the package name.", + "type": "array", + "items": { + "$ref": "#/definitions/SchemaConflictingGroup" + } + }, "Source": { "description": "A `tool.uv.sources` value.", "anyOf": [