From 70219fcd9f64c7eb53c678808fd753348eca8d8d Mon Sep 17 00:00:00 2001 From: konstin Date: Tue, 28 Oct 2025 12:41:49 +0100 Subject: [PATCH 1/2] Don't allow local versions when a non-local version is pinned See https://github.com/astral-sh/uv/issues/16368 Current behavior of `uv sync`: * Requested: `1.0.0+cpu`, Installed: `1.0.0`: Install new package * Requested: `1.0.0+cpu`, Installed: `1.0.0+cu128`: Install new package * Requested: `1.0.0`, Installed: `1.0.0+cpu`: Keep installed package The new behavior is to always reinstall when the local version part is different, for consistency and to fix torch. This behavior happens because internally, we translate the version from the lockfile to an `=={version}` request, and version matching says local versions are allowed if the specifier has no local version. This is a (minor) behavior change: When running `uv sync` in a venv with a package after installing the same package with the same version except the local version, uv will now remove the installed package and use the one from the lockfile instead. This seems more correct, as the other version was not matching the lockfile. There is still a gap as what we actually want to track is the index URL (or even better, the package hash), but as that information is not tracked in the venv, checking the local version is the next bests thing we can do. The main motivation is fixing torch, our main user of packages with both local and non-local versions (https://github.com/astral-sh/uv/issues/16368). --- .../uv-distribution-types/src/requirement.rs | 87 ++++++++-- .../uv-distribution-types/src/resolution.rs | 15 +- .../uv-distribution/src/metadata/lowering.rs | 7 +- crates/uv-installer/src/plan.rs | 1 + crates/uv-resolver/src/prerelease.rs | 1 + .../uv-resolver/src/pubgrub/dependencies.rs | 11 +- crates/uv-resolver/src/yanks.rs | 1 + crates/uv-types/src/hash.rs | 32 ++-- crates/uv/src/commands/tool/install.rs | 12 +- crates/uv/src/commands/tool/run.rs | 12 +- crates/uv/src/commands/tool/upgrade.rs | 15 +- crates/uv/tests/it/lock.rs | 4 +- crates/uv/tests/it/show_settings.rs | 24 ++- crates/uv/tests/it/sync.rs | 158 ++++++++++++++++++ 14 files changed, 306 insertions(+), 74 deletions(-) diff --git a/crates/uv-distribution-types/src/requirement.rs b/crates/uv-distribution-types/src/requirement.rs index 25676d999ecd6..1999ae708b897 100644 --- a/crates/uv-distribution-types/src/requirement.rs +++ b/crates/uv-distribution-types/src/requirement.rs @@ -9,7 +9,7 @@ use uv_distribution_filename::DistExtension; use uv_fs::{CWD, PortablePath, PortablePathBuf, relative_to}; use uv_git_types::{GitOid, GitReference, GitUrl, GitUrlParseError, OidParseError}; use uv_normalize::{ExtraName, GroupName, PackageName}; -use uv_pep440::VersionSpecifiers; +use uv_pep440::{Version, VersionSpecifier, VersionSpecifiers}; use uv_pep508::{ MarkerEnvironment, MarkerTree, RequirementOrigin, VerbatimUrl, VersionOrUrl, marker, }; @@ -201,9 +201,9 @@ impl From for uv_pep508::Requirement { marker: requirement.marker, origin: requirement.origin, version_or_url: match requirement.source { - RequirementSource::Registry { specifier, .. } => { - Some(VersionOrUrl::VersionSpecifier(specifier)) - } + RequirementSource::Registry { specifier, .. } => Some( + VersionOrUrl::VersionSpecifier(specifier.to_version_specifiers()), + ), RequirementSource::Url { url, .. } | RequirementSource::Git { url, .. } | RequirementSource::Path { url, .. } @@ -222,9 +222,9 @@ impl From for uv_pep508::Requirement { marker: requirement.marker, origin: requirement.origin, version_or_url: match requirement.source { - RequirementSource::Registry { specifier, .. } => { - Some(VersionOrUrl::VersionSpecifier(specifier)) - } + RequirementSource::Registry { specifier, .. } => Some( + VersionOrUrl::VersionSpecifier(specifier.to_version_specifiers()), + ), RequirementSource::Url { location, subdirectory, @@ -285,13 +285,13 @@ impl From> for Requirement { fn from(requirement: uv_pep508::Requirement) -> Self { let source = match requirement.version_or_url { None => RequirementSource::Registry { - specifier: VersionSpecifiers::empty(), + specifier: VersionSpecifiersOrExact::VersionSpecifiers(VersionSpecifiers::empty()), index: None, conflict: None, }, // The most popular case: just a name, a version range and maybe extras. Some(VersionOrUrl::VersionSpecifier(specifier)) => RequirementSource::Registry { - specifier, + specifier: VersionSpecifiersOrExact::VersionSpecifiers(specifier), index: None, conflict: None, }, @@ -393,6 +393,9 @@ impl CacheKey for Requirement { conflict: _, } => { 0u8.cache_key(state); + // TODO(konsti): We should use the information whether this is an exact specifier + // or not here, but this changes the cache. + let specifier = specifier.to_version_specifiers(); specifier.len().cache_key(state); for spec in specifier.iter() { spec.operator().as_str().cache_key(state); @@ -466,6 +469,54 @@ impl CacheKey for Requirement { } } +/// A local version aware version specifier. +/// +/// For checking installed requirements against a lockfile, we want to check that the local version +/// matches, too. When checking whether the specifier `=={version}` contains `{version}+{local}`, +/// the check will pass, which we avoid by doing an exact version comparison instead. An example +/// is torch `2.9.0` in the lockfile vs. `2.9.0+cu128` installed. +#[derive(Hash, Debug, Clone, Eq, PartialEq, Ord, PartialOrd)] +pub enum VersionSpecifiersOrExact { + VersionSpecifiers(VersionSpecifiers), + Exact(Version), +} + +impl VersionSpecifiersOrExact { + /// Note that this conversion is lossy wrt to local version matching. + pub fn to_version_specifiers(&self) -> VersionSpecifiers { + match self { + Self::VersionSpecifiers(specifier) => specifier.clone(), + Self::Exact(version) => { + VersionSpecifiers::from(VersionSpecifier::equals_version(version.clone())) + } + } + } + + pub fn is_empty(&self) -> bool { + match self { + Self::VersionSpecifiers(specifier) => specifier.is_empty(), + Self::Exact(_) => false, + } + } + + pub fn contains(&self, other: &Version) -> bool { + match self { + Self::VersionSpecifiers(specifiers) => specifiers.contains(other), + // Compare including the version specifier + Self::Exact(version) => version == other, + } + } +} + +impl Display for VersionSpecifiersOrExact { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::VersionSpecifiers(specifiers) => Display::fmt(specifiers, f), + Self::Exact(version) => write!(f, "=={}", version), + } + } +} + /// The different locations with can install a distribution from: Version specifier (from an index), /// HTTP(S) URL, git repository, and path. /// @@ -479,7 +530,7 @@ impl CacheKey for Requirement { pub enum RequirementSource { /// The requirement has a version specifier, such as `foo >1,<2`. Registry { - specifier: VersionSpecifiers, + specifier: VersionSpecifiersOrExact, /// Choose a version from the index at the given URL. index: Option, /// The conflict item associated with the source, if any. @@ -635,7 +686,9 @@ impl RequirementSource { if specifier.is_empty() { None } else { - Some(VersionOrUrl::VersionSpecifier(specifier.clone())) + Some(VersionOrUrl::VersionSpecifier( + specifier.to_version_specifiers(), + )) } } Self::Url { .. } | Self::Git { .. } | Self::Path { .. } | Self::Directory { .. } => { @@ -666,9 +719,9 @@ impl RequirementSource { } /// If the source is the registry, return the version specifiers - pub fn version_specifiers(&self) -> Option<&VersionSpecifiers> { + pub fn version_specifiers(&self) -> Option { match self { - Self::Registry { specifier, .. } => Some(specifier), + Self::Registry { specifier, .. } => Some(specifier.to_version_specifiers()), Self::Url { .. } | Self::Git { .. } | Self::Path { .. } | Self::Directory { .. } => { None } @@ -817,7 +870,7 @@ impl From for RequirementSourceWire { index }); Self::Registry { - specifier, + specifier: specifier.to_version_specifiers(), index, conflict, } @@ -922,7 +975,7 @@ impl TryFrom for RequirementSource { index, conflict, } => Ok(Self::Registry { - specifier, + specifier: VersionSpecifiersOrExact::VersionSpecifiers(specifier), index: index .map(|index| IndexMetadata::from(IndexUrl::from(VerbatimUrl::from_url(index)))), conflict, @@ -1049,7 +1102,7 @@ mod tests { use uv_pep508::{MarkerTree, VerbatimUrl}; - use crate::{Requirement, RequirementSource}; + use crate::{Requirement, RequirementSource, VersionSpecifiersOrExact}; #[test] fn roundtrip() { @@ -1059,7 +1112,7 @@ mod tests { groups: Box::new([]), marker: MarkerTree::TRUE, source: RequirementSource::Registry { - specifier: ">1,<2".parse().unwrap(), + specifier: VersionSpecifiersOrExact::VersionSpecifiers(">1,<2".parse().unwrap()), index: None, conflict: None, }, diff --git a/crates/uv-distribution-types/src/resolution.rs b/crates/uv-distribution-types/src/resolution.rs index e11d194cfa30e..be14b9ac4e9bf 100644 --- a/crates/uv-distribution-types/src/resolution.rs +++ b/crates/uv-distribution-types/src/resolution.rs @@ -4,6 +4,7 @@ use uv_pypi_types::{HashDigest, HashDigests}; use crate::{ BuiltDist, Diagnostic, Dist, IndexMetadata, Name, RequirementSource, ResolvedDist, SourceDist, + VersionSpecifiersOrExact, }; /// A set of packages pinned at specific versions. @@ -216,11 +217,7 @@ impl From<&ResolvedDist> for RequirementSource { Dist::Built(BuiltDist::Registry(wheels)) => { let wheel = wheels.best_wheel(); Self::Registry { - specifier: uv_pep440::VersionSpecifiers::from( - uv_pep440::VersionSpecifier::equals_version( - wheel.filename.version.clone(), - ), - ), + specifier: VersionSpecifiersOrExact::Exact(wheel.filename.version.clone()), index: Some(IndexMetadata::from(wheel.index.clone())), conflict: None, } @@ -241,9 +238,7 @@ impl From<&ResolvedDist> for RequirementSource { ext: DistExtension::Wheel, }, Dist::Source(SourceDist::Registry(sdist)) => Self::Registry { - specifier: uv_pep440::VersionSpecifiers::from( - uv_pep440::VersionSpecifier::equals_version(sdist.version.clone()), - ), + specifier: VersionSpecifiersOrExact::Exact(sdist.version.clone()), index: Some(IndexMetadata::from(sdist.index.clone())), conflict: None, }, @@ -275,9 +270,7 @@ impl From<&ResolvedDist> for RequirementSource { }, }, ResolvedDist::Installed { dist } => Self::Registry { - specifier: uv_pep440::VersionSpecifiers::from( - uv_pep440::VersionSpecifier::equals_version(dist.version().clone()), - ), + specifier: VersionSpecifiersOrExact::Exact(dist.version().clone()), index: None, conflict: None, }, diff --git a/crates/uv-distribution/src/metadata/lowering.rs b/crates/uv-distribution/src/metadata/lowering.rs index 627e2d4dd14a6..c0d46455d125e 100644 --- a/crates/uv-distribution/src/metadata/lowering.rs +++ b/crates/uv-distribution/src/metadata/lowering.rs @@ -8,6 +8,7 @@ use thiserror::Error; use uv_distribution_filename::DistExtension; use uv_distribution_types::{ Index, IndexLocations, IndexMetadata, IndexName, Origin, Requirement, RequirementSource, + VersionSpecifiersOrExact, }; use uv_git_types::{GitReference, GitUrl, GitUrlParseError}; use uv_normalize::{ExtraName, GroupName, PackageName}; @@ -659,17 +660,17 @@ fn registry_source( ) -> RequirementSource { match &requirement.version_or_url { None => RequirementSource::Registry { - specifier: VersionSpecifiers::empty(), + specifier: VersionSpecifiersOrExact::VersionSpecifiers(VersionSpecifiers::empty()), index: Some(index), conflict, }, Some(VersionOrUrl::VersionSpecifier(version)) => RequirementSource::Registry { - specifier: version.clone(), + specifier: VersionSpecifiersOrExact::VersionSpecifiers(version.clone()), index: Some(index), conflict, }, Some(VersionOrUrl::Url(_)) => RequirementSource::Registry { - specifier: VersionSpecifiers::empty(), + specifier: VersionSpecifiersOrExact::VersionSpecifiers(VersionSpecifiers::empty()), index: Some(index), conflict, }, diff --git a/crates/uv-installer/src/plan.rs b/crates/uv-installer/src/plan.rs index 1941a83719a47..aa649bfce8f1f 100644 --- a/crates/uv-installer/src/plan.rs +++ b/crates/uv-installer/src/plan.rs @@ -122,6 +122,7 @@ impl<'a> Planner<'a> { [] => {} [installed] => { let source = RequirementSource::from(dist); + match RequirementSatisfaction::check( dist.name(), installed, diff --git a/crates/uv-resolver/src/prerelease.rs b/crates/uv-resolver/src/prerelease.rs index d1d1181ee5e44..8e8c109f180c1 100644 --- a/crates/uv-resolver/src/prerelease.rs +++ b/crates/uv-resolver/src/prerelease.rs @@ -83,6 +83,7 @@ impl PrereleaseStrategy { }; if specifier + .to_version_specifiers() .iter() .filter(|spec| { !matches!(spec.operator(), Operator::NotEqual | Operator::NotEqualStar) diff --git a/crates/uv-resolver/src/pubgrub/dependencies.rs b/crates/uv-resolver/src/pubgrub/dependencies.rs index 7ef848ab26a23..9beb81258bc28 100644 --- a/crates/uv-resolver/src/pubgrub/dependencies.rs +++ b/crates/uv-resolver/src/pubgrub/dependencies.rs @@ -191,7 +191,12 @@ impl PubGrubRequirement { ) -> Self { let (verbatim_url, parsed_url) = match &requirement.source { RequirementSource::Registry { specifier, .. } => { - return Self::from_registry_requirement(specifier, extra, group, requirement); + return Self::from_registry_requirement( + specifier.to_version_specifiers(), + extra, + group, + requirement, + ); } RequirementSource::Url { subdirectory, @@ -259,7 +264,7 @@ impl PubGrubRequirement { } fn from_registry_requirement( - specifier: &VersionSpecifiers, + specifier: VersionSpecifiers, extra: Option, group: Option, requirement: &Requirement, @@ -272,7 +277,7 @@ impl PubGrubRequirement { requirement.marker, ), url: None, - version: Ranges::from(specifier.clone()), + version: Ranges::from(specifier), } } } diff --git a/crates/uv-resolver/src/yanks.rs b/crates/uv-resolver/src/yanks.rs index f8cb27d8a2547..3173868104634 100644 --- a/crates/uv-resolver/src/yanks.rs +++ b/crates/uv-resolver/src/yanks.rs @@ -26,6 +26,7 @@ impl AllowedYanks { let RequirementSource::Registry { specifier, .. } = &requirement.source else { continue; }; + let specifier = specifier.to_version_specifiers(); let [specifier] = specifier.as_ref() else { continue; }; diff --git a/crates/uv-types/src/hash.rs b/crates/uv-types/src/hash.rs index 9d48c8221bc0a..ca60286b4b1f6 100644 --- a/crates/uv-types/src/hash.rs +++ b/crates/uv-types/src/hash.rs @@ -6,7 +6,7 @@ use rustc_hash::FxHashMap; use uv_configuration::HashCheckingMode; use uv_distribution_types::{ DistributionMetadata, HashGeneration, HashPolicy, Name, Requirement, RequirementSource, - Resolution, UnresolvedRequirement, VersionId, + Resolution, UnresolvedRequirement, VersionId, VersionSpecifiersOrExact, }; use uv_normalize::PackageName; use uv_pep440::Version; @@ -301,19 +301,27 @@ impl HashStrategy { match &requirement.source { RequirementSource::Registry { specifier, .. } => { // Must be a single specifier. - let [specifier] = specifier.as_ref() else { - return None; - }; + match specifier { + VersionSpecifiersOrExact::VersionSpecifiers(specifier) => { + let [specifier] = specifier.as_ref() else { + return None; + }; - // Must be pinned to a specific version. - if *specifier.operator() != uv_pep440::Operator::Equal { - return None; - } + // Must be pinned to a specific version. + if *specifier.operator() != uv_pep440::Operator::Equal { + return None; + } - Some(VersionId::from_registry( - requirement.name.clone(), - specifier.version().clone(), - )) + Some(VersionId::from_registry( + requirement.name.clone(), + specifier.version().clone(), + )) + } + VersionSpecifiersOrExact::Exact(version) => Some(VersionId::from_registry( + requirement.name.clone(), + version.clone(), + )), + } } RequirementSource::Url { url, .. } | RequirementSource::Git { url, .. } diff --git a/crates/uv/src/commands/tool/install.rs b/crates/uv/src/commands/tool/install.rs index 0f35ad70ce0bb..6450f531c0f66 100644 --- a/crates/uv/src/commands/tool/install.rs +++ b/crates/uv/src/commands/tool/install.rs @@ -12,11 +12,11 @@ use uv_configuration::{Concurrency, Constraints, DryRun, Reinstall, TargetTriple use uv_distribution::LoweredExtraBuildDependencies; use uv_distribution_types::{ ExtraBuildRequires, NameRequirementSpecification, Requirement, RequirementSource, - UnresolvedRequirementSpecification, + UnresolvedRequirementSpecification, VersionSpecifiersOrExact, }; use uv_installer::{InstallationStrategy, SatisfiesResult, SitePackages}; use uv_normalize::PackageName; -use uv_pep440::{VersionSpecifier, VersionSpecifiers}; +use uv_pep440::VersionSpecifiers; use uv_pep508::MarkerTree; use uv_preview::Preview; use uv_python::{ @@ -180,9 +180,7 @@ pub(crate) async fn install( groups: Box::new([]), marker: MarkerTree::default(), source: RequirementSource::Registry { - specifier: VersionSpecifiers::from(VersionSpecifier::equals_version( - version.clone(), - )), + specifier: VersionSpecifiersOrExact::Exact(version.clone()), index: None, conflict: None, }, @@ -204,7 +202,9 @@ pub(crate) async fn install( groups: Box::new([]), marker: MarkerTree::default(), source: RequirementSource::Registry { - specifier: VersionSpecifiers::empty(), + specifier: VersionSpecifiersOrExact::VersionSpecifiers( + VersionSpecifiers::empty(), + ), index: None, conflict: None, }, diff --git a/crates/uv/src/commands/tool/run.rs b/crates/uv/src/commands/tool/run.rs index 918d014f9bcca..c49bc64414933 100644 --- a/crates/uv/src/commands/tool/run.rs +++ b/crates/uv/src/commands/tool/run.rs @@ -20,15 +20,15 @@ use uv_configuration::Concurrency; use uv_configuration::Constraints; use uv_configuration::TargetTriple; use uv_distribution::LoweredExtraBuildDependencies; -use uv_distribution_types::InstalledDist; use uv_distribution_types::{ IndexUrl, Name, NameRequirementSpecification, Requirement, RequirementSource, UnresolvedRequirement, UnresolvedRequirementSpecification, }; +use uv_distribution_types::{InstalledDist, VersionSpecifiersOrExact}; use uv_fs::Simplified; use uv_installer::{InstallationStrategy, SatisfiesResult, SitePackages}; use uv_normalize::PackageName; -use uv_pep440::{VersionSpecifier, VersionSpecifiers}; +use uv_pep440::VersionSpecifiers; use uv_pep508::MarkerTree; use uv_preview::Preview; use uv_python::{ @@ -830,9 +830,7 @@ async fn get_or_create_environment( groups: Box::new([]), marker: MarkerTree::default(), source: RequirementSource::Registry { - specifier: VersionSpecifiers::from(VersionSpecifier::equals_version( - version.clone(), - )), + specifier: VersionSpecifiersOrExact::Exact(version.clone()), index: None, conflict: None, }, @@ -852,7 +850,9 @@ async fn get_or_create_environment( groups: Box::new([]), marker: MarkerTree::default(), source: RequirementSource::Registry { - specifier: VersionSpecifiers::empty(), + specifier: VersionSpecifiersOrExact::VersionSpecifiers( + VersionSpecifiers::empty(), + ), index: None, conflict: None, }, diff --git a/crates/uv/src/commands/tool/upgrade.rs b/crates/uv/src/commands/tool/upgrade.rs index 330673c244987..4663e64b452ea 100644 --- a/crates/uv/src/commands/tool/upgrade.rs +++ b/crates/uv/src/commands/tool/upgrade.rs @@ -475,14 +475,13 @@ fn pinned_version_from(requirements: &[Requirement], name: &PackageName) -> Opti .iter() .filter(|requirement| requirement.name == *name) .find_map(|requirement| match &requirement.source { - RequirementSource::Registry { specifier, .. } => { - specifier - .iter() - .find_map(|specifier| match specifier.operator() { - Operator::Equal | Operator::ExactEqual => Some(specifier.version().clone()), - _ => None, - }) - } + RequirementSource::Registry { specifier, .. } => specifier + .to_version_specifiers() + .iter() + .find_map(|specifier| match specifier.operator() { + Operator::Equal | Operator::ExactEqual => Some(specifier.version().clone()), + _ => None, + }), _ => None, }) } diff --git a/crates/uv/tests/it/lock.rs b/crates/uv/tests/it/lock.rs index b9d54bd44d0ba..cb9dd2a60dd75 100644 --- a/crates/uv/tests/it/lock.rs +++ b/crates/uv/tests/it/lock.rs @@ -17767,8 +17767,8 @@ fn lock_explicit_default_index() -> Result<()> { DEBUG Found static `pyproject.toml` for: project @ file://[TEMP_DIR]/ DEBUG No workspace root found, using project root DEBUG Resolving despite existing lockfile due to mismatched requirements for: `project==0.1.0` - Requested: {Requirement { name: PackageName("anyio"), extras: [], groups: [], marker: true, source: Registry { specifier: VersionSpecifiers([]), index: None, conflict: None }, origin: None }} - Existing: {Requirement { name: PackageName("iniconfig"), extras: [], groups: [], marker: true, source: Registry { specifier: VersionSpecifiers([VersionSpecifier { operator: Equal, version: "2.0.0" }]), index: Some(IndexMetadata { url: Url(VerbatimUrl { url: DisplaySafeUrl { scheme: "https", cannot_be_a_base: false, username: "", password: None, host: Some(Domain("test.pypi.org")), port: None, path: "/simple", query: None, fragment: None }, given: None }), format: Simple }), conflict: None }, origin: None }} + Requested: {Requirement { name: PackageName("anyio"), extras: [], groups: [], marker: true, source: Registry { specifier: VersionSpecifiers(VersionSpecifiers([])), index: None, conflict: None }, origin: None }} + Existing: {Requirement { name: PackageName("iniconfig"), extras: [], groups: [], marker: true, source: Registry { specifier: VersionSpecifiers(VersionSpecifiers([VersionSpecifier { operator: Equal, version: "2.0.0" }])), index: Some(IndexMetadata { url: Url(VerbatimUrl { url: DisplaySafeUrl { scheme: "https", cannot_be_a_base: false, username: "", password: None, host: Some(Domain("test.pypi.org")), port: None, path: "/simple", query: None, fragment: None }, given: None }), format: Simple }), conflict: None }, origin: None }} DEBUG Solving with installed Python version: 3.12.[X] DEBUG Solving with target Python version: >=3.12 DEBUG Adding direct dependency: project* diff --git a/crates/uv/tests/it/show_settings.rs b/crates/uv/tests/it/show_settings.rs index 9ee19e5c41847..ca122b94cb8b6 100644 --- a/crates/uv/tests/it/show_settings.rs +++ b/crates/uv/tests/it/show_settings.rs @@ -8786,7 +8786,9 @@ fn upgrade_pip_cli_config_interaction() -> anyhow::Result<()> { marker: true, source: Registry { specifier: VersionSpecifiers( - [], + VersionSpecifiers( + [], + ), ), index: None, conflict: None, @@ -9492,7 +9494,9 @@ fn upgrade_pip_cli_config_interaction() -> anyhow::Result<()> { marker: true, source: Registry { specifier: VersionSpecifiers( - [], + VersionSpecifiers( + [], + ), ), index: None, conflict: None, @@ -9512,7 +9516,9 @@ fn upgrade_pip_cli_config_interaction() -> anyhow::Result<()> { marker: true, source: Registry { specifier: VersionSpecifiers( - [], + VersionSpecifiers( + [], + ), ), index: None, conflict: None, @@ -9786,7 +9792,9 @@ fn upgrade_project_cli_config_interaction() -> anyhow::Result<()> { marker: true, source: Registry { specifier: VersionSpecifiers( - [], + VersionSpecifiers( + [], + ), ), index: None, conflict: None, @@ -10260,7 +10268,9 @@ fn upgrade_project_cli_config_interaction() -> anyhow::Result<()> { marker: true, source: Registry { specifier: VersionSpecifiers( - [], + VersionSpecifiers( + [], + ), ), index: None, conflict: None, @@ -10280,7 +10290,9 @@ fn upgrade_project_cli_config_interaction() -> anyhow::Result<()> { marker: true, source: Registry { specifier: VersionSpecifiers( - [], + VersionSpecifiers( + [], + ), ), index: None, conflict: None, diff --git a/crates/uv/tests/it/sync.rs b/crates/uv/tests/it/sync.rs index 566011a9eb826..7ceb39259a2ac 100644 --- a/crates/uv/tests/it/sync.rs +++ b/crates/uv/tests/it/sync.rs @@ -14362,3 +14362,161 @@ fn sync_no_sources_editable_to_package_switch() -> Result<()> { Ok(()) } + +/// Test that switching between indexes with local and non-local versions of the same package, +/// the package is always reinstalled, not only when switching from a non-local to a local version. +/// +/// +#[test] +fn install_non_local_version_when_local_version_is_installed() -> Result<()> { + let start = std::time::Instant::now(); + dbg!(start.elapsed()); + let context = TestContext::new("3.12"); + dbg!(start.elapsed()); + + context.temp_dir.child("pyproject.toml").write_str( + r#" + [project] + name = "main" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [] + + [project.optional-dependencies] + non-local = ["project"] + local = ["project"] + + [tool.uv] + conflicts = [ + [ + { extra = "non-local" }, + { extra = "local" }, + ], + ] + + [tool.uv.sources] + project = [ + { index = "non-local", extra = "non-local" }, + { index = "local", extra = "local" }, + ] + + [[tool.uv.index]] + name = "non-local" + url = "./non-local/dist" + format = "flat" + explicit = true + + [[tool.uv.index]] + name = "local" + url = "./local/dist" + format = "flat" + explicit = true + "#, + )?; + dbg!(start.elapsed()); + + context + .init() + .arg("--lib") + .arg("--no-workspace") + .arg("--name") + .arg("project") + .arg("local") + .assert() + .success(); + dbg!(start.elapsed()); + context + .version() + .arg("1.0.0+local") + .current_dir(context.temp_dir.child("local").as_ref()) + .assert() + .success(); + dbg!(start.elapsed()); + context + .build() + .arg("--wheel") + .current_dir(context.temp_dir.child("local").as_ref()) + .assert() + .success(); + dbg!(start.elapsed()); + context + .init() + .arg("--lib") + .arg("--no-workspace") + .arg("--name") + .arg("project") + .arg("non-local") + .assert() + .success(); + dbg!(start.elapsed()); + context + .version() + .arg("1.0.0") + .current_dir(context.temp_dir.child("non-local").as_ref()) + .assert() + .success(); + dbg!(start.elapsed()); + context + .build() + .arg("--wheel") + .current_dir(context.temp_dir.child("non-local").as_ref()) + .assert() + .success(); + dbg!(start.elapsed()); + + // Install the non-local version. + uv_snapshot!(context.filters(), context.sync().arg("--extra").arg("non-local"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 3 packages in [TIME] + Prepared 1 package in [TIME] + Installed 1 package in [TIME] + + project==1.0.0 + "); + dbg!(start.elapsed()); + // Check that switching to the local version + uv_snapshot!(context.filters(), context.sync().arg("--extra").arg("local"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 3 packages in [TIME] + Prepared 1 package in [TIME] + Uninstalled 1 package in [TIME] + Installed 1 package in [TIME] + - project==1.0.0 + + project==1.0.0+local + "); + // Keeping the local version is a noop. + dbg!(start.elapsed()); + uv_snapshot!(context.filters(), context.sync().arg("--extra").arg("local"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 3 packages in [TIME] + Audited 1 package in [TIME] + "); + dbg!(start.elapsed()); + // Check that switching to the non-local version invalidates the installed local version. + uv_snapshot!(context.filters(), context.sync().arg("--extra").arg("non-local"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 3 packages in [TIME] + Uninstalled 1 package in [TIME] + Installed 1 package in [TIME] + - project==1.0.0+local + + project==1.0.0 + "); + dbg!(start.elapsed()); + + Ok(()) +} From 81e2c1027840bab5f9f943214304743c40a7af16 Mon Sep 17 00:00:00 2001 From: konstin Date: Tue, 28 Oct 2025 13:30:19 +0100 Subject: [PATCH 2/2] clippy --- crates/uv-distribution-types/src/requirement.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/uv-distribution-types/src/requirement.rs b/crates/uv-distribution-types/src/requirement.rs index 1999ae708b897..4de012ba583b7 100644 --- a/crates/uv-distribution-types/src/requirement.rs +++ b/crates/uv-distribution-types/src/requirement.rs @@ -512,7 +512,7 @@ impl Display for VersionSpecifiersOrExact { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::VersionSpecifiers(specifiers) => Display::fmt(specifiers, f), - Self::Exact(version) => write!(f, "=={}", version), + Self::Exact(version) => write!(f, "=={version}"), } } }