Skip to content

Commit 6567125

Browse files
committed
Error when tool.uv.sources contains duplicate package names
1 parent 54f171c commit 6567125

File tree

5 files changed

+153
-12
lines changed

5 files changed

+153
-12
lines changed

crates/uv-distribution/src/metadata/requires_dist.rs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
use std::collections::BTreeMap;
22
use std::path::Path;
33

4+
use crate::metadata::{LoweredRequirement, MetadataError};
5+
use crate::Metadata;
46
use uv_configuration::SourceStrategy;
57
use uv_normalize::{ExtraName, GroupName, PackageName, DEV_DEPENDENCIES};
8+
use uv_workspace::pyproject::ToolUvSources;
69
use uv_workspace::{DiscoveryOptions, ProjectWorkspace};
710

8-
use crate::metadata::{LoweredRequirement, MetadataError};
9-
use crate::Metadata;
10-
1111
#[derive(Debug, Clone)]
1212
pub struct RequiresDist {
1313
pub name: PackageName,
@@ -71,6 +71,7 @@ impl RequiresDist {
7171
.as_ref()
7272
.and_then(|tool| tool.uv.as_ref())
7373
.and_then(|uv| uv.sources.as_ref())
74+
.map(ToolUvSources::inner)
7475
.unwrap_or(&empty);
7576

7677
let dev_dependencies = {

crates/uv-workspace/src/pyproject.rs

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ pub struct Tool {
109109
pub struct ToolUv {
110110
/// The sources to use (e.g., workspace members, Git repositories, local paths) when resolving
111111
/// dependencies.
112-
pub sources: Option<BTreeMap<PackageName, Source>>,
112+
pub sources: Option<ToolUvSources>,
113113
/// The workspace definition for the project, if any.
114114
#[option_group]
115115
pub workspace: Option<ToolUvWorkspace>,
@@ -245,6 +245,65 @@ pub struct ToolUv {
245245
pub constraint_dependencies: Option<Vec<pep508_rs::Requirement<VerbatimParsedUrl>>>,
246246
}
247247

248+
#[derive(Serialize, Default, Debug, Clone, PartialEq, Eq)]
249+
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
250+
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
251+
pub struct ToolUvSources(BTreeMap<PackageName, Source>);
252+
253+
impl ToolUvSources {
254+
/// Returns the underlying `BTreeMap` of package names to sources.
255+
pub fn inner(&self) -> &BTreeMap<PackageName, Source> {
256+
&self.0
257+
}
258+
259+
/// Convert the [`ToolUvSources`] into its inner `BTreeMap`.
260+
#[must_use]
261+
pub fn into_inner(self) -> BTreeMap<PackageName, Source> {
262+
self.0
263+
}
264+
}
265+
266+
/// Ensure that all keys in the TOML table are unique.
267+
impl<'de> serde::de::Deserialize<'de> for ToolUvSources {
268+
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
269+
where
270+
D: serde::de::Deserializer<'de>,
271+
{
272+
struct SourcesVisitor;
273+
274+
impl<'de> serde::de::Visitor<'de> for SourcesVisitor {
275+
type Value = ToolUvSources;
276+
277+
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
278+
formatter.write_str("a map with unique keys")
279+
}
280+
281+
fn visit_map<M>(self, mut access: M) -> Result<Self::Value, M::Error>
282+
where
283+
M: serde::de::MapAccess<'de>,
284+
{
285+
let mut sources = BTreeMap::new();
286+
while let Some((key, value)) = access.next_entry::<PackageName, Source>()? {
287+
match sources.entry(key) {
288+
std::collections::btree_map::Entry::Occupied(entry) => {
289+
return Err(serde::de::Error::custom(format!(
290+
"duplicate sources for package `{}`",
291+
entry.key()
292+
)));
293+
}
294+
std::collections::btree_map::Entry::Vacant(entry) => {
295+
entry.insert(value);
296+
}
297+
}
298+
}
299+
Ok(ToolUvSources(sources))
300+
}
301+
}
302+
303+
deserializer.deserialize_map(SourcesVisitor)
304+
}
305+
}
306+
248307
#[derive(Serialize, Deserialize, OptionsMetadata, Default, Debug, Clone, PartialEq, Eq)]
249308
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
250309
#[serde(rename_all = "kebab-case", deny_unknown_fields)]

crates/uv-workspace/src/workspace.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ use uv_fs::{Simplified, CWD};
1414
use uv_normalize::{GroupName, PackageName, DEV_DEPENDENCIES};
1515
use uv_warnings::{warn_user, warn_user_once};
1616

17-
use crate::pyproject::{Project, PyProjectToml, Source, ToolUvWorkspace};
17+
use crate::pyproject::{Project, PyProjectToml, Source, ToolUvSources, ToolUvWorkspace};
1818

1919
#[derive(thiserror::Error, Debug)]
2020
pub enum WorkspaceError {
@@ -234,6 +234,7 @@ impl Workspace {
234234
.clone()
235235
.and_then(|tool| tool.uv)
236236
.and_then(|uv| uv.sources)
237+
.map(ToolUvSources::into_inner)
237238
.unwrap_or_default();
238239

239240
// Set the `pyproject.toml` for the member.
@@ -741,6 +742,7 @@ impl Workspace {
741742
.clone()
742743
.and_then(|tool| tool.uv)
743744
.and_then(|uv| uv.sources)
745+
.map(ToolUvSources::into_inner)
744746
.unwrap_or_default();
745747

746748
Ok(Workspace {

crates/uv/tests/lock.rs

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12628,3 +12628,75 @@ fn lock_request_requires_python() -> Result<()> {
1262812628

1262912629
Ok(())
1263012630
}
12631+
12632+
#[test]
12633+
fn lock_duplicate_sources() -> Result<()> {
12634+
let context = TestContext::new("3.12");
12635+
12636+
let pyproject_toml = context.temp_dir.child("pyproject.toml");
12637+
pyproject_toml.write_str(
12638+
r#"
12639+
[project]
12640+
name = "projeect"
12641+
version = "0.1.0"
12642+
dependencies = ["python-multipart"]
12643+
12644+
[tool.uv.sources]
12645+
python-multipart = { url = "https://files.pythonhosted.org/packages/3d/47/444768600d9e0ebc82f8e347775d24aef8f6348cf00e9fa0e81910814e6d/python_multipart-0.0.9-py3-none-any.whl" }
12646+
python-multipart = { url = "https://files.pythonhosted.org/packages/c0/3e/9fbfd74e7f5b54f653f7ca99d44ceb56e718846920162165061c4c22b71a/python_multipart-0.0.8-py3-none-any.whl" }
12647+
"#,
12648+
)?;
12649+
12650+
uv_snapshot!(context.filters(), context.lock(), @r###"
12651+
success: false
12652+
exit_code: 2
12653+
----- stdout -----
12654+
12655+
----- stderr -----
12656+
warning: Failed to parse `pyproject.toml` during settings discovery:
12657+
TOML parse error at line 9, column 9
12658+
|
12659+
9 | python-multipart = { url = "https://files.pythonhosted.org/packages/c0/3e/9fbfd74e7f5b54f653f7ca99d44ceb56e718846920162165061c4c22b71a/python_multipart-0.0.8-py3-none-any.whl" }
12660+
| ^
12661+
duplicate key `python-multipart` in table `tool.uv.sources`
12662+
12663+
error: Failed to parse: `pyproject.toml`
12664+
Caused by: TOML parse error at line 9, column 9
12665+
|
12666+
9 | python-multipart = { url = "https://files.pythonhosted.org/packages/c0/3e/9fbfd74e7f5b54f653f7ca99d44ceb56e718846920162165061c4c22b71a/python_multipart-0.0.8-py3-none-any.whl" }
12667+
| ^
12668+
duplicate key `python-multipart` in table `tool.uv.sources`
12669+
12670+
"###);
12671+
12672+
let pyproject_toml = context.temp_dir.child("pyproject.toml");
12673+
pyproject_toml.write_str(
12674+
r#"
12675+
[project]
12676+
name = "project"
12677+
version = "0.1.0"
12678+
dependencies = ["python-multipart"]
12679+
12680+
[tool.uv.sources]
12681+
python-multipart = { url = "https://files.pythonhosted.org/packages/3d/47/444768600d9e0ebc82f8e347775d24aef8f6348cf00e9fa0e81910814e6d/python_multipart-0.0.9-py3-none-any.whl" }
12682+
python_multipart = { url = "https://files.pythonhosted.org/packages/c0/3e/9fbfd74e7f5b54f653f7ca99d44ceb56e718846920162165061c4c22b71a/python_multipart-0.0.8-py3-none-any.whl" }
12683+
"#,
12684+
)?;
12685+
12686+
uv_snapshot!(context.filters(), context.lock(), @r###"
12687+
success: false
12688+
exit_code: 2
12689+
----- stdout -----
12690+
12691+
----- stderr -----
12692+
error: Failed to parse: `pyproject.toml`
12693+
Caused by: TOML parse error at line 7, column 9
12694+
|
12695+
7 | [tool.uv.sources]
12696+
| ^^^^^^^^^^^^^^^^^
12697+
duplicate sources for package `python-multipart`
12698+
12699+
"###);
12700+
12701+
Ok(())
12702+
}

uv.schema.json

Lines changed: 14 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)