Accept `file://` URLs for `requirements.txt` et all references (#4145)

## Summary

Closes https://github.com/astral-sh/uv/issues/4124.
This commit is contained in:
Charlie Marsh 2024-06-07 15:03:08 -07:00 committed by GitHub
parent e3b274413d
commit d7cc622d6c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 104 additions and 14 deletions

View File

@ -262,6 +262,7 @@ impl RequirementsTxt {
read_url_to_string(&requirements_txt, client).await read_url_to_string(&requirements_txt, client).await
} }
} else { } else {
// Ex) `file:///home/ferris/project/requirements.txt`
uv_fs::read_to_string_transcode(&requirements_txt) uv_fs::read_to_string_transcode(&requirements_txt)
.await .await
.map_err(RequirementsTxtParserError::IO) .map_err(RequirementsTxtParserError::IO)
@ -321,6 +322,22 @@ impl RequirementsTxt {
let sub_file = let sub_file =
if filename.starts_with("http://") || filename.starts_with("https://") { if filename.starts_with("http://") || filename.starts_with("https://") {
PathBuf::from(filename.as_ref()) 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: err,
url: filename.to_string(),
start,
end,
})?
.to_file_path()
.map_err(|()| RequirementsTxtParserError::FileUrl {
url: filename.to_string(),
start,
end,
})?,
)
} else { } else {
requirements_dir.join(filename.as_ref()) requirements_dir.join(filename.as_ref())
}; };
@ -360,6 +377,22 @@ impl RequirementsTxt {
let sub_file = let sub_file =
if filename.starts_with("http://") || filename.starts_with("https://") { if filename.starts_with("http://") || filename.starts_with("https://") {
PathBuf::from(filename.as_ref()) 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: err,
url: filename.to_string(),
start,
end,
})?
.to_file_path()
.map_err(|()| RequirementsTxtParserError::FileUrl {
url: filename.to_string(),
start,
end,
})?,
)
} else { } else {
requirements_dir.join(filename.as_ref()) requirements_dir.join(filename.as_ref())
}; };
@ -815,6 +848,11 @@ pub enum RequirementsTxtParserError {
start: usize, start: usize,
end: usize, end: usize,
}, },
FileUrl {
url: String,
start: usize,
end: usize,
},
VerbatimUrl { VerbatimUrl {
source: pep508_rs::VerbatimUrlError, source: pep508_rs::VerbatimUrlError,
url: String, url: String,
@ -882,6 +920,9 @@ impl Display for RequirementsTxtParserError {
Self::Url { url, start, .. } => { Self::Url { url, start, .. } => {
write!(f, "Invalid URL at position {start}: `{url}`") write!(f, "Invalid URL at position {start}: `{url}`")
} }
Self::FileUrl { url, start, .. } => {
write!(f, "Invalid file URL at position {start}: `{url}`")
}
Self::VerbatimUrl { source, url } => { Self::VerbatimUrl { source, url } => {
write!(f, "Invalid URL: `{url}`: {source}") write!(f, "Invalid URL: `{url}`: {source}")
} }
@ -945,6 +986,7 @@ impl std::error::Error for RequirementsTxtParserError {
match &self { match &self {
Self::IO(err) => err.source(), Self::IO(err) => err.source(),
Self::Url { source, .. } => Some(source), Self::Url { source, .. } => Some(source),
Self::FileUrl { .. } => None,
Self::VerbatimUrl { source, .. } => Some(source), Self::VerbatimUrl { source, .. } => Some(source),
Self::UrlConversion(_) => None, Self::UrlConversion(_) => None,
Self::UnsupportedUrl(_) => None, Self::UnsupportedUrl(_) => None,
@ -976,6 +1018,13 @@ impl Display for RequirementsTxtFileError {
self.file.user_display(), 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, .. } => { RequirementsTxtParserError::VerbatimUrl { url, .. } => {
write!(f, "Invalid URL in `{}`: `{url}`", self.file.user_display()) write!(f, "Invalid URL in `{}`: `{url}`", self.file.user_display())
} }

View File

@ -253,15 +253,30 @@ fn parse_index_url(input: &str) -> Result<Maybe<IndexUrl>, String> {
} }
} }
/// Parse a string into a [`PathBuf`]. The string can represent a file, either as a path or a
/// `file://` URL.
fn parse_file_path(input: &str) -> Result<PathBuf, String> {
if input.starts_with("file://") {
let url = match url::Url::from_str(input) {
Ok(url) => url,
Err(err) => return Err(err.to_string()),
};
url.to_file_path()
.map_err(|()| "invalid file URL".to_string())
} else {
match PathBuf::from_str(input) {
Ok(path) => Ok(path),
Err(err) => Err(err.to_string()),
}
}
}
/// Parse a string into a [`PathBuf`], mapping the empty string to `None`. /// Parse a string into a [`PathBuf`], mapping the empty string to `None`.
fn parse_file_path(input: &str) -> Result<Maybe<PathBuf>, String> { fn parse_maybe_file_path(input: &str) -> Result<Maybe<PathBuf>, String> {
if input.is_empty() { if input.is_empty() {
Ok(Maybe::None) Ok(Maybe::None)
} else { } else {
match PathBuf::from_str(input) { parse_file_path(input).map(Maybe::Some)
Ok(path) => Ok(Maybe::Some(path)),
Err(err) => Err(err.to_string()),
}
} }
} }
@ -271,7 +286,7 @@ pub(crate) struct PipCompileArgs {
/// Include all packages listed in the given `requirements.in` files. /// Include all packages listed in the given `requirements.in` files.
/// ///
/// When the path is `-`, then requirements are read from stdin. /// When the path is `-`, then requirements are read from stdin.
#[arg(required(true))] #[arg(required(true), value_parser = parse_file_path)]
pub(crate) src_file: Vec<PathBuf>, pub(crate) src_file: Vec<PathBuf>,
/// Constrain versions using the given requirements files. /// Constrain versions using the given requirements files.
@ -281,7 +296,7 @@ pub(crate) struct PipCompileArgs {
/// trigger the installation of that package. /// trigger the installation of that package.
/// ///
/// This is equivalent to pip's `--constraint` option. /// This is equivalent to pip's `--constraint` option.
#[arg(long, short, env = "UV_CONSTRAINT", value_delimiter = ' ', value_parser = parse_file_path)] #[arg(long, short, env = "UV_CONSTRAINT", value_delimiter = ' ', value_parser = parse_maybe_file_path)]
pub(crate) constraint: Vec<Maybe<PathBuf>>, pub(crate) constraint: Vec<Maybe<PathBuf>>,
/// Override versions using the given requirements files. /// Override versions using the given requirements files.
@ -293,7 +308,7 @@ pub(crate) struct PipCompileArgs {
/// While constraints are _additive_, in that they're combined with the requirements of the /// While constraints are _additive_, in that they're combined with the requirements of the
/// constituent packages, overrides are _absolute_, in that they completely replace the /// constituent packages, overrides are _absolute_, in that they completely replace the
/// requirements of the constituent packages. /// requirements of the constituent packages.
#[arg(long)] #[arg(long, value_parser = parse_file_path)]
pub(crate) r#override: Vec<PathBuf>, pub(crate) r#override: Vec<PathBuf>,
/// Include optional dependencies in the given extra group name; may be provided more than once. /// Include optional dependencies in the given extra group name; may be provided more than once.
@ -593,7 +608,7 @@ pub(crate) struct PipCompileArgs {
#[allow(clippy::struct_excessive_bools)] #[allow(clippy::struct_excessive_bools)]
pub(crate) struct PipSyncArgs { pub(crate) struct PipSyncArgs {
/// Include all packages listed in the given `requirements.txt` files. /// Include all packages listed in the given `requirements.txt` files.
#[arg(required(true))] #[arg(required(true), value_parser = parse_file_path)]
pub(crate) src_file: Vec<PathBuf>, pub(crate) src_file: Vec<PathBuf>,
/// Constrain versions using the given requirements files. /// Constrain versions using the given requirements files.
@ -603,7 +618,7 @@ pub(crate) struct PipSyncArgs {
/// trigger the installation of that package. /// trigger the installation of that package.
/// ///
/// This is equivalent to pip's `--constraint` option. /// This is equivalent to pip's `--constraint` option.
#[arg(long, short, env = "UV_CONSTRAINT", value_delimiter = ' ', value_parser = parse_file_path)] #[arg(long, short, env = "UV_CONSTRAINT", value_delimiter = ' ', value_parser = parse_maybe_file_path)]
pub(crate) constraint: Vec<Maybe<PathBuf>>, pub(crate) constraint: Vec<Maybe<PathBuf>>,
/// Reinstall all packages, regardless of whether they're already installed. /// Reinstall all packages, regardless of whether they're already installed.
@ -892,7 +907,7 @@ pub(crate) struct PipInstallArgs {
pub(crate) package: Vec<String>, pub(crate) package: Vec<String>,
/// Install all packages listed in the given requirements files. /// Install all packages listed in the given requirements files.
#[arg(long, short, group = "sources")] #[arg(long, short, group = "sources", value_parser = parse_file_path)]
pub(crate) requirement: Vec<PathBuf>, pub(crate) requirement: Vec<PathBuf>,
/// Install the editable package based on the provided local file path. /// Install the editable package based on the provided local file path.
@ -906,7 +921,7 @@ pub(crate) struct PipInstallArgs {
/// trigger the installation of that package. /// trigger the installation of that package.
/// ///
/// This is equivalent to pip's `--constraint` option. /// This is equivalent to pip's `--constraint` option.
#[arg(long, short, env = "UV_CONSTRAINT", value_delimiter = ' ', value_parser = parse_file_path)] #[arg(long, short, env = "UV_CONSTRAINT", value_delimiter = ' ', value_parser = parse_maybe_file_path)]
pub(crate) constraint: Vec<Maybe<PathBuf>>, pub(crate) constraint: Vec<Maybe<PathBuf>>,
/// Override versions using the given requirements files. /// Override versions using the given requirements files.
@ -918,7 +933,7 @@ pub(crate) struct PipInstallArgs {
/// While constraints are _additive_, in that they're combined with the requirements of the /// While constraints are _additive_, in that they're combined with the requirements of the
/// constituent packages, overrides are _absolute_, in that they completely replace the /// constituent packages, overrides are _absolute_, in that they completely replace the
/// requirements of the constituent packages. /// requirements of the constituent packages.
#[arg(long)] #[arg(long, value_parser = parse_file_path)]
pub(crate) r#override: Vec<PathBuf>, pub(crate) r#override: Vec<PathBuf>,
/// Include optional dependencies in the given extra group name; may be provided more than once. /// Include optional dependencies in the given extra group name; may be provided more than once.
@ -1259,7 +1274,7 @@ pub(crate) struct PipUninstallArgs {
pub(crate) package: Vec<String>, pub(crate) package: Vec<String>,
/// Uninstall all packages listed in the given requirements files. /// Uninstall all packages listed in the given requirements files.
#[arg(long, short, group = "sources")] #[arg(long, short, group = "sources", value_parser = parse_file_path)]
pub(crate) requirement: Vec<PathBuf>, pub(crate) requirement: Vec<PathBuf>,
/// The Python interpreter from which packages should be uninstalled. /// The Python interpreter from which packages should be uninstalled.

View File

@ -9629,3 +9629,29 @@ fn dynamic_pyproject_toml() -> Result<()> {
Ok(()) Ok(())
} }
/// Accept `file://` URLs as installation sources.
#[test]
fn file_url() -> Result<()> {
let context = TestContext::new("3.12");
let requirements_txt = context.temp_dir.child("requirements file.txt");
requirements_txt.write_str("iniconfig")?;
let url = Url::from_file_path(requirements_txt.simple_canonicalize()?).expect("valid file URL");
uv_snapshot!(context.filters(), context.compile().arg(url.to_string()), @r###"
success: true
exit_code: 0
----- stdout -----
# This file was autogenerated by uv via the following command:
# uv pip compile --cache-dir [CACHE_DIR] --exclude-newer 2024-03-25T00:00:00Z file://[TEMP_DIR]/requirements%20file.txt
iniconfig==2.0.0
# via -r requirements file.txt
----- stderr -----
Resolved 1 package in [TIME]
"###);
Ok(())
}