mirror of
https://github.com/astral-sh/uv.git
synced 2025-07-07 13:25:00 +00:00
Make .egg-info
filename parsing spec compliant (#4533)
## Summary It turns out that `.egg-info` files and directories can _both_ have up to four segments in the filename: https://setuptools.pypa.io/en/latest/deprecated/python_eggs.html#filename-embedded-metadata. This PR upgrades the parsing and now uses the same parsing for files and directories. Closes https://github.com/astral-sh/uv/issues/4532.
This commit is contained in:
parent
41f051db3b
commit
ca92b55605
4 changed files with 228 additions and 33 deletions
112
crates/distribution-filename/src/egg.rs
Normal file
112
crates/distribution-filename/src/egg.rs
Normal file
|
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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 {
|
||||
let metadata = match fs_err::metadata(path) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(err) => {
|
||||
warn!("Invalid `.egg-info` path: {err}");
|
||||
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))?;
|
||||
}
|
||||
};
|
||||
|
||||
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(),
|
||||
})));
|
||||
}
|
||||
|
|
|
@ -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()?;
|
||||
|
@ -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");
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue