uv/crates/puffin/src/commands/pip_install.rs

664 lines
20 KiB
Rust

use std::fmt::Write;
use std::path::Path;
use anstream::eprint;
use anyhow::{anyhow, Context, Result};
use chrono::{DateTime, Utc};
use itertools::Itertools;
use owo_colors::OwoColorize;
use tempfile::tempdir_in;
use tracing::debug;
use distribution_types::{
IndexLocations, InstalledMetadata, LocalDist, LocalEditable, Name, Resolution,
};
use install_wheel_rs::linker::LinkMode;
use pep508_rs::{MarkerEnvironment, Requirement};
use platform_host::Platform;
use platform_tags::Tags;
use puffin_cache::Cache;
use puffin_client::{FlatIndex, FlatIndexClient, RegistryClient, RegistryClientBuilder};
use puffin_dispatch::BuildDispatch;
use puffin_fs::NormalizedDisplay;
use puffin_installer::{
BuiltEditable, Downloader, NoBinary, Plan, Planner, Reinstall, ResolvedEditable, SitePackages,
};
use puffin_interpreter::{Interpreter, Virtualenv};
use puffin_normalize::PackageName;
use puffin_resolver::{
InMemoryIndex, Manifest, PreReleaseMode, ResolutionGraph, ResolutionMode, ResolutionOptions,
Resolver,
};
use puffin_traits::{InFlight, SetupPyStrategy};
use requirements_txt::EditableRequirement;
use crate::commands::reporters::{DownloadReporter, InstallReporter, ResolverReporter};
use crate::commands::{elapsed, ChangeEvent, ChangeEventKind, ExitStatus};
use crate::printer::Printer;
use crate::requirements::{ExtrasSpecification, RequirementsSource, RequirementsSpecification};
/// Install packages into the current environment.
#[allow(clippy::too_many_arguments)]
pub(crate) async fn pip_install(
requirements: &[RequirementsSource],
constraints: &[RequirementsSource],
overrides: &[RequirementsSource],
extras: &ExtrasSpecification<'_>,
resolution_mode: ResolutionMode,
prerelease_mode: PreReleaseMode,
index_locations: IndexLocations,
reinstall: &Reinstall,
link_mode: LinkMode,
setup_py: SetupPyStrategy,
no_build: bool,
no_binary: &NoBinary,
strict: bool,
exclude_newer: Option<DateTime<Utc>>,
cache: Cache,
mut printer: Printer,
) -> Result<ExitStatus> {
let start = std::time::Instant::now();
// Read all requirements from the provided sources.
let RequirementsSpecification {
project,
requirements,
constraints,
overrides,
editables,
extras: used_extras,
} = specification(requirements, constraints, overrides, extras)?;
// Check that all provided extras are used
if let ExtrasSpecification::Some(extras) = extras {
let mut unused_extras = extras
.iter()
.filter(|extra| !used_extras.contains(extra))
.collect::<Vec<_>>();
if !unused_extras.is_empty() {
unused_extras.sort_unstable();
unused_extras.dedup();
let s = if unused_extras.len() == 1 { "" } else { "s" };
return Err(anyhow!(
"Requested extra{s} not found: {}",
unused_extras.iter().join(", ")
));
}
}
// Detect the current Python interpreter.
let platform = Platform::current()?;
let venv = Virtualenv::from_env(platform, &cache)?;
debug!(
"Using Python {} environment at {}",
venv.interpreter().python_version(),
venv.python_executable().normalized_display().cyan()
);
let _lock = venv.lock()?;
// Determine the set of installed packages.
let site_packages =
SitePackages::from_executable(&venv).context("Failed to list installed packages")?;
// If the requirements are already satisfied, we're done. Ideally, the resolver would be fast
// enough to let us remove this check. But right now, for large environments, it's an order of
// magnitude faster to validate the environment than to resolve the requirements.
if reinstall.is_none() && site_packages.satisfies(&requirements, &editables, &constraints)? {
let num_requirements = requirements.len() + editables.len();
let s = if num_requirements == 1 { "" } else { "s" };
writeln!(
printer,
"{}",
format!(
"Audited {} in {}",
format!("{num_requirements} package{s}").bold(),
elapsed(start.elapsed())
)
.dimmed()
)?;
return Ok(ExitStatus::Success);
}
// Determine the tags, markers, and interpreter to use for resolution.
let interpreter = venv.interpreter().clone();
let tags = venv.interpreter().tags()?;
let markers = venv.interpreter().markers();
// Instantiate a client.
let client = RegistryClientBuilder::new(cache.clone())
.index_urls(index_locations.index_urls())
.build();
// Resolve the flat indexes from `--find-links`.
let flat_index = {
let client = FlatIndexClient::new(&client, &cache);
let entries = client.fetch(index_locations.flat_index()).await?;
FlatIndex::from_entries(entries, tags)
};
// Create a shared in-memory index.
let index = InMemoryIndex::default();
// Track in-flight downloads, builds, etc., across resolutions.
let in_flight = InFlight::default();
let options = ResolutionOptions::new(resolution_mode, prerelease_mode, exclude_newer);
let resolve_dispatch = BuildDispatch::new(
&client,
&cache,
&interpreter,
&index_locations,
&flat_index,
&index,
&in_flight,
venv.python_executable(),
setup_py,
no_build,
no_binary,
)
.with_options(options);
// Build all editable distributions. The editables are shared between resolution and
// installation, and should live for the duration of the command. If an editable is already
// installed in the environment, we'll still re-build it here.
let editable_wheel_dir;
let editables = if editables.is_empty() {
vec![]
} else {
editable_wheel_dir = tempdir_in(venv.root())?;
build_editables(
&editables,
editable_wheel_dir.path(),
&cache,
tags,
&client,
&resolve_dispatch,
printer,
)
.await?
};
// Resolve the requirements.
let resolution = match resolve(
requirements,
constraints,
overrides,
project,
&editables,
&site_packages,
reinstall,
&interpreter,
tags,
markers,
&client,
&flat_index,
&index,
&resolve_dispatch,
options,
printer,
)
.await
{
Ok(resolution) => Resolution::from(resolution),
Err(Error::Resolve(puffin_resolver::ResolveError::NoSolution(err))) => {
#[allow(clippy::print_stderr)]
{
let report = miette::Report::msg(format!("{err}"))
.context("No solution found when resolving dependencies:");
eprint!("{report:?}");
}
return Ok(ExitStatus::Failure);
}
Err(err) => return Err(err.into()),
};
// Re-initialize the in-flight map.
let in_flight = InFlight::default();
// If we're running with `--reinstall`, initialize a separate `BuildDispatch`, since we may
// end up removing some distributions from the environment.
let install_dispatch = if reinstall.is_none() {
resolve_dispatch
} else {
BuildDispatch::new(
&client,
&cache,
&interpreter,
&index_locations,
&flat_index,
&index,
&in_flight,
venv.python_executable(),
setup_py,
no_build,
no_binary,
)
};
// Sync the environment.
install(
&resolution,
editables,
site_packages,
reinstall,
no_binary,
link_mode,
&index_locations,
tags,
&client,
&in_flight,
&install_dispatch,
&cache,
&venv,
printer,
)
.await?;
// Validate the environment.
if strict {
validate(&resolution, &venv, printer)?;
}
Ok(ExitStatus::Success)
}
/// Consolidate the requirements for an installation.
fn specification(
requirements: &[RequirementsSource],
constraints: &[RequirementsSource],
overrides: &[RequirementsSource],
extras: &ExtrasSpecification<'_>,
) -> Result<RequirementsSpecification, Error> {
// If the user requests `extras` but does not provide a pyproject toml source
if !matches!(extras, ExtrasSpecification::None)
&& !requirements
.iter()
.any(|source| matches!(source, RequirementsSource::PyprojectToml(_)))
{
return Err(anyhow!("Requesting extras requires a pyproject.toml input file.").into());
}
// Read all requirements from the provided sources.
let spec =
RequirementsSpecification::from_sources(requirements, constraints, overrides, extras)?;
// Check that all provided extras are used
if let ExtrasSpecification::Some(extras) = extras {
let mut unused_extras = extras
.iter()
.filter(|extra| !spec.extras.contains(extra))
.collect::<Vec<_>>();
if !unused_extras.is_empty() {
unused_extras.sort_unstable();
unused_extras.dedup();
let s = if unused_extras.len() == 1 { "" } else { "s" };
return Err(anyhow!(
"Requested extra{s} not found: {}",
unused_extras.iter().join(", ")
)
.into());
}
}
Ok(spec)
}
/// Build a set of editable distributions.
async fn build_editables(
editables: &[EditableRequirement],
editable_wheel_dir: &Path,
cache: &Cache,
tags: &Tags,
client: &RegistryClient,
build_dispatch: &BuildDispatch<'_>,
mut printer: Printer,
) -> Result<Vec<BuiltEditable>, Error> {
let start = std::time::Instant::now();
let downloader = Downloader::new(cache, tags, client, build_dispatch)
.with_reporter(DownloadReporter::from(printer).with_length(editables.len() as u64));
let editables: Vec<LocalEditable> = editables
.iter()
.map(|editable| {
let EditableRequirement { url, extras, path } = editable;
Ok(LocalEditable {
url: url.clone(),
extras: extras.clone(),
path: path.clone(),
})
})
.collect::<Result<_>>()?;
let editables: Vec<_> = downloader
.build_editables(editables, editable_wheel_dir)
.await
.context("Failed to build editables")?
.into_iter()
.collect();
let s = if editables.len() == 1 { "" } else { "s" };
writeln!(
printer,
"{}",
format!(
"Built {} in {}",
format!("{} editable{}", editables.len(), s).bold(),
elapsed(start.elapsed())
)
.dimmed()
)?;
Ok(editables)
}
/// Resolve a set of requirements, similar to running `pip compile`.
#[allow(clippy::too_many_arguments)]
async fn resolve(
requirements: Vec<Requirement>,
constraints: Vec<Requirement>,
overrides: Vec<Requirement>,
project: Option<PackageName>,
editables: &[BuiltEditable],
site_packages: &SitePackages<'_>,
reinstall: &Reinstall,
interpreter: &Interpreter,
tags: &Tags,
markers: &MarkerEnvironment,
client: &RegistryClient,
flat_index: &FlatIndex,
index: &InMemoryIndex,
build_dispatch: &BuildDispatch<'_>,
options: ResolutionOptions,
mut printer: Printer,
) -> Result<ResolutionGraph, Error> {
let start = std::time::Instant::now();
// Respect preferences from the existing environments.
let preferences: Vec<Requirement> = match reinstall {
Reinstall::All => vec![],
Reinstall::None => site_packages.requirements().collect(),
Reinstall::Packages(packages) => site_packages
.requirements()
.filter(|requirement| !packages.contains(&requirement.name))
.collect(),
};
// Map the editables to their metadata.
let editables = editables
.iter()
.map(|built_editable| {
(
built_editable.editable.clone(),
built_editable.metadata.clone(),
)
})
.collect();
// Create a manifest of the requirements.
let manifest = Manifest::new(
requirements,
constraints,
overrides,
preferences,
project,
editables,
);
// Resolve the dependencies.
let resolver = Resolver::new(
manifest,
options,
markers,
interpreter,
tags,
client,
flat_index,
index,
build_dispatch,
)
.with_reporter(ResolverReporter::from(printer));
let resolution = resolver.resolve().await?;
let s = if resolution.len() == 1 { "" } else { "s" };
writeln!(
printer,
"{}",
format!(
"Resolved {} in {}",
format!("{} package{}", resolution.len(), s).bold(),
elapsed(start.elapsed())
)
.dimmed()
)?;
Ok(resolution)
}
/// Install a set of requirements into the current environment.
#[allow(clippy::too_many_arguments)]
async fn install(
resolution: &Resolution,
built_editables: Vec<BuiltEditable>,
site_packages: SitePackages<'_>,
reinstall: &Reinstall,
no_binary: &NoBinary,
link_mode: LinkMode,
index_urls: &IndexLocations,
tags: &Tags,
client: &RegistryClient,
in_flight: &InFlight,
build_dispatch: &BuildDispatch<'_>,
cache: &Cache,
venv: &Virtualenv,
mut printer: Printer,
) -> Result<(), Error> {
let start = std::time::Instant::now();
// Partition into those that should be linked from the cache (`local`), those that need to be
// downloaded (`remote`), and those that should be removed (`extraneous`).
let requirements = resolution.requirements();
let editables = built_editables
.into_iter()
.map(ResolvedEditable::Built)
.collect::<Vec<_>>();
let Plan {
local,
remote,
reinstalls,
extraneous: _,
} = Planner::with_requirements(&requirements)
.with_editable_requirements(editables)
.build(
site_packages,
reinstall,
no_binary,
index_urls,
cache,
venv,
tags,
)
.context("Failed to determine installation plan")?;
// Nothing to do.
if remote.is_empty() && local.is_empty() && reinstalls.is_empty() {
let s = if resolution.len() == 1 { "" } else { "s" };
writeln!(
printer,
"{}",
format!(
"Audited {} in {}",
format!("{} package{}", resolution.len(), s).bold(),
elapsed(start.elapsed())
)
.dimmed()
)?;
return Ok(());
}
// Map any registry-based requirements back to those returned by the resolver.
let remote = remote
.iter()
.map(|dist| {
resolution
.get(&dist.name)
.cloned()
.expect("Resolution should contain all packages")
})
.collect::<Vec<_>>();
// Download, build, and unzip any missing distributions.
let wheels = if remote.is_empty() {
vec![]
} else {
let start = std::time::Instant::now();
let downloader = Downloader::new(cache, tags, client, build_dispatch)
.with_reporter(DownloadReporter::from(printer).with_length(remote.len() as u64));
let wheels = downloader
.download(remote, in_flight)
.await
.context("Failed to download distributions")?;
let s = if wheels.len() == 1 { "" } else { "s" };
writeln!(
printer,
"{}",
format!(
"Downloaded {} in {}",
format!("{} package{}", wheels.len(), s).bold(),
elapsed(start.elapsed())
)
.dimmed()
)?;
wheels
};
// Remove any existing installations.
if !reinstalls.is_empty() {
for dist_info in &reinstalls {
let summary = puffin_installer::uninstall(dist_info).await?;
debug!(
"Uninstalled {} ({} file{}, {} director{})",
dist_info.name(),
summary.file_count,
if summary.file_count == 1 { "" } else { "s" },
summary.dir_count,
if summary.dir_count == 1 { "y" } else { "ies" },
);
}
}
// Install the resolved distributions.
let wheels = wheels.into_iter().chain(local).collect::<Vec<_>>();
if !wheels.is_empty() {
let start = std::time::Instant::now();
puffin_installer::Installer::new(venv)
.with_link_mode(link_mode)
.with_reporter(InstallReporter::from(printer).with_length(wheels.len() as u64))
.install(&wheels)?;
let s = if wheels.len() == 1 { "" } else { "s" };
writeln!(
printer,
"{}",
format!(
"Installed {} in {}",
format!("{} package{}", wheels.len(), s).bold(),
elapsed(start.elapsed())
)
.dimmed()
)?;
}
for event in reinstalls
.into_iter()
.map(|distribution| ChangeEvent {
dist: LocalDist::from(distribution),
kind: ChangeEventKind::Removed,
})
.chain(wheels.into_iter().map(|distribution| ChangeEvent {
dist: LocalDist::from(distribution),
kind: ChangeEventKind::Added,
}))
.sorted_unstable_by(|a, b| {
a.dist
.name()
.cmp(b.dist.name())
.then_with(|| a.kind.cmp(&b.kind))
})
{
match event.kind {
ChangeEventKind::Added => {
writeln!(
printer,
" {} {}{}",
"+".green(),
event.dist.name().as_ref().white().bold(),
event.dist.installed_version().to_string().dimmed()
)?;
}
ChangeEventKind::Removed => {
writeln!(
printer,
" {} {}{}",
"-".red(),
event.dist.name().as_ref().white().bold(),
event.dist.installed_version().to_string().dimmed()
)?;
}
}
}
Ok(())
}
/// Validate the installed packages in the virtual environment.
fn validate(resolution: &Resolution, venv: &Virtualenv, mut printer: Printer) -> Result<(), Error> {
let site_packages = SitePackages::from_executable(venv)?;
let diagnostics = site_packages.diagnostics()?;
for diagnostic in diagnostics {
// Only surface diagnostics that are "relevant" to the current resolution.
if resolution
.packages()
.any(|package| diagnostic.includes(package))
{
writeln!(
printer,
"{}{} {}",
"warning".yellow().bold(),
":".bold(),
diagnostic.message().bold()
)?;
}
}
Ok(())
}
#[derive(thiserror::Error, Debug)]
enum Error {
#[error(transparent)]
Resolve(#[from] puffin_resolver::ResolveError),
#[error(transparent)]
Client(#[from] puffin_client::Error),
#[error(transparent)]
Platform(#[from] platform_host::PlatformError),
#[error(transparent)]
Io(#[from] std::io::Error),
#[error(transparent)]
Fmt(#[from] std::fmt::Error),
#[error(transparent)]
Anyhow(#[from] anyhow::Error),
}