Skip to content
3 changes: 2 additions & 1 deletion crates/uv-dispatch/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ use uv_distribution_types::{
PackageConfigSettings, Requirement, Resolution, SourceDist, VersionOrUrlRef,
};
use uv_git::GitResolver;
use uv_installer::{Installer, Plan, Planner, Preparer, SitePackages};
use uv_installer::{InstallationStrategy, Installer, Plan, Planner, Preparer, SitePackages};
use uv_preview::Preview;
use uv_pypi_types::Conflicts;
use uv_python::{Interpreter, PythonEnvironment};
Expand Down Expand Up @@ -316,6 +316,7 @@ impl BuildContext for BuildDispatch<'_> {
extraneous: _,
} = Planner::new(resolution).build(
site_packages,
InstallationStrategy::Permissive,
&Reinstall::default(),
self.build_options,
self.hasher,
Expand Down
4 changes: 3 additions & 1 deletion crates/uv-installer/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ pub use compile::{CompileError, compile_tree};
pub use installer::{Installer, Reporter as InstallReporter};
pub use plan::{Plan, Planner};
pub use preparer::{Error as PrepareError, Preparer, Reporter as PrepareReporter};
pub use site_packages::{SatisfiesResult, SitePackages, SitePackagesDiagnostic};
pub use site_packages::{
InstallationStrategy, SatisfiesResult, SitePackages, SitePackagesDiagnostic,
};
pub use uninstall::{UninstallError, uninstall};

mod compile;
Expand Down
4 changes: 3 additions & 1 deletion crates/uv-installer/src/plan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ use uv_pypi_types::VerbatimParsedUrl;
use uv_python::PythonEnvironment;
use uv_types::HashStrategy;

use crate::SitePackages;
use crate::satisfies::RequirementSatisfaction;
use crate::{InstallationStrategy, SitePackages};

/// A planner to generate an [`Plan`] based on a set of requirements.
#[derive(Debug)]
Expand Down Expand Up @@ -52,6 +52,7 @@ impl<'a> Planner<'a> {
pub fn build(
self,
mut site_packages: SitePackages,
installation: InstallationStrategy,
reinstall: &Reinstall,
build_options: &BuildOptions,
hasher: &HashStrategy,
Expand Down Expand Up @@ -125,6 +126,7 @@ impl<'a> Planner<'a> {
dist.name(),
installed,
&source,
installation,
tags,
config_settings,
config_settings_package,
Expand Down
23 changes: 23 additions & 0 deletions crates/uv-installer/src/satisfies.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ use uv_normalize::PackageName;
use uv_platform_tags::{IncompatibleTag, TagCompatibility, Tags};
use uv_pypi_types::{DirInfo, DirectUrl, VcsInfo, VcsKind};

use crate::InstallationStrategy;

#[derive(Debug, Copy, Clone)]
pub(crate) enum RequirementSatisfaction {
Mismatch,
Expand All @@ -35,6 +37,7 @@ impl RequirementSatisfaction {
name: &PackageName,
distribution: &InstalledDist,
source: &RequirementSource,
installation: InstallationStrategy,
tags: &Tags,
config_settings: &ConfigSettings,
config_settings_package: &PackageConfigSettings,
Expand Down Expand Up @@ -67,6 +70,26 @@ impl RequirementSatisfaction {
match source {
// If the requirement comes from a registry, check by name.
RequirementSource::Registry { specifier, .. } => {
// If the installed distribution is _not_ from a registry, reject it if and only if
// we're in a stateless install.
//
// For example: the `uv pip` CLI is stateful, in that it "respects"
// already-installed packages in the virtual environment. So if you run `uv pip
// install ./path/to/idna`, and then `uv pip install anyio` (which depends on
// `idna`), we'll "accept" the already-installed `idna` even though it is implicitly
// being "required" as a registry package.
//
// The `uv sync` CLI is stateless, in that all requirements must be defined
// declaratively ahead-of-time. So if you `uv sync` to install `./path/to/idna` and
// later `uv sync` to install `anyio`, we'll know (during that second sync) if the
// already-installed `idna` should come from the registry or not.
if installation == InstallationStrategy::Strict {
if !matches!(distribution.kind, InstalledDistKind::Registry { .. }) {
debug!("Distribution type mismatch for {name}: {distribution:?}");
return Self::Mismatch;
}
}

if !specifier.contains(distribution.version()) {
return Self::Mismatch;
}
Expand Down
26 changes: 26 additions & 0 deletions crates/uv-installer/src/site_packages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,7 @@ impl SitePackages {
requirements: &[UnresolvedRequirementSpecification],
constraints: &[NameRequirementSpecification],
overrides: &[UnresolvedRequirementSpecification],
installation: InstallationStrategy,
markers: &ResolverMarkerEnvironment,
tags: &Tags,
config_settings: &ConfigSettings,
Expand Down Expand Up @@ -404,6 +405,7 @@ impl SitePackages {
requirements.iter().map(Cow::as_ref),
constraints.iter().map(|constraint| &constraint.requirement),
overrides.iter().map(Cow::as_ref),
installation,
markers,
tags,
config_settings,
Expand All @@ -419,6 +421,7 @@ impl SitePackages {
requirements: impl ExactSizeIterator<Item = &'a Requirement>,
constraints: impl Iterator<Item = &'a Requirement>,
overrides: impl Iterator<Item = &'a Requirement>,
installation: InstallationStrategy,
markers: &ResolverMarkerEnvironment,
tags: &Tags,
config_settings: &ConfigSettings,
Expand Down Expand Up @@ -482,6 +485,7 @@ impl SitePackages {
name,
distribution,
&requirement.source,
installation,
tags,
config_settings,
config_settings_package,
Expand All @@ -504,6 +508,7 @@ impl SitePackages {
name,
distribution,
&constraint.source,
installation,
tags,
config_settings,
config_settings_package,
Expand Down Expand Up @@ -560,6 +565,27 @@ impl SitePackages {
}
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InstallationStrategy {
/// A permissive installation strategy, which accepts existing installations even if the source
/// type differs, as in the `pip` and `uv pip` CLIs.
///
/// In this strategy, packages that are already installed in the environment may be reused if
/// they implicitly match the requirements. For example, if the user installs `./path/to/idna`,
/// then runs `uv pip install anyio` (which depends on `idna`), the existing `idna` installation
/// will be reused if its version matches the requirement, even though it was installed from a
/// path and is being implicitly requested from a registry.
Permissive,

/// A strict installation strategy, which requires that existing installations match the source
/// type, as in the `uv sync` CLI.
///
/// This strategy enforces that the installation source must match the requirement source.
/// It prevents reusing packages that were installed from different sources, ensuring
/// declarative and reproducible environments.
Strict,
}

/// We check if all requirements are already satisfied, recursing through the requirements tree.
#[derive(Debug)]
pub enum SatisfiesResult {
Expand Down
28 changes: 25 additions & 3 deletions crates/uv-resolver/src/candidate_selector.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@ use pubgrub::Range;
use smallvec::SmallVec;
use tracing::{debug, trace};

use uv_configuration::IndexStrategy;
use uv_distribution_types::{CompatibleDist, IncompatibleDist, IncompatibleSource, IndexUrl};
use uv_configuration::{IndexStrategy, SourceStrategy};
use uv_distribution_types::{
CompatibleDist, IncompatibleDist, IncompatibleSource, IndexUrl, InstalledDistKind,
};
use uv_distribution_types::{DistributionMetadata, IncompatibleWheel, Name, PrioritizedDist};
use uv_normalize::PackageName;
use uv_pep440::Version;
Expand All @@ -26,6 +28,7 @@ pub(crate) struct CandidateSelector {
resolution_strategy: ResolutionStrategy,
prerelease_strategy: PrereleaseStrategy,
index_strategy: IndexStrategy,
source_strategy: SourceStrategy,
}

impl CandidateSelector {
Expand All @@ -34,6 +37,7 @@ impl CandidateSelector {
options: &Options,
manifest: &Manifest,
env: &ResolverEnvironment,
source_strategy: SourceStrategy,
) -> Self {
Self {
resolution_strategy: ResolutionStrategy::from_mode(
Expand All @@ -49,6 +53,7 @@ impl CandidateSelector {
options.dependency_mode,
),
index_strategy: options.index_strategy,
source_strategy,
}
}

Expand Down Expand Up @@ -119,7 +124,13 @@ impl CandidateSelector {
let installed = if reinstall {
None
} else {
Self::get_installed(package_name, range, installed_packages, tags)
Self::get_installed(
package_name,
range,
installed_packages,
tags,
self.source_strategy,
)
};

// If we're not upgrading, we should prefer the already-installed distribution.
Expand Down Expand Up @@ -369,6 +380,7 @@ impl CandidateSelector {
range: &Range<Version>,
installed_packages: &'a InstalledPackages,
tags: Option<&'a Tags>,
source_strategy: SourceStrategy,
) -> Option<Candidate<'a>> {
let installed_dists = installed_packages.get_packages(package_name);
match installed_dists.as_slice() {
Expand All @@ -381,6 +393,16 @@ impl CandidateSelector {
return None;
}

// When sources are disabled, only allow registry installations to be reused
if matches!(source_strategy, SourceStrategy::Disabled) {
if !matches!(dist.kind, InstalledDistKind::Registry(_)) {
debug!(
"Source strategy is disabled, rejecting non-registry installed distribution: {dist}"
);
return None;
}
}

// Verify that the installed distribution is compatible with the environment.
if tags.is_some_and(|tags| {
let Ok(Some(wheel_tags)) = dist.read_tags() else {
Expand Down
8 changes: 5 additions & 3 deletions crates/uv-resolver/src/resolver/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ use tokio::sync::oneshot;
use tokio_stream::wrappers::ReceiverStream;
use tracing::{Level, debug, info, instrument, trace, warn};

use uv_configuration::{Constraints, Overrides};
use uv_configuration::{Constraints, Overrides, SourceStrategy};
use uv_distribution::{ArchiveMetadata, DistributionDatabase};
use uv_distribution_types::{
BuiltDist, CompatibleDist, DerivationChain, Dist, DistErrorKind, DistributionMetadata,
Expand All @@ -36,6 +36,7 @@ use uv_pep508::{
};
use uv_platform_tags::Tags;
use uv_pypi_types::{ConflictItem, ConflictItemRef, ConflictKindRef, Conflicts, VerbatimParsedUrl};
use uv_torch::TorchStrategy;
use uv_types::{BuildContext, HashStrategy, InstalledPackagesProvider};
use uv_warnings::warn_user_once;

Expand Down Expand Up @@ -82,7 +83,6 @@ use crate::{
marker,
};
pub(crate) use provider::MetadataUnavailable;
use uv_torch::TorchStrategy;

mod availability;
mod batch_prefetch;
Expand Down Expand Up @@ -201,6 +201,7 @@ impl<'a, Context: BuildContext, InstalledPackages: InstalledPackagesProvider>
build_context.git(),
build_context.capabilities(),
build_context.locations(),
build_context.sources(),
provider,
installed_packages,
)
Expand All @@ -224,14 +225,15 @@ impl<Provider: ResolverProvider, InstalledPackages: InstalledPackagesProvider>
git: &GitResolver,
capabilities: &IndexCapabilities,
locations: &IndexLocations,
source_strategy: SourceStrategy,
provider: Provider,
installed_packages: InstalledPackages,
) -> Result<Self, ResolveError> {
let state = ResolverState {
index: index.clone(),
git: git.clone(),
capabilities: capabilities.clone(),
selector: CandidateSelector::for_resolution(&options, &manifest, &env),
selector: CandidateSelector::for_resolution(&options, &manifest, &env, source_strategy),
dependency_mode: options.dependency_mode,
urls: Urls::from_manifest(&manifest, &env, git, options.dependency_mode),
indexes: Indexes::from_manifest(&manifest, &env, options.dependency_mode),
Expand Down
4 changes: 3 additions & 1 deletion crates/uv/src/commands/pip/install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ use uv_distribution_types::{
};
use uv_fs::Simplified;
use uv_install_wheel::LinkMode;
use uv_installer::{SatisfiesResult, SitePackages};
use uv_installer::{InstallationStrategy, SatisfiesResult, SitePackages};
use uv_normalize::{DefaultExtras, DefaultGroups};
use uv_preview::{Preview, PreviewFeatures};
use uv_pypi_types::Conflicts;
Expand Down Expand Up @@ -289,6 +289,7 @@ pub(crate) async fn pip_install(
&requirements,
&constraints,
&overrides,
InstallationStrategy::Permissive,
&marker_env,
&tags,
config_settings,
Expand Down Expand Up @@ -602,6 +603,7 @@ pub(crate) async fn pip_install(
match operations::install(
&resolution,
site_packages,
InstallationStrategy::Permissive,
modifications,
&reinstall,
&build_options,
Expand Down
4 changes: 3 additions & 1 deletion crates/uv/src/commands/pip/operations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ use uv_distribution_types::{
use uv_distribution_types::{DistributionMetadata, InstalledMetadata, Name, Resolution};
use uv_fs::Simplified;
use uv_install_wheel::LinkMode;
use uv_installer::{Plan, Planner, Preparer, SitePackages};
use uv_installer::{InstallationStrategy, Plan, Planner, Preparer, SitePackages};
use uv_normalize::PackageName;
use uv_pep508::{MarkerEnvironment, RequirementOrigin};
use uv_platform_tags::Tags;
Expand Down Expand Up @@ -436,6 +436,7 @@ impl Changelog {
pub(crate) async fn install(
resolution: &Resolution,
site_packages: SitePackages,
installation: InstallationStrategy,
modifications: Modifications,
reinstall: &Reinstall,
build_options: &BuildOptions,
Expand All @@ -462,6 +463,7 @@ pub(crate) async fn install(
let plan = Planner::new(resolution)
.build(
site_packages,
installation,
reinstall,
build_options,
hasher,
Expand Down
3 changes: 2 additions & 1 deletion crates/uv/src/commands/pip/sync.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ use uv_distribution_types::{
};
use uv_fs::Simplified;
use uv_install_wheel::LinkMode;
use uv_installer::SitePackages;
use uv_installer::{InstallationStrategy, SitePackages};
use uv_normalize::{DefaultExtras, DefaultGroups};
use uv_preview::{Preview, PreviewFeatures};
use uv_pypi_types::Conflicts;
Expand Down Expand Up @@ -533,6 +533,7 @@ pub(crate) async fn pip_sync(
match operations::install(
&resolution,
site_packages,
InstallationStrategy::Permissive,
Modifications::Exact,
&reinstall,
&build_options,
Expand Down
5 changes: 4 additions & 1 deletion crates/uv/src/commands/project/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ use uv_distribution_types::{
};
use uv_fs::{CWD, LockedFile, Simplified};
use uv_git::ResolvedRepositoryReference;
use uv_installer::{SatisfiesResult, SitePackages};
use uv_installer::{InstallationStrategy, SatisfiesResult, SitePackages};
use uv_normalize::{DEV_DEPENDENCIES, DefaultGroups, ExtraName, GroupName, PackageName};
use uv_pep440::{TildeVersionSpecifier, Version, VersionSpecifiers};
use uv_pep508::MarkerTreeContents;
Expand Down Expand Up @@ -2124,6 +2124,7 @@ pub(crate) async fn sync_environment(
pip::operations::install(
resolution,
site_packages,
InstallationStrategy::Permissive,
modifications,
reinstall,
build_options,
Expand Down Expand Up @@ -2243,6 +2244,7 @@ pub(crate) async fn update_environment(
&requirements,
&constraints,
&overrides,
InstallationStrategy::Permissive,
&marker_env,
&tags,
config_setting,
Expand Down Expand Up @@ -2388,6 +2390,7 @@ pub(crate) async fn update_environment(
let changelog = pip::operations::install(
&resolution,
site_packages,
InstallationStrategy::Permissive,
modifications,
reinstall,
build_options,
Expand Down
Loading
Loading