Files
uv/crates/uv-resolver/src/preferences.rs
T
Charlie Marsh 13ae5ac8dc Replace PyPI-internal Hashes representation with flat vector (#2925)
## Summary

Right now, we have a `Hashes` representation that looks like:

```rust
/// A dictionary mapping a hash name to a hex encoded digest of the file.
///
/// PEP 691 says multiple hashes can be included and the interpretation is left to the client.
#[derive(Debug, Clone, Eq, PartialEq, Default, Deserialize)]
pub struct Hashes {
    pub md5: Option<Box<str>>,
    pub sha256: Option<Box<str>>,
    pub sha384: Option<Box<str>>,
    pub sha512: Option<Box<str>>,
}
```

It stems from the PyPI API, which returns a dictionary of hashes.

We tend to pass these around as a vector of `Vec<Hashes>`. But it's a
bit strange because each entry in that vector could contain multiple
hashes. And it makes it difficult to ask questions like "Is
`sha256:ab21378ca980a8` in the set of hashes"?

This PR instead treats `Hashes` as the PyPI-internal type, and uses a
new `Vec<HashDigest>` everywhere in our own APIs.
2024-04-09 16:56:16 +00:00

175 lines
6.0 KiB
Rust

use std::str::FromStr;
use rustc_hash::FxHashMap;
use pep440_rs::{Operator, Version};
use pep508_rs::{
MarkerEnvironment, Requirement, RequirementsTxtRequirement, UnnamedRequirement, VersionOrUrl,
};
use pypi_types::{HashDigest, HashError};
use requirements_txt::RequirementEntry;
use tracing::trace;
use uv_normalize::PackageName;
#[derive(thiserror::Error, Debug)]
pub enum PreferenceError {
#[error("direct URL requirements without package names are not supported: {0}")]
Bare(UnnamedRequirement),
#[error(transparent)]
Hash(#[from] HashError),
}
/// A pinned requirement, as extracted from a `requirements.txt` file.
#[derive(Clone, Debug)]
pub struct Preference {
requirement: Requirement,
hashes: Vec<HashDigest>,
}
impl Preference {
/// Create a [`Preference`] from a [`RequirementEntry`].
pub fn from_entry(entry: RequirementEntry) -> Result<Self, PreferenceError> {
Ok(Self {
requirement: match entry.requirement {
RequirementsTxtRequirement::Pep508(requirement) => requirement,
RequirementsTxtRequirement::Unnamed(requirement) => {
return Err(PreferenceError::Bare(requirement))
}
},
hashes: entry
.hashes
.iter()
.map(String::as_str)
.map(HashDigest::from_str)
.collect::<Result<_, _>>()?,
})
}
/// Create a [`Preference`] from a [`Requirement`].
pub fn from_requirement(requirement: Requirement) -> Self {
Self {
requirement,
hashes: Vec::new(),
}
}
/// Return the name of the package for this preference.
pub fn name(&self) -> &PackageName {
&self.requirement.name
}
/// Return the [`Requirement`] for this preference.
pub fn requirement(&self) -> &Requirement {
&self.requirement
}
}
/// A set of pinned packages that should be preserved during resolution, if possible.
#[derive(Debug, Clone)]
pub(crate) struct Preferences(FxHashMap<PackageName, Pin>);
impl Preferences {
/// Create a map of pinned packages from an iterator of [`Preference`] entries.
/// Takes ownership of the [`Preference`] entries.
///
/// The provided [`MarkerEnvironment`] will be used to filter the preferences
/// to an applicable subset.
pub(crate) fn from_iter<PreferenceIterator: IntoIterator<Item = Preference>>(
preferences: PreferenceIterator,
markers: &MarkerEnvironment,
) -> Self {
Self(
// TODO(zanieb): We should explicitly ensure that when a package name is seen multiple times
// that the newest or oldest version is preferred dependning on the resolution strategy;
// right now, the order is dependent on the given iterator.
preferences
.into_iter()
.filter_map(|preference| {
let Preference {
requirement,
hashes,
} = preference;
// Search for, e.g., `flask==1.2.3` entries that match the current environment.
if !requirement.evaluate_markers(markers, &[]) {
trace!(
"Excluding {requirement} from preferences due to unmatched markers."
);
return None;
}
match requirement.version_or_url.as_ref() {
Some(VersionOrUrl::VersionSpecifier(version_specifiers)) =>
{
let [version_specifier] = version_specifiers.as_ref() else {
trace!(
"Excluding {requirement} from preferences due to multiple version specifiers."
);
return None;
};
if *version_specifier.operator() != Operator::Equal {
trace!(
"Excluding {requirement} from preferences due to inexact version specifier."
);
return None;
}
Some((
requirement.name,
Pin {
version: version_specifier.version().clone(),
hashes,
},
))
}
Some(VersionOrUrl::Url(_)) => {
trace!(
"Excluding {requirement} from preferences due to URL dependency."
);
None
}
_ => {
None
}
}
})
.collect(),
)
}
/// Return the pinned version for a package, if any.
pub(crate) fn version(&self, package_name: &PackageName) -> Option<&Version> {
self.0.get(package_name).map(Pin::version)
}
/// Return the hashes for a package, if the version matches that of the pin.
pub(crate) fn match_hashes(
&self,
package_name: &PackageName,
version: &Version,
) -> Option<&[HashDigest]> {
self.0
.get(package_name)
.filter(|pin| pin.version() == version)
.map(Pin::hashes)
}
}
/// The pinned data associated with a package in a locked `requirements.txt` file (e.g., `flask==1.2.3`).
#[derive(Debug, Clone)]
struct Pin {
version: Version,
hashes: Vec<HashDigest>,
}
impl Pin {
/// Return the version of the pinned package.
fn version(&self) -> &Version {
&self.version
}
/// Return the hashes of the pinned package.
fn hashes(&self) -> &[HashDigest] {
&self.hashes
}
}