Show a concise error message for missing version field (#9912)

## Summary

This now looks like:

```
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
```

Closes https://github.com/astral-sh/uv/issues/9910.
This commit is contained in:
Charlie Marsh 2024-12-15 10:27:43 -05:00 committed by GitHub
parent d4c2c46f6e
commit 48c9196f9e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 153 additions and 34 deletions

View file

@ -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}")]

View file

@ -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 <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,
@ -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 {

View file

@ -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<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 })
}
@ -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,
@ -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))]

View file

@ -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(())

View file

@ -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
"###
);
@ -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"]
"#,
)?;

View file

@ -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(())