uv/crates/uv-installer/src/plan.rs

480 lines
22 KiB
Rust

use anyhow::{Result, bail};
use std::sync::Arc;
use tracing::{debug, warn};
use uv_cache::{Cache, CacheBucket, WheelCache};
use uv_cache_info::Timestamp;
use uv_configuration::{BuildOptions, ConfigSettings, Reinstall};
use uv_distribution::{
BuiltWheelIndex, HttpArchivePointer, LocalArchivePointer, RegistryWheelIndex,
};
use uv_distribution_types::{
BuiltDist, CachedDirectUrlDist, CachedDist, Dist, Error, Hashed, IndexLocations, InstalledDist,
Name, RequirementSource, Resolution, ResolvedDist, SourceDist,
};
use uv_fs::Simplified;
use uv_platform_tags::Tags;
use uv_pypi_types::VerbatimParsedUrl;
use uv_python::PythonEnvironment;
use uv_types::HashStrategy;
use crate::SitePackages;
use crate::satisfies::RequirementSatisfaction;
/// A planner to generate an [`Plan`] based on a set of requirements.
#[derive(Debug)]
pub struct Planner<'a> {
resolution: &'a Resolution,
}
impl<'a> Planner<'a> {
/// Set the requirements use in the [`Plan`].
pub fn new(resolution: &'a Resolution) -> Self {
Self { resolution }
}
/// Partition a set of requirements into those that should be linked from the cache, those that
/// need to be downloaded, and those that should be removed.
///
/// The install plan will respect cache [`Freshness`]. Specifically, if refresh is enabled, the
/// plan will respect cache entries created after the current time (as per the [`Refresh`]
/// policy). Otherwise, entries will be ignored. The downstream distribution database may still
/// read those entries from the cache after revalidating them.
///
/// The install plan will also respect the required hashes, such that it will never return a
/// cached distribution that does not match the required hash. Like pip, though, it _will_
/// return an _installed_ distribution that does not match the required hash.
pub fn build(
self,
mut site_packages: SitePackages,
reinstall: &Reinstall,
build_options: &BuildOptions,
hasher: &HashStrategy,
index_locations: &IndexLocations,
config_settings: &ConfigSettings,
cache: &Cache,
venv: &PythonEnvironment,
tags: &Tags,
) -> Result<Plan> {
// Index all the already-downloaded wheels in the cache.
let mut registry_index =
RegistryWheelIndex::new(cache, tags, index_locations, hasher, config_settings);
let built_index = BuiltWheelIndex::new(cache, tags, hasher, config_settings);
let mut cached = vec![];
let mut remote = vec![];
let mut reinstalls = vec![];
let mut extraneous = vec![];
// TODO(charlie): There are a few assumptions here that are hard to spot:
//
// 1. Apparently, we never return direct URL distributions as [`ResolvedDist::Installed`].
// If you trace the resolver, we only ever return [`ResolvedDist::Installed`] if you go
// through the [`CandidateSelector`], and we only go through the [`CandidateSelector`]
// for registry distributions.
//
// 2. We expect any distribution returned as [`ResolvedDist::Installed`] to hit the
// "Requirement already installed" path (hence the `unreachable!`) a few lines below it.
// So, e.g., if a package is marked as `--reinstall`, we _expect_ that it's not passed in
// as [`ResolvedDist::Installed`] here.
for dist in self.resolution.distributions() {
// Check if the package should be reinstalled.
let reinstall = reinstall.contains_package(dist.name())
|| dist
.source_tree()
.is_some_and(|source_tree| reinstall.contains_path(source_tree));
// Check if installation of a binary version of the package should be allowed.
let no_binary = build_options.no_binary_package(dist.name());
let no_build = build_options.no_build_package(dist.name());
// Determine whether the distribution is already installed.
let installed_dists = site_packages.remove_packages(dist.name());
if reinstall {
reinstalls.extend(installed_dists);
} else {
match installed_dists.as_slice() {
[] => {}
[installed] => {
let source = RequirementSource::from(dist);
match RequirementSatisfaction::check(installed, &source) {
RequirementSatisfaction::Mismatch => {
debug!(
"Requirement installed, but mismatched:\n Installed: {installed:?}\n Requested: {source:?}"
);
}
RequirementSatisfaction::Satisfied => {
debug!("Requirement already installed: {installed}");
continue;
}
RequirementSatisfaction::OutOfDate => {
debug!("Requirement installed, but not fresh: {installed}");
}
RequirementSatisfaction::CacheInvalid => {
// Already logged
}
}
reinstalls.push(installed.clone());
}
// We reinstall installed distributions with multiple versions because
// we do not want to keep multiple incompatible versions but removing
// one version is likely to break another.
_ => reinstalls.extend(installed_dists),
}
}
let ResolvedDist::Installable { dist, .. } = dist else {
unreachable!("Installed distribution could not be found in site-packages: {dist}");
};
if cache.must_revalidate_package(dist.name())
|| dist
.source_tree()
.is_some_and(|source_tree| cache.must_revalidate_path(source_tree))
{
debug!("Must revalidate requirement: {}", dist.name());
remote.push(dist.clone());
continue;
}
// Identify any cached distributions that satisfy the requirement.
match dist.as_ref() {
Dist::Built(BuiltDist::Registry(wheel)) => {
if let Some(distribution) = registry_index.get(wheel.name()).find_map(|entry| {
if *entry.index.url() != wheel.best_wheel().index {
return None;
}
if entry.dist.filename != wheel.best_wheel().filename {
return None;
}
if entry.built && no_build {
return None;
}
if !entry.built && no_binary {
return None;
}
Some(&entry.dist)
}) {
debug!("Registry requirement already cached: {distribution}");
cached.push(CachedDist::Registry(distribution.clone()));
continue;
}
}
Dist::Built(BuiltDist::DirectUrl(wheel)) => {
if !wheel.filename.is_compatible(tags) {
bail!(
"A URL dependency is incompatible with the current platform: {}",
wheel.url
);
}
if no_binary {
bail!(
"A URL dependency points to a wheel which conflicts with `--no-binary`: {}",
wheel.url
);
}
// Find the exact wheel from the cache, since we know the filename in
// advance.
let cache_entry = cache
.shard(
CacheBucket::Wheels,
WheelCache::Url(&wheel.url).wheel_dir(wheel.name().as_ref()),
)
.entry(format!("{}.http", wheel.filename.cache_key()));
// Read the HTTP pointer.
match HttpArchivePointer::read_from(&cache_entry) {
Ok(Some(pointer)) => {
let cache_info = pointer.to_cache_info();
let archive = pointer.into_archive();
if archive.satisfies(hasher.get(dist.as_ref())) {
let cached_dist = CachedDirectUrlDist {
filename: wheel.filename.clone(),
url: VerbatimParsedUrl {
parsed_url: wheel.parsed_url(),
verbatim: wheel.url.clone(),
},
hashes: archive.hashes,
cache_info,
path: cache.archive(&archive.id).into_boxed_path(),
};
debug!("URL wheel requirement already cached: {cached_dist}");
cached.push(CachedDist::Url(cached_dist));
continue;
}
debug!(
"Cached URL wheel requirement does not match expected hash policy for: {wheel}"
);
}
Ok(None) => {}
Err(err) => {
debug!(
"Failed to deserialize cached URL wheel requirement for: {wheel} ({err})"
);
}
}
}
Dist::Built(BuiltDist::Path(wheel)) => {
// Validate that the path exists.
if !wheel.install_path.exists() {
return Err(Error::NotFound(wheel.url.to_url()).into());
}
if !wheel.filename.is_compatible(tags) {
bail!(
"A path dependency is incompatible with the current platform: {}",
wheel.install_path.user_display()
);
}
if no_binary {
bail!(
"A path dependency points to a wheel which conflicts with `--no-binary`: {}",
wheel.url
);
}
// Find the exact wheel from the cache, since we know the filename in
// advance.
let cache_entry = cache
.shard(
CacheBucket::Wheels,
WheelCache::Url(&wheel.url).wheel_dir(wheel.name().as_ref()),
)
.entry(format!("{}.rev", wheel.filename.cache_key()));
match LocalArchivePointer::read_from(&cache_entry) {
Ok(Some(pointer)) => match Timestamp::from_path(&wheel.install_path) {
Ok(timestamp) => {
if pointer.is_up_to_date(timestamp) {
let cache_info = pointer.to_cache_info();
let archive = pointer.into_archive();
if archive.satisfies(hasher.get(dist.as_ref())) {
let cached_dist = CachedDirectUrlDist {
filename: wheel.filename.clone(),
url: VerbatimParsedUrl {
parsed_url: wheel.parsed_url(),
verbatim: wheel.url.clone(),
},
hashes: archive.hashes,
cache_info,
path: cache.archive(&archive.id).into_boxed_path(),
};
debug!(
"Path wheel requirement already cached: {cached_dist}"
);
cached.push(CachedDist::Url(cached_dist));
continue;
}
debug!(
"Cached path wheel requirement does not match expected hash policy for: {wheel}"
);
}
}
Err(err) => {
debug!("Failed to get timestamp for wheel {wheel} ({err})");
}
},
Ok(None) => {}
Err(err) => {
debug!(
"Failed to deserialize cached path wheel requirement for: {wheel} ({err})"
);
}
}
}
Dist::Source(SourceDist::Registry(sdist)) => {
if let Some(distribution) = registry_index.get(sdist.name()).find_map(|entry| {
if *entry.index.url() != sdist.index {
return None;
}
if entry.dist.filename.name != sdist.name {
return None;
}
if entry.dist.filename.version != sdist.version {
return None;
}
if entry.built && no_build {
return None;
}
if !entry.built && no_binary {
return None;
}
Some(&entry.dist)
}) {
debug!("Registry requirement already cached: {distribution}");
cached.push(CachedDist::Registry(distribution.clone()));
continue;
}
}
Dist::Source(SourceDist::DirectUrl(sdist)) => {
// Find the most-compatible wheel from the cache, since we don't know
// the filename in advance.
match built_index.url(sdist) {
Ok(Some(wheel)) => {
if wheel.filename.name == sdist.name {
let cached_dist = wheel.into_url_dist(sdist);
debug!("URL source requirement already cached: {cached_dist}");
cached.push(CachedDist::Url(cached_dist));
continue;
}
warn!(
"Cached wheel filename does not match requested distribution for: `{}` (found: `{}`)",
sdist, wheel.filename
);
}
Ok(None) => {}
Err(err) => {
debug!(
"Failed to deserialize cached wheel filename for: {sdist} ({err})"
);
}
}
}
Dist::Source(SourceDist::Git(sdist)) => {
// Find the most-compatible wheel from the cache, since we don't know
// the filename in advance.
if let Some(wheel) = built_index.git(sdist) {
if wheel.filename.name == sdist.name {
let cached_dist = wheel.into_git_dist(sdist);
debug!("Git source requirement already cached: {cached_dist}");
cached.push(CachedDist::Url(cached_dist));
continue;
}
warn!(
"Cached wheel filename does not match requested distribution for: `{}` (found: `{}`)",
sdist, wheel.filename
);
}
}
Dist::Source(SourceDist::Path(sdist)) => {
// Validate that the path exists.
if !sdist.install_path.exists() {
return Err(Error::NotFound(sdist.url.to_url()).into());
}
// Find the most-compatible wheel from the cache, since we don't know
// the filename in advance.
match built_index.path(sdist) {
Ok(Some(wheel)) => {
if wheel.filename.name == sdist.name {
let cached_dist = wheel.into_path_dist(sdist);
debug!("Path source requirement already cached: {cached_dist}");
cached.push(CachedDist::Url(cached_dist));
continue;
}
warn!(
"Cached wheel filename does not match requested distribution for: `{}` (found: `{}`)",
sdist, wheel.filename
);
}
Ok(None) => {}
Err(err) => {
debug!(
"Failed to deserialize cached wheel filename for: {sdist} ({err})"
);
}
}
}
Dist::Source(SourceDist::Directory(sdist)) => {
// Validate that the path exists.
if !sdist.install_path.exists() {
return Err(Error::NotFound(sdist.url.to_url()).into());
}
// Find the most-compatible wheel from the cache, since we don't know
// the filename in advance.
match built_index.directory(sdist) {
Ok(Some(wheel)) => {
if wheel.filename.name == sdist.name {
let cached_dist = wheel.into_directory_dist(sdist);
debug!(
"Directory source requirement already cached: {cached_dist}"
);
cached.push(CachedDist::Url(cached_dist));
continue;
}
warn!(
"Cached wheel filename does not match requested distribution for: `{}` (found: `{}`)",
sdist, wheel.filename
);
}
Ok(None) => {}
Err(err) => {
debug!(
"Failed to deserialize cached wheel filename for: {sdist} ({err})"
);
}
}
}
}
debug!("Identified uncached distribution: {dist}");
remote.push(dist.clone());
}
// Remove any unnecessary packages.
if site_packages.any() {
// Retain seed packages unless: (1) the virtual environment was created by uv and
// (2) the `--seed` argument was not passed to `uv venv`.
let seed_packages = !venv.cfg().is_ok_and(|cfg| cfg.is_uv() && !cfg.is_seed());
for dist_info in site_packages {
if seed_packages && is_seed_package(&dist_info, venv) {
debug!("Preserving seed package: {dist_info}");
continue;
}
debug!("Unnecessary package: {dist_info}");
extraneous.push(dist_info);
}
}
Ok(Plan {
cached,
remote,
reinstalls,
extraneous,
})
}
}
/// Returns `true` if the given distribution is a seed package.
fn is_seed_package(dist_info: &InstalledDist, venv: &PythonEnvironment) -> bool {
if venv.interpreter().python_tuple() >= (3, 12) {
matches!(dist_info.name().as_ref(), "uv" | "pip")
} else {
// Include `setuptools` and `wheel` on Python <3.12.
matches!(
dist_info.name().as_ref(),
"pip" | "setuptools" | "wheel" | "uv"
)
}
}
#[derive(Debug, Default)]
pub struct Plan {
/// The distributions that are not already installed in the current environment, but are
/// available in the local cache.
pub cached: Vec<CachedDist>,
/// The distributions that are not already installed in the current environment, and are
/// not available in the local cache.
pub remote: Vec<Arc<Dist>>,
/// Any distributions that are already installed in the current environment, but will be
/// re-installed (including upgraded) to satisfy the requirements.
pub reinstalls: Vec<InstalledDist>,
/// Any distributions that are already installed in the current environment, and are
/// _not_ necessary to satisfy the requirements.
pub extraneous: Vec<InstalledDist>,
}