diff --git a/crates/distribution-types/src/index_url.rs b/crates/distribution-types/src/index_url.rs index 4b206a576e40..4d6d006d9162 100644 --- a/crates/distribution-types/src/index_url.rs +++ b/crates/distribution-types/src/index_url.rs @@ -9,7 +9,7 @@ use once_cell::sync::Lazy; use serde::{Deserialize, Serialize}; use url::Url; -use pep508_rs::{split_scheme, Scheme, VerbatimUrl}; +use pep508_rs::{expand_env_vars, split_scheme, Scheme, VerbatimUrl}; use uv_fs::normalize_url_path; use crate::Verbatim; @@ -108,7 +108,11 @@ impl FromStr for FlatIndexLocation { /// - `../ferris/` /// - `https://download.pytorch.org/whl/torch_stable.html` fn from_str(s: &str) -> Result { - if let Some((scheme, path)) = split_scheme(s) { + // Expand environment variables. + let expanded = expand_env_vars(s); + + // Parse the expanded path. + if let Some((scheme, path)) = split_scheme(&expanded) { match Scheme::parse(scheme) { // Ex) `file:///home/ferris/project/scripts/...` or `file:../ferris/` Some(Scheme::File) => { @@ -123,19 +127,19 @@ impl FromStr for FlatIndexLocation { // Ex) `https://download.pytorch.org/whl/torch_stable.html` Some(_) => { - let url = Url::parse(s)?; + let url = Url::parse(expanded.as_ref())?; Ok(Self::Url(url)) } // Ex) `C:\Users\ferris\wheel-0.42.0.tar.gz` None => { - let path = PathBuf::from(s); + let path = PathBuf::from(expanded.as_ref()); Ok(Self::Path(path)) } } } else { // Ex) `../ferris/` - let path = PathBuf::from(s); + let path = PathBuf::from(expanded.as_ref()); Ok(Self::Path(path)) } } diff --git a/crates/pep508-rs/src/lib.rs b/crates/pep508-rs/src/lib.rs index ebde80ab9abf..05dc1a603d02 100644 --- a/crates/pep508-rs/src/lib.rs +++ b/crates/pep508-rs/src/lib.rs @@ -44,8 +44,9 @@ pub use marker::{ use pep440_rs::{Version, VersionSpecifier, VersionSpecifiers}; use uv_fs::normalize_url_path; // Parity with the crates.io version of pep508_rs +use crate::verbatim_url::VerbatimUrlError; pub use uv_normalize::{ExtraName, InvalidNameError, PackageName}; -pub use verbatim_url::{expand_path_vars, split_scheme, Scheme, VerbatimUrl}; +pub use verbatim_url::{expand_env_vars, split_scheme, Scheme, VerbatimUrl}; mod marker; mod verbatim_url; @@ -803,7 +804,10 @@ fn preprocess_url( start: usize, len: usize, ) -> Result { - if let Some((scheme, path)) = split_scheme(url) { + // Expand environment variables in the URL. + let expanded = expand_env_vars(url); + + if let Some((scheme, path)) = split_scheme(&expanded) { match Scheme::parse(scheme) { // Ex) `file:///home/ferris/project/scripts/...` or `file:../editable/`. Some(Scheme::File) => { @@ -814,12 +818,11 @@ fn preprocess_url( #[cfg(feature = "non-pep508-extensions")] if let Some(working_dir) = working_dir { - return Ok( - VerbatimUrl::parse_path(path, working_dir).with_given(url.to_string()) - ); + return Ok(VerbatimUrl::parse_path(path.as_ref(), working_dir) + .with_given(url.to_string())); } - Ok(VerbatimUrl::parse_absolute_path(path) + Ok(VerbatimUrl::parse_absolute_path(path.as_ref()) .map_err(|err| Pep508Error { message: Pep508ErrorSource::UrlError(err), start, @@ -831,24 +834,25 @@ fn preprocess_url( // Ex) `https://download.pytorch.org/whl/torch_stable.html` Some(_) => { // Ex) `https://download.pytorch.org/whl/torch_stable.html` - Ok(VerbatimUrl::from_str(url).map_err(|err| Pep508Error { - message: Pep508ErrorSource::UrlError(err), - start, - len, - input: cursor.to_string(), - })?) + Ok(VerbatimUrl::parse_url(expanded.as_ref()) + .map_err(|err| Pep508Error { + message: Pep508ErrorSource::UrlError(VerbatimUrlError::Url(err)), + start, + len, + input: cursor.to_string(), + })? + .with_given(url.to_string())) } // Ex) `C:\Users\ferris\wheel-0.42.0.tar.gz` _ => { #[cfg(feature = "non-pep508-extensions")] if let Some(working_dir) = working_dir { - return Ok( - VerbatimUrl::parse_path(url, working_dir).with_given(url.to_string()) - ); + return Ok(VerbatimUrl::parse_path(expanded.as_ref(), working_dir) + .with_given(url.to_string())); } - Ok(VerbatimUrl::parse_absolute_path(url) + Ok(VerbatimUrl::parse_absolute_path(expanded.as_ref()) .map_err(|err| Pep508Error { message: Pep508ErrorSource::UrlError(err), start, @@ -862,10 +866,12 @@ fn preprocess_url( // Ex) `../editable/` #[cfg(feature = "non-pep508-extensions")] if let Some(working_dir) = working_dir { - return Ok(VerbatimUrl::parse_path(url, working_dir).with_given(url.to_string())); + return Ok( + VerbatimUrl::parse_path(expanded.as_ref(), working_dir).with_given(url.to_string()) + ); } - Ok(VerbatimUrl::parse_absolute_path(url) + Ok(VerbatimUrl::parse_absolute_path(expanded.as_ref()) .map_err(|err| Pep508Error { message: Pep508ErrorSource::UrlError(err), start, diff --git a/crates/pep508-rs/src/verbatim_url.rs b/crates/pep508-rs/src/verbatim_url.rs index 32123639d732..7e6559d8794a 100644 --- a/crates/pep508-rs/src/verbatim_url.rs +++ b/crates/pep508-rs/src/verbatim_url.rs @@ -30,12 +30,6 @@ pub struct VerbatimUrl { } impl VerbatimUrl { - /// Parse a URL from a string, expanding any environment variables. - pub fn parse(given: impl AsRef) -> Result { - let url = Url::parse(&expand_env_vars(given.as_ref(), Escape::Url))?; - Ok(Self { url, given: None }) - } - /// Create a [`VerbatimUrl`] from a [`Url`]. pub fn from_url(url: Url) -> Self { Self { url, given: None } @@ -48,15 +42,18 @@ impl VerbatimUrl { Self { url, given: None } } + /// Parse a URL from a string, expanding any environment variables. + pub fn parse_url(given: impl AsRef) -> Result { + let url = Url::parse(given.as_ref())?; + Ok(Self { url, given: None }) + } + /// Parse a URL from an absolute or relative path. #[cfg(feature = "non-pep508-extensions")] // PEP 508 arguably only allows absolute file URLs. - pub fn parse_path(path: impl AsRef, working_dir: impl AsRef) -> Self { - // Expand any environment variables. - let path = PathBuf::from(expand_env_vars(path.as_ref(), Escape::Path).as_ref()); - + pub fn parse_path(path: impl AsRef, working_dir: impl AsRef) -> Self { // Convert the path to an absolute path, if necessary. - let path = if path.is_absolute() { - path + let path = if path.as_ref().is_absolute() { + path.as_ref().to_path_buf() } else { working_dir.as_ref().join(path) }; @@ -71,15 +68,12 @@ impl VerbatimUrl { } /// Parse a URL from an absolute path. - pub fn parse_absolute_path(path: impl AsRef) -> Result { - // Expand any environment variables. - let path = PathBuf::from(expand_env_vars(path.as_ref(), Escape::Path).as_ref()); - + pub fn parse_absolute_path(path: impl AsRef) -> Result { // Convert the path to an absolute path, if necessary. - let path = if path.is_absolute() { - path + let path = if path.as_ref().is_absolute() { + path.as_ref().to_path_buf() } else { - return Err(VerbatimUrlError::RelativePath(path)); + return Err(VerbatimUrlError::RelativePath(path.as_ref().to_path_buf())); }; // Normalize the path. @@ -128,9 +122,7 @@ impl std::str::FromStr for VerbatimUrl { type Err = VerbatimUrlError; fn from_str(s: &str) -> Result { - Self::parse(s) - .map(|url| url.with_given(s.to_owned())) - .map_err(|e| VerbatimUrlError::Url(s.to_owned(), e)) + Ok(Self::parse_url(s).map(|url| url.with_given(s.to_owned()))?) } } @@ -152,23 +144,14 @@ impl Deref for VerbatimUrl { #[derive(thiserror::Error, Debug)] pub enum VerbatimUrlError { /// Failed to parse a URL. - #[error("{0}")] - Url(String, #[source] ParseError), + #[error(transparent)] + Url(#[from] ParseError), /// Received a relative path, but no working directory was provided. #[error("relative path without a working directory: {0}")] RelativePath(PathBuf), } -/// Whether to apply percent-encoding when expanding environment variables. -#[derive(Debug, Clone, PartialEq, Eq)] -enum Escape { - /// Apply percent-encoding. - Url, - /// Do not apply percent-encoding. - Path, -} - /// Expand all available environment variables. /// /// This is modeled off of pip's environment variable expansion, which states: @@ -184,7 +167,7 @@ enum Escape { /// Valid characters in variable names follow the `POSIX standard /// `_ and are limited /// to uppercase letter, digits and the `_` (underscore). -fn expand_env_vars(s: &str, escape: Escape) -> Cow<'_, str> { +pub fn expand_env_vars(s: &str) -> Cow<'_, str> { // Generate the project root, to be used via the `${PROJECT_ROOT}` // environment variable. static PROJECT_ROOT_FRAGMENT: Lazy = Lazy::new(|| { @@ -198,21 +181,12 @@ fn expand_env_vars(s: &str, escape: Escape) -> Cow<'_, str> { RE.replace_all(s, |caps: ®ex::Captures<'_>| { let name = caps.name("name").unwrap().as_str(); std::env::var(name).unwrap_or_else(|_| match name { - // Ensure that the variable is URL-escaped, if necessary. - "PROJECT_ROOT" => match escape { - Escape::Url => PROJECT_ROOT_FRAGMENT.replace(' ', "%20"), - Escape::Path => PROJECT_ROOT_FRAGMENT.to_string(), - }, + "PROJECT_ROOT" => PROJECT_ROOT_FRAGMENT.to_string(), _ => caps["var"].to_owned(), }) }) } -/// Expand all available environment variables in a path-like string. -pub fn expand_path_vars(path: &str) -> Cow<'_, str> { - expand_env_vars(path, Escape::Path) -} - /// Like [`Url::parse`], but only splits the scheme. Derived from the `url` crate. pub fn split_scheme(s: &str) -> Option<(&str, &str)> { /// diff --git a/crates/requirements-txt/src/lib.rs b/crates/requirements-txt/src/lib.rs index 9506af352e0d..fd04bd58d735 100644 --- a/crates/requirements-txt/src/lib.rs +++ b/crates/requirements-txt/src/lib.rs @@ -46,7 +46,7 @@ use unscanny::{Pattern, Scanner}; use url::Url; use pep508_rs::{ - expand_path_vars, split_scheme, Extras, Pep508Error, Pep508ErrorSource, Requirement, Scheme, + expand_env_vars, split_scheme, Extras, Pep508Error, Pep508ErrorSource, Requirement, Scheme, VerbatimUrl, }; use uv_client::Connectivity; @@ -97,7 +97,10 @@ impl FindLink { /// - `../ferris/` /// - `https://download.pytorch.org/whl/torch_stable.html` pub fn parse(given: &str, working_dir: impl AsRef) -> Result { - if let Some((scheme, path)) = split_scheme(given) { + // Expand environment variables. + let expanded = expand_env_vars(given); + + if let Some((scheme, path)) = split_scheme(&expanded) { match Scheme::parse(scheme) { // Ex) `file:///home/ferris/project/scripts/...` or `file:../ferris/` Some(Scheme::File) => { @@ -117,13 +120,13 @@ impl FindLink { // Ex) `https://download.pytorch.org/whl/torch_stable.html` Some(_) => { - let url = Url::parse(given)?; + let url = Url::parse(&expanded)?; Ok(Self::Url(url)) } // Ex) `C:/Users/ferris/wheel-0.42.0.tar.gz` _ => { - let path = PathBuf::from(given); + let path = PathBuf::from(expanded.as_ref()); let path = if path.is_absolute() { path } else { @@ -134,7 +137,7 @@ impl FindLink { } } else { // Ex) `../ferris/` - let path = PathBuf::from(given); + let path = PathBuf::from(expanded.as_ref()); let path = if path.is_absolute() { path } else { @@ -208,8 +211,11 @@ impl EditableRequirement { (given, vec![]) }; + // Expand environment variables. + let expanded = expand_env_vars(requirement); + // Create a `VerbatimUrl` to represent the editable requirement. - let url = if let Some((scheme, path)) = split_scheme(requirement) { + let url = if let Some((scheme, path)) = split_scheme(&expanded) { match Scheme::parse(scheme) { // Ex) `file:///home/ferris/project/scripts/...` or `file:../editable/` Some(Scheme::File) => { @@ -218,28 +224,28 @@ impl EditableRequirement { // Transform, e.g., `/C:/Users/ferris/wheel-0.42.0.tar.gz` to `C:\Users\ferris\wheel-0.42.0.tar.gz`. let path = normalize_url_path(path); - VerbatimUrl::parse_path(path, working_dir.as_ref()) + VerbatimUrl::parse_path(path.as_ref(), working_dir.as_ref()) } // Ex) `https://download.pytorch.org/whl/torch_stable.html` Some(_) => { return Err(RequirementsTxtParserError::UnsupportedUrl( - requirement.to_string(), + expanded.to_string(), )); } // Ex) `C:/Users/ferris/wheel-0.42.0.tar.gz` - _ => VerbatimUrl::parse_path(requirement, working_dir.as_ref()), + _ => VerbatimUrl::parse_path(expanded.as_ref(), working_dir.as_ref()), } } else { // Ex) `../editable/` - VerbatimUrl::parse_path(requirement, working_dir.as_ref()) + VerbatimUrl::parse_path(expanded.as_ref(), working_dir.as_ref()) }; // Create a `PathBuf`. - let path = url.to_file_path().map_err(|()| { - RequirementsTxtParserError::InvalidEditablePath(requirement.to_string()) - })?; + let path = url + .to_file_path() + .map_err(|()| RequirementsTxtParserError::InvalidEditablePath(expanded.to_string()))?; // Add the verbatim representation of the URL to the `VerbatimUrl`. let url = url.with_given(requirement.to_string()); @@ -409,7 +415,7 @@ impl RequirementsTxt { start, end, } => { - let filename = expand_path_vars(&filename); + let filename = expand_env_vars(&filename); let sub_file = if filename.starts_with("http://") || filename.starts_with("https://") { PathBuf::from(filename.as_ref()) @@ -447,7 +453,7 @@ impl RequirementsTxt { start, end, } => { - let filename = expand_path_vars(&filename); + let filename = expand_env_vars(&filename); let sub_file = if filename.starts_with("http://") || filename.starts_with("https://") { PathBuf::from(filename.as_ref()) @@ -569,7 +575,8 @@ fn parse_entry( RequirementsTxtStatement::EditableRequirement(editable_requirement) } else if s.eat_if("-i") || s.eat_if("--index-url") { let given = parse_value(content, s, |c: char| !['\n', '\r'].contains(&c))?; - let url = VerbatimUrl::parse(given) + let expanded = expand_env_vars(given); + let url = VerbatimUrl::parse_url(expanded.as_ref()) .map(|url| url.with_given(given.to_owned())) .map_err(|err| RequirementsTxtParserError::Url { source: err, @@ -580,7 +587,8 @@ fn parse_entry( RequirementsTxtStatement::IndexUrl(url) } else if s.eat_if("--extra-index-url") { let given = parse_value(content, s, |c: char| !['\n', '\r'].contains(&c))?; - let url = VerbatimUrl::parse(given) + let expanded = expand_env_vars(given); + let url = VerbatimUrl::parse_url(expanded.as_ref()) .map(|url| url.with_given(given.to_owned())) .map_err(|err| RequirementsTxtParserError::Url { source: err, @@ -1289,7 +1297,7 @@ mod test { } #[tokio::test] - async fn invalid_requirement() -> Result<()> { + 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! {" @@ -1325,6 +1333,43 @@ mod test { 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(), + Connectivity::Offline, + ) + .await + .unwrap_err(); + let errors = anyhow::Error::new(error).chain().join("\n"); + + let requirement_txt = + regex::escape(&requirements_txt.path().simplified_display().to_string()); + let filters = vec![ + (requirement_txt.as_str(), ""), + (r"\\", "/"), + ]; + insta::with_settings!({ + filters => filters + }, { + insta::assert_snapshot!(errors, @r###" + Couldn't parse requirement in `` at position 0 + empty host + numpy @ https:/// + ^^^^^^^^^ + "###); + }); + + Ok(()) + } + #[tokio::test] async fn unsupported_editable() -> Result<()> { let temp_dir = assert_fs::TempDir::new()?; @@ -1392,7 +1437,7 @@ mod test { } #[tokio::test] - async fn invalid_index_url() -> Result<()> { + 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! {" @@ -1426,6 +1471,41 @@ mod test { 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(), + Connectivity::Offline, + ) + .await + .unwrap_err(); + let errors = anyhow::Error::new(error).chain().join("\n"); + + let requirement_txt = + regex::escape(&requirements_txt.path().simplified_display().to_string()); + let filters = vec![ + (requirement_txt.as_str(), ""), + (r"\\", "/"), + ]; + insta::with_settings!({ + filters => filters + }, { + insta::assert_snapshot!(errors, @r###" + Invalid URL in `` at position 0: `https:////` + empty host + "###); + }); + + Ok(()) + } + #[tokio::test] async fn missing_r() -> Result<()> { let temp_dir = assert_fs::TempDir::new()?; diff --git a/crates/uv-resolver/src/redirect.rs b/crates/uv-resolver/src/redirect.rs index d6e8699e1f32..f5acd9108fcb 100644 --- a/crates/uv-resolver/src/redirect.rs +++ b/crates/uv-resolver/src/redirect.rs @@ -44,12 +44,12 @@ mod tests { fn test_apply_redirect() -> Result<(), url::ParseError> { // If there's no `@` in the original representation, we can just append the precise suffix // to the given representation. - let verbatim = VerbatimUrl::parse("https://github.com/flask.git")? + let verbatim = VerbatimUrl::parse_url("https://github.com/flask.git")? .with_given("git+https://github.com/flask.git"); let redirect = Url::parse("https://github.com/flask.git@b90a4f1f4a370e92054b9cc9db0efcb864f87ebe")?; - let expected = VerbatimUrl::parse( + let expected = VerbatimUrl::parse_url( "https://github.com/flask.git@b90a4f1f4a370e92054b9cc9db0efcb864f87ebe", )? .with_given("https://github.com/flask.git@b90a4f1f4a370e92054b9cc9db0efcb864f87ebe"); @@ -58,24 +58,24 @@ mod tests { // If there's an `@` in the original representation, and it's stable between the parsed and // given representations, we preserve everything that precedes the `@` in the precise // representation. - let verbatim = VerbatimUrl::parse("https://github.com/flask.git@main")? + let verbatim = VerbatimUrl::parse_url("https://github.com/flask.git@main")? .with_given("git+https://${DOMAIN}.com/flask.git@main"); let redirect = Url::parse("https://github.com/flask.git@b90a4f1f4a370e92054b9cc9db0efcb864f87ebe")?; - let expected = VerbatimUrl::parse( + let expected = VerbatimUrl::parse_url( "https://github.com/flask.git@b90a4f1f4a370e92054b9cc9db0efcb864f87ebe", )? .with_given("https://${DOMAIN}.com/flask.git@b90a4f1f4a370e92054b9cc9db0efcb864f87ebe"); assert_eq!(apply_redirect(&verbatim, &redirect), expected); // If there's a conflict after the `@`, discard the original representation. - let verbatim = VerbatimUrl::parse("https://github.com/flask.git@main")? + let verbatim = VerbatimUrl::parse_url("https://github.com/flask.git@main")? .with_given("git+https://github.com/flask.git@${TAG}".to_string()); let redirect = Url::parse("https://github.com/flask.git@b90a4f1f4a370e92054b9cc9db0efcb864f87ebe")?; - let expected = VerbatimUrl::parse( + let expected = VerbatimUrl::parse_url( "https://github.com/flask.git@b90a4f1f4a370e92054b9cc9db0efcb864f87ebe", )?; assert_eq!(apply_redirect(&verbatim, &redirect), expected); diff --git a/crates/uv-resolver/src/resolver/urls.rs b/crates/uv-resolver/src/resolver/urls.rs index df0354c02fc2..9230f5627eeb 100644 --- a/crates/uv-resolver/src/resolver/urls.rs +++ b/crates/uv-resolver/src/resolver/urls.rs @@ -200,28 +200,28 @@ mod tests { #[test] fn url_compatibility() -> Result<(), url::ParseError> { // Same repository, same tag. - let previous = VerbatimUrl::parse("git+https://example.com/MyProject.git@v1.0")?; - let url = VerbatimUrl::parse("git+https://example.com/MyProject.git@v1.0")?; + let previous = VerbatimUrl::parse_url("git+https://example.com/MyProject.git@v1.0")?; + let url = VerbatimUrl::parse_url("git+https://example.com/MyProject.git@v1.0")?; assert!(is_equal(&previous, &url)); // Same repository, different tags. - let previous = VerbatimUrl::parse("git+https://example.com/MyProject.git@v1.0")?; - let url = VerbatimUrl::parse("git+https://example.com/MyProject.git@v1.1")?; + let previous = VerbatimUrl::parse_url("git+https://example.com/MyProject.git@v1.0")?; + let url = VerbatimUrl::parse_url("git+https://example.com/MyProject.git@v1.1")?; assert!(!is_equal(&previous, &url)); // Same repository (with and without `.git`), same tag. - let previous = VerbatimUrl::parse("git+https://example.com/MyProject@v1.0")?; - let url = VerbatimUrl::parse("git+https://example.com/MyProject.git@v1.0")?; + let previous = VerbatimUrl::parse_url("git+https://example.com/MyProject@v1.0")?; + let url = VerbatimUrl::parse_url("git+https://example.com/MyProject.git@v1.0")?; assert!(is_equal(&previous, &url)); // Same repository, no tag on the previous URL. - let previous = VerbatimUrl::parse("git+https://example.com/MyProject.git")?; - let url = VerbatimUrl::parse("git+https://example.com/MyProject.git@v1.0")?; + let previous = VerbatimUrl::parse_url("git+https://example.com/MyProject.git")?; + let url = VerbatimUrl::parse_url("git+https://example.com/MyProject.git@v1.0")?; assert!(!is_equal(&previous, &url)); // Same repository, tag on the previous URL, no tag on the overriding URL. - let previous = VerbatimUrl::parse("git+https://example.com/MyProject.git@v1.0")?; - let url = VerbatimUrl::parse("git+https://example.com/MyProject.git")?; + let previous = VerbatimUrl::parse_url("git+https://example.com/MyProject.git@v1.0")?; + let url = VerbatimUrl::parse_url("git+https://example.com/MyProject.git")?; assert!(!is_equal(&previous, &url)); Ok(()) @@ -230,29 +230,29 @@ mod tests { #[test] fn url_precision() -> Result<(), url::ParseError> { // Same repository, no tag on the previous URL, non-SHA on the overriding URL. - let previous = VerbatimUrl::parse("git+https://example.com/MyProject.git")?; - let url = VerbatimUrl::parse("git+https://example.com/MyProject.git@v1.0")?; + let previous = VerbatimUrl::parse_url("git+https://example.com/MyProject.git")?; + let url = VerbatimUrl::parse_url("git+https://example.com/MyProject.git@v1.0")?; assert!(!is_precise(&previous, &url)); // Same repository, no tag on the previous URL, SHA on the overriding URL. - let previous = VerbatimUrl::parse("git+https://example.com/MyProject.git")?; - let url = VerbatimUrl::parse( + let previous = VerbatimUrl::parse_url("git+https://example.com/MyProject.git")?; + let url = VerbatimUrl::parse_url( "git+https://example.com/MyProject.git@c3cd550a7a7c41b2c286ca52fbb6dec5fea195ef", )?; assert!(is_precise(&previous, &url)); // Same repository, tag on the previous URL, SHA on the overriding URL. - let previous = VerbatimUrl::parse("git+https://example.com/MyProject.git@v1.0")?; - let url = VerbatimUrl::parse( + let previous = VerbatimUrl::parse_url("git+https://example.com/MyProject.git@v1.0")?; + let url = VerbatimUrl::parse_url( "git+https://example.com/MyProject.git@c3cd550a7a7c41b2c286ca52fbb6dec5fea195ef", )?; assert!(is_precise(&previous, &url)); // Same repository, SHA on the previous URL, different SHA on the overriding URL. - let previous = VerbatimUrl::parse( + let previous = VerbatimUrl::parse_url( "git+https://example.com/MyProject.git@5ae5980c885e350a34ca019a84ba14a2a228d262", )?; - let url = VerbatimUrl::parse( + let url = VerbatimUrl::parse_url( "git+https://example.com/MyProject.git@c3cd550a7a7c41b2c286ca52fbb6dec5fea195ef", )?; assert!(!is_precise(&previous, &url)); diff --git a/crates/uv/tests/pip_compile.rs b/crates/uv/tests/pip_compile.rs index c48718ebdc12..e9d2d4960357 100644 --- a/crates/uv/tests/pip_compile.rs +++ b/crates/uv/tests/pip_compile.rs @@ -2399,7 +2399,7 @@ fn preserve_url() -> Result<()> { /// Resolve a dependency from a URL, preserving the unexpanded environment variable as specified in /// the requirements file. #[test] -fn preserve_env_var() -> Result<()> { +fn preserve_project_root() -> Result<()> { let context = TestContext::new("3.12"); // Download a wheel. let response = reqwest::blocking::get("https://files.pythonhosted.org/packages/36/42/015c23096649b908c809c69388a805a571a3bea44362fe87e33fc3afa01f/flask-3.0.0-py3-none-any.whl")?; @@ -2441,6 +2441,91 @@ fn preserve_env_var() -> Result<()> { Ok(()) } +/// Resolve a dependency from a URL, passing in the entire URL as an environment variable. +#[test] +fn respect_http_env_var() -> Result<()> { + let context = TestContext::new("3.12"); + + let requirements_in = context.temp_dir.child("requirements.in"); + requirements_in.write_str("flask @ ${URL}")?; + + uv_snapshot!(context.compile() + .arg("requirements.in") + .env("URL", "https://files.pythonhosted.org/packages/36/42/015c23096649b908c809c69388a805a571a3bea44362fe87e33fc3afa01f/flask-3.0.0-py3-none-any.whl"), @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 2023-11-18T12:00:00Z requirements.in + blinker==1.7.0 + # via flask + click==8.1.7 + # via flask + flask @ ${URL} + 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(()) +} + +/// Resolve a dependency from a file path, passing in the entire path as an environment variable. +#[test] +fn respect_file_env_var() -> Result<()> { + let context = TestContext::new("3.12"); + // Download a wheel. + let response = reqwest::blocking::get("https://files.pythonhosted.org/packages/36/42/015c23096649b908c809c69388a805a571a3bea44362fe87e33fc3afa01f/flask-3.0.0-py3-none-any.whl")?; + let flask_wheel = context.temp_dir.child("flask-3.0.0-py3-none-any.whl"); + let mut flask_wheel_file = std::fs::File::create(flask_wheel)?; + std::io::copy(&mut response.bytes()?.as_ref(), &mut flask_wheel_file)?; + + let requirements_in = context.temp_dir.child("requirements.in"); + requirements_in.write_str("flask @ ${FILE_PATH}")?; + + uv_snapshot!(context.compile() + .arg("requirements.in") + .env("FILE_PATH", context.temp_dir.join("flask-3.0.0-py3-none-any.whl")), @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 2023-11-18T12:00:00Z requirements.in + blinker==1.7.0 + # via flask + click==8.1.7 + # via flask + flask @ ${FILE_PATH} + 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(()) +} + #[test] #[cfg(feature = "maturin")] fn compile_editable() -> Result<()> { @@ -3035,6 +3120,32 @@ fn find_links_url() -> Result<()> { Ok(()) } +/// Compile using `--find-links` with a URL passed via an environment variable. +#[test] +fn find_links_env_var() -> Result<()> { + let context = TestContext::new("3.12"); + let requirements_in = context.temp_dir.child("requirements.in"); + requirements_in.write_str("tqdm\n--find-links ${URL}")?; + + uv_snapshot!(context.compile() + .arg("requirements.in") + .arg("--no-index") + .env("URL", "https://download.pytorch.org/whl/torch_stable.html"), @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 2023-11-18T12:00:00Z requirements.in --no-index + tqdm==4.64.1 + + ----- stderr ----- + Resolved 1 package in [TIME] + "### + ); + + Ok(()) +} + /// Compile using `--find-links` with a URL by resolving `tqdm` from the `PyTorch` wheels index, /// with the URL itself provided in a `requirements.txt` file. #[test]