This commit is contained in:
John Mumm 2025-07-05 09:03:37 +10:00 committed by GitHub
commit cce6049eaf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 111 additions and 20 deletions

View file

@ -990,7 +990,7 @@ fn parse_find_links(input: &str) -> Result<Maybe<PipFindLinks>, String> {
if input.is_empty() { if input.is_empty() {
Ok(Maybe::None) Ok(Maybe::None)
} else { } else {
IndexUrl::from_str(input) IndexUrl::parse_preserving_trailing_slash(input)
.map(Index::from_find_links) .map(Index::from_find_links)
.map(|index| Index { .map(|index| Index {
origin: Some(Origin::Cli), origin: Some(Origin::Cli),

View file

@ -41,6 +41,30 @@ impl IndexUrl {
/// ///
/// Normalizes non-file URLs by removing trailing slashes for consistency. /// Normalizes non-file URLs by removing trailing slashes for consistency.
pub fn parse(path: &str, root_dir: Option<&Path>) -> Result<Self, IndexUrlError> { pub fn parse(path: &str, root_dir: Option<&Path>) -> Result<Self, IndexUrlError> {
Self::parse_with_trailing_slash_policy(path, root_dir, TrailingSlashPolicy::Remove)
}
/// Parse an [`IndexUrl`] from a string, relative to an optional root directory.
///
/// If no root directory is provided, relative paths are resolved against the current working
/// directory.
///
/// Preserves trailing slash if present in `path`.
pub fn parse_preserving_trailing_slash(path: &str) -> Result<Self, IndexUrlError> {
Self::parse_with_trailing_slash_policy(path, None, TrailingSlashPolicy::Preserve)
}
/// Parse an [`IndexUrl`] from a string, relative to an optional root directory.
///
/// If no root directory is provided, relative paths are resolved against the current working
/// directory.
///
/// Applies trailing slash policy to non-file URLs.
fn parse_with_trailing_slash_policy(
path: &str,
root_dir: Option<&Path>,
slash_policy: TrailingSlashPolicy,
) -> Result<Self, IndexUrlError> {
let url = match split_scheme(path) { let url = match split_scheme(path) {
Some((scheme, ..)) => { Some((scheme, ..)) => {
match Scheme::parse(scheme) { match Scheme::parse(scheme) {
@ -67,7 +91,10 @@ impl IndexUrl {
} }
} }
}; };
Ok(Self::from(url.with_given(path))) Ok(Self::from_verbatim_url_with_trailing_slash_policy(
url.with_given(path),
slash_policy,
))
} }
/// Return the root [`Url`] of the index, if applicable. /// Return the root [`Url`] of the index, if applicable.
@ -91,6 +118,34 @@ impl IndexUrl {
url.path_segments_mut().ok()?.pop_if_empty().pop(); url.path_segments_mut().ok()?.pop_if_empty().pop();
Some(url) Some(url)
} }
/// Construct an [`IndexUrl`] from a [`VerbatimUrl`], preserving a trailing
/// slash if present.
pub fn from_verbatim_url_preserving_trailing_slash(url: VerbatimUrl) -> Self {
Self::from_verbatim_url_with_trailing_slash_policy(url, TrailingSlashPolicy::Preserve)
}
/// Construct an [`IndexUrl`] from a [`VerbatimUrl`], applying a [`TrailingSlashPolicy`]
/// to non-file URLs.
fn from_verbatim_url_with_trailing_slash_policy(
mut url: VerbatimUrl,
slash_policy: TrailingSlashPolicy,
) -> Self {
if url.scheme() == "file" {
return Self::Path(Arc::new(url));
}
if slash_policy == TrailingSlashPolicy::Remove {
if let Ok(mut path_segments) = url.raw_mut().path_segments_mut() {
path_segments.pop_if_empty();
}
}
if *url.raw() == *PYPI_URL {
Self::Pypi(Arc::new(url))
} else {
Self::Url(Arc::new(url))
}
}
} }
#[cfg(feature = "schemars")] #[cfg(feature = "schemars")]
@ -184,6 +239,16 @@ impl Verbatim for IndexUrl {
} }
} }
/// Whether to preserve or remove a trailing slash from a non-file URL.
#[derive(Default, Clone, Copy, Eq, PartialEq)]
enum TrailingSlashPolicy {
/// Preserve trailing slash if present.
#[default]
Preserve,
/// Remove trailing slash if present.
Remove,
}
/// Checks if a path is disambiguated. /// Checks if a path is disambiguated.
/// ///
/// Disambiguated paths are absolute paths, paths with valid schemes, /// Disambiguated paths are absolute paths, paths with valid schemes,
@ -258,21 +323,10 @@ impl<'de> serde::de::Deserialize<'de> for IndexUrl {
} }
impl From<VerbatimUrl> for IndexUrl { impl From<VerbatimUrl> for IndexUrl {
fn from(mut url: VerbatimUrl) -> Self { fn from(url: VerbatimUrl) -> Self {
if url.scheme() == "file" { // Remove trailing slashes for consistency. They'll be re-added if necessary when
Self::Path(Arc::new(url)) // querying the Simple API.
} else { Self::from_verbatim_url_with_trailing_slash_policy(url, TrailingSlashPolicy::Remove)
// Remove trailing slashes for consistency. They'll be re-added if necessary when
// querying the Simple API.
if let Ok(mut path_segments) = url.raw_mut().path_segments_mut() {
path_segments.pop_if_empty();
}
if *url.raw() == *PYPI_URL {
Self::Pypi(Arc::new(url))
} else {
Self::Url(Arc::new(url))
}
}
} }
} }
@ -726,4 +780,42 @@ mod tests {
"git+https://github.com/example/repo.git" "git+https://github.com/example/repo.git"
)); ));
} }
#[test]
fn test_index_url_trailing_slash_policies() {
let url_with_trailing_slash = DisplaySafeUrl::parse("https://example.com/path/").unwrap();
let url_without_trailing_slash = DisplaySafeUrl::parse("https://example.com/path").unwrap();
let verbatim_url_with_trailing_slash =
VerbatimUrl::from_url(url_with_trailing_slash.clone());
let verbatim_url_without_trailing_slash =
VerbatimUrl::from_url(url_without_trailing_slash.clone());
// Test `From<VerbatimUrl>` implementation.
// Trailing slash should be removed if present.
assert_eq!(
IndexUrl::from(verbatim_url_with_trailing_slash.clone()).url(),
&url_without_trailing_slash
);
assert_eq!(
IndexUrl::from(verbatim_url_without_trailing_slash.clone()).url(),
&url_without_trailing_slash
);
// Test `from_verbatim_url_preserving_trailing_slash`.
// Trailing slash should be preserved if present.
assert_eq!(
IndexUrl::from_verbatim_url_preserving_trailing_slash(
verbatim_url_with_trailing_slash.clone()
)
.url(),
&url_with_trailing_slash
);
assert_eq!(
IndexUrl::from_verbatim_url_preserving_trailing_slash(
verbatim_url_without_trailing_slash.clone()
)
.url(),
&url_without_trailing_slash
);
}
} }

View file

@ -37,9 +37,8 @@ use tracing::instrument;
use uv_cache_key::CanonicalUrl; use uv_cache_key::CanonicalUrl;
use uv_client::BaseClientBuilder; use uv_client::BaseClientBuilder;
use uv_configuration::{DependencyGroups, NoBinary, NoBuild}; use uv_configuration::{DependencyGroups, NoBinary, NoBuild};
use uv_distribution_types::Requirement;
use uv_distribution_types::{ use uv_distribution_types::{
IndexUrl, NameRequirementSpecification, UnresolvedRequirement, IndexUrl, NameRequirementSpecification, Requirement, UnresolvedRequirement,
UnresolvedRequirementSpecification, UnresolvedRequirementSpecification,
}; };
use uv_fs::{CWD, Simplified}; use uv_fs::{CWD, Simplified};
@ -152,7 +151,7 @@ impl RequirementsSpecification {
find_links: requirements_txt find_links: requirements_txt
.find_links .find_links
.into_iter() .into_iter()
.map(IndexUrl::from) .map(IndexUrl::from_verbatim_url_preserving_trailing_slash)
.collect(), .collect(),
no_binary: requirements_txt.no_binary, no_binary: requirements_txt.no_binary,
no_build: requirements_txt.only_binary, no_build: requirements_txt.only_binary,