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
}
} else {
// Ex) `file:///home/ferris/project/requirements.txt`
uv_fs::read_to_string_transcode(&requirements_txt)
.await
.map_err(RequirementsTxtParserError::IO)
@ -321,6 +322,22 @@ impl RequirementsTxt {
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: err,
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())
};
@ -360,6 +377,22 @@ impl RequirementsTxt {
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: err,
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())
};
@ -815,6 +848,11 @@ pub enum RequirementsTxtParserError {
start: usize,
end: usize,
},
FileUrl {
url: String,
start: usize,
end: usize,
},
VerbatimUrl {
source: pep508_rs::VerbatimUrlError,
url: String,
@ -882,6 +920,9 @@ impl Display for RequirementsTxtParserError {
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 { source, url } => {
write!(f, "Invalid URL: `{url}`: {source}")
}
@ -945,6 +986,7 @@ impl std::error::Error for RequirementsTxtParserError {
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,
@ -976,6 +1018,13 @@ impl Display for RequirementsTxtFileError {
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, .. } => {
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`.
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() {
Ok(Maybe::None)
} else {
match PathBuf::from_str(input) {
Ok(path) => Ok(Maybe::Some(path)),
Err(err) => Err(err.to_string()),
}
parse_file_path(input).map(Maybe::Some)
}
}
@ -271,7 +286,7 @@ pub(crate) struct PipCompileArgs {
/// Include all packages listed in the given `requirements.in` files.
///
/// 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>,
/// Constrain versions using the given requirements files.
@ -281,7 +296,7 @@ pub(crate) struct PipCompileArgs {
/// trigger the installation of that package.
///
/// 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>>,
/// 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
/// constituent packages, overrides are _absolute_, in that they completely replace the
/// requirements of the constituent packages.
#[arg(long)]
#[arg(long, value_parser = parse_file_path)]
pub(crate) r#override: Vec<PathBuf>,
/// 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)]
pub(crate) struct PipSyncArgs {
/// 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>,
/// Constrain versions using the given requirements files.
@ -603,7 +618,7 @@ pub(crate) struct PipSyncArgs {
/// trigger the installation of that package.
///
/// 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>>,
/// Reinstall all packages, regardless of whether they're already installed.
@ -892,7 +907,7 @@ pub(crate) struct PipInstallArgs {
pub(crate) package: Vec<String>,
/// 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>,
/// 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.
///
/// 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>>,
/// 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
/// constituent packages, overrides are _absolute_, in that they completely replace the
/// requirements of the constituent packages.
#[arg(long)]
#[arg(long, value_parser = parse_file_path)]
pub(crate) r#override: Vec<PathBuf>,
/// 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>,
/// 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>,
/// The Python interpreter from which packages should be uninstalled.

View file

@ -9629,3 +9629,29 @@ fn dynamic_pyproject_toml() -> Result<()> {
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(())
}