Skip to content

Commit 125a4b2

Browse files
Improve static metadata extraction for Poetry projects (#4182)
## Summary Adds handling for a few cases to improve interoperability with Poetry: - If the `project` schema is invalid, we now raise a hard error, rather than treating the metadata as dynamic and then falling back to the build backend. This could cause problems, I'm not sure. It's stricter than before. - If the project contains `tool.poetry` but omits `project.dependencies`, we now treat it as dynamic. We could go even further and treat _any_ Poetry project as dynamic, but then we'd be ignoring user-declared dependencies, which is also confusing. Closes #4142.
1 parent c6da4f1 commit 125a4b2

File tree

4 files changed

+166
-14
lines changed

4 files changed

+166
-14
lines changed

crates/pypi-types/src/metadata.rs

+34-3
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ pub enum MetadataError {
6060
UnsupportedMetadataVersion(String),
6161
#[error("The following field was marked as dynamic: {0}")]
6262
DynamicField(&'static str),
63+
#[error("The project uses Poetry's syntax to declare its dependencies, despite including a `project` table in `pyproject.toml`")]
64+
PoetrySyntax,
6365
}
6466

6567
impl From<Pep508Error<VerbatimParsedUrl>> for MetadataError {
@@ -210,6 +212,15 @@ impl Metadata23 {
210212
}
211213
}
212214

215+
// If dependencies are declared with Poetry, and `project.dependencies` is omitted, treat
216+
// the dependencies as dynamic. The inclusion of a `project` table without defining
217+
// `project.dependencies` is almost certainly an error.
218+
if project.dependencies.is_none()
219+
&& pyproject_toml.tool.and_then(|tool| tool.poetry).is_some()
220+
{
221+
return Err(MetadataError::PoetrySyntax);
222+
}
223+
213224
let name = project.name;
214225
let version = project
215226
.version
@@ -257,11 +268,11 @@ impl Metadata23 {
257268
}
258269

259270
/// A `pyproject.toml` as specified in PEP 517.
260-
#[derive(Deserialize, Debug, Clone)]
271+
#[derive(Deserialize, Debug)]
261272
#[serde(rename_all = "kebab-case")]
262273
struct PyProjectToml {
263-
/// Project metadata
264274
project: Option<Project>,
275+
tool: Option<Tool>,
265276
}
266277

267278
/// PEP 621 project metadata.
@@ -270,7 +281,7 @@ struct PyProjectToml {
270281
/// relevant for dependency resolution.
271282
///
272283
/// See <https://packaging.python.org/en/latest/specifications/pyproject-toml>.
273-
#[derive(Deserialize, Debug, Clone)]
284+
#[derive(Deserialize, Debug)]
274285
#[serde(rename_all = "kebab-case")]
275286
struct Project {
276287
/// The name of the project
@@ -288,6 +299,17 @@ struct Project {
288299
dynamic: Option<Vec<String>>,
289300
}
290301

302+
#[derive(Deserialize, Debug)]
303+
#[serde(rename_all = "kebab-case")]
304+
struct Tool {
305+
poetry: Option<ToolPoetry>,
306+
}
307+
308+
#[derive(Deserialize, Debug)]
309+
#[serde(rename_all = "kebab-case")]
310+
#[allow(clippy::empty_structs_with_brackets)]
311+
struct ToolPoetry {}
312+
291313
/// Python Package Metadata 1.0 and later as specified in
292314
/// <https://peps.python.org/pep-0241/>.
293315
///
@@ -369,6 +391,15 @@ impl RequiresDist {
369391
}
370392
}
371393

394+
// If dependencies are declared with Poetry, and `project.dependencies` is omitted, treat
395+
// the dependencies as dynamic. The inclusion of a `project` table without defining
396+
// `project.dependencies` is almost certainly an error.
397+
if project.dependencies.is_none()
398+
&& pyproject_toml.tool.and_then(|tool| tool.poetry).is_some()
399+
{
400+
return Err(MetadataError::PoetrySyntax);
401+
}
402+
372403
let name = project.name;
373404

374405
// Extract the requirements.

crates/uv-distribution/src/error.rs

+4-4
Original file line numberDiff line numberDiff line change
@@ -70,12 +70,12 @@ pub enum Error {
7070
NotFound(PathBuf),
7171
#[error("The source distribution is missing a `PKG-INFO` file")]
7272
MissingPkgInfo,
73-
#[error("The source distribution does not support static metadata in `PKG-INFO`")]
74-
DynamicPkgInfo(#[source] pypi_types::MetadataError),
73+
#[error("Failed to extract static metadata from `PKG-INFO`")]
74+
PkgInfo(#[source] pypi_types::MetadataError),
7575
#[error("The source distribution is missing a `pyproject.toml` file")]
7676
MissingPyprojectToml,
77-
#[error("The source distribution does not support static metadata in `pyproject.toml`")]
78-
DynamicPyprojectToml(#[source] pypi_types::MetadataError),
77+
#[error("Failed to extract static metadata from `pyproject.toml`")]
78+
PyprojectToml(#[source] pypi_types::MetadataError),
7979
#[error("Unsupported scheme in URL: {0}")]
8080
UnsupportedScheme(String),
8181
#[error(transparent)]

crates/uv-distribution/src/source/mod.rs

+24-7
Original file line numberDiff line numberDiff line change
@@ -1411,7 +1411,16 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> {
14111411

14121412
return Ok(Some(metadata));
14131413
}
1414-
Err(err @ (Error::MissingPkgInfo | Error::DynamicPkgInfo(_))) => {
1414+
Err(
1415+
err @ (Error::MissingPkgInfo
1416+
| Error::PkgInfo(
1417+
pypi_types::MetadataError::Pep508Error(_)
1418+
| pypi_types::MetadataError::DynamicField(_)
1419+
| pypi_types::MetadataError::FieldNotFound(_)
1420+
| pypi_types::MetadataError::UnsupportedMetadataVersion(_)
1421+
| pypi_types::MetadataError::PoetrySyntax,
1422+
)),
1423+
) => {
14151424
debug!("No static `PKG-INFO` available for: {source} ({err:?})");
14161425
}
14171426
Err(err) => return Err(err),
@@ -1427,7 +1436,16 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> {
14271436

14281437
return Ok(Some(metadata));
14291438
}
1430-
Err(err @ (Error::MissingPyprojectToml | Error::DynamicPyprojectToml(_))) => {
1439+
Err(
1440+
err @ (Error::MissingPyprojectToml
1441+
| Error::PyprojectToml(
1442+
pypi_types::MetadataError::Pep508Error(_)
1443+
| pypi_types::MetadataError::DynamicField(_)
1444+
| pypi_types::MetadataError::FieldNotFound(_)
1445+
| pypi_types::MetadataError::UnsupportedMetadataVersion(_)
1446+
| pypi_types::MetadataError::PoetrySyntax,
1447+
)),
1448+
) => {
14311449
debug!("No static `pyproject.toml` available for: {source} ({err:?})");
14321450
}
14331451
Err(err) => return Err(err),
@@ -1602,7 +1620,7 @@ async fn read_pkg_info(
16021620
};
16031621

16041622
// Parse the metadata.
1605-
let metadata = Metadata23::parse_pkg_info(&content).map_err(Error::DynamicPkgInfo)?;
1623+
let metadata = Metadata23::parse_pkg_info(&content).map_err(Error::PkgInfo)?;
16061624

16071625
Ok(metadata)
16081626
}
@@ -1627,8 +1645,7 @@ async fn read_pyproject_toml(
16271645
};
16281646

16291647
// Parse the metadata.
1630-
let metadata =
1631-
Metadata23::parse_pyproject_toml(&content).map_err(Error::DynamicPyprojectToml)?;
1648+
let metadata = Metadata23::parse_pyproject_toml(&content).map_err(Error::PyprojectToml)?;
16321649

16331650
Ok(metadata)
16341651
}
@@ -1646,8 +1663,8 @@ async fn read_requires_dist(project_root: &Path) -> Result<pypi_types::RequiresD
16461663
};
16471664

16481665
// Parse the metadata.
1649-
let requires_dist = pypi_types::RequiresDist::parse_pyproject_toml(&content)
1650-
.map_err(Error::DynamicPyprojectToml)?;
1666+
let requires_dist =
1667+
pypi_types::RequiresDist::parse_pyproject_toml(&content).map_err(Error::PyprojectToml)?;
16511668

16521669
Ok(requires_dist)
16531670
}

crates/uv/tests/pip_compile.rs

+104
Original file line numberDiff line numberDiff line change
@@ -615,6 +615,110 @@ build-backend = "poetry.core.masonry.api"
615615
Ok(())
616616
}
617617

618+
/// Compile a `pyproject.toml` file with a `poetry` section and a `project` section without a
619+
/// `dependencies` field, which should be treated as an empty list.
620+
#[test]
621+
fn compile_pyproject_toml_poetry_empty_dependencies() -> Result<()> {
622+
let context = TestContext::new("3.12");
623+
let pyproject_toml = context.temp_dir.child("pyproject.toml");
624+
pyproject_toml.write_str(
625+
r#"[project]
626+
name = "poetry-editable"
627+
version = "0.1.0"
628+
description = ""
629+
authors = ["Astral Software Inc. <hey@astral.sh>"]
630+
631+
[tool.poetry]
632+
name = "poetry-editable"
633+
version = "0.1.0"
634+
description = ""
635+
authors = ["Astral Software Inc. <hey@astral.sh>"]
636+
637+
[tool.poetry.dependencies]
638+
python = "^3.10"
639+
anyio = "^3"
640+
641+
[build-system]
642+
requires = ["poetry-core"]
643+
build-backend = "poetry.core.masonry.api"
644+
"#,
645+
)?;
646+
647+
uv_snapshot!(context.compile()
648+
.arg("pyproject.toml"), @r###"
649+
success: true
650+
exit_code: 0
651+
----- stdout -----
652+
# This file was autogenerated by uv via the following command:
653+
# uv pip compile --cache-dir [CACHE_DIR] --exclude-newer 2024-03-25T00:00:00Z pyproject.toml
654+
anyio==3.7.1
655+
# via poetry-editable (pyproject.toml)
656+
idna==3.6
657+
# via anyio
658+
sniffio==1.3.1
659+
# via anyio
660+
661+
----- stderr -----
662+
Resolved 3 packages in [TIME]
663+
"###
664+
);
665+
666+
Ok(())
667+
}
668+
669+
/// Compile a `pyproject.toml` file with a `poetry` section and a `project` section with an invalid
670+
/// `dependencies` field.
671+
#[test]
672+
fn compile_pyproject_toml_poetry_invalid_dependencies() -> Result<()> {
673+
let context = TestContext::new("3.12");
674+
let pyproject_toml = context.temp_dir.child("pyproject.toml");
675+
pyproject_toml.write_str(
676+
r#"[project]
677+
name = "poetry-editable"
678+
version = "0.1.0"
679+
description = ""
680+
authors = ["Astral Software Inc. <hey@astral.sh>"]
681+
682+
[tool.poetry]
683+
name = "poetry-editable"
684+
version = "0.1.0"
685+
description = ""
686+
authors = ["Astral Software Inc. <hey@astral.sh>"]
687+
688+
[project.dependencies]
689+
python = "^3.12"
690+
msgspec = "^0.18.4"
691+
692+
[tool.poetry.dependencies]
693+
python = "^3.10"
694+
anyio = "^3"
695+
696+
[build-system]
697+
requires = ["poetry-core"]
698+
build-backend = "poetry.core.masonry.api"
699+
"#,
700+
)?;
701+
702+
uv_snapshot!(context.compile()
703+
.arg("pyproject.toml"), @r###"
704+
success: false
705+
exit_code: 2
706+
----- stdout -----
707+
708+
----- stderr -----
709+
error: Failed to extract static metadata from `pyproject.toml`
710+
Caused by: TOML parse error at line 13, column 1
711+
|
712+
13 | [project.dependencies]
713+
| ^^^^^^^^^^^^^^^^^^^^^^
714+
invalid type: map, expected a sequence
715+
716+
"###
717+
);
718+
719+
Ok(())
720+
}
721+
618722
/// Compile a `pyproject.toml` file that uses setuptools as the build backend.
619723
#[test]
620724
fn compile_pyproject_toml_setuptools() -> Result<()> {

0 commit comments

Comments
 (0)