Skip to content

Commit

Permalink
Respect existing .egg-info packages
Browse files Browse the repository at this point in the history
  • Loading branch information
charliermarsh committed May 6, 2024
1 parent ef92c38 commit 4ee9968
Show file tree
Hide file tree
Showing 8 changed files with 309 additions and 14 deletions.
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

0 comments on commit 4ee9968

Please sign in to comment.