Drop trailing slashes when converting index URL from URL (#14346)

## Summary

In #14245, we started normalizing index URLs by dropping the trailing
slash in the lockfile. We added tests to ensure that this didn't cause
existing lockfiles to be invalidated, but we missed one of the
constructors (specifically, the path that's used with
`tool.uv.sources`).
This commit is contained in:
Charlie Marsh 2025-06-29 09:36:13 -04:00 committed by GitHub
parent f9d3f8ea3b
commit 734b228edf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 201 additions and 15 deletions

View file

@ -44,16 +44,9 @@ impl IndexUrl {
let url = match split_scheme(path) {
Some((scheme, ..)) => {
match Scheme::parse(scheme) {
Some(scheme) => {
if scheme.is_file() {
// Ex) `file:///path/to/something/`
VerbatimUrl::parse_url(path)?
} else {
// Ex) `https://pypi.org/simple/`
// Remove a trailing slash if it exists.
let normalized_path = path.strip_suffix('/').unwrap_or(path);
VerbatimUrl::parse_url(normalized_path)?
}
Some(_) => {
// Ex) `https://pypi.org/simple`
VerbatimUrl::parse_url(path)?
}
None => {
// Ex) `C:\Users\user\index`
@ -265,13 +258,20 @@ impl<'de> serde::de::Deserialize<'de> for IndexUrl {
}
impl From<VerbatimUrl> for IndexUrl {
fn from(url: VerbatimUrl) -> Self {
fn from(mut url: VerbatimUrl) -> Self {
if url.scheme() == "file" {
Self::Path(Arc::new(url))
} else if *url.raw() == *PYPI_URL {
Self::Pypi(Arc::new(url))
} else {
Self::Url(Arc::new(url))
// 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))
}
}
}
}

View file

@ -166,6 +166,11 @@ impl VerbatimUrl {
&self.url
}
/// Return a mutable reference to the underlying [`DisplaySafeUrl`].
pub fn raw_mut(&mut self) -> &mut DisplaySafeUrl {
&mut self.url
}
/// Convert a [`VerbatimUrl`] into a [`DisplaySafeUrl`].
pub fn to_url(&self) -> DisplaySafeUrl {
self.url.clone()

View file

@ -28448,6 +28448,9 @@ fn lock_with_index_trailing_slashes_in_lockfile() -> Result<()> {
[[tool.uv.index]]
name = "pypi-proxy"
url = "https://pypi-proxy.fly.dev/simple"
[tool.uv.sources]
anyio = { index = "pypi-proxy" }
"#,
)?;
@ -28491,7 +28494,185 @@ fn lock_with_index_trailing_slashes_in_lockfile() -> Result<()> {
]
[package.metadata]
requires-dist = [{ name = "anyio" }]
requires-dist = [{ name = "anyio", index = "https://pypi-proxy.fly.dev/simple/" }]
[[package]]
name = "sniffio"
version = "1.3.1"
source = { registry = "https://pypi-proxy.fly.dev/simple/" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
]
"#
)?;
// Run `uv lock --locked`.
uv_snapshot!(context.filters(), context.lock().arg("--locked"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 4 packages in [TIME]
");
Ok(())
}
/// Run `uv lock --locked` with a lockfile with trailing slashes on index URLs.
#[test]
fn lock_with_index_trailing_slashes_in_pyproject_toml() -> Result<()> {
let context = TestContext::new("3.12");
let pyproject_toml = context.temp_dir.child("pyproject.toml");
pyproject_toml.write_str(
r#"
[project]
name = "project"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = ["anyio"]
[[tool.uv.index]]
name = "pypi-proxy"
url = "https://pypi-proxy.fly.dev/simple/"
[tool.uv.sources]
anyio = { index = "pypi-proxy" }
"#,
)?;
let lock = context.temp_dir.child("uv.lock");
lock.write_str(r#"
version = 1
revision = 2
requires-python = ">=3.12"
[options]
exclude-newer = "2024-03-25T00:00:00Z"
[[package]]
name = "anyio"
version = "4.3.0"
source = { registry = "https://pypi-proxy.fly.dev/simple" }
dependencies = [
{ name = "idna" },
{ name = "sniffio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/db/4d/3970183622f0330d3c23d9b8a5f52e365e50381fd484d08e3285104333d3/anyio-4.3.0.tar.gz", hash = "sha256:f75253795a87df48568485fd18cdd2a3fa5c4f7c5be8e5e36637733fce06fed6", size = 159642, upload-time = "2024-02-19T08:36:28.641Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/14/fd/2f20c40b45e4fb4324834aea24bd4afdf1143390242c0b33774da0e2e34f/anyio-4.3.0-py3-none-any.whl", hash = "sha256:048e05d0f6caeed70d731f3db756d35dcc1f35747c8c403364a8332c630441b8", size = 85584, upload-time = "2024-02-19T08:36:26.842Z" },
]
[[package]]
name = "idna"
version = "3.6"
source = { registry = "https://pypi-proxy.fly.dev/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/bf/3f/ea4b9117521a1e9c50344b909be7886dd00a519552724809bb1f486986c2/idna-3.6.tar.gz", hash = "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca", size = 175426, upload-time = "2023-11-25T15:40:54.902Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c2/e7/a82b05cf63a603df6e68d59ae6a68bf5064484a0718ea5033660af4b54a9/idna-3.6-py3-none-any.whl", hash = "sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f", size = 61567, upload-time = "2023-11-25T15:40:52.604Z" },
]
[[package]]
name = "project"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "anyio" },
]
[package.metadata]
requires-dist = [{ name = "anyio", index = "https://pypi-proxy.fly.dev/simple" }]
[[package]]
name = "sniffio"
version = "1.3.1"
source = { registry = "https://pypi-proxy.fly.dev/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
]
"#
)?;
// Run `uv lock --locked`.
uv_snapshot!(context.filters(), context.lock().arg("--locked"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 4 packages in [TIME]
");
Ok(())
}
/// Run `uv lock --locked` with a lockfile with trailing slashes on index URLs.
#[test]
fn lock_with_index_trailing_slashes_in_lockfile_and_pyproject_toml() -> Result<()> {
let context = TestContext::new("3.12");
let pyproject_toml = context.temp_dir.child("pyproject.toml");
pyproject_toml.write_str(
r#"
[project]
name = "project"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = ["anyio"]
[[tool.uv.index]]
name = "pypi-proxy"
url = "https://pypi-proxy.fly.dev/simple/"
[tool.uv.sources]
anyio = { index = "pypi-proxy" }
"#,
)?;
let lock = context.temp_dir.child("uv.lock");
lock.write_str(r#"
version = 1
revision = 2
requires-python = ">=3.12"
[options]
exclude-newer = "2024-03-25T00:00:00Z"
[[package]]
name = "anyio"
version = "4.3.0"
source = { registry = "https://pypi-proxy.fly.dev/simple/" }
dependencies = [
{ name = "idna" },
{ name = "sniffio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/db/4d/3970183622f0330d3c23d9b8a5f52e365e50381fd484d08e3285104333d3/anyio-4.3.0.tar.gz", hash = "sha256:f75253795a87df48568485fd18cdd2a3fa5c4f7c5be8e5e36637733fce06fed6", size = 159642, upload-time = "2024-02-19T08:36:28.641Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/14/fd/2f20c40b45e4fb4324834aea24bd4afdf1143390242c0b33774da0e2e34f/anyio-4.3.0-py3-none-any.whl", hash = "sha256:048e05d0f6caeed70d731f3db756d35dcc1f35747c8c403364a8332c630441b8", size = 85584, upload-time = "2024-02-19T08:36:26.842Z" },
]
[[package]]
name = "idna"
version = "3.6"
source = { registry = "https://pypi-proxy.fly.dev/simple/" }
sdist = { url = "https://files.pythonhosted.org/packages/bf/3f/ea4b9117521a1e9c50344b909be7886dd00a519552724809bb1f486986c2/idna-3.6.tar.gz", hash = "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca", size = 175426, upload-time = "2023-11-25T15:40:54.902Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c2/e7/a82b05cf63a603df6e68d59ae6a68bf5064484a0718ea5033660af4b54a9/idna-3.6-py3-none-any.whl", hash = "sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f", size = 61567, upload-time = "2023-11-25T15:40:52.604Z" },
]
[[package]]
name = "project"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "anyio" },
]
[package.metadata]
requires-dist = [{ name = "anyio", index = "https://pypi-proxy.fly.dev/simple/" }]
[[package]]
name = "sniffio"