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

Make .egg-info filename parsing spec compliant #4533

Merged
merged 1 commit into from
Jun 25, 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
112 changes: 112 additions & 0 deletions crates/distribution-filename/src/egg.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
use std::str::FromStr;

use thiserror::Error;

use pep440_rs::{Version, VersionParseError};
use uv_normalize::{InvalidNameError, PackageName};

#[derive(Error, Debug)]
pub enum EggInfoFilenameError {
#[error("The filename \"{0}\" does not end in `.egg-info`")]
InvalidExtension(String),
#[error("The `.egg-info` filename \"{0}\" is missing a package name")]
MissingPackageName(String),
#[error("The `.egg-info` filename \"{0}\" is missing a version")]
MissingVersion(String),
#[error("The `.egg-info` filename \"{0}\" has an invalid package name")]
InvalidPackageName(String, InvalidNameError),
#[error("The `.egg-info` filename \"{0}\" has an invalid version: {1}")]
InvalidVersion(String, VersionParseError),
}

/// A filename parsed from an `.egg-info` file or directory (e.g., `zstandard-0.22.0-py3.12.egg-info`).
///
/// An `.egg-info` filename can contain up to four components, as in:
///
/// ```text
/// name ["-" version ["-py" pyver ["-" required_platform]]] "." ext
/// ```
///
/// See: <https://setuptools.pypa.io/en/latest/deprecated/python_eggs.html#filename-embedded-metadata>
#[derive(Debug, Clone)]
pub struct EggInfoFilename {
pub name: PackageName,
pub version: Version,
}

impl EggInfoFilename {
/// Parse an `.egg-info` filename, requiring at least a name and version.
pub fn parse(stem: &str) -> Result<Self, EggInfoFilenameError> {
// pip uses the following regex:
// ```python
// EGG_NAME = re.compile(
// r"""
// (?P<name>[^-]+) (
// -(?P<ver>[^-]+) (
// -py(?P<pyver>[^-]+) (
// -(?P<plat>.+)
// )?
// )?
// )?
// """,
// re.VERBOSE | re.IGNORECASE,
// ).match
// ```
let mut parts = stem.split('-');
let name = parts
.next()
.ok_or_else(|| EggInfoFilenameError::MissingPackageName(format!("{stem}.egg-info")))?;
let version = parts
.next()
.ok_or_else(|| EggInfoFilenameError::MissingVersion(format!("{stem}.egg-info")))?;
let name = PackageName::from_str(name)
.map_err(|e| EggInfoFilenameError::InvalidPackageName(format!("{stem}.egg-info"), e))?;
let version = Version::from_str(version)
.map_err(|e| EggInfoFilenameError::InvalidVersion(format!("{stem}.egg-info"), e))?;
Ok(Self { name, version })
}
}

impl FromStr for EggInfoFilename {
type Err = EggInfoFilenameError;

fn from_str(filename: &str) -> Result<Self, Self::Err> {
let stem = filename
.strip_suffix(".egg-info")
.ok_or_else(|| EggInfoFilenameError::InvalidExtension(filename.to_string()))?;
Self::parse(stem)
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn egg_info_filename() {
let filename = "zstandard-0.22.0-py3.12-darwin.egg-info";
let parsed = EggInfoFilename::from_str(filename).unwrap();
assert_eq!(parsed.name.as_ref(), "zstandard");
assert_eq!(parsed.version.to_string(), "0.22.0");

let filename = "zstandard-0.22.0-py3.12.egg-info";
let parsed = EggInfoFilename::from_str(filename).unwrap();
assert_eq!(parsed.name.as_ref(), "zstandard");
assert_eq!(parsed.version.to_string(), "0.22.0");

let filename = "zstandard-0.22.0.egg-info";
let parsed = EggInfoFilename::from_str(filename).unwrap();
assert_eq!(parsed.name.as_ref(), "zstandard");
assert_eq!(parsed.version.to_string(), "0.22.0");
}

#[test]
fn egg_info_filename_missing_version() {
let filename = "zstandard.egg-info";
let err = EggInfoFilename::from_str(filename).unwrap_err();
assert_eq!(
err.to_string(),
"The `.egg-info` filename \"zstandard.egg-info\" is missing a version"
);
}
}
2 changes: 2 additions & 0 deletions crates/distribution-filename/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@ use std::str::FromStr;
use uv_normalize::PackageName;

pub use build_tag::{BuildTag, BuildTagError};
pub use egg::{EggInfoFilename, EggInfoFilenameError};
pub use source_dist::{SourceDistExtension, SourceDistFilename, SourceDistFilenameError};
pub use wheel::{WheelFilename, WheelFilenameError};

mod build_tag;
mod egg;
mod source_dist;
mod wheel;

Expand Down
55 changes: 23 additions & 32 deletions crates/distribution-types/src/installed.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ use std::path::{Path, PathBuf};
use std::str::FromStr;

use anyhow::{anyhow, Context, Result};
use distribution_filename::EggInfoFilename;
use fs_err as fs;
use tracing::warn;
use url::Url;
Expand Down Expand Up @@ -117,46 +118,36 @@ impl InstalledDist {
};
}

// Ex) `zstandard-0.22.0-py3.12.egg-info` or `vtk-9.2.6.egg-info`
if path.extension().is_some_and(|ext| ext == "egg-info") {
// Ex) `zstandard-0.22.0-py3.12.egg-info`
if path.is_dir() {
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 {
let metadata = match fs_err::metadata(path) {
Ok(metadata) => metadata,
Err(err) => {
warn!("Invalid `.egg-info` path: {err}");
return Ok(None);
};
let name = PackageName::from_str(name)?;
let version = Version::from_str(version).map_err(|err| anyhow!(err))?;
}
};

let Some(file_stem) = path.file_stem() else {
return Ok(None);
};
let Some(file_stem) = file_stem.to_str() else {
return Ok(None);
};
let file_name = EggInfoFilename::parse(file_stem)?;

if metadata.is_dir() {
return Ok(Some(Self::EggInfoDirectory(InstalledEggInfoDirectory {
name,
version,
name: file_name.name,
version: file_name.version,
path: path.to_path_buf(),
})));
}

// Ex) `vtk-9.2.6.egg-info`
if path.is_file() {
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)) = file_stem.split_once('-') else {
return Ok(None);
};
let name = PackageName::from_str(name)?;
let version = Version::from_str(version).map_err(|err| anyhow!(err))?;
if metadata.is_file() {
return Ok(Some(Self::EggInfoFile(InstalledEggInfoFile {
name,
version,
name: file_name.name,
version: file_name.version,
path: path.to_path_buf(),
})));
}
Expand Down
92 changes: 91 additions & 1 deletion crates/uv/tests/pip_freeze.rs
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ fn freeze_with_egg_info() -> Result<()> {

let site_packages = ChildPath::new(context.site_packages());

// Manually create a `.egg-info` directory.
// Manually create an `.egg-info` directory.
site_packages
.child("zstandard-0.22.0-py3.12.egg-info")
.create_dir_all()?;
Expand Down Expand Up @@ -242,6 +242,96 @@ fn freeze_with_egg_info() -> Result<()> {
Ok(())
}

/// Show an `.egg-info` package in a virtual environment. In this case, the filename omits the
/// Python version.
#[test]
fn freeze_with_egg_info_no_py() -> Result<()> {
let context = TestContext::new("3.12");

let site_packages = ChildPath::new(context.site_packages());

// Manually create an `.egg-info` directory.
site_packages
.child("zstandard-0.22.0.egg-info")
.create_dir_all()?;
site_packages
.child("zstandard-0.22.0.egg-info")
.child("top_level.txt")
.write_str("zstd")?;
site_packages
.child("zstandard-0.22.0.egg-info")
.child("SOURCES.txt")
.write_str("")?;
site_packages
.child("zstandard-0.22.0.egg-info")
.child("PKG-INFO")
.write_str("")?;
site_packages
.child("zstandard-0.22.0.egg-info")
.child("dependency_links.txt")
.write_str("")?;
site_packages
.child("zstandard-0.22.0.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(())
}

/// Show a set of `.egg-info` files in a virtual environment.
#[test]
fn freeze_with_egg_info_file() -> Result<()> {
let context = TestContext::new("3.11");
let site_packages = ChildPath::new(context.site_packages());

// Manually create a `.egg-info` file with python version.
site_packages
.child("pycurl-7.45.1-py3.11.egg-info")
.write_str(indoc::indoc! {"
Metadata-Version: 1.1
Name: pycurl
Version: 7.45.1
"})?;

// Manually create another `.egg-info` file with no python version.
site_packages
.child("vtk-9.2.6.egg-info")
.write_str(indoc::indoc! {"
Metadata-Version: 1.1
Name: vtk
Version: 9.2.6
"})?;

// Run `pip freeze`.
uv_snapshot!(context.filters(), command(&context), @r###"
success: true
exit_code: 0
----- stdout -----
pycurl==7.45.1
vtk==9.2.6

----- stderr -----
"###);
Ok(())
}

#[test]
fn freeze_with_legacy_editable() -> Result<()> {
let context = TestContext::new("3.12");
Expand Down
Loading