mirror of https://github.com/astral-sh/uv
Allow relative paths in requirements.txt (#1027)
This PR attempts to fix a common footgun in `requirements.txt` files. Previously, to provide a file, you had to use `package_name @ file:///Users/crmarsh/...` -- in other words, an absolute path. Now, these requirements follow the exact same rules as editables, so you can do: ``` package_name @ ./file.zip ``` And similar. The way the parsing is setup, this is intentionally _not_ supported when reading metadata -- only when parsing `requirements.txt` directly. Closes #984.
This commit is contained in:
parent
e09a51653e
commit
145ba0e5ab
|
|
@ -22,6 +22,7 @@ use std::collections::HashSet;
|
|||
use std::fmt::{Display, Formatter};
|
||||
#[cfg(feature = "pyo3")]
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::path::Path;
|
||||
use std::str::{Chars, FromStr};
|
||||
|
||||
#[cfg(feature = "pyo3")]
|
||||
|
|
@ -44,7 +45,7 @@ use pep440_rs::{Version, VersionSpecifier, VersionSpecifiers};
|
|||
#[cfg(feature = "pyo3")]
|
||||
use puffin_normalize::InvalidNameError;
|
||||
use puffin_normalize::{ExtraName, PackageName};
|
||||
pub use verbatim_url::VerbatimUrl;
|
||||
pub use verbatim_url::{split_scheme, VerbatimUrl};
|
||||
|
||||
mod marker;
|
||||
mod verbatim_url;
|
||||
|
|
@ -396,14 +397,14 @@ impl FromStr for Requirement {
|
|||
|
||||
/// Parse a [Dependency Specifier](https://packaging.python.org/en/latest/specifications/dependency-specifiers/)
|
||||
fn from_str(input: &str) -> Result<Self, Self::Err> {
|
||||
parse(&mut Cursor::new(input))
|
||||
parse(&mut Cursor::new(input), None)
|
||||
}
|
||||
}
|
||||
|
||||
impl Requirement {
|
||||
/// Parse a [Dependency Specifier](https://packaging.python.org/en/latest/specifications/dependency-specifiers/)
|
||||
pub fn parse(input: &mut Cursor) -> Result<Self, Pep508Error> {
|
||||
parse(input)
|
||||
pub fn parse(input: &str, working_dir: Option<&Path>) -> Result<Self, Pep508Error> {
|
||||
parse(&mut Cursor::new(input), working_dir)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -690,7 +691,15 @@ fn parse_extras(cursor: &mut Cursor) -> Result<Option<Vec<ExtraName>>, Pep508Err
|
|||
Ok(Some(extras))
|
||||
}
|
||||
|
||||
fn parse_url(cursor: &mut Cursor) -> Result<VerbatimUrl, Pep508Error> {
|
||||
/// Parse a raw string for a URL requirement, which could be either a URL or a local path, and which
|
||||
/// could contain unexpanded environment variables.
|
||||
///
|
||||
/// For example:
|
||||
/// - `https://pypi.org/project/requests/...`
|
||||
/// - `file:///home/ferris/project/scripts/...`
|
||||
/// - `file:../editable/`
|
||||
/// - `../editable/`
|
||||
fn parse_url(cursor: &mut Cursor, working_dir: Option<&Path>) -> Result<VerbatimUrl, Pep508Error> {
|
||||
// wsp*
|
||||
cursor.eat_whitespace();
|
||||
// <URI_reference>
|
||||
|
|
@ -704,12 +713,64 @@ fn parse_url(cursor: &mut Cursor) -> Result<VerbatimUrl, Pep508Error> {
|
|||
input: cursor.to_string(),
|
||||
});
|
||||
}
|
||||
let url = VerbatimUrl::from_str(url).map_err(|err| Pep508Error {
|
||||
message: Pep508ErrorSource::UrlError(err),
|
||||
start,
|
||||
len,
|
||||
input: cursor.to_string(),
|
||||
})?;
|
||||
|
||||
// Create a `VerbatimUrl` to represent the requirement.
|
||||
let url = if let Some((scheme, path)) = split_scheme(url) {
|
||||
if scheme == "file" {
|
||||
if let Some(path) = path.strip_prefix("//") {
|
||||
// Ex) `file:///home/ferris/project/scripts/...`
|
||||
if let Some(working_dir) = working_dir {
|
||||
VerbatimUrl::from_path(path, working_dir).with_given(url.to_string())
|
||||
} else {
|
||||
VerbatimUrl::from_absolute_path(path)
|
||||
.map_err(|err| Pep508Error {
|
||||
message: Pep508ErrorSource::UrlError(err),
|
||||
start,
|
||||
len,
|
||||
input: cursor.to_string(),
|
||||
})?
|
||||
.with_given(url.to_string())
|
||||
}
|
||||
} else {
|
||||
// Ex) `file:../editable/`
|
||||
if let Some(working_dir) = working_dir {
|
||||
VerbatimUrl::from_path(path, working_dir).with_given(url.to_string())
|
||||
} else {
|
||||
VerbatimUrl::from_absolute_path(path)
|
||||
.map_err(|err| Pep508Error {
|
||||
message: Pep508ErrorSource::UrlError(err),
|
||||
start,
|
||||
len,
|
||||
input: cursor.to_string(),
|
||||
})?
|
||||
.with_given(url.to_string())
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Ex) `https://...`
|
||||
VerbatimUrl::from_str(url).map_err(|err| Pep508Error {
|
||||
message: Pep508ErrorSource::UrlError(err),
|
||||
start,
|
||||
len,
|
||||
input: cursor.to_string(),
|
||||
})?
|
||||
}
|
||||
} else {
|
||||
// Ex) `../editable/`
|
||||
if let Some(working_dir) = working_dir {
|
||||
VerbatimUrl::from_path(url, working_dir).with_given(url.to_string())
|
||||
} else {
|
||||
VerbatimUrl::from_absolute_path(url)
|
||||
.map_err(|err| Pep508Error {
|
||||
message: Pep508ErrorSource::UrlError(err),
|
||||
start,
|
||||
len,
|
||||
input: cursor.to_string(),
|
||||
})?
|
||||
.with_given(url.to_string())
|
||||
}
|
||||
};
|
||||
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
|
|
@ -805,7 +866,7 @@ fn parse_version_specifier_parentheses(
|
|||
}
|
||||
|
||||
/// Parse a [dependency specifier](https://packaging.python.org/en/latest/specifications/dependency-specifiers)
|
||||
fn parse(cursor: &mut Cursor) -> Result<Requirement, Pep508Error> {
|
||||
fn parse(cursor: &mut Cursor, working_dir: Option<&Path>) -> Result<Requirement, Pep508Error> {
|
||||
let start = cursor.pos();
|
||||
|
||||
// Technically, the grammar is:
|
||||
|
|
@ -835,7 +896,7 @@ fn parse(cursor: &mut Cursor) -> Result<Requirement, Pep508Error> {
|
|||
let requirement_kind = match cursor.peek_char() {
|
||||
Some('@') => {
|
||||
cursor.next();
|
||||
Some(VersionOrUrl::Url(parse_url(cursor)?))
|
||||
Some(VersionOrUrl::Url(parse_url(cursor, working_dir)?))
|
||||
}
|
||||
Some('(') => parse_version_specifier_parentheses(cursor)?,
|
||||
Some('<' | '=' | '>' | '~' | '!') => parse_version_specifier(cursor)?,
|
||||
|
|
@ -845,7 +906,7 @@ fn parse(cursor: &mut Cursor) -> Result<Requirement, Pep508Error> {
|
|||
// a package name. pip supports this in `requirements.txt`, but it doesn't adhere to
|
||||
// the PEP 508 grammar.
|
||||
let mut clone = cursor.clone().at(start);
|
||||
return if parse_url(&mut clone).is_ok() {
|
||||
return if parse_url(&mut clone, working_dir).is_ok() {
|
||||
Err(Pep508Error {
|
||||
message: Pep508ErrorSource::UnsupportedRequirement("URL requirement must be preceded by a package name. Add the name of the package before the URL (e.g., `package_name @ https://...`).".to_string()),
|
||||
start,
|
||||
|
|
|
|||
|
|
@ -29,16 +29,13 @@ pub struct VerbatimUrl {
|
|||
|
||||
impl VerbatimUrl {
|
||||
/// Parse a URL from a string, expanding any environment variables.
|
||||
pub fn parse(given: String) -> Result<Self, VerbatimUrlError> {
|
||||
let url = Url::parse(&expand_env_vars(&given, true))
|
||||
.map_err(|err| VerbatimUrlError::Url(given.clone(), err))?;
|
||||
Ok(Self {
|
||||
given: Some(given),
|
||||
url,
|
||||
})
|
||||
pub fn parse(given: impl AsRef<str>) -> Result<Self, VerbatimUrlError> {
|
||||
let url = Url::parse(&expand_env_vars(given.as_ref(), true))
|
||||
.map_err(|err| VerbatimUrlError::Url(given.as_ref().to_owned(), err))?;
|
||||
Ok(Self { url, given: None })
|
||||
}
|
||||
|
||||
/// Parse a URL from a path.
|
||||
/// Parse a URL from am absolute or relative path.
|
||||
pub fn from_path(path: impl AsRef<str>, working_dir: impl AsRef<Path>) -> Self {
|
||||
// Expand any environment variables.
|
||||
let path = PathBuf::from(expand_env_vars(path.as_ref(), false).as_ref());
|
||||
|
|
@ -59,6 +56,27 @@ impl VerbatimUrl {
|
|||
Self { url, given: None }
|
||||
}
|
||||
|
||||
/// Parse a URL from an absolute path.
|
||||
pub fn from_absolute_path(path: impl AsRef<str>) -> Result<Self, VerbatimUrlError> {
|
||||
// Expand any environment variables.
|
||||
let path = PathBuf::from(expand_env_vars(path.as_ref(), false).as_ref());
|
||||
|
||||
// Convert the path to an absolute path, if necessary.
|
||||
let path = if path.is_absolute() {
|
||||
path
|
||||
} else {
|
||||
return Err(VerbatimUrlError::RelativePath(path));
|
||||
};
|
||||
|
||||
// Normalize the path.
|
||||
let path = normalize_path(&path);
|
||||
|
||||
// Convert to a URL.
|
||||
let url = Url::from_file_path(path).expect("path is absolute");
|
||||
|
||||
Ok(Self { url, given: None })
|
||||
}
|
||||
|
||||
/// Set the verbatim representation of the URL.
|
||||
#[must_use]
|
||||
pub fn with_given(self, given: String) -> Self {
|
||||
|
|
@ -96,7 +114,7 @@ impl std::str::FromStr for VerbatimUrl {
|
|||
type Err = VerbatimUrlError;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
Self::parse(s.to_owned())
|
||||
Self::parse(s).map(|url| url.with_given(s.to_owned()))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -120,6 +138,10 @@ pub enum VerbatimUrlError {
|
|||
/// Failed to parse a URL.
|
||||
#[error("{0}")]
|
||||
Url(String, #[source] url::ParseError),
|
||||
|
||||
/// Received a relative path, but no working directory was provided.
|
||||
#[error("relative path without a working directory: {0}")]
|
||||
RelativePath(PathBuf),
|
||||
}
|
||||
|
||||
/// Expand all available environment variables.
|
||||
|
|
@ -193,3 +215,60 @@ fn normalize_path(path: &Path) -> PathBuf {
|
|||
}
|
||||
ret
|
||||
}
|
||||
|
||||
/// Like [`Url::parse`], but only splits the scheme. Derived from the `url` crate.
|
||||
pub fn split_scheme(s: &str) -> Option<(&str, &str)> {
|
||||
/// <https://url.spec.whatwg.org/#c0-controls-and-space>
|
||||
#[inline]
|
||||
fn c0_control_or_space(ch: char) -> bool {
|
||||
ch <= ' ' // U+0000 to U+0020
|
||||
}
|
||||
|
||||
/// <https://url.spec.whatwg.org/#ascii-alpha>
|
||||
#[inline]
|
||||
fn ascii_alpha(ch: char) -> bool {
|
||||
ch.is_ascii_alphabetic()
|
||||
}
|
||||
|
||||
// Trim control characters and spaces from the start and end.
|
||||
let s = s.trim_matches(c0_control_or_space);
|
||||
if s.is_empty() || !s.starts_with(ascii_alpha) {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Find the `:` following any alpha characters.
|
||||
let mut iter = s.char_indices();
|
||||
let end = loop {
|
||||
match iter.next() {
|
||||
Some((_i, 'a'..='z' | 'A'..='Z' | '0'..='9' | '+' | '-' | '.')) => {}
|
||||
Some((i, ':')) => break i,
|
||||
_ => return None,
|
||||
}
|
||||
};
|
||||
|
||||
let scheme = &s[..end];
|
||||
let rest = &s[end + 1..];
|
||||
Some((scheme, rest))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn scheme() {
|
||||
assert_eq!(
|
||||
split_scheme("file:///home/ferris/project/scripts"),
|
||||
Some(("file", "///home/ferris/project/scripts"))
|
||||
);
|
||||
assert_eq!(
|
||||
split_scheme("file:home/ferris/project/scripts"),
|
||||
Some(("file", "home/ferris/project/scripts"))
|
||||
);
|
||||
assert_eq!(
|
||||
split_scheme("https://example.com"),
|
||||
Some(("https", "//example.com"))
|
||||
);
|
||||
assert_eq!(split_scheme("https:"), Some(("https", "")));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -88,7 +88,7 @@ impl RequirementsSpecification {
|
|||
}
|
||||
}
|
||||
RequirementsSource::Editable(name) => {
|
||||
let requirement = EditableRequirement::from_str(name)
|
||||
let requirement = EditableRequirement::parse(name, std::env::current_dir()?)
|
||||
.with_context(|| format!("Failed to parse `{name}`"))?;
|
||||
Self {
|
||||
project: None,
|
||||
|
|
|
|||
|
|
@ -2110,6 +2110,135 @@ fn compile_wheel_path_dependency() -> Result<()> {
|
|||
"###);
|
||||
});
|
||||
|
||||
// Run the same operation, but this time with a relative path.
|
||||
let requirements_in = temp_dir.child("requirements.in");
|
||||
requirements_in.write_str("flask @ file:flask-3.0.0-py3-none-any.whl")?;
|
||||
|
||||
insta::with_settings!({
|
||||
filters => INSTA_FILTERS.to_vec()
|
||||
}, {
|
||||
assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME))
|
||||
.arg("pip")
|
||||
.arg("compile")
|
||||
.arg("requirements.in")
|
||||
.arg("--cache-dir")
|
||||
.arg(cache_dir.path())
|
||||
.arg("--exclude-newer")
|
||||
.arg(EXCLUDE_NEWER)
|
||||
.env("VIRTUAL_ENV", venv.as_os_str())
|
||||
.current_dir(&temp_dir), @r###"
|
||||
success: true
|
||||
exit_code: 0
|
||||
----- stdout -----
|
||||
# This file was autogenerated by Puffin v[VERSION] via the following command:
|
||||
# puffin pip compile requirements.in --cache-dir [CACHE_DIR]
|
||||
blinker==1.7.0
|
||||
# via flask
|
||||
click==8.1.7
|
||||
# via flask
|
||||
flask @ file:flask-3.0.0-py3-none-any.whl
|
||||
itsdangerous==2.1.2
|
||||
# via flask
|
||||
jinja2==3.1.2
|
||||
# via flask
|
||||
markupsafe==2.1.3
|
||||
# via
|
||||
# jinja2
|
||||
# werkzeug
|
||||
werkzeug==3.0.1
|
||||
# via flask
|
||||
|
||||
----- stderr -----
|
||||
Resolved 7 packages in [TIME]
|
||||
"###);
|
||||
});
|
||||
|
||||
// Run the same operation, but this time with a relative path.
|
||||
let requirements_in = temp_dir.child("requirements.in");
|
||||
requirements_in.write_str("flask @ file://flask-3.0.0-py3-none-any.whl")?;
|
||||
|
||||
insta::with_settings!({
|
||||
filters => INSTA_FILTERS.to_vec()
|
||||
}, {
|
||||
assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME))
|
||||
.arg("pip")
|
||||
.arg("compile")
|
||||
.arg("requirements.in")
|
||||
.arg("--cache-dir")
|
||||
.arg(cache_dir.path())
|
||||
.arg("--exclude-newer")
|
||||
.arg(EXCLUDE_NEWER)
|
||||
.env("VIRTUAL_ENV", venv.as_os_str())
|
||||
.current_dir(&temp_dir), @r###"
|
||||
success: true
|
||||
exit_code: 0
|
||||
----- stdout -----
|
||||
# This file was autogenerated by Puffin v[VERSION] via the following command:
|
||||
# puffin pip compile requirements.in --cache-dir [CACHE_DIR]
|
||||
blinker==1.7.0
|
||||
# via flask
|
||||
click==8.1.7
|
||||
# via flask
|
||||
flask @ file://flask-3.0.0-py3-none-any.whl
|
||||
itsdangerous==2.1.2
|
||||
# via flask
|
||||
jinja2==3.1.2
|
||||
# via flask
|
||||
markupsafe==2.1.3
|
||||
# via
|
||||
# jinja2
|
||||
# werkzeug
|
||||
werkzeug==3.0.1
|
||||
# via flask
|
||||
|
||||
----- stderr -----
|
||||
Resolved 7 packages in [TIME]
|
||||
"###);
|
||||
});
|
||||
|
||||
// Run the same operation, but this time with a relative path.
|
||||
let requirements_in = temp_dir.child("requirements.in");
|
||||
requirements_in.write_str("flask @ ./flask-3.0.0-py3-none-any.whl")?;
|
||||
|
||||
insta::with_settings!({
|
||||
filters => INSTA_FILTERS.to_vec()
|
||||
}, {
|
||||
assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME))
|
||||
.arg("pip")
|
||||
.arg("compile")
|
||||
.arg("requirements.in")
|
||||
.arg("--cache-dir")
|
||||
.arg(cache_dir.path())
|
||||
.arg("--exclude-newer")
|
||||
.arg(EXCLUDE_NEWER)
|
||||
.env("VIRTUAL_ENV", venv.as_os_str())
|
||||
.current_dir(&temp_dir), @r###"
|
||||
success: true
|
||||
exit_code: 0
|
||||
----- stdout -----
|
||||
# This file was autogenerated by Puffin v[VERSION] via the following command:
|
||||
# puffin pip compile requirements.in --cache-dir [CACHE_DIR]
|
||||
blinker==1.7.0
|
||||
# via flask
|
||||
click==8.1.7
|
||||
# via flask
|
||||
flask @ ./flask-3.0.0-py3-none-any.whl
|
||||
itsdangerous==2.1.2
|
||||
# via flask
|
||||
jinja2==3.1.2
|
||||
# via flask
|
||||
markupsafe==2.1.3
|
||||
# via
|
||||
# jinja2
|
||||
# werkzeug
|
||||
werkzeug==3.0.1
|
||||
# via flask
|
||||
|
||||
----- stderr -----
|
||||
Resolved 7 packages in [TIME]
|
||||
"###);
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -38,16 +38,14 @@ use std::fmt::{Display, Formatter};
|
|||
use std::io;
|
||||
use std::io::Error;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::str::FromStr;
|
||||
|
||||
use fs_err as fs;
|
||||
use once_cell::sync::Lazy;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::warn;
|
||||
use unscanny::{Pattern, Scanner};
|
||||
use url::Url;
|
||||
|
||||
use pep508_rs::{Pep508Error, Pep508ErrorSource, Requirement, VerbatimUrl};
|
||||
use pep508_rs::{split_scheme, Pep508Error, Pep508ErrorSource, Requirement, VerbatimUrl};
|
||||
|
||||
/// We emit one of those for each requirements.txt entry
|
||||
enum RequirementsTxtStatement {
|
||||
|
|
@ -66,7 +64,7 @@ enum RequirementsTxtStatement {
|
|||
/// PEP 508 requirement plus metadata
|
||||
RequirementEntry(RequirementEntry),
|
||||
/// `-e`
|
||||
EditableRequirement(ParsedEditableRequirement),
|
||||
EditableRequirement(EditableRequirement),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Eq, PartialEq)]
|
||||
|
|
@ -85,80 +83,29 @@ impl EditableRequirement {
|
|||
}
|
||||
}
|
||||
|
||||
impl FromStr for EditableRequirement {
|
||||
type Err = RequirementsTxtParserError;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
static CWD: Lazy<PathBuf> = Lazy::new(|| std::env::current_dir().unwrap());
|
||||
|
||||
let editable_requirement = ParsedEditableRequirement::from(s.to_string());
|
||||
editable_requirement.with_working_dir(&*CWD)
|
||||
}
|
||||
}
|
||||
|
||||
/// A raw string for an editable requirement (`pip install -e <editable>`), which could be a URL or
|
||||
/// a local path, and could contain unexpanded environment variables.
|
||||
///
|
||||
/// For example:
|
||||
/// - `file:///home/ferris/project/scripts/...`
|
||||
/// - `file:../editable/`
|
||||
/// - `../editable/`
|
||||
///
|
||||
/// We disallow URLs with schemes other than `file://` (e.g., `https://...`).
|
||||
#[derive(Debug, Clone, Eq, PartialEq)]
|
||||
pub struct ParsedEditableRequirement(String);
|
||||
|
||||
/// Like [`Url::parse`], but only splits the scheme. Derived from the `url` crate.
|
||||
fn split_scheme(s: &str) -> Option<(&str, &str)> {
|
||||
/// <https://url.spec.whatwg.org/#c0-controls-and-space>
|
||||
#[inline]
|
||||
fn c0_control_or_space(ch: char) -> bool {
|
||||
ch <= ' ' // U+0000 to U+0020
|
||||
}
|
||||
|
||||
/// <https://url.spec.whatwg.org/#ascii-alpha>
|
||||
#[inline]
|
||||
fn ascii_alpha(ch: char) -> bool {
|
||||
ch.is_ascii_alphabetic()
|
||||
}
|
||||
|
||||
// Trim control characters and spaces from the start and end.
|
||||
let s = s.trim_matches(c0_control_or_space);
|
||||
if s.is_empty() || !s.starts_with(ascii_alpha) {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Find the `:` following any alpha characters.
|
||||
let mut iter = s.char_indices();
|
||||
let end = loop {
|
||||
match iter.next() {
|
||||
Some((_i, 'a'..='z' | 'A'..='Z' | '0'..='9' | '+' | '-' | '.')) => {}
|
||||
Some((i, ':')) => break i,
|
||||
_ => return None,
|
||||
}
|
||||
};
|
||||
|
||||
let scheme = &s[..end];
|
||||
let rest = &s[end + 1..];
|
||||
Some((scheme, rest))
|
||||
}
|
||||
|
||||
impl ParsedEditableRequirement {
|
||||
pub fn with_working_dir(
|
||||
self,
|
||||
impl EditableRequirement {
|
||||
/// Parse a raw string for an editable requirement (`pip install -e <editable>`), which could be
|
||||
/// a URL or a local path, and could contain unexpanded environment variables.
|
||||
///
|
||||
/// For example:
|
||||
/// - `file:///home/ferris/project/scripts/...`
|
||||
/// - `file:../editable/`
|
||||
/// - `../editable/`
|
||||
///
|
||||
/// We disallow URLs with schemes other than `file://` (e.g., `https://...`).
|
||||
pub fn parse(
|
||||
given: &str,
|
||||
working_dir: impl AsRef<Path>,
|
||||
) -> Result<EditableRequirement, RequirementsTxtParserError> {
|
||||
let given = self.0;
|
||||
|
||||
// Create a `VerbatimUrl` to represent the editable requirement.
|
||||
let url = if let Some((scheme, path)) = split_scheme(&given) {
|
||||
let url = if let Some((scheme, path)) = split_scheme(given) {
|
||||
if scheme == "file" {
|
||||
if let Some(path) = path.strip_prefix("//") {
|
||||
// Ex) `file:///home/ferris/project/scripts/...`
|
||||
VerbatimUrl::from_path(path, working_dir)
|
||||
VerbatimUrl::from_path(path, working_dir.as_ref())
|
||||
} else {
|
||||
// Ex) `file:../editable/`
|
||||
VerbatimUrl::from_path(path, working_dir)
|
||||
VerbatimUrl::from_path(path, working_dir.as_ref())
|
||||
}
|
||||
} else {
|
||||
// Ex) `https://...`
|
||||
|
|
@ -168,33 +115,21 @@ impl ParsedEditableRequirement {
|
|||
}
|
||||
} else {
|
||||
// Ex) `../editable/`
|
||||
VerbatimUrl::from_path(&given, working_dir)
|
||||
VerbatimUrl::from_path(given, working_dir.as_ref())
|
||||
};
|
||||
|
||||
// Create a `PathBuf`.
|
||||
let path = url
|
||||
.to_file_path()
|
||||
.map_err(|()| RequirementsTxtParserError::InvalidEditablePath(given.clone()))?;
|
||||
.map_err(|()| RequirementsTxtParserError::InvalidEditablePath(given.to_string()))?;
|
||||
|
||||
// Add the verbatim representation of the URL to the `VerbatimUrl`.
|
||||
let url = url.with_given(given);
|
||||
let url = url.with_given(given.to_string());
|
||||
|
||||
Ok(EditableRequirement { url, path })
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for ParsedEditableRequirement {
|
||||
fn from(s: String) -> Self {
|
||||
Self(s)
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for ParsedEditableRequirement {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
Display::fmt(&self.0, f)
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for EditableRequirement {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
Display::fmt(&self.url, f)
|
||||
|
|
@ -248,11 +183,12 @@ impl RequirementsTxt {
|
|||
file: requirements_txt.as_ref().to_path_buf(),
|
||||
error: RequirementsTxtParserError::IO(err),
|
||||
})?;
|
||||
let data =
|
||||
Self::parse_inner(&content, working_dir).map_err(|err| RequirementsTxtFileError {
|
||||
let data = Self::parse_inner(&content, working_dir.as_ref()).map_err(|err| {
|
||||
RequirementsTxtFileError {
|
||||
file: requirements_txt.as_ref().to_path_buf(),
|
||||
error: err,
|
||||
})?;
|
||||
}
|
||||
})?;
|
||||
if data == Self::default() {
|
||||
warn!(
|
||||
"Requirements file {} does not contain any dependencies",
|
||||
|
|
@ -268,27 +204,26 @@ impl RequirementsTxt {
|
|||
/// of the file
|
||||
pub fn parse_inner(
|
||||
content: &str,
|
||||
working_dir: impl AsRef<Path>,
|
||||
working_dir: &Path,
|
||||
) -> Result<Self, RequirementsTxtParserError> {
|
||||
let mut s = Scanner::new(content);
|
||||
|
||||
let mut data = Self::default();
|
||||
while let Some(statement) = parse_entry(&mut s, content)? {
|
||||
while let Some(statement) = parse_entry(&mut s, content, working_dir)? {
|
||||
match statement {
|
||||
RequirementsTxtStatement::Requirements {
|
||||
filename,
|
||||
start,
|
||||
end,
|
||||
} => {
|
||||
let sub_file = working_dir.as_ref().join(filename);
|
||||
let sub_requirements =
|
||||
Self::parse(&sub_file, working_dir.as_ref()).map_err(|err| {
|
||||
RequirementsTxtParserError::Subfile {
|
||||
source: Box::new(err),
|
||||
start,
|
||||
end,
|
||||
}
|
||||
})?;
|
||||
let sub_file = working_dir.join(filename);
|
||||
let sub_requirements = Self::parse(&sub_file, working_dir).map_err(|err| {
|
||||
RequirementsTxtParserError::Subfile {
|
||||
source: Box::new(err),
|
||||
start,
|
||||
end,
|
||||
}
|
||||
})?;
|
||||
// Add each to the correct category
|
||||
data.update_from(sub_requirements);
|
||||
}
|
||||
|
|
@ -297,15 +232,14 @@ impl RequirementsTxt {
|
|||
start,
|
||||
end,
|
||||
} => {
|
||||
let sub_file = working_dir.as_ref().join(filename);
|
||||
let sub_constraints =
|
||||
Self::parse(&sub_file, working_dir.as_ref()).map_err(|err| {
|
||||
RequirementsTxtParserError::Subfile {
|
||||
source: Box::new(err),
|
||||
start,
|
||||
end,
|
||||
}
|
||||
})?;
|
||||
let sub_file = working_dir.join(filename);
|
||||
let sub_constraints = Self::parse(&sub_file, working_dir).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.
|
||||
|
|
@ -321,8 +255,7 @@ impl RequirementsTxt {
|
|||
data.requirements.push(requirement_entry);
|
||||
}
|
||||
RequirementsTxtStatement::EditableRequirement(editable) => {
|
||||
data.editables
|
||||
.push(editable.with_working_dir(&working_dir)?);
|
||||
data.editables.push(editable);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -343,6 +276,7 @@ impl RequirementsTxt {
|
|||
fn parse_entry(
|
||||
s: &mut Scanner,
|
||||
content: &str,
|
||||
working_dir: &Path,
|
||||
) -> Result<Option<RequirementsTxtStatement>, RequirementsTxtParserError> {
|
||||
// Eat all preceding whitespace, this may run us to the end of file
|
||||
eat_wrappable_whitespace(s);
|
||||
|
|
@ -373,10 +307,10 @@ fn parse_entry(
|
|||
}
|
||||
} else if s.eat_if("-e") {
|
||||
let path_or_url = parse_value(s, |c: char| !['\n', '\r'].contains(&c))?;
|
||||
let editable_requirement = ParsedEditableRequirement::from(path_or_url.to_string());
|
||||
let editable_requirement = EditableRequirement::parse(path_or_url, working_dir)?;
|
||||
RequirementsTxtStatement::EditableRequirement(editable_requirement)
|
||||
} else if s.at(char::is_ascii_alphanumeric) {
|
||||
let (requirement, hashes) = parse_requirement_and_hashes(s, content)?;
|
||||
let (requirement, hashes) = parse_requirement_and_hashes(s, content, working_dir)?;
|
||||
RequirementsTxtStatement::RequirementEntry(RequirementEntry {
|
||||
requirement,
|
||||
hashes,
|
||||
|
|
@ -435,6 +369,7 @@ fn eat_trailing_line(s: &mut Scanner) -> Result<(), RequirementsTxtParserError>
|
|||
fn parse_requirement_and_hashes(
|
||||
s: &mut Scanner,
|
||||
content: &str,
|
||||
working_dir: &Path,
|
||||
) -> Result<(Requirement, Vec<String>), RequirementsTxtParserError> {
|
||||
// PEP 508 requirement
|
||||
let start = s.cursor();
|
||||
|
|
@ -469,22 +404,24 @@ fn parse_requirement_and_hashes(
|
|||
}
|
||||
};
|
||||
let requirement =
|
||||
Requirement::from_str(&content[start..end]).map_err(|err| match err.message {
|
||||
Pep508ErrorSource::String(_) => RequirementsTxtParserError::Pep508 {
|
||||
source: err,
|
||||
start,
|
||||
end,
|
||||
},
|
||||
Pep508ErrorSource::UrlError(_) => RequirementsTxtParserError::Pep508 {
|
||||
source: err,
|
||||
start,
|
||||
end,
|
||||
},
|
||||
Pep508ErrorSource::UnsupportedRequirement(_) => {
|
||||
RequirementsTxtParserError::UnsupportedRequirement {
|
||||
Requirement::parse(&content[start..end], Some(working_dir)).map_err(|err| {
|
||||
match err.message {
|
||||
Pep508ErrorSource::String(_) => RequirementsTxtParserError::Pep508 {
|
||||
source: err,
|
||||
start,
|
||||
end,
|
||||
},
|
||||
Pep508ErrorSource::UrlError(_) => RequirementsTxtParserError::Pep508 {
|
||||
source: err,
|
||||
start,
|
||||
end,
|
||||
},
|
||||
Pep508ErrorSource::UnsupportedRequirement(_) => {
|
||||
RequirementsTxtParserError::UnsupportedRequirement {
|
||||
source: err,
|
||||
start,
|
||||
end,
|
||||
}
|
||||
}
|
||||
}
|
||||
})?;
|
||||
|
|
@ -690,7 +627,7 @@ mod test {
|
|||
use tempfile::tempdir;
|
||||
use test_case::test_case;
|
||||
|
||||
use crate::{split_scheme, RequirementsTxt};
|
||||
use crate::RequirementsTxt;
|
||||
|
||||
#[test_case(Path::new("basic.txt"))]
|
||||
#[test_case(Path::new("constraints-a.txt"))]
|
||||
|
|
@ -814,21 +751,4 @@ mod test {
|
|||
];
|
||||
assert_eq!(errors, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scheme() {
|
||||
assert_eq!(
|
||||
split_scheme("file:///home/ferris/project/scripts"),
|
||||
Some(("file", "///home/ferris/project/scripts"))
|
||||
);
|
||||
assert_eq!(
|
||||
split_scheme("file:home/ferris/project/scripts"),
|
||||
Some(("file", "home/ferris/project/scripts"))
|
||||
);
|
||||
assert_eq!(
|
||||
split_scheme("https://example.com"),
|
||||
Some(("https", "//example.com"))
|
||||
);
|
||||
assert_eq!(split_scheme("https:"), Some(("https", "")));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue