From fa058cd5d21916de9a657e762d87123fd4004433 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Sun, 15 Dec 2024 09:16:39 -0500 Subject: [PATCH] Show a concise error message for missing version field --- crates/uv-pypi-types/src/metadata/mod.rs | 6 +- .../src/metadata/pyproject_toml.rs | 61 +++++++++++++---- crates/uv-workspace/src/pyproject.rs | 54 +++++++++++---- crates/uv/tests/it/lock.rs | 66 ++++++++++++++++++- crates/uv/tests/it/run.rs | 4 +- 5 files changed, 159 insertions(+), 32 deletions(-) diff --git a/crates/uv-pypi-types/src/metadata/mod.rs b/crates/uv-pypi-types/src/metadata/mod.rs index 771c463aeeb52..396019a114975 100644 --- a/crates/uv-pypi-types/src/metadata/mod.rs +++ b/crates/uv-pypi-types/src/metadata/mod.rs @@ -34,8 +34,10 @@ pub enum MetadataError { MailParse(#[from] MailParseError), #[error("Invalid `pyproject.toml`")] InvalidPyprojectTomlSyntax(#[source] toml_edit::TomlError), - #[error("`pyproject.toml` is using the `[project]` table, but the required `project.name` field is not set.")] - InvalidPyprojectTomlMissingName(#[source] toml_edit::de::Error), + #[error("`pyproject.toml` is using the `[project]` table, but the required `project.name` field is not set")] + InvalidPyprojectTomlMissingName, + #[error("`pyproject.toml` is using the `[project]` table, but the required `project.version` field is neither set nor present in the `project.dynamic` list")] + InvalidPyprojectTomlMissingVersion, #[error(transparent)] InvalidPyprojectTomlSchema(toml_edit::de::Error), #[error("Metadata field {0} not found")] diff --git a/crates/uv-pypi-types/src/metadata/pyproject_toml.rs b/crates/uv-pypi-types/src/metadata/pyproject_toml.rs index 3a6201aa7adc6..e197422d4c769 100644 --- a/crates/uv-pypi-types/src/metadata/pyproject_toml.rs +++ b/crates/uv-pypi-types/src/metadata/pyproject_toml.rs @@ -1,16 +1,19 @@ -use crate::{ - LenientRequirement, LenientVersionSpecifiers, MetadataError, ResolutionMetadata, - VerbatimParsedUrl, -}; +use std::str::FromStr; + use indexmap::IndexMap; use itertools::Itertools; use serde::de::IntoDeserializer; use serde::{Deserialize, Serialize}; -use std::str::FromStr; + use uv_normalize::{ExtraName, PackageName}; use uv_pep440::{Version, VersionSpecifiers}; use uv_pep508::Requirement; +use crate::{ + LenientRequirement, LenientVersionSpecifiers, MetadataError, ResolutionMetadata, + VerbatimParsedUrl, +}; + /// Extract the metadata from a `pyproject.toml` file, as specified in PEP 621. /// /// If we're coming from a source distribution, we may already know the version (unlike for a source @@ -112,14 +115,7 @@ impl PyProjectToml { let pyproject_toml: toml_edit::ImDocument<_> = toml_edit::ImDocument::from_str(toml) .map_err(MetadataError::InvalidPyprojectTomlSyntax)?; let pyproject_toml: Self = PyProjectToml::deserialize(pyproject_toml.into_deserializer()) - .map_err(|err| { - // TODO(konsti): A typed error would be nicer, this can break on toml upgrades. - if err.message().contains("missing field `name`") { - MetadataError::InvalidPyprojectTomlMissingName(err) - } else { - MetadataError::InvalidPyprojectTomlSchema(err) - } - })?; + .map_err(MetadataError::InvalidPyprojectTomlSchema)?; Ok(pyproject_toml) } } @@ -131,7 +127,7 @@ impl PyProjectToml { /// /// See . #[derive(Deserialize, Debug)] -#[serde(rename_all = "kebab-case")] +#[serde(try_from = "PyprojectTomlWire")] struct Project { /// The name of the project name: PackageName, @@ -148,6 +144,43 @@ struct Project { dynamic: Option>, } +#[derive(Deserialize, Debug)] +#[serde(rename_all = "kebab-case")] +struct PyprojectTomlWire { + name: Option, + version: Option, + requires_python: Option, + dependencies: Option>, + optional_dependencies: Option>>, + dynamic: Option>, +} + +impl TryFrom for Project { + type Error = MetadataError; + + fn try_from(wire: PyprojectTomlWire) -> Result { + let name = wire + .name + .ok_or(MetadataError::InvalidPyprojectTomlMissingName)?; + if wire.version.is_none() + && !wire + .dynamic + .as_ref() + .is_some_and(|dynamic| dynamic.iter().any(|field| field == "version")) + { + return Err(MetadataError::InvalidPyprojectTomlMissingVersion); + } + Ok(Project { + name, + version: wire.version, + requires_python: wire.requires_python, + dependencies: wire.dependencies, + optional_dependencies: wire.optional_dependencies, + dynamic: wire.dynamic, + }) + } +} + #[derive(Deserialize, Debug)] #[serde(rename_all = "kebab-case")] struct Tool { diff --git a/crates/uv-workspace/src/pyproject.rs b/crates/uv-workspace/src/pyproject.rs index 24f55336659f7..8d573830dabb1 100644 --- a/crates/uv-workspace/src/pyproject.rs +++ b/crates/uv-workspace/src/pyproject.rs @@ -35,7 +35,9 @@ pub enum PyprojectTomlError { #[error(transparent)] TomlSchema(#[from] toml_edit::de::Error), #[error("`pyproject.toml` is using the `[project]` table, but the required `project.name` field is not set")] - MissingName(#[source] toml_edit::de::Error), + MissingName, + #[error("`pyproject.toml` is using the `[project]` table, but the required `project.version` field is neither set nor present in the `project.dynamic` list")] + MissingVersion, } /// A `pyproject.toml` as specified in PEP 517. @@ -63,15 +65,8 @@ impl PyProjectToml { pub fn from_string(raw: String) -> Result { let pyproject: toml_edit::ImDocument<_> = toml_edit::ImDocument::from_str(&raw).map_err(PyprojectTomlError::TomlSyntax)?; - let pyproject = - PyProjectToml::deserialize(pyproject.into_deserializer()).map_err(|err| { - // TODO(konsti): A typed error would be nicer, this can break on toml upgrades. - if err.message().contains("missing field `name`") { - PyprojectTomlError::MissingName(err) - } else { - PyprojectTomlError::TomlSchema(err) - } - })?; + let pyproject = PyProjectToml::deserialize(pyproject.into_deserializer()) + .map_err(PyprojectTomlError::TomlSchema)?; Ok(PyProjectToml { raw, ..pyproject }) } @@ -207,7 +202,7 @@ impl<'de> Deserialize<'de> for DependencyGroupSpecifier { /// See . #[derive(Deserialize, Debug, Clone, PartialEq)] #[cfg_attr(test, derive(Serialize))] -#[serde(rename_all = "kebab-case")] +#[serde(rename_all = "kebab-case", try_from = "ProjectWire")] pub struct Project { /// The name of the project pub name: PackageName, @@ -228,6 +223,43 @@ pub struct Project { pub(crate) scripts: Option, } +#[derive(Deserialize, Debug)] +struct ProjectWire { + name: Option, + version: Option, + dynamic: Option>, + requires_python: Option, + dependencies: Option>, + optional_dependencies: Option>>, + gui_scripts: Option, + scripts: Option, +} + +impl TryFrom for Project { + type Error = PyprojectTomlError; + + fn try_from(value: ProjectWire) -> Result { + let name = value.name.ok_or(PyprojectTomlError::MissingName)?; + if value.version.is_none() + && !value + .dynamic + .as_ref() + .is_some_and(|dynamic| dynamic.iter().any(|field| field == "version")) + { + return Err(PyprojectTomlError::MissingVersion); + } + Ok(Project { + name, + version: value.version, + requires_python: value.requires_python, + dependencies: value.dependencies, + optional_dependencies: value.optional_dependencies, + gui_scripts: value.gui_scripts, + scripts: value.scripts, + }) + } +} + #[derive(Deserialize, Debug, Clone, PartialEq, Eq)] #[cfg_attr(test, derive(Serialize))] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] diff --git a/crates/uv/tests/it/lock.rs b/crates/uv/tests/it/lock.rs index 409867e247e40..e8259c859eb02 100644 --- a/crates/uv/tests/it/lock.rs +++ b/crates/uv/tests/it/lock.rs @@ -16776,14 +16776,76 @@ fn lock_invalid_project_table() -> Result<()> { ----- stderr ----- Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + warning: No `requires-python` value found in the workspace. Defaulting to `>=3.12`. × Failed to build `b @ file://[TEMP_DIR]/b` ├─▶ Failed to extract static metadata from `pyproject.toml` - ├─▶ `pyproject.toml` is using the `[project]` table, but the required `project.name` field is not set. ╰─▶ TOML parse error at line 2, column 10 | 2 | [project.urls] | ^^^^^^^ - missing field `name` + `pyproject.toml` is using the `[project]` table, but the required `project.name` field is not set + "###); + + Ok(()) +} + +#[test] +fn lock_missing_name() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str(indoc::indoc! { + r#" + [project] + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["iniconfig"] + "#, + })?; + + 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 1, column 1 + | + 1 | [project] + | ^^^^^^^^^ + `pyproject.toml` is using the `[project]` table, but the required `project.name` field is not set + "###); + + Ok(()) +} + +#[test] +fn lock_missing_version() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str(indoc::indoc! { + r#" + [project] + name = "project" + requires-python = ">=3.12" + dependencies = ["iniconfig"] + "#, + })?; + + 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 1, column 1 + | + 1 | [project] + | ^^^^^^^^^ + `pyproject.toml` is using the `[project]` table, but the required `project.version` field is neither set nor present in the `project.dynamic` list "###); Ok(()) diff --git a/crates/uv/tests/it/run.rs b/crates/uv/tests/it/run.rs index 173098d44c2b3..8feecd781944a 100644 --- a/crates/uv/tests/it/run.rs +++ b/crates/uv/tests/it/run.rs @@ -2774,13 +2774,11 @@ fn run_invalid_project_table() -> Result<()> { ----- stderr ----- error: Failed to parse: `pyproject.toml` - Caused by: `pyproject.toml` is using the `[project]` table, but the required `project.name` field is not set Caused by: TOML parse error at line 1, column 2 | 1 | [project.urls] | ^^^^^^^ - missing field `name` - + `pyproject.toml` is using the `[project]` table, but the required `project.name` field is not set "###); Ok(())