diff --git a/PIP_COMPATIBILITY.md b/PIP_COMPATIBILITY.md index 91d65d1244b..ccd48473359 100644 --- a/PIP_COMPATIBILITY.md +++ b/PIP_COMPATIBILITY.md @@ -320,10 +320,12 @@ Unlike `pip`, uv does not enable keyring authentication by default. Unlike `pip`, uv does not wait until a request returns a HTTP 401 before searching for authentication. uv attaches authentication to all requests for hosts with credentials available. -## Legacy features +## `egg` support uv does not support features that are considered legacy or deprecated in `pip`. For example, uv does not support `.egg`-style distributions. -uv does not plan to support features that the `pip` maintainers explicitly recommend against, -like `--target`. +However, uv does have partial support for `.egg-info`-style distributions, which are occasionally +found in Docker images and Conda environments. Specifically, uv does not support installing new +`.egg-info`-style distributions, but it will respect any existing `.egg-info`-style distributions +during resolution, and can uninstall `.egg-info` distributions with `uv pip uninstall`. diff --git a/crates/distribution-types/src/installed.rs b/crates/distribution-types/src/installed.rs index 870694aeda0..7b95e0b3e39 100644 --- a/crates/distribution-types/src/installed.rs +++ b/crates/distribution-types/src/installed.rs @@ -20,6 +20,8 @@ pub enum InstalledDist { Registry(InstalledRegistryDist), /// The distribution was derived from an arbitrary URL. Url(InstalledDirectUrlDist), + /// The distribution was derived from pre-existing `.egg-info` directory. + EggInfo(InstalledEggInfo), } #[derive(Debug, Clone)] @@ -39,11 +41,26 @@ pub struct InstalledDirectUrlDist { pub path: PathBuf, } +#[derive(Debug, Clone)] +pub struct InstalledEggInfo { + pub name: PackageName, + pub version: Version, + pub path: PathBuf, +} + +/// The format of the distribution. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Format { + DistInfo, + EggInfo, +} + impl InstalledDist { /// Try to parse a distribution from a `.dist-info` directory name (like `django-5.0a1.dist-info`). /// /// See: pub fn try_from_path(path: &Path) -> Result> { + // Ex) `cffi-1.16.0.dist-info` if path.extension().is_some_and(|ext| ext == "dist-info") { let Some(file_stem) = path.file_stem() else { return Ok(None); @@ -84,14 +101,48 @@ impl InstalledDist { }))) }; } + + // Ex) `zstandard-0.22.0-py3.12.egg-info` + if path.extension().is_some_and(|ext| ext == "egg-info") { + let Some(file_stem) = path.file_stem() else { + return Ok(None); + }; + let Some(file_stem) = file_stem.to_str() else { + return Ok(None); + }; + let Some((name, version_python)) = file_stem.split_once('-') else { + return Ok(None); + }; + let Some((version, _)) = version_python.split_once('-') else { + return Ok(None); + }; + let name = PackageName::from_str(name)?; + let version = Version::from_str(version).map_err(|err| anyhow!(err))?; + return Ok(Some(Self::EggInfo(InstalledEggInfo { + name, + version, + path: path.to_path_buf(), + }))); + } + Ok(None) } + /// Return the [`Format`] of the distribution. + pub fn format(&self) -> Format { + match self { + Self::Registry(_) => Format::DistInfo, + Self::Url(_) => Format::DistInfo, + Self::EggInfo(_) => Format::EggInfo, + } + } + /// Return the [`Path`] at which the distribution is stored on-disk. pub fn path(&self) -> &Path { match self { Self::Registry(dist) => &dist.path, Self::Url(dist) => &dist.path, + Self::EggInfo(dist) => &dist.path, } } @@ -100,6 +151,7 @@ impl InstalledDist { match self { Self::Registry(dist) => &dist.version, Self::Url(dist) => &dist.version, + Self::EggInfo(dist) => &dist.version, } } @@ -115,11 +167,29 @@ impl InstalledDist { /// Read the `METADATA` file from a `.dist-info` directory. pub fn metadata(&self) -> Result { - let path = self.path().join("METADATA"); - let contents = fs::read(&path)?; - // TODO(zanieb): Update this to use thiserror so we can unpack parse errors downstream - pypi_types::Metadata23::parse_metadata(&contents) - .with_context(|| format!("Failed to parse METADATA file at: {}", path.user_display())) + match self.format() { + Format::DistInfo => { + let path = self.path().join("METADATA"); + let contents = fs::read(&path)?; + // TODO(zanieb): Update this to use thiserror so we can unpack parse errors downstream + pypi_types::Metadata23::parse_metadata(&contents).with_context(|| { + format!( + "Failed to parse `METADATA` file at: {}", + path.user_display() + ) + }) + } + Format::EggInfo => { + let path = self.path().join("PKG-INFO"); + let contents = fs::read(&path)?; + pypi_types::Metadata23::parse_metadata(&contents).with_context(|| { + format!( + "Failed to parse `PKG-INFO` file at: {}", + path.user_display() + ) + }) + } + } } /// Return the `INSTALLER` of the distribution. @@ -137,6 +207,7 @@ impl InstalledDist { match self { Self::Registry(_) => false, Self::Url(dist) => dist.editable, + Self::EggInfo(_) => false, } } @@ -145,6 +216,7 @@ impl InstalledDist { match self { Self::Registry(_) => None, Self::Url(dist) => dist.editable.then_some(&dist.url), + Self::EggInfo(_) => None, } } } @@ -167,11 +239,18 @@ impl Name for InstalledDirectUrlDist { } } +impl Name for InstalledEggInfo { + fn name(&self) -> &PackageName { + &self.name + } +} + impl Name for InstalledDist { fn name(&self) -> &PackageName { match self { Self::Registry(dist) => dist.name(), Self::Url(dist) => dist.name(), + Self::EggInfo(dist) => dist.name(), } } } @@ -188,11 +267,18 @@ impl InstalledMetadata for InstalledDirectUrlDist { } } +impl InstalledMetadata for InstalledEggInfo { + fn installed_version(&self) -> InstalledVersion { + InstalledVersion::Version(&self.version) + } +} + impl InstalledMetadata for InstalledDist { fn installed_version(&self) -> InstalledVersion { match self { Self::Registry(dist) => dist.installed_version(), Self::Url(dist) => dist.installed_version(), + Self::EggInfo(dist) => dist.installed_version(), } } } diff --git a/crates/install-wheel-rs/src/lib.rs b/crates/install-wheel-rs/src/lib.rs index 519b2d5cbbb..da2a01d3779 100644 --- a/crates/install-wheel-rs/src/lib.rs +++ b/crates/install-wheel-rs/src/lib.rs @@ -11,7 +11,7 @@ use zip::result::ZipError; use pep440_rs::Version; use platform_tags::{Arch, Os}; use pypi_types::Scheme; -pub use uninstall::{uninstall_wheel, Uninstall}; +pub use uninstall::{uninstall_egg, uninstall_wheel, Uninstall}; use uv_fs::Simplified; use uv_normalize::PackageName; @@ -82,8 +82,10 @@ pub enum Error { DirectUrlJson(#[from] serde_json::Error), #[error("No .dist-info directory found")] MissingDistInfo, - #[error("Cannot uninstall package; RECORD file not found at: {}", _0.user_display())] + #[error("Cannot uninstall package; `RECORD` file not found at: {}", _0.user_display())] MissingRecord(PathBuf), + #[error("Cannot uninstall package; `top_level.txt` file not found at: {}", _0.user_display())] + MissingTopLevel(PathBuf), #[error("Multiple .dist-info directories found: {0}")] MultipleDistInfo(String), #[error( diff --git a/crates/install-wheel-rs/src/uninstall.rs b/crates/install-wheel-rs/src/uninstall.rs index de917c09ef8..34c9962d2a4 100644 --- a/crates/install-wheel-rs/src/uninstall.rs +++ b/crates/install-wheel-rs/src/uninstall.rs @@ -7,7 +7,7 @@ use tracing::debug; use crate::wheel::read_record_file; use crate::Error; -/// Uninstall the wheel represented by the given `dist_info` directory. +/// Uninstall the wheel represented by the given `.dist-info` directory. pub fn uninstall_wheel(dist_info: &Path) -> Result { let Some(site_packages) = dist_info.parent() else { return Err(Error::BrokenVenv( @@ -118,6 +118,96 @@ pub fn uninstall_wheel(dist_info: &Path) -> Result { }) } +/// Uninstall the egg represented by the `.egg-info` directory. +/// +/// See: +pub fn uninstall_egg(egg_info: &Path) -> Result { + let mut file_count = 0usize; + let mut dir_count = 0usize; + + let dist_location = egg_info + .parent() + .expect("egg-info directory is not in a site-packages directory"); + + // Read the `namespace_packages.txt` file. + let namespace_packages = { + let namespace_packages_path = egg_info.join("namespace_packages.txt"); + match fs_err::read_to_string(namespace_packages_path) { + Ok(namespace_packages) => namespace_packages + .lines() + .map(ToString::to_string) + .collect::>(), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + vec![] + } + Err(err) => return Err(err.into()), + } + }; + + // Read the `top_level.txt` file, ignoring anything in `namespace_packages.txt`. + let top_level = { + let top_level_path = egg_info.join("top_level.txt"); + match fs_err::read_to_string(&top_level_path) { + Ok(top_level) => top_level + .lines() + .map(ToString::to_string) + .filter(|line| !namespace_packages.contains(line)) + .collect::>(), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + return Err(Error::MissingTopLevel(top_level_path)); + } + Err(err) => return Err(err.into()), + } + }; + + // Remove everything in `top_level.txt`. + for entry in top_level { + let path = dist_location.join(&entry); + + // Remove as a directory. + match fs_err::remove_dir_all(&path) { + Ok(()) => { + debug!("Removed directory: {}", path.display()); + dir_count += 1; + continue; + } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + Err(err) => return Err(err.into()), + } + + // Remove as a `.py`, `.pyc`, or `.pyo` file. + for exten in &["py", "pyc", "pyo"] { + let path = path.with_extension(exten); + match fs_err::remove_file(&path) { + Ok(()) => { + debug!("Removed file: {}", path.display()); + file_count += 1; + break; + } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + Err(err) => return Err(err.into()), + } + } + } + + // Remove the `.egg-info` directory. + match fs_err::remove_dir_all(egg_info) { + Ok(()) => { + debug!("Removed directory: {}", egg_info.display()); + dir_count += 1; + } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + Err(err) => { + return Err(err.into()); + } + } + + Ok(Uninstall { + file_count, + dir_count, + }) +} + #[derive(Debug, Default)] pub struct Uninstall { /// The number of files that were removed during the uninstallation. diff --git a/crates/uv-installer/src/uninstall.rs b/crates/uv-installer/src/uninstall.rs index cae1a2d995a..63954d98507 100644 --- a/crates/uv-installer/src/uninstall.rs +++ b/crates/uv-installer/src/uninstall.rs @@ -1,6 +1,6 @@ use anyhow::Result; -use distribution_types::InstalledDist; +use distribution_types::{Format, InstalledDist}; /// Uninstall a package from the specified Python environment. pub async fn uninstall( @@ -8,7 +8,11 @@ pub async fn uninstall( ) -> Result { let uninstall = tokio::task::spawn_blocking({ let path = dist.path().to_owned(); - move || install_wheel_rs::uninstall_wheel(&path) + let format = dist.format(); + move || match format { + Format::DistInfo => install_wheel_rs::uninstall_wheel(&path), + Format::EggInfo => install_wheel_rs::uninstall_egg(&path), + } }) .await??; diff --git a/crates/uv/src/commands/pip_freeze.rs b/crates/uv/src/commands/pip_freeze.rs index 349a167cfb2..a02101eee4a 100644 --- a/crates/uv/src/commands/pip_freeze.rs +++ b/crates/uv/src/commands/pip_freeze.rs @@ -62,6 +62,9 @@ pub(crate) fn pip_freeze( writeln!(printer.stdout(), "{} @ {}", dist.name().bold(), dist.url)?; } } + InstalledDist::EggInfo(dist) => { + writeln!(printer.stdout(), "{}=={}", dist.name().bold(), dist.version)?; + } } } diff --git a/crates/uv/tests/pip_freeze.rs b/crates/uv/tests/pip_freeze.rs index 71da76dc9f8..3e88577e99a 100644 --- a/crates/uv/tests/pip_freeze.rs +++ b/crates/uv/tests/pip_freeze.rs @@ -4,6 +4,7 @@ use std::process::Command; use anyhow::Result; use assert_cmd::prelude::*; +use assert_fs::fixture::ChildPath; use assert_fs::prelude::*; use crate::common::{get_bin, uv_snapshot, TestContext}; @@ -210,3 +211,55 @@ fn freeze_with_editable() -> Result<()> { Ok(()) } + +/// Show an `.egg-info` package in a virtual environment. +#[test] +fn freeze_with_egg_info() -> Result<()> { + let context = TestContext::new("3.12"); + + let site_packages = ChildPath::new(context.site_packages()); + + // Manually create a `.egg-info` directory. + site_packages + .child("zstandard-0.22.0-py3.12.egg-info") + .create_dir_all()?; + site_packages + .child("zstandard-0.22.0-py3.12.egg-info") + .child("top_level.txt") + .write_str("zstd")?; + site_packages + .child("zstandard-0.22.0-py3.12.egg-info") + .child("SOURCES.txt") + .write_str("")?; + site_packages + .child("zstandard-0.22.0-py3.12.egg-info") + .child("PKG-INFO") + .write_str("")?; + site_packages + .child("zstandard-0.22.0-py3.12.egg-info") + .child("dependency_links.txt") + .write_str("")?; + site_packages + .child("zstandard-0.22.0-py3.12.egg-info") + .child("entry_points.txt") + .write_str("")?; + + // Manually create the package directory. + site_packages.child("zstd").create_dir_all()?; + site_packages + .child("zstd") + .child("__init__.py") + .write_str("")?; + + // Run `pip freeze`. + uv_snapshot!(context.filters(), command(&context), @r###" + success: true + exit_code: 0 + ----- stdout ----- + zstandard==0.22.0 + + ----- stderr ----- + "###); + + Ok(()) +} diff --git a/crates/uv/tests/pip_uninstall.rs b/crates/uv/tests/pip_uninstall.rs index 99e4ab4448d..b6eca9003f8 100644 --- a/crates/uv/tests/pip_uninstall.rs +++ b/crates/uv/tests/pip_uninstall.rs @@ -2,6 +2,7 @@ use std::process::Command; use anyhow::Result; use assert_cmd::prelude::*; +use assert_fs::fixture::ChildPath; use assert_fs::prelude::*; use common::uv_snapshot; @@ -220,7 +221,7 @@ fn missing_record() -> Result<()> { ----- stdout ----- ----- stderr ----- - error: Cannot uninstall package; RECORD file not found at: [SITE_PACKAGES]/MarkupSafe-2.1.3.dist-info/RECORD + error: Cannot uninstall package; `RECORD` file not found at: [SITE_PACKAGES]/MarkupSafe-2.1.3.dist-info/RECORD "### ); @@ -418,3 +419,57 @@ fn uninstall_duplicate() -> Result<()> { Ok(()) } + +/// Uninstall a `.egg-info` package in a virtual environment. +#[test] +fn uninstall_egg_info() -> Result<()> { + let context = TestContext::new("3.12"); + + let site_packages = ChildPath::new(context.site_packages()); + + // Manually create a `.egg-info` directory. + site_packages + .child("zstandard-0.22.0-py3.12.egg-info") + .create_dir_all()?; + site_packages + .child("zstandard-0.22.0-py3.12.egg-info") + .child("top_level.txt") + .write_str("zstd")?; + site_packages + .child("zstandard-0.22.0-py3.12.egg-info") + .child("SOURCES.txt") + .write_str("")?; + site_packages + .child("zstandard-0.22.0-py3.12.egg-info") + .child("PKG-INFO") + .write_str("")?; + site_packages + .child("zstandard-0.22.0-py3.12.egg-info") + .child("dependency_links.txt") + .write_str("")?; + site_packages + .child("zstandard-0.22.0-py3.12.egg-info") + .child("entry_points.txt") + .write_str("")?; + + // Manually create the package directory. + site_packages.child("zstd").create_dir_all()?; + site_packages + .child("zstd") + .child("__init__.py") + .write_str("")?; + + // Run `pip uninstall`. + uv_snapshot!(uninstall_command(&context) + .arg("zstandard"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Uninstalled 1 package in [TIME] + - zstandard==0.22.0 + "###); + + Ok(()) +}