diff --git a/crates/uv-installer/src/plan.rs b/crates/uv-installer/src/plan.rs index 1941a8371..e79277bf3 100644 --- a/crates/uv-installer/src/plan.rs +++ b/crates/uv-installer/src/plan.rs @@ -126,6 +126,7 @@ impl<'a> Planner<'a> { dist.name(), installed, &source, + dist.version(), installation, tags, config_settings, diff --git a/crates/uv-installer/src/satisfies.rs b/crates/uv-installer/src/satisfies.rs index 87c78bb33..351157e02 100644 --- a/crates/uv-installer/src/satisfies.rs +++ b/crates/uv-installer/src/satisfies.rs @@ -15,6 +15,7 @@ use uv_distribution_types::{ }; use uv_git_types::{GitLfs, GitOid}; use uv_normalize::PackageName; +use uv_pep440::Version; use uv_platform_tags::{IncompatibleTag, TagCompatibility, Tags}; use uv_pypi_types::{DirInfo, DirectUrl, VcsInfo, VcsKind}; @@ -36,6 +37,7 @@ impl RequirementSatisfaction { name: &PackageName, distribution: &InstalledDist, source: &RequirementSource, + version: Option<&Version>, installation: InstallationStrategy, tags: &Tags, config_settings: &ConfigSettings, @@ -358,6 +360,21 @@ impl RequirementSatisfaction { } } + // If a resolved version is provided, check that it matches the installed version. + // This is needed for sources that don't include explicit version specifiers (e.g., + // directory dependencies with dynamic versioning), where the resolver may have determined + // a new version should be installed. + if let Some(version) = version { + if distribution.version() != version { + debug!( + "Installed version does not match resolved version for {name}: {} vs. {}", + distribution.version(), + version + ); + return Self::OutOfDate; + } + } + // Otherwise, assume the requirement is up-to-date. Self::Satisfied } diff --git a/crates/uv-installer/src/site_packages.rs b/crates/uv-installer/src/site_packages.rs index 84dc7f66b..e6ef1c61d 100644 --- a/crates/uv-installer/src/site_packages.rs +++ b/crates/uv-installer/src/site_packages.rs @@ -485,6 +485,7 @@ impl SitePackages { name, distribution, &requirement.source, + None, installation, tags, config_settings, @@ -508,6 +509,7 @@ impl SitePackages { name, distribution, &constraint.source, + None, installation, tags, config_settings, diff --git a/crates/uv/tests/it/sync.rs b/crates/uv/tests/it/sync.rs index d1f2cb0e8..7a95f6070 100644 --- a/crates/uv/tests/it/sync.rs +++ b/crates/uv/tests/it/sync.rs @@ -15376,3 +15376,113 @@ fn sync_fails_ambiguous_url() -> Result<()> { Ok(()) } + +/// Test that when a local directory dependency's version changes, the planner reinstalls it +/// even if the source directory content (cache info) hasn't changed. +/// +/// Regression test for: +#[test] +fn sync_reinstalls_on_version_change() -> Result<()> { + let context = TestContext::new("3.12"); + + // Create a workspace with a local directory dependency. + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["child"] + + [tool.uv.sources] + child = { path = "packages/child" } + "#, + )?; + + // Create the child package with version 0.1.0. + let child = context.temp_dir.child("packages/child"); + child.create_dir_all()?; + child.child("pyproject.toml").write_str( + r#" + [project] + name = "child" + version = "0.1.0" + requires-python = ">=3.12" + + [build-system] + requires = ["hatchling"] + build-backend = "hatchling.build" + "#, + )?; + child + .child("src") + .child("child") + .child("__init__.py") + .touch()?; + + // Lock and sync (installs child v0.1.0). + uv_snapshot!(context.filters(), context.lock(), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 2 packages in [TIME] + "); + + uv_snapshot!(context.filters(), context.sync(), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 2 packages in [TIME] + Prepared 1 package in [TIME] + Installed 1 package in [TIME] + + child==0.1.0 (from file://[TEMP_DIR]/packages/child) + "); + + // Now bump the child's version to 0.1.1. + child.child("pyproject.toml").write_str( + r#" + [project] + name = "child" + version = "0.1.1" + requires-python = ">=3.12" + + [build-system] + requires = ["hatchling"] + build-backend = "hatchling.build" + "#, + )?; + + // Lock again; lockfile should show v0.1.1. + uv_snapshot!(context.filters(), context.lock(), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 2 packages in [TIME] + Updated child v0.1.0 -> v0.1.1 + "); + + // Sync should reinstall child with the new version. Before the fix for #17370, + // this would incorrectly say "Audited 2 packages" and not reinstall the child package. + uv_snapshot!(context.filters(), context.sync(), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 2 packages in [TIME] + Prepared 1 package in [TIME] + Uninstalled 1 package in [TIME] + Installed 1 package in [TIME] + - child==0.1.0 (from file://[TEMP_DIR]/packages/child) + + child==0.1.1 (from file://[TEMP_DIR]/packages/child) + "); + + Ok(()) +}