Respect and enable uninstalls of existing `.egg-info` packages (#3380)

## Summary

Users often find themselves dropped into environments that contain
`.egg-info` packages. While we won't support installing these, it's not
hard to support identifying them (e.g., in `pip freeze`) and
_uninstalling_ them.

Closes https://github.com/astral-sh/uv/issues/2841.
Closes #2928.
Closes #3341.

## Test Plan

Ran `cargo run pip freeze --python
/opt/homebrew/Caskroom/miniforge/base/envs/TEST/bin/python`, with an
environment that includes `pip` as an `.egg-info`
(`/opt/homebrew/Caskroom/miniforge/base/envs/TEST/lib/python3.12/site-packages/pip-24.0-py3.12.egg-info`):

```
cffi @ file:///Users/runner/miniforge3/conda-bld/cffi_1696001825047/work
pip==24.0
pycparser @ file:///home/conda/feedstock_root/build_artifacts/pycparser_1711811537435/work
setuptools==69.5.1
wheel==0.43.0
```

Then ran `cargo run pip uninstall`, verified that `pip` was uninstalled,
and no longer listed in `pip freeze`.
This commit is contained in:
Charlie Marsh 2024-05-06 09:47:28 -04:00 committed by GitHub
parent 098944fc7d
commit 26045e5f59
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 309 additions and 14 deletions

View File

@ -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`.

View File

@ -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: <https://packaging.python.org/en/latest/specifications/recording-installed-packages/#recording-installed-packages>
pub fn try_from_path(path: &Path) -> Result<Option<Self>> {
// 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<pypi_types::Metadata23> {
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(),
}
}
}

View File

@ -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(

View File

@ -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<Uninstall, Error> {
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, Error> {
})
}
/// Uninstall the egg represented by the `.egg-info` directory.
///
/// See: <https://github.com/pypa/pip/blob/41587f5e0017bcd849f42b314dc8a34a7db75621/src/pip/_internal/req/req_uninstall.py#L483>
pub fn uninstall_egg(egg_info: &Path) -> Result<Uninstall, Error> {
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::<Vec<_>>(),
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::<Vec<_>>(),
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.

View File

@ -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<install_wheel_rs::Uninstall, UninstallError> {
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??;

View File

@ -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)?;
}
}
}

View File

@ -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(())
}

View File

@ -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(())
}