diff --git a/crates/pep508-rs/src/lib.rs b/crates/pep508-rs/src/lib.rs index 9b05d72c9..89c70c7cc 100644 --- a/crates/pep508-rs/src/lib.rs +++ b/crates/pep508-rs/src/lib.rs @@ -1128,7 +1128,7 @@ mod tests { parse_markers_impl, MarkerExpression, MarkerOperator, MarkerTree, MarkerValue, MarkerValueString, MarkerValueVersion, }; - use crate::{Cursor, Requirement, VerbatimUrl, VersionOrUrl}; + use crate::{Cursor, Pep508Error, Requirement, VerbatimUrl, VersionOrUrl}; fn parse_err(input: &str) -> String { Requirement::from_str(input).unwrap_err().to_string() @@ -1744,4 +1744,37 @@ mod tests { let requirement = Requirement::from_str("pytest;'4.0'>=python_version").unwrap(); assert_eq!(requirement.to_string(), "pytest ; '4.0' >= python_version"); } + + #[test] + fn path_with_fragment() -> Result<(), Pep508Error> { + let requirements = if cfg!(windows) { + &[ + "wheel @ file:///C:/Users/ferris/wheel-0.42.0.whl#hash=somehash", + "wheel @ C:/Users/ferris/wheel-0.42.0.whl#hash=somehash", + ] + } else { + &[ + "wheel @ file:///Users/ferris/wheel-0.42.0.whl#hash=somehash", + "wheel @ /Users/ferris/wheel-0.42.0.whl#hash=somehash", + ] + }; + + for requirement in requirements { + // Extract the URL. + let Some(VersionOrUrl::Url(url)) = Requirement::from_str(requirement)?.version_or_url + else { + unreachable!("Expected a URL") + }; + + // Assert that the fragment and path have been separated correctly. + assert_eq!(url.fragment(), Some("hash=somehash")); + assert!( + url.path().ends_with("/Users/ferris/wheel-0.42.0.whl"), + "Expected the path to end with '/Users/ferris/wheel-0.42.0.whl', found '{}'", + url.path() + ); + } + + Ok(()) + } } diff --git a/crates/pep508-rs/src/verbatim_url.rs b/crates/pep508-rs/src/verbatim_url.rs index 7e6559d87..62f3907aa 100644 --- a/crates/pep508-rs/src/verbatim_url.rs +++ b/crates/pep508-rs/src/verbatim_url.rs @@ -37,8 +37,22 @@ impl VerbatimUrl { /// Create a [`VerbatimUrl`] from a file path. pub fn from_path(path: impl AsRef) -> Self { - let path = normalize_path(path.as_ref()); - let url = Url::from_file_path(path).expect("path is absolute"); + let path = path.as_ref(); + + // Normalize the path. + let path = normalize_path(path); + + // Extract the fragment, if it exists. + let (path, fragment) = split_fragment(&path); + + // Convert to a URL. + let mut url = Url::from_file_path(path).expect("path is absolute"); + + // Set the fragment, if it exists. + if let Some(fragment) = fragment { + url.set_fragment(Some(fragment)); + } + Self { url, given: None } } @@ -51,9 +65,11 @@ impl VerbatimUrl { /// 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 { + let path = path.as_ref(); + // Convert the path to an absolute path, if necessary. - let path = if path.as_ref().is_absolute() { - path.as_ref().to_path_buf() + let path = if path.is_absolute() { + path.to_path_buf() } else { working_dir.as_ref().join(path) }; @@ -61,26 +77,44 @@ impl VerbatimUrl { // Normalize the path. let path = normalize_path(path); + // Extract the fragment, if it exists. + let (path, fragment) = split_fragment(&path); + // Convert to a URL. - let url = Url::from_file_path(path).expect("path is absolute"); + let mut url = Url::from_file_path(path).expect("path is absolute"); + + // Set the fragment, if it exists. + if let Some(fragment) = fragment { + url.set_fragment(Some(fragment)); + } Self { url, given: None } } /// Parse a URL from an absolute path. pub fn parse_absolute_path(path: impl AsRef) -> Result { + let path = path.as_ref(); + // Convert the path to an absolute path, if necessary. - let path = if path.as_ref().is_absolute() { - path.as_ref().to_path_buf() + let path = if path.is_absolute() { + path.to_path_buf() } else { - return Err(VerbatimUrlError::RelativePath(path.as_ref().to_path_buf())); + return Err(VerbatimUrlError::RelativePath(path.to_path_buf())); }; // Normalize the path. let path = normalize_path(path); + // Extract the fragment, if it exists. + let (path, fragment) = split_fragment(&path); + // Convert to a URL. - let url = Url::from_file_path(path).expect("path is absolute"); + let mut url = Url::from_file_path(path).expect("path is absolute"); + + // Set the fragment, if it exists. + if let Some(fragment) = fragment { + url.set_fragment(Some(fragment)); + } Ok(Self { url, given: None }) } @@ -222,6 +256,22 @@ pub fn split_scheme(s: &str) -> Option<(&str, &str)> { Some((scheme, rest)) } +/// Split the fragment from a URL. +/// +/// For example, given `file:///home/ferris/project/scripts#hash=somehash`, returns +/// `("/home/ferris/project/scripts", Some("hash=somehash"))`. +fn split_fragment(path: &Path) -> (Cow, Option<&str>) { + let Some(s) = path.to_str() else { + return (Cow::Borrowed(path), None); + }; + + let Some((path, fragment)) = s.split_once('#') else { + return (Cow::Borrowed(path), None); + }; + + (Cow::Owned(PathBuf::from(path)), Some(fragment)) +} + /// A supported URL scheme for PEP 508 direct-URL requirements. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Scheme { @@ -363,4 +413,42 @@ mod tests { ); assert_eq!(split_scheme("https:"), Some(("https", ""))); } + + #[test] + fn fragment() { + assert_eq!( + split_fragment(Path::new( + "file:///home/ferris/project/scripts#hash=somehash" + )), + ( + Cow::Owned(PathBuf::from("file:///home/ferris/project/scripts")), + Some("hash=somehash") + ) + ); + assert_eq!( + split_fragment(Path::new("file:home/ferris/project/scripts#hash=somehash")), + ( + Cow::Owned(PathBuf::from("file:home/ferris/project/scripts")), + Some("hash=somehash") + ) + ); + assert_eq!( + split_fragment(Path::new("/home/ferris/project/scripts#hash=somehash")), + ( + Cow::Owned(PathBuf::from("/home/ferris/project/scripts")), + Some("hash=somehash") + ) + ); + assert_eq!( + split_fragment(Path::new("file:///home/ferris/project/scripts")), + ( + Cow::Borrowed(Path::new("file:///home/ferris/project/scripts")), + None + ) + ); + assert_eq!( + split_fragment(Path::new("")), + (Cow::Borrowed(Path::new("")), None) + ); + } }