Skip to content

Commit

Permalink
Make egg-info filename parsing spec compliant
Browse files Browse the repository at this point in the history
  • Loading branch information
charliermarsh committed Jun 25, 2024
1 parent c28a2c7 commit 7dacaaa
Show file tree
Hide file tree
Showing 4 changed files with 192 additions and 34 deletions.
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
57 changes: 55 additions & 2 deletions crates/uv/tests/pip_freeze.rs
Original file line number Diff line number Diff line change
Expand Up @@ -192,12 +192,12 @@ fn freeze_with_editable() -> Result<()> {

/// Show an `.egg-info` package in a virtual environment.
#[test]
fn freeze_with_egg_info() -> Result<()> {
fn freeze_with_egg_info_py() -> Result<()> {
let context = TestContext::new("3.12");

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,59 @@ 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() -> 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(())
}

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

0 comments on commit 7dacaaa

Please sign in to comment.