mirror of https://github.com/astral-sh/uv
2985 lines
113 KiB
Rust
2985 lines
113 KiB
Rust
//! Parses a subset of requirement.txt syntax
|
|
//!
|
|
//! <https://pip.pypa.io/en/stable/reference/requirements-file-format/>
|
|
//!
|
|
//! Supported:
|
|
//! * [PEP 508 requirements](https://packaging.python.org/en/latest/specifications/dependency-specifiers/)
|
|
//! * `-r`
|
|
//! * `-c`
|
|
//! * `--hash` (postfix)
|
|
//! * `-e`
|
|
//!
|
|
//! Unsupported:
|
|
//! * `<path>`. TBD
|
|
//! * `<archive_url>`. TBD
|
|
//! * Options without a requirement, such as `--find-links` or `--index-url`
|
|
//!
|
|
//! Grammar as implemented:
|
|
//!
|
|
//! ```text
|
|
//! file = (statement | empty ('#' any*)? '\n')*
|
|
//! empty = whitespace*
|
|
//! statement = constraint_include | requirements_include | editable_requirement | requirement
|
|
//! constraint_include = '-c' ('=' | wrappable_whitespaces) filepath
|
|
//! requirements_include = '-r' ('=' | wrappable_whitespaces) filepath
|
|
//! editable_requirement = '-e' ('=' | wrappable_whitespaces) requirement
|
|
//! # We check whether the line starts with a letter or a number, in that case we assume it's a
|
|
//! # PEP 508 requirement
|
|
//! # https://packaging.python.org/en/latest/specifications/name-normalization/#valid-non-normalized-names
|
|
//! # This does not (yet?) support plain files or urls, we use a letter or a number as first
|
|
//! # character to assume a PEP 508 requirement
|
|
//! requirement = [a-zA-Z0-9] pep508_grammar_tail wrappable_whitespaces hashes
|
|
//! hashes = ('--hash' ('=' | wrappable_whitespaces) [a-zA-Z0-9-_]+ ':' [a-zA-Z0-9-_] wrappable_whitespaces+)*
|
|
//! # This should indicate a single backslash before a newline
|
|
//! wrappable_whitespaces = whitespace ('\\\n' | whitespace)*
|
|
//! ```
|
|
|
|
use std::borrow::Cow;
|
|
use std::fmt::{Display, Formatter};
|
|
use std::io;
|
|
use std::path::{Path, PathBuf};
|
|
use std::str::FromStr;
|
|
|
|
use rustc_hash::FxHashSet;
|
|
use tracing::instrument;
|
|
use unscanny::{Pattern, Scanner};
|
|
use url::Url;
|
|
|
|
#[cfg(feature = "http")]
|
|
use uv_client::BaseClient;
|
|
use uv_client::BaseClientBuilder;
|
|
use uv_configuration::{NoBinary, NoBuild, PackageNameSpecifier};
|
|
use uv_distribution_types::{
|
|
Requirement, UnresolvedRequirement, UnresolvedRequirementSpecification,
|
|
};
|
|
use uv_fs::Simplified;
|
|
use uv_pep508::{Pep508Error, RequirementOrigin, VerbatimUrl, expand_env_vars};
|
|
use uv_pypi_types::VerbatimParsedUrl;
|
|
#[cfg(feature = "http")]
|
|
use uv_redacted::DisplaySafeUrl;
|
|
use uv_redacted::DisplaySafeUrlError;
|
|
|
|
use crate::requirement::EditableError;
|
|
pub use crate::requirement::RequirementsTxtRequirement;
|
|
use crate::shquote::unquote;
|
|
|
|
mod requirement;
|
|
mod shquote;
|
|
|
|
/// We emit one of those for each `requirements.txt` entry.
|
|
enum RequirementsTxtStatement {
|
|
/// `-r` inclusion filename
|
|
Requirements {
|
|
filename: String,
|
|
start: usize,
|
|
end: usize,
|
|
},
|
|
/// `-c` inclusion filename
|
|
Constraint {
|
|
filename: String,
|
|
start: usize,
|
|
end: usize,
|
|
},
|
|
/// PEP 508 requirement plus metadata
|
|
RequirementEntry(RequirementEntry),
|
|
/// `-e`
|
|
EditableRequirementEntry(RequirementEntry),
|
|
/// `--index-url`
|
|
IndexUrl(VerbatimUrl),
|
|
/// `--extra-index-url`
|
|
ExtraIndexUrl(VerbatimUrl),
|
|
/// `--find-links`
|
|
FindLinks(VerbatimUrl),
|
|
/// `--no-index`
|
|
NoIndex,
|
|
/// `--no-binary`
|
|
NoBinary(NoBinary),
|
|
/// `--only-binary`
|
|
OnlyBinary(NoBuild),
|
|
/// An unsupported option (e.g., `--trusted-host`).
|
|
UnsupportedOption(UnsupportedOption),
|
|
}
|
|
|
|
/// A [Requirement] with additional metadata from the `requirements.txt`, currently only hashes but in
|
|
/// the future also editable and similar information.
|
|
#[derive(Debug, Clone, Eq, PartialEq, Hash)]
|
|
pub struct RequirementEntry {
|
|
/// The actual PEP 508 requirement.
|
|
pub requirement: RequirementsTxtRequirement,
|
|
/// Hashes of the downloadable packages.
|
|
pub hashes: Vec<String>,
|
|
}
|
|
|
|
// We place the impl here instead of next to `UnresolvedRequirementSpecification` because
|
|
// `UnresolvedRequirementSpecification` is defined in `distribution-types` and `requirements-txt`
|
|
// depends on `distribution-types`.
|
|
impl From<RequirementEntry> for UnresolvedRequirementSpecification {
|
|
fn from(value: RequirementEntry) -> Self {
|
|
Self {
|
|
requirement: match value.requirement {
|
|
RequirementsTxtRequirement::Named(named) => {
|
|
UnresolvedRequirement::Named(Requirement::from(named))
|
|
}
|
|
RequirementsTxtRequirement::Unnamed(unnamed) => {
|
|
UnresolvedRequirement::Unnamed(unnamed)
|
|
}
|
|
},
|
|
hashes: value.hashes,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<RequirementsTxtRequirement> for UnresolvedRequirementSpecification {
|
|
fn from(value: RequirementsTxtRequirement) -> Self {
|
|
Self::from(RequirementEntry {
|
|
requirement: value,
|
|
hashes: vec![],
|
|
})
|
|
}
|
|
}
|
|
|
|
/// Parsed and flattened requirements.txt with requirements and constraints
|
|
#[derive(Debug, Default, Clone, PartialEq, Eq)]
|
|
pub struct RequirementsTxt {
|
|
/// The actual requirements with the hashes.
|
|
pub requirements: Vec<RequirementEntry>,
|
|
/// Constraints included with `-c`.
|
|
pub constraints: Vec<uv_pep508::Requirement<VerbatimParsedUrl>>,
|
|
/// Editables with `-e`.
|
|
pub editables: Vec<RequirementEntry>,
|
|
/// The index URL, specified with `--index-url`.
|
|
pub index_url: Option<VerbatimUrl>,
|
|
/// The extra index URLs, specified with `--extra-index-url`.
|
|
pub extra_index_urls: Vec<VerbatimUrl>,
|
|
/// The find links locations, specified with `--find-links`.
|
|
pub find_links: Vec<VerbatimUrl>,
|
|
/// Whether to ignore the index, specified with `--no-index`.
|
|
pub no_index: bool,
|
|
/// Whether to disallow wheels, specified with `--no-binary`.
|
|
pub no_binary: NoBinary,
|
|
/// Whether to allow only wheels, specified with `--only-binary`.
|
|
pub only_binary: NoBuild,
|
|
}
|
|
|
|
impl RequirementsTxt {
|
|
/// See module level documentation
|
|
#[instrument(
|
|
skip_all,
|
|
fields(requirements_txt = requirements_txt.as_ref().as_os_str().to_str())
|
|
)]
|
|
pub async fn parse(
|
|
requirements_txt: impl AsRef<Path>,
|
|
working_dir: impl AsRef<Path>,
|
|
client_builder: &BaseClientBuilder<'_>,
|
|
) -> Result<Self, RequirementsTxtFileError> {
|
|
let mut visited = VisitedFiles::Requirements {
|
|
requirements: &mut FxHashSet::default(),
|
|
constraints: &mut FxHashSet::default(),
|
|
};
|
|
Self::parse_impl(requirements_txt, working_dir, client_builder, &mut visited).await
|
|
}
|
|
|
|
/// See module level documentation
|
|
#[instrument(
|
|
skip_all,
|
|
fields(requirements_txt = requirements_txt.as_ref().as_os_str().to_str())
|
|
)]
|
|
async fn parse_impl(
|
|
requirements_txt: impl AsRef<Path>,
|
|
working_dir: impl AsRef<Path>,
|
|
client_builder: &BaseClientBuilder<'_>,
|
|
visited: &mut VisitedFiles<'_>,
|
|
) -> Result<Self, RequirementsTxtFileError> {
|
|
let requirements_txt = requirements_txt.as_ref();
|
|
let working_dir = working_dir.as_ref();
|
|
|
|
let content =
|
|
if requirements_txt.starts_with("http://") | requirements_txt.starts_with("https://") {
|
|
#[cfg(not(feature = "http"))]
|
|
{
|
|
return Err(RequirementsTxtFileError {
|
|
file: requirements_txt.to_path_buf(),
|
|
error: RequirementsTxtParserError::Io(io::Error::new(
|
|
io::ErrorKind::InvalidInput,
|
|
"Remote file not supported without `http` feature",
|
|
)),
|
|
});
|
|
}
|
|
|
|
#[cfg(feature = "http")]
|
|
{
|
|
// Avoid constructing a client if network is disabled already
|
|
if client_builder.is_offline() {
|
|
return Err(RequirementsTxtFileError {
|
|
file: requirements_txt.to_path_buf(),
|
|
error: RequirementsTxtParserError::Io(io::Error::new(
|
|
io::ErrorKind::InvalidInput,
|
|
format!("Network connectivity is disabled, but a remote requirements file was requested: {}", requirements_txt.display()),
|
|
)),
|
|
});
|
|
}
|
|
|
|
let client = client_builder.build();
|
|
read_url_to_string(&requirements_txt, client).await
|
|
}
|
|
} else {
|
|
// Ex) `file:///home/ferris/project/requirements.txt`
|
|
uv_fs::read_to_string_transcode(&requirements_txt)
|
|
.await
|
|
.map_err(RequirementsTxtParserError::Io)
|
|
}
|
|
.map_err(|err| RequirementsTxtFileError {
|
|
file: requirements_txt.to_path_buf(),
|
|
error: err,
|
|
})?;
|
|
|
|
let requirements_dir = requirements_txt.parent().unwrap_or(working_dir);
|
|
let data = Self::parse_inner(
|
|
&content,
|
|
working_dir,
|
|
requirements_dir,
|
|
client_builder,
|
|
requirements_txt,
|
|
visited,
|
|
)
|
|
.await
|
|
.map_err(|err| RequirementsTxtFileError {
|
|
file: requirements_txt.to_path_buf(),
|
|
error: err,
|
|
})?;
|
|
|
|
Ok(data)
|
|
}
|
|
|
|
/// See module level documentation.
|
|
///
|
|
/// When parsing, relative paths to requirements (e.g., `-e ../editable/`) are resolved against
|
|
/// the current working directory. However, relative paths to sub-files (e.g., `-r ../requirements.txt`)
|
|
/// are resolved against the directory of the containing `requirements.txt` file, to match
|
|
/// `pip`'s behavior.
|
|
async fn parse_inner(
|
|
content: &str,
|
|
working_dir: &Path,
|
|
requirements_dir: &Path,
|
|
client_builder: &BaseClientBuilder<'_>,
|
|
requirements_txt: &Path,
|
|
visited: &mut VisitedFiles<'_>,
|
|
) -> Result<Self, RequirementsTxtParserError> {
|
|
let mut s = Scanner::new(content);
|
|
|
|
let mut data = Self::default();
|
|
while let Some(statement) = parse_entry(&mut s, content, working_dir, requirements_txt)? {
|
|
match statement {
|
|
RequirementsTxtStatement::Requirements {
|
|
filename,
|
|
start,
|
|
end,
|
|
} => {
|
|
let filename = expand_env_vars(&filename);
|
|
let sub_file =
|
|
if filename.starts_with("http://") || filename.starts_with("https://") {
|
|
PathBuf::from(filename.as_ref())
|
|
} else if filename.starts_with("file://") {
|
|
requirements_txt.join(
|
|
Url::parse(filename.as_ref())
|
|
.map_err(|err| RequirementsTxtParserError::Url {
|
|
source: DisplaySafeUrlError::Url(err).into(),
|
|
url: filename.to_string(),
|
|
start,
|
|
end,
|
|
})?
|
|
.to_file_path()
|
|
.map_err(|()| RequirementsTxtParserError::FileUrl {
|
|
url: filename.to_string(),
|
|
start,
|
|
end,
|
|
})?,
|
|
)
|
|
} else {
|
|
requirements_dir.join(filename.as_ref())
|
|
};
|
|
match visited {
|
|
VisitedFiles::Requirements { requirements, .. } => {
|
|
if !requirements.insert(sub_file.clone()) {
|
|
continue;
|
|
}
|
|
}
|
|
// Treat any nested requirements or constraints as constraints. This differs
|
|
// from `pip`, which seems to treat `-r` requirements in constraints files as
|
|
// _requirements_, but we don't want to support that.
|
|
VisitedFiles::Constraints { constraints } => {
|
|
if !constraints.insert(sub_file.clone()) {
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
let sub_requirements = Box::pin(Self::parse_impl(
|
|
&sub_file,
|
|
working_dir,
|
|
client_builder,
|
|
visited,
|
|
))
|
|
.await
|
|
.map_err(|err| RequirementsTxtParserError::Subfile {
|
|
source: Box::new(err),
|
|
start,
|
|
end,
|
|
})?;
|
|
|
|
// Disallow conflicting `--index-url` in nested `requirements` files.
|
|
if sub_requirements.index_url.is_some()
|
|
&& data.index_url.is_some()
|
|
&& sub_requirements.index_url != data.index_url
|
|
{
|
|
let (line, column) = calculate_row_column(content, s.cursor());
|
|
return Err(RequirementsTxtParserError::Parser {
|
|
message:
|
|
"Nested `requirements` file contains conflicting `--index-url`"
|
|
.to_string(),
|
|
line,
|
|
column,
|
|
});
|
|
}
|
|
|
|
// Add each to the correct category.
|
|
data.update_from(sub_requirements);
|
|
}
|
|
RequirementsTxtStatement::Constraint {
|
|
filename,
|
|
start,
|
|
end,
|
|
} => {
|
|
let filename = expand_env_vars(&filename);
|
|
let sub_file =
|
|
if filename.starts_with("http://") || filename.starts_with("https://") {
|
|
PathBuf::from(filename.as_ref())
|
|
} else if filename.starts_with("file://") {
|
|
requirements_txt.join(
|
|
Url::parse(filename.as_ref())
|
|
.map_err(|err| RequirementsTxtParserError::Url {
|
|
source: DisplaySafeUrlError::Url(err).into(),
|
|
url: filename.to_string(),
|
|
start,
|
|
end,
|
|
})?
|
|
.to_file_path()
|
|
.map_err(|()| RequirementsTxtParserError::FileUrl {
|
|
url: filename.to_string(),
|
|
start,
|
|
end,
|
|
})?,
|
|
)
|
|
} else {
|
|
requirements_dir.join(filename.as_ref())
|
|
};
|
|
|
|
// Switch to constraints mode, if we aren't in it already.
|
|
let mut visited = match visited {
|
|
VisitedFiles::Requirements { constraints, .. } => {
|
|
if !constraints.insert(sub_file.clone()) {
|
|
continue;
|
|
}
|
|
VisitedFiles::Constraints { constraints }
|
|
}
|
|
VisitedFiles::Constraints { constraints } => {
|
|
if !constraints.insert(sub_file.clone()) {
|
|
continue;
|
|
}
|
|
VisitedFiles::Constraints { constraints }
|
|
}
|
|
};
|
|
|
|
let sub_constraints = Box::pin(Self::parse_impl(
|
|
&sub_file,
|
|
working_dir,
|
|
client_builder,
|
|
&mut visited,
|
|
))
|
|
.await
|
|
.map_err(|err| RequirementsTxtParserError::Subfile {
|
|
source: Box::new(err),
|
|
start,
|
|
end,
|
|
})?;
|
|
|
|
// Treat any nested requirements or constraints as constraints. This differs
|
|
// from `pip`, which seems to treat `-r` requirements in constraints files as
|
|
// _requirements_, but we don't want to support that.
|
|
for entry in sub_constraints.requirements {
|
|
match entry.requirement {
|
|
RequirementsTxtRequirement::Named(requirement) => {
|
|
data.constraints.push(requirement);
|
|
}
|
|
RequirementsTxtRequirement::Unnamed(_) => {
|
|
return Err(RequirementsTxtParserError::UnnamedConstraint {
|
|
start,
|
|
end,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
for constraint in sub_constraints.constraints {
|
|
data.constraints.push(constraint);
|
|
}
|
|
}
|
|
RequirementsTxtStatement::RequirementEntry(requirement_entry) => {
|
|
data.requirements.push(requirement_entry);
|
|
}
|
|
RequirementsTxtStatement::EditableRequirementEntry(editable) => {
|
|
data.editables.push(editable);
|
|
}
|
|
RequirementsTxtStatement::IndexUrl(url) => {
|
|
if data.index_url.is_some() {
|
|
let (line, column) = calculate_row_column(content, s.cursor());
|
|
return Err(RequirementsTxtParserError::Parser {
|
|
message: "Multiple `--index-url` values provided".to_string(),
|
|
line,
|
|
column,
|
|
});
|
|
}
|
|
data.index_url = Some(url);
|
|
}
|
|
RequirementsTxtStatement::ExtraIndexUrl(url) => {
|
|
data.extra_index_urls.push(url);
|
|
}
|
|
RequirementsTxtStatement::FindLinks(url) => {
|
|
data.find_links.push(url);
|
|
}
|
|
RequirementsTxtStatement::NoIndex => {
|
|
data.no_index = true;
|
|
}
|
|
RequirementsTxtStatement::NoBinary(no_binary) => {
|
|
data.no_binary.extend(no_binary);
|
|
}
|
|
RequirementsTxtStatement::OnlyBinary(only_binary) => {
|
|
data.only_binary.extend(only_binary);
|
|
}
|
|
RequirementsTxtStatement::UnsupportedOption(flag) => {
|
|
if requirements_txt == Path::new("-") {
|
|
if flag.cli() {
|
|
uv_warnings::warn_user!(
|
|
"Ignoring unsupported option from stdin: `{flag}` (hint: pass `{flag}` on the command line instead)",
|
|
flag = flag.green()
|
|
);
|
|
} else {
|
|
uv_warnings::warn_user!(
|
|
"Ignoring unsupported option from stdin: `{flag}`",
|
|
flag = flag.green()
|
|
);
|
|
}
|
|
} else {
|
|
if flag.cli() {
|
|
uv_warnings::warn_user!(
|
|
"Ignoring unsupported option in `{path}`: `{flag}` (hint: pass `{flag}` on the command line instead)",
|
|
path = requirements_txt.user_display().cyan(),
|
|
flag = flag.green()
|
|
);
|
|
} else {
|
|
uv_warnings::warn_user!(
|
|
"Ignoring unsupported option in `{path}`: `{flag}`",
|
|
path = requirements_txt.user_display().cyan(),
|
|
flag = flag.green()
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Ok(data)
|
|
}
|
|
|
|
/// Merge the data from a nested `requirements` file (`other`) into this one.
|
|
pub fn update_from(&mut self, other: Self) {
|
|
let Self {
|
|
requirements,
|
|
constraints,
|
|
editables,
|
|
index_url,
|
|
extra_index_urls,
|
|
find_links,
|
|
no_index,
|
|
no_binary,
|
|
only_binary,
|
|
} = other;
|
|
self.requirements.extend(requirements);
|
|
self.constraints.extend(constraints);
|
|
self.editables.extend(editables);
|
|
if self.index_url.is_none() {
|
|
self.index_url = index_url;
|
|
}
|
|
self.extra_index_urls.extend(extra_index_urls);
|
|
self.find_links.extend(find_links);
|
|
self.no_index = self.no_index || no_index;
|
|
self.no_binary.extend(no_binary);
|
|
self.only_binary.extend(only_binary);
|
|
}
|
|
}
|
|
|
|
/// An unsupported option (e.g., `--trusted-host`).
|
|
///
|
|
/// See: <https://pip.pypa.io/en/stable/reference/requirements-file-format/#global-options>
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum UnsupportedOption {
|
|
PreferBinary,
|
|
RequireHashes,
|
|
Pre,
|
|
TrustedHost,
|
|
UseFeature,
|
|
}
|
|
|
|
impl UnsupportedOption {
|
|
/// The name of the unsupported option.
|
|
fn name(self) -> &'static str {
|
|
match self {
|
|
Self::PreferBinary => "--prefer-binary",
|
|
Self::RequireHashes => "--require-hashes",
|
|
Self::Pre => "--pre",
|
|
Self::TrustedHost => "--trusted-host",
|
|
Self::UseFeature => "--use-feature",
|
|
}
|
|
}
|
|
|
|
/// Returns `true` if the option is supported on the CLI.
|
|
fn cli(self) -> bool {
|
|
match self {
|
|
Self::PreferBinary => false,
|
|
Self::RequireHashes => true,
|
|
Self::Pre => true,
|
|
Self::TrustedHost => true,
|
|
Self::UseFeature => false,
|
|
}
|
|
}
|
|
|
|
/// Returns an iterator over all unsupported options.
|
|
fn iter() -> impl Iterator<Item = Self> {
|
|
[
|
|
Self::PreferBinary,
|
|
Self::RequireHashes,
|
|
Self::Pre,
|
|
Self::TrustedHost,
|
|
Self::UseFeature,
|
|
]
|
|
.iter()
|
|
.copied()
|
|
}
|
|
}
|
|
|
|
impl Display for UnsupportedOption {
|
|
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
|
write!(f, "{}", self.name())
|
|
}
|
|
}
|
|
|
|
/// Returns `true` if the character is a newline or a comment character.
|
|
const fn is_terminal(c: char) -> bool {
|
|
matches!(c, '\n' | '\r' | '#')
|
|
}
|
|
|
|
/// Parse a single entry, that is a requirement, an inclusion or a comment line.
|
|
///
|
|
/// Consumes all preceding trivia (whitespace and comments). If it returns `None`, we've reached
|
|
/// the end of file.
|
|
fn parse_entry(
|
|
s: &mut Scanner,
|
|
content: &str,
|
|
working_dir: &Path,
|
|
requirements_txt: &Path,
|
|
) -> Result<Option<RequirementsTxtStatement>, RequirementsTxtParserError> {
|
|
// Eat all preceding whitespace, this may run us to the end of file
|
|
eat_wrappable_whitespace(s);
|
|
while s.at(['\n', '\r', '#']) {
|
|
// skip comments
|
|
eat_trailing_line(content, s)?;
|
|
eat_wrappable_whitespace(s);
|
|
}
|
|
|
|
let start = s.cursor();
|
|
Ok(Some(if s.eat_if("-r") || s.eat_if("--requirement") {
|
|
let filename = parse_value("--requirement", content, s, |c: char| !is_terminal(c))?;
|
|
let filename = unquote(filename)
|
|
.ok()
|
|
.flatten()
|
|
.unwrap_or_else(|| filename.to_string());
|
|
let end = s.cursor();
|
|
RequirementsTxtStatement::Requirements {
|
|
filename,
|
|
start,
|
|
end,
|
|
}
|
|
} else if s.eat_if("-c") || s.eat_if("--constraint") {
|
|
let filename = parse_value("--constraint", content, s, |c: char| !is_terminal(c))?;
|
|
let filename = unquote(filename)
|
|
.ok()
|
|
.flatten()
|
|
.unwrap_or_else(|| filename.to_string());
|
|
let end = s.cursor();
|
|
RequirementsTxtStatement::Constraint {
|
|
filename,
|
|
start,
|
|
end,
|
|
}
|
|
} else if s.eat_if("-e") || s.eat_if("--editable") {
|
|
if s.eat_if('=') {
|
|
// Explicit equals sign.
|
|
} else if s.eat_if(char::is_whitespace) {
|
|
// Key and value are separated by whitespace instead.
|
|
s.eat_whitespace();
|
|
} else {
|
|
let (line, column) = calculate_row_column(content, s.cursor());
|
|
return Err(RequirementsTxtParserError::Parser {
|
|
message: format!("Expected '=' or whitespace, found {:?}", s.peek()),
|
|
line,
|
|
column,
|
|
});
|
|
}
|
|
|
|
let source = if requirements_txt == Path::new("-") {
|
|
None
|
|
} else {
|
|
Some(requirements_txt)
|
|
};
|
|
|
|
let (requirement, hashes) =
|
|
parse_requirement_and_hashes(s, content, source, working_dir, true)?;
|
|
let requirement =
|
|
requirement
|
|
.into_editable()
|
|
.map_err(|err| RequirementsTxtParserError::NonEditable {
|
|
source: err,
|
|
start,
|
|
end: s.cursor(),
|
|
})?;
|
|
RequirementsTxtStatement::EditableRequirementEntry(RequirementEntry {
|
|
requirement,
|
|
hashes,
|
|
})
|
|
} else if s.eat_if("-i") || s.eat_if("--index-url") {
|
|
let given = parse_value("--index-url", content, s, |c: char| !is_terminal(c))?;
|
|
let given = unquote(given)
|
|
.ok()
|
|
.flatten()
|
|
.map(Cow::Owned)
|
|
.unwrap_or(Cow::Borrowed(given));
|
|
let expanded = expand_env_vars(given.as_ref());
|
|
let url = if let Some(path) = std::path::absolute(expanded.as_ref())
|
|
.ok()
|
|
.filter(|path| path.exists())
|
|
{
|
|
VerbatimUrl::from_absolute_path(path).map_err(|err| {
|
|
RequirementsTxtParserError::VerbatimUrl {
|
|
source: err,
|
|
url: given.to_string(),
|
|
start,
|
|
end: s.cursor(),
|
|
}
|
|
})?
|
|
} else {
|
|
VerbatimUrl::parse_url(expanded.as_ref()).map_err(|err| {
|
|
RequirementsTxtParserError::Url {
|
|
source: err,
|
|
url: given.to_string(),
|
|
start,
|
|
end: s.cursor(),
|
|
}
|
|
})?
|
|
};
|
|
RequirementsTxtStatement::IndexUrl(url.with_given(given))
|
|
} else if s.eat_if("--extra-index-url") {
|
|
let given = parse_value("--extra-index-url", content, s, |c: char| !is_terminal(c))?;
|
|
let given = unquote(given)
|
|
.ok()
|
|
.flatten()
|
|
.map(Cow::Owned)
|
|
.unwrap_or(Cow::Borrowed(given));
|
|
let expanded = expand_env_vars(given.as_ref());
|
|
let url = if let Some(path) = std::path::absolute(expanded.as_ref())
|
|
.ok()
|
|
.filter(|path| path.exists())
|
|
{
|
|
VerbatimUrl::from_absolute_path(path).map_err(|err| {
|
|
RequirementsTxtParserError::VerbatimUrl {
|
|
source: err,
|
|
url: given.to_string(),
|
|
start,
|
|
end: s.cursor(),
|
|
}
|
|
})?
|
|
} else {
|
|
VerbatimUrl::parse_url(expanded.as_ref()).map_err(|err| {
|
|
RequirementsTxtParserError::Url {
|
|
source: err,
|
|
url: given.to_string(),
|
|
start,
|
|
end: s.cursor(),
|
|
}
|
|
})?
|
|
};
|
|
RequirementsTxtStatement::ExtraIndexUrl(url.with_given(given))
|
|
} else if s.eat_if("--no-index") {
|
|
RequirementsTxtStatement::NoIndex
|
|
} else if s.eat_if("--find-links") || s.eat_if("-f") {
|
|
let given = parse_value("--find-links", content, s, |c: char| !is_terminal(c))?;
|
|
let given = unquote(given)
|
|
.ok()
|
|
.flatten()
|
|
.map(Cow::Owned)
|
|
.unwrap_or(Cow::Borrowed(given));
|
|
let expanded = expand_env_vars(given.as_ref());
|
|
let url = if let Some(path) = std::path::absolute(expanded.as_ref())
|
|
.ok()
|
|
.filter(|path| path.exists())
|
|
{
|
|
VerbatimUrl::from_absolute_path(path).map_err(|err| {
|
|
RequirementsTxtParserError::VerbatimUrl {
|
|
source: err,
|
|
url: given.to_string(),
|
|
start,
|
|
end: s.cursor(),
|
|
}
|
|
})?
|
|
} else {
|
|
VerbatimUrl::parse_url(expanded.as_ref()).map_err(|err| {
|
|
RequirementsTxtParserError::Url {
|
|
source: err,
|
|
url: given.to_string(),
|
|
start,
|
|
end: s.cursor(),
|
|
}
|
|
})?
|
|
};
|
|
RequirementsTxtStatement::FindLinks(url.with_given(given))
|
|
} else if s.eat_if("--no-binary") {
|
|
let given = parse_value("--no-binary", content, s, |c: char| !is_terminal(c))?;
|
|
let given = unquote(given)
|
|
.ok()
|
|
.flatten()
|
|
.map(Cow::Owned)
|
|
.unwrap_or(Cow::Borrowed(given));
|
|
let specifier = PackageNameSpecifier::from_str(given.as_ref()).map_err(|err| {
|
|
RequirementsTxtParserError::NoBinary {
|
|
source: err,
|
|
specifier: given.to_string(),
|
|
start,
|
|
end: s.cursor(),
|
|
}
|
|
})?;
|
|
RequirementsTxtStatement::NoBinary(NoBinary::from_pip_arg(specifier))
|
|
} else if s.eat_if("--only-binary") {
|
|
let given = parse_value("--only-binary", content, s, |c: char| !is_terminal(c))?;
|
|
let given = unquote(given)
|
|
.ok()
|
|
.flatten()
|
|
.map(Cow::Owned)
|
|
.unwrap_or(Cow::Borrowed(given));
|
|
let specifier = PackageNameSpecifier::from_str(given.as_ref()).map_err(|err| {
|
|
RequirementsTxtParserError::NoBinary {
|
|
source: err,
|
|
specifier: given.to_string(),
|
|
start,
|
|
end: s.cursor(),
|
|
}
|
|
})?;
|
|
RequirementsTxtStatement::OnlyBinary(NoBuild::from_pip_arg(specifier))
|
|
} else if s.at(char::is_ascii_alphanumeric) || s.at(|char| matches!(char, '.' | '/' | '$')) {
|
|
let source = if requirements_txt == Path::new("-") {
|
|
None
|
|
} else {
|
|
Some(requirements_txt)
|
|
};
|
|
|
|
let (requirement, hashes) =
|
|
parse_requirement_and_hashes(s, content, source, working_dir, false)?;
|
|
RequirementsTxtStatement::RequirementEntry(RequirementEntry {
|
|
requirement,
|
|
hashes,
|
|
})
|
|
} else if let Some(char) = s.peek() {
|
|
// Identify an unsupported option, like `--trusted-host`.
|
|
if let Some(option) = UnsupportedOption::iter().find(|option| s.eat_if(option.name())) {
|
|
s.eat_while(|c: char| !is_terminal(c));
|
|
RequirementsTxtStatement::UnsupportedOption(option)
|
|
} else {
|
|
let (line, column) = calculate_row_column(content, s.cursor());
|
|
return Err(RequirementsTxtParserError::Parser {
|
|
message: format!(
|
|
"Unexpected '{char}', expected '-c', '-e', '-r' or the start of a requirement"
|
|
),
|
|
line,
|
|
column,
|
|
});
|
|
}
|
|
} else {
|
|
// EOF
|
|
return Ok(None);
|
|
}))
|
|
}
|
|
|
|
/// Eat whitespace and ignore newlines escaped with a backslash
|
|
fn eat_wrappable_whitespace<'a>(s: &mut Scanner<'a>) -> &'a str {
|
|
let start = s.cursor();
|
|
s.eat_while([' ', '\t']);
|
|
// Allow multiple escaped line breaks
|
|
// With the order we support `\n`, `\r`, `\r\n` without accidentally eating a `\n\r`
|
|
while s.eat_if("\\\n") || s.eat_if("\\\r\n") || s.eat_if("\\\r") {
|
|
s.eat_while([' ', '\t']);
|
|
}
|
|
s.from(start)
|
|
}
|
|
|
|
/// Eats the end of line or a potential trailing comma
|
|
fn eat_trailing_line(content: &str, s: &mut Scanner) -> Result<(), RequirementsTxtParserError> {
|
|
s.eat_while([' ', '\t']);
|
|
match s.eat() {
|
|
None | Some('\n') => {} // End of file or end of line, nothing to do
|
|
Some('\r') => {
|
|
s.eat_if('\n'); // `\r\n`, but just `\r` is also accepted
|
|
}
|
|
Some('#') => {
|
|
s.eat_until(['\r', '\n']);
|
|
if s.at('\r') {
|
|
s.eat_if('\n'); // `\r\n`, but just `\r` is also accepted
|
|
}
|
|
}
|
|
Some(other) => {
|
|
let (line, column) = calculate_row_column(content, s.cursor());
|
|
return Err(RequirementsTxtParserError::Parser {
|
|
message: format!("Expected comment or end-of-line, found `{other}`"),
|
|
line,
|
|
column,
|
|
});
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Parse a PEP 508 requirement with optional trailing hashes
|
|
fn parse_requirement_and_hashes(
|
|
s: &mut Scanner,
|
|
content: &str,
|
|
source: Option<&Path>,
|
|
working_dir: &Path,
|
|
editable: bool,
|
|
) -> Result<(RequirementsTxtRequirement, Vec<String>), RequirementsTxtParserError> {
|
|
// PEP 508 requirement
|
|
let start = s.cursor();
|
|
// Termination: s.eat() eventually becomes None
|
|
let (end, has_hashes) = loop {
|
|
let end = s.cursor();
|
|
|
|
// We look for the end of the line ...
|
|
if s.eat_if('\n') {
|
|
break (end, false);
|
|
}
|
|
if s.eat_if('\r') {
|
|
s.eat_if('\n'); // Support `\r\n` but also accept stray `\r`
|
|
break (end, false);
|
|
}
|
|
// ... or `--hash`, an escaped newline or a comment separated by whitespace ...
|
|
if !eat_wrappable_whitespace(s).is_empty() {
|
|
if s.after().starts_with("--") {
|
|
break (end, true);
|
|
} else if s.eat_if('#') {
|
|
s.eat_until(['\r', '\n']);
|
|
if s.at('\r') {
|
|
s.eat_if('\n'); // `\r\n`, but just `\r` is also accepted
|
|
}
|
|
break (end, false);
|
|
}
|
|
continue;
|
|
}
|
|
// ... or the end of the file, which works like the end of line
|
|
if s.eat().is_none() {
|
|
break (end, false);
|
|
}
|
|
};
|
|
|
|
let requirement = &content[start..end];
|
|
|
|
// If the requirement looks like a `requirements.txt` file (with a missing `-r`), raise an
|
|
// error.
|
|
//
|
|
// While `requirements.txt` is a valid package name (per the spec), PyPI disallows
|
|
// `requirements.txt` and some other variants anyway.
|
|
#[allow(clippy::case_sensitive_file_extension_comparisons)]
|
|
if requirement.ends_with(".txt") || requirement.ends_with(".in") {
|
|
let path = Path::new(requirement);
|
|
let path = if path.is_absolute() {
|
|
Cow::Borrowed(path)
|
|
} else {
|
|
Cow::Owned(working_dir.join(path))
|
|
};
|
|
if path.is_file() {
|
|
return Err(RequirementsTxtParserError::MissingRequirementPrefix(
|
|
requirement.to_string(),
|
|
));
|
|
}
|
|
}
|
|
|
|
let requirement = RequirementsTxtRequirement::parse(requirement, working_dir, editable)
|
|
.map(|requirement| {
|
|
if let Some(source) = source {
|
|
requirement.with_origin(RequirementOrigin::File(source.to_path_buf()))
|
|
} else {
|
|
requirement
|
|
}
|
|
})
|
|
.map_err(|err| RequirementsTxtParserError::Pep508 {
|
|
source: err,
|
|
start,
|
|
end,
|
|
})?;
|
|
|
|
let hashes = if has_hashes {
|
|
parse_hashes(content, s)?
|
|
} else {
|
|
Vec::new()
|
|
};
|
|
Ok((requirement, hashes))
|
|
}
|
|
|
|
/// Parse `--hash=... --hash ...` after a requirement
|
|
fn parse_hashes(content: &str, s: &mut Scanner) -> Result<Vec<String>, RequirementsTxtParserError> {
|
|
let mut hashes = Vec::new();
|
|
if s.eat_while("--hash").is_empty() {
|
|
let (line, column) = calculate_row_column(content, s.cursor());
|
|
return Err(RequirementsTxtParserError::Parser {
|
|
message: format!(
|
|
"Expected `--hash`, found `{:?}`",
|
|
s.eat_while(|c: char| !c.is_whitespace())
|
|
),
|
|
line,
|
|
column,
|
|
});
|
|
}
|
|
let hash = parse_value("--hash", content, s, |c: char| !c.is_whitespace())?;
|
|
hashes.push(hash.to_string());
|
|
loop {
|
|
eat_wrappable_whitespace(s);
|
|
if !s.eat_if("--hash") {
|
|
break;
|
|
}
|
|
let hash = parse_value("--hash", content, s, |c: char| !c.is_whitespace())?;
|
|
hashes.push(hash.to_string());
|
|
}
|
|
Ok(hashes)
|
|
}
|
|
|
|
/// In `-<key>=<value>` or `-<key> value`, this parses the part after the key
|
|
fn parse_value<'a, T>(
|
|
option: &str,
|
|
content: &str,
|
|
s: &mut Scanner<'a>,
|
|
while_pattern: impl Pattern<T>,
|
|
) -> Result<&'a str, RequirementsTxtParserError> {
|
|
let value = if s.eat_if('=') {
|
|
// Explicit equals sign.
|
|
s.eat_while(while_pattern).trim_end()
|
|
} else if s.eat_if(char::is_whitespace) {
|
|
// Key and value are separated by whitespace instead.
|
|
s.eat_whitespace();
|
|
s.eat_while(while_pattern).trim_end()
|
|
} else {
|
|
let (line, column) = calculate_row_column(content, s.cursor());
|
|
return Err(RequirementsTxtParserError::Parser {
|
|
message: format!("Expected '=' or whitespace, found {:?}", s.peek()),
|
|
line,
|
|
column,
|
|
});
|
|
};
|
|
|
|
if value.is_empty() {
|
|
let (line, column) = calculate_row_column(content, s.cursor());
|
|
return Err(RequirementsTxtParserError::Parser {
|
|
message: format!("`{option}` must be followed by an argument"),
|
|
line,
|
|
column,
|
|
});
|
|
}
|
|
|
|
Ok(value)
|
|
}
|
|
|
|
/// Fetch the contents of a URL and return them as a string.
|
|
#[cfg(feature = "http")]
|
|
async fn read_url_to_string(
|
|
path: impl AsRef<Path>,
|
|
client: BaseClient,
|
|
) -> Result<String, RequirementsTxtParserError> {
|
|
// pip would URL-encode the non-UTF-8 bytes of the string; we just don't support them.
|
|
let path_utf8 =
|
|
path.as_ref()
|
|
.to_str()
|
|
.ok_or_else(|| RequirementsTxtParserError::NonUnicodeUrl {
|
|
url: path.as_ref().to_owned(),
|
|
})?;
|
|
|
|
let url = DisplaySafeUrl::from_str(path_utf8)
|
|
.map_err(|err| RequirementsTxtParserError::InvalidUrl(path_utf8.to_string(), err))?;
|
|
let response = client
|
|
.for_host(&url)
|
|
.get(Url::from(url.clone()))
|
|
.send()
|
|
.await
|
|
.map_err(|err| RequirementsTxtParserError::from_reqwest_middleware(url.clone(), err))?;
|
|
let text = response
|
|
.error_for_status()
|
|
.map_err(|err| RequirementsTxtParserError::from_reqwest(url.clone(), err))?
|
|
.text()
|
|
.await
|
|
.map_err(|err| RequirementsTxtParserError::from_reqwest(url.clone(), err))?;
|
|
Ok(text)
|
|
}
|
|
|
|
/// Error parsing requirements.txt, wrapper with filename
|
|
#[derive(Debug)]
|
|
pub struct RequirementsTxtFileError {
|
|
file: PathBuf,
|
|
error: RequirementsTxtParserError,
|
|
}
|
|
|
|
/// Error parsing requirements.txt, error disambiguation
|
|
#[derive(Debug)]
|
|
pub enum RequirementsTxtParserError {
|
|
Io(io::Error),
|
|
Url {
|
|
source: uv_pep508::VerbatimUrlError,
|
|
url: String,
|
|
start: usize,
|
|
end: usize,
|
|
},
|
|
FileUrl {
|
|
url: String,
|
|
start: usize,
|
|
end: usize,
|
|
},
|
|
VerbatimUrl {
|
|
source: uv_pep508::VerbatimUrlError,
|
|
url: String,
|
|
start: usize,
|
|
end: usize,
|
|
},
|
|
UrlConversion(String),
|
|
UnsupportedUrl(String),
|
|
MissingRequirementPrefix(String),
|
|
NonEditable {
|
|
source: EditableError,
|
|
start: usize,
|
|
end: usize,
|
|
},
|
|
NoBinary {
|
|
source: uv_normalize::InvalidNameError,
|
|
specifier: String,
|
|
start: usize,
|
|
end: usize,
|
|
},
|
|
OnlyBinary {
|
|
source: uv_normalize::InvalidNameError,
|
|
specifier: String,
|
|
start: usize,
|
|
end: usize,
|
|
},
|
|
UnnamedConstraint {
|
|
start: usize,
|
|
end: usize,
|
|
},
|
|
Parser {
|
|
message: String,
|
|
line: usize,
|
|
column: usize,
|
|
},
|
|
UnsupportedRequirement {
|
|
source: Box<Pep508Error<VerbatimParsedUrl>>,
|
|
start: usize,
|
|
end: usize,
|
|
},
|
|
Pep508 {
|
|
source: Box<Pep508Error<VerbatimParsedUrl>>,
|
|
start: usize,
|
|
end: usize,
|
|
},
|
|
ParsedUrl {
|
|
source: Box<Pep508Error<VerbatimParsedUrl>>,
|
|
start: usize,
|
|
end: usize,
|
|
},
|
|
Subfile {
|
|
source: Box<RequirementsTxtFileError>,
|
|
start: usize,
|
|
end: usize,
|
|
},
|
|
NonUnicodeUrl {
|
|
url: PathBuf,
|
|
},
|
|
#[cfg(feature = "http")]
|
|
Reqwest(DisplaySafeUrl, reqwest_middleware::Error),
|
|
#[cfg(feature = "http")]
|
|
InvalidUrl(String, DisplaySafeUrlError),
|
|
}
|
|
|
|
impl Display for RequirementsTxtParserError {
|
|
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
Self::Io(err) => err.fmt(f),
|
|
Self::Url { url, start, .. } => {
|
|
write!(f, "Invalid URL at position {start}: `{url}`")
|
|
}
|
|
Self::FileUrl { url, start, .. } => {
|
|
write!(f, "Invalid file URL at position {start}: `{url}`")
|
|
}
|
|
Self::VerbatimUrl { url, start, .. } => {
|
|
write!(f, "Invalid URL at position {start}: `{url}`")
|
|
}
|
|
Self::UrlConversion(given) => {
|
|
write!(f, "Unable to convert URL to path: {given}")
|
|
}
|
|
Self::UnsupportedUrl(url) => {
|
|
write!(f, "Unsupported URL (expected a `file://` scheme): `{url}`")
|
|
}
|
|
Self::NonEditable { .. } => {
|
|
write!(f, "Unsupported editable requirement")
|
|
}
|
|
Self::MissingRequirementPrefix(given) => {
|
|
write!(
|
|
f,
|
|
"Requirement `{given}` looks like a requirements file but was passed as a package name. Did you mean `-r {given}`?"
|
|
)
|
|
}
|
|
Self::NoBinary { specifier, .. } => {
|
|
write!(f, "Invalid specifier for `--no-binary`: {specifier}")
|
|
}
|
|
Self::OnlyBinary { specifier, .. } => {
|
|
write!(f, "Invalid specifier for `--only-binary`: {specifier}")
|
|
}
|
|
Self::UnnamedConstraint { .. } => {
|
|
write!(f, "Unnamed requirements are not allowed as constraints")
|
|
}
|
|
Self::Parser {
|
|
message,
|
|
line,
|
|
column,
|
|
} => {
|
|
write!(f, "{message} at {line}:{column}")
|
|
}
|
|
Self::UnsupportedRequirement { start, end, .. } => {
|
|
write!(f, "Unsupported requirement in position {start} to {end}")
|
|
}
|
|
Self::Pep508 { start, .. } => {
|
|
write!(f, "Couldn't parse requirement at position {start}")
|
|
}
|
|
Self::ParsedUrl { start, .. } => {
|
|
write!(f, "Couldn't URL at position {start}")
|
|
}
|
|
Self::Subfile { start, .. } => {
|
|
write!(f, "Error parsing included file at position {start}")
|
|
}
|
|
Self::NonUnicodeUrl { url } => {
|
|
write!(
|
|
f,
|
|
"Remote requirements URL contains non-unicode characters: {}",
|
|
url.display(),
|
|
)
|
|
}
|
|
#[cfg(feature = "http")]
|
|
Self::Reqwest(url, _err) => {
|
|
write!(f, "Error while accessing remote requirements file: `{url}`")
|
|
}
|
|
#[cfg(feature = "http")]
|
|
Self::InvalidUrl(url, err) => {
|
|
match err {
|
|
DisplaySafeUrlError::Url(err) => write!(f, "Not a valid URL, {err}: `{url}`"),
|
|
DisplaySafeUrlError::AmbiguousAuthority(_) => {
|
|
// Intentionally avoid leaking the URL here, since we suspect that the user
|
|
// has given us an ambiguous URL that contains sensitive information.
|
|
// The error's own Display will provide a redacted version of the URL.
|
|
write!(f, "Invalid URL: {err}")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::error::Error for RequirementsTxtParserError {
|
|
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
|
match self {
|
|
Self::Io(err) => err.source(),
|
|
Self::Url { source, .. } => Some(source),
|
|
Self::FileUrl { .. } => None,
|
|
Self::VerbatimUrl { source, .. } => Some(source),
|
|
Self::UrlConversion(_) => None,
|
|
Self::UnsupportedUrl(_) => None,
|
|
Self::NonEditable { source, .. } => Some(source),
|
|
Self::MissingRequirementPrefix(_) => None,
|
|
Self::NoBinary { source, .. } => Some(source),
|
|
Self::OnlyBinary { source, .. } => Some(source),
|
|
Self::UnnamedConstraint { .. } => None,
|
|
Self::UnsupportedRequirement { source, .. } => Some(source),
|
|
Self::Pep508 { source, .. } => Some(source),
|
|
Self::ParsedUrl { source, .. } => Some(source),
|
|
Self::Subfile { source, .. } => Some(source.as_ref()),
|
|
Self::Parser { .. } => None,
|
|
Self::NonUnicodeUrl { .. } => None,
|
|
#[cfg(feature = "http")]
|
|
Self::Reqwest(_, err) => err.source(),
|
|
#[cfg(feature = "http")]
|
|
Self::InvalidUrl(_, err) => err.source(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Display for RequirementsTxtFileError {
|
|
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
|
match &self.error {
|
|
RequirementsTxtParserError::Io(err) => err.fmt(f),
|
|
RequirementsTxtParserError::Url { url, start, .. } => {
|
|
write!(
|
|
f,
|
|
"Invalid URL in `{}` at position {start}: `{url}`",
|
|
self.file.user_display(),
|
|
)
|
|
}
|
|
RequirementsTxtParserError::FileUrl { url, start, .. } => {
|
|
write!(
|
|
f,
|
|
"Invalid file URL in `{}` at position {start}: `{url}`",
|
|
self.file.user_display(),
|
|
)
|
|
}
|
|
RequirementsTxtParserError::VerbatimUrl { url, start, .. } => {
|
|
write!(
|
|
f,
|
|
"Invalid URL in `{}` at position {start}: `{url}`",
|
|
self.file.user_display(),
|
|
)
|
|
}
|
|
RequirementsTxtParserError::UrlConversion(given) => {
|
|
write!(
|
|
f,
|
|
"Unable to convert URL to path `{}`: {given}",
|
|
self.file.user_display()
|
|
)
|
|
}
|
|
RequirementsTxtParserError::UnsupportedUrl(url) => {
|
|
write!(
|
|
f,
|
|
"Unsupported URL (expected a `file://` scheme) in `{}`: `{url}`",
|
|
self.file.user_display(),
|
|
)
|
|
}
|
|
RequirementsTxtParserError::NonEditable { .. } => {
|
|
write!(
|
|
f,
|
|
"Unsupported editable requirement in `{}`",
|
|
self.file.user_display(),
|
|
)
|
|
}
|
|
RequirementsTxtParserError::MissingRequirementPrefix(given) => {
|
|
write!(
|
|
f,
|
|
"Requirement `{given}` in `{}` looks like a requirements file but was passed as a package name. Did you mean `-r {given}`?",
|
|
self.file.user_display(),
|
|
)
|
|
}
|
|
RequirementsTxtParserError::NoBinary { specifier, .. } => {
|
|
write!(
|
|
f,
|
|
"Invalid specifier for `--no-binary` in `{}`: {specifier}",
|
|
self.file.user_display(),
|
|
)
|
|
}
|
|
RequirementsTxtParserError::OnlyBinary { specifier, .. } => {
|
|
write!(
|
|
f,
|
|
"Invalid specifier for `--only-binary` in `{}`: {specifier}",
|
|
self.file.user_display(),
|
|
)
|
|
}
|
|
RequirementsTxtParserError::UnnamedConstraint { .. } => {
|
|
write!(
|
|
f,
|
|
"Unnamed requirements are not allowed as constraints in `{}`",
|
|
self.file.user_display(),
|
|
)
|
|
}
|
|
RequirementsTxtParserError::Parser {
|
|
message,
|
|
line,
|
|
column,
|
|
} => {
|
|
write!(
|
|
f,
|
|
"{message} at {}:{line}:{column}",
|
|
self.file.user_display(),
|
|
)
|
|
}
|
|
RequirementsTxtParserError::UnsupportedRequirement { start, .. } => {
|
|
write!(
|
|
f,
|
|
"Unsupported requirement in {} at position {start}",
|
|
self.file.user_display(),
|
|
)
|
|
}
|
|
RequirementsTxtParserError::Pep508 { start, .. } => {
|
|
write!(
|
|
f,
|
|
"Couldn't parse requirement in `{}` at position {start}",
|
|
self.file.user_display(),
|
|
)
|
|
}
|
|
RequirementsTxtParserError::ParsedUrl { start, .. } => {
|
|
write!(
|
|
f,
|
|
"Couldn't parse URL in `{}` at position {start}",
|
|
self.file.user_display(),
|
|
)
|
|
}
|
|
RequirementsTxtParserError::Subfile { start, .. } => {
|
|
write!(
|
|
f,
|
|
"Error parsing included file in `{}` at position {start}",
|
|
self.file.user_display(),
|
|
)
|
|
}
|
|
RequirementsTxtParserError::NonUnicodeUrl { url } => {
|
|
write!(
|
|
f,
|
|
"Remote requirements URL contains non-unicode characters: {}",
|
|
url.display(),
|
|
)
|
|
}
|
|
#[cfg(feature = "http")]
|
|
RequirementsTxtParserError::Reqwest(url, _err) => {
|
|
write!(f, "Error while accessing remote requirements file: `{url}`")
|
|
}
|
|
#[cfg(feature = "http")]
|
|
RequirementsTxtParserError::InvalidUrl(url, err) => match err {
|
|
DisplaySafeUrlError::Url(err) => write!(f, "Not a valid URL, {err}: `{url}`"),
|
|
DisplaySafeUrlError::AmbiguousAuthority(_) => {
|
|
// Intentionally avoid leaking the URL here, since we suspect that the user
|
|
// has given us an ambiguous URL that contains sensitive information.
|
|
// The error's own Display will provide a redacted version of the URL.
|
|
write!(f, "Invalid URL: {err}")
|
|
}
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::error::Error for RequirementsTxtFileError {
|
|
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
|
self.error.source()
|
|
}
|
|
}
|
|
|
|
impl From<io::Error> for RequirementsTxtParserError {
|
|
fn from(err: io::Error) -> Self {
|
|
Self::Io(err)
|
|
}
|
|
}
|
|
|
|
#[cfg(feature = "http")]
|
|
impl RequirementsTxtParserError {
|
|
fn from_reqwest(url: DisplaySafeUrl, err: reqwest::Error) -> Self {
|
|
Self::Reqwest(url, reqwest_middleware::Error::Reqwest(err))
|
|
}
|
|
|
|
fn from_reqwest_middleware(url: DisplaySafeUrl, err: reqwest_middleware::Error) -> Self {
|
|
Self::Reqwest(url, err)
|
|
}
|
|
}
|
|
|
|
/// Avoid infinite recursion through recursive inclusions, while also being mindful of nested
|
|
/// requirements and constraint inclusions.
|
|
#[derive(Debug)]
|
|
enum VisitedFiles<'a> {
|
|
/// The requirements are included as regular requirements, and can recursively include both
|
|
/// requirements and constraints.
|
|
Requirements {
|
|
requirements: &'a mut FxHashSet<PathBuf>,
|
|
constraints: &'a mut FxHashSet<PathBuf>,
|
|
},
|
|
/// The requirements are included as constraints, all recursive inclusions are considered
|
|
/// constraints.
|
|
Constraints {
|
|
constraints: &'a mut FxHashSet<PathBuf>,
|
|
},
|
|
}
|
|
|
|
/// Calculates the column and line offset of a given cursor based on the
|
|
/// number of Unicode codepoints.
|
|
fn calculate_row_column(content: &str, position: usize) -> (usize, usize) {
|
|
let mut line = 1;
|
|
let mut column = 1;
|
|
|
|
let mut chars = content.char_indices().peekable();
|
|
while let Some((index, char)) = chars.next() {
|
|
if index >= position {
|
|
break;
|
|
}
|
|
match char {
|
|
'\r' => {
|
|
// If the next character is a newline, skip it.
|
|
if chars
|
|
.peek()
|
|
.is_some_and(|&(_, next_char)| next_char == '\n')
|
|
{
|
|
chars.next();
|
|
}
|
|
|
|
// Reset.
|
|
line += 1;
|
|
column = 1;
|
|
}
|
|
'\n' => {
|
|
//
|
|
line += 1;
|
|
column = 1;
|
|
}
|
|
// Increment column by Unicode codepoint. We don't use visual width
|
|
// (e.g., `UnicodeWidthChar::width(char).unwrap_or(0)`), since that's
|
|
// not what editors typically count.
|
|
_ => column += 1,
|
|
}
|
|
}
|
|
|
|
(line, column)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod test {
|
|
use std::collections::BTreeSet;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use anyhow::Result;
|
|
use assert_fs::prelude::*;
|
|
use fs_err as fs;
|
|
use indoc::indoc;
|
|
use insta::assert_debug_snapshot;
|
|
use itertools::Itertools;
|
|
use tempfile::tempdir;
|
|
use test_case::test_case;
|
|
use unscanny::Scanner;
|
|
|
|
use uv_client::BaseClientBuilder;
|
|
use uv_fs::Simplified;
|
|
|
|
use crate::{RequirementsTxt, calculate_row_column};
|
|
|
|
fn workspace_test_data_dir() -> PathBuf {
|
|
Path::new("./test-data").simple_canonicalize().unwrap()
|
|
}
|
|
|
|
/// Filter a path for use in snapshots; in particular, match the Windows debug representation
|
|
/// of a path.
|
|
///
|
|
/// We replace backslashes to match the debug representation for paths, and match _either_
|
|
/// backslashes or forward slashes as the latter appear when constructing a path from a URL.
|
|
fn path_filter(path: &Path) -> String {
|
|
regex::escape(&path.simplified_display().to_string()).replace(r"\\", r"(\\\\|/)")
|
|
}
|
|
|
|
/// Return the insta filters for a given path.
|
|
fn path_filters(filter: &str) -> Vec<(&str, &str)> {
|
|
vec![(filter, "<REQUIREMENTS_DIR>"), (r"\\\\", "/")]
|
|
}
|
|
|
|
#[test_case(Path::new("basic.txt"))]
|
|
#[test_case(Path::new("constraints-a.txt"))]
|
|
#[test_case(Path::new("constraints-b.txt"))]
|
|
#[test_case(Path::new("empty.txt"))]
|
|
#[test_case(Path::new("for-poetry.txt"))]
|
|
#[test_case(Path::new("include-a.txt"))]
|
|
#[test_case(Path::new("include-b.txt"))]
|
|
#[test_case(Path::new("poetry-with-hashes.txt"))]
|
|
#[test_case(Path::new("small.txt"))]
|
|
#[test_case(Path::new("whitespace.txt"))]
|
|
#[tokio::test]
|
|
async fn parse(path: &Path) {
|
|
let working_dir = workspace_test_data_dir().join("requirements-txt");
|
|
let requirements_txt = working_dir.join(path);
|
|
|
|
let actual = RequirementsTxt::parse(
|
|
requirements_txt.clone(),
|
|
&working_dir,
|
|
&BaseClientBuilder::default(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let snapshot = format!("parse-{}", path.to_string_lossy());
|
|
|
|
insta::with_settings!({
|
|
filters => path_filters(&path_filter(&working_dir)),
|
|
}, {
|
|
insta::assert_debug_snapshot!(snapshot, actual);
|
|
});
|
|
}
|
|
|
|
#[test_case(Path::new("basic.txt"))]
|
|
#[test_case(Path::new("constraints-a.txt"))]
|
|
#[test_case(Path::new("constraints-b.txt"))]
|
|
#[test_case(Path::new("empty.txt"))]
|
|
#[test_case(Path::new("for-poetry.txt"))]
|
|
#[test_case(Path::new("include-a.txt"))]
|
|
#[test_case(Path::new("include-b.txt"))]
|
|
#[test_case(Path::new("poetry-with-hashes.txt"))]
|
|
#[test_case(Path::new("small.txt"))]
|
|
#[test_case(Path::new("whitespace.txt"))]
|
|
#[tokio::test]
|
|
async fn line_endings(path: &Path) {
|
|
let working_dir = workspace_test_data_dir().join("requirements-txt");
|
|
let requirements_txt = working_dir.join(path);
|
|
|
|
// Copy the existing files over to a temporary directory.
|
|
let temp_dir = tempdir().unwrap();
|
|
for entry in fs::read_dir(&working_dir).unwrap() {
|
|
let entry = entry.unwrap();
|
|
let path = entry.path();
|
|
let dest = temp_dir.path().join(path.file_name().unwrap());
|
|
fs::copy(&path, &dest).unwrap();
|
|
}
|
|
|
|
// Replace line endings with the other choice. This works even if you use git with LF
|
|
// only on windows.
|
|
let contents = fs::read_to_string(requirements_txt).unwrap();
|
|
let contents = if contents.contains("\r\n") {
|
|
contents.replace("\r\n", "\n")
|
|
} else {
|
|
contents.replace('\n', "\r\n")
|
|
};
|
|
let requirements_txt = temp_dir.path().join(path);
|
|
fs::write(&requirements_txt, contents).unwrap();
|
|
|
|
let actual = RequirementsTxt::parse(
|
|
&requirements_txt,
|
|
&working_dir,
|
|
&BaseClientBuilder::default(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let snapshot = format!("line-endings-{}", path.to_string_lossy());
|
|
|
|
insta::with_settings!({
|
|
filters => path_filters(&path_filter(temp_dir.path())),
|
|
}, {
|
|
insta::assert_debug_snapshot!(snapshot, actual);
|
|
});
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test_case(Path::new("bare-url.txt"))]
|
|
#[test_case(Path::new("editable.txt"))]
|
|
#[tokio::test]
|
|
async fn parse_unix(path: &Path) {
|
|
let working_dir = workspace_test_data_dir().join("requirements-txt");
|
|
let requirements_txt = working_dir.join(path);
|
|
|
|
let actual = RequirementsTxt::parse(
|
|
requirements_txt,
|
|
&working_dir,
|
|
&BaseClientBuilder::default(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let snapshot = format!("parse-unix-{}", path.to_string_lossy());
|
|
|
|
insta::with_settings!({
|
|
filters => path_filters(&path_filter(&working_dir)),
|
|
}, {
|
|
insta::assert_debug_snapshot!(snapshot, actual);
|
|
});
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test_case(Path::new("semicolon.txt"))]
|
|
#[test_case(Path::new("hash.txt"))]
|
|
#[tokio::test]
|
|
async fn parse_err(path: &Path) {
|
|
let working_dir = workspace_test_data_dir().join("requirements-txt");
|
|
let requirements_txt = working_dir.join(path);
|
|
|
|
let actual = RequirementsTxt::parse(
|
|
requirements_txt,
|
|
&working_dir,
|
|
&BaseClientBuilder::default(),
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
|
|
let snapshot = format!("parse-unix-{}", path.to_string_lossy());
|
|
|
|
insta::with_settings!({
|
|
filters => path_filters(&path_filter(&working_dir)),
|
|
}, {
|
|
insta::assert_debug_snapshot!(snapshot, actual);
|
|
});
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
#[test_case(Path::new("bare-url.txt"))]
|
|
#[test_case(Path::new("editable.txt"))]
|
|
#[tokio::test]
|
|
async fn parse_windows(path: &Path) {
|
|
let working_dir = workspace_test_data_dir().join("requirements-txt");
|
|
let requirements_txt = working_dir.join(path);
|
|
|
|
let actual = RequirementsTxt::parse(
|
|
requirements_txt,
|
|
&working_dir,
|
|
&BaseClientBuilder::default(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let snapshot = format!("parse-windows-{}", path.to_string_lossy());
|
|
|
|
insta::with_settings!({
|
|
filters => path_filters(&path_filter(&working_dir)),
|
|
}, {
|
|
insta::assert_debug_snapshot!(snapshot, actual);
|
|
});
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn invalid_include_missing_file() -> Result<()> {
|
|
let temp_dir = assert_fs::TempDir::new()?;
|
|
let missing_txt = temp_dir.child("missing.txt");
|
|
let requirements_txt = temp_dir.child("requirements.txt");
|
|
requirements_txt.write_str(indoc! {"
|
|
-r missing.txt
|
|
"})?;
|
|
|
|
let error = RequirementsTxt::parse(
|
|
requirements_txt.path(),
|
|
temp_dir.path(),
|
|
&BaseClientBuilder::default(),
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
let errors = anyhow::Error::new(error)
|
|
.chain()
|
|
// The last error is operating-system specific.
|
|
.take(2)
|
|
.join("\n");
|
|
|
|
let requirement_txt = regex::escape(&requirements_txt.path().user_display().to_string());
|
|
let missing_txt = regex::escape(&missing_txt.path().user_display().to_string());
|
|
let filters = vec![
|
|
(requirement_txt.as_str(), "<REQUIREMENTS_TXT>"),
|
|
(missing_txt.as_str(), "<MISSING_TXT>"),
|
|
// Windows translates error messages, for example i get:
|
|
// "Das System kann den angegebenen Pfad nicht finden. (os error 3)"
|
|
(
|
|
r": .* \(os error 2\)",
|
|
": The system cannot find the path specified. (os error 2)",
|
|
),
|
|
];
|
|
insta::with_settings!({
|
|
filters => filters,
|
|
}, {
|
|
insta::assert_snapshot!(errors, @r###"
|
|
Error parsing included file in `<REQUIREMENTS_TXT>` at position 0
|
|
failed to read from file `<MISSING_TXT>`: The system cannot find the path specified. (os error 2)
|
|
"###);
|
|
});
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn invalid_requirement_version() -> Result<()> {
|
|
let temp_dir = assert_fs::TempDir::new()?;
|
|
let requirements_txt = temp_dir.child("requirements.txt");
|
|
requirements_txt.write_str(indoc! {"
|
|
numpy[ö]==1.29
|
|
"})?;
|
|
|
|
let error = RequirementsTxt::parse(
|
|
requirements_txt.path(),
|
|
temp_dir.path(),
|
|
&BaseClientBuilder::default(),
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
let errors = anyhow::Error::new(error).chain().join("\n");
|
|
|
|
let requirement_txt = regex::escape(&requirements_txt.path().user_display().to_string());
|
|
let filters = vec![(requirement_txt.as_str(), "<REQUIREMENTS_TXT>")];
|
|
insta::with_settings!({
|
|
filters => filters
|
|
}, {
|
|
insta::assert_snapshot!(errors, @r###"
|
|
Couldn't parse requirement in `<REQUIREMENTS_TXT>` at position 0
|
|
Expected an alphanumeric character starting the extra name, found `ö`
|
|
numpy[ö]==1.29
|
|
^
|
|
"###);
|
|
});
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn invalid_requirement_url() -> Result<()> {
|
|
let temp_dir = assert_fs::TempDir::new()?;
|
|
let requirements_txt = temp_dir.child("requirements.txt");
|
|
requirements_txt.write_str(indoc! {"
|
|
numpy @ https:///
|
|
"})?;
|
|
|
|
let error = RequirementsTxt::parse(
|
|
requirements_txt.path(),
|
|
temp_dir.path(),
|
|
&BaseClientBuilder::default(),
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
let errors = anyhow::Error::new(error).chain().join("\n");
|
|
|
|
let requirement_txt = regex::escape(&requirements_txt.path().user_display().to_string());
|
|
let filters = vec![(requirement_txt.as_str(), "<REQUIREMENTS_TXT>")];
|
|
insta::with_settings!({
|
|
filters => filters
|
|
}, {
|
|
insta::assert_snapshot!(errors, @r###"
|
|
Couldn't parse requirement in `<REQUIREMENTS_TXT>` at position 0
|
|
empty host
|
|
numpy @ https:///
|
|
^^^^^^^^^
|
|
"###);
|
|
});
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn unsupported_editable() -> Result<()> {
|
|
let temp_dir = assert_fs::TempDir::new()?;
|
|
let requirements_txt = temp_dir.child("requirements.txt");
|
|
requirements_txt.write_str(indoc! {"
|
|
-e https://localhost:8080/
|
|
"})?;
|
|
|
|
let error = RequirementsTxt::parse(
|
|
requirements_txt.path(),
|
|
temp_dir.path(),
|
|
&BaseClientBuilder::default(),
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
let errors = anyhow::Error::new(error).chain().join("\n");
|
|
|
|
let requirement_txt = regex::escape(&requirements_txt.path().user_display().to_string());
|
|
let filters = vec![(requirement_txt.as_str(), "<REQUIREMENTS_TXT>")];
|
|
insta::with_settings!({
|
|
filters => filters
|
|
}, {
|
|
insta::assert_snapshot!(errors, @r###"
|
|
Couldn't parse requirement in `<REQUIREMENTS_TXT>` at position 3
|
|
Expected direct URL (`https://localhost:8080/`) to end in a supported file extension: `.whl`, `.tar.gz`, `.zip`, `.tar.bz2`, `.tar.lz`, `.tar.lzma`, `.tar.xz`, `.tar.zst`, `.tar`, `.tbz`, `.tgz`, `.tlz`, or `.txz`
|
|
https://localhost:8080/
|
|
^^^^^^^^^^^^^^^^^^^^^^^
|
|
"###);
|
|
});
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn unsupported_editable_extension() -> Result<()> {
|
|
let temp_dir = assert_fs::TempDir::new()?;
|
|
let requirements_txt = temp_dir.child("requirements.txt");
|
|
requirements_txt.write_str(indoc! {"
|
|
-e https://files.pythonhosted.org/packages/f7/69/96766da2cdb5605e6a31ef2734aff0be17901cefb385b885c2ab88896d76/ruff-0.5.6.tar.gz
|
|
"})?;
|
|
|
|
let error = RequirementsTxt::parse(
|
|
requirements_txt.path(),
|
|
temp_dir.path(),
|
|
&BaseClientBuilder::default(),
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
let errors = anyhow::Error::new(error).chain().join("\n");
|
|
|
|
let requirement_txt = regex::escape(&requirements_txt.path().user_display().to_string());
|
|
let filters = vec![(requirement_txt.as_str(), "<REQUIREMENTS_TXT>")];
|
|
insta::with_settings!({
|
|
filters => filters
|
|
}, {
|
|
insta::assert_snapshot!(errors, @r###"
|
|
Unsupported editable requirement in `<REQUIREMENTS_TXT>`
|
|
Editable must refer to a local directory, not an HTTPS URL: `https://files.pythonhosted.org/packages/f7/69/96766da2cdb5605e6a31ef2734aff0be17901cefb385b885c2ab88896d76/ruff-0.5.6.tar.gz`
|
|
"###);
|
|
});
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn invalid_editable_extra() -> Result<()> {
|
|
let temp_dir = assert_fs::TempDir::new()?;
|
|
let requirements_txt = temp_dir.child("requirements.txt");
|
|
requirements_txt.write_str(indoc! {"
|
|
-e black[,abcdef]
|
|
"})?;
|
|
|
|
let error = RequirementsTxt::parse(
|
|
requirements_txt.path(),
|
|
temp_dir.path(),
|
|
&BaseClientBuilder::default(),
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
let errors = anyhow::Error::new(error).chain().join("\n");
|
|
|
|
let requirement_txt = regex::escape(&requirements_txt.path().user_display().to_string());
|
|
let filters = vec![(requirement_txt.as_str(), "<REQUIREMENTS_TXT>")];
|
|
insta::with_settings!({
|
|
filters => filters
|
|
}, {
|
|
insta::assert_snapshot!(errors, @r###"
|
|
Couldn't parse requirement in `<REQUIREMENTS_TXT>` at position 3
|
|
Expected either alphanumerical character (starting the extra name) or `]` (ending the extras section), found `,`
|
|
black[,abcdef]
|
|
^
|
|
"###);
|
|
});
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn relative_index_url() -> Result<()> {
|
|
let temp_dir = assert_fs::TempDir::new()?;
|
|
let requirements_txt = temp_dir.child("requirements.txt");
|
|
requirements_txt.write_str(indoc! {"
|
|
--index-url 123
|
|
"})?;
|
|
|
|
let error = RequirementsTxt::parse(
|
|
requirements_txt.path(),
|
|
temp_dir.path(),
|
|
&BaseClientBuilder::default(),
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
let errors = anyhow::Error::new(error).chain().join("\n");
|
|
|
|
let requirement_txt = regex::escape(&requirements_txt.path().user_display().to_string());
|
|
let filters = vec![(requirement_txt.as_str(), "<REQUIREMENTS_TXT>")];
|
|
insta::with_settings!({
|
|
filters => filters
|
|
}, {
|
|
insta::assert_snapshot!(errors, @r###"
|
|
Invalid URL in `<REQUIREMENTS_TXT>` at position 0: `123`
|
|
relative URL without a base
|
|
"###);
|
|
});
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn invalid_index_url() -> Result<()> {
|
|
let temp_dir = assert_fs::TempDir::new()?;
|
|
let requirements_txt = temp_dir.child("requirements.txt");
|
|
requirements_txt.write_str(indoc! {"
|
|
--index-url https:////
|
|
"})?;
|
|
|
|
let error = RequirementsTxt::parse(
|
|
requirements_txt.path(),
|
|
temp_dir.path(),
|
|
&BaseClientBuilder::default(),
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
let errors = anyhow::Error::new(error).chain().join("\n");
|
|
|
|
let requirement_txt = regex::escape(&requirements_txt.path().user_display().to_string());
|
|
let filters = vec![(requirement_txt.as_str(), "<REQUIREMENTS_TXT>")];
|
|
insta::with_settings!({
|
|
filters => filters
|
|
}, {
|
|
insta::assert_snapshot!(errors, @r###"
|
|
Invalid URL in `<REQUIREMENTS_TXT>` at position 0: `https:////`
|
|
empty host
|
|
"###);
|
|
});
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn missing_value() -> Result<()> {
|
|
let temp_dir = assert_fs::TempDir::new()?;
|
|
let requirements_txt = temp_dir.child("requirements.txt");
|
|
requirements_txt.write_str(indoc! {"
|
|
flask
|
|
--no-binary
|
|
"})?;
|
|
|
|
let error = RequirementsTxt::parse(
|
|
requirements_txt.path(),
|
|
temp_dir.path(),
|
|
&BaseClientBuilder::default(),
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
let errors = anyhow::Error::new(error).chain().join("\n");
|
|
|
|
let requirement_txt = regex::escape(&requirements_txt.path().user_display().to_string());
|
|
let filters = vec![(requirement_txt.as_str(), "<REQUIREMENTS_TXT>")];
|
|
insta::with_settings!({
|
|
filters => filters
|
|
}, {
|
|
insta::assert_snapshot!(errors, @"`--no-binary` must be followed by an argument at <REQUIREMENTS_TXT>:3:1");
|
|
});
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn missing_r() -> Result<()> {
|
|
let temp_dir = assert_fs::TempDir::new()?;
|
|
|
|
let file_txt = temp_dir.child("file.txt");
|
|
file_txt.touch()?;
|
|
|
|
let requirements_txt = temp_dir.child("requirements.txt");
|
|
requirements_txt.write_str(indoc! {"
|
|
flask
|
|
file.txt
|
|
"})?;
|
|
|
|
let error = RequirementsTxt::parse(
|
|
requirements_txt.path(),
|
|
temp_dir.path(),
|
|
&BaseClientBuilder::default(),
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
let errors = anyhow::Error::new(error).chain().join("\n");
|
|
|
|
let requirement_txt = regex::escape(&requirements_txt.path().user_display().to_string());
|
|
let filters = vec![(requirement_txt.as_str(), "<REQUIREMENTS_TXT>")];
|
|
insta::with_settings!({
|
|
filters => filters
|
|
}, {
|
|
insta::assert_snapshot!(errors, @"Requirement `file.txt` in `<REQUIREMENTS_TXT>` looks like a requirements file but was passed as a package name. Did you mean `-r file.txt`?");
|
|
});
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn relative_requirement() -> Result<()> {
|
|
let temp_dir = assert_fs::TempDir::new()?;
|
|
|
|
// Create a requirements file with a relative entry, in a subdirectory.
|
|
let sub_dir = temp_dir.child("subdir");
|
|
|
|
let sibling_txt = sub_dir.child("sibling.txt");
|
|
sibling_txt.write_str(indoc! {"
|
|
flask
|
|
"})?;
|
|
|
|
let child_txt = sub_dir.child("child.txt");
|
|
child_txt.write_str(indoc! {"
|
|
-r sibling.txt
|
|
"})?;
|
|
|
|
// Create a requirements file that points at `requirements.txt`.
|
|
let parent_txt = temp_dir.child("parent.txt");
|
|
parent_txt.write_str(indoc! {"
|
|
-r subdir/child.txt
|
|
"})?;
|
|
|
|
let requirements = RequirementsTxt::parse(
|
|
parent_txt.path(),
|
|
temp_dir.path(),
|
|
&BaseClientBuilder::default(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
insta::with_settings!({
|
|
filters => path_filters(&path_filter(temp_dir.path())),
|
|
}, {
|
|
insta::assert_debug_snapshot!(requirements, @r###"
|
|
RequirementsTxt {
|
|
requirements: [
|
|
RequirementEntry {
|
|
requirement: Named(
|
|
Requirement {
|
|
name: PackageName(
|
|
"flask",
|
|
),
|
|
extras: [],
|
|
version_or_url: None,
|
|
marker: true,
|
|
origin: Some(
|
|
File(
|
|
"<REQUIREMENTS_DIR>/subdir/sibling.txt",
|
|
),
|
|
),
|
|
},
|
|
),
|
|
hashes: [],
|
|
},
|
|
],
|
|
constraints: [],
|
|
editables: [],
|
|
index_url: None,
|
|
extra_index_urls: [],
|
|
find_links: [],
|
|
no_index: false,
|
|
no_binary: None,
|
|
only_binary: None,
|
|
}
|
|
"###);
|
|
});
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn nested_no_binary() -> Result<()> {
|
|
let temp_dir = assert_fs::TempDir::new()?;
|
|
|
|
let requirements_txt = temp_dir.child("requirements.txt");
|
|
requirements_txt.write_str(indoc! {"
|
|
flask
|
|
--no-binary :none:
|
|
-r child.txt
|
|
"})?;
|
|
|
|
let child = temp_dir.child("child.txt");
|
|
child.write_str(indoc! {"
|
|
--no-binary flask
|
|
"})?;
|
|
|
|
let requirements = RequirementsTxt::parse(
|
|
requirements_txt.path(),
|
|
temp_dir.path(),
|
|
&BaseClientBuilder::default(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
insta::with_settings!({
|
|
filters => path_filters(&path_filter(temp_dir.path())),
|
|
}, {
|
|
insta::assert_debug_snapshot!(requirements, @r###"
|
|
RequirementsTxt {
|
|
requirements: [
|
|
RequirementEntry {
|
|
requirement: Named(
|
|
Requirement {
|
|
name: PackageName(
|
|
"flask",
|
|
),
|
|
extras: [],
|
|
version_or_url: None,
|
|
marker: true,
|
|
origin: Some(
|
|
File(
|
|
"<REQUIREMENTS_DIR>/requirements.txt",
|
|
),
|
|
),
|
|
},
|
|
),
|
|
hashes: [],
|
|
},
|
|
],
|
|
constraints: [],
|
|
editables: [],
|
|
index_url: None,
|
|
extra_index_urls: [],
|
|
find_links: [],
|
|
no_index: false,
|
|
no_binary: Packages(
|
|
[
|
|
PackageName(
|
|
"flask",
|
|
),
|
|
],
|
|
),
|
|
only_binary: None,
|
|
}
|
|
"###);
|
|
});
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[cfg(not(windows))]
|
|
async fn nested_editable() -> Result<()> {
|
|
let temp_dir = assert_fs::TempDir::new()?;
|
|
|
|
let requirements_txt = temp_dir.child("requirements.txt");
|
|
requirements_txt.write_str(indoc! {"
|
|
-r child.txt
|
|
"})?;
|
|
|
|
let child = temp_dir.child("child.txt");
|
|
child.write_str(indoc! {"
|
|
-r grandchild.txt
|
|
"})?;
|
|
|
|
let grandchild = temp_dir.child("grandchild.txt");
|
|
grandchild.write_str(indoc! {"
|
|
-e /foo/bar
|
|
--no-index
|
|
"})?;
|
|
|
|
let requirements = RequirementsTxt::parse(
|
|
requirements_txt.path(),
|
|
temp_dir.path(),
|
|
&BaseClientBuilder::default(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
insta::with_settings!({
|
|
filters => path_filters(&path_filter(temp_dir.path())),
|
|
}, {
|
|
insta::assert_debug_snapshot!(requirements, @r#"
|
|
RequirementsTxt {
|
|
requirements: [],
|
|
constraints: [],
|
|
editables: [
|
|
RequirementEntry {
|
|
requirement: Unnamed(
|
|
UnnamedRequirement {
|
|
url: VerbatimParsedUrl {
|
|
parsed_url: Directory(
|
|
ParsedDirectoryUrl {
|
|
url: DisplaySafeUrl {
|
|
scheme: "file",
|
|
cannot_be_a_base: false,
|
|
username: "",
|
|
password: None,
|
|
host: None,
|
|
port: None,
|
|
path: "/foo/bar",
|
|
query: None,
|
|
fragment: None,
|
|
},
|
|
install_path: "/foo/bar",
|
|
editable: Some(
|
|
true,
|
|
),
|
|
virtual: None,
|
|
},
|
|
),
|
|
verbatim: VerbatimUrl {
|
|
url: DisplaySafeUrl {
|
|
scheme: "file",
|
|
cannot_be_a_base: false,
|
|
username: "",
|
|
password: None,
|
|
host: None,
|
|
port: None,
|
|
path: "/foo/bar",
|
|
query: None,
|
|
fragment: None,
|
|
},
|
|
given: Some(
|
|
"/foo/bar",
|
|
),
|
|
},
|
|
},
|
|
extras: [],
|
|
marker: true,
|
|
origin: Some(
|
|
File(
|
|
"<REQUIREMENTS_DIR>/grandchild.txt",
|
|
),
|
|
),
|
|
},
|
|
),
|
|
hashes: [],
|
|
},
|
|
],
|
|
index_url: None,
|
|
extra_index_urls: [],
|
|
find_links: [],
|
|
no_index: true,
|
|
no_binary: None,
|
|
only_binary: None,
|
|
}
|
|
"#);
|
|
});
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn nested_conflicting_index_url() -> Result<()> {
|
|
let temp_dir = assert_fs::TempDir::new()?;
|
|
|
|
let requirements_txt = temp_dir.child("requirements.txt");
|
|
requirements_txt.write_str(indoc! {"
|
|
--index-url https://test.pypi.org/simple
|
|
-r child.txt
|
|
"})?;
|
|
|
|
let child = temp_dir.child("child.txt");
|
|
child.write_str(indoc! {"
|
|
-r grandchild.txt
|
|
"})?;
|
|
|
|
let grandchild = temp_dir.child("grandchild.txt");
|
|
grandchild.write_str(indoc! {"
|
|
--index-url https://fake.pypi.org/simple
|
|
"})?;
|
|
|
|
let error = RequirementsTxt::parse(
|
|
requirements_txt.path(),
|
|
temp_dir.path(),
|
|
&BaseClientBuilder::default(),
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
let errors = anyhow::Error::new(error).chain().join("\n");
|
|
|
|
let requirement_txt = regex::escape(&requirements_txt.path().user_display().to_string());
|
|
let filters = vec![(requirement_txt.as_str(), "<REQUIREMENTS_TXT>")];
|
|
insta::with_settings!({
|
|
filters => filters
|
|
}, {
|
|
insta::assert_snapshot!(errors, @"Nested `requirements` file contains conflicting `--index-url` at <REQUIREMENTS_TXT>:2:13");
|
|
});
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn comments() -> Result<()> {
|
|
let temp_dir = assert_fs::TempDir::new()?;
|
|
|
|
let requirements_txt = temp_dir.child("requirements.txt");
|
|
requirements_txt.write_str(indoc! {r"
|
|
-r ./sibling.txt # comment
|
|
--index-url https://test.pypi.org/simple/ # comment
|
|
--no-binary :all: # comment
|
|
|
|
flask==3.0.0 \
|
|
--hash=sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef \
|
|
# comment
|
|
|
|
requests==2.26.0 \
|
|
--hash=sha256:fedcba0987654321fedcba0987654321fedcba0987654321fedcba0987654321 # comment
|
|
|
|
black==21.12b0 # comment
|
|
|
|
mypy==0.910 \
|
|
# comment
|
|
"})?;
|
|
|
|
let sibling_txt = temp_dir.child("sibling.txt");
|
|
sibling_txt.write_str(indoc! {"
|
|
httpx # comment
|
|
"})?;
|
|
|
|
let requirements = RequirementsTxt::parse(
|
|
requirements_txt.path(),
|
|
temp_dir.path(),
|
|
&BaseClientBuilder::default(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
insta::with_settings!({
|
|
filters => path_filters(&path_filter(temp_dir.path())),
|
|
}, {
|
|
insta::assert_debug_snapshot!(requirements, @r#"
|
|
RequirementsTxt {
|
|
requirements: [
|
|
RequirementEntry {
|
|
requirement: Named(
|
|
Requirement {
|
|
name: PackageName(
|
|
"httpx",
|
|
),
|
|
extras: [],
|
|
version_or_url: None,
|
|
marker: true,
|
|
origin: Some(
|
|
File(
|
|
"<REQUIREMENTS_DIR>/./sibling.txt",
|
|
),
|
|
),
|
|
},
|
|
),
|
|
hashes: [],
|
|
},
|
|
RequirementEntry {
|
|
requirement: Named(
|
|
Requirement {
|
|
name: PackageName(
|
|
"flask",
|
|
),
|
|
extras: [],
|
|
version_or_url: Some(
|
|
VersionSpecifier(
|
|
VersionSpecifiers(
|
|
[
|
|
VersionSpecifier {
|
|
operator: Equal,
|
|
version: "3.0.0",
|
|
},
|
|
],
|
|
),
|
|
),
|
|
),
|
|
marker: true,
|
|
origin: Some(
|
|
File(
|
|
"<REQUIREMENTS_DIR>/requirements.txt",
|
|
),
|
|
),
|
|
},
|
|
),
|
|
hashes: [
|
|
"sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
|
|
],
|
|
},
|
|
RequirementEntry {
|
|
requirement: Named(
|
|
Requirement {
|
|
name: PackageName(
|
|
"requests",
|
|
),
|
|
extras: [],
|
|
version_or_url: Some(
|
|
VersionSpecifier(
|
|
VersionSpecifiers(
|
|
[
|
|
VersionSpecifier {
|
|
operator: Equal,
|
|
version: "2.26.0",
|
|
},
|
|
],
|
|
),
|
|
),
|
|
),
|
|
marker: true,
|
|
origin: Some(
|
|
File(
|
|
"<REQUIREMENTS_DIR>/requirements.txt",
|
|
),
|
|
),
|
|
},
|
|
),
|
|
hashes: [
|
|
"sha256:fedcba0987654321fedcba0987654321fedcba0987654321fedcba0987654321",
|
|
],
|
|
},
|
|
RequirementEntry {
|
|
requirement: Named(
|
|
Requirement {
|
|
name: PackageName(
|
|
"black",
|
|
),
|
|
extras: [],
|
|
version_or_url: Some(
|
|
VersionSpecifier(
|
|
VersionSpecifiers(
|
|
[
|
|
VersionSpecifier {
|
|
operator: Equal,
|
|
version: "21.12b0",
|
|
},
|
|
],
|
|
),
|
|
),
|
|
),
|
|
marker: true,
|
|
origin: Some(
|
|
File(
|
|
"<REQUIREMENTS_DIR>/requirements.txt",
|
|
),
|
|
),
|
|
},
|
|
),
|
|
hashes: [],
|
|
},
|
|
RequirementEntry {
|
|
requirement: Named(
|
|
Requirement {
|
|
name: PackageName(
|
|
"mypy",
|
|
),
|
|
extras: [],
|
|
version_or_url: Some(
|
|
VersionSpecifier(
|
|
VersionSpecifiers(
|
|
[
|
|
VersionSpecifier {
|
|
operator: Equal,
|
|
version: "0.910",
|
|
},
|
|
],
|
|
),
|
|
),
|
|
),
|
|
marker: true,
|
|
origin: Some(
|
|
File(
|
|
"<REQUIREMENTS_DIR>/requirements.txt",
|
|
),
|
|
),
|
|
},
|
|
),
|
|
hashes: [],
|
|
},
|
|
],
|
|
constraints: [],
|
|
editables: [],
|
|
index_url: Some(
|
|
VerbatimUrl {
|
|
url: DisplaySafeUrl {
|
|
scheme: "https",
|
|
cannot_be_a_base: false,
|
|
username: "",
|
|
password: None,
|
|
host: Some(
|
|
Domain(
|
|
"test.pypi.org",
|
|
),
|
|
),
|
|
port: None,
|
|
path: "/simple/",
|
|
query: None,
|
|
fragment: None,
|
|
},
|
|
given: Some(
|
|
"https://test.pypi.org/simple/",
|
|
),
|
|
},
|
|
),
|
|
extra_index_urls: [],
|
|
find_links: [],
|
|
no_index: false,
|
|
no_binary: All,
|
|
only_binary: None,
|
|
}
|
|
"#);
|
|
});
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[cfg(not(windows))]
|
|
async fn archive_requirement() -> Result<()> {
|
|
let temp_dir = assert_fs::TempDir::new()?;
|
|
|
|
let requirements_txt = temp_dir.child("requirements.txt");
|
|
requirements_txt.write_str(indoc! {r"
|
|
# Archive name that's also a valid Python package name.
|
|
importlib_metadata-8.3.0-py3-none-any.whl
|
|
|
|
# Archive name that's also a valid Python package name, with markers.
|
|
importlib_metadata-8.2.0-py3-none-any.whl ; sys_platform == 'win32'
|
|
|
|
# Archive name that's also a valid Python package name, with extras.
|
|
importlib_metadata-8.2.0-py3-none-any.whl[extra]
|
|
|
|
# Archive name that's not a valid Python package name.
|
|
importlib_metadata-8.2.0+local-py3-none-any.whl
|
|
|
|
# Archive name that's not a valid Python package name, with markers.
|
|
importlib_metadata-8.2.0+local-py3-none-any.whl ; sys_platform == 'win32'
|
|
|
|
# Archive name that's not a valid Python package name, with extras.
|
|
importlib_metadata-8.2.0+local-py3-none-any.whl[extra]
|
|
"})?;
|
|
|
|
let requirements = RequirementsTxt::parse(
|
|
requirements_txt.path(),
|
|
temp_dir.path(),
|
|
&BaseClientBuilder::default(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
insta::with_settings!({
|
|
filters => path_filters(&path_filter(temp_dir.path())),
|
|
}, {
|
|
insta::assert_debug_snapshot!(requirements, @r#"
|
|
RequirementsTxt {
|
|
requirements: [
|
|
RequirementEntry {
|
|
requirement: Unnamed(
|
|
UnnamedRequirement {
|
|
url: VerbatimParsedUrl {
|
|
parsed_url: Path(
|
|
ParsedPathUrl {
|
|
url: DisplaySafeUrl {
|
|
scheme: "file",
|
|
cannot_be_a_base: false,
|
|
username: "",
|
|
password: None,
|
|
host: None,
|
|
port: None,
|
|
path: "<REQUIREMENTS_DIR>/importlib_metadata-8.3.0-py3-none-any.whl",
|
|
query: None,
|
|
fragment: None,
|
|
},
|
|
install_path: "<REQUIREMENTS_DIR>/importlib_metadata-8.3.0-py3-none-any.whl",
|
|
ext: Wheel,
|
|
},
|
|
),
|
|
verbatim: VerbatimUrl {
|
|
url: DisplaySafeUrl {
|
|
scheme: "file",
|
|
cannot_be_a_base: false,
|
|
username: "",
|
|
password: None,
|
|
host: None,
|
|
port: None,
|
|
path: "<REQUIREMENTS_DIR>/importlib_metadata-8.3.0-py3-none-any.whl",
|
|
query: None,
|
|
fragment: None,
|
|
},
|
|
given: Some(
|
|
"importlib_metadata-8.3.0-py3-none-any.whl",
|
|
),
|
|
},
|
|
},
|
|
extras: [],
|
|
marker: true,
|
|
origin: Some(
|
|
File(
|
|
"<REQUIREMENTS_DIR>/requirements.txt",
|
|
),
|
|
),
|
|
},
|
|
),
|
|
hashes: [],
|
|
},
|
|
RequirementEntry {
|
|
requirement: Unnamed(
|
|
UnnamedRequirement {
|
|
url: VerbatimParsedUrl {
|
|
parsed_url: Path(
|
|
ParsedPathUrl {
|
|
url: DisplaySafeUrl {
|
|
scheme: "file",
|
|
cannot_be_a_base: false,
|
|
username: "",
|
|
password: None,
|
|
host: None,
|
|
port: None,
|
|
path: "<REQUIREMENTS_DIR>/importlib_metadata-8.2.0-py3-none-any.whl",
|
|
query: None,
|
|
fragment: None,
|
|
},
|
|
install_path: "<REQUIREMENTS_DIR>/importlib_metadata-8.2.0-py3-none-any.whl",
|
|
ext: Wheel,
|
|
},
|
|
),
|
|
verbatim: VerbatimUrl {
|
|
url: DisplaySafeUrl {
|
|
scheme: "file",
|
|
cannot_be_a_base: false,
|
|
username: "",
|
|
password: None,
|
|
host: None,
|
|
port: None,
|
|
path: "<REQUIREMENTS_DIR>/importlib_metadata-8.2.0-py3-none-any.whl",
|
|
query: None,
|
|
fragment: None,
|
|
},
|
|
given: Some(
|
|
"importlib_metadata-8.2.0-py3-none-any.whl",
|
|
),
|
|
},
|
|
},
|
|
extras: [],
|
|
marker: sys_platform == 'win32',
|
|
origin: Some(
|
|
File(
|
|
"<REQUIREMENTS_DIR>/requirements.txt",
|
|
),
|
|
),
|
|
},
|
|
),
|
|
hashes: [],
|
|
},
|
|
RequirementEntry {
|
|
requirement: Unnamed(
|
|
UnnamedRequirement {
|
|
url: VerbatimParsedUrl {
|
|
parsed_url: Path(
|
|
ParsedPathUrl {
|
|
url: DisplaySafeUrl {
|
|
scheme: "file",
|
|
cannot_be_a_base: false,
|
|
username: "",
|
|
password: None,
|
|
host: None,
|
|
port: None,
|
|
path: "<REQUIREMENTS_DIR>/importlib_metadata-8.2.0-py3-none-any.whl",
|
|
query: None,
|
|
fragment: None,
|
|
},
|
|
install_path: "<REQUIREMENTS_DIR>/importlib_metadata-8.2.0-py3-none-any.whl",
|
|
ext: Wheel,
|
|
},
|
|
),
|
|
verbatim: VerbatimUrl {
|
|
url: DisplaySafeUrl {
|
|
scheme: "file",
|
|
cannot_be_a_base: false,
|
|
username: "",
|
|
password: None,
|
|
host: None,
|
|
port: None,
|
|
path: "<REQUIREMENTS_DIR>/importlib_metadata-8.2.0-py3-none-any.whl",
|
|
query: None,
|
|
fragment: None,
|
|
},
|
|
given: Some(
|
|
"importlib_metadata-8.2.0-py3-none-any.whl",
|
|
),
|
|
},
|
|
},
|
|
extras: [
|
|
ExtraName(
|
|
"extra",
|
|
),
|
|
],
|
|
marker: true,
|
|
origin: Some(
|
|
File(
|
|
"<REQUIREMENTS_DIR>/requirements.txt",
|
|
),
|
|
),
|
|
},
|
|
),
|
|
hashes: [],
|
|
},
|
|
RequirementEntry {
|
|
requirement: Unnamed(
|
|
UnnamedRequirement {
|
|
url: VerbatimParsedUrl {
|
|
parsed_url: Path(
|
|
ParsedPathUrl {
|
|
url: DisplaySafeUrl {
|
|
scheme: "file",
|
|
cannot_be_a_base: false,
|
|
username: "",
|
|
password: None,
|
|
host: None,
|
|
port: None,
|
|
path: "<REQUIREMENTS_DIR>/importlib_metadata-8.2.0+local-py3-none-any.whl",
|
|
query: None,
|
|
fragment: None,
|
|
},
|
|
install_path: "<REQUIREMENTS_DIR>/importlib_metadata-8.2.0+local-py3-none-any.whl",
|
|
ext: Wheel,
|
|
},
|
|
),
|
|
verbatim: VerbatimUrl {
|
|
url: DisplaySafeUrl {
|
|
scheme: "file",
|
|
cannot_be_a_base: false,
|
|
username: "",
|
|
password: None,
|
|
host: None,
|
|
port: None,
|
|
path: "<REQUIREMENTS_DIR>/importlib_metadata-8.2.0+local-py3-none-any.whl",
|
|
query: None,
|
|
fragment: None,
|
|
},
|
|
given: Some(
|
|
"importlib_metadata-8.2.0+local-py3-none-any.whl",
|
|
),
|
|
},
|
|
},
|
|
extras: [],
|
|
marker: true,
|
|
origin: Some(
|
|
File(
|
|
"<REQUIREMENTS_DIR>/requirements.txt",
|
|
),
|
|
),
|
|
},
|
|
),
|
|
hashes: [],
|
|
},
|
|
RequirementEntry {
|
|
requirement: Unnamed(
|
|
UnnamedRequirement {
|
|
url: VerbatimParsedUrl {
|
|
parsed_url: Path(
|
|
ParsedPathUrl {
|
|
url: DisplaySafeUrl {
|
|
scheme: "file",
|
|
cannot_be_a_base: false,
|
|
username: "",
|
|
password: None,
|
|
host: None,
|
|
port: None,
|
|
path: "<REQUIREMENTS_DIR>/importlib_metadata-8.2.0+local-py3-none-any.whl",
|
|
query: None,
|
|
fragment: None,
|
|
},
|
|
install_path: "<REQUIREMENTS_DIR>/importlib_metadata-8.2.0+local-py3-none-any.whl",
|
|
ext: Wheel,
|
|
},
|
|
),
|
|
verbatim: VerbatimUrl {
|
|
url: DisplaySafeUrl {
|
|
scheme: "file",
|
|
cannot_be_a_base: false,
|
|
username: "",
|
|
password: None,
|
|
host: None,
|
|
port: None,
|
|
path: "<REQUIREMENTS_DIR>/importlib_metadata-8.2.0+local-py3-none-any.whl",
|
|
query: None,
|
|
fragment: None,
|
|
},
|
|
given: Some(
|
|
"importlib_metadata-8.2.0+local-py3-none-any.whl",
|
|
),
|
|
},
|
|
},
|
|
extras: [],
|
|
marker: sys_platform == 'win32',
|
|
origin: Some(
|
|
File(
|
|
"<REQUIREMENTS_DIR>/requirements.txt",
|
|
),
|
|
),
|
|
},
|
|
),
|
|
hashes: [],
|
|
},
|
|
RequirementEntry {
|
|
requirement: Unnamed(
|
|
UnnamedRequirement {
|
|
url: VerbatimParsedUrl {
|
|
parsed_url: Path(
|
|
ParsedPathUrl {
|
|
url: DisplaySafeUrl {
|
|
scheme: "file",
|
|
cannot_be_a_base: false,
|
|
username: "",
|
|
password: None,
|
|
host: None,
|
|
port: None,
|
|
path: "<REQUIREMENTS_DIR>/importlib_metadata-8.2.0+local-py3-none-any.whl",
|
|
query: None,
|
|
fragment: None,
|
|
},
|
|
install_path: "<REQUIREMENTS_DIR>/importlib_metadata-8.2.0+local-py3-none-any.whl",
|
|
ext: Wheel,
|
|
},
|
|
),
|
|
verbatim: VerbatimUrl {
|
|
url: DisplaySafeUrl {
|
|
scheme: "file",
|
|
cannot_be_a_base: false,
|
|
username: "",
|
|
password: None,
|
|
host: None,
|
|
port: None,
|
|
path: "<REQUIREMENTS_DIR>/importlib_metadata-8.2.0+local-py3-none-any.whl",
|
|
query: None,
|
|
fragment: None,
|
|
},
|
|
given: Some(
|
|
"importlib_metadata-8.2.0+local-py3-none-any.whl",
|
|
),
|
|
},
|
|
},
|
|
extras: [
|
|
ExtraName(
|
|
"extra",
|
|
),
|
|
],
|
|
marker: true,
|
|
origin: Some(
|
|
File(
|
|
"<REQUIREMENTS_DIR>/requirements.txt",
|
|
),
|
|
),
|
|
},
|
|
),
|
|
hashes: [],
|
|
},
|
|
],
|
|
constraints: [],
|
|
editables: [],
|
|
index_url: None,
|
|
extra_index_urls: [],
|
|
find_links: [],
|
|
no_index: false,
|
|
no_binary: None,
|
|
only_binary: None,
|
|
}
|
|
"#);
|
|
});
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn parser_error_line_and_column() -> Result<()> {
|
|
let temp_dir = assert_fs::TempDir::new()?;
|
|
let requirements_txt = temp_dir.child("requirements.txt");
|
|
requirements_txt.write_str(indoc! {"
|
|
numpy>=1,<2
|
|
--broken
|
|
tqdm
|
|
"})?;
|
|
|
|
let error = RequirementsTxt::parse(
|
|
requirements_txt.path(),
|
|
temp_dir.path(),
|
|
&BaseClientBuilder::default(),
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
let errors = anyhow::Error::new(error).chain().join("\n");
|
|
|
|
let requirement_txt = regex::escape(&requirements_txt.path().user_display().to_string());
|
|
let filters = vec![(requirement_txt.as_str(), "<REQUIREMENTS_TXT>")];
|
|
insta::with_settings!({
|
|
filters => filters
|
|
}, {
|
|
insta::assert_snapshot!(errors, @r###"
|
|
Unexpected '-', expected '-c', '-e', '-r' or the start of a requirement at <REQUIREMENTS_TXT>:2:3
|
|
"###);
|
|
});
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test_case("numpy>=1,<2\n @-broken\ntqdm", "2:4"; "ASCII Character with LF")]
|
|
#[test_case("numpy>=1,<2\r\n #-broken\ntqdm", "2:4"; "ASCII Character with CRLF")]
|
|
#[test_case("numpy>=1,<2\n \n-broken\ntqdm", "3:1"; "ASCII Character LF then LF")]
|
|
#[test_case("numpy>=1,<2\n \r-broken\ntqdm", "3:1"; "ASCII Character LF then CR but no LF")]
|
|
#[test_case("numpy>=1,<2\n \r\n-broken\ntqdm", "3:1"; "ASCII Character LF then CRLF")]
|
|
#[test_case("numpy>=1,<2\n 🚀-broken\ntqdm", "2:4"; "Emoji (Wide) Character")]
|
|
#[test_case("numpy>=1,<2\n 中-broken\ntqdm", "2:4"; "Fullwidth character")]
|
|
#[test_case("numpy>=1,<2\n e\u{0301}-broken\ntqdm", "2:5"; "Two codepoints")]
|
|
#[test_case("numpy>=1,<2\n a\u{0300}\u{0316}-broken\ntqdm", "2:6"; "Three codepoints")]
|
|
fn test_calculate_line_column_pair(input: &str, expected: &str) {
|
|
let mut s = Scanner::new(input);
|
|
// Place cursor right after the character we want to test
|
|
s.eat_until('-');
|
|
|
|
// Compute line/column
|
|
let (line, column) = calculate_row_column(input, s.cursor());
|
|
let line_column = format!("{line}:{column}");
|
|
|
|
// Assert line and columns are expected
|
|
assert_eq!(line_column, expected, "Issues with input: {input}");
|
|
}
|
|
|
|
/// Test different kinds of recursive inclusions with requirements and constraints
|
|
#[tokio::test]
|
|
async fn recursive_circular_inclusion() -> Result<()> {
|
|
let temp_dir = assert_fs::TempDir::new()?;
|
|
let both = temp_dir.child("both.txt");
|
|
both.write_str(indoc! {"
|
|
pkg-both
|
|
"})?;
|
|
let both = temp_dir.child("both-recursive.txt");
|
|
both.write_str(indoc! {"
|
|
pkg-both-recursive
|
|
-r both-recursive.txt
|
|
-c both-recursive.txt
|
|
"})?;
|
|
let requirements_only = temp_dir.child("requirements-only.txt");
|
|
requirements_only.write_str(indoc! {"
|
|
pkg-requirements-only
|
|
-r requirements-only.txt
|
|
"})?;
|
|
let requirements_only = temp_dir.child("requirements-only-recursive.txt");
|
|
requirements_only.write_str(indoc! {"
|
|
pkg-requirements-only-recursive
|
|
-r requirements-only-recursive.txt
|
|
"})?;
|
|
let constraints_only = temp_dir.child("requirements-in-constraints.txt");
|
|
constraints_only.write_str(indoc! {"
|
|
pkg-requirements-in-constraints
|
|
# Some nested recursion for good measure
|
|
-c constraints-only.txt
|
|
"})?;
|
|
let constraints_only = temp_dir.child("constraints-only.txt");
|
|
constraints_only.write_str(indoc! {"
|
|
pkg-constraints-only
|
|
-c constraints-only.txt
|
|
# Using `-r` inside `-c`
|
|
-r requirements-in-constraints.txt
|
|
"})?;
|
|
let constraints_only = temp_dir.child("constraints-only-recursive.txt");
|
|
constraints_only.write_str(indoc! {"
|
|
pkg-constraints-only-recursive
|
|
-r constraints-only-recursive.txt
|
|
"})?;
|
|
|
|
let requirements = temp_dir.child("requirements.txt");
|
|
requirements.write_str(indoc! {"
|
|
# Even if a package was already included as a constraint, it is also included as
|
|
# requirement
|
|
-c both.txt
|
|
-r both.txt
|
|
-c both-recursive.txt
|
|
-r both-recursive.txt
|
|
|
|
-r requirements-only.txt
|
|
-r requirements-only-recursive.txt
|
|
-c constraints-only.txt
|
|
-c constraints-only-recursive.txt
|
|
"})?;
|
|
|
|
let parsed = RequirementsTxt::parse(
|
|
&requirements,
|
|
temp_dir.path(),
|
|
&BaseClientBuilder::default(),
|
|
)
|
|
.await?;
|
|
|
|
let requirements: BTreeSet<String> = parsed
|
|
.requirements
|
|
.iter()
|
|
.map(|entry| entry.requirement.to_string())
|
|
.collect();
|
|
let constraints: BTreeSet<String> =
|
|
parsed.constraints.iter().map(ToString::to_string).collect();
|
|
|
|
assert_debug_snapshot!(requirements, @r#"
|
|
{
|
|
"pkg-both",
|
|
"pkg-both-recursive",
|
|
"pkg-requirements-only",
|
|
"pkg-requirements-only-recursive",
|
|
}
|
|
"#);
|
|
assert_debug_snapshot!(constraints, @r#"
|
|
{
|
|
"pkg-both",
|
|
"pkg-both-recursive",
|
|
"pkg-constraints-only",
|
|
"pkg-constraints-only-recursive",
|
|
"pkg-requirements-in-constraints",
|
|
}
|
|
"#);
|
|
|
|
Ok(())
|
|
}
|
|
}
|