mirror of
https://github.com/astral-sh/uv.git
synced 2025-07-07 21:35: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;
|
use uv_normalize::PackageName;
|
||||||
|
|
||||||
pub use build_tag::{BuildTag, BuildTagError};
|
pub use build_tag::{BuildTag, BuildTagError};
|
||||||
|
pub use egg::{EggInfoFilename, EggInfoFilenameError};
|
||||||
pub use source_dist::{SourceDistExtension, SourceDistFilename, SourceDistFilenameError};
|
pub use source_dist::{SourceDistExtension, SourceDistFilename, SourceDistFilenameError};
|
||||||
pub use wheel::{WheelFilename, WheelFilenameError};
|
pub use wheel::{WheelFilename, WheelFilenameError};
|
||||||
|
|
||||||
mod build_tag;
|
mod build_tag;
|
||||||
|
mod egg;
|
||||||
mod source_dist;
|
mod source_dist;
|
||||||
mod wheel;
|
mod wheel;
|
||||||
|
|
||||||
|
|
|
@ -3,6 +3,7 @@ use std::path::{Path, PathBuf};
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
|
|
||||||
use anyhow::{anyhow, Context, Result};
|
use anyhow::{anyhow, Context, Result};
|
||||||
|
use distribution_filename::EggInfoFilename;
|
||||||
use fs_err as fs;
|
use fs_err as fs;
|
||||||
use tracing::warn;
|
use tracing::warn;
|
||||||
use url::Url;
|
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") {
|
if path.extension().is_some_and(|ext| ext == "egg-info") {
|
||||||
// Ex) `zstandard-0.22.0-py3.12.egg-info`
|
let metadata = match fs_err::metadata(path) {
|
||||||
if path.is_dir() {
|
Ok(metadata) => metadata,
|
||||||
let Some(file_stem) = path.file_stem() else {
|
Err(err) => {
|
||||||
|
warn!("Invalid `.egg-info` path: {err}");
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
}
|
||||||
let Some(file_stem) = file_stem.to_str() else {
|
};
|
||||||
return Ok(None);
|
|
||||||
};
|
let Some(file_stem) = path.file_stem() else {
|
||||||
let Some((name, version_python)) = file_stem.split_once('-') else {
|
return Ok(None);
|
||||||
return Ok(None);
|
};
|
||||||
};
|
let Some(file_stem) = file_stem.to_str() else {
|
||||||
let Some((version, _)) = version_python.split_once('-') else {
|
return Ok(None);
|
||||||
return Ok(None);
|
};
|
||||||
};
|
let file_name = EggInfoFilename::parse(file_stem)?;
|
||||||
let name = PackageName::from_str(name)?;
|
|
||||||
let version = Version::from_str(version).map_err(|err| anyhow!(err))?;
|
if metadata.is_dir() {
|
||||||
return Ok(Some(Self::EggInfoDirectory(InstalledEggInfoDirectory {
|
return Ok(Some(Self::EggInfoDirectory(InstalledEggInfoDirectory {
|
||||||
name,
|
name: file_name.name,
|
||||||
version,
|
version: file_name.version,
|
||||||
path: path.to_path_buf(),
|
path: path.to_path_buf(),
|
||||||
})));
|
})));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ex) `vtk-9.2.6.egg-info`
|
if metadata.is_file() {
|
||||||
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))?;
|
|
||||||
return Ok(Some(Self::EggInfoFile(InstalledEggInfoFile {
|
return Ok(Some(Self::EggInfoFile(InstalledEggInfoFile {
|
||||||
name,
|
name: file_name.name,
|
||||||
version,
|
version: file_name.version,
|
||||||
path: path.to_path_buf(),
|
path: path.to_path_buf(),
|
||||||
})));
|
})));
|
||||||
}
|
}
|
||||||
|
|
|
@ -197,7 +197,7 @@ fn freeze_with_egg_info() -> Result<()> {
|
||||||
|
|
||||||
let site_packages = ChildPath::new(context.site_packages());
|
let site_packages = ChildPath::new(context.site_packages());
|
||||||
|
|
||||||
// Manually create a `.egg-info` directory.
|
// Manually create an `.egg-info` directory.
|
||||||
site_packages
|
site_packages
|
||||||
.child("zstandard-0.22.0-py3.12.egg-info")
|
.child("zstandard-0.22.0-py3.12.egg-info")
|
||||||
.create_dir_all()?;
|
.create_dir_all()?;
|
||||||
|
@ -242,6 +242,96 @@ fn freeze_with_egg_info() -> Result<()> {
|
||||||
Ok(())
|
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]
|
#[test]
|
||||||
fn freeze_with_legacy_editable() -> Result<()> {
|
fn freeze_with_legacy_editable() -> Result<()> {
|
||||||
let context = TestContext::new("3.12");
|
let context = TestContext::new("3.12");
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue