Skip to content

Commit 48c9196

Browse files
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 #9910.
1 parent d4c2c46 commit 48c9196

File tree

6 files changed

+153
-34
lines changed

6 files changed

+153
-34
lines changed

crates/uv-pypi-types/src/metadata/mod.rs

+2-2
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,10 @@ pub enum MetadataError {
3434
MailParse(#[from] MailParseError),
3535
#[error("Invalid `pyproject.toml`")]
3636
InvalidPyprojectTomlSyntax(#[source] toml_edit::TomlError),
37-
#[error("`pyproject.toml` is using the `[project]` table, but the required `project.name` field is not set.")]
38-
InvalidPyprojectTomlMissingName(#[source] toml_edit::de::Error),
3937
#[error(transparent)]
4038
InvalidPyprojectTomlSchema(toml_edit::de::Error),
39+
#[error("`pyproject.toml` is using the `[project]` table, but the required `project.name` field is not set")]
40+
MissingName,
4141
#[error("Metadata field {0} not found")]
4242
FieldNotFound(&'static str),
4343
#[error("Invalid version: {0}")]

crates/uv-pypi-types/src/metadata/pyproject_toml.rs

+37-14
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,19 @@
1-
use crate::{
2-
LenientRequirement, LenientVersionSpecifiers, MetadataError, ResolutionMetadata,
3-
VerbatimParsedUrl,
4-
};
1+
use std::str::FromStr;
2+
53
use indexmap::IndexMap;
64
use itertools::Itertools;
75
use serde::de::IntoDeserializer;
86
use serde::{Deserialize, Serialize};
9-
use std::str::FromStr;
7+
108
use uv_normalize::{ExtraName, PackageName};
119
use uv_pep440::{Version, VersionSpecifiers};
1210
use uv_pep508::Requirement;
1311

12+
use crate::{
13+
LenientRequirement, LenientVersionSpecifiers, MetadataError, ResolutionMetadata,
14+
VerbatimParsedUrl,
15+
};
16+
1417
/// Extract the metadata from a `pyproject.toml` file, as specified in PEP 621.
1518
///
1619
/// If we're coming from a source distribution, we may already know the version (unlike for a source
@@ -112,14 +115,7 @@ impl PyProjectToml {
112115
let pyproject_toml: toml_edit::ImDocument<_> = toml_edit::ImDocument::from_str(toml)
113116
.map_err(MetadataError::InvalidPyprojectTomlSyntax)?;
114117
let pyproject_toml: Self = PyProjectToml::deserialize(pyproject_toml.into_deserializer())
115-
.map_err(|err| {
116-
// TODO(konsti): A typed error would be nicer, this can break on toml upgrades.
117-
if err.message().contains("missing field `name`") {
118-
MetadataError::InvalidPyprojectTomlMissingName(err)
119-
} else {
120-
MetadataError::InvalidPyprojectTomlSchema(err)
121-
}
122-
})?;
118+
.map_err(MetadataError::InvalidPyprojectTomlSchema)?;
123119
Ok(pyproject_toml)
124120
}
125121
}
@@ -131,7 +127,7 @@ impl PyProjectToml {
131127
///
132128
/// See <https://packaging.python.org/en/latest/specifications/pyproject-toml>.
133129
#[derive(Deserialize, Debug)]
134-
#[serde(rename_all = "kebab-case")]
130+
#[serde(try_from = "PyprojectTomlWire")]
135131
struct Project {
136132
/// The name of the project
137133
name: PackageName,
@@ -148,6 +144,33 @@ struct Project {
148144
dynamic: Option<Vec<String>>,
149145
}
150146

147+
#[derive(Deserialize, Debug)]
148+
#[serde(rename_all = "kebab-case")]
149+
struct PyprojectTomlWire {
150+
name: Option<PackageName>,
151+
version: Option<Version>,
152+
requires_python: Option<String>,
153+
dependencies: Option<Vec<String>>,
154+
optional_dependencies: Option<IndexMap<ExtraName, Vec<String>>>,
155+
dynamic: Option<Vec<String>>,
156+
}
157+
158+
impl TryFrom<PyprojectTomlWire> for Project {
159+
type Error = MetadataError;
160+
161+
fn try_from(wire: PyprojectTomlWire) -> Result<Self, Self::Error> {
162+
let name = wire.name.ok_or(MetadataError::MissingName)?;
163+
Ok(Project {
164+
name,
165+
version: wire.version,
166+
requires_python: wire.requires_python,
167+
dependencies: wire.dependencies,
168+
optional_dependencies: wire.optional_dependencies,
169+
dynamic: wire.dynamic,
170+
})
171+
}
172+
}
173+
151174
#[derive(Deserialize, Debug)]
152175
#[serde(rename_all = "kebab-case")]
153176
struct Tool {

crates/uv-workspace/src/pyproject.rs

+48-11
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,9 @@ pub enum PyprojectTomlError {
3535
#[error(transparent)]
3636
TomlSchema(#[from] toml_edit::de::Error),
3737
#[error("`pyproject.toml` is using the `[project]` table, but the required `project.name` field is not set")]
38-
MissingName(#[source] toml_edit::de::Error),
38+
MissingName,
39+
#[error("`pyproject.toml` is using the `[project]` table, but the required `project.version` field is neither set nor present in the `project.dynamic` list")]
40+
MissingVersion,
3941
}
4042

4143
/// A `pyproject.toml` as specified in PEP 517.
@@ -63,15 +65,8 @@ impl PyProjectToml {
6365
pub fn from_string(raw: String) -> Result<Self, PyprojectTomlError> {
6466
let pyproject: toml_edit::ImDocument<_> =
6567
toml_edit::ImDocument::from_str(&raw).map_err(PyprojectTomlError::TomlSyntax)?;
66-
let pyproject =
67-
PyProjectToml::deserialize(pyproject.into_deserializer()).map_err(|err| {
68-
// TODO(konsti): A typed error would be nicer, this can break on toml upgrades.
69-
if err.message().contains("missing field `name`") {
70-
PyprojectTomlError::MissingName(err)
71-
} else {
72-
PyprojectTomlError::TomlSchema(err)
73-
}
74-
})?;
68+
let pyproject = PyProjectToml::deserialize(pyproject.into_deserializer())
69+
.map_err(PyprojectTomlError::TomlSchema)?;
7570
Ok(PyProjectToml { raw, ..pyproject })
7671
}
7772

@@ -207,7 +202,7 @@ impl<'de> Deserialize<'de> for DependencyGroupSpecifier {
207202
/// See <https://packaging.python.org/en/latest/specifications/pyproject-toml>.
208203
#[derive(Deserialize, Debug, Clone, PartialEq)]
209204
#[cfg_attr(test, derive(Serialize))]
210-
#[serde(rename_all = "kebab-case")]
205+
#[serde(rename_all = "kebab-case", try_from = "ProjectWire")]
211206
pub struct Project {
212207
/// The name of the project
213208
pub name: PackageName,
@@ -228,6 +223,48 @@ pub struct Project {
228223
pub(crate) scripts: Option<serde::de::IgnoredAny>,
229224
}
230225

226+
#[derive(Deserialize, Debug)]
227+
#[serde(rename_all = "kebab-case")]
228+
struct ProjectWire {
229+
name: Option<PackageName>,
230+
version: Option<Version>,
231+
dynamic: Option<Vec<String>>,
232+
requires_python: Option<VersionSpecifiers>,
233+
dependencies: Option<Vec<String>>,
234+
optional_dependencies: Option<BTreeMap<ExtraName, Vec<String>>>,
235+
gui_scripts: Option<serde::de::IgnoredAny>,
236+
scripts: Option<serde::de::IgnoredAny>,
237+
}
238+
239+
impl TryFrom<ProjectWire> for Project {
240+
type Error = PyprojectTomlError;
241+
242+
fn try_from(value: ProjectWire) -> Result<Self, Self::Error> {
243+
// If `[project.name]` is not present, show a dedicated error message.
244+
let name = value.name.ok_or(PyprojectTomlError::MissingName)?;
245+
246+
// If `[project.version]` is not present (or listed in `[project.dynamic]`), show a dedicated error message.
247+
if value.version.is_none()
248+
&& !value
249+
.dynamic
250+
.as_ref()
251+
.is_some_and(|dynamic| dynamic.iter().any(|field| field == "version"))
252+
{
253+
return Err(PyprojectTomlError::MissingVersion);
254+
}
255+
256+
Ok(Project {
257+
name,
258+
version: value.version,
259+
requires_python: value.requires_python,
260+
dependencies: value.dependencies,
261+
optional_dependencies: value.optional_dependencies,
262+
gui_scripts: value.gui_scripts,
263+
scripts: value.scripts,
264+
})
265+
}
266+
}
267+
231268
#[derive(Deserialize, Debug, Clone, PartialEq, Eq)]
232269
#[cfg_attr(test, derive(Serialize))]
233270
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]

crates/uv/tests/it/lock.rs

+63-2
Original file line numberDiff line numberDiff line change
@@ -16778,12 +16778,73 @@ fn lock_invalid_project_table() -> Result<()> {
1677816778
Using CPython 3.12.[X] interpreter at: [PYTHON-3.12]
1677916779
× Failed to build `b @ file://[TEMP_DIR]/b`
1678016780
├─▶ Failed to extract static metadata from `pyproject.toml`
16781-
├─▶ `pyproject.toml` is using the `[project]` table, but the required `project.name` field is not set.
1678216781
╰─▶ TOML parse error at line 2, column 10
1678316782
|
1678416783
2 | [project.urls]
1678516784
| ^^^^^^^
16786-
missing field `name`
16785+
`pyproject.toml` is using the `[project]` table, but the required `project.name` field is not set
16786+
"###);
16787+
16788+
Ok(())
16789+
}
16790+
16791+
#[test]
16792+
fn lock_missing_name() -> Result<()> {
16793+
let context = TestContext::new("3.12");
16794+
16795+
let pyproject_toml = context.temp_dir.child("pyproject.toml");
16796+
pyproject_toml.write_str(indoc::indoc! {
16797+
r#"
16798+
[project]
16799+
version = "0.1.0"
16800+
requires-python = ">=3.12"
16801+
dependencies = ["iniconfig"]
16802+
"#,
16803+
})?;
16804+
16805+
uv_snapshot!(context.filters(), context.lock(), @r###"
16806+
success: false
16807+
exit_code: 2
16808+
----- stdout -----
16809+
16810+
----- stderr -----
16811+
error: Failed to parse: `pyproject.toml`
16812+
Caused by: TOML parse error at line 1, column 1
16813+
|
16814+
1 | [project]
16815+
| ^^^^^^^^^
16816+
`pyproject.toml` is using the `[project]` table, but the required `project.name` field is not set
16817+
"###);
16818+
16819+
Ok(())
16820+
}
16821+
16822+
#[test]
16823+
fn lock_missing_version() -> Result<()> {
16824+
let context = TestContext::new("3.12");
16825+
16826+
let pyproject_toml = context.temp_dir.child("pyproject.toml");
16827+
pyproject_toml.write_str(indoc::indoc! {
16828+
r#"
16829+
[project]
16830+
name = "project"
16831+
requires-python = ">=3.12"
16832+
dependencies = ["iniconfig"]
16833+
"#,
16834+
})?;
16835+
16836+
uv_snapshot!(context.filters(), context.lock(), @r###"
16837+
success: false
16838+
exit_code: 2
16839+
----- stdout -----
16840+
16841+
----- stderr -----
16842+
error: Failed to parse: `pyproject.toml`
16843+
Caused by: TOML parse error at line 1, column 1
16844+
|
16845+
1 | [project]
16846+
| ^^^^^^^^^
16847+
`pyproject.toml` is using the `[project]` table, but the required `project.version` field is neither set nor present in the `project.dynamic` list
1678716848
"###);
1678816849

1678916850
Ok(())

crates/uv/tests/it/pip_install.rs

+2-2
Original file line numberDiff line numberDiff line change
@@ -167,8 +167,7 @@ fn invalid_pyproject_toml_project_schema() -> Result<()> {
167167
|
168168
1 | [project]
169169
| ^^^^^^^^^
170-
missing field `name`
171-
170+
`pyproject.toml` is using the `[project]` table, but the required `project.name` field is not set
172171
"###
173172
);
174173

@@ -285,6 +284,7 @@ fn invalid_pyproject_toml_requirement_indirect() -> Result<()> {
285284
pyproject_toml.write_str(
286285
r#"[project]
287286
name = "project"
287+
version = "0.1.0"
288288
dependencies = ["flask==1.0.x"]
289289
"#,
290290
)?;

crates/uv/tests/it/run.rs

+1-3
Original file line numberDiff line numberDiff line change
@@ -2774,13 +2774,11 @@ fn run_invalid_project_table() -> Result<()> {
27742774
27752775
----- stderr -----
27762776
error: Failed to parse: `pyproject.toml`
2777-
Caused by: `pyproject.toml` is using the `[project]` table, but the required `project.name` field is not set
27782777
Caused by: TOML parse error at line 1, column 2
27792778
|
27802779
1 | [project.urls]
27812780
| ^^^^^^^
2782-
missing field `name`
2783-
2781+
`pyproject.toml` is using the `[project]` table, but the required `project.name` field is not set
27842782
"###);
27852783

27862784
Ok(())

0 commit comments

Comments
 (0)