mirror of https://github.com/astral-sh/uv
Hint at missing `project.name` (#6803)
We got user reports where users were confused about why they can't use `[project.urls]` in `pyproject.toml` (i think that's from poetry?). This PR adds a hint that (according to PEP 621), you need to set `project.name` when using any `project` fields. (PEP 621 also requires `project.version` xor `dynamic = ["version"]`, but we check that later.) The intermediate parsing layer to tell apart syntax errors from schema errors doesn't incur a performance penalty according to epage (https://github.com/toml-rs/toml/issues/778#issuecomment-2310369253). Closes #6419 Closes #6760
This commit is contained in:
parent
3d62154849
commit
4aad89cf06
|
|
@ -2817,6 +2817,7 @@ dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
"toml",
|
"toml",
|
||||||
|
"toml_edit",
|
||||||
"tracing",
|
"tracing",
|
||||||
"url",
|
"url",
|
||||||
"uv-fs",
|
"uv-fs",
|
||||||
|
|
@ -4573,7 +4574,7 @@ dependencies = [
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
"tokio",
|
"tokio",
|
||||||
"toml",
|
"toml_edit",
|
||||||
"tracing",
|
"tracing",
|
||||||
"uv-configuration",
|
"uv-configuration",
|
||||||
"uv-fs",
|
"uv-fs",
|
||||||
|
|
|
||||||
|
|
@ -148,7 +148,7 @@ tokio = { version = "1.35.1", features = ["fs", "io-util", "macros", "process",
|
||||||
tokio-stream = { version = "0.1.14" }
|
tokio-stream = { version = "0.1.14" }
|
||||||
tokio-util = { version = "0.7.10", features = ["compat"] }
|
tokio-util = { version = "0.7.10", features = ["compat"] }
|
||||||
toml = { version = "0.8.12" }
|
toml = { version = "0.8.12" }
|
||||||
toml_edit = { version = "0.22.13" }
|
toml_edit = { version = "0.22.13", features = ["serde"] }
|
||||||
tracing = { version = "0.1.40" }
|
tracing = { version = "0.1.40" }
|
||||||
tracing-durations-export = { version = "0.3.0", features = ["plot"] }
|
tracing-durations-export = { version = "0.3.0", features = ["plot"] }
|
||||||
tracing-subscriber = { version = "0.3.18", features = ["env-filter", "json", "registry"] }
|
tracing-subscriber = { version = "0.3.18", features = ["env-filter", "json", "registry"] }
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ rkyv = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
toml = { workspace = true }
|
toml = { workspace = true }
|
||||||
|
toml_edit = { workspace = true }
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
url = { workspace = true }
|
url = { workspace = true }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ use std::str::FromStr;
|
||||||
use indexmap::IndexMap;
|
use indexmap::IndexMap;
|
||||||
use itertools::Itertools;
|
use itertools::Itertools;
|
||||||
use mailparse::{MailHeaderMap, MailParseError};
|
use mailparse::{MailHeaderMap, MailParseError};
|
||||||
|
use serde::de::IntoDeserializer;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
use tracing::warn;
|
use tracing::warn;
|
||||||
|
|
@ -44,8 +45,12 @@ pub struct Metadata23 {
|
||||||
pub enum MetadataError {
|
pub enum MetadataError {
|
||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
MailParse(#[from] MailParseError),
|
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` is not set.")]
|
||||||
|
InvalidPyprojectTomlMissingName(#[source] toml_edit::de::Error),
|
||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
Toml(#[from] toml::de::Error),
|
InvalidPyprojectTomlSchema(toml_edit::de::Error),
|
||||||
#[error("metadata field {0} not found")]
|
#[error("metadata field {0} not found")]
|
||||||
FieldNotFound(&'static str),
|
FieldNotFound(&'static str),
|
||||||
#[error("invalid version: {0}")]
|
#[error("invalid version: {0}")]
|
||||||
|
|
@ -196,7 +201,7 @@ impl Metadata23 {
|
||||||
|
|
||||||
/// Extract the metadata from a `pyproject.toml` file, as specified in PEP 621.
|
/// Extract the metadata from a `pyproject.toml` file, as specified in PEP 621.
|
||||||
pub fn parse_pyproject_toml(contents: &str) -> Result<Self, MetadataError> {
|
pub fn parse_pyproject_toml(contents: &str) -> Result<Self, MetadataError> {
|
||||||
let pyproject_toml: PyProjectToml = toml::from_str(contents)?;
|
let pyproject_toml = PyProjectToml::from_toml(contents)?;
|
||||||
|
|
||||||
let project = pyproject_toml
|
let project = pyproject_toml
|
||||||
.project
|
.project
|
||||||
|
|
@ -279,6 +284,23 @@ struct PyProjectToml {
|
||||||
tool: Option<Tool>,
|
tool: Option<Tool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl PyProjectToml {
|
||||||
|
fn from_toml(toml: &str) -> Result<Self, MetadataError> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
Ok(pyproject_toml)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// PEP 621 project metadata.
|
/// PEP 621 project metadata.
|
||||||
///
|
///
|
||||||
/// This is a subset of the full metadata specification, and only includes the fields that are
|
/// This is a subset of the full metadata specification, and only includes the fields that are
|
||||||
|
|
@ -435,7 +457,7 @@ pub struct RequiresDist {
|
||||||
impl RequiresDist {
|
impl RequiresDist {
|
||||||
/// Extract the [`RequiresDist`] from a `pyproject.toml` file, as specified in PEP 621.
|
/// Extract the [`RequiresDist`] from a `pyproject.toml` file, as specified in PEP 621.
|
||||||
pub fn parse_pyproject_toml(contents: &str) -> Result<Self, MetadataError> {
|
pub fn parse_pyproject_toml(contents: &str) -> Result<Self, MetadataError> {
|
||||||
let pyproject_toml: PyProjectToml = toml::from_str(contents)?;
|
let pyproject_toml = PyProjectToml::from_toml(contents)?;
|
||||||
|
|
||||||
let project = pyproject_toml
|
let project = pyproject_toml
|
||||||
.project
|
.project
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ serde_json = { workspace = true }
|
||||||
tempfile = { workspace = true }
|
tempfile = { workspace = true }
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
toml = { workspace = true }
|
toml_edit = { workspace = true }
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
rustc-hash = { workspace = true }
|
rustc-hash = { workspace = true }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,9 @@ pub enum Error {
|
||||||
#[error("{} does not appear to be a Python project, as neither `pyproject.toml` nor `setup.py` are present in the directory", _0.simplified_display())]
|
#[error("{} does not appear to be a Python project, as neither `pyproject.toml` nor `setup.py` are present in the directory", _0.simplified_display())]
|
||||||
InvalidSourceDist(PathBuf),
|
InvalidSourceDist(PathBuf),
|
||||||
#[error("Invalid `pyproject.toml`")]
|
#[error("Invalid `pyproject.toml`")]
|
||||||
InvalidPyprojectToml(#[from] toml::de::Error),
|
InvalidPyprojectTomlSyntax(#[from] toml_edit::TomlError),
|
||||||
|
#[error("`pyproject.toml` does not match the required schema. When the `[project]` table is present, `project.name` must be present and non-empty.")]
|
||||||
|
InvalidPyprojectTomlSchema(#[from] toml_edit::de::Error),
|
||||||
#[error("Editable installs with setup.py legacy builds are unsupported, please specify a build backend in pyproject.toml")]
|
#[error("Editable installs with setup.py legacy builds are unsupported, please specify a build backend in pyproject.toml")]
|
||||||
EditableSetupPy,
|
EditableSetupPy,
|
||||||
#[error("Failed to install requirements from {0}")]
|
#[error("Failed to install requirements from {0}")]
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ use fs_err as fs;
|
||||||
use indoc::formatdoc;
|
use indoc::formatdoc;
|
||||||
use itertools::Itertools;
|
use itertools::Itertools;
|
||||||
use rustc_hash::FxHashMap;
|
use rustc_hash::FxHashMap;
|
||||||
use serde::de::{value, SeqAccess, Visitor};
|
use serde::de::{value, IntoDeserializer, SeqAccess, Visitor};
|
||||||
use serde::{de, Deserialize, Deserializer};
|
use serde::{de, Deserialize, Deserializer};
|
||||||
use std::ffi::OsString;
|
use std::ffi::OsString;
|
||||||
use std::fmt::Formatter;
|
use std::fmt::Formatter;
|
||||||
|
|
@ -430,8 +430,12 @@ impl SourceBuild {
|
||||||
) -> Result<(Pep517Backend, Option<Project>), Box<Error>> {
|
) -> Result<(Pep517Backend, Option<Project>), Box<Error>> {
|
||||||
match fs::read_to_string(source_tree.join("pyproject.toml")) {
|
match fs::read_to_string(source_tree.join("pyproject.toml")) {
|
||||||
Ok(toml) => {
|
Ok(toml) => {
|
||||||
|
let pyproject_toml: toml_edit::ImDocument<_> =
|
||||||
|
toml_edit::ImDocument::from_str(&toml)
|
||||||
|
.map_err(Error::InvalidPyprojectTomlSyntax)?;
|
||||||
let pyproject_toml: PyProjectToml =
|
let pyproject_toml: PyProjectToml =
|
||||||
toml::from_str(&toml).map_err(Error::InvalidPyprojectToml)?;
|
PyProjectToml::deserialize(pyproject_toml.into_deserializer())
|
||||||
|
.map_err(Error::InvalidPyprojectTomlSchema)?;
|
||||||
let backend = if let Some(build_system) = pyproject_toml.build_system {
|
let backend = if let Some(build_system) = pyproject_toml.build_system {
|
||||||
Pep517Backend {
|
Pep517Backend {
|
||||||
// If `build-backend` is missing, inject the legacy setuptools backend, but
|
// If `build-backend` is missing, inject the legacy setuptools backend, but
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ pub enum Error {
|
||||||
#[error("The .dist-info directory name contains invalid characters")]
|
#[error("The .dist-info directory name contains invalid characters")]
|
||||||
InvalidName(#[from] InvalidNameError),
|
InvalidName(#[from] InvalidNameError),
|
||||||
#[error("The metadata at {0} is invalid")]
|
#[error("The metadata at {0} is invalid")]
|
||||||
InvalidMetadata(String, pypi_types::MetadataError),
|
InvalidMetadata(String, Box<pypi_types::MetadataError>),
|
||||||
#[error("Failed to read from zip file")]
|
#[error("Failed to read from zip file")]
|
||||||
Zip(#[from] zip::result::ZipError),
|
Zip(#[from] zip::result::ZipError),
|
||||||
#[error("Failed to read from zip file")]
|
#[error("Failed to read from zip file")]
|
||||||
|
|
@ -285,7 +285,7 @@ pub async fn read_metadata_async_stream<R: futures::AsyncRead + Unpin>(
|
||||||
reader.read_to_end(&mut contents).await.unwrap();
|
reader.read_to_end(&mut contents).await.unwrap();
|
||||||
|
|
||||||
let metadata = Metadata23::parse_metadata(&contents)
|
let metadata = Metadata23::parse_metadata(&contents)
|
||||||
.map_err(|err| Error::InvalidMetadata(debug_path.to_string(), err))?;
|
.map_err(|err| Error::InvalidMetadata(debug_path.to_string(), Box::new(err)))?;
|
||||||
return Ok(metadata);
|
return Ok(metadata);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -305,7 +305,10 @@ pub fn read_flat_wheel_metadata(
|
||||||
let dist_info_prefix = find_flat_dist_info(filename, &wheel)?;
|
let dist_info_prefix = find_flat_dist_info(filename, &wheel)?;
|
||||||
let metadata = read_dist_info_metadata(&dist_info_prefix, &wheel)?;
|
let metadata = read_dist_info_metadata(&dist_info_prefix, &wheel)?;
|
||||||
Metadata23::parse_metadata(&metadata).map_err(|err| {
|
Metadata23::parse_metadata(&metadata).map_err(|err| {
|
||||||
Error::InvalidMetadata(format!("{dist_info_prefix}.dist-info/METADATA"), err)
|
Error::InvalidMetadata(
|
||||||
|
format!("{dist_info_prefix}.dist-info/METADATA"),
|
||||||
|
Box::new(err),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,5 +31,5 @@ pathdiff = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
toml = { workspace = true }
|
toml = { workspace = true }
|
||||||
toml_edit = { workspace = true, features = ["serde"] }
|
toml_edit = { workspace = true }
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
|
|
|
||||||
|
|
@ -12693,3 +12693,50 @@ fn lock_duplicate_sources() -> Result<()> {
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lock_invalid_project_table() -> Result<()> {
|
||||||
|
let context = TestContext::new("3.12");
|
||||||
|
|
||||||
|
let pyproject_toml = context.temp_dir.child("a/pyproject.toml");
|
||||||
|
pyproject_toml.write_str(
|
||||||
|
r#"
|
||||||
|
[project]
|
||||||
|
name = "a"
|
||||||
|
version = "0.1.0"
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
dependencies = ["b"]
|
||||||
|
|
||||||
|
[tool.uv.sources]
|
||||||
|
b = { path = "../b" }
|
||||||
|
"#,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let pyproject_toml = context.temp_dir.child("b/pyproject.toml");
|
||||||
|
pyproject_toml.write_str(
|
||||||
|
r"
|
||||||
|
[project.urls]
|
||||||
|
repository = 'https://github.com/octocat/octocat-python'
|
||||||
|
",
|
||||||
|
)?;
|
||||||
|
|
||||||
|
uv_snapshot!(context.filters(), context.lock().current_dir(context.temp_dir.join("a")), @r###"
|
||||||
|
success: false
|
||||||
|
exit_code: 2
|
||||||
|
----- stdout -----
|
||||||
|
|
||||||
|
----- stderr -----
|
||||||
|
Using Python 3.12.[X] interpreter at: [PYTHON-3.12]
|
||||||
|
error: Failed to build: `b @ file://[TEMP_DIR]/b`
|
||||||
|
Caused by: Failed to extract static metadata from `pyproject.toml`
|
||||||
|
Caused by: `pyproject.toml` is using the `[project]` table, but the required `project.name` is not set.
|
||||||
|
Caused by: TOML parse error at line 2, column 10
|
||||||
|
|
|
||||||
|
2 | [project.urls]
|
||||||
|
| ^^^^^^^
|
||||||
|
missing field `name`
|
||||||
|
|
||||||
|
"###);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1910,3 +1910,42 @@ fn run_exit_code() -> Result<()> {
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn run_lock_invalid_project_table() -> Result<()> {
|
||||||
|
let context = TestContext::new_with_versions(&["3.12", "3.11", "3.8"]);
|
||||||
|
|
||||||
|
let pyproject_toml = context.temp_dir.child("pyproject.toml");
|
||||||
|
pyproject_toml.write_str(indoc! { r#"
|
||||||
|
[project.urls]
|
||||||
|
repository = 'https://github.com/octocat/octocat-python'
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=42"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
"#
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let test_script = context.temp_dir.child("main.py");
|
||||||
|
test_script.write_str(indoc! { r#"
|
||||||
|
print("Hello, world!")
|
||||||
|
"#
|
||||||
|
})?;
|
||||||
|
|
||||||
|
uv_snapshot!(context.filters(), context.run().arg("main.py"), @r###"
|
||||||
|
success: false
|
||||||
|
exit_code: 2
|
||||||
|
----- stdout -----
|
||||||
|
|
||||||
|
----- stderr -----
|
||||||
|
error: Failed to parse: `pyproject.toml`
|
||||||
|
Caused by: TOML parse error at line 1, column 2
|
||||||
|
|
|
||||||
|
1 | [project.urls]
|
||||||
|
| ^^^^^^^
|
||||||
|
missing field `name`
|
||||||
|
|
||||||
|
"###);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue