Skip to content

Commit

Permalink
Error when tool.uv.sources contains duplicate package names (#7383)
Browse files Browse the repository at this point in the history
## Summary

Closes #7229.
  • Loading branch information
charliermarsh authored Sep 14, 2024
1 parent 211fa91 commit 083ec2f
Show file tree
Hide file tree
Showing 5 changed files with 153 additions and 12 deletions.
7 changes: 4 additions & 3 deletions crates/uv-distribution/src/metadata/requires_dist.rs
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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 = {
Expand Down
61 changes: 60 additions & 1 deletion crates/uv-workspace/src/pyproject.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<BTreeMap<PackageName, Source>>,
pub sources: Option<ToolUvSources>,
/// The workspace definition for the project, if any.
#[option_group]
pub workspace: Option<ToolUvWorkspace>,
Expand Down Expand Up @@ -245,6 +245,65 @@ pub struct ToolUv {
pub constraint_dependencies: Option<Vec<pep508_rs::Requirement<VerbatimParsedUrl>>>,
}

#[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<PackageName, Source>);

impl ToolUvSources {
/// Returns the underlying `BTreeMap` of package names to sources.
pub fn inner(&self) -> &BTreeMap<PackageName, Source> {
&self.0
}

/// Convert the [`ToolUvSources`] into its inner `BTreeMap`.
#[must_use]
pub fn into_inner(self) -> BTreeMap<PackageName, Source> {
self.0
}
}

/// Ensure that all keys in the TOML table are unique.
impl<'de> serde::de::Deserialize<'de> for ToolUvSources {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
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<M>(self, mut access: M) -> Result<Self::Value, M::Error>
where
M: serde::de::MapAccess<'de>,
{
let mut sources = BTreeMap::new();
while let Some((key, value)) = access.next_entry::<PackageName, Source>()? {
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)]
Expand Down
4 changes: 3 additions & 1 deletion crates/uv-workspace/src/workspace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down
72 changes: 72 additions & 0 deletions crates/uv/tests/lock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(())
}
21 changes: 14 additions & 7 deletions uv.schema.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit 083ec2f

Please sign in to comment.