diff --git a/crates/uv-installer/src/plan.rs b/crates/uv-installer/src/plan.rs index a9f856f47a4ce..ad478c9d1ee52 100644 --- a/crates/uv-installer/src/plan.rs +++ b/crates/uv-installer/src/plan.rs @@ -175,9 +175,12 @@ impl<'a> Planner<'a> { [distribution] => { // Filter out already-installed packages. match requirement.version_or_url.as_ref() { + // Accept any version of the package. + None => continue, + // If the requirement comes from a registry, check by name. - None | Some(VersionOrUrl::VersionSpecifier(_)) => { - if requirement.is_satisfied_by(distribution.version()) { + Some(VersionOrUrl::VersionSpecifier(version_specifier)) => { + if version_specifier.contains(distribution.version()) { debug!("Requirement already satisfied: {distribution}"); continue; } @@ -196,6 +199,7 @@ impl<'a> Planner<'a> { debug!("Requirement already satisfied (and up-to-date): {installed}"); continue; } + debug!("Requirement already satisfied (but not up-to-date): {installed}"); } else { // Otherwise, assume the requirement is up-to-date. debug!("Requirement already satisfied (assumed up-to-date): {installed}"); diff --git a/crates/uv-installer/src/site_packages.rs b/crates/uv-installer/src/site_packages.rs index 66f1240efcc77..9de913278bb00 100644 --- a/crates/uv-installer/src/site_packages.rs +++ b/crates/uv-installer/src/site_packages.rs @@ -323,7 +323,30 @@ impl<'a> SitePackages<'a> { [distribution] => { // Validate that the installed version matches the requirement. match &requirement.version_or_url { - None | Some(pep508_rs::VersionOrUrl::Url(_)) => {} + // Accept any installed version. + None => {} + + // If the requirement comes from a URL, verify by URL. + Some(pep508_rs::VersionOrUrl::Url(url)) => { + let InstalledDist::Url(installed) = &distribution else { + return Ok(false); + }; + + if &installed.url != url.raw() { + return Ok(false); + } + + // If the requirement came from a local path, check freshness. + if let Ok(archive) = url.to_file_path() { + if !ArchiveTimestamp::up_to_date_with( + &archive, + ArchiveTarget::Install(distribution), + )? { + return Ok(false); + } + } + } + Some(pep508_rs::VersionOrUrl::VersionSpecifier(version_specifier)) => { // The installed version doesn't satisfy the requirement. if !version_specifier.contains(distribution.version()) { @@ -343,9 +366,32 @@ impl<'a> SitePackages<'a> { } match &constraint.version_or_url { - None | Some(pep508_rs::VersionOrUrl::Url(_)) => {} + // Accept any installed version. + None => {} + + // If the requirement comes from a URL, verify by URL. + Some(pep508_rs::VersionOrUrl::Url(url)) => { + let InstalledDist::Url(installed) = &distribution else { + return Ok(false); + }; + + if &installed.url != url.raw() { + return Ok(false); + } + + // If the requirement came from a local path, check freshness. + if let Ok(archive) = url.to_file_path() { + if !ArchiveTimestamp::up_to_date_with( + &archive, + ArchiveTarget::Install(distribution), + )? { + return Ok(false); + } + } + } + Some(pep508_rs::VersionOrUrl::VersionSpecifier(version_specifier)) => { - // The installed version doesn't satisfy the constraint. + // The installed version doesn't satisfy the requirement. if !version_specifier.contains(distribution.version()) { return Ok(false); } diff --git a/crates/uv/tests/pip_install.rs b/crates/uv/tests/pip_install.rs index bb6f719e03567..59eb3049b563b 100644 --- a/crates/uv/tests/pip_install.rs +++ b/crates/uv/tests/pip_install.rs @@ -1920,7 +1920,7 @@ fn install_symlink() { } #[test] -fn invalidate_on_change() -> Result<()> { +fn invalidate_editable_on_change() -> Result<()> { let context = TestContext::new("3.12"); // Create an editable package. @@ -2010,7 +2010,7 @@ requires-python = ">=3.8" } #[test] -fn invalidate_dynamic() -> Result<()> { +fn invalidate_editable_dynamic() -> Result<()> { let context = TestContext::new("3.12"); // Create an editable package with dynamic metadata @@ -2098,3 +2098,91 @@ dependencies = {file = ["requirements.txt"]} Ok(()) } + +#[test] +fn invalidate_path_on_change() -> Result<()> { + let context = TestContext::new("3.12"); + + // Create a local package. + let editable_dir = assert_fs::TempDir::new()?; + let pyproject_toml = editable_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#"[project] +name = "example" +version = "0.0.0" +dependencies = [ + "anyio==4.0.0" +] +requires-python = ">=3.8" +"#, + )?; + + let filters = [(r"\(from file://.*\)", "(from [WORKSPACE_DIR])")] + .into_iter() + .chain(INSTA_FILTERS.to_vec()) + .collect::>(); + + uv_snapshot!(filters, command(&context) + .arg("example @ .") + .current_dir(editable_dir.path()), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 4 packages in [TIME] + Downloaded 4 packages in [TIME] + Installed 4 packages in [TIME] + + anyio==4.0.0 + + example==0.0.0 (from [WORKSPACE_DIR]) + + idna==3.4 + + sniffio==1.3.0 + "### + ); + + // Re-installing should be a no-op. + uv_snapshot!(filters, command(&context) + .arg("example @ .") + .current_dir(editable_dir.path()), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Audited 1 package in [TIME] + "### + ); + + // Modify the editable package. + pyproject_toml.write_str( + r#"[project] +name = "example" +version = "0.0.0" +dependencies = [ + "anyio==3.7.1" +] +requires-python = ">=3.8" +"#, + )?; + + // Re-installing should update the package. + uv_snapshot!(filters, command(&context) + .arg("example @ .") + .current_dir(editable_dir.path()), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 4 packages in [TIME] + Downloaded 2 packages in [TIME] + Installed 2 packages in [TIME] + - anyio==4.0.0 + + anyio==3.7.1 + - example==0.0.0 (from [WORKSPACE_DIR]) + + example==0.0.0 (from [WORKSPACE_DIR]) + "### + ); + + Ok(()) +}