mirror of https://github.com/astral-sh/uv
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:
parent
098944fc7d
commit
26045e5f59
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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??;
|
||||
|
||||
|
|
|
|||
|
|
@ -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)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue