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(()) +}