Skip to content

Commit

Permalink
Add support for requires.txt
Browse files Browse the repository at this point in the history
  • Loading branch information
charliermarsh committed Aug 26, 2024
1 parent 430cd30 commit 38463d7
Show file tree
Hide file tree
Showing 4 changed files with 346 additions and 13 deletions.
166 changes: 164 additions & 2 deletions crates/pypi-types/src/metadata.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
//! Derived from `pypi_types_crate`.

use std::io::BufRead;
use std::str::FromStr;

use indexmap::IndexMap;
Expand All @@ -10,7 +11,8 @@ use thiserror::Error;
use tracing::warn;

use pep440_rs::{Version, VersionParseError, VersionSpecifiers, VersionSpecifiersParseError};
use pep508_rs::{Pep508Error, Requirement};
use pep508_rs::marker::MarkerValueExtra;
use pep508_rs::{ExtraOperator, MarkerExpression, MarkerTree, Pep508Error, Requirement};
use uv_normalize::{ExtraName, InvalidNameError, PackageName};

use crate::lenient_requirement::LenientRequirement;
Expand Down Expand Up @@ -62,6 +64,8 @@ pub enum MetadataError {
DynamicField(&'static str),
#[error("The project uses Poetry's syntax to declare its dependencies, despite including a `project` table in `pyproject.toml`")]
PoetrySyntax,
#[error("Failed to read `requires.txt` contents")]
RequiresTxtContents(#[from] std::io::Error),
}

impl From<Pep508Error<VerbatimParsedUrl>> for MetadataError {
Expand Down Expand Up @@ -492,6 +496,109 @@ impl RequiresDist {
}
}

/// `requires.txt` metadata as defined in <https://setuptools.pypa.io/en/latest/deprecated/python_eggs.html#dependency-metadata>.
///
/// This is a subset of the full metadata specification, and only includes the fields that are
/// included in the legacy `requires.txt` file.
#[derive(Deserialize, Debug, Clone)]
#[serde(rename_all = "kebab-case")]
pub struct RequiresTxt {
pub requires_dist: Vec<Requirement<VerbatimParsedUrl>>,
pub provides_extras: Vec<ExtraName>,
}

impl RequiresTxt {
/// Parse the [`RequiresTxt`] from a `requires.txt` file, as included in an `egg-info`.
///
/// See: <https://setuptools.pypa.io/en/latest/deprecated/python_eggs.html#dependency-metadata>
pub fn parse(content: &[u8]) -> Result<Self, MetadataError> {
let mut requires_dist = vec![];
let mut provides_extras = vec![];
let mut current_marker = MarkerTree::default();

for line in content.lines() {
let line = line.map_err(MetadataError::RequiresTxtContents)?;

let line = line.trim();
if line.is_empty() {
continue;
}

// When encountering a new section, parse the extra and marker from the header, e.g.,
// `[:sys_platform == "win32"]` or `[dev]`.
if line.starts_with('[') {
let line = line.trim_start_matches('[').trim_end_matches(']');

// Split into extra and marker, both of which can be empty.
let (extra, marker) = {
let (extra, marker) = match line.split_once(':') {
Some((extra, marker)) => (Some(extra), Some(marker)),
None => (Some(line), None),
};
let extra = extra.filter(|extra| !extra.is_empty());
let marker = marker.filter(|marker| !marker.is_empty());
(extra, marker)
};

// Parse the extra.
let extra = if let Some(extra) = extra {
if let Ok(extra) = ExtraName::from_str(extra) {
provides_extras.push(extra.clone());
Some(MarkerValueExtra::Extra(extra))
} else {
Some(MarkerValueExtra::Arbitrary(extra.to_string()))
}
} else {
None
};

// Parse the marker.
let marker = marker.map(MarkerTree::parse_str).transpose()?;

// Create the marker tree.
match (extra, marker) {
(Some(extra), Some(mut marker)) => {
marker.and(MarkerTree::expression(MarkerExpression::Extra {
operator: ExtraOperator::Equal,
name: extra,
}));
current_marker = marker;
}
(Some(extra), None) => {
current_marker = MarkerTree::expression(MarkerExpression::Extra {
operator: ExtraOperator::Equal,
name: extra,
});
}
(None, Some(marker)) => {
current_marker = marker;
}
(None, None) => {
current_marker = MarkerTree::default();
}
}

continue;
}

// Parse the requirement.
let requirement =
Requirement::<VerbatimParsedUrl>::from(LenientRequirement::from_str(line)?);

// Add the markers and extra, if necessary.
requires_dist.push(Requirement {
marker: current_marker.clone(),
..requirement
});
}

Ok(Self {
requires_dist,
provides_extras,
})
}
}

/// The headers of a distribution metadata file.
#[derive(Debug)]
struct Headers<'a>(Vec<mailparse::MailHeader<'a>>);
Expand Down Expand Up @@ -531,7 +638,7 @@ mod tests {
use pep440_rs::Version;
use uv_normalize::PackageName;

use crate::MetadataError;
use crate::{MetadataError, RequiresTxt};

use super::Metadata23;

Expand Down Expand Up @@ -677,4 +784,59 @@ mod tests {
);
assert_eq!(meta.provides_extras, vec!["dotenv".parse().unwrap()]);
}

#[test]
fn test_requires_txt() {
let s = r"
Werkzeug>=0.14
Jinja2>=2.10
[dev]
pytest>=3
sphinx
[dotenv]
python-dotenv
";
let meta = RequiresTxt::parse(s.as_bytes()).unwrap();
assert_eq!(
meta.requires_dist,
vec![
"Werkzeug>=0.14".parse().unwrap(),
"Jinja2>=2.10".parse().unwrap(),
"pytest>=3; extra == \"dev\"".parse().unwrap(),
"sphinx; extra == \"dev\"".parse().unwrap(),
"python-dotenv; extra == \"dotenv\"".parse().unwrap(),
]
);

let s = r"
Werkzeug>=0.14
[dev:]
Jinja2>=2.10
[:sys_platform == 'win32']
pytest>=3
[]
sphinx
[dotenv:sys_platform == 'darwin']
python-dotenv
";
let meta = RequiresTxt::parse(s.as_bytes()).unwrap();
assert_eq!(
meta.requires_dist,
vec![
"Werkzeug>=0.14".parse().unwrap(),
"Jinja2>=2.10 ; extra == \"dev\"".parse().unwrap(),
"pytest>=3; sys_platform == 'win32'".parse().unwrap(),
"sphinx".parse().unwrap(),
"python-dotenv; sys_platform == 'darwin' and extra == \"dotenv\""
.parse()
.unwrap(),
]
);
}
}
6 changes: 6 additions & 0 deletions crates/uv-distribution/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,14 @@ pub enum Error {
Extract(#[from] uv_extract::Error),
#[error("The source distribution is missing a `PKG-INFO` file")]
MissingPkgInfo,
#[error("The source distribution is missing an `egg-info` directory")]
MissingEggInfo,
#[error("The source distribution is missing a `requires.txt` file")]
MissingRequiresTxt,
#[error("Failed to extract static metadata from `PKG-INFO`")]
PkgInfo(#[source] pypi_types::MetadataError),
#[error("Failed to extract metadata from `requires.txt`")]
RequiresTxt(#[source] pypi_types::MetadataError),
#[error("The source distribution is missing a `pyproject.toml` file")]
MissingPyprojectToml,
#[error("Failed to extract static metadata from `pyproject.toml`")]
Expand Down
Loading

0 comments on commit 38463d7

Please sign in to comment.