Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 56 additions & 1 deletion crates/uv-installer/src/site_packages.rs
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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<Item = &'a PackageName>,
) -> Result<Self> {
let package_names = package_names.into_iter().collect::<FxHashSet<_>>();
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> {
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<Self> {
let mut distributions: Vec<Option<InstalledDist>> = Vec::new();
let mut by_name = FxHashMap::default();
let mut by_url = FxHashMap::default();
Expand All @@ -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,
Expand All @@ -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.
Expand Down Expand Up @@ -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<PackageName> {
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<std::vec::IntoIter<Option<InstalledDist>>>;
Expand Down
13 changes: 12 additions & 1 deletion crates/uv-types/src/traits.rs
Original file line number Diff line number Diff line change
Expand Up @@ -226,12 +226,23 @@ pub trait SourceBuildTrait {
) -> impl Future<Output = Result<String, AnyErrorBuild>> + '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<Item = &InstalledDist>;
fn get_packages(&self, name: &PackageName) -> Vec<&InstalledDist>;
}

impl<Provider: InstalledPackagesProvider> InstalledPackagesProvider for Option<Provider> {
fn iter(&self) -> impl Iterator<Item = &InstalledDist> {
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;
Expand Down
27 changes: 24 additions & 3 deletions crates/uv/src/commands/pip/install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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)?;

Expand Down
30 changes: 30 additions & 0 deletions crates/uv/tests/pip_install/pip_install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")), @"
Expand Down Expand Up @@ -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")
Expand Down
Loading