diff --git a/crates/distribution-types/src/lib.rs b/crates/distribution-types/src/lib.rs index 2904c2bdd0bd..931f7cc365b0 100644 --- a/crates/distribution-types/src/lib.rs +++ b/crates/distribution-types/src/lib.rs @@ -96,7 +96,7 @@ impl std::fmt::Display for VersionOrUrl<'_> { } } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] pub enum InstalledVersion<'a> { /// A PEP 440 version specifier, used to identify a distribution in a registry. Version(&'a Version), diff --git a/crates/uv-installer/src/plan.rs b/crates/uv-installer/src/plan.rs index 37e8dc66a84b..53afb7f43805 100644 --- a/crates/uv-installer/src/plan.rs +++ b/crates/uv-installer/src/plan.rs @@ -106,7 +106,8 @@ impl<'a> Planner<'a> { warn!("Editable requirement is not editable: {installed}"); continue; }; - if site_packages.remove_editable(editable).is_none() { + let existing = site_packages.remove_editables(editable); + if existing.is_empty() { warn!("Editable requirement is not installed: {installed}"); continue; } @@ -114,14 +115,14 @@ impl<'a> Planner<'a> { ResolvedEditable::Built(built) => { debug!("Treating editable requirement as mutable: {built}"); - if let Some(dist) = site_packages.remove_editable(built.editable.raw()) { - // Remove any editable installs. - reinstalls.push(dist); - } - if let Some(dist) = site_packages.remove(built.name()) { - // Remove any non-editable installs of the same package. - reinstalls.push(dist); - } + // Remove any editable installs. + let existing = site_packages.remove_editables(built.editable.raw()); + reinstalls.extend(existing); + + // Remove any non-editable installs of the same package. + let existing = site_packages.remove_packages(built.name()); + reinstalls.extend(existing); + local.push(built.wheel.clone()); } } @@ -166,42 +167,46 @@ impl<'a> Planner<'a> { }; if reinstall { - if let Some(distribution) = site_packages.remove(&requirement.name) { - reinstalls.push(distribution); - } + let installed = site_packages.remove_packages(&requirement.name); + reinstalls.extend(installed); } else { - if let Some(distribution) = site_packages.remove(&requirement.name) { - // Filter out already-installed packages. - match requirement.version_or_url.as_ref() { - // If the requirement comes from a registry, check by name. - None | Some(VersionOrUrl::VersionSpecifier(_)) => { - if requirement.is_satisfied_by(distribution.version()) { - debug!("Requirement already satisfied: {distribution}"); - continue; + let installed = site_packages.remove_packages(&requirement.name); + match installed.as_slice() { + [] => {} + [distribution] => { + // Filter out already-installed packages. + match requirement.version_or_url.as_ref() { + // If the requirement comes from a registry, check by name. + None | Some(VersionOrUrl::VersionSpecifier(_)) => { + if requirement.is_satisfied_by(distribution.version()) { + debug!("Requirement already satisfied: {distribution}"); + continue; + } } - } - // If the requirement comes from a direct URL, check by URL. - Some(VersionOrUrl::Url(url)) => { - if let InstalledDist::Url(distribution) = &distribution { - if &distribution.url == url.raw() { - // If the requirement came from a local path, check freshness. - if let Ok(archive) = url.to_file_path() { - if not_modified_install(distribution, &archive)? { - debug!("Requirement already satisfied (and up-to-date): {distribution}"); + // If the requirement comes from a direct URL, check by URL. + Some(VersionOrUrl::Url(url)) => { + if let InstalledDist::Url(distribution) = &distribution { + if &distribution.url == url.raw() { + // If the requirement came from a local path, check freshness. + if let Ok(archive) = url.to_file_path() { + if not_modified_install(distribution, &archive)? { + debug!("Requirement already satisfied (and up-to-date): {distribution}"); + continue; + } + } else { + // Otherwise, assume the requirement is up-to-date. + debug!("Requirement already satisfied (assumed up-to-date): {distribution}"); continue; } - } else { - // Otherwise, assume the requirement is up-to-date. - debug!("Requirement already satisfied (assumed up-to-date): {distribution}"); - continue; } } } } - } - reinstalls.push(distribution); + reinstalls.push(distribution.clone()); + } + _ => reinstalls.extend(installed), } } @@ -376,7 +381,7 @@ impl<'a> Planner<'a> { } // Remove any unnecessary packages. - if !site_packages.is_empty() { + if site_packages.any() { // If uv created the virtual environment, then remove all packages, regardless of // whether they're considered "seed" packages. let seed_packages = !venv.cfg().is_ok_and(|cfg| cfg.is_gourgeist()); diff --git a/crates/uv-installer/src/site_packages.rs b/crates/uv-installer/src/site_packages.rs index 00cade583777..4390bf8699a9 100644 --- a/crates/uv-installer/src/site_packages.rs +++ b/crates/uv-installer/src/site_packages.rs @@ -1,4 +1,5 @@ use std::hash::BuildHasherDefault; +use std::iter::Flatten; use std::path::PathBuf; use anyhow::{Context, Result}; @@ -19,8 +20,10 @@ use uv_normalize::PackageName; #[derive(Debug)] pub struct SitePackages<'a> { venv: &'a Virtualenv, - /// The vector of all installed distributions. - distributions: Vec, + /// The vector of all installed distributions. The `by_name` and `by_url` indices index into + /// this vector. The vector may contain `None` values, which represent distributions that were + /// removed from the virtual environment. + distributions: Vec>, /// The installed distributions, keyed by name. by_name: FxHashMap>, /// The installed editable distributions, keyed by URL. @@ -30,7 +33,7 @@ pub struct SitePackages<'a> { impl<'a> SitePackages<'a> { /// Build an index of installed packages from the given Python executable. pub fn from_executable(venv: &'a Virtualenv) -> Result> { - let mut distributions: Vec = Vec::new(); + let mut distributions: Vec> = Vec::new(); let mut by_name = FxHashMap::default(); let mut by_url = FxHashMap::default(); @@ -60,7 +63,7 @@ impl<'a> SitePackages<'a> { } // Add the distribution to the database. - distributions.push(dist_info); + distributions.push(Some(dist_info)); } } @@ -74,7 +77,7 @@ impl<'a> SitePackages<'a> { /// Returns an iterator over the installed distributions. pub fn iter(&self) -> impl Iterator { - self.distributions.iter() + self.distributions.iter().flatten() } /// Returns an iterator over the the installed distributions, represented as requirements. @@ -97,126 +100,140 @@ impl<'a> SitePackages<'a> { } /// Returns the version of the given package, if it is installed. - pub fn get_all(&self, name: &PackageName) -> Vec<&InstalledDist> { + pub fn get_packages(&self, name: &PackageName) -> Vec<&InstalledDist> { let Some(indexes) = self.by_name.get(name) else { return Vec::new(); }; - indexes.iter().map(|&index| &self.distributions[index]).collect() + indexes + .iter() + .flat_map(|&index| &self.distributions[index]) + .collect() } /// Remove the given package from the index, returning its version if it was installed. - pub fn remove(&mut self, name: &PackageName) -> Option { - let idx = self.by_name.get(name)?; - Some(self.swap_remove(*idx)) + pub fn remove_packages(&mut self, name: &PackageName) -> Vec { + let Some(indexes) = self.by_name.get(name) else { + return Vec::new(); + }; + indexes + .iter() + .filter_map(|index| std::mem::take(&mut self.distributions[*index])) + .collect() } /// Returns the editable distribution installed from the given URL, if any. - pub fn get_editable(&self, url: &Url) -> Option<&InstalledDist> { - self.by_url.get(url).map(|idx| &self.distributions[*idx]) + pub fn get_editables(&self, url: &Url) -> Vec<&InstalledDist> { + let Some(indexes) = self.by_url.get(url) else { + return Vec::new(); + }; + indexes + .iter() + .flat_map(|&index| &self.distributions[index]) + .collect() } /// Remove the editable distribution installed from the given URL, if any. - pub fn remove_editable(&mut self, url: &Url) -> Option { - let idx = self.by_url.get(url)?; - Some(self.swap_remove(*idx)) - } - - /// Remove the distribution at the given index. - fn swap_remove(&mut self, idx: usize) -> InstalledDist { - // Remove from the existing index. - let dist = self.distributions.swap_remove(idx); - - // If the distribution wasn't at the end, rewrite the entries for the moved distribution. - if idx < self.distributions.len() { - let moved = &self.distributions[idx]; - if let Some(prev) = self.by_name.get_mut(moved.name()) { - for prev in prev { - if *prev == self.distributions.len() { - *prev = idx; - } - } - } - if let Some(url) = moved.as_editable() { - if let Some(prev) = self.by_url.get_mut(url) { - for prev in prev { - if *prev == self.distributions.len() { - *prev = idx; - } - } - } - } - } - - dist - } - - /// Returns `true` if there are no installed packages. - pub fn is_empty(&self) -> bool { - self.distributions.is_empty() + pub fn remove_editables(&mut self, url: &Url) -> Vec { + let Some(indexes) = self.by_url.get(url) else { + return Vec::new(); + }; + indexes + .iter() + .filter_map(|index| std::mem::take(&mut self.distributions[*index])) + .collect() } - /// Returns the number of installed packages. - pub fn len(&self) -> usize { - self.distributions.len() + /// Returns `true` if there are any installed packages. + pub fn any(&self) -> bool { + self.distributions.iter().any(Option::is_some) } /// Validate the installed packages in the virtual environment. pub fn diagnostics(&self) -> Result> { let mut diagnostics = Vec::new(); - for (package, index) in &self.by_name { - let distribution = &self.distributions[*index]; + for (package, indexes) in &self.by_name { + let mut distributions = indexes.iter().flat_map(|index| &self.distributions[*index]); - // Determine the dependencies for the given package. - let Ok(metadata) = distribution.metadata() else { - diagnostics.push(Diagnostic::IncompletePackage { - package: package.clone(), - path: distribution.path().to_owned(), - }); + // Find the installed distribution for the given package. + let Some(distribution) = distributions.next() else { continue; }; - // Verify that the package is compatible with the current Python version. - if let Some(requires_python) = metadata.requires_python.as_ref() { - if !requires_python.contains(self.venv.interpreter().python_version()) { - diagnostics.push(Diagnostic::IncompatiblePythonVersion { - package: package.clone(), - version: self.venv.interpreter().python_version().clone(), - requires_python: requires_python.clone(), - }); - } + if let Some(conflict) = distributions.next() { + // There are multiple installed distributions for the same package. + diagnostics.push(Diagnostic::DuplicatePackage { + package: package.clone(), + paths: std::iter::once(distribution.path().to_owned()) + .chain(std::iter::once(conflict.path().to_owned())) + .chain(distributions.map(|dist| dist.path().to_owned())) + .collect(), + }); + continue; } - // Verify that the dependencies are installed. - for dependency in &metadata.requires_dist { - if !dependency.evaluate_markers(self.venv.interpreter().markers(), &[]) { + for index in indexes { + let Some(distribution) = &self.distributions[*index] else { continue; - } + }; - let Some(installed) = self - .by_name - .get(&dependency.name) - .map(|idx| &self.distributions[*idx]) - else { - diagnostics.push(Diagnostic::MissingDependency { + // Determine the dependencies for the given package. + let Ok(metadata) = distribution.metadata() else { + diagnostics.push(Diagnostic::IncompletePackage { package: package.clone(), - requirement: dependency.clone(), + path: distribution.path().to_owned(), }); continue; }; - match &dependency.version_or_url { - None | Some(pep508_rs::VersionOrUrl::Url(_)) => { - // Nothing to do (accept any installed version). + // Verify that the package is compatible with the current Python version. + if let Some(requires_python) = metadata.requires_python.as_ref() { + if !requires_python.contains(self.venv.interpreter().python_version()) { + diagnostics.push(Diagnostic::IncompatiblePythonVersion { + package: package.clone(), + version: self.venv.interpreter().python_version().clone(), + requires_python: requires_python.clone(), + }); } - Some(pep508_rs::VersionOrUrl::VersionSpecifier(version_specifier)) => { - if !version_specifier.contains(installed.version()) { - diagnostics.push(Diagnostic::IncompatibleDependency { + } + + // Verify that the dependencies are installed. + for dependency in &metadata.requires_dist { + if !dependency.evaluate_markers(self.venv.interpreter().markers(), &[]) { + continue; + } + + let installed = self.get_packages(&dependency.name); + match installed.as_slice() { + [] => { + // No version installed. + diagnostics.push(Diagnostic::MissingDependency { package: package.clone(), - version: installed.version().clone(), requirement: dependency.clone(), }); } + [installed] => { + match &dependency.version_or_url { + None | Some(pep508_rs::VersionOrUrl::Url(_)) => { + // Nothing to do (accept any installed version). + } + Some(pep508_rs::VersionOrUrl::VersionSpecifier( + version_specifier, + )) => { + // The installed version doesn't satisfy the requirement. + if !version_specifier.contains(installed.version()) { + diagnostics.push(Diagnostic::IncompatibleDependency { + package: package.clone(), + version: installed.version().clone(), + requirement: dependency.clone(), + }); + } + } + } + } + _ => { + // There are multiple installed distributions for the same package. + } } } } @@ -247,85 +264,95 @@ impl<'a> SitePackages<'a> { // Verify that all editable requirements are met. for requirement in editables { - let Some(distribution) = self - .by_url - .get(requirement.raw()) - .map(|idx| &self.distributions[*idx]) - else { - // The package isn't installed. - return Ok(false); - }; - - // Recurse into the dependencies. - let metadata = distribution - .metadata() - .with_context(|| format!("Failed to read metadata for: {distribution}"))?; - - // Add the dependencies to the queue. - for dependency in metadata.requires_dist { - if dependency - .evaluate_markers(self.venv.interpreter().markers(), &requirement.extras) - { - if seen.insert(dependency.clone()) { - stack.push(dependency); + let installed = self.get_editables(requirement.raw()); + match installed.as_slice() { + [] => { + // The package isn't installed. + return Ok(false); + } + [distribution] => { + // Recurse into the dependencies. + let metadata = distribution + .metadata() + .with_context(|| format!("Failed to read metadata for: {distribution}"))?; + + // Add the dependencies to the queue. + for dependency in metadata.requires_dist { + if dependency.evaluate_markers( + self.venv.interpreter().markers(), + &requirement.extras, + ) { + if seen.insert(dependency.clone()) { + stack.push(dependency); + } + } } } + _ => { + // There are multiple installed distributions for the same package. + return Ok(false); + } } } // Verify that all non-editable requirements are met. while let Some(requirement) = stack.pop() { - let Some(distribution) = self - .by_name - .get(&requirement.name) - .map(|idx| &self.distributions[*idx]) - else { - // The package isn't installed. - return Ok(false); - }; - - // Validate that the installed version matches the requirement. - match &requirement.version_or_url { - None | Some(pep508_rs::VersionOrUrl::Url(_)) => {} - Some(pep508_rs::VersionOrUrl::VersionSpecifier(version_specifier)) => { - // The installed version doesn't satisfy the requirement. - if !version_specifier.contains(distribution.version()) { - return Ok(false); - } + let installed = self.get_packages(&requirement.name); + match installed.as_slice() { + [] => { + // The package isn't installed. + return Ok(false); } - } + [distribution] => { + // Validate that the installed version matches the requirement. + match &requirement.version_or_url { + None | Some(pep508_rs::VersionOrUrl::Url(_)) => {} + Some(pep508_rs::VersionOrUrl::VersionSpecifier(version_specifier)) => { + // The installed version doesn't satisfy the requirement. + if !version_specifier.contains(distribution.version()) { + return Ok(false); + } + } + } - // Validate that the installed version satisfies the constraints. - for constraint in constraints { - if !constraint.evaluate_markers(self.venv.interpreter().markers(), &[]) { - continue; - } + // Validate that the installed version satisfies the constraints. + for constraint in constraints { + if !constraint.evaluate_markers(self.venv.interpreter().markers(), &[]) { + continue; + } - match &constraint.version_or_url { - None | Some(pep508_rs::VersionOrUrl::Url(_)) => {} - Some(pep508_rs::VersionOrUrl::VersionSpecifier(version_specifier)) => { - // The installed version doesn't satisfy the constraint. - if !version_specifier.contains(distribution.version()) { - return Ok(false); + match &constraint.version_or_url { + None | Some(pep508_rs::VersionOrUrl::Url(_)) => {} + Some(pep508_rs::VersionOrUrl::VersionSpecifier(version_specifier)) => { + // The installed version doesn't satisfy the constraint. + if !version_specifier.contains(distribution.version()) { + return Ok(false); + } + } } } - } - } - // Recurse into the dependencies. - let metadata = distribution - .metadata() - .with_context(|| format!("Failed to read metadata for: {distribution}"))?; - - // Add the dependencies to the queue. - for dependency in metadata.requires_dist { - if dependency - .evaluate_markers(self.venv.interpreter().markers(), &requirement.extras) - { - if seen.insert(dependency.clone()) { - stack.push(dependency); + // Recurse into the dependencies. + let metadata = distribution + .metadata() + .with_context(|| format!("Failed to read metadata for: {distribution}"))?; + + // Add the dependencies to the queue. + for dependency in metadata.requires_dist { + if dependency.evaluate_markers( + self.venv.interpreter().markers(), + &requirement.extras, + ) { + if seen.insert(dependency.clone()) { + stack.push(dependency); + } + } } } + _ => { + // There are multiple installed distributions for the same package. + return Ok(false); + } } } @@ -335,10 +362,10 @@ impl<'a> SitePackages<'a> { impl IntoIterator for SitePackages<'_> { type Item = InstalledDist; - type IntoIter = std::vec::IntoIter; + type IntoIter = Flatten>>; fn into_iter(self) -> Self::IntoIter { - self.distributions.into_iter() + self.distributions.into_iter().flatten() } } @@ -372,6 +399,12 @@ pub enum Diagnostic { /// The dependency that is incompatible. requirement: Requirement, }, + DuplicatePackage { + /// The package that has multiple installed distributions. + package: PackageName, + /// The installed versions of the package. + paths: Vec, + }, } impl Diagnostic { @@ -401,6 +434,14 @@ impl Diagnostic { } => format!( "The package `{package}` requires `{requirement}`, but `{version}` is installed." ), + Self::DuplicatePackage { package, paths} => { + let mut paths = paths.clone(); + paths.sort(); + format!( + "The package `{package}` has multiple installed distributions:{}", + paths.iter().fold(String::new(), |acc, path| acc + &format!("\n - {}", path.display())) + ) + }, } } @@ -415,6 +456,7 @@ impl Diagnostic { requirement, .. } => name == package || &requirement.name == name, + Self::DuplicatePackage { package, .. } => name == package, } } } diff --git a/crates/uv/src/commands/pip_freeze.rs b/crates/uv/src/commands/pip_freeze.rs index 28750fd1464a..ba2215bdd220 100644 --- a/crates/uv/src/commands/pip_freeze.rs +++ b/crates/uv/src/commands/pip_freeze.rs @@ -32,7 +32,7 @@ pub(crate) fn pip_freeze(cache: &Cache, strict: bool, mut printer: Printer) -> R let site_packages = SitePackages::from_executable(&venv)?; for dist in site_packages .iter() - .sorted_unstable_by(|a, b| a.name().cmp(b.name())) + .sorted_unstable_by(|a, b| a.name().cmp(b.name()).then(a.version().cmp(b.version()))) { println!("{dist}"); } diff --git a/crates/uv/src/commands/pip_install.rs b/crates/uv/src/commands/pip_install.rs index f9a931fde2ca..0745bc7ea783 100644 --- a/crates/uv/src/commands/pip_install.rs +++ b/crates/uv/src/commands/pip_install.rs @@ -631,6 +631,7 @@ async fn install( .name() .cmp(b.dist.name()) .then_with(|| a.kind.cmp(&b.kind)) + .then_with(|| a.dist.installed_version().cmp(&b.dist.installed_version())) }) { match event.kind { diff --git a/crates/uv/src/commands/pip_sync.rs b/crates/uv/src/commands/pip_sync.rs index 39aa3523bbad..6b894cd65166 100644 --- a/crates/uv/src/commands/pip_sync.rs +++ b/crates/uv/src/commands/pip_sync.rs @@ -300,6 +300,7 @@ pub(crate) async fn pip_sync( .name() .cmp(b.dist.name()) .then_with(|| a.kind.cmp(&b.kind)) + .then_with(|| a.dist.installed_version().cmp(&b.dist.installed_version())) }) { match event.kind { @@ -397,24 +398,32 @@ async fn resolve_editables( for editable in editables { match reinstall { Reinstall::None => { - if let Some(dist) = site_packages.get_editable(editable.raw()) { - installed.push(dist.clone()); - } else { - uninstalled.push(editable); + let existing = site_packages.get_editables(editable.raw()); + match existing.as_slice() { + [] => uninstalled.push(editable), + [dist] => installed.push((*dist).clone()), + _ => { + uninstalled.push(editable); + } } } Reinstall::All => { uninstalled.push(editable); } Reinstall::Packages(packages) => { - if let Some(dist) = site_packages.get_editable(editable.raw()) { - if packages.contains(dist.name()) { + let existing = site_packages.get_editables(editable.raw()); + match existing.as_slice() { + [] => uninstalled.push(editable), + [dist] => { + if packages.contains(dist.name()) { + uninstalled.push(editable); + } else { + installed.push((*dist).clone()); + } + } + _ => { uninstalled.push(editable); - } else { - installed.push(dist.clone()); } - } else { - uninstalled.push(editable); } } } diff --git a/crates/uv/src/commands/pip_uninstall.rs b/crates/uv/src/commands/pip_uninstall.rs index 5209f562948f..5aa9e8b9d59a 100644 --- a/crates/uv/src/commands/pip_uninstall.rs +++ b/crates/uv/src/commands/pip_uninstall.rs @@ -78,9 +78,8 @@ pub(crate) async fn pip_uninstall( // Identify all packages that are installed. for package in &packages { - if let Some(distribution) = site_packages.get(package) { - distributions.push(distribution); - } else { + let installed = site_packages.get_packages(package); + if installed.is_empty() { writeln!( printer, "{}{} Skipping {} as it is not installed.", @@ -88,14 +87,15 @@ pub(crate) async fn pip_uninstall( ":".bold(), package.as_ref().bold() )?; - }; + } else { + distributions.extend(installed); + } } // Identify all editables that are installed. for editable in &editables { - if let Some(distribution) = site_packages.get_editable(editable) { - distributions.push(distribution); - } else { + let installed = site_packages.get_editables(editable); + if installed.is_empty() { writeln!( printer, "{}{} Skipping {} as it is not installed.", @@ -103,7 +103,9 @@ pub(crate) async fn pip_uninstall( ":".bold(), editable.as_ref().bold() )?; - }; + } else { + distributions.extend(installed); + } } // Deduplicate, since a package could be listed both by name and editable URL. diff --git a/crates/uv/tests/common/mod.rs b/crates/uv/tests/common/mod.rs index 38696e4e1019..1faf86a94dc6 100644 --- a/crates/uv/tests/common/mod.rs +++ b/crates/uv/tests/common/mod.rs @@ -367,6 +367,21 @@ pub fn run_and_format<'a>( (snapshot, output) } +/// Recursively copy a directory and its contents. +pub fn copy_dir_all(src: impl AsRef, dst: impl AsRef) -> std::io::Result<()> { + std::fs::create_dir_all(&dst)?; + for entry in std::fs::read_dir(src)? { + let entry = entry?; + let ty = entry.file_type()?; + if ty.is_dir() { + copy_dir_all(entry.path(), dst.as_ref().join(entry.file_name()))?; + } else { + std::fs::copy(entry.path(), dst.as_ref().join(entry.file_name()))?; + } + } + Ok(()) +} + /// Run [`assert_cmd_snapshot!`], with default filters or with custom filters. /// /// By default, the filters will search for the generally windows-only deps colorama and tzdata, diff --git a/crates/uv/tests/pip_freeze.rs b/crates/uv/tests/pip_freeze.rs new file mode 100644 index 000000000000..4db47dd352ae --- /dev/null +++ b/crates/uv/tests/pip_freeze.rs @@ -0,0 +1,159 @@ +#![cfg(all(feature = "python", feature = "pypi"))] + +use std::process::Command; + +use anyhow::Result; +use assert_fs::prelude::*; + +use crate::common::{get_bin, uv_snapshot, TestContext}; + +mod common; + +/// Create a `pip freeze` command with options shared across scenarios. +fn command(context: &TestContext) -> Command { + let mut command = Command::new(get_bin()); + command + .arg("pip") + .arg("freeze") + .arg("--cache-dir") + .arg(context.cache_dir.path()) + .env("VIRTUAL_ENV", context.venv.as_os_str()) + .current_dir(&context.temp_dir); + command +} + +/// Create a `pip sync` command with options shared across scenarios. +fn sync_command(context: &TestContext) -> Command { + let mut command = Command::new(get_bin()); + command + .arg("pip") + .arg("sync") + .arg("--cache-dir") + .arg(context.cache_dir.path()) + .env("VIRTUAL_ENV", context.venv.as_os_str()) + .current_dir(&context.temp_dir); + command +} + +/// List multiple installed packages in a virtual environment. +#[test] +fn freeze_many() -> Result<()> { + let context = TestContext::new("3.12"); + + let requirements_txt = context.temp_dir.child("requirements.txt"); + requirements_txt.write_str("MarkupSafe==2.1.3\ntomli==2.0.1")?; + + // Run `pip sync`. + uv_snapshot!(sync_command(&context) + .arg("requirements.txt"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 2 packages in [TIME] + Downloaded 2 packages in [TIME] + Installed 2 packages in [TIME] + + markupsafe==2.1.3 + + tomli==2.0.1 + "### + ); + + // Run `pip freeze`. + uv_snapshot!(command(&context) + .arg("--strict"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + markupsafe==2.1.3 + tomli==2.0.1 + + ----- stderr ----- + "### + ); + + Ok(()) +} + +/// List a package with multiple installed distributions in a virtual environment. +#[test] +#[cfg(unix)] +fn freeze_duplicate() -> Result<()> { + use assert_cmd::assert::OutputAssertExt; + + use crate::common::{copy_dir_all, INSTA_FILTERS}; + + // Sync a version of `pip` into a virtual environment. + let context1 = TestContext::new("3.12"); + let requirements_txt = context1.temp_dir.child("requirements.txt"); + requirements_txt.write_str("pip==21.3.1")?; + + // Run `pip sync`. + Command::new(get_bin()) + .arg("pip") + .arg("sync") + .arg(requirements_txt.path()) + .arg("--cache-dir") + .arg(context1.cache_dir.path()) + .env("VIRTUAL_ENV", context1.venv.as_os_str()) + .assert() + .success(); + + // Sync a different version of `pip` into a virtual environment. + let context2 = TestContext::new("3.12"); + let requirements_txt = context2.temp_dir.child("requirements.txt"); + requirements_txt.write_str("pip==22.1.1")?; + + // Run `pip sync`. + Command::new(get_bin()) + .arg("pip") + .arg("sync") + .arg(requirements_txt.path()) + .arg("--cache-dir") + .arg(context2.cache_dir.path()) + .env("VIRTUAL_ENV", context2.venv.as_os_str()) + .assert() + .success(); + + // Copy the virtual environment to a new location. + copy_dir_all( + context2 + .venv + .join("lib/python3.12/site-packages/pip-22.1.1.dist-info"), + context1 + .venv + .join("lib/python3.12/site-packages/pip-22.1.1.dist-info"), + )?; + + // Run `pip freeze`. + let filters = INSTA_FILTERS + .iter() + .chain(&[ + ( + ".*/lib/python3.12/site-packages/pip-22.1.1.dist-info", + "/pip-22.1.1.dist-info", + ), + ( + ".*/lib/python3.12/site-packages/pip-21.3.1.dist-info", + "/pip-21.3.1.dist-info", + ), + ]) + .copied() + .collect::>(); + + uv_snapshot!(filters, command(&context1).arg("--strict"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + pip==21.3.1 + pip==22.1.1 + + ----- stderr ----- + warning: The package `pip` has multiple installed distributions: + /pip-21.3.1.dist-info + /pip-22.1.1.dist-info + "### + ); + + Ok(()) +} diff --git a/crates/uv/tests/pip_install.rs b/crates/uv/tests/pip_install.rs index 6cc7a6b8eb78..f216ae36b0f8 100644 --- a/crates/uv/tests/pip_install.rs +++ b/crates/uv/tests/pip_install.rs @@ -1700,3 +1700,73 @@ fn config_settings() -> Result<()> { Ok(()) } + +/// Reinstall a duplicate package in a virtual environment. +#[test] +#[cfg(unix)] +fn reinstall_duplicate() -> Result<()> { + use crate::common::copy_dir_all; + + // Sync a version of `pip` into a virtual environment. + let context1 = TestContext::new("3.12"); + let requirements_txt = context1.temp_dir.child("requirements.txt"); + requirements_txt.write_str("pip==21.3.1")?; + + // Run `pip sync`. + Command::new(get_bin()) + .arg("pip") + .arg("sync") + .arg(requirements_txt.path()) + .arg("--cache-dir") + .arg(context1.cache_dir.path()) + .env("VIRTUAL_ENV", context1.venv.as_os_str()) + .assert() + .success(); + + // Sync a different version of `pip` into a virtual environment. + let context2 = TestContext::new("3.12"); + let requirements_txt = context2.temp_dir.child("requirements.txt"); + requirements_txt.write_str("pip==22.1.1")?; + + // Run `pip sync`. + Command::new(get_bin()) + .arg("pip") + .arg("sync") + .arg(requirements_txt.path()) + .arg("--cache-dir") + .arg(context2.cache_dir.path()) + .env("VIRTUAL_ENV", context2.venv.as_os_str()) + .assert() + .success(); + + // Copy the virtual environment to a new location. + copy_dir_all( + context2 + .venv + .join("lib/python3.12/site-packages/pip-22.1.1.dist-info"), + context1 + .venv + .join("lib/python3.12/site-packages/pip-22.1.1.dist-info"), + )?; + + // Run `pip install`. + uv_snapshot!(command(&context1) + .arg("pip") + .arg("--reinstall"), + @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 1 package in [TIME] + Downloaded 1 package in [TIME] + Installed 1 package in [TIME] + - pip==21.3.1 + - pip==22.1.1 + + pip==23.3.1 + "### + ); + + Ok(()) +} diff --git a/crates/uv/tests/pip_uninstall.rs b/crates/uv/tests/pip_uninstall.rs index e3832062184c..64fcf2881a34 100644 --- a/crates/uv/tests/pip_uninstall.rs +++ b/crates/uv/tests/pip_uninstall.rs @@ -3,8 +3,9 @@ use std::process::Command; use anyhow::Result; use assert_cmd::prelude::*; use assert_fs::prelude::*; -use common::{uv_snapshot, INSTA_FILTERS}; use url::Url; + +use common::{uv_snapshot, INSTA_FILTERS}; use uv_fs::Normalized; use crate::common::{get_bin, venv_to_interpreter, TestContext}; @@ -552,3 +553,76 @@ fn uninstall_duplicate_editable() -> Result<()> { Ok(()) } + +/// Uninstall a duplicate package in a virtual environment. +#[test] +#[cfg(unix)] +fn uninstall_duplicate() -> Result<()> { + use crate::common::copy_dir_all; + + // Sync a version of `pip` into a virtual environment. + let context1 = TestContext::new("3.12"); + let requirements_txt = context1.temp_dir.child("requirements.txt"); + requirements_txt.touch()?; + requirements_txt.write_str("pip==21.3.1")?; + + // Run `pip sync`. + Command::new(get_bin()) + .arg("pip") + .arg("sync") + .arg(requirements_txt.path()) + .arg("--cache-dir") + .arg(context1.cache_dir.path()) + .env("VIRTUAL_ENV", context1.venv.as_os_str()) + .assert() + .success(); + + // Sync a different version of `pip` into a virtual environment. + let context2 = TestContext::new("3.12"); + let requirements_txt = context2.temp_dir.child("requirements.txt"); + requirements_txt.touch()?; + requirements_txt.write_str("pip==22.1.1")?; + + // Run `pip sync`. + Command::new(get_bin()) + .arg("pip") + .arg("sync") + .arg(requirements_txt.path()) + .arg("--cache-dir") + .arg(context2.cache_dir.path()) + .env("VIRTUAL_ENV", context2.venv.as_os_str()) + .assert() + .success(); + + // Copy the virtual environment to a new location. + copy_dir_all( + context2 + .venv + .join("lib/python3.12/site-packages/pip-22.1.1.dist-info"), + context1 + .venv + .join("lib/python3.12/site-packages/pip-22.1.1.dist-info"), + )?; + + // Run `pip uninstall`. + uv_snapshot!(Command::new(get_bin()) + .arg("pip") + .arg("uninstall") + .arg("pip") + .arg("--cache-dir") + .arg(context1.cache_dir.path()) + .env("VIRTUAL_ENV", context1.venv.as_os_str()) + .current_dir(&context1.temp_dir), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Uninstalled 2 packages in [TIME] + - pip==21.3.1 + - pip==22.1.1 + "### + ); + + Ok(()) +} diff --git a/scripts/editable-installs/black_editable/black2/__init__.py b/scripts/editable-installs/black_editable/black/__init__.py similarity index 100% rename from scripts/editable-installs/black_editable/black2/__init__.py rename to scripts/editable-installs/black_editable/black/__init__.py diff --git a/scripts/editable-installs/black_editable/pyproject.toml b/scripts/editable-installs/black_editable/pyproject.toml index 4e3bf1f6ffe4..4f8bd991b624 100644 --- a/scripts/editable-installs/black_editable/pyproject.toml +++ b/scripts/editable-installs/black_editable/pyproject.toml @@ -1,5 +1,5 @@ [project] -name = "black2" +name = "black" version = "0.1.0" description = "Default template for a Flit project" authors = [