diff --git a/crates/uv-distribution/src/metadata/requires_dist.rs b/crates/uv-distribution/src/metadata/requires_dist.rs index 4e1ce9eb52b5..568e2e73d288 100644 --- a/crates/uv-distribution/src/metadata/requires_dist.rs +++ b/crates/uv-distribution/src/metadata/requires_dist.rs @@ -1,13 +1,13 @@ use std::collections::BTreeMap; use std::path::Path; +use crate::metadata::{LoweredRequirement, MetadataError}; +use crate::Metadata; use uv_configuration::SourceStrategy; use uv_normalize::{ExtraName, GroupName, PackageName, DEV_DEPENDENCIES}; +use uv_workspace::pyproject::ToolUvSources; use uv_workspace::{DiscoveryOptions, ProjectWorkspace}; -use crate::metadata::{LoweredRequirement, MetadataError}; -use crate::Metadata; - #[derive(Debug, Clone)] pub struct RequiresDist { pub name: PackageName, @@ -71,6 +71,7 @@ impl RequiresDist { .as_ref() .and_then(|tool| tool.uv.as_ref()) .and_then(|uv| uv.sources.as_ref()) + .map(ToolUvSources::inner) .unwrap_or(&empty); let dev_dependencies = { diff --git a/crates/uv-workspace/src/pyproject.rs b/crates/uv-workspace/src/pyproject.rs index 6cc0d2a0c18c..a40332ed6abc 100644 --- a/crates/uv-workspace/src/pyproject.rs +++ b/crates/uv-workspace/src/pyproject.rs @@ -109,7 +109,7 @@ pub struct Tool { pub struct ToolUv { /// The sources to use (e.g., workspace members, Git repositories, local paths) when resolving /// dependencies. - pub sources: Option>, + pub sources: Option, /// The workspace definition for the project, if any. #[option_group] pub workspace: Option, @@ -245,6 +245,65 @@ pub struct ToolUv { pub constraint_dependencies: Option>>, } +#[derive(Serialize, Default, Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +pub struct ToolUvSources(BTreeMap); + +impl ToolUvSources { + /// Returns the underlying `BTreeMap` of package names to sources. + pub fn inner(&self) -> &BTreeMap { + &self.0 + } + + /// Convert the [`ToolUvSources`] into its inner `BTreeMap`. + #[must_use] + pub fn into_inner(self) -> BTreeMap { + self.0 + } +} + +/// Ensure that all keys in the TOML table are unique. +impl<'de> serde::de::Deserialize<'de> for ToolUvSources { + fn deserialize(deserializer: D) -> Result + where + D: serde::de::Deserializer<'de>, + { + struct SourcesVisitor; + + impl<'de> serde::de::Visitor<'de> for SourcesVisitor { + type Value = ToolUvSources; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("a map with unique keys") + } + + fn visit_map(self, mut access: M) -> Result + where + M: serde::de::MapAccess<'de>, + { + let mut sources = BTreeMap::new(); + while let Some((key, value)) = access.next_entry::()? { + match sources.entry(key) { + std::collections::btree_map::Entry::Occupied(entry) => { + return Err(serde::de::Error::custom(format!( + "duplicate sources for package `{}`", + entry.key() + ))); + } + std::collections::btree_map::Entry::Vacant(entry) => { + entry.insert(value); + } + } + } + Ok(ToolUvSources(sources)) + } + } + + deserializer.deserialize_map(SourcesVisitor) + } +} + #[derive(Serialize, Deserialize, OptionsMetadata, Default, Debug, Clone, PartialEq, Eq)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[serde(rename_all = "kebab-case", deny_unknown_fields)] diff --git a/crates/uv-workspace/src/workspace.rs b/crates/uv-workspace/src/workspace.rs index e8e1a0871732..10548c964982 100644 --- a/crates/uv-workspace/src/workspace.rs +++ b/crates/uv-workspace/src/workspace.rs @@ -14,7 +14,7 @@ use uv_fs::{Simplified, CWD}; use uv_normalize::{GroupName, PackageName, DEV_DEPENDENCIES}; use uv_warnings::{warn_user, warn_user_once}; -use crate::pyproject::{Project, PyProjectToml, Source, ToolUvWorkspace}; +use crate::pyproject::{Project, PyProjectToml, Source, ToolUvSources, ToolUvWorkspace}; #[derive(thiserror::Error, Debug)] pub enum WorkspaceError { @@ -234,6 +234,7 @@ impl Workspace { .clone() .and_then(|tool| tool.uv) .and_then(|uv| uv.sources) + .map(ToolUvSources::into_inner) .unwrap_or_default(); // Set the `pyproject.toml` for the member. @@ -741,6 +742,7 @@ impl Workspace { .clone() .and_then(|tool| tool.uv) .and_then(|uv| uv.sources) + .map(ToolUvSources::into_inner) .unwrap_or_default(); Ok(Workspace { diff --git a/crates/uv/tests/lock.rs b/crates/uv/tests/lock.rs index aaebf89f7692..4efbd128e089 100644 --- a/crates/uv/tests/lock.rs +++ b/crates/uv/tests/lock.rs @@ -12628,3 +12628,75 @@ fn lock_request_requires_python() -> Result<()> { Ok(()) } + +#[test] +fn lock_duplicate_sources() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "projeect" + version = "0.1.0" + dependencies = ["python-multipart"] + + [tool.uv.sources] + python-multipart = { url = "https://files.pythonhosted.org/packages/3d/47/444768600d9e0ebc82f8e347775d24aef8f6348cf00e9fa0e81910814e6d/python_multipart-0.0.9-py3-none-any.whl" } + python-multipart = { url = "https://files.pythonhosted.org/packages/c0/3e/9fbfd74e7f5b54f653f7ca99d44ceb56e718846920162165061c4c22b71a/python_multipart-0.0.8-py3-none-any.whl" } + "#, + )?; + + uv_snapshot!(context.filters(), context.lock(), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + warning: Failed to parse `pyproject.toml` during settings discovery: + TOML parse error at line 9, column 9 + | + 9 | python-multipart = { url = "https://files.pythonhosted.org/packages/c0/3e/9fbfd74e7f5b54f653f7ca99d44ceb56e718846920162165061c4c22b71a/python_multipart-0.0.8-py3-none-any.whl" } + | ^ + duplicate key `python-multipart` in table `tool.uv.sources` + + error: Failed to parse: `pyproject.toml` + Caused by: TOML parse error at line 9, column 9 + | + 9 | python-multipart = { url = "https://files.pythonhosted.org/packages/c0/3e/9fbfd74e7f5b54f653f7ca99d44ceb56e718846920162165061c4c22b71a/python_multipart-0.0.8-py3-none-any.whl" } + | ^ + duplicate key `python-multipart` in table `tool.uv.sources` + + "###); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + dependencies = ["python-multipart"] + + [tool.uv.sources] + python-multipart = { url = "https://files.pythonhosted.org/packages/3d/47/444768600d9e0ebc82f8e347775d24aef8f6348cf00e9fa0e81910814e6d/python_multipart-0.0.9-py3-none-any.whl" } + python_multipart = { url = "https://files.pythonhosted.org/packages/c0/3e/9fbfd74e7f5b54f653f7ca99d44ceb56e718846920162165061c4c22b71a/python_multipart-0.0.8-py3-none-any.whl" } + "#, + )?; + + 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 7, column 9 + | + 7 | [tool.uv.sources] + | ^^^^^^^^^^^^^^^^^ + duplicate sources for package `python-multipart` + + "###); + + Ok(()) +} diff --git a/uv.schema.json b/uv.schema.json index f28b74468ab5..50d564251079 100644 --- a/uv.schema.json +++ b/uv.schema.json @@ -372,13 +372,14 @@ }, "sources": { "description": "The sources to use (e.g., workspace members, Git repositories, local paths) when resolving dependencies.", - "type": [ - "object", - "null" - ], - "additionalProperties": { - "$ref": "#/definitions/Source" - } + "anyOf": [ + { + "$ref": "#/definitions/ToolUvSources" + }, + { + "type": "null" + } + ] }, "upgrade": { "description": "Allow package upgrades, ignoring pinned versions in any existing output file.", @@ -1473,6 +1474,12 @@ } ] }, + "ToolUvSources": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/Source" + } + }, "ToolUvWorkspace": { "type": "object", "properties": {