Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 1 commit into from
May 6, 2024
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
8 changes: 5 additions & 3 deletions PIP_COMPATIBILITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
96 changes: 91 additions & 5 deletions crates/distribution-types/src/installed.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand All @@ -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);
Expand Down Expand Up @@ -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,
}
}

Expand All @@ -100,6 +151,7 @@ impl InstalledDist {
match self {
Self::Registry(dist) => &dist.version,
Self::Url(dist) => &dist.version,
Self::EggInfo(dist) => &dist.version,
}
}

Expand All @@ -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.
Expand All @@ -137,6 +207,7 @@ impl InstalledDist {
match self {
Self::Registry(_) => false,
Self::Url(dist) => dist.editable,
Self::EggInfo(_) => false,
}
}

Expand All @@ -145,6 +216,7 @@ impl InstalledDist {
match self {
Self::Registry(_) => None,
Self::Url(dist) => dist.editable.then_some(&dist.url),
Self::EggInfo(_) => None,
}
}
}
Expand All @@ -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(),
}
}
}
Expand All @@ -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(),
}
}
}
6 changes: 4 additions & 2 deletions crates/install-wheel-rs/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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(
Expand Down
92 changes: 91 additions & 1 deletion crates/install-wheel-rs/src/uninstall.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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.
Expand Down
8 changes: 6 additions & 2 deletions crates/uv-installer/src/uninstall.rs
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
use anyhow::Result;

use distribution_types::InstalledDist;
use distribution_types::{Format, InstalledDist};

/// Uninstall a package from the specified Python environment.
pub async fn uninstall(
dist: &InstalledDist,
) -> 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??;

Expand Down
3 changes: 3 additions & 0 deletions crates/uv/src/commands/pip_freeze.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)?;
}
}
}

Expand Down
Loading
Loading