Skip to content

Commit fa058cd

Browse files
committed
Show a concise error message for missing version field
1 parent 9e2e9f2 commit fa058cd

File tree

5 files changed

+159
-32
lines changed

5 files changed

+159
-32
lines changed

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +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),
37+
#[error("`pyproject.toml` is using the `[project]` table, but the required `project.name` field is not set")]
38+
InvalidPyprojectTomlMissingName,
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+
InvalidPyprojectTomlMissingVersion,
3941
#[error(transparent)]
4042
InvalidPyprojectTomlSchema(toml_edit::de::Error),
4143
#[error("Metadata field {0} not found")]

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

Lines changed: 47 additions & 14 deletions
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,43 @@ 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
163+
.name
164+
.ok_or(MetadataError::InvalidPyprojectTomlMissingName)?;
165+
if wire.version.is_none()
166+
&& !wire
167+
.dynamic
168+
.as_ref()
169+
.is_some_and(|dynamic| dynamic.iter().any(|field| field == "version"))
170+
{
171+
return Err(MetadataError::InvalidPyprojectTomlMissingVersion);
172+
}
173+
Ok(Project {
174+
name,
175+
version: wire.version,
176+
requires_python: wire.requires_python,
177+
dependencies: wire.dependencies,
178+
optional_dependencies: wire.optional_dependencies,
179+
dynamic: wire.dynamic,
180+
})
181+
}
182+
}
183+
151184
#[derive(Deserialize, Debug)]
152185
#[serde(rename_all = "kebab-case")]
153186
struct Tool {

crates/uv-workspace/src/pyproject.rs

Lines changed: 43 additions & 11 deletions
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,43 @@ pub struct Project {
228223
pub(crate) scripts: Option<serde::de::IgnoredAny>,
229224
}
230225

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

crates/uv/tests/it/lock.rs

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16776,14 +16776,76 @@ fn lock_invalid_project_table() -> Result<()> {
1677616776

1677716777
----- stderr -----
1677816778
Using CPython 3.12.[X] interpreter at: [PYTHON-3.12]
16779+
warning: No `requires-python` value found in the workspace. Defaulting to `>=3.12`.
1677916780
× Failed to build `b @ file://[TEMP_DIR]/b`
1678016781
├─▶ 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.
1678216782
╰─▶ TOML parse error at line 2, column 10
1678316783
|
1678416784
2 | [project.urls]
1678516785
| ^^^^^^^
16786-
missing field `name`
16786+
`pyproject.toml` is using the `[project]` table, but the required `project.name` field is not set
16787+
"###);
16788+
16789+
Ok(())
16790+
}
16791+
16792+
#[test]
16793+
fn lock_missing_name() -> Result<()> {
16794+
let context = TestContext::new("3.12");
16795+
16796+
let pyproject_toml = context.temp_dir.child("pyproject.toml");
16797+
pyproject_toml.write_str(indoc::indoc! {
16798+
r#"
16799+
[project]
16800+
version = "0.1.0"
16801+
requires-python = ">=3.12"
16802+
dependencies = ["iniconfig"]
16803+
"#,
16804+
})?;
16805+
16806+
uv_snapshot!(context.filters(), context.lock(), @r###"
16807+
success: false
16808+
exit_code: 2
16809+
----- stdout -----
16810+
16811+
----- stderr -----
16812+
error: Failed to parse: `pyproject.toml`
16813+
Caused by: TOML parse error at line 1, column 1
16814+
|
16815+
1 | [project]
16816+
| ^^^^^^^^^
16817+
`pyproject.toml` is using the `[project]` table, but the required `project.name` field is not set
16818+
"###);
16819+
16820+
Ok(())
16821+
}
16822+
16823+
#[test]
16824+
fn lock_missing_version() -> Result<()> {
16825+
let context = TestContext::new("3.12");
16826+
16827+
let pyproject_toml = context.temp_dir.child("pyproject.toml");
16828+
pyproject_toml.write_str(indoc::indoc! {
16829+
r#"
16830+
[project]
16831+
name = "project"
16832+
requires-python = ">=3.12"
16833+
dependencies = ["iniconfig"]
16834+
"#,
16835+
})?;
16836+
16837+
uv_snapshot!(context.filters(), context.lock(), @r###"
16838+
success: false
16839+
exit_code: 2
16840+
----- stdout -----
16841+
16842+
----- stderr -----
16843+
error: Failed to parse: `pyproject.toml`
16844+
Caused by: TOML parse error at line 1, column 1
16845+
|
16846+
1 | [project]
16847+
| ^^^^^^^^^
16848+
`pyproject.toml` is using the `[project]` table, but the required `project.version` field is neither set nor present in the `project.dynamic` list
1678716849
"###);
1678816850

1678916851
Ok(())

crates/uv/tests/it/run.rs

Lines changed: 1 addition & 3 deletions
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)