From d013520b56c931adc2d1a13648d9de8aa7958573 Mon Sep 17 00:00:00 2001 From: BitWeaverDev Date: Fri, 12 Jun 2026 15:01:49 +0200 Subject: [PATCH 1/3] Sync index format when `uv add --index` updates an existing index URL When `uv add --index =` matches an existing index by name, it overwrites the `url` but previously left the `format` key untouched. An index declared with `format = "flat"` would keep that format after being repointed at a Simple-API URL, silently producing an inconsistent configuration that breaks a subsequent `uv lock`. Track whether the URL actually changes and, when it does, sync the `format` to match the incoming index: write `format = "flat"` for flat indexes, and drop the key entirely for Simple indexes (the default). The format is left alone when the URL is unchanged, so existing entries keep their configuration. Closes #19759 --- crates/uv-workspace/src/pyproject_mut.rs | 112 ++++++++++++++++++++++- 1 file changed, 107 insertions(+), 5 deletions(-) diff --git a/crates/uv-workspace/src/pyproject_mut.rs b/crates/uv-workspace/src/pyproject_mut.rs index 094e5ec913e..8e5e87776de 100644 --- a/crates/uv-workspace/src/pyproject_mut.rs +++ b/crates/uv-workspace/src/pyproject_mut.rs @@ -11,7 +11,7 @@ use toml_edit::{ }; use uv_cache_key::CanonicalUrl; -use uv_distribution_types::Index; +use uv_distribution_types::{Index, IndexFormat}; use uv_fs::PortablePath; use uv_normalize::{ExtraName, GroupName, PackageName}; use uv_pep440::{Version, VersionParseError, VersionSpecifier, VersionSpecifiers}; @@ -504,12 +504,14 @@ impl PyProjectTomlMut { table.insert("name", Value::String(formatted).into()); } - // If necessary, update the URL. - if table + // Determine whether the URL is changing so that format can be updated accordingly. + let url_changed = table .get("url") .and_then(|item| item.as_str()) - .is_none_or(|url| url != index.url.without_credentials().as_str()) - { + .is_none_or(|url| url != index.url.without_credentials().as_str()); + + // If necessary, update the URL. + if url_changed { let mut formatted = Formatted::new(index.url.without_credentials().to_string()); if let Some(value) = table.get("url").and_then(Item::as_value) { if let Some(prefix) = value.decor().prefix() { @@ -542,6 +544,35 @@ impl PyProjectTomlMut { } } + // If the URL changed, sync the format to match the incoming index. + // A format is tied to a URL; when the URL changes, the old format may be incompatible. + if url_changed { + match index.format { + IndexFormat::Flat => { + if table + .get("format") + .and_then(Item::as_str) + .is_none_or(|format| format != "flat") + { + let mut formatted = Formatted::new("flat".to_string()); + if let Some(value) = table.get("format").and_then(Item::as_value) { + if let Some(prefix) = value.decor().prefix() { + formatted.decor_mut().set_prefix(prefix.clone()); + } + if let Some(suffix) = value.decor().suffix() { + formatted.decor_mut().set_suffix(suffix.clone()); + } + } + table.insert("format", Value::String(formatted).into()); + } + } + IndexFormat::Simple => { + // Remove the format key if it exists (Simple is the default). + table.remove("format"); + } + } + } + // Remove any replaced tables. existing.retain(|table| { // If the index has the same name, skip it. @@ -1831,6 +1862,7 @@ mod test { use insta::assert_snapshot; use std::str::FromStr; use toml_edit::DocumentMut; + use uv_distribution_types::Index; use uv_normalize::PackageName; use uv_pep440::Version; use uv_pep508::Requirement; @@ -2283,4 +2315,74 @@ dependencies = [ "# ); } + + #[test] + fn add_index_clears_format_on_url_update() { + let toml = r#" +[project] +name = "project" +version = "0.1.0" +requires-python = ">=3.12" +dependencies = [] + +[[tool.uv.index]] +name = "index" +url = "https://example.com/flat" +format = "flat" +"#; + + let mut doc = PyProjectTomlMut::from_toml(toml, DependencyTarget::PyProjectToml).unwrap(); + + let new_index = Index::from_str("index=https://pypi.org/simple").unwrap(); + doc.add_index(&new_index).unwrap(); + + assert_snapshot!(doc.to_string(), @r#" + +[project] +name = "project" +version = "0.1.0" +requires-python = ">=3.12" +dependencies = [] + +[[tool.uv.index]] +name = "index" +url = "https://pypi.org/simple" +"#); + } + + #[test] + fn add_index_preserves_format_when_url_unchanged() { + let toml = r#" +[project] +name = "project" +version = "0.1.0" +requires-python = ">=3.12" +dependencies = [] + +[[tool.uv.index]] +name = "index" +url = "https://example.com/flat" +format = "flat" +"#; + + let mut doc = PyProjectTomlMut::from_toml(toml, DependencyTarget::PyProjectToml).unwrap(); + + // Same URL, same name — URL is not changing so format should be preserved. + let new_index = Index::from_str("index=https://example.com/flat").unwrap(); + doc.add_index(&new_index).unwrap(); + + assert_snapshot!(doc.to_string(), @r#" + +[project] +name = "project" +version = "0.1.0" +requires-python = ">=3.12" +dependencies = [] + +[[tool.uv.index]] +name = "index" +url = "https://example.com/flat" +format = "flat" +"#); + } } From 7624e180140d43a458acbb01a4b5649d237a9bd9 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Wed, 1 Jul 2026 08:45:49 -0400 Subject: [PATCH 2/3] Preserve index format for equivalent locations --- crates/uv-workspace/src/pyproject_mut.rs | 116 +++++++++++++---------- crates/uv/src/commands/project/add.rs | 8 +- crates/uv/tests/project/edit.rs | 105 ++++++++++++++++++-- 3 files changed, 172 insertions(+), 57 deletions(-) diff --git a/crates/uv-workspace/src/pyproject_mut.rs b/crates/uv-workspace/src/pyproject_mut.rs index 8e5e87776de..75e5293909e 100644 --- a/crates/uv-workspace/src/pyproject_mut.rs +++ b/crates/uv-workspace/src/pyproject_mut.rs @@ -11,12 +11,11 @@ use toml_edit::{ }; use uv_cache_key::CanonicalUrl; -use uv_distribution_types::{Index, IndexFormat}; -use uv_fs::PortablePath; +use uv_distribution_types::{Index, IndexFormat, IndexUrl}; +use uv_fs::{PortablePath, is_same_file_allow_missing}; use uv_normalize::{ExtraName, GroupName, PackageName}; use uv_pep440::{Version, VersionParseError, VersionSpecifier, VersionSpecifiers}; use uv_pep508::{MarkerTree, Requirement, VersionOrUrl}; -use uv_redacted::DisplaySafeUrl; use crate::pyproject::{DependencyType, Source}; @@ -29,6 +28,21 @@ pub struct PyProjectTomlMut { target: DependencyTarget, } +fn index_locations_equal(existing: &str, incoming: &IndexUrl, root_dir: &Path) -> bool { + let Ok(existing) = IndexUrl::parse(existing, Some(root_dir)) else { + return false; + }; + + if let (IndexUrl::Path(existing), IndexUrl::Path(incoming)) = (&existing, incoming) + && let (Ok(existing), Ok(incoming)) = (existing.to_file_path(), incoming.to_file_path()) + && let Some(equal) = is_same_file_allow_missing(&existing, &incoming) + { + return equal; + } + + CanonicalUrl::new(existing.url()) == CanonicalUrl::new(incoming.url()) +} + #[derive(Error, Debug)] pub enum Error { #[error("Failed to parse `pyproject.toml`")] @@ -428,7 +442,7 @@ impl PyProjectTomlMut { } /// Add an [`Index`] to `tool.uv.index`. - pub fn add_index(&mut self, index: &Index) -> Result<(), Error> { + pub fn add_index(&mut self, index: &Index, root_dir: &Path) -> Result<(), Error> { let size = self.doc.len(); let existing = self .doc @@ -472,10 +486,7 @@ impl PyProjectTomlMut { if table .get("url") .and_then(|item| item.as_str()) - .and_then(|url| DisplaySafeUrl::parse(url).ok()) - .is_some_and(|url| { - CanonicalUrl::new(&url) == CanonicalUrl::new(index.url.url()) - }) + .is_some_and(|url| index_locations_equal(url, &index.url, root_dir)) { return true; } @@ -504,14 +515,16 @@ impl PyProjectTomlMut { table.insert("name", Value::String(formatted).into()); } - // Determine whether the URL is changing so that format can be updated accordingly. - let url_changed = table - .get("url") - .and_then(|item| item.as_str()) - .is_none_or(|url| url != index.url.without_credentials().as_str()); + let existing_url = table.get("url").and_then(|item| item.as_str()); + + // Update the stored URL independently of whether the index location changed. + let url_needs_update = + existing_url.is_none_or(|url| url != index.url.without_credentials().as_str()); + let index_location_changed = + existing_url.is_none_or(|url| !index_locations_equal(url, &index.url, root_dir)); // If necessary, update the URL. - if url_changed { + if url_needs_update { let mut formatted = Formatted::new(index.url.without_credentials().to_string()); if let Some(value) = table.get("url").and_then(Item::as_value) { if let Some(prefix) = value.decor().prefix() { @@ -544,9 +557,8 @@ impl PyProjectTomlMut { } } - // If the URL changed, sync the format to match the incoming index. - // A format is tied to a URL; when the URL changes, the old format may be incompatible. - if url_changed { + // If the index location changed, sync the format to match the incoming index. + if index_location_changed { match index.format { IndexFormat::Flat => { if table @@ -598,8 +610,7 @@ impl PyProjectTomlMut { if table .get("url") .and_then(|item| item.as_str()) - .and_then(|url| DisplaySafeUrl::parse(url).ok()) - .is_some_and(|url| CanonicalUrl::new(&url) == CanonicalUrl::new(index.url.url())) + .is_some_and(|url| index_locations_equal(url, &index.url, root_dir)) { return false; } @@ -1860,6 +1871,7 @@ mod test { }; use anyhow::Result; use insta::assert_snapshot; + use std::path::Path; use std::str::FromStr; use toml_edit::DocumentMut; use uv_distribution_types::Index; @@ -2319,12 +2331,6 @@ dependencies = [ #[test] fn add_index_clears_format_on_url_update() { let toml = r#" -[project] -name = "project" -version = "0.1.0" -requires-python = ">=3.12" -dependencies = [] - [[tool.uv.index]] name = "index" url = "https://example.com/flat" @@ -2334,16 +2340,10 @@ format = "flat" let mut doc = PyProjectTomlMut::from_toml(toml, DependencyTarget::PyProjectToml).unwrap(); let new_index = Index::from_str("index=https://pypi.org/simple").unwrap(); - doc.add_index(&new_index).unwrap(); + doc.add_index(&new_index, Path::new(".")).unwrap(); assert_snapshot!(doc.to_string(), @r#" -[project] -name = "project" -version = "0.1.0" -requires-python = ">=3.12" -dependencies = [] - [[tool.uv.index]] name = "index" url = "https://pypi.org/simple" @@ -2351,38 +2351,58 @@ url = "https://pypi.org/simple" } #[test] - fn add_index_preserves_format_when_url_unchanged() { + fn add_index_preserves_format_when_url_canonically_unchanged() { let toml = r#" -[project] -name = "project" -version = "0.1.0" -requires-python = ">=3.12" -dependencies = [] - [[tool.uv.index]] name = "index" -url = "https://example.com/flat" +url = "https://example.com/flat/" format = "flat" "#; let mut doc = PyProjectTomlMut::from_toml(toml, DependencyTarget::PyProjectToml).unwrap(); - // Same URL, same name — URL is not changing so format should be preserved. + // The URL spelling changes, but the canonical URL does not, so format should be preserved. let new_index = Index::from_str("index=https://example.com/flat").unwrap(); - doc.add_index(&new_index).unwrap(); + doc.add_index(&new_index, Path::new(".")).unwrap(); assert_snapshot!(doc.to_string(), @r#" -[project] -name = "project" -version = "0.1.0" -requires-python = ">=3.12" -dependencies = [] - [[tool.uv.index]] name = "index" url = "https://example.com/flat" format = "flat" "#); } + + #[cfg(windows)] + #[test] + fn add_index_preserves_format_when_windows_path_unchanged() -> Result<()> { + let toml = r#" +[[tool.uv.index]] +name = "index" +url = 'C:\links' +format = "flat" +"#; + + let mut doc = PyProjectTomlMut::from_toml(toml, DependencyTarget::PyProjectToml)?; + + let new_index = Index::from_str(r"index=C:\links")?; + doc.add_index(&new_index, &std::env::current_dir()?)?; + + let expected_url = new_index.url.without_credentials(); + let index = doc.doc["tool"]["uv"]["index"] + .as_array_of_tables() + .and_then(|indexes| indexes.get(0)) + .expect("index table"); + assert_eq!( + index.get("url").and_then(|item| item.as_str()), + Some(expected_url.as_str()) + ); + assert_eq!( + index.get("format").and_then(|item| item.as_str()), + Some("flat") + ); + + Ok(()) + } } diff --git a/crates/uv/src/commands/project/add.rs b/crates/uv/src/commands/project/add.rs index 30b99d38fb5..2abe327c187 100644 --- a/crates/uv/src/commands/project/add.rs +++ b/crates/uv/src/commands/project/add.rs @@ -26,7 +26,7 @@ use uv_distribution_types::{ Identifier, Index, IndexLocations, IndexName, IndexUrl, NameRequirementSpecification, Requirement, RequirementSource, UnresolvedRequirement, }; -use uv_fs::{LockedFile, LockedFileError, Simplified}; +use uv_fs::{CWD, LockedFile, LockedFileError, Simplified}; use uv_git::store_credentials; use uv_normalize::{DEV_DEPENDENCIES, DefaultExtras, DefaultGroups, ExtraName, PackageName}; use uv_pep508::{MarkerTree, VersionOrUrl}; @@ -688,11 +688,15 @@ pub(crate) async fn add( // Add any indexes that were provided on the command-line, in priority order. if !raw { + let root_dir = match &target { + AddTarget::Script(_, _) => CWD.as_path(), + AddTarget::Project(project, _) => project.root(), + }; let locations = IndexLocations::new(indexes, Vec::new(), false); let mut indexes = locations.defined_indexes().collect::>(); indexes.reverse(); for index in indexes { - toml.add_index(index)?; + toml.add_index(index, root_dir)?; } } diff --git a/crates/uv/tests/project/edit.rs b/crates/uv/tests/project/edit.rs index d1f60cac2bc..d5047aec654 100644 --- a/crates/uv/tests/project/edit.rs +++ b/crates/uv/tests/project/edit.rs @@ -11255,17 +11255,24 @@ fn add_index_without_trailing_slash() -> Result<()> { fn add_index_with_existing_relative_path_index() -> Result<()> { let context = uv_test::test_context!("3.12"); - let pyproject_toml = context.temp_dir.child("pyproject.toml"); + let project = context.temp_dir.child("project"); + project.create_dir_all()?; + let pyproject_toml = project.child("pyproject.toml"); pyproject_toml.write_str(indoc! {r#" [project] name = "project" version = "0.1.0" requires-python = ">=3.12" dependencies = [] + + [[tool.uv.index]] + name = "local" + url = "./links-alias" + format = "flat" "#})?; // Create test-index/ subdirectory and copy our "offline" tqdm wheel there - let packages = context.temp_dir.child("test-index"); + let packages = project.child("test-index"); packages.create_dir_all()?; let wheel_src = context @@ -11273,19 +11280,103 @@ fn add_index_with_existing_relative_path_index() -> Result<()> { .join("test/links/ok-1.0.0-py3-none-any.whl"); let wheel_dst = packages.child("ok-1.0.0-py3-none-any.whl"); fs_err::copy(&wheel_src, &wheel_dst)?; + uv_fs::create_symlink(packages.path(), project.child("links-alias").path())?; - uv_snapshot!(context.filters(), context.add().arg("iniconfig").arg("--index").arg("./test-index"), @" + let index = format!("local={}", packages.path().display()); + uv_snapshot!(context.filters(), context.add().arg("iniconfig").arg("--frozen").arg("--project").arg(project.path()).arg("--index").arg(index), @" success: true exit_code: 0 ----- stdout ----- ----- stderr ----- - Resolved 2 packages in [TIME] - Prepared 1 package in [TIME] - Installed 1 package in [TIME] - + iniconfig==2.0.0 + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + "); + + let pyproject_toml = fs_err::read_to_string(project.join("pyproject.toml"))?; + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!(pyproject_toml, @r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [ + "iniconfig", + ] + + [[tool.uv.index]] + name = "local" + url = "file://[TEMP_DIR]/project/test-index" + format = "flat" + + [tool.uv.sources] + iniconfig = { index = "local" } + "#); + }); + + Ok(()) +} + +/// Add an index with an existing relative path to a script outside the working directory. +#[test] +fn add_index_with_existing_relative_path_in_script() -> Result<()> { + let context = uv_test::test_context!("3.12"); + + let scripts = context.temp_dir.child("scripts"); + scripts.create_dir_all()?; + let script = scripts.child("main.py"); + script.write_str(indoc! {r#" + # /// script + # requires-python = ">=3.12" + # dependencies = [] + # + # [[tool.uv.index]] + # name = "local" + # url = "./links" + # format = "flat" + # /// + "#})?; + + let packages = context.temp_dir.child("links"); + packages.create_dir_all()?; + let wheel_src = context + .workspace_root + .join("test/links/ok-1.0.0-py3-none-any.whl"); + fs_err::copy(&wheel_src, packages.child("ok-1.0.0-py3-none-any.whl"))?; + + uv_snapshot!(context.filters(), context.add().arg("iniconfig").arg("--frozen").arg("--script").arg(script.path()).arg("--index").arg("local=./links"), @" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: `--frozen` is a no-op for Python scripts with inline metadata, which always run in isolation "); + let script = fs_err::read_to_string(script.path())?; + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!(script, @r#" + # /// script + # requires-python = ">=3.12" + # dependencies = [ + # "iniconfig", + # ] + # + # [[tool.uv.index]] + # name = "local" + # url = "file://[TEMP_DIR]/links" + # format = "flat" + # + # [tool.uv.sources] + # iniconfig = { index = "local" } + # /// + "#); + }); + Ok(()) } From 0101ba32616fd7a06d9bcb86916e3fd37bfb936d Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Wed, 1 Jul 2026 08:54:26 -0400 Subject: [PATCH 3/3] Simplify index format regression tests --- crates/uv-workspace/src/pyproject_mut.rs | 30 +++++++----------------- crates/uv/tests/project/edit.rs | 9 ++----- 2 files changed, 11 insertions(+), 28 deletions(-) diff --git a/crates/uv-workspace/src/pyproject_mut.rs b/crates/uv-workspace/src/pyproject_mut.rs index 75e5293909e..ad3a2ec823a 100644 --- a/crates/uv-workspace/src/pyproject_mut.rs +++ b/crates/uv-workspace/src/pyproject_mut.rs @@ -2329,48 +2329,36 @@ dependencies = [ } #[test] - fn add_index_clears_format_on_url_update() { + fn add_index_syncs_format_on_url_update() { let toml = r#" [[tool.uv.index]] name = "index" -url = "https://example.com/flat" +url = "https://example.com/flat/" format = "flat" "#; let mut doc = PyProjectTomlMut::from_toml(toml, DependencyTarget::PyProjectToml).unwrap(); - let new_index = Index::from_str("index=https://pypi.org/simple").unwrap(); - doc.add_index(&new_index, Path::new(".")).unwrap(); + // The URL spelling changes, but the canonical URL does not, so format should be preserved. + let equivalent_index = Index::from_str("index=https://example.com/flat").unwrap(); + doc.add_index(&equivalent_index, Path::new(".")).unwrap(); assert_snapshot!(doc.to_string(), @r#" [[tool.uv.index]] name = "index" -url = "https://pypi.org/simple" -"#); - } - - #[test] - fn add_index_preserves_format_when_url_canonically_unchanged() { - let toml = r#" -[[tool.uv.index]] -name = "index" -url = "https://example.com/flat/" +url = "https://example.com/flat" format = "flat" -"#; - - let mut doc = PyProjectTomlMut::from_toml(toml, DependencyTarget::PyProjectToml).unwrap(); +"#); - // The URL spelling changes, but the canonical URL does not, so format should be preserved. - let new_index = Index::from_str("index=https://example.com/flat").unwrap(); + let new_index = Index::from_str("index=https://pypi.org/simple").unwrap(); doc.add_index(&new_index, Path::new(".")).unwrap(); assert_snapshot!(doc.to_string(), @r#" [[tool.uv.index]] name = "index" -url = "https://example.com/flat" -format = "flat" +url = "https://pypi.org/simple" "#); } diff --git a/crates/uv/tests/project/edit.rs b/crates/uv/tests/project/edit.rs index d5047aec654..648ea79c262 100644 --- a/crates/uv/tests/project/edit.rs +++ b/crates/uv/tests/project/edit.rs @@ -11271,15 +11271,10 @@ fn add_index_with_existing_relative_path_index() -> Result<()> { format = "flat" "#})?; - // Create test-index/ subdirectory and copy our "offline" tqdm wheel there + // Create a non-empty flat index. let packages = project.child("test-index"); packages.create_dir_all()?; - - let wheel_src = context - .workspace_root - .join("test/links/ok-1.0.0-py3-none-any.whl"); - let wheel_dst = packages.child("ok-1.0.0-py3-none-any.whl"); - fs_err::copy(&wheel_src, &wheel_dst)?; + packages.child("placeholder").touch()?; uv_fs::create_symlink(packages.path(), project.child("links-alias").path())?; let index = format!("local={}", packages.path().display());