diff --git a/Cargo.lock b/Cargo.lock index 329b780c3..06b051645 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4798,6 +4798,7 @@ dependencies = [ "platform-tags", "pubgrub", "pypi-types", + "requirements-txt", "reqwest", "rkyv", "rustc-hash", diff --git a/crates/pypi-types/src/simple_json.rs b/crates/pypi-types/src/simple_json.rs index 2257c109f..5c629c551 100644 --- a/crates/pypi-types/src/simple_json.rs +++ b/crates/pypi-types/src/simple_json.rs @@ -162,3 +162,93 @@ impl Hashes { self.sha256.as_deref().or(self.md5.as_deref()) } } + +impl FromStr for Hashes { + type Err = HashError; + + fn from_str(s: &str) -> Result { + let mut parts = s.split(':'); + + // Extract the key and value. + let name = parts + .next() + .ok_or_else(|| HashError::InvalidStructure(s.to_string()))?; + let value = parts + .next() + .ok_or_else(|| HashError::InvalidStructure(s.to_string()))?; + + // Ensure there are no more parts. + if parts.next().is_some() { + return Err(HashError::InvalidStructure(s.to_string())); + } + + match name { + "md5" => { + let md5 = value.to_string(); + Ok(Hashes { + md5: Some(md5), + sha256: None, + }) + } + "sha256" => { + let sha256 = value.to_string(); + Ok(Hashes { + md5: None, + sha256: Some(sha256), + }) + } + _ => Err(HashError::UnsupportedHashAlgorithm(s.to_string())), + } + } +} + +#[derive(thiserror::Error, Debug)] +pub enum HashError { + #[error("Unexpected hash (expected `sha256:` or `md5:`) on: {0}")] + InvalidStructure(String), + + #[error("Unsupported hash algorithm (expected `sha256` or `md5`) on: {0}")] + UnsupportedHashAlgorithm(String), +} + +#[cfg(test)] +mod tests { + use crate::{HashError, Hashes}; + + #[test] + fn parse_hashes() -> Result<(), HashError> { + let hashes: Hashes = + "sha256:40627dcf047dadb22cd25ea7ecfe9cbf3bbbad0482ee5920b582f3809c97654f".parse()?; + assert_eq!( + hashes, + Hashes { + md5: None, + sha256: Some( + "40627dcf047dadb22cd25ea7ecfe9cbf3bbbad0482ee5920b582f3809c97654f".to_string() + ) + } + ); + + let hashes: Hashes = + "md5:090376d812fb6ac5f171e5938e82e7f2d7adc2b629101cec0db8b267815c85e2".parse()?; + assert_eq!( + hashes, + Hashes { + md5: Some( + "090376d812fb6ac5f171e5938e82e7f2d7adc2b629101cec0db8b267815c85e2".to_string() + ), + sha256: None + } + ); + + let result = "sha256=40627dcf047dadb22cd25ea7ecfe9cbf3bbbad0482ee5920b582f3809c97654f" + .parse::(); + assert!(result.is_err()); + + let result = "sha512:55f44b440d491028addb3b88f72207d71eeebfb7b5dbf0643f7c023ae1fba619" + .parse::(); + assert!(result.is_err()); + + Ok(()) + } +} diff --git a/crates/uv-client/src/html.rs b/crates/uv-client/src/html.rs index 8349ef4e5..3161de45e 100644 --- a/crates/uv-client/src/html.rs +++ b/crates/uv-client/src/html.rs @@ -221,7 +221,7 @@ pub enum Error { #[error("Unexpected fragment (expected `#sha256=...`) on URL: {0}")] FragmentParse(String), - #[error("Unsupported hash algorithm (expected `sha256`) on: {0}")] + #[error("Unsupported hash algorithm (expected `sha256` or `md5`) on: {0}")] UnsupportedHashAlgorithm(String), #[error("Invalid `requires-python` specifier: {0}")] @@ -672,7 +672,7 @@ mod tests { "#; let base = Url::parse("https://download.pytorch.org/whl/jinja2/").unwrap(); let result = SimpleHtml::parse(text, &base).unwrap_err(); - insta::assert_snapshot!(result, @"Unsupported hash algorithm (expected `sha256`) on: sha512=6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61"); + insta::assert_snapshot!(result, @"Unsupported hash algorithm (expected `sha256` or `md5`) on: sha512=6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61"); } #[test] diff --git a/crates/uv-resolver/Cargo.toml b/crates/uv-resolver/Cargo.toml index 6b519a510..605a88249 100644 --- a/crates/uv-resolver/Cargo.toml +++ b/crates/uv-resolver/Cargo.toml @@ -22,6 +22,7 @@ pep440_rs = { path = "../pep440-rs", features = ["pubgrub"] } pep508_rs = { path = "../pep508-rs" } platform-tags = { path = "../platform-tags" } pypi-types = { path = "../pypi-types" } +requirements-txt = { path = "../requirements-txt" } uv-cache = { path = "../uv-cache" } uv-client = { path = "../uv-client" } uv-distribution = { path = "../uv-distribution" } diff --git a/crates/uv-resolver/src/candidate_selector.rs b/crates/uv-resolver/src/candidate_selector.rs index de7892645..31e96be80 100644 --- a/crates/uv-resolver/src/candidate_selector.rs +++ b/crates/uv-resolver/src/candidate_selector.rs @@ -1,12 +1,12 @@ use pubgrub::range::Range; -use rustc_hash::FxHashMap; use distribution_types::{CompatibleDist, IncompatibleDist, IncompatibleSource}; use distribution_types::{DistributionMetadata, IncompatibleWheel, Name, PrioritizedDist}; use pep440_rs::Version; -use pep508_rs::{MarkerEnvironment, Requirement, VersionOrUrl}; +use pep508_rs::MarkerEnvironment; use uv_normalize::PackageName; +use crate::preferences::Preferences; use crate::prerelease_mode::PreReleaseStrategy; use crate::resolution_mode::ResolutionStrategy; use crate::version_map::{VersionMap, VersionMapDistHandle}; @@ -16,7 +16,6 @@ use crate::{Manifest, Options}; pub(crate) struct CandidateSelector { resolution_strategy: ResolutionStrategy, prerelease_strategy: PreReleaseStrategy, - preferences: Preferences, } impl CandidateSelector { @@ -37,7 +36,6 @@ impl CandidateSelector { manifest, markers, ), - preferences: Preferences::from_requirements(manifest.preferences.as_slice(), markers), } } @@ -54,43 +52,6 @@ impl CandidateSelector { } } -/// A set of pinned packages that should be preserved during resolution, if possible. -#[derive(Debug, Clone)] -struct Preferences(FxHashMap); - -impl Preferences { - /// Create a set of [`Preferences`] from a set of requirements. - fn from_requirements(requirements: &[Requirement], markers: &MarkerEnvironment) -> Self { - Self( - requirements - .iter() - .filter_map(|requirement| { - if !requirement.evaluate_markers(markers, &[]) { - return None; - } - let Some(VersionOrUrl::VersionSpecifier(version_specifiers)) = - requirement.version_or_url.as_ref() - else { - return None; - }; - let [version_specifier] = version_specifiers.as_ref() else { - return None; - }; - Some(( - requirement.name.clone(), - version_specifier.version().clone(), - )) - }) - .collect(), - ) - } - - /// Return the pinned version for a package, if any. - fn get(&self, package_name: &PackageName) -> Option<&Version> { - self.0.get(package_name) - } -} - #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] enum AllowPreRelease { Yes, @@ -105,10 +66,11 @@ impl CandidateSelector { package_name: &'a PackageName, range: &'a Range, version_map: &'a VersionMap, + preferences: &'a Preferences, ) -> Option> { // If the package has a preference (e.g., an existing version from an existing lockfile), // and the preference satisfies the current range, use that. - if let Some(version) = self.preferences.get(package_name) { + if let Some(version) = preferences.version(package_name) { if range.contains(version) { if let Some(file) = version_map.get(version) { return Some(Candidate::new(package_name, version, file)); diff --git a/crates/uv-resolver/src/lib.rs b/crates/uv-resolver/src/lib.rs index f2b87c4fd..b68550e84 100644 --- a/crates/uv-resolver/src/lib.rs +++ b/crates/uv-resolver/src/lib.rs @@ -3,6 +3,7 @@ pub use error::ResolveError; pub use finder::{DistFinder, Reporter as FinderReporter}; pub use manifest::Manifest; pub use options::{Options, OptionsBuilder}; +pub use preferences::Preference; pub use prerelease_mode::PreReleaseMode; pub use python_requirement::PythonRequirement; pub use resolution::{AnnotationStyle, Diagnostic, DisplayResolutionGraph, ResolutionGraph}; @@ -24,6 +25,7 @@ mod manifest; mod options; mod overrides; mod pins; +mod preferences; mod prerelease_mode; mod pubgrub; mod python_requirement; diff --git a/crates/uv-resolver/src/manifest.rs b/crates/uv-resolver/src/manifest.rs index 02767feb1..8923e112b 100644 --- a/crates/uv-resolver/src/manifest.rs +++ b/crates/uv-resolver/src/manifest.rs @@ -3,13 +3,15 @@ use pep508_rs::Requirement; use pypi_types::Metadata23; use uv_normalize::PackageName; +use crate::preferences::Preference; + /// A manifest of requirements, constraints, and preferences. #[derive(Debug)] pub struct Manifest { pub(crate) requirements: Vec, pub(crate) constraints: Vec, pub(crate) overrides: Vec, - pub(crate) preferences: Vec, + pub(crate) preferences: Vec, pub(crate) project: Option, pub(crate) editables: Vec<(LocalEditable, Metadata23)>, } @@ -19,7 +21,7 @@ impl Manifest { requirements: Vec, constraints: Vec, overrides: Vec, - preferences: Vec, + preferences: Vec, project: Option, editables: Vec<(LocalEditable, Metadata23)>, ) -> Self { diff --git a/crates/uv-resolver/src/preferences.rs b/crates/uv-resolver/src/preferences.rs new file mode 100644 index 000000000..f2010537d --- /dev/null +++ b/crates/uv-resolver/src/preferences.rs @@ -0,0 +1,132 @@ +use std::str::FromStr; + +use rustc_hash::FxHashMap; + +use pep440_rs::{Operator, Version}; +use pep508_rs::{MarkerEnvironment, Requirement, VersionOrUrl}; +use pypi_types::{HashError, Hashes}; +use requirements_txt::RequirementEntry; +use uv_normalize::PackageName; + +/// A pinned requirement, as extracted from a `requirements.txt` file. +#[derive(Debug)] +pub struct Preference { + requirement: Requirement, + hashes: Vec, +} + +impl Preference { + /// Create a [`Preference`] from a [`RequirementEntry`]. + pub fn from_entry(entry: RequirementEntry) -> Result { + Ok(Self { + requirement: entry.requirement, + hashes: entry + .hashes + .iter() + .map(String::as_str) + .map(Hashes::from_str) + .collect::>()?, + }) + } + + /// 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); + +impl Preferences { + /// Create a map of pinned packages from a list of [`Preference`] entries. + pub(crate) fn from_requirements( + requirements: Vec, + markers: &MarkerEnvironment, + ) -> Self { + Self( + requirements + .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, &[]) { + return None; + } + let Some(VersionOrUrl::VersionSpecifier(version_specifiers)) = + requirement.version_or_url.as_ref() + else { + return None; + }; + let [version_specifier] = version_specifiers.as_ref() else { + return None; + }; + if *version_specifier.operator() != Operator::Equal { + return None; + } + Some(( + requirement.name, + Pin { + version: version_specifier.version().clone(), + hashes, + }, + )) + }) + .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<&[Hashes]> { + 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, +} + +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) -> &[Hashes] { + &self.hashes + } +} diff --git a/crates/uv-resolver/src/resolution.rs b/crates/uv-resolver/src/resolution.rs index acd02060e..e8a2ef4be 100644 --- a/crates/uv-resolver/src/resolution.rs +++ b/crates/uv-resolver/src/resolution.rs @@ -20,6 +20,7 @@ use uv_normalize::{ExtraName, PackageName}; use crate::editables::Editables; use crate::pins::FilePins; +use crate::preferences::Preferences; use crate::pubgrub::{PubGrubDistribution, PubGrubPackage, PubGrubPriority}; use crate::redirect::apply_redirect; use crate::resolver::VersionsResponse; @@ -53,6 +54,7 @@ pub struct ResolutionGraph { impl ResolutionGraph { /// Create a new graph from the resolved `PubGrub` state. + #[allow(clippy::too_many_arguments)] pub(crate) fn from_state( selection: &SelectedDependencies, pins: &FilePins, @@ -60,6 +62,7 @@ impl ResolutionGraph { distributions: &OnceMap, redirects: &DashMap, state: &State, PubGrubPriority>, + preferences: &Preferences, editables: Editables, ) -> Result { // TODO(charlie): petgraph is a really heavy and unnecessary dependency here. We should @@ -84,13 +87,16 @@ impl ResolutionGraph { .clone() }; - // Add its hashes to the index. - if let Some(versions_response) = packages.get(package_name) { + // Add its hashes to the index, preserving those that were already present in + // the lockfile if necessary. + if let Some(hash) = preferences.match_hashes(package_name, version) { + hashes.insert(package_name.clone(), hash.to_vec()); + } else if let Some(versions_response) = packages.get(package_name) { if let VersionsResponse::Found(ref version_map) = *versions_response { hashes.insert(package_name.clone(), { - let mut hashes = version_map.hashes(version); - hashes.sort_unstable(); - hashes + let mut hash = version_map.hashes(version); + hash.sort_unstable(); + hash }); } } @@ -111,13 +117,16 @@ impl ResolutionGraph { Dist::from_url(package_name.clone(), url)? }; - // Add its hashes to the index. - if let Some(versions_response) = packages.get(package_name) { + // Add its hashes to the index, preserving those that were already present in + // the lockfile if necessary. + if let Some(hash) = preferences.match_hashes(package_name, version) { + hashes.insert(package_name.clone(), hash.to_vec()); + } else if let Some(versions_response) = packages.get(package_name) { if let VersionsResponse::Found(ref version_map) = *versions_response { hashes.insert(package_name.clone(), { - let mut hashes = version_map.hashes(version); - hashes.sort_unstable(); - hashes + let mut hash = version_map.hashes(version); + hash.sort_unstable(); + hash }); } } diff --git a/crates/uv-resolver/src/resolver/mod.rs b/crates/uv-resolver/src/resolver/mod.rs index fe9965d0a..34fbc7c71 100644 --- a/crates/uv-resolver/src/resolver/mod.rs +++ b/crates/uv-resolver/src/resolver/mod.rs @@ -40,6 +40,7 @@ use crate::error::ResolveError; use crate::manifest::Manifest; use crate::overrides::Overrides; use crate::pins::FilePins; +use crate::preferences::Preferences; use crate::pubgrub::{ PubGrubDependencies, PubGrubDistribution, PubGrubPackage, PubGrubPriorities, PubGrubPython, PubGrubSpecifier, @@ -93,6 +94,7 @@ pub struct Resolver<'a, Provider: ResolverProvider> { requirements: Vec, constraints: Constraints, overrides: Overrides, + preferences: Preferences, editables: Editables, urls: Urls, locals: Locals, @@ -169,6 +171,7 @@ impl<'a, Provider: ResolverProvider> Resolver<'a, Provider> { requirements: manifest.requirements, constraints: Constraints::from_requirements(manifest.constraints), overrides: Overrides::from_requirements(manifest.overrides), + preferences: Preferences::from_requirements(manifest.preferences, markers), editables: Editables::from_requirements(manifest.editables), markers, python_requirement, @@ -278,6 +281,7 @@ impl<'a, Provider: ResolverProvider> Resolver<'a, Provider> { &self.index.distributions, &self.index.redirects, &state, + &self.preferences, self.editables.clone(), ); }; @@ -641,7 +645,10 @@ impl<'a, Provider: ResolverProvider> Resolver<'a, Provider> { } // Find a version. - let Some(candidate) = self.selector.select(package_name, range, version_map) else { + let Some(candidate) = + self.selector + .select(package_name, range, version_map, &self.preferences) + else { // Short circuit: we couldn't find _any_ versions for a package. return Ok(None); }; @@ -1002,7 +1009,9 @@ impl<'a, Provider: ResolverProvider> Resolver<'a, Provider> { // Try to find a compatible version. If there aren't any compatible versions, // short-circuit. - let Some(candidate) = self.selector.select(&package_name, &range, version_map) + let Some(candidate) = + self.selector + .select(&package_name, &range, version_map, &self.preferences) else { return Ok(None); }; diff --git a/crates/uv-resolver/src/yanks.rs b/crates/uv-resolver/src/yanks.rs index 786d82a83..fd3b79164 100644 --- a/crates/uv-resolver/src/yanks.rs +++ b/crates/uv-resolver/src/yanks.rs @@ -4,6 +4,7 @@ use pep440_rs::Version; use pep508_rs::MarkerEnvironment; use uv_normalize::PackageName; +use crate::preferences::Preference; use crate::Manifest; /// A set of package versions that are permitted, even if they're marked as yanked by the @@ -19,7 +20,7 @@ impl AllowedYanks { .iter() .chain(manifest.constraints.iter()) .chain(manifest.overrides.iter()) - .chain(manifest.preferences.iter()) + .chain(manifest.preferences.iter().map(Preference::requirement)) .filter(|requirement| requirement.evaluate_markers(markers, &[])) .chain(manifest.editables.iter().flat_map(|(editable, metadata)| { metadata diff --git a/crates/uv-resolver/tests/resolver.rs b/crates/uv-resolver/tests/resolver.rs index 9e54155bf..939f4b0c1 100644 --- a/crates/uv-resolver/tests/resolver.rs +++ b/crates/uv-resolver/tests/resolver.rs @@ -18,7 +18,7 @@ use uv_client::{FlatIndex, RegistryClientBuilder}; use uv_interpreter::{find_default_python, Interpreter, PythonEnvironment}; use uv_resolver::{ DisplayResolutionGraph, InMemoryIndex, Manifest, Options, OptionsBuilder, PreReleaseMode, - ResolutionGraph, ResolutionMode, Resolver, + Preference, ResolutionGraph, ResolutionMode, Resolver, }; use uv_traits::{ BuildContext, BuildIsolation, BuildKind, NoBinary, NoBuild, SetupPyStrategy, SourceBuildTrait, @@ -422,10 +422,12 @@ async fn black_lowest_direct() -> Result<()> { #[tokio::test] async fn black_respect_preference() -> Result<()> { let manifest = Manifest::new( - vec![Requirement::from_str("black<=23.9.1").unwrap()], + vec![Requirement::from_str("black<=23.9.1")?], vec![], vec![], - vec![Requirement::from_str("black==23.9.0").unwrap()], + vec![Preference::from_requirement(Requirement::from_str( + "black==23.9.0", + )?)], None, vec![], ); @@ -455,10 +457,12 @@ async fn black_respect_preference() -> Result<()> { #[tokio::test] async fn black_ignore_preference() -> Result<()> { let manifest = Manifest::new( - vec![Requirement::from_str("black<=23.9.1").unwrap()], + vec![Requirement::from_str("black<=23.9.1")?], vec![], vec![], - vec![Requirement::from_str("black==23.9.2").unwrap()], + vec![Preference::from_requirement(Requirement::from_str( + "black==23.9.2", + )?)], None, vec![], ); diff --git a/crates/uv/src/commands/pip_install.rs b/crates/uv/src/commands/pip_install.rs index 1bb02246f..6e988a0ec 100644 --- a/crates/uv/src/commands/pip_install.rs +++ b/crates/uv/src/commands/pip_install.rs @@ -31,7 +31,7 @@ use uv_installer::{ use uv_interpreter::{Interpreter, PythonEnvironment}; use uv_normalize::PackageName; use uv_resolver::{ - DependencyMode, InMemoryIndex, Manifest, Options, OptionsBuilder, PreReleaseMode, + DependencyMode, InMemoryIndex, Manifest, Options, OptionsBuilder, PreReleaseMode, Preference, ResolutionGraph, ResolutionMode, Resolver, }; use uv_traits::{BuildIsolation, ConfigSettings, InFlight, NoBuild, SetupPyStrategy}; @@ -500,7 +500,8 @@ async fn resolve( // Prefer current site packages, unless in the upgrade or reinstall lists site_packages .requirements() - .filter(|requirement| !exclusions.contains(&requirement.name)) + .map(Preference::from_requirement) + .filter(|preference| !exclusions.contains(preference.name())) .collect() }; diff --git a/crates/uv/src/requirements.rs b/crates/uv/src/requirements.rs index 123c9036b..dea672bd8 100644 --- a/crates/uv/src/requirements.rs +++ b/crates/uv/src/requirements.rs @@ -11,10 +11,12 @@ use tracing::{instrument, Level}; use distribution_types::{FlatIndexLocation, IndexUrl}; use pep508_rs::Requirement; +use pypi_types::HashError; use requirements_txt::{EditableRequirement, FindLink, RequirementsTxt}; use uv_client::Connectivity; use uv_fs::Simplified; use uv_normalize::{ExtraName, PackageName}; +use uv_resolver::Preference; use uv_warnings::warn_user; use crate::commands::Upgrade; @@ -445,7 +447,7 @@ fn flatten_extra( pub(crate) async fn read_lockfile( output_file: Option<&Path>, upgrade: Upgrade, -) -> Result> { +) -> Result> { // As an optimization, skip reading the lockfile is we're upgrading all packages anyway. let Some(output_file) = output_file .filter(|_| !upgrade.is_all()) @@ -458,28 +460,23 @@ pub(crate) async fn read_lockfile( let requirements_txt = RequirementsTxt::parse(output_file, std::env::current_dir()?, Connectivity::Offline) .await?; - let requirements = requirements_txt + let preferences = requirements_txt .requirements .into_iter() - .filter_map(|entry| { - if entry.editable { - None - } else { - Some(entry.requirement) - } - }) - .collect::>(); + .filter(|entry| !entry.editable) + .map(Preference::from_entry) + .collect::, HashError>>()?; // Apply the upgrade strategy to the requirements. Ok(match upgrade { // Respect all pinned versions from the existing lockfile. - Upgrade::None => requirements, + Upgrade::None => preferences, // Ignore all pinned versions from the existing lockfile. Upgrade::All => vec![], // Ignore pinned versions for the specified packages. - Upgrade::Packages(packages) => requirements + Upgrade::Packages(packages) => preferences .into_iter() - .filter(|requirement| !packages.contains(&requirement.name)) + .filter(|preference| !packages.contains(preference.name())) .collect(), }) } diff --git a/crates/uv/tests/pip_compile.rs b/crates/uv/tests/pip_compile.rs index 464cac9c3..9430c6f1f 100644 --- a/crates/uv/tests/pip_compile.rs +++ b/crates/uv/tests/pip_compile.rs @@ -5208,3 +5208,237 @@ fn requirement_wheel_name_mismatch() -> Result<()> { Ok(()) } + +/// `--generate-hashes` should not update the hashes in the "lockfile" if the package is not +/// upgraded. +#[test] +fn preserve_hashes_no_upgrade() -> Result<()> { + let context = TestContext::new("3.12"); + let requirements_in = context.temp_dir.child("requirements.in"); + requirements_in.write_str("markupsafe")?; + + // Write a subset of the hashes to the "lockfile". + let requirements_txt = context.temp_dir.child("requirements.txt"); + requirements_txt.write_str(indoc! {r" + # This file was autogenerated by uv via the following command: + # uv pip compile requirements.in --python-version 3.12 --cache-dir [CACHE_DIR] + markupsafe==2.1.2 \ + --hash=sha256:0576fe974b40a400449768941d5d0858cc624e3249dfd1e0c33674e5c7ca7aed \ + --hash=sha256:085fd3201e7b12809f9e6e9bc1e5c96a368c8523fad5afb02afe3c051ae4afcc \ + --hash=sha256:090376d812fb6ac5f171e5938e82e7f2d7adc2b629101cec0db8b267815c85e2 + "})?; + + // Avoid adding any additional hashes to the "lockfile". + uv_snapshot!(context.compile() + .arg("requirements.in") + .arg("--output-file") + .arg("requirements.txt") + .arg("--generate-hashes"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + # This file was autogenerated by uv via the following command: + # uv pip compile --cache-dir [CACHE_DIR] --exclude-newer 2023-11-18T12:00:00Z requirements.in --output-file requirements.txt --generate-hashes + markupsafe==2.1.2 \ + --hash=sha256:0576fe974b40a400449768941d5d0858cc624e3249dfd1e0c33674e5c7ca7aed \ + --hash=sha256:085fd3201e7b12809f9e6e9bc1e5c96a368c8523fad5afb02afe3c051ae4afcc \ + --hash=sha256:090376d812fb6ac5f171e5938e82e7f2d7adc2b629101cec0db8b267815c85e2 + + ----- stderr ----- + Resolved 1 package in [TIME] + "### + ); + + Ok(()) +} + +/// `--generate-hashes` should update the hashes in the "lockfile" if the package is upgraded via +/// `--upgrade`. +#[test] +fn preserve_hashes_upgrade() -> Result<()> { + let context = TestContext::new("3.12"); + let requirements_in = context.temp_dir.child("requirements.in"); + requirements_in.write_str("markupsafe==2.1.2")?; + + // Write a subset of the hashes to the "lockfile". + let requirements_txt = context.temp_dir.child("requirements.txt"); + requirements_txt.write_str(indoc! {r" + # This file was autogenerated by uv via the following command: + # uv pip compile requirements.in --python-version 3.12 --cache-dir [CACHE_DIR] + markupsafe==2.1.2 \ + --hash=sha256:0576fe974b40a400449768941d5d0858cc624e3249dfd1e0c33674e5c7ca7aed \ + --hash=sha256:085fd3201e7b12809f9e6e9bc1e5c96a368c8523fad5afb02afe3c051ae4afcc \ + --hash=sha256:090376d812fb6ac5f171e5938e82e7f2d7adc2b629101cec0db8b267815c85e2 + "})?; + + // Requesting an upgrade should update the hashes, even if the version didn't change. + uv_snapshot!(context.compile() + .arg("requirements.in") + .arg("--output-file") + .arg("requirements.txt") + .arg("--generate-hashes") + .arg("--upgrade"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + # This file was autogenerated by uv via the following command: + # uv pip compile --cache-dir [CACHE_DIR] --exclude-newer 2023-11-18T12:00:00Z requirements.in --output-file requirements.txt --generate-hashes + markupsafe==2.1.2 \ + --hash=sha256:0576fe974b40a400449768941d5d0858cc624e3249dfd1e0c33674e5c7ca7aed \ + --hash=sha256:085fd3201e7b12809f9e6e9bc1e5c96a368c8523fad5afb02afe3c051ae4afcc \ + --hash=sha256:090376d812fb6ac5f171e5938e82e7f2d7adc2b629101cec0db8b267815c85e2 \ + --hash=sha256:0b462104ba25f1ac006fdab8b6a01ebbfbce9ed37fd37fd4acd70c67c973e460 \ + --hash=sha256:137678c63c977754abe9086a3ec011e8fd985ab90631145dfb9294ad09c102a7 \ + --hash=sha256:1bea30e9bf331f3fef67e0a3877b2288593c98a21ccb2cf29b74c581a4eb3af0 \ + --hash=sha256:22152d00bf4a9c7c83960521fc558f55a1adbc0631fbb00a9471e097b19d72e1 \ + --hash=sha256:22731d79ed2eb25059ae3df1dfc9cb1546691cc41f4e3130fe6bfbc3ecbbecfa \ + --hash=sha256:2298c859cfc5463f1b64bd55cb3e602528db6fa0f3cfd568d3605c50678f8f03 \ + --hash=sha256:28057e985dace2f478e042eaa15606c7efccb700797660629da387eb289b9323 \ + --hash=sha256:2e7821bffe00aa6bd07a23913b7f4e01328c3d5cc0b40b36c0bd81d362faeb65 \ + --hash=sha256:2ec4f2d48ae59bbb9d1f9d7efb9236ab81429a764dedca114f5fdabbc3788013 \ + --hash=sha256:340bea174e9761308703ae988e982005aedf427de816d1afe98147668cc03036 \ + --hash=sha256:40627dcf047dadb22cd25ea7ecfe9cbf3bbbad0482ee5920b582f3809c97654f \ + --hash=sha256:40dfd3fefbef579ee058f139733ac336312663c6706d1163b82b3003fb1925c4 \ + --hash=sha256:4cf06cdc1dda95223e9d2d3c58d3b178aa5dacb35ee7e3bbac10e4e1faacb419 \ + --hash=sha256:50c42830a633fa0cf9e7d27664637532791bfc31c731a87b202d2d8ac40c3ea2 \ + --hash=sha256:55f44b440d491028addb3b88f72207d71eeebfb7b5dbf0643f7c023ae1fba619 \ + --hash=sha256:608e7073dfa9e38a85d38474c082d4281f4ce276ac0010224eaba11e929dd53a \ + --hash=sha256:63ba06c9941e46fa389d389644e2d8225e0e3e5ebcc4ff1ea8506dce646f8c8a \ + --hash=sha256:65608c35bfb8a76763f37036547f7adfd09270fbdbf96608be2bead319728fcd \ + --hash=sha256:665a36ae6f8f20a4676b53224e33d456a6f5a72657d9c83c2aa00765072f31f7 \ + --hash=sha256:6d6607f98fcf17e534162f0709aaad3ab7a96032723d8ac8750ffe17ae5a0666 \ + --hash=sha256:7313ce6a199651c4ed9d7e4cfb4aa56fe923b1adf9af3b420ee14e6d9a73df65 \ + --hash=sha256:7668b52e102d0ed87cb082380a7e2e1e78737ddecdde129acadb0eccc5423859 \ + --hash=sha256:7df70907e00c970c60b9ef2938d894a9381f38e6b9db73c5be35e59d92e06625 \ + --hash=sha256:7e007132af78ea9df29495dbf7b5824cb71648d7133cf7848a2a5dd00d36f9ff \ + --hash=sha256:835fb5e38fd89328e9c81067fd642b3593c33e1e17e2fdbf77f5676abb14a156 \ + --hash=sha256:8bca7e26c1dd751236cfb0c6c72d4ad61d986e9a41bbf76cb445f69488b2a2bd \ + --hash=sha256:8db032bf0ce9022a8e41a22598eefc802314e81b879ae093f36ce9ddf39ab1ba \ + --hash=sha256:99625a92da8229df6d44335e6fcc558a5037dd0a760e11d84be2260e6f37002f \ + --hash=sha256:9cad97ab29dfc3f0249b483412c85c8ef4766d96cdf9dcf5a1e3caa3f3661cf1 \ + --hash=sha256:a4abaec6ca3ad8660690236d11bfe28dfd707778e2442b45addd2f086d6ef094 \ + --hash=sha256:a6e40afa7f45939ca356f348c8e23048e02cb109ced1eb8420961b2f40fb373a \ + --hash=sha256:a6f2fcca746e8d5910e18782f976489939d54a91f9411c32051b4aab2bd7c513 \ + --hash=sha256:a806db027852538d2ad7555b203300173dd1b77ba116de92da9afbc3a3be3eed \ + --hash=sha256:abcabc8c2b26036d62d4c746381a6f7cf60aafcc653198ad678306986b09450d \ + --hash=sha256:b8526c6d437855442cdd3d87eede9c425c4445ea011ca38d937db299382e6fa3 \ + --hash=sha256:bb06feb762bade6bf3c8b844462274db0c76acc95c52abe8dbed28ae3d44a147 \ + --hash=sha256:c0a33bc9f02c2b17c3ea382f91b4db0e6cde90b63b296422a939886a7a80de1c \ + --hash=sha256:c4a549890a45f57f1ebf99c067a4ad0cb423a05544accaf2b065246827ed9603 \ + --hash=sha256:ca244fa73f50a800cf8c3ebf7fd93149ec37f5cb9596aa8873ae2c1d23498601 \ + --hash=sha256:cf877ab4ed6e302ec1d04952ca358b381a882fbd9d1b07cccbfd61783561f98a \ + --hash=sha256:d9d971ec1e79906046aa3ca266de79eac42f1dbf3612a05dc9368125952bd1a1 \ + --hash=sha256:da25303d91526aac3672ee6d49a2f3db2d9502a4a60b55519feb1a4c7714e07d \ + --hash=sha256:e55e40ff0cc8cc5c07996915ad367fa47da6b3fc091fdadca7f5403239c5fec3 \ + --hash=sha256:f03a532d7dee1bed20bc4884194a16160a2de9ffc6354b3878ec9682bb623c54 \ + --hash=sha256:f1cd098434e83e656abf198f103a8207a8187c0fc110306691a2e94a78d0abb2 \ + --hash=sha256:f2bfb563d0211ce16b63c7cb9395d2c682a23187f54c3d79bfec33e6705473c6 \ + --hash=sha256:f8ffb705ffcf5ddd0e80b65ddf7bed7ee4f5a441ea7d3419e861a12eaf41af58 + + ----- stderr ----- + Resolved 1 package in [TIME] + "### + ); + + Ok(()) +} + +/// `--generate-hashes` should update the hashes in the "lockfile" if the package is upgraded due +/// to a change in requirements. +#[test] +fn preserve_hashes_newer_version() -> Result<()> { + let context = TestContext::new("3.12"); + let requirements_in = context.temp_dir.child("requirements.in"); + requirements_in.write_str("markupsafe==2.1.3")?; + + // Write a subset of the hashes to the "lockfile". + let requirements_txt = context.temp_dir.child("requirements.txt"); + requirements_txt.write_str(indoc! {r" + # This file was autogenerated by uv via the following command: + # uv pip compile requirements.in --python-version 3.12 --cache-dir [CACHE_DIR] + markupsafe==2.1.2 \ + --hash=sha256:0576fe974b40a400449768941d5d0858cc624e3249dfd1e0c33674e5c7ca7aed \ + --hash=sha256:085fd3201e7b12809f9e6e9bc1e5c96a368c8523fad5afb02afe3c051ae4afcc \ + --hash=sha256:090376d812fb6ac5f171e5938e82e7f2d7adc2b629101cec0db8b267815c85e2 + "})?; + + // Requesting a more specific version should update the hashes. + uv_snapshot!(context.compile() + .arg("requirements.in") + .arg("--output-file") + .arg("requirements.txt") + .arg("--generate-hashes"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + # This file was autogenerated by uv via the following command: + # uv pip compile --cache-dir [CACHE_DIR] --exclude-newer 2023-11-18T12:00:00Z requirements.in --output-file requirements.txt --generate-hashes + markupsafe==2.1.3 \ + --hash=sha256:05fb21170423db021895e1ea1e1f3ab3adb85d1c2333cbc2310f2a26bc77272e \ + --hash=sha256:0a4e4a1aff6c7ac4cd55792abf96c915634c2b97e3cc1c7129578aa68ebd754e \ + --hash=sha256:10bbfe99883db80bdbaff2dcf681dfc6533a614f700da1287707e8a5d78a8431 \ + --hash=sha256:134da1eca9ec0ae528110ccc9e48041e0828d79f24121a1a146161103c76e686 \ + --hash=sha256:14ff806850827afd6b07a5f32bd917fb7f45b046ba40c57abdb636674a8b559c \ + --hash=sha256:1577735524cdad32f9f694208aa75e422adba74f1baee7551620e43a3141f559 \ + --hash=sha256:1b40069d487e7edb2676d3fbdb2b0829ffa2cd63a2ec26c4938b2d34391b4ecc \ + --hash=sha256:1b8dd8c3fd14349433c79fa8abeb573a55fc0fdd769133baac1f5e07abf54aeb \ + --hash=sha256:1f67c7038d560d92149c060157d623c542173016c4babc0c1913cca0564b9939 \ + --hash=sha256:282c2cb35b5b673bbcadb33a585408104df04f14b2d9b01d4c345a3b92861c2c \ + --hash=sha256:2c1b19b3aaacc6e57b7e25710ff571c24d6c3613a45e905b1fde04d691b98ee0 \ + --hash=sha256:2ef12179d3a291be237280175b542c07a36e7f60718296278d8593d21ca937d4 \ + --hash=sha256:338ae27d6b8745585f87218a3f23f1512dbf52c26c28e322dbe54bcede54ccb9 \ + --hash=sha256:3c0fae6c3be832a0a0473ac912810b2877c8cb9d76ca48de1ed31e1c68386575 \ + --hash=sha256:3fd4abcb888d15a94f32b75d8fd18ee162ca0c064f35b11134be77050296d6ba \ + --hash=sha256:42de32b22b6b804f42c5d98be4f7e5e977ecdd9ee9b660fda1a3edf03b11792d \ + --hash=sha256:47d4f1c5f80fc62fdd7777d0d40a2e9dda0a05883ab11374334f6c4de38adffd \ + --hash=sha256:504b320cd4b7eff6f968eddf81127112db685e81f7e36e75f9f84f0df46041c3 \ + --hash=sha256:525808b8019e36eb524b8c68acdd63a37e75714eac50e988180b169d64480a00 \ + --hash=sha256:56d9f2ecac662ca1611d183feb03a3fa4406469dafe241673d521dd5ae92a155 \ + --hash=sha256:5bbe06f8eeafd38e5d0a4894ffec89378b6c6a625ff57e3028921f8ff59318ac \ + --hash=sha256:65c1a9bcdadc6c28eecee2c119465aebff8f7a584dd719facdd9e825ec61ab52 \ + --hash=sha256:68e78619a61ecf91e76aa3e6e8e33fc4894a2bebe93410754bd28fce0a8a4f9f \ + --hash=sha256:69c0f17e9f5a7afdf2cc9fb2d1ce6aabdb3bafb7f38017c0b77862bcec2bbad8 \ + --hash=sha256:6b2b56950d93e41f33b4223ead100ea0fe11f8e6ee5f641eb753ce4b77a7042b \ + --hash=sha256:715d3562f79d540f251b99ebd6d8baa547118974341db04f5ad06d5ea3eb8007 \ + --hash=sha256:787003c0ddb00500e49a10f2844fac87aa6ce977b90b0feaaf9de23c22508b24 \ + --hash=sha256:7ef3cb2ebbf91e330e3bb937efada0edd9003683db6b57bb108c4001f37a02ea \ + --hash=sha256:8023faf4e01efadfa183e863fefde0046de576c6f14659e8782065bcece22198 \ + --hash=sha256:8758846a7e80910096950b67071243da3e5a20ed2546e6392603c096778d48e0 \ + --hash=sha256:8afafd99945ead6e075b973fefa56379c5b5c53fd8937dad92c662da5d8fd5ee \ + --hash=sha256:8c41976a29d078bb235fea9b2ecd3da465df42a562910f9022f1a03107bd02be \ + --hash=sha256:8e254ae696c88d98da6555f5ace2279cf7cd5b3f52be2b5cf97feafe883b58d2 \ + --hash=sha256:8f9293864fe09b8149f0cc42ce56e3f0e54de883a9de90cd427f191c346eb2e1 \ + --hash=sha256:9402b03f1a1b4dc4c19845e5c749e3ab82d5078d16a2a4c2cd2df62d57bb0707 \ + --hash=sha256:962f82a3086483f5e5f64dbad880d31038b698494799b097bc59c2edf392fce6 \ + --hash=sha256:9aad3c1755095ce347e26488214ef77e0485a3c34a50c5a5e2471dff60b9dd9c \ + --hash=sha256:9dcdfd0eaf283af041973bff14a2e143b8bd64e069f4c383416ecd79a81aab58 \ + --hash=sha256:aa57bd9cf8ae831a362185ee444e15a93ecb2e344c8e52e4d721ea3ab6ef1823 \ + --hash=sha256:aa7bd130efab1c280bed0f45501b7c8795f9fdbeb02e965371bbef3523627779 \ + --hash=sha256:ab4a0df41e7c16a1392727727e7998a467472d0ad65f3ad5e6e765015df08636 \ + --hash=sha256:ad9e82fb8f09ade1c3e1b996a6337afac2b8b9e365f926f5a61aacc71adc5b3c \ + --hash=sha256:af598ed32d6ae86f1b747b82783958b1a4ab8f617b06fe68795c7f026abbdcad \ + --hash=sha256:b076b6226fb84157e3f7c971a47ff3a679d837cf338547532ab866c57930dbee \ + --hash=sha256:b7ff0f54cb4ff66dd38bebd335a38e2c22c41a8ee45aa608efc890ac3e3931bc \ + --hash=sha256:bfce63a9e7834b12b87c64d6b155fdd9b3b96191b6bd334bf37db7ff1fe457f2 \ + --hash=sha256:c011a4149cfbcf9f03994ec2edffcb8b1dc2d2aede7ca243746df97a5d41ce48 \ + --hash=sha256:c9c804664ebe8f83a211cace637506669e7890fec1b4195b505c214e50dd4eb7 \ + --hash=sha256:ca379055a47383d02a5400cb0d110cef0a776fc644cda797db0c5696cfd7e18e \ + --hash=sha256:cb0932dc158471523c9637e807d9bfb93e06a95cbf010f1a38b98623b929ef2b \ + --hash=sha256:cd0f502fe016460680cd20aaa5a76d241d6f35a1c3350c474bac1273803893fa \ + --hash=sha256:ceb01949af7121f9fc39f7d27f91be8546f3fb112c608bc4029aef0bab86a2a5 \ + --hash=sha256:d080e0a5eb2529460b30190fcfcc4199bd7f827663f858a226a81bc27beaa97e \ + --hash=sha256:dd15ff04ffd7e05ffcb7fe79f1b98041b8ea30ae9234aed2a9168b5797c3effb \ + --hash=sha256:df0be2b576a7abbf737b1575f048c23fb1d769f267ec4358296f31c2479db8f9 \ + --hash=sha256:e09031c87a1e51556fdcb46e5bd4f59dfb743061cf93c4d6831bf894f125eb57 \ + --hash=sha256:e4dd52d80b8c83fdce44e12478ad2e85c64ea965e75d66dbeafb0a3e77308fcc \ + --hash=sha256:f698de3fd0c4e6972b92290a45bd9b1536bffe8c6759c62471efaa8acb4c37bc \ + --hash=sha256:fec21693218efe39aa7f8599346e90c705afa52c5b31ae019b2e57e8f6542bb2 \ + --hash=sha256:ffcc3f7c66b5f5b7931a5aa68fc9cecc51e685ef90282f4a82f0f5e9b704ad11 + + ----- stderr ----- + Resolved 1 package in [TIME] + "### + ); + + Ok(()) +}