diff --git a/crates/uv-installer/src/site_packages.rs b/crates/uv-installer/src/site_packages.rs index 93a2c4d217c..6262d0482de 100644 --- a/crates/uv-installer/src/site_packages.rs +++ b/crates/uv-installer/src/site_packages.rs @@ -1,12 +1,14 @@ use std::borrow::Cow; use std::iter::Flatten; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; +use std::str::FromStr; use anyhow::{Context, Result}; use fs_err as fs; use rustc_hash::{FxBuildHasher, FxHashMap, FxHashSet}; use uv_configuration::{ExcludeDependency, Excludes, Override, Overrides}; +use uv_distribution_filename::EggInfoFilename; use uv_distribution_types::{ ConfigSettings, DependencyMetadata, Diagnostic, ExtraBuildRequires, ExtraBuildVariables, InstalledDist, InstalledDistKind, Name, NameRequirementSpecification, PackageConfigSettings, @@ -49,8 +51,25 @@ impl SitePackages { Self::from_interpreter(environment.interpreter()) } + /// Build an index of the requested installed packages from the given Python environment. + pub fn from_environment_for_packages<'a>( + environment: &PythonEnvironment, + package_names: impl IntoIterator, + ) -> Result { + let package_names = package_names.into_iter().collect::>(); + Self::from_interpreter_with_filter(environment.interpreter(), Some(&package_names)) + } + /// Build an index of installed packages from the given Python executable. pub fn from_interpreter(interpreter: &Interpreter) -> Result { + Self::from_interpreter_with_filter(interpreter, None) + } + + /// Build an index of installed packages from the given Python executable. + fn from_interpreter_with_filter( + interpreter: &Interpreter, + package_names: Option<&FxHashSet<&PackageName>>, + ) -> Result { let mut distributions: Vec> = Vec::new(); let mut by_name = FxHashMap::default(); let mut by_url = FxHashMap::default(); @@ -77,6 +96,13 @@ impl SitePackages { // Index all installed packages by name. for path in site_packages { + if let Some(package_names) = package_names + && let Some(package_name) = installed_dist_name(&path) + && !package_names.contains(&package_name) + { + continue; + } + let dist_info = match InstalledDist::try_from_path(&path) { Ok(Some(dist_info)) => dist_info, Ok(None) => continue, @@ -99,6 +125,12 @@ impl SitePackages { } }; + if let Some(package_names) = package_names + && !package_names.contains(dist_info.name()) + { + continue; + } + let idx = distributions.len(); // Index the distribution by name. @@ -596,6 +628,29 @@ pub enum SatisfiesResult { Unsatisfied(String), } +/// Infer the package name from an installed distribution path without reading its metadata. +/// +/// Returns `None` when the name cannot safely be derived from the filename alone. +fn installed_dist_name(path: &Path) -> Option { + let extension = path.extension()?.to_str()?; + let file_stem = path.file_stem()?.to_str()?; + + match extension { + "dist-info" => { + let (name, version) = file_stem.split_once('-')?; + Version::from_str(version).ok()?; + PackageName::from_str(name).ok() + } + "egg-info" => { + let filename = EggInfoFilename::parse(file_stem).ok()?; + filename.version?; + Some(filename.name) + } + // Legacy editables require reading metadata to determine their package name. + _ => None, + } +} + impl IntoIterator for SitePackages { type Item = InstalledDist; type IntoIter = Flatten>>; diff --git a/crates/uv-types/src/traits.rs b/crates/uv-types/src/traits.rs index aa08aa1e37e..040703633ea 100644 --- a/crates/uv-types/src/traits.rs +++ b/crates/uv-types/src/traits.rs @@ -226,12 +226,23 @@ pub trait SourceBuildTrait { ) -> impl Future> + 'a; } -/// A wrapper for [`uv_installer::SitePackages`] +/// Provides access to installed distributions during resolution. pub trait InstalledPackagesProvider: Clone + Send + Sync + 'static { fn iter(&self) -> impl Iterator; fn get_packages(&self, name: &PackageName) -> Vec<&InstalledDist>; } +impl InstalledPackagesProvider for Option { + fn iter(&self) -> impl Iterator { + self.as_ref().into_iter().flat_map(Provider::iter) + } + + fn get_packages(&self, name: &PackageName) -> Vec<&InstalledDist> { + self.as_ref() + .map_or_else(Vec::new, |provider| provider.get_packages(name)) + } +} + /// An [`InstalledPackagesProvider`] with no packages in it. #[derive(Clone)] pub struct EmptyInstalledPackages; diff --git a/crates/uv/src/commands/pip/install.rs b/crates/uv/src/commands/pip/install.rs index 35be805e040..213ef0b54ab 100644 --- a/crates/uv/src/commands/pip/install.rs +++ b/crates/uv/src/commands/pip/install.rs @@ -19,7 +19,7 @@ use uv_configuration::{KeyringProviderType, TargetTriple}; use uv_dispatch::{BuildDispatch, SharedState}; use uv_distribution::LoweredExtraBuildDependencies; use uv_distribution_types::{ - ConfigSettings, DependencyMetadata, ExtraBuildVariables, Index, IndexLocations, + ConfigSettings, DependencyMetadata, ExtraBuildVariables, Index, IndexLocations, Name, NameRequirementSpecification, Origin, PackageConfigSettings, Requirement, Resolution, }; use uv_fs::Simplified; @@ -308,8 +308,19 @@ pub(crate) async fn pip_install( interpreter, )?; - // Determine the set of installed packages. - let site_packages = SitePackages::from_environment(&environment)?; + // With sufficient modifications, installation only needs installed distributions selected by + // the resolution. A `pylock.toml` resolution never consults the environment, while reinstalling + // every package excludes all installed distributions from candidate selection, including for + // transitive dependencies. Delay the environment scan in either case, then restrict it to the + // resolved package names. + let defer_site_packages = matches!(modifications, Modifications::Sufficient) + && (pylock.is_some() || matches!(&reinstall, Reinstall::All)); + + let site_packages = if defer_site_packages { + None + } else { + Some(SitePackages::from_environment(&environment)?) + }; // Check if the current environment satisfies the requirements. // Ideally, the resolver would be fast enough to let us remove this check. But right now, for large environments, @@ -320,6 +331,7 @@ pub(crate) async fn pip_install( && groups.is_empty() && pylock.is_none() && matches!(modifications, Modifications::Sufficient) + && let Some(site_packages) = &site_packages { match site_packages.satisfies_spec( &requirements, @@ -596,6 +608,15 @@ pub(crate) async fn pip_install( // If necessary, convert editable distributions to non-editable. let resolution = apply_editable_mode(resolution, editable); + let site_packages = match site_packages { + // Only resolved packages can be modified when using sufficient installation semantics. + None => SitePackages::from_environment_for_packages( + &environment, + resolution.distributions().map(Name::name), + )?, + Some(site_packages) => site_packages, + }; + // Constrain any build requirements marked as `match-runtime = true`. let extra_build_requires = extra_build_requires.match_runtime(&resolution)?; diff --git a/crates/uv/tests/pip_install/pip_install.rs b/crates/uv/tests/pip_install/pip_install.rs index 4b8eac2f0b6..83c06ed0ab4 100644 --- a/crates/uv/tests/pip_install/pip_install.rs +++ b/crates/uv/tests/pip_install/pip_install.rs @@ -4044,6 +4044,12 @@ fn install_no_downgrade() -> Result<()> { " ); + // An unrelated distribution should not be inspected when all resolved packages will be + // reinstalled. + let unrelated = context.site_packages().join("unrelated-1.0.0.dist-info"); + fs::create_dir_all(&unrelated)?; + fs::write(unrelated.join("direct_url.json"), "invalid")?; + // Install `anyio` with `--reinstall`, which should downgrade `idna`. uv_snapshot!(context.filters(), context.pip_install() .arg("--reinstall") @@ -4917,6 +4923,7 @@ fn reinstall_duplicate() -> Result<()> { // Run `pip install`. uv_snapshot!(context1.pip_install() .arg("pip") + .arg("--no-deps") .arg("--reinstall"), @" success: true @@ -5597,6 +5604,24 @@ fn path_name_version_change() { " ); + // Reinstalling a direct wheel without dependencies should reinstall the requested package. + uv_snapshot!(context.filters(), context.pip_install() + .arg(context.workspace_root.join("test/links/ok-1.0.0-py3-none-any.whl")) + .arg("--no-deps") + .arg("--reinstall"), @" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 1 package in [TIME] + Prepared 1 package in [TIME] + Uninstalled 1 package in [TIME] + Installed 1 package in [TIME] + ~ ok==1.0.0 (from file://[WORKSPACE]/test/links/ok-1.0.0-py3-none-any.whl) + " + ); + // Installing a new path should succeed uv_snapshot!(context.filters(), context.pip_install() .arg(context.workspace_root.join("test/links/ok-2.0.0-py3-none-any.whl")), @" @@ -12625,6 +12650,11 @@ fn pep_751_install_registry_wheel() -> Result<()> { " ); + // An unrelated distribution should not be inspected when installing from a `pylock.toml`. + let unrelated = context.site_packages().join("unrelated-1.0.0.dist-info"); + fs::create_dir_all(&unrelated)?; + fs::write(unrelated.join("direct_url.json"), "invalid")?; + uv_snapshot!(context.filters(), context.pip_install() .arg("--preview") .arg("-r")