diff --git a/crates/uv-resolver/src/lock/mod.rs b/crates/uv-resolver/src/lock/mod.rs index ba9150b6f..329104149 100644 --- a/crates/uv-resolver/src/lock/mod.rs +++ b/crates/uv-resolver/src/lock/mod.rs @@ -1233,7 +1233,8 @@ impl Lock { build_constraints: &[Requirement], dependency_groups: &BTreeMap>, dependency_metadata: &DependencyMetadata, - indexes: Option>, + indexes: Option<&IndexLocations>, + path_dependency_indexes: &BTreeSet, tags: &Tags, hasher: &HashStrategy, index: &InMemoryIndex, @@ -1399,7 +1400,7 @@ impl Lock { } // Collect the set of available indexes (both `--index-url` and `--find-links` entries). - let remotes = indexes.as_ref().map(|locations| { + let remotes = indexes.map(|locations| { locations .allowed_indexes() .into_iter() @@ -1412,7 +1413,7 @@ impl Lock { .collect::>() }); - let locals = indexes.as_ref().map(|locations| { + let locals = indexes.map(|locations| { locations .allowed_indexes() .into_iter() @@ -1452,10 +1453,9 @@ impl Lock { if let Source::Registry(index) = &package.id.source { match index { RegistrySource::Url(url) => { - if remotes - .as_ref() - .is_some_and(|remotes| !remotes.contains(url)) - { + if remotes.as_ref().is_some_and(|remotes| { + !remotes.contains(url) && !path_dependency_indexes.contains(url) + }) { let name = &package.id.name; let version = &package .id diff --git a/crates/uv-workspace/src/workspace.rs b/crates/uv-workspace/src/workspace.rs index 967f01e96..b6ae9ee0a 100644 --- a/crates/uv-workspace/src/workspace.rs +++ b/crates/uv-workspace/src/workspace.rs @@ -1,6 +1,7 @@ //! Resolve the current [`ProjectWorkspace`] or [`Workspace`]. -use std::collections::{BTreeMap, BTreeSet}; +use std::borrow::Cow; +use std::collections::{BTreeMap, BTreeSet, VecDeque}; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; @@ -939,10 +940,13 @@ impl Workspace { // We will only add indexes if we have not already seen the URLs. let known_urls: FxHashSet<_> = self.indexes.iter().map(Index::url).collect(); - let mut pyprojects = std::collections::VecDeque::new(); - pyprojects.push_back((self.install_path.clone(), self.pyproject_toml.clone())); + let mut pyproject_queue = VecDeque::new(); + for package in self.packages.values() { + pyproject_queue + .push_back((package.root.clone(), Cow::Borrowed(&package.pyproject_toml))); + } - while let Some((base_path, pyproject)) = pyprojects.pop_front() { + while let Some((base_path, pyproject)) = pyproject_queue.pop_front() { if let Some(tool_uv_sources) = pyproject .tool .as_ref() @@ -975,8 +979,8 @@ impl Workspace { let dep_pyproject_path = canonical_path.join("pyproject.toml"); match pyproject_toml_from_path(dep_pyproject_path.clone()) { - Ok(dep_pyproject) => { - if let Some(dep_indexes) = dep_pyproject + Ok(pyproject_toml) => { + if let Some(dep_indexes) = pyproject_toml .tool .as_ref() .and_then(|tool| tool.uv.as_ref()) @@ -990,7 +994,8 @@ impl Workspace { ); } - pyprojects.push_back((canonical_path, dep_pyproject)); + pyproject_queue + .push_back((canonical_path, Cow::Owned(pyproject_toml))); } Err(e) => { debug!( diff --git a/crates/uv/src/commands/project/lock.rs b/crates/uv/src/commands/project/lock.rs index ae94d5ca4..07116c526 100644 --- a/crates/uv/src/commands/project/lock.rs +++ b/crates/uv/src/commands/project/lock.rs @@ -1,6 +1,5 @@ #![allow(clippy::single_match_else)] -use std::borrow::Cow; use std::collections::{BTreeMap, BTreeSet}; use std::fmt::Write; use std::path::Path; @@ -18,8 +17,8 @@ use uv_configuration::{ use uv_dispatch::BuildDispatch; use uv_distribution::DistributionDatabase; use uv_distribution_types::{ - DependencyMetadata, HashGeneration, Index, IndexLocations, NameRequirementSpecification, - Requirement, UnresolvedRequirementSpecification, + DependencyMetadata, HashGeneration, Index, IndexLocations, IndexUrl, + NameRequirementSpecification, Requirement, UnresolvedRequirementSpecification, UrlString, }; use uv_git::ResolvedRepositoryReference; use uv_normalize::{GroupName, PackageName}; @@ -1067,26 +1066,26 @@ impl ValidatedLock { // However, if _no_ indexes were provided, we assume that the user wants to reuse the existing // distributions, even though a failure to reuse the lockfile will result in re-resolving // against PyPI by default. - let validation_indexes = if index_locations.is_none() { + let indexes = if index_locations.is_none() { None } else { - // If indexes were defined as sources in path dependencies, add them to the - // index locations to use for validation. - if let LockTarget::Workspace(workspace) = target { - let path_dependency_source_indexes = - workspace.collect_path_dependency_source_indexes(); - if path_dependency_source_indexes.is_empty() { - Some(Cow::Borrowed(index_locations)) - } else { - Some(Cow::Owned(index_locations.clone().combine( - path_dependency_source_indexes, - Vec::new(), - false, - ))) - } - } else { - Some(Cow::Borrowed(index_locations)) - } + Some(index_locations) + }; + + // Collect indexes specified in path dependencies + let path_dependency_indexes = if let LockTarget::Workspace(workspace) = target { + workspace + .collect_path_dependency_source_indexes() + .into_iter() + .filter_map(|index| match index.url() { + IndexUrl::Pypi(_) | IndexUrl::Url(_) => { + Some(UrlString::from(index.url().without_credentials().as_ref())) + } + IndexUrl::Path(_) => None, + }) + .collect::>() + } else { + BTreeSet::default() }; // Determine whether the lockfile satisfies the workspace requirements. @@ -1101,7 +1100,8 @@ impl ValidatedLock { build_constraints, dependency_groups, dependency_metadata, - validation_indexes, + indexes, + &path_dependency_indexes, interpreter.tags()?, hasher, index, diff --git a/crates/uv/tests/it/lock.rs b/crates/uv/tests/it/lock.rs index 62575de31..3ecea9f19 100644 --- a/crates/uv/tests/it/lock.rs +++ b/crates/uv/tests/it/lock.rs @@ -27634,6 +27634,104 @@ fn lock_path_dependency_explicit_index() -> Result<()> { 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]