From 1146f3f62d88bcfe6d81a45987b1fd4745b3eeb4 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Fri, 25 Jul 2025 08:18:28 -0400 Subject: [PATCH] Avoid invalidating lockfile when path or workspace dependencies define explicit indexes (#14876) ## Summary This is an alternative to #14003 that takes advantage of the fact that we already validate that the requirements are up-to-date when validating the lockfile, and the requirements for pinned requirements include the index itself -- so rather than collecting all the explicit indexes upfront, we can just add them to the available list as we iterate over the lockfile's dependency graph. This gets all the tests passing from that PR, but with ~no performance impact and a much less invasive change. It also gets the "circular dependency" test passing, which is marked with a TODO in that PR. Closes https://github.com/astral-sh/uv/issues/11419. --- crates/uv-resolver/src/lock/mod.rs | 36 ++- crates/uv/tests/it/lock.rs | 487 +++++++++++++++++++++++++++++ 2 files changed, 521 insertions(+), 2 deletions(-) diff --git a/crates/uv-resolver/src/lock/mod.rs b/crates/uv-resolver/src/lock/mod.rs index 49cb851b3..2e3ee56d4 100644 --- a/crates/uv-resolver/src/lock/mod.rs +++ b/crates/uv-resolver/src/lock/mod.rs @@ -1431,7 +1431,7 @@ impl Lock { } // Collect the set of available indexes (both `--index-url` and `--find-links` entries). - let remotes = indexes.map(|locations| { + let mut remotes = indexes.map(|locations| { locations .allowed_indexes() .into_iter() @@ -1444,7 +1444,7 @@ impl Lock { .collect::>() }); - let locals = indexes.map(|locations| { + let mut locals = indexes.map(|locations| { locations .allowed_indexes() .into_iter() @@ -1717,6 +1717,38 @@ impl Lock { return Ok(SatisfiesResult::MissingVersion(&package.id.name)); } + // Add any explicit indexes to the list of known locals or remotes. These indexes may + // not be available as top-level configuration (i.e., if they're defined within a + // workspace member), but we already validated that the dependencies are up-to-date, so + // we can consider them "available". + for requirement in &package.metadata.requires_dist { + if let RequirementSource::Registry { + index: Some(index), .. + } = &requirement.source + { + match &index.url { + IndexUrl::Pypi(_) | IndexUrl::Url(_) => { + if let Some(remotes) = remotes.as_mut() { + remotes.insert(UrlString::from( + index.url().without_credentials().as_ref(), + )); + } + } + IndexUrl::Path(url) => { + if let Some(locals) = locals.as_mut() { + if let Some(path) = url.to_file_path().ok().and_then(|path| { + relative_to(&path, root) + .or_else(|_| std::path::absolute(path)) + .ok() + }) { + locals.insert(path.into_boxed_path()); + } + } + } + } + } + } + // Recurse. for dep in &package.dependencies { if seen.insert(&dep.package_id) { diff --git a/crates/uv/tests/it/lock.rs b/crates/uv/tests/it/lock.rs index 8a006ffb6..9f38a168a 100644 --- a/crates/uv/tests/it/lock.rs +++ b/crates/uv/tests/it/lock.rs @@ -29411,3 +29411,490 @@ fn test_tilde_equals_python_version() -> Result<()> { Ok(()) } + +/// Test that lockfile validation includes explicit indexes from path dependencies. +/// +#[test] +fn lock_path_dependency_explicit_index() -> Result<()> { + let context = TestContext::new("3.12"); + + // Create the path dependency with explicit index + let pkg_a = context.temp_dir.child("pkg_a"); + fs_err::create_dir_all(&pkg_a)?; + + let pyproject_toml = pkg_a.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "pkg-a" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["iniconfig"] + + [tool.uv.sources] + iniconfig = { index = "inner-index" } + + [[tool.uv.index]] + name = "inner-index" + url = "https://pypi-proxy.fly.dev/simple" + explicit = true + "#, + )?; + + // Create a project that depends on pkg_a + let pkg_b = context.temp_dir.child("pkg_b"); + fs_err::create_dir_all(&pkg_b)?; + + let pyproject_toml = pkg_b.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "pkg-b" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["pkg-a"] + + [tool.uv.sources] + pkg-a = { path = "../pkg_a/", editable = true } + black = { index = "outer-index" } + + [[tool.uv.index]] + name = "outer-index" + url = "https://outer-index.com/simple" + explicit = true + "#, + )?; + + uv_snapshot!(context.filters(), context.lock().current_dir(&pkg_b), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + Resolved 3 packages in [TIME] + "); + + uv_snapshot!(context.filters(), context.lock().arg("--check").current_dir(&pkg_b), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + Resolved 3 packages in [TIME] + "); + + Ok(()) +} + +/// Test that lockfile validation includes explicit indexes from path dependencies +/// defined in a non-root workspace member. +#[test] +fn lock_path_dependency_explicit_index_workspace_member() -> Result<()> { + let context = TestContext::new("3.12"); + + // Create the path dependency with explicit index + let pkg_a = context.temp_dir.child("pkg_a"); + fs_err::create_dir_all(&pkg_a)?; + + let pyproject_toml = pkg_a.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "pkg-a" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["iniconfig"] + + [tool.uv.sources] + iniconfig = { index = "inner-index" } + + [[tool.uv.index]] + name = "inner-index" + url = "https://pypi-proxy.fly.dev/simple" + explicit = true + "#, + )?; + + // Create a project that depends on pkg_a + let member = context.temp_dir.child("member"); + fs_err::create_dir_all(&member)?; + + let pyproject_toml = member.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "member" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["pkg-a"] + + [tool.uv.sources] + pkg-a = { path = "../pkg_a/", editable = true } + black = { index = "middle-index" } + + [[tool.uv.index]] + name = "middle-index" + url = "https://middle-index.com/simple" + explicit = true + "#, + )?; + + // Create a root with workspace member + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "root-project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["member"] + + [tool.uv.workspace] + members = ["member"] + + [tool.uv.sources] + member = { workspace = true } + anyio = { index = "outer-index" } + + [[tool.uv.index]] + name = "outer-index" + url = "https://outer-index.com/simple" + explicit = true + "#, + )?; + + uv_snapshot!(context.filters(), context.lock(), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 4 packages in [TIME] + "); + + uv_snapshot!(context.filters(), context.lock().arg("--check"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 4 packages in [TIME] + "); + + Ok(()) +} + +/// Test that lockfile validation works correctly when path dependency has +/// both explicit and non-explicit indexes. +#[test] +fn lock_path_dependency_mixed_indexes() -> Result<()> { + let context = TestContext::new("3.12"); + + // Create the path dependency with both explicit and non-explicit indexes. + let pkg_a = context.temp_dir.child("pkg_a"); + fs_err::create_dir_all(&pkg_a)?; + + let pyproject_toml = pkg_a.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "pkg-a" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["iniconfig", "anyio"] + + [tool.uv.sources] + iniconfig = { index = "explicit-index" } + anyio = { index = "non-explicit-index" } + + [[tool.uv.index]] + name = "non-explicit-index" + url = "https://pypi-proxy.fly.dev/simple" + + [[tool.uv.index]] + name = "explicit-index" + url = "https://pypi.org/simple" + explicit = true + "#, + )?; + + // Create a project that depends on pkg_a. + let pkg_b = context.temp_dir.child("pkg_b"); + fs_err::create_dir_all(&pkg_b)?; + + let pyproject_toml = pkg_b.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "pkg-b" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["pkg-a"] + + [tool.uv.sources] + pkg-a = { path = "../pkg_a/", editable = true } + black = { index = "outer-index" } + + [[tool.uv.index]] + name = "outer-index" + url = "https://outer-index.com/simple" + explicit = true + "#, + )?; + + uv_snapshot!(context.filters(), context.lock().current_dir(&pkg_b), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + Resolved 6 packages in [TIME] + "); + + uv_snapshot!(context.filters(), context.lock().arg("--check").current_dir(&pkg_b), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + Resolved 6 packages in [TIME] + "); + + Ok(()) +} + +/// Test that path dependencies without an index don't affect validation. +#[test] +fn lock_path_dependency_no_index() -> Result<()> { + let context = TestContext::new("3.12"); + + // Create the path dependency without explicit indexes. + let pkg_a = context.temp_dir.child("pkg_a"); + fs_err::create_dir_all(&pkg_a)?; + + let pyproject_toml = pkg_a.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "pkg-a" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["requests"] + "#, + )?; + + // Create a project that depends on pkg_a. + let pkg_b = context.temp_dir.child("pkg_b"); + fs_err::create_dir_all(&pkg_b)?; + + let pyproject_toml = pkg_b.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "pkg-b" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["pkg-a"] + + [tool.uv.sources] + pkg-a = { path = "../pkg_a/", editable = true } + "#, + )?; + + uv_snapshot!(context.filters(), context.lock().current_dir(&pkg_b), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + Resolved 7 packages in [TIME] + "); + + uv_snapshot!(context.filters(), context.lock().arg("--check").current_dir(&pkg_b), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + Resolved 7 packages in [TIME] + "); + + Ok(()) +} + +/// Test that a nested path dependency with an explicit index validates correctly. +#[test] +fn lock_nested_path_dependency_explicit_index() -> Result<()> { + let context = TestContext::new("3.12"); + + // Create the inner dependency with explicit index. + let pkg_a = context.temp_dir.child("pkg_a"); + fs_err::create_dir_all(&pkg_a)?; + + let pyproject_toml = pkg_a.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "pkg-a" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["iniconfig"] + + [tool.uv.sources] + iniconfig = { index = "inner-index" } + + [[tool.uv.index]] + name = "inner-index" + url = "https://pypi-proxy.fly.dev/simple" + explicit = true + "#, + )?; + + // Create intermediate dependency that depends on pkg_a. + let pkg_b = context.temp_dir.child("pkg_b"); + fs_err::create_dir_all(&pkg_b)?; + + let pyproject_toml = pkg_b.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "pkg-b" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["pkg-a"] + + [tool.uv.sources] + pkg-a = { path = "../pkg_a/", editable = true } + "#, + )?; + + // Create a project that depends on intermediate dependency. + let pkg_c = context.temp_dir.child("pkg_c"); + fs_err::create_dir_all(&pkg_c)?; + + let pyproject_toml = pkg_c.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "pkg-c" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["pkg-b"] + + [tool.uv.sources] + pkg-b = { path = "../pkg_b/", editable = true } + black = { index = "outer-index" } + + [[tool.uv.index]] + name = "outer-index" + url = "https://outer-index.com/simple" + explicit = true + "#, + )?; + + uv_snapshot!(context.filters(), context.lock().current_dir(&pkg_c), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + Resolved 4 packages in [TIME] + "); + + uv_snapshot!(context.filters(), context.lock().arg("--check").current_dir(&pkg_c), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + Resolved 4 packages in [TIME] + "); + + Ok(()) +} + +/// Test that validating circular path dependency indexes doesn't cause an infinite loop. +#[test] +fn lock_circular_path_dependency_explicit_index() -> Result<()> { + let context = TestContext::new("3.12"); + + // Create pkg_a (with explicit index) that depends on pkg_b. + let pkg_a = context.temp_dir.child("pkg_a"); + fs_err::create_dir_all(&pkg_a)?; + + let pyproject_toml = pkg_a.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "pkg-a" + version = "0.1.0" + requires-python = ">=3.10" + dependencies = ["pkg-b", "iniconfig"] + + [tool.uv.sources] + pkg-b = { path = "../pkg_b/" } + iniconfig = { index = "index-a" } + + [[tool.uv.index]] + name = "index-a" + url = "https://pypi-proxy.fly.dev/simple" + explicit = true + "#, + )?; + + // Create pkg_b that depends on pkg_a. This is a circular dependency. + let pkg_b = context.temp_dir.child("pkg_b"); + fs_err::create_dir_all(&pkg_b)?; + + let pyproject_toml = pkg_b.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "pkg-b" + version = "0.1.0" + requires-python = ">=3.10" + dependencies = ["pkg-a", "anyio"] + + [tool.uv.sources] + pkg-a = { path = "../pkg_a/" } + anyio = { index = "index-b" } + + [[tool.uv.index]] + name = "index-b" + url = "https://pypi.org/simple" + explicit = true + default = true + "#, + )?; + + // This should not hang or crash due to the circular dependency. + uv_snapshot!(context.filters(), context.lock().current_dir(&pkg_a), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + Resolved 8 packages in [TIME] + "); + + uv_snapshot!(context.filters(), context.lock().arg("--check").current_dir(&pkg_a), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + Resolved 8 packages in [TIME] + "); + + Ok(()) +}