Skip to content

Commit

Permalink
Fall back to PEP 517 hooks for non-compliant PEP 621 metadata (astral…
Browse files Browse the repository at this point in the history
…-sh#2662)

If you pass a `pyproject.toml` that use Hatch's context formatting API,
we currently fail because the dependencies aren't valid under PEP 508.
This PR makes the static metadata parsing a little more relaxed, so that
we appropriately fall back to PEP 517 there.
  • Loading branch information
charliermarsh authored Mar 26, 2024
1 parent 12846c2 commit 39769d8
Show file tree
Hide file tree
Showing 9 changed files with 347 additions and 160 deletions.
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 3 additions & 3 deletions crates/uv-build/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ use tracing::{debug, info_span, instrument, Instrument};

use distribution_types::Resolution;
use pep440_rs::{Version, VersionSpecifiers};
use pep508_rs::Requirement;
use pep508_rs::{PackageName, Requirement};
use uv_fs::{PythonExt, Simplified};
use uv_interpreter::{Interpreter, PythonEnvironment};
use uv_traits::{
Expand Down Expand Up @@ -72,7 +72,7 @@ pub enum Error {
IO(#[from] io::Error),
#[error("Invalid source distribution: {0}")]
InvalidSourceDist(String),
#[error("Invalid pyproject.toml")]
#[error("Invalid `pyproject.toml`")]
InvalidPyprojectToml(#[from] toml::de::Error),
#[error("Editable installs with setup.py legacy builds are unsupported, please specify a build backend in pyproject.toml")]
EditableSetupPy,
Expand Down Expand Up @@ -208,7 +208,7 @@ pub struct PyProjectToml {
#[serde(rename_all = "kebab-case")]
pub struct Project {
/// The name of the project
pub name: String,
pub name: PackageName,
/// The version of the project as supported by PEP 440
pub version: Option<Version>,
/// The Python version requirements of the project
Expand Down
1 change: 1 addition & 0 deletions crates/uv-requirements/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ indexmap = { workspace = true }
pyproject-toml = { workspace = true }
rustc-hash = { workspace = true }
serde = { workspace = true }
thiserror = { workspace = true }
toml = { workspace = true }
tracing = { workspace = true }
url = { workspace = true }
Expand Down
1 change: 1 addition & 0 deletions crates/uv-requirements/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ pub use crate::sources::*;
pub use crate::specification::*;

mod confirm;
mod pyproject;
mod resolver;
mod source_tree;
mod sources;
Expand Down
185 changes: 185 additions & 0 deletions crates/uv-requirements/src/pyproject.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
use indexmap::IndexMap;
use rustc_hash::FxHashSet;
use serde::{Deserialize, Serialize};
use std::str::FromStr;

use pep508_rs::Requirement;
use uv_normalize::{ExtraName, PackageName};

use crate::ExtrasSpecification;

/// A pyproject.toml as specified in PEP 517
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub(crate) struct PyProjectToml {
/// Project metadata
pub(crate) project: Option<Project>,
}

/// PEP 621 project metadata
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub(crate) struct Project {
/// The name of the project
pub(crate) name: PackageName,
/// Project dependencies
pub(crate) dependencies: Option<Vec<String>>,
/// Optional dependencies
pub(crate) optional_dependencies: Option<IndexMap<ExtraName, Vec<String>>>,
/// Specifies which fields listed by PEP 621 were intentionally unspecified
/// so another tool can/will provide such metadata dynamically.
pub(crate) dynamic: Option<Vec<String>>,
}

/// The PEP 621 project metadata, with static requirements extracted in advance.
#[derive(Debug)]
pub(crate) struct Pep621Metadata {
/// The name of the project.
pub(crate) name: PackageName,
/// The requirements extracted from the project.
pub(crate) requirements: Vec<Requirement>,
/// The extras used to collect requirements.
pub(crate) used_extras: FxHashSet<ExtraName>,
}

#[derive(thiserror::Error, Debug)]
pub(crate) enum Pep621Error {
#[error(transparent)]
Pep508(#[from] pep508_rs::Pep508Error),
}

impl Pep621Metadata {
/// Extract the static [`Pep621Metadata`] from a [`Project`] and [`ExtrasSpecification`], if
/// possible.
///
/// If the project specifies dynamic dependencies, or if the project specifies dynamic optional
/// dependencies and the extras are requested, the requirements cannot be extracted.
///
/// Returns an error if the requirements are not valid PEP 508 requirements.
pub(crate) fn try_from(
project: Project,
extras: &ExtrasSpecification,
) -> Result<Option<Self>, Pep621Error> {
if let Some(dynamic) = project.dynamic.as_ref() {
// If the project specifies dynamic dependencies, we can't extract the requirements.
if dynamic.iter().any(|field| field == "dependencies") {
return Ok(None);
}
// If we requested extras, and the project specifies dynamic optional dependencies, we can't
// extract the requirements.
if !extras.is_empty() && dynamic.iter().any(|field| field == "optional-dependencies") {
return Ok(None);
}
}

let name = project.name;

// Parse out the project requirements.
let mut requirements = project
.dependencies
.unwrap_or_default()
.iter()
.map(String::as_str)
.map(Requirement::from_str)
.collect::<Result<Vec<_>, _>>()?;

// Include any optional dependencies specified in `extras`.
let mut used_extras = FxHashSet::default();
if !extras.is_empty() {
if let Some(optional_dependencies) = project.optional_dependencies {
// Parse out the optional dependencies.
let optional_dependencies = optional_dependencies
.into_iter()
.map(|(extra, requirements)| {
let requirements = requirements
.iter()
.map(String::as_str)
.map(Requirement::from_str)
.collect::<Result<Vec<_>, _>>()?;
Ok::<(ExtraName, Vec<Requirement>), Pep621Error>((extra, requirements))
})
.collect::<Result<IndexMap<_, _>, _>>()?;

// Include the optional dependencies if the extras are requested.
for (extra, optional_requirements) in &optional_dependencies {
if extras.contains(extra) {
used_extras.insert(extra.clone());
requirements.extend(flatten_extra(
&name,
optional_requirements,
&optional_dependencies,
));
}
}
}
}

Ok(Some(Self {
name,
requirements,
used_extras,
}))
}
}

/// Given an extra in a project that may contain references to the project
/// itself, flatten it into a list of requirements.
///
/// For example:
/// ```toml
/// [project]
/// name = "my-project"
/// version = "0.0.1"
/// dependencies = [
/// "tomli",
/// ]
///
/// [project.optional-dependencies]
/// test = [
/// "pep517",
/// ]
/// dev = [
/// "my-project[test]",
/// ]
/// ```
fn flatten_extra(
project_name: &PackageName,
requirements: &[Requirement],
extras: &IndexMap<ExtraName, Vec<Requirement>>,
) -> Vec<Requirement> {
fn inner(
project_name: &PackageName,
requirements: &[Requirement],
extras: &IndexMap<ExtraName, Vec<Requirement>>,
seen: &mut FxHashSet<ExtraName>,
) -> Vec<Requirement> {
let mut flattened = Vec::with_capacity(requirements.len());
for requirement in requirements {
if requirement.name == *project_name {
for extra in &requirement.extras {
// Avoid infinite recursion on mutually recursive extras.
if !seen.insert(extra.clone()) {
continue;
}

// Flatten the extra requirements.
for (other_extra, extra_requirements) in extras {
if other_extra == extra {
flattened.extend(inner(project_name, extra_requirements, extras, seen));
}
}
}
} else {
flattened.push(requirement.clone());
}
}
flattened
}

inner(
project_name,
requirements,
extras,
&mut FxHashSet::default(),
)
}
5 changes: 1 addition & 4 deletions crates/uv-requirements/src/source_tree.rs
Original file line number Diff line number Diff line change
Expand Up @@ -92,10 +92,7 @@ impl<'a> SourceTreeResolver<'a> {
SourceDistCachedBuilder::new(context, client)
};

let metadata = builder
.download_and_build_metadata(&source)
.await
.context("Failed to build source distribution")?;
let metadata = builder.download_and_build_metadata(&source).await?;

// Determine the appropriate requirements to return based on the extras. This involves
// evaluating the `extras` expression in any markers, but preserving the remaining marker
Expand Down
Loading

0 comments on commit 39769d8

Please sign in to comment.