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

List and uninstall legacy editables #3415

Merged
merged 5 commits into from
May 7, 2024
Merged
Show file tree
Hide file tree
Changes from 4 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
9 changes: 5 additions & 4 deletions PIP_COMPATIBILITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -325,7 +325,8 @@ authentication. uv attaches authentication to all requests for hosts with creden
uv does not support features that are considered legacy or deprecated in `pip`. For example,
uv does not support `.egg`-style distributions.

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`.
However, uv does have partial support for `.egg-info`-style distributions (which are occasionally
found in Docker images and Conda environments) and legacy editable `.egg-link`-style distributions.
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`.
108 changes: 91 additions & 17 deletions crates/distribution-types/src/installed.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ pub enum InstalledDist {
Url(InstalledDirectUrlDist),
/// The distribution was derived from pre-existing `.egg-info` directory.
EggInfo(InstalledEggInfo),
/// The distribution was derived from an `.egg-link` pointer.
LegacyEditable(InstalledLegacyEditable),
}

#[derive(Debug, Clone)]
Expand All @@ -48,11 +50,14 @@ pub struct InstalledEggInfo {
pub path: PathBuf,
}

/// The format of the distribution.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Format {
DistInfo,
EggInfo,
#[derive(Debug, Clone)]
pub struct InstalledLegacyEditable {
pub name: PackageName,
pub version: Version,
pub egg_link: PathBuf,
pub target: PathBuf,
pub target_url: Url,
pub egg_info: PathBuf,
}

impl InstalledDist {
Expand Down Expand Up @@ -125,16 +130,63 @@ impl InstalledDist {
})));
}

Ok(None)
}
// Ex) `zstandard.egg-link`
if path.extension().is_some_and(|ext| ext == "egg-link") {
let Some(file_stem) = path.file_stem() else {
return Ok(None);
};
let Some(file_stem) = file_stem.to_str() else {
return 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,
// https://setuptools.pypa.io/en/latest/deprecated/python_eggs.html#egg-links
// https://github.com/pypa/pip/blob/946f95d17431f645da8e2e0bf4054a72db5be766/src/pip/_internal/metadata/importlib/_envs.py#L86-L108
let contents = fs::read_to_string(path)?;
let target = if let Some(line) = contents.lines().find(|line| !line.is_empty()) {
PathBuf::from(line.trim())
} else {
warn!("Invalid `.egg-link` file: {path:?}");
return Ok(None);
};

// Match pip, but note setuptools only puts absolute paths in `.egg-link` files.
let target = path
.parent()
.ok_or_else(|| anyhow!("Invalid `.egg-link` path: {}", path.user_display()))?
.join(target);

// Normalisation comes from `pkg_resources.to_filename`.
let egg_info = target.join(file_stem.replace('-', "_") + ".egg-info");
let url = Url::from_file_path(&target)
.map_err(|()| anyhow!("Invalid `.egg-link` target: {}", target.user_display()))?;

// Mildly unfortunate that we must read metadata to get the version.
let content = match fs::read(egg_info.join("PKG-INFO")) {
Ok(content) => content,
Err(err) => {
warn!("Failed to read metadata for {path:?}: {err}");
return Ok(None);
}
};
let metadata = match pypi_types::Metadata23::parse_pkg_info(&content) {
Ok(metadata) => metadata,
Err(err) => {
warn!("Failed to parse metadata for {path:?}: {err}");
return Ok(None);
}
};

return Ok(Some(Self::LegacyEditable(InstalledLegacyEditable {
name: metadata.name,
version: metadata.version,
egg_link: path.to_path_buf(),
target,
target_url: url,
egg_info,
})));
}

Ok(None)
}

/// Return the [`Path`] at which the distribution is stored on-disk.
Expand All @@ -143,6 +195,7 @@ impl InstalledDist {
Self::Registry(dist) => &dist.path,
Self::Url(dist) => &dist.path,
Self::EggInfo(dist) => &dist.path,
Self::LegacyEditable(dist) => &dist.egg_info,
}
}

Expand All @@ -152,6 +205,7 @@ impl InstalledDist {
Self::Registry(dist) => &dist.version,
Self::Url(dist) => &dist.version,
Self::EggInfo(dist) => &dist.version,
Self::LegacyEditable(dist) => &dist.version,
}
}

Expand All @@ -167,8 +221,8 @@ impl InstalledDist {

/// Read the `METADATA` file from a `.dist-info` directory.
pub fn metadata(&self) -> Result<pypi_types::Metadata23> {
match self.format() {
Format::DistInfo => {
match self {
Self::Registry(_) | Self::Url(_) => {
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
Expand All @@ -179,8 +233,12 @@ impl InstalledDist {
)
})
}
Format::EggInfo => {
let path = self.path().join("PKG-INFO");
Self::EggInfo(_) | Self::LegacyEditable(_) => {
let path = match self {
Self::EggInfo(dist) => dist.path.join("PKG-INFO"),
Self::LegacyEditable(dist) => dist.egg_info.join("PKG-INFO"),
_ => unreachable!(),
};
let contents = fs::read(&path)?;
pypi_types::Metadata23::parse_metadata(&contents).with_context(|| {
format!(
Expand Down Expand Up @@ -208,6 +266,7 @@ impl InstalledDist {
Self::Registry(_) => false,
Self::Url(dist) => dist.editable,
Self::EggInfo(_) => false,
Self::LegacyEditable(_) => true,
}
}

Expand All @@ -217,6 +276,7 @@ impl InstalledDist {
Self::Registry(_) => None,
Self::Url(dist) => dist.editable.then_some(&dist.url),
Self::EggInfo(_) => None,
Self::LegacyEditable(dist) => Some(&dist.target_url),
}
}
}
Expand Down Expand Up @@ -245,12 +305,19 @@ impl Name for InstalledEggInfo {
}
}

impl Name for InstalledLegacyEditable {
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(),
Self::LegacyEditable(dist) => dist.name(),
}
}
}
Expand All @@ -273,12 +340,19 @@ impl InstalledMetadata for InstalledEggInfo {
}
}

impl InstalledMetadata for InstalledLegacyEditable {
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(),
Self::LegacyEditable(dist) => dist.installed_version(),
}
}
}
4 changes: 3 additions & 1 deletion 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_egg, uninstall_wheel, Uninstall};
pub use uninstall::{uninstall_egg, uninstall_legacy_editable, uninstall_wheel, Uninstall};
use uv_fs::Simplified;
use uv_normalize::PackageName;

Expand Down Expand Up @@ -108,4 +108,6 @@ pub enum Error {
MismatchedName(PackageName, PackageName),
#[error("Wheel version does not match filename: {0} != {1}")]
MismatchedVersion(Version, Version),
#[error("Invalid egg-link")]
InvalidEggLink(PathBuf),
}
68 changes: 68 additions & 0 deletions crates/install-wheel-rs/src/uninstall.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ use std::collections::BTreeSet;
use std::path::{Component, Path, PathBuf};

use fs_err as fs;
use once_cell::sync::Lazy;
use std::sync::Mutex;
use tracing::debug;
use uv_fs::write_atomic_sync;

use crate::wheel::read_record_file;
use crate::Error;
Expand Down Expand Up @@ -208,6 +211,71 @@ pub fn uninstall_egg(egg_info: &Path) -> Result<Uninstall, Error> {
})
}

static EASY_INSTALL_PTH: Lazy<Mutex<i32>> = Lazy::new(Mutex::default);

/// Uninstall the legacy editable represented by the `.egg-link` file.
///
/// See: <https://github.com/pypa/pip/blob/41587f5e0017bcd849f42b314dc8a34a7db75621/src/pip/_internal/req/req_uninstall.py#L534-L552>
pub fn uninstall_legacy_editable(egg_link: &Path) -> Result<Uninstall, Error> {
let mut file_count = 0usize;

// Find the target line in the `.egg-link` file.
let contents = fs::read_to_string(egg_link)?;
let target_line = contents
.lines()
.find_map(|line| {
let line = line.trim();
if line.is_empty() {
None
} else {
Some(line)
}
})
.ok_or_else(|| Error::InvalidEggLink(egg_link.to_path_buf()))?;

match fs::remove_file(egg_link) {
Ok(()) => {
debug!("Removed file: {}", egg_link.display());
file_count += 1;
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
Err(err) => return Err(err.into()),
}

let site_package = egg_link.parent().ok_or(Error::BrokenVenv(
"`.egg-link` file is not in a directory".to_string(),
))?;
let easy_install = site_package.join("easy-install.pth");

// Since uv has an environment lock, it's enough to add a mutex here to ensure we never
// lose writes to `easy-install.pth` (this is the only place in uv where `easy-install.pth`
// is modified).
let _guard = EASY_INSTALL_PTH.lock().unwrap();

let content = fs::read_to_string(&easy_install)?;
let mut new_content = String::with_capacity(content.len());
let mut removed = false;

// https://github.com/pypa/pip/blob/41587f5e0017bcd849f42b314dc8a34a7db75621/src/pip/_internal/req/req_uninstall.py#L634
for line in content.lines() {
if !removed && line.trim() == target_line {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

removed = true;
} else {
new_content.push_str(line);
new_content.push('\n');
}
}
if removed {
write_atomic_sync(&easy_install, new_content)?;
debug!("Removed line from `easy-install.pth`: {target_line}");
}

Ok(Uninstall {
file_count,
dir_count: 0usize,
})
}

#[derive(Debug, Default)]
pub struct Uninstall {
/// The number of files that were removed during the uninstallation.
Expand Down
11 changes: 8 additions & 3 deletions crates/uv-installer/src/site_packages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,16 +50,21 @@ impl<'a> SitePackages<'a> {
let site_packages = match fs::read_dir(site_packages) {
Ok(site_packages) => {
// Collect sorted directory paths; `read_dir` is not stable across platforms
let directories: BTreeSet<_> = site_packages
let dist_likes: BTreeSet<_> = site_packages
.filter_map(|read_dir| match read_dir {
Ok(entry) => match entry.file_type() {
Ok(file_type) => file_type.is_dir().then_some(Ok(entry.path())),
Ok(file_type) => (file_type.is_dir()
|| entry
.path()
.extension()
.map_or(false, |ext| ext == "egg-link"))
.then_some(Ok(entry.path())),
Err(err) => Some(Err(err)),
},
Err(err) => Some(Err(err)),
})
.collect::<Result<_, std::io::Error>>()?;
directories
dist_likes
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
return Ok(Self {
Expand Down
16 changes: 10 additions & 6 deletions crates/uv-installer/src/uninstall.rs
Original file line number Diff line number Diff line change
@@ -1,17 +1,21 @@
use anyhow::Result;

use distribution_types::{Format, InstalledDist};
use distribution_types::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();
let format = dist.format();
move || match format {
Format::DistInfo => install_wheel_rs::uninstall_wheel(&path),
Format::EggInfo => install_wheel_rs::uninstall_egg(&path),
let dist = dist.clone();
move || match dist {
InstalledDist::Registry(_) | InstalledDist::Url(_) => {
install_wheel_rs::uninstall_wheel(dist.path())
}
InstalledDist::EggInfo(_) => install_wheel_rs::uninstall_egg(dist.path()),
InstalledDist::LegacyEditable(dist) => {
install_wheel_rs::uninstall_legacy_editable(&dist.egg_link)
}
}
})
.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 @@ -65,6 +65,9 @@ pub(crate) fn pip_freeze(
InstalledDist::EggInfo(dist) => {
writeln!(printer.stdout(), "{}=={}", dist.name().bold(), dist.version)?;
}
InstalledDist::LegacyEditable(dist) => {
writeln!(printer.stdout(), "-e {}", dist.target.display())?;
}
}
}

Expand Down
Loading
Loading