Skip to content
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
59 changes: 39 additions & 20 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ anstream = { version = "1.0.0" }
anyhow = { version = "1.0.89" }
arcstr = { version = "1.2.0" }
arrayvec = { version = "0.7.6" }
astral-tokio-tar = { version = "0.6.2" }
astral-tokio-tar = { version = "0.6.3" }
async-channel = { version = "2.3.1" }
async-compression = { version = "0.4.12", features = [
"bzip2",
Expand Down
32 changes: 22 additions & 10 deletions crates/uv-client/src/registry_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1491,11 +1491,17 @@ impl SimpleDetailMetadata {

// Group the distributions by version and kind
for file in files {
let Some(filename) = DistFilename::try_from_filename(&file.filename, package_name)
else {
debug!("Skipping file for {package_name}: {}", file.filename);
continue;
};
let filename =
match DistFilename::try_from_filename_with_reason(&file.filename, package_name) {
Ok(filename) => filename,
Err(err) => {
debug!(
"Skipping file for {package_name}: {:?} ({err})",
file.filename
);
continue;
}
};
let file = match File::try_from_pypi(file, &base) {
Ok(file) => file,
Err(err) => {
Expand Down Expand Up @@ -1551,11 +1557,17 @@ impl SimpleDetailMetadata {
continue;
}
};
let Some(filename) = DistFilename::try_from_filename(&file.filename, package_name)
else {
debug!("Skipping file for {package_name}: {}", file.filename);
continue;
};
let filename =
match DistFilename::try_from_filename_with_reason(&file.filename, package_name) {
Ok(filename) => filename,
Err(err) => {
debug!(
"Skipping file for {package_name}: {:?} ({err})",
file.filename
);
continue;
}
};
match version_map.entry(filename.version().clone()) {
std::collections::btree_map::Entry::Occupied(mut entry) => {
entry.get_mut().push(filename, file);
Expand Down
104 changes: 94 additions & 10 deletions crates/uv-distribution-filename/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use std::fmt::{Display, Formatter};
use std::str::FromStr;

use thiserror::Error;
use uv_normalize::PackageName;
use uv_pep440::Version;

Expand Down Expand Up @@ -29,20 +30,32 @@ pub enum DistFilename {
impl DistFilename {
/// Parse a filename as wheel or source dist name.
pub fn try_from_filename(filename: &str, package_name: &PackageName) -> Option<Self> {
Self::try_from_filename_with_reason(filename, package_name).ok()
}

/// Parse a filename as wheel or source dist name, returning the reason the filename was
/// rejected when parsing fails.
///
/// This is useful for surfacing actionable diagnostics when a registry returns entries that
/// do not look like distribution files (for example, devpi index entries like `+searchhelp`,
/// bare version directory links, or files with unrecognized extensions).
pub fn try_from_filename_with_reason(
filename: &str,
package_name: &PackageName,
) -> Result<Self, DistFilenameError> {
match DistExtension::from_path(filename) {
Ok(DistExtension::Wheel) => {
if let Ok(filename) = WheelFilename::from_str(filename) {
return Some(Self::WheelFilename(filename));
}
}
Ok(DistExtension::Wheel) => match WheelFilename::from_str(filename) {
Ok(filename) => Ok(Self::WheelFilename(filename)),
Err(err) => Err(DistFilenameError::InvalidWheel(err)),
},
Ok(DistExtension::Source(extension)) => {
if let Ok(filename) = SourceDistFilename::parse(filename, extension, package_name) {
return Some(Self::SourceDistFilename(filename));
match SourceDistFilename::parse(filename, extension, package_name) {
Ok(filename) => Ok(Self::SourceDistFilename(filename)),
Err(err) => Err(DistFilenameError::InvalidSourceDist(err)),
}
}
Err(_) => {}
Err(err) => Err(DistFilenameError::NoRecognizedExtension(err)),
}
None
}

/// Like [`DistFilename::try_from_normalized_filename`], but without knowing the package name.
Expand Down Expand Up @@ -98,12 +111,83 @@ impl Display for DistFilename {
}
}

/// The reason a registry entry could not be parsed as a wheel or source distribution filename.
#[derive(Error, Debug)]
pub enum DistFilenameError {
/// The filename does not have a recognized wheel or source distribution extension.
///
/// This typically indicates the registry returned a non-distribution entry, such as a
/// directory listing link (e.g., a bare version like `0.1.0`) or an index management endpoint
/// (e.g., devpi's `+searchhelp`, `+status`).
#[error("not a wheel or source distribution archive (expected one of {0})")]
NoRecognizedExtension(#[source] ExtensionError),
/// The filename has a `.whl` extension but is otherwise an invalid wheel filename.
#[error(transparent)]
InvalidWheel(#[from] WheelFilenameError),
/// The filename has a source distribution extension but is otherwise invalid.
#[error(transparent)]
InvalidSourceDist(#[from] SourceDistFilenameError),
}

#[cfg(test)]
mod tests {
use crate::WheelFilename;
use std::str::FromStr;

use uv_normalize::PackageName;

use crate::{DistFilename, DistFilenameError, WheelFilename};

#[test]
fn wheel_filename_size() {
assert_eq!(size_of::<WheelFilename>(), 48);
}

#[test]
fn try_from_filename_with_reason_no_extension() {
// A bare version string (the kind of entry devpi serves for its directory listings)
// is rejected because it has no recognized distribution extension.
let name = PackageName::from_str("my-package").unwrap();
let err = DistFilename::try_from_filename_with_reason("0.1.0", &name).unwrap_err();
assert!(
matches!(err, DistFilenameError::NoRecognizedExtension(_)),
"unexpected error variant: {err:?}"
);
let rendered = err.to_string();
assert!(
rendered.contains("not a wheel or source distribution archive"),
"unexpected error message: {rendered}"
);
}

#[test]
fn try_from_filename_with_reason_empty_filename() {
// An empty filename (which is what devpi reports for its top-level index entries) is
// similarly rejected with the no-extension reason rather than silently swallowing it.
let name = PackageName::from_str("my-package").unwrap();
let err = DistFilename::try_from_filename_with_reason("", &name).unwrap_err();
assert!(
matches!(err, DistFilenameError::NoRecognizedExtension(_)),
"unexpected error variant: {err:?}"
);
}

#[test]
fn try_from_filename_with_reason_invalid_wheel() {
// A file that looks like a wheel by extension but isn't a valid wheel name should bubble
// up the wheel parsing error rather than a generic extension error.
let name = PackageName::from_str("my-package").unwrap();
let err =
DistFilename::try_from_filename_with_reason("not-a-wheel.whl", &name).unwrap_err();
assert!(
matches!(err, DistFilenameError::InvalidWheel(_)),
"unexpected error variant: {err:?}"
);
}

#[test]
fn try_from_filename_accepts_valid_wheel() {
let name = PackageName::from_str("my-package").unwrap();
let parsed = DistFilename::try_from_filename("my_package-0.1.0-py3-none-any.whl", &name);
assert!(parsed.is_some(), "expected wheel to parse successfully");
}
}
Loading