From 7dacaaa0334098acf3f8dc4eb9bec84165908bc7 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Tue, 25 Jun 2024 19:14:22 -0400 Subject: [PATCH] Make egg-info filename parsing spec compliant --- crates/distribution-filename/src/egg.rs | 112 +++++++++++++++++++++ crates/distribution-filename/src/lib.rs | 2 + crates/distribution-types/src/installed.rs | 55 +++++----- crates/uv/tests/pip_freeze.rs | 57 ++++++++++- 4 files changed, 192 insertions(+), 34 deletions(-) create mode 100644 crates/distribution-filename/src/egg.rs diff --git a/crates/distribution-filename/src/egg.rs b/crates/distribution-filename/src/egg.rs new file mode 100644 index 000000000000..b247a4d40978 --- /dev/null +++ b/crates/distribution-filename/src/egg.rs @@ -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: +#[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 { + // pip uses the following regex: + // ```python + // EGG_NAME = re.compile( + // r""" + // (?P[^-]+) ( + // -(?P[^-]+) ( + // -py(?P[^-]+) ( + // -(?P.+) + // )? + // )? + // )? + // """, + // 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 { + 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" + ); + } +} diff --git a/crates/distribution-filename/src/lib.rs b/crates/distribution-filename/src/lib.rs index 0e1c11d02f7b..0736e5914658 100644 --- a/crates/distribution-filename/src/lib.rs +++ b/crates/distribution-filename/src/lib.rs @@ -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; diff --git a/crates/distribution-types/src/installed.rs b/crates/distribution-types/src/installed.rs index 63f23c2b3ecb..4186b8f3c482 100644 --- a/crates/distribution-types/src/installed.rs +++ b/crates/distribution-types/src/installed.rs @@ -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; @@ -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(), }))); } diff --git a/crates/uv/tests/pip_freeze.rs b/crates/uv/tests/pip_freeze.rs index 5d963977d5d7..1e3d75915057 100644 --- a/crates/uv/tests/pip_freeze.rs +++ b/crates/uv/tests/pip_freeze.rs @@ -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()?; @@ -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");