This commit is contained in:
John Mumm 2025-06-27 10:21:31 +02:00 committed by GitHub
commit 153f7d0159
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 510 additions and 13 deletions

View file

@ -1261,7 +1261,7 @@ impl Lock {
build_constraints: &[Requirement],
dependency_groups: &BTreeMap<GroupName, Vec<Requirement>>,
dependency_metadata: &DependencyMetadata,
indexes: Option<&IndexLocations>,
indexes: Option<Cow<'_, IndexLocations>>,
tags: &Tags,
hasher: &HashStrategy,
index: &InMemoryIndex,
@ -1427,7 +1427,7 @@ impl Lock {
}
// Collect the set of available indexes (both `--index-url` and `--find-links` entries).
let remotes = indexes.map(|locations| {
let remotes = indexes.as_ref().map(|locations| {
locations
.allowed_indexes()
.into_iter()
@ -1440,7 +1440,7 @@ impl Lock {
.collect::<BTreeSet<_>>()
});
let locals = indexes.map(|locations| {
let locals = indexes.as_ref().map(|locations| {
locations
.allowed_indexes()
.into_iter()
@ -1473,6 +1473,8 @@ impl Lock {
queue.push_back(root);
}
// Unlike path dependencies, Git dependencies are immutable. Their sources cannot change
// without the hashes changing, so we know their indexes are still present.
while let Some(package) = queue.pop_front() {
// If the lockfile references an index that was not provided, we can't validate it.
if let Source::Registry(index) = &package.id.source {

View file

@ -20,7 +20,7 @@ use uv_warnings::warn_user_once;
use crate::dependency_groups::{DependencyGroupError, FlatDependencyGroup, FlatDependencyGroups};
use crate::pyproject::{
Project, PyProjectToml, PyprojectTomlError, Sources, ToolUvSources, ToolUvWorkspace,
Project, PyProjectToml, PyprojectTomlError, Source, Sources, ToolUvSources, ToolUvWorkspace,
};
type WorkspaceMembers = Arc<BTreeMap<PackageName, WorkspaceMember>>;
@ -769,9 +769,7 @@ impl Workspace {
// project. If it is the current project, it is added as such in the next step.
if let Some(project) = &workspace_pyproject_toml.project {
let pyproject_path = workspace_root.join("pyproject.toml");
let contents = fs_err::read_to_string(&pyproject_path)?;
let pyproject_toml = PyProjectToml::from_string(contents)
.map_err(|err| WorkspaceError::Toml(pyproject_path.clone(), Box::new(err)))?;
let pyproject_toml = pyproject_toml_from_path(pyproject_path.clone())?;
debug!(
"Adding root workspace member: `{}`",
@ -935,6 +933,85 @@ impl Workspace {
}
Ok(workspace_members)
}
/// Collects indexes provided as sources in (transitive) path dependencies that
/// have not already been defined in the workspace.
pub fn collect_path_dependency_source_indexes(&self) -> Vec<Index> {
let mut dependency_indexes = FxHashSet::default();
let mut seen = FxHashSet::default();
// 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()));
while let Some((base_path, pyproject)) = pyprojects.pop_front() {
if let Some(tool_uv_sources) = pyproject
.tool
.as_ref()
.and_then(|tool| tool.uv.as_ref())
.and_then(|uv| uv.sources.as_ref())
{
for sources in tool_uv_sources.inner().values() {
for source in sources.iter() {
if let Source::Path { path, .. } = source {
let dep_path = if path.as_ref().is_absolute() {
path.as_ref().to_path_buf()
} else {
base_path.join(path)
};
// Canonicalize path to compare symlinks and relative paths correctly
let Ok(canonical_path) = dep_path.canonicalize() else {
debug!(
"Failed to canonicalize path dependency path: {}",
dep_path.display()
);
continue;
};
// Prevent infinite loops from circular dependencies
if !seen.insert(canonical_path.clone()) {
continue;
}
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
.tool
.as_ref()
.and_then(|tool| tool.uv.as_ref())
.and_then(|uv| uv.index.as_ref())
{
dependency_indexes.extend(
dep_indexes
.iter()
.filter(|idx| !known_urls.contains(idx.url()))
.cloned(),
);
}
pyprojects.push_back((canonical_path, dep_pyproject));
}
Err(e) => {
debug!(
"Failed to read `pyproject.toml` in path dependency `{}`: {}",
dep_pyproject_path.display(),
e
);
}
}
}
}
}
}
}
dependency_indexes.into_iter().collect::<Vec<_>>()
}
}
/// A project in a workspace.
@ -1606,6 +1683,13 @@ impl VirtualProject {
}
}
/// Parses a `pyproject.toml` file from a path.
fn pyproject_toml_from_path(pyproject_path: PathBuf) -> Result<PyProjectToml, WorkspaceError> {
let contents = fs_err::read_to_string(&pyproject_path)?;
PyProjectToml::from_string(contents)
.map_err(|err| WorkspaceError::Toml(pyproject_path, Box::new(err)))
}
#[cfg(test)]
#[cfg(unix)] // Avoid path escaping for the unit tests
mod tests {

View file

@ -1,5 +1,6 @@
#![allow(clippy::single_match_else)]
use std::borrow::Cow;
use std::collections::{BTreeMap, BTreeSet};
use std::fmt::Write;
use std::path::Path;
@ -685,7 +686,7 @@ async fn do_lock(
let existing_lock = if let Some(existing_lock) = existing_lock {
match ValidatedLock::validate(
existing_lock,
target.install_path(),
target,
packages,
&members,
&requirements,
@ -898,7 +899,7 @@ impl ValidatedLock {
/// Validate a [`Lock`] against the workspace requirements.
async fn validate<Context: BuildContext>(
lock: Lock,
install_path: &Path,
target: LockTarget<'_>,
packages: &BTreeMap<PackageName, WorkspaceMember>,
members: &[PackageName],
requirements: &[Requirement],
@ -1100,16 +1101,32 @@ 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 indexes = if index_locations.is_none() {
let validation_indexes = if index_locations.is_none() {
None
} else {
Some(index_locations)
// 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))
}
};
// Determine whether the lockfile satisfies the workspace requirements.
match lock
.satisfies(
install_path,
target.install_path(),
packages,
members,
requirements,
@ -1118,7 +1135,7 @@ impl ValidatedLock {
build_constraints,
dependency_groups,
dependency_metadata,
indexes,
validation_indexes,
interpreter.tags()?,
hasher,
index,

View file

@ -28257,3 +28257,397 @@ fn lock_conflict_for_disjoint_platform() -> Result<()> {
Ok(())
}
/// Test that lockfile validation includes explicit indexes from path dependencies.
/// <https://github.com/astral-sh/uv/issues/11419>
#[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 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.
// TODO(john): Validation doesn't hang but the `uv lock --check` step fails to validate
// in the circular path dependency case (same behavior as prior to the fix for path
// dependency index lock validation).
#[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/", editable = true }
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/", editable = true }
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]
");
// TODO(john): This currently fails due to circular dependency validation issues
uv_snapshot!(context.filters(), context.lock().arg("--check").current_dir(&pkg_a), @r"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
Using CPython 3.12.[X] interpreter at: [PYTHON-3.12]
Resolved 8 packages in [TIME]
error: The lockfile at `uv.lock` needs to be updated, but `--locked` was provided. To update the lockfile, run `uv lock`.
");
Ok(())
}