Skip to content

Commit

Permalink
Show a concise error message for missing version field
Browse files Browse the repository at this point in the history
  • Loading branch information
charliermarsh committed Dec 15, 2024
1 parent 9e2e9f2 commit 78c929d
Show file tree
Hide file tree
Showing 6 changed files with 153 additions and 34 deletions.
4 changes: 2 additions & 2 deletions crates/uv-pypi-types/src/metadata/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +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(transparent)]
InvalidPyprojectTomlSchema(toml_edit::de::Error),
#[error("`pyproject.toml` is using the `[project]` table, but the required `project.name` field is not set")]
MissingName,
#[error("Metadata field {0} not found")]
FieldNotFound(&'static str),
#[error("Invalid version: {0}")]
Expand Down
51 changes: 37 additions & 14 deletions crates/uv-pypi-types/src/metadata/pyproject_toml.rs
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
}
}
Expand All @@ -131,7 +127,7 @@ impl PyProjectToml {
///
/// See <https://packaging.python.org/en/latest/specifications/pyproject-toml>.
#[derive(Deserialize, Debug)]
#[serde(rename_all = "kebab-case")]
#[serde(try_from = "PyprojectTomlWire")]
struct Project {
/// The name of the project
name: PackageName,
Expand All @@ -148,6 +144,33 @@ struct Project {
dynamic: Option<Vec<String>>,
}

#[derive(Deserialize, Debug)]
#[serde(rename_all = "kebab-case")]
struct PyprojectTomlWire {
name: Option<PackageName>,
version: Option<Version>,
requires_python: Option<String>,
dependencies: Option<Vec<String>>,
optional_dependencies: Option<IndexMap<ExtraName, Vec<String>>>,
dynamic: Option<Vec<String>>,
}

impl TryFrom<PyprojectTomlWire> for Project {
type Error = MetadataError;

fn try_from(wire: PyprojectTomlWire) -> Result<Self, Self::Error> {
let name = wire.name.ok_or(MetadataError::MissingName)?;
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 {
Expand Down
59 changes: 48 additions & 11 deletions crates/uv-workspace/src/pyproject.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -63,15 +65,8 @@ impl PyProjectToml {
pub fn from_string(raw: String) -> Result<Self, PyprojectTomlError> {
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 })
}

Expand Down Expand Up @@ -207,7 +202,7 @@ impl<'de> Deserialize<'de> for DependencyGroupSpecifier {
/// See <https://packaging.python.org/en/latest/specifications/pyproject-toml>.
#[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,
Expand All @@ -228,6 +223,48 @@ pub struct Project {
pub(crate) scripts: Option<serde::de::IgnoredAny>,
}

#[derive(Deserialize, Debug)]
#[serde(rename_all = "kebab-case")]
struct ProjectWire {
name: Option<PackageName>,
version: Option<Version>,
dynamic: Option<Vec<String>>,
requires_python: Option<VersionSpecifiers>,
dependencies: Option<Vec<String>>,
optional_dependencies: Option<BTreeMap<ExtraName, Vec<String>>>,
gui_scripts: Option<serde::de::IgnoredAny>,
scripts: Option<serde::de::IgnoredAny>,
}

impl TryFrom<ProjectWire> for Project {
type Error = PyprojectTomlError;

fn try_from(value: ProjectWire) -> Result<Self, Self::Error> {
// If `[project.name]` is not present, show a dedicated error message.
let name = value.name.ok_or(PyprojectTomlError::MissingName)?;

// If `[project.version]` is not present (or listed in `[project.dynamic]`), show a dedicated error message.
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))]
Expand Down
65 changes: 63 additions & 2 deletions crates/uv/tests/it/lock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16778,12 +16778,73 @@ fn lock_invalid_project_table() -> Result<()> {
Using CPython 3.12.[X] interpreter at: [PYTHON-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(())
Expand Down
4 changes: 2 additions & 2 deletions crates/uv/tests/it/pip_install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -167,8 +167,7 @@ fn invalid_pyproject_toml_project_schema() -> Result<()> {
|
1 | [project]
| ^^^^^^^^^
missing field `name`
`pyproject.toml` is using the `[project]` table, but the required `project.name` field is not set
"###
);

Expand Down Expand Up @@ -285,6 +284,7 @@ fn invalid_pyproject_toml_requirement_indirect() -> Result<()> {
pyproject_toml.write_str(
r#"[project]
name = "project"
version = "0.1.0"
dependencies = ["flask==1.0.x"]
"#,
)?;
Expand Down
4 changes: 1 addition & 3 deletions crates/uv/tests/it/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(())
Expand Down

0 comments on commit 78c929d

Please sign in to comment.