From d0a6f5d13fb5a7c569db9f9deddadc106f9aa1cb Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Tue, 9 Dec 2025 13:52:14 -0600 Subject: [PATCH] Add support for relative durations in `exclude-newer` (#16814) Adds support for "friendly" durations like, 1 week, 7 days, 24 hours using Jiff's parser. During resolution, we calculate this relative to the current time and resolve it into a concrete timestamp for the lockfile. If the span has not changed, e.g., to another relative value, then locking again will not change the lockfile. The locked timestamp will only be updated when the lockfile is invalidated, e.g., with `--upgrade`. This prevents the lockfile from repeatedly churning when a relative value is used. --- crates/uv-cli/src/lib.rs | 164 ++- crates/uv-resolver/src/exclude_newer.rs | 580 ++++++++- crates/uv-resolver/src/lib.rs | 3 +- crates/uv-resolver/src/lock/mod.rs | 41 +- ...r__lock__tests__hash_optional_missing.snap | 1 + ...r__lock__tests__hash_optional_present.snap | 1 + ...r__lock__tests__hash_required_present.snap | 1 + ...missing_dependency_source_unambiguous.snap | 1 + ...dependency_source_version_unambiguous.snap | 1 + ...s__missing_dependency_version_dynamic.snap | 1 + ...issing_dependency_version_unambiguous.snap | 1 + ...lock__tests__source_direct_has_subdir.snap | 1 + ..._lock__tests__source_direct_no_subdir.snap | 1 + ...solver__lock__tests__source_directory.snap | 1 + ...esolver__lock__tests__source_editable.snap | 1 + crates/uv-resolver/src/version_map.rs | 4 +- crates/uv-settings/src/combine.rs | 6 +- crates/uv-settings/src/settings.rs | 14 +- crates/uv-static/src/env_vars.rs | 6 + crates/uv/src/commands/project/lock.rs | 34 +- crates/uv/src/commands/tool/upgrade.rs | 4 +- crates/uv/tests/it/common/mod.rs | 2 + .../tests/it/lock_exclude_newer_relative.rs | 1141 +++++++++++++++++ crates/uv/tests/it/main.rs | 3 + crates/uv/tests/it/pip_compile.rs | 14 +- ...m__home-assistant-core-uv-lock-output.snap | 2 +- .../it__ecosystem__saleor-uv-lock-output.snap | 2 +- crates/uv/tests/it/sync.rs | 4 +- docs/concepts/resolution.md | 76 +- docs/guides/scripts.md | 2 +- uv.schema.json | 2 +- 31 files changed, 1937 insertions(+), 178 deletions(-) create mode 100644 crates/uv/tests/it/lock_exclude_newer_relative.rs diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index ea4fc07bf..9f9d41a13 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -28,7 +28,7 @@ use uv_pypi_types::VerbatimParsedUrl; use uv_python::{PythonDownloads, PythonPreference, PythonVersion}; use uv_redacted::DisplaySafeUrl; use uv_resolver::{ - AnnotationStyle, ExcludeNewerPackageEntry, ExcludeNewerTimestamp, ForkStrategy, PrereleaseMode, + AnnotationStyle, ExcludeNewerPackageEntry, ExcludeNewerValue, ForkStrategy, PrereleaseMode, ResolutionMode, }; use uv_settings::PythonInstallMirrors; @@ -3083,15 +3083,29 @@ pub struct VenvArgs { /// Limit candidate packages to those that were uploaded prior to the given date. /// - /// Accepts both RFC 3339 timestamps (e.g., `2006-12-02T02:07:43Z`) and local dates in the same - /// format (e.g., `2006-12-02`) in your system's configured time zone. - #[arg(long, env = EnvVars::UV_EXCLUDE_NEWER)] - pub exclude_newer: Option, - - /// Limit candidate packages for a specific package to those that were uploaded prior to the given date. + /// Accepts RFC 3339 timestamps (e.g., `2006-12-02T02:07:43Z`), local dates in the same format + /// (e.g., `2006-12-02`) resolved based on your system's configured time zone, a "friendly" + /// duration (e.g., `24 hours`, `1 week`, `30 days`), or an ISO 8601 duration (e.g., `PT24H`, + /// `P7D`, `P30D`). /// - /// Accepts package-date pairs in the format `PACKAGE=DATE`, where `DATE` is an RFC 3339 timestamp - /// (e.g., `2006-12-02T02:07:43Z`) or local date (e.g., `2006-12-02`) in your system's configured time zone. + /// Durations do not respect semantics of the local time zone and are always resolved to a fixed + /// number of seconds assuming that a day is 24 hours (e.g., DST transitions are ignored). + /// Calendar units such as months and years are not allowed. + #[arg(long, env = EnvVars::UV_EXCLUDE_NEWER)] + pub exclude_newer: Option, + + /// Limit candidate packages for a specific package to those that were uploaded prior to the + /// given date. + /// + /// Accepts package-date pairs in the format `PACKAGE=DATE`, where `DATE` is an RFC 3339 + /// timestamp (e.g., `2006-12-02T02:07:43Z`), a local date in the same format (e.g., + /// `2006-12-02`) resolved based on your system's configured time zone, a "friendly" duration + /// (e.g., `24 hours`, `1 week`, `30 days`), or a ISO 8601 duration (e.g., `PT24H`, `P7D`, + /// `P30D`). + /// + /// Durations do not respect semantics of the local time zone and are always resolved to a fixed + /// number of seconds assuming that a day is 24 hours (e.g., DST transitions are ignored). + /// Calendar units such as months and years are not allowed. /// /// Can be provided multiple times for different packages. #[arg(long)] @@ -5579,15 +5593,29 @@ pub struct ToolUpgradeArgs { /// Limit candidate packages to those that were uploaded prior to the given date. /// - /// Accepts both RFC 3339 timestamps (e.g., `2006-12-02T02:07:43Z`) and local dates in the same - /// format (e.g., `2006-12-02`) in your system's configured time zone. - #[arg(long, env = EnvVars::UV_EXCLUDE_NEWER, help_heading = "Resolver options")] - pub exclude_newer: Option, - - /// Limit candidate packages for specific packages to those that were uploaded prior to the given date. + /// Accepts RFC 3339 timestamps (e.g., `2006-12-02T02:07:43Z`), local dates in the same format + /// (e.g., `2006-12-02`) resolved based on your system's configured time zone, a "friendly" + /// duration (e.g., `24 hours`, `1 week`, `30 days`), or an ISO 8601 duration (e.g., `PT24H`, + /// `P7D`, `P30D`). /// - /// Accepts package-date pairs in the format `PACKAGE=DATE`, where `DATE` is an RFC 3339 timestamp - /// (e.g., `2006-12-02T02:07:43Z`) or local date (e.g., `2006-12-02`) in your system's configured time zone. + /// Durations do not respect semantics of the local time zone and are always resolved to a fixed + /// number of seconds assuming that a day is 24 hours (e.g., DST transitions are ignored). + /// Calendar units such as months and years are not allowed. + #[arg(long, env = EnvVars::UV_EXCLUDE_NEWER, help_heading = "Resolver options")] + pub exclude_newer: Option, + + /// Limit candidate packages for specific packages to those that were uploaded prior to the + /// given date. + /// + /// Accepts package-date pairs in the format `PACKAGE=DATE`, where `DATE` is an RFC 3339 + /// timestamp (e.g., `2006-12-02T02:07:43Z`), a local date in the same format (e.g., + /// `2006-12-02`) resolved based on your system's configured time zone, a "friendly" duration + /// (e.g., `24 hours`, `1 week`, `30 days`), or an ISO 8601 duration (e.g., `PT24H`, `P7D`, + /// `P30D`). + /// + /// Durations do not respect semantics of the local time zone and are always resolved to a fixed + /// number of seconds assuming that a day is 24 hours (e.g., DST transitions are ignored). + /// Calendar units such as months and years are not allowed. /// /// Can be provided multiple times for different packages. #[arg(long, help_heading = "Resolver options")] @@ -6537,15 +6565,29 @@ pub struct InstallerArgs { /// Limit candidate packages to those that were uploaded prior to the given date. /// - /// Accepts both RFC 3339 timestamps (e.g., `2006-12-02T02:07:43Z`) and local dates in the same - /// format (e.g., `2006-12-02`) in your system's configured time zone. - #[arg(long, env = EnvVars::UV_EXCLUDE_NEWER, help_heading = "Resolver options")] - pub exclude_newer: Option, - - /// Limit candidate packages for specific packages to those that were uploaded prior to the given date. + /// Accepts RFC 3339 timestamps (e.g., `2006-12-02T02:07:43Z`), local dates in the same format + /// (e.g., `2006-12-02`) resolved based on your system's configured time zone, a "friendly" + /// duration (e.g., `24 hours`, `1 week`, `30 days`), or an ISO 8601 duration (e.g., `PT24H`, + /// `P7D`, `P30D`). /// - /// Accepts package-date pairs in the format `PACKAGE=DATE`, where `DATE` is an RFC 3339 timestamp - /// (e.g., `2006-12-02T02:07:43Z`) or local date (e.g., `2006-12-02`) in your system's configured time zone. + /// Durations do not respect semantics of the local time zone and are always resolved to a fixed + /// number of seconds assuming that a day is 24 hours (e.g., DST transitions are ignored). + /// Calendar units such as months and years are not allowed. + #[arg(long, env = EnvVars::UV_EXCLUDE_NEWER, help_heading = "Resolver options")] + pub exclude_newer: Option, + + /// Limit candidate packages for specific packages to those that were uploaded prior to the + /// given date. + /// + /// Accepts package-date pairs in the format `PACKAGE=DATE`, where `DATE` is an RFC 3339 + /// timestamp (e.g., `2006-12-02T02:07:43Z`), a local date in the same format (e.g., + /// `2006-12-02`) resolved based on your system's configured time zone, a "friendly" duration + /// (e.g., `24 hours`, `1 week`, `30 days`), or an ISO 8601 duration (e.g., `PT24H`, `P7D`, + /// `P30D`). + /// + /// Durations do not respect semantics of the local time zone and are always resolved to a fixed + /// number of seconds assuming that a day is 24 hours (e.g., DST transitions are ignored). + /// Calendar units such as months and years are not allowed. /// /// Can be provided multiple times for different packages. #[arg(long, help_heading = "Resolver options")] @@ -6757,15 +6799,29 @@ pub struct ResolverArgs { /// Limit candidate packages to those that were uploaded prior to the given date. /// - /// Accepts both RFC 3339 timestamps (e.g., `2006-12-02T02:07:43Z`) and local dates in the same - /// format (e.g., `2006-12-02`) in your system's configured time zone. - #[arg(long, env = EnvVars::UV_EXCLUDE_NEWER, help_heading = "Resolver options")] - pub exclude_newer: Option, - - /// Limit candidate packages for a specific package to those that were uploaded prior to the given date. + /// Accepts RFC 3339 timestamps (e.g., `2006-12-02T02:07:43Z`), local dates in the same format + /// (e.g., `2006-12-02`) resolved based on your system's configured time zone, a "friendly" + /// duration (e.g., `24 hours`, `1 week`, `30 days`), or an ISO 8601 duration (e.g., `PT24H`, + /// `P7D`, `P30D`). /// - /// Accepts package-date pairs in the format `PACKAGE=DATE`, where `DATE` is an RFC 3339 timestamp - /// (e.g., `2006-12-02T02:07:43Z`) or local date (e.g., `2006-12-02`) in your system's configured time zone. + /// Durations do not respect semantics of the local time zone and are always resolved to a fixed + /// number of seconds assuming that a day is 24 hours (e.g., DST transitions are ignored). + /// Calendar units such as months and years are not allowed. + #[arg(long, env = EnvVars::UV_EXCLUDE_NEWER, help_heading = "Resolver options")] + pub exclude_newer: Option, + + /// Limit candidate packages for specific packages to those that were uploaded prior to the + /// given date. + /// + /// Accepts package-date pairs in the format `PACKAGE=DATE`, where `DATE` is an RFC 3339 + /// timestamp (e.g., `2006-12-02T02:07:43Z`), a local date in the same format (e.g., + /// `2006-12-02`) resolved based on your system's configured time zone, a "friendly" duration + /// (e.g., `24 hours`, `1 week`, `30 days`), or an ISO 8601 duration (e.g., `PT24H`, `P7D`, + /// `P30D`). + /// + /// Durations do not respect semantics of the local time zone and are always resolved to a fixed + /// number of seconds assuming that a day is 24 hours (e.g., DST transitions are ignored). + /// Calendar units such as months and years are not allowed. /// /// Can be provided multiple times for different packages. #[arg(long, help_heading = "Resolver options")] @@ -6973,15 +7029,29 @@ pub struct ResolverInstallerArgs { /// Limit candidate packages to those that were uploaded prior to the given date. /// - /// Accepts both RFC 3339 timestamps (e.g., `2006-12-02T02:07:43Z`) and local dates in the same - /// format (e.g., `2006-12-02`) in your system's configured time zone. - #[arg(long, env = EnvVars::UV_EXCLUDE_NEWER, help_heading = "Resolver options")] - pub exclude_newer: Option, - - /// Limit candidate packages for specific packages to those that were uploaded prior to the given date. + /// Accepts RFC 3339 timestamps (e.g., `2006-12-02T02:07:43Z`), local dates in the same format + /// (e.g., `2006-12-02`) resolved based on your system's configured time zone, a "friendly" + /// duration (e.g., `24 hours`, `1 week`, `30 days`), or an ISO 8601 duration (e.g., `PT24H`, + /// `P7D`, `P30D`). /// - /// Accepts package-date pairs in the format `PACKAGE=DATE`, where `DATE` is an RFC 3339 timestamp - /// (e.g., `2006-12-02T02:07:43Z`) or local date (e.g., `2006-12-02`) in your system's configured time zone. + /// Durations do not respect semantics of the local time zone and are always resolved to a fixed + /// number of seconds assuming that a day is 24 hours (e.g., DST transitions are ignored). + /// Calendar units such as months and years are not allowed. + #[arg(long, env = EnvVars::UV_EXCLUDE_NEWER, help_heading = "Resolver options")] + pub exclude_newer: Option, + + /// Limit candidate packages for specific packages to those that were uploaded prior to the + /// given date. + /// + /// Accepts package-date pairs in the format `PACKAGE=DATE`, where `DATE` is an RFC 3339 + /// timestamp (e.g., `2006-12-02T02:07:43Z`), a local date in the same format (e.g., + /// `2006-12-02`) resolved based on your system's configured time zone, a "friendly" duration + /// (e.g., `24 hours`, `1 week`, `30 days`), or an ISO 8601 duration (e.g., `PT24H`, `P7D`, + /// `P30D`). + /// + /// Durations do not respect semantics of the local time zone and are always resolved to a fixed + /// number of seconds assuming that a day is 24 hours (e.g., DST transitions are ignored). + /// Calendar units such as months and years are not allowed. /// /// Can be provided multiple times for different packages. #[arg(long, help_heading = "Resolver options")] @@ -7081,10 +7151,16 @@ pub struct FetchArgs { /// Limit candidate packages to those that were uploaded prior to the given date. /// - /// Accepts both RFC 3339 timestamps (e.g., `2006-12-02T02:07:43Z`) and local dates in the same - /// format (e.g., `2006-12-02`) in your system's configured time zone. + /// Accepts RFC 3339 timestamps (e.g., `2006-12-02T02:07:43Z`), local dates in the same format + /// (e.g., `2006-12-02`) resolved based on your system's configured time zone, a "friendly" + /// duration (e.g., `24 hours`, `1 week`, `30 days`), or an ISO 8601 duration (e.g., `PT24H`, + /// `P7D`, `P30D`). + /// + /// Durations do not respect semantics of the local time zone and are always resolved to a fixed + /// number of seconds assuming that a day is 24 hours (e.g., DST transitions are ignored). + /// Calendar units such as months and years are not allowed. #[arg(long, env = EnvVars::UV_EXCLUDE_NEWER, help_heading = "Resolver options")] - pub exclude_newer: Option, + pub exclude_newer: Option, } #[derive(Args)] diff --git a/crates/uv-resolver/src/exclude_newer.rs b/crates/uv-resolver/src/exclude_newer.rs index 7f4166f98..ea28332e5 100644 --- a/crates/uv-resolver/src/exclude_newer.rs +++ b/crates/uv-resolver/src/exclude_newer.rs @@ -5,70 +5,423 @@ use std::{ str::FromStr, }; -use jiff::{Timestamp, ToSpan, tz::TimeZone}; +use jiff::{Span, Timestamp, ToSpan, Unit, tz::TimeZone}; use rustc_hash::FxHashMap; use uv_normalize::PackageName; +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ExcludeNewerValueChange { + /// A relative span changed to a new value + SpanChanged(ExcludeNewerSpan, ExcludeNewerSpan), + /// A relative span was added + SpanAdded(ExcludeNewerSpan), + /// A relative span was removed + SpanRemoved, + /// A relative span is present and the timestamp changed + RelativeTimestampChanged(Timestamp, Timestamp, ExcludeNewerSpan), + /// The timestamp changed and a relative span is not present + AbsoluteTimestampChanged(Timestamp, Timestamp), +} + +impl ExcludeNewerValueChange { + pub fn is_relative_timestamp_change(&self) -> bool { + matches!(self, Self::RelativeTimestampChanged(_, _, _)) + } +} + +impl std::fmt::Display for ExcludeNewerValueChange { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::SpanChanged(old, new) => { + write!(f, "change of exclude newer span from `{old}` to `{new}`") + } + Self::SpanAdded(span) => { + write!(f, "addition of exclude newer span `{span}`") + } + Self::SpanRemoved => { + write!(f, "removal of exclude newer span") + } + Self::RelativeTimestampChanged(old, new, span) => { + write!( + f, + "change of calculated ({span}) exclude newer timestamp from `{old}` to `{new}`" + ) + } + Self::AbsoluteTimestampChanged(old, new) => { + write!( + f, + "change of exclude newer timestamp from `{old}` to `{new}`" + ) + } + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ExcludeNewerChange { + GlobalChanged(ExcludeNewerValueChange), + GlobalAdded(ExcludeNewerValue), + GlobalRemoved, + Package(ExcludeNewerPackageChange), +} + +impl ExcludeNewerChange { + /// Whether the change is due to a change in a relative timestamp. + pub fn is_relative_timestamp_change(&self) -> bool { + match self { + Self::GlobalChanged(change) => change.is_relative_timestamp_change(), + Self::GlobalAdded(_) | Self::GlobalRemoved => false, + Self::Package(change) => change.is_relative_timestamp_change(), + } + } +} + +impl std::fmt::Display for ExcludeNewerChange { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::GlobalChanged(change) => { + write!(f, "{change}") + } + Self::GlobalAdded(value) => { + write!(f, "addition of global exclude newer {value}") + } + Self::GlobalRemoved => write!(f, "removal of global exclude newer"), + Self::Package(change) => { + write!(f, "{change}") + } + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ExcludeNewerPackageChange { + PackageAdded(PackageName, ExcludeNewerValue), + PackageRemoved(PackageName), + PackageChanged(PackageName, ExcludeNewerValueChange), +} + +impl ExcludeNewerPackageChange { + pub fn is_relative_timestamp_change(&self) -> bool { + match self { + Self::PackageAdded(_, _) | Self::PackageRemoved(_) => false, + Self::PackageChanged(_, change) => change.is_relative_timestamp_change(), + } + } +} + +impl std::fmt::Display for ExcludeNewerPackageChange { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::PackageAdded(name, value) => { + write!( + f, + "addition of exclude newer `{value}` for package `{name}`" + ) + } + Self::PackageRemoved(name) => { + write!(f, "removal of exclude newer for package `{name}`") + } + Self::PackageChanged(name, change) => { + write!(f, "{change} for package `{name}`") + } + } + } +} /// A timestamp that excludes files newer than it. -#[derive(Debug, Copy, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)] -pub struct ExcludeNewerTimestamp(Timestamp); +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExcludeNewerValue { + /// The resolved timestamp. + timestamp: Timestamp, + /// The span used to derive the [`Timestamp`], if any. + span: Option, +} -impl ExcludeNewerTimestamp { - /// Returns the timestamp in milliseconds. +impl ExcludeNewerValue { + pub fn into_parts(self) -> (Timestamp, Option) { + (self.timestamp, self.span) + } + + pub fn compare(&self, other: &Self) -> Option { + match (&self.span, &other.span) { + (None, Some(span)) => Some(ExcludeNewerValueChange::SpanAdded(*span)), + (Some(_), None) => Some(ExcludeNewerValueChange::SpanRemoved), + (Some(self_span), Some(other_span)) if self_span != other_span => Some( + ExcludeNewerValueChange::SpanChanged(*self_span, *other_span), + ), + (Some(_), Some(span)) if self.timestamp != other.timestamp => { + Some(ExcludeNewerValueChange::RelativeTimestampChanged( + self.timestamp, + other.timestamp, + *span, + )) + } + (None, None) if self.timestamp != other.timestamp => Some( + ExcludeNewerValueChange::AbsoluteTimestampChanged(self.timestamp, other.timestamp), + ), + (Some(_), Some(_)) | (None, None) => None, + } + } +} + +#[derive(Debug, Copy, Clone)] +pub struct ExcludeNewerSpan(Span); + +impl std::fmt::Display for ExcludeNewerSpan { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } +} + +impl PartialEq for ExcludeNewerSpan { + fn eq(&self, other: &Self) -> bool { + self.0.fieldwise() == other.0.fieldwise() + } +} + +impl Eq for ExcludeNewerSpan {} + +impl serde::Serialize for ExcludeNewerSpan { + /// Serialize to an ISO 8601 duration string. + /// + /// We use ISO 8601 format for serialization (rather than the "friendly" format). + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(&self.0.to_string()) + } +} + +impl<'de> serde::Deserialize<'de> for ExcludeNewerSpan { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + let span: Span = s.parse().map_err(serde::de::Error::custom)?; + Ok(Self(span)) + } +} + +impl serde::Serialize for ExcludeNewerValue { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + self.timestamp.serialize(serializer) + } +} + +impl<'de> serde::Deserialize<'de> for ExcludeNewerValue { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + // Support both a simple string ("2024-03-11T00:00:00Z") and a table + // ({ timestamp = "2024-03-11T00:00:00Z", span = "P2W" }) + #[derive(serde::Deserialize)] + struct TableForm { + timestamp: Timestamp, + span: Option, + } + + #[derive(serde::Deserialize)] + #[serde(untagged)] + enum Helper { + String(String), + Table(Box), + } + + match Helper::deserialize(deserializer)? { + Helper::String(s) => Self::from_str(&s).map_err(serde::de::Error::custom), + Helper::Table(table) => Ok(Self::new(table.timestamp, table.span)), + } + } +} + +impl ExcludeNewerValue { + /// Return the [`Timestamp`] in milliseconds. pub fn timestamp_millis(&self) -> i64 { - self.0.as_millisecond() + self.timestamp.as_millisecond() + } + + /// Return the [`Timestamp`]. + pub fn timestamp(&self) -> Timestamp { + self.timestamp + } + + /// Return the [`ExcludeNewerSpan`] used to construct the [`Timestamp`], if any. + pub fn span(&self) -> Option<&ExcludeNewerSpan> { + self.span.as_ref() + } + + /// Create a new [`ExcludeNewerTimestamp`]. + pub fn new(timestamp: Timestamp, span: Option) -> Self { + Self { timestamp, span } } } -impl From for ExcludeNewerTimestamp { +impl From for ExcludeNewerValue { fn from(timestamp: Timestamp) -> Self { - Self(timestamp) + Self { + timestamp, + span: None, + } } } -impl FromStr for ExcludeNewerTimestamp { +/// Determine what format the user likely intended and return an appropriate error message. +fn format_exclude_newer_error( + input: &str, + date_err: &jiff::Error, + span_err: &jiff::Error, +) -> String { + let trimmed = input.trim(); + + // Check for ISO 8601 duration (`[-+]?[Pp]`), e.g., "P2W", "+P1D", "-P30D" + let after_sign = trimmed.trim_start_matches(['+', '-']); + if after_sign.starts_with('P') || after_sign.starts_with('p') { + return format!("`{input}` could not be parsed as an ISO 8601 duration: {span_err}"); + } + + // Check for friendly duration (`[-+]?\s*[0-9]+\s*[A-Za-z]`), e.g., "2 weeks", "-30 days", + // "1hour" + let after_sign_trimmed = after_sign.trim_start(); + let mut chars = after_sign_trimmed.chars().peekable(); + + // Check if we start with a digit + if chars.peek().is_some_and(char::is_ascii_digit) { + // Skip digits + while chars.peek().is_some_and(char::is_ascii_digit) { + chars.next(); + } + // Skip optional whitespace + while chars.peek().is_some_and(|c| c.is_whitespace()) { + chars.next(); + } + // Check if next character is a letter (unit designator) + if chars.peek().is_some_and(char::is_ascii_alphabetic) { + return format!("`{input}` could not be parsed as a duration: {span_err}"); + } + } + + // Check for date/timestamp (`[-+]?[0-9]{4}-`), e.g., "2024-01-01", "2024-01-01T00:00:00Z" + let mut chars = after_sign.chars(); + let looks_like_date = chars.next().is_some_and(|c| c.is_ascii_digit()) + && chars.next().is_some_and(|c| c.is_ascii_digit()) + && chars.next().is_some_and(|c| c.is_ascii_digit()) + && chars.next().is_some_and(|c| c.is_ascii_digit()) + && chars.next().is_some_and(|c| c == '-'); + + if looks_like_date { + return format!("`{input}` could not be parsed as a valid date: {date_err}"); + } + + // If we can't tell, return a generic error message + format!( + "`{input}` could not be parsed as a valid exclude-newer value (expected a date like `2024-01-01`, a timestamp like `2024-01-01T00:00:00Z`, or a duration like `3 days` or `P3D`)" + ) +} + +impl FromStr for ExcludeNewerValue { type Err = String; /// Parse an [`ExcludeNewerTimestamp`] from a string. /// - /// Accepts both RFC 3339 timestamps (e.g., `2006-12-02T02:07:43Z`) and local dates in the same - /// format (e.g., `2006-12-02`). + /// Accepts RFC 3339 timestamps (e.g., `2006-12-02T02:07:43Z`), local dates in the same format + /// (e.g., `2006-12-02`), "friendly" durations (e.g., `1 week`, `30 days`), and ISO 8601 + /// durations (e.g., `PT24H`, `P7D`, `P30D`). fn from_str(input: &str) -> Result { - // NOTE(burntsushi): Previously, when using Chrono, we tried - // to parse as a date first, then a timestamp, and if both - // failed, we combined both of the errors into one message. - // But in Jiff, if an RFC 3339 timestamp could be parsed, then - // it must necessarily be the case that a date can also be - // parsed. So we can collapse the error cases here. That is, - // if we fail to parse a timestamp and a date, then it should - // be sufficient to just report the error from parsing the date. - // If someone tried to write a timestamp but committed an error - // in the non-date portion, the date parsing below will still - // report a holistic error that will make sense to the user. - // (I added a snapshot test for that case.) + // Try parsing as a timestamp first if let Ok(timestamp) = input.parse::() { - return Ok(Self(timestamp)); + return Ok(Self::new(timestamp, None)); } - let date = input - .parse::() - .map_err(|err| format!("`{input}` could not be parsed as a valid date: {err}"))?; - let timestamp = date - .checked_add(1.day()) - .and_then(|date| date.to_zoned(TimeZone::system())) - .map(|zdt| zdt.timestamp()) - .map_err(|err| { - format!( - "`{input}` parsed to date `{date}`, but could not \ - be converted to a timestamp: {err}", - ) - })?; - Ok(Self(timestamp)) + + // Try parsing as a date + // In Jiff, if an RFC 3339 timestamp could be parsed, then it must necessarily be the case + // that a date can also be parsed. So we can collapse the error cases here. That is, if we + // fail to parse a timestamp and a date, then it should be sufficient to just report the + // error from parsing the date. If someone tried to write a timestamp but committed an error + // in the non-date portion, the date parsing below will still report a holistic error that + // will make sense to the user. (I added a snapshot test for that case.) + let date_err = match input.parse::() { + Ok(date) => { + let timestamp = date + .checked_add(1.day()) + .and_then(|date| date.to_zoned(TimeZone::system())) + .map(|zdt| zdt.timestamp()) + .map_err(|err| { + format!( + "`{input}` parsed to date `{date}`, but could not \ + be converted to a timestamp: {err}", + ) + })?; + return Ok(Self::new(timestamp, None)); + } + Err(err) => err, + }; + + // Try parsing as a span + let span_err = match input.parse::() { + Ok(span) => { + // Allow overriding the current time in tests for deterministic snapshots + let now = if let Ok(test_time) = std::env::var("UV_TEST_CURRENT_TIMESTAMP") { + test_time + .parse::() + .expect("UV_TEST_CURRENT_TIMESTAMP must be a valid RFC 3339 timestamp") + .to_zoned(TimeZone::UTC) + } else { + Timestamp::now().to_zoned(TimeZone::UTC) + }; + + // We do not allow years and months as units, as the amount of time they represent + // is not fixed and can differ depending on the local time zone. We could allow this + // via the CLI in the future, but shouldn't allow it via persistent configuration. + if span.get_years() != 0 { + let years = span + .total((Unit::Year, &now)) + .map(f64::ceil) + .unwrap_or(1.0) + .abs(); + let days = years * 365.0; + return Err(format!( + "Duration `{input}` uses unit 'years' which is not allowed; use days instead, e.g., `{days:.0} days`.", + )); + } + if span.get_months() != 0 { + let months = span + .total((Unit::Month, &now)) + .map(f64::ceil) + .unwrap_or(1.0) + .abs(); + let days = months * 30.0; + return Err(format!( + "Duration `{input}` uses 'months' which is not allowed; use days instead, e.g., `{days:.0} days`." + )); + } + + // We're using a UTC timezone so there are no transitions (e.g., DST) and days are + // always 24 hours. This means that we can also allow weeks as a unit. + // + // Note we use `span.abs()` so `1 day ago` has the same effect as `1 day` instead + // of resulting in a future date. + let cutoff = now.checked_sub(span.abs()).map_err(|err| { + format!("Duration `{input}` is too large to subtract from current time: {err}") + })?; + + return Ok(Self::new(cutoff.into(), Some(ExcludeNewerSpan(span)))); + } + Err(err) => err, + }; + + // Return a targeted error message based on heuristics about what the user likely intended + Err(format_exclude_newer_error(input, &date_err, &span_err)) } } -impl std::fmt::Display for ExcludeNewerTimestamp { +impl std::fmt::Display for ExcludeNewerValue { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - self.0.fmt(f) + self.timestamp.fmt(f) } } @@ -77,7 +430,7 @@ impl std::fmt::Display for ExcludeNewerTimestamp { #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] pub struct ExcludeNewerPackageEntry { pub package: PackageName, - pub timestamp: ExcludeNewerTimestamp, + pub timestamp: ExcludeNewerValue, } impl FromStr for ExcludeNewerPackageEntry { @@ -94,25 +447,25 @@ impl FromStr for ExcludeNewerPackageEntry { let package = PackageName::from_str(package).map_err(|err| { format!("Invalid `exclude-newer-package` package name `{package}`: {err}") })?; - let timestamp = ExcludeNewerTimestamp::from_str(date) + let timestamp = ExcludeNewerValue::from_str(date) .map_err(|err| format!("Invalid `exclude-newer-package` timestamp `{date}`: {err}"))?; Ok(Self { package, timestamp }) } } -impl From<(PackageName, ExcludeNewerTimestamp)> for ExcludeNewerPackageEntry { - fn from((package, timestamp): (PackageName, ExcludeNewerTimestamp)) -> Self { +impl From<(PackageName, ExcludeNewerValue)> for ExcludeNewerPackageEntry { + fn from((package, timestamp): (PackageName, ExcludeNewerValue)) -> Self { Self { package, timestamp } } } #[derive(Debug, Clone, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -pub struct ExcludeNewerPackage(FxHashMap); +pub struct ExcludeNewerPackage(FxHashMap); impl Deref for ExcludeNewerPackage { - type Target = FxHashMap; + type Target = FxHashMap; fn deref(&self) -> &Self::Target { &self.0 @@ -136,8 +489,8 @@ impl FromIterator for ExcludeNewerPackage { } impl IntoIterator for ExcludeNewerPackage { - type Item = (PackageName, ExcludeNewerTimestamp); - type IntoIter = std::collections::hash_map::IntoIter; + type Item = (PackageName, ExcludeNewerValue); + type IntoIter = std::collections::hash_map::IntoIter; fn into_iter(self) -> Self::IntoIter { self.0.into_iter() @@ -145,8 +498,8 @@ impl IntoIterator for ExcludeNewerPackage { } impl<'a> IntoIterator for &'a ExcludeNewerPackage { - type Item = (&'a PackageName, &'a ExcludeNewerTimestamp); - type IntoIter = std::collections::hash_map::Iter<'a, PackageName, ExcludeNewerTimestamp>; + type Item = (&'a PackageName, &'a ExcludeNewerValue); + type IntoIter = std::collections::hash_map::Iter<'a, PackageName, ExcludeNewerValue>; fn into_iter(self) -> Self::IntoIter { self.0.iter() @@ -155,9 +508,34 @@ impl<'a> IntoIterator for &'a ExcludeNewerPackage { impl ExcludeNewerPackage { /// Convert to the inner `HashMap`. - pub fn into_inner(self) -> FxHashMap { + pub fn into_inner(self) -> FxHashMap { self.0 } + + pub fn compare(&self, other: &Self) -> Option { + for (package, timestamp) in self { + let Some(other_timestamp) = other.get(package) else { + return Some(ExcludeNewerPackageChange::PackageRemoved(package.clone())); + }; + if let Some(change) = timestamp.compare(other_timestamp) { + return Some(ExcludeNewerPackageChange::PackageChanged( + package.clone(), + change, + )); + } + } + + for (package, value) in other { + if !self.contains_key(package) { + return Some(ExcludeNewerPackageChange::PackageAdded( + package.clone(), + value.clone(), + )); + } + } + + None + } } /// A setting that excludes files newer than a timestamp, at a global level or per-package. @@ -166,7 +544,7 @@ impl ExcludeNewerPackage { pub struct ExcludeNewer { /// Global timestamp that applies to all packages if no package-specific timestamp is set. #[serde(default, skip_serializing_if = "Option::is_none")] - pub global: Option, + pub global: Option, /// Per-package timestamps that override the global timestamp. #[serde(default, skip_serializing_if = "FxHashMap::is_empty")] pub package: ExcludeNewerPackage, @@ -174,7 +552,7 @@ pub struct ExcludeNewer { impl ExcludeNewer { /// Create a new exclude newer configuration with just a global timestamp. - pub fn global(global: ExcludeNewerTimestamp) -> Self { + pub fn global(global: ExcludeNewerValue) -> Self { Self { global: Some(global), package: ExcludeNewerPackage::default(), @@ -182,13 +560,13 @@ impl ExcludeNewer { } /// Create a new exclude newer configuration. - pub fn new(global: Option, package: ExcludeNewerPackage) -> Self { + pub fn new(global: Option, package: ExcludeNewerPackage) -> Self { Self { global, package } } /// Create from CLI arguments. pub fn from_args( - global: Option, + global: Option, package: Vec, ) -> Self { let package: ExcludeNewerPackage = package.into_iter().collect(); @@ -197,22 +575,40 @@ impl ExcludeNewer { } /// Returns the timestamp for a specific package, falling back to the global timestamp if set. - pub fn exclude_newer_package( - &self, - package_name: &PackageName, - ) -> Option { - self.package.get(package_name).copied().or(self.global) + pub fn exclude_newer_package(&self, package_name: &PackageName) -> Option { + self.package + .get(package_name) + .cloned() + .or(self.global.clone()) } /// Returns true if this has any configuration (global or per-package). pub fn is_empty(&self) -> bool { self.global.is_none() && self.package.is_empty() } + + pub fn compare(&self, other: &Self) -> Option { + match (&self.global, &other.global) { + (Some(self_global), Some(other_global)) => { + if let Some(change) = self_global.compare(other_global) { + return Some(ExcludeNewerChange::GlobalChanged(change)); + } + } + (None, Some(global)) => { + return Some(ExcludeNewerChange::GlobalAdded(global.clone())); + } + (Some(_), None) => return Some(ExcludeNewerChange::GlobalRemoved), + (None, None) => (), + } + self.package + .compare(&other.package) + .map(ExcludeNewerChange::Package) + } } impl std::fmt::Display for ExcludeNewer { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - if let Some(global) = self.global { + if let Some(global) = &self.global { write!(f, "global: {global}")?; if !self.package.is_empty() { write!(f, ", ")?; @@ -231,7 +627,7 @@ impl std::fmt::Display for ExcludeNewer { } #[cfg(feature = "schemars")] -impl schemars::JsonSchema for ExcludeNewerTimestamp { +impl schemars::JsonSchema for ExcludeNewerValue { fn schema_name() -> Cow<'static, str> { Cow::Borrowed("ExcludeNewerTimestamp") } @@ -240,7 +636,65 @@ impl schemars::JsonSchema for ExcludeNewerTimestamp { schemars::json_schema!({ "type": "string", "pattern": r"^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}(Z|[+-]\d{2}:\d{2}))?$", - "description": "Exclude distributions uploaded after the given timestamp.\n\nAccepts both RFC 3339 timestamps (e.g., `2006-12-02T02:07:43Z`) and local dates in the same format (e.g., `2006-12-02`).", + "description": "Exclude distributions uploaded after the given timestamp.\n\nAccepts both RFC 3339 timestamps (e.g., `2006-12-02T02:07:43Z`) and local dates in the same format (e.g., `2006-12-02`), as well as relative durations (e.g., `1 week`, `30 days`, `6 months`). Relative durations are resolved to a timestamp at lock time.", }) } } + +#[cfg(test)] +mod tests { + use super::*; + use std::str::FromStr; + + #[test] + fn test_exclude_newer_timestamp_absolute() { + // Test RFC 3339 timestamp + let timestamp = ExcludeNewerValue::from_str("2023-01-01T00:00:00Z").unwrap(); + assert!(timestamp.to_string().contains("2023-01-01")); + + // Test local date + let timestamp = ExcludeNewerValue::from_str("2023-06-15").unwrap(); + assert!(timestamp.to_string().contains("2023-06-16")); // Should be next day + } + + #[test] + fn test_exclude_newer_timestamp_relative() { + // Test "1 hour" - simpler test case + let timestamp = ExcludeNewerValue::from_str("1 hour").unwrap(); + let now = jiff::Timestamp::now(); + let diff = now.as_second() - timestamp.timestamp.as_second(); + // Should be approximately 1 hour (3600 seconds) ago + assert!( + (3550..=3650).contains(&diff), + "Expected ~3600 seconds, got {diff}" + ); + + // Test that we get a timestamp in the past + assert!(timestamp.timestamp < now, "Timestamp should be in the past"); + + // Test parsing succeeds for various formats + assert!(ExcludeNewerValue::from_str("2 days").is_ok()); + assert!(ExcludeNewerValue::from_str("1 week").is_ok()); + assert!(ExcludeNewerValue::from_str("30 days").is_ok()); + } + + #[test] + fn test_exclude_newer_timestamp_invalid() { + // Test invalid formats + assert!(ExcludeNewerValue::from_str("invalid").is_err()); + assert!(ExcludeNewerValue::from_str("not a date").is_err()); + assert!(ExcludeNewerValue::from_str("").is_err()); + } + + #[test] + fn test_exclude_newer_package_entry() { + let entry = ExcludeNewerPackageEntry::from_str("numpy=2023-01-01T00:00:00Z").unwrap(); + assert_eq!(entry.package.as_ref(), "numpy"); + assert!(entry.timestamp.to_string().contains("2023-01-01")); + + // Test with relative timestamp + let entry = ExcludeNewerPackageEntry::from_str("requests=7 days").unwrap(); + assert_eq!(entry.package.as_ref(), "requests"); + // Just verify it parsed without error - the timestamp will be relative to now + } +} diff --git a/crates/uv-resolver/src/lib.rs b/crates/uv-resolver/src/lib.rs index 901f8366a..d57f8f097 100644 --- a/crates/uv-resolver/src/lib.rs +++ b/crates/uv-resolver/src/lib.rs @@ -1,7 +1,8 @@ pub use dependency_mode::DependencyMode; pub use error::{ErrorTree, NoSolutionError, NoSolutionHeader, ResolveError, SentinelRange}; pub use exclude_newer::{ - ExcludeNewer, ExcludeNewerPackage, ExcludeNewerPackageEntry, ExcludeNewerTimestamp, + ExcludeNewer, ExcludeNewerChange, ExcludeNewerPackage, ExcludeNewerPackageChange, + ExcludeNewerPackageEntry, ExcludeNewerValue, ExcludeNewerValueChange, }; pub use exclusions::Exclusions; pub use flat_index::{FlatDistributions, FlatIndex}; diff --git a/crates/uv-resolver/src/lock/mod.rs b/crates/uv-resolver/src/lock/mod.rs index b86cd7f3e..3ef583155 100644 --- a/crates/uv-resolver/src/lock/mod.rs +++ b/crates/uv-resolver/src/lock/mod.rs @@ -49,6 +49,7 @@ use uv_small_str::SmallString; use uv_types::{BuildContext, HashStrategy}; use uv_workspace::{Editability, WorkspaceMember}; +use crate::exclude_newer::ExcludeNewerSpan; use crate::fork_strategy::ForkStrategy; pub(crate) use crate::lock::export::PylockTomlPackage; pub use crate::lock::export::RequirementsTxtExport; @@ -59,7 +60,7 @@ pub use crate::lock::tree::TreeDisplay; use crate::resolution::{AnnotatedDist, ResolutionGraphNode}; use crate::universal_marker::{ConflictMarker, UniversalMarker}; use crate::{ - ExcludeNewer, ExcludeNewerPackage, ExcludeNewerTimestamp, InMemoryIndex, MetadataResponse, + ExcludeNewer, ExcludeNewerPackage, ExcludeNewerValue, InMemoryIndex, MetadataResponse, PrereleaseMode, ResolutionMode, ResolverOutput, }; @@ -1059,15 +1060,32 @@ impl Lock { let exclude_newer = ExcludeNewer::from(self.options.exclude_newer.clone()); if !exclude_newer.is_empty() { // Always serialize global exclude-newer as a string - if let Some(global) = exclude_newer.global { + if let Some(global) = &exclude_newer.global { options_table.insert("exclude-newer", value(global.to_string())); + // Serialize the original span if present + if let Some(span) = global.span() { + options_table.insert("exclude-newer-span", value(span.to_string())); + } } // Serialize package-specific exclusions as a separate field if !exclude_newer.package.is_empty() { let mut package_table = toml_edit::Table::new(); - for (name, timestamp) in &exclude_newer.package { - package_table.insert(name.as_ref(), value(timestamp.to_string())); + for (name, exclude_newer_value) in &exclude_newer.package { + if let Some(span) = exclude_newer_value.span() { + // Serialize as inline table with timestamp and span + let mut inline = toml_edit::InlineTable::new(); + inline.insert( + "timestamp", + exclude_newer_value.timestamp().to_string().into(), + ); + inline.insert("span", span.to_string().into()); + package_table.insert(name.as_ref(), Item::Value(inline.into())); + } else { + // Serialize as simple string + package_table + .insert(name.as_ref(), value(exclude_newer_value.to_string())); + } } options_table.insert("exclude-newer-package", Item::Table(package_table)); } @@ -2132,10 +2150,12 @@ struct ResolverOptions { exclude_newer: ExcludeNewerWire, } +#[allow(clippy::struct_field_names)] #[derive(Clone, Debug, Default, serde::Deserialize, PartialEq, Eq)] #[serde(rename_all = "kebab-case")] struct ExcludeNewerWire { - exclude_newer: Option, + exclude_newer: Option, + exclude_newer_span: Option, #[serde(default, skip_serializing_if = "ExcludeNewerPackage::is_empty")] exclude_newer_package: ExcludeNewerPackage, } @@ -2143,7 +2163,9 @@ struct ExcludeNewerWire { impl From for ExcludeNewer { fn from(wire: ExcludeNewerWire) -> Self { Self { - global: wire.exclude_newer, + global: wire + .exclude_newer + .map(|timestamp| ExcludeNewerValue::new(timestamp, wire.exclude_newer_span)), package: wire.exclude_newer_package, } } @@ -2151,8 +2173,13 @@ impl From for ExcludeNewer { impl From for ExcludeNewerWire { fn from(exclude_newer: ExcludeNewer) -> Self { + let (timestamp, span) = exclude_newer + .global + .map(ExcludeNewerValue::into_parts) + .map_or((None, None), |(t, s)| (Some(t), s)); Self { - exclude_newer: exclude_newer.global, + exclude_newer: timestamp, + exclude_newer_span: span, exclude_newer_package: exclude_newer.package, } } diff --git a/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__hash_optional_missing.snap b/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__hash_optional_missing.snap index 46298339f..c19f3fb73 100644 --- a/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__hash_optional_missing.snap +++ b/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__hash_optional_missing.snap @@ -38,6 +38,7 @@ Ok( fork_strategy: RequiresPython, exclude_newer: ExcludeNewerWire { exclude_newer: None, + exclude_newer_span: None, exclude_newer_package: ExcludeNewerPackage( {}, ), diff --git a/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__hash_optional_present.snap b/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__hash_optional_present.snap index c5ef76e9a..f1a117d11 100644 --- a/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__hash_optional_present.snap +++ b/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__hash_optional_present.snap @@ -38,6 +38,7 @@ Ok( fork_strategy: RequiresPython, exclude_newer: ExcludeNewerWire { exclude_newer: None, + exclude_newer_span: None, exclude_newer_package: ExcludeNewerPackage( {}, ), diff --git a/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__hash_required_present.snap b/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__hash_required_present.snap index eb24b2b94..c76ca8d03 100644 --- a/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__hash_required_present.snap +++ b/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__hash_required_present.snap @@ -38,6 +38,7 @@ Ok( fork_strategy: RequiresPython, exclude_newer: ExcludeNewerWire { exclude_newer: None, + exclude_newer_span: None, exclude_newer_package: ExcludeNewerPackage( {}, ), diff --git a/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__missing_dependency_source_unambiguous.snap b/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__missing_dependency_source_unambiguous.snap index 4414974a0..0345c2f48 100644 --- a/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__missing_dependency_source_unambiguous.snap +++ b/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__missing_dependency_source_unambiguous.snap @@ -38,6 +38,7 @@ Ok( fork_strategy: RequiresPython, exclude_newer: ExcludeNewerWire { exclude_newer: None, + exclude_newer_span: None, exclude_newer_package: ExcludeNewerPackage( {}, ), diff --git a/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__missing_dependency_source_version_unambiguous.snap b/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__missing_dependency_source_version_unambiguous.snap index 4414974a0..0345c2f48 100644 --- a/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__missing_dependency_source_version_unambiguous.snap +++ b/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__missing_dependency_source_version_unambiguous.snap @@ -38,6 +38,7 @@ Ok( fork_strategy: RequiresPython, exclude_newer: ExcludeNewerWire { exclude_newer: None, + exclude_newer_span: None, exclude_newer_package: ExcludeNewerPackage( {}, ), diff --git a/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__missing_dependency_version_dynamic.snap b/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__missing_dependency_version_dynamic.snap index 50a5965e3..108c19b4c 100644 --- a/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__missing_dependency_version_dynamic.snap +++ b/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__missing_dependency_version_dynamic.snap @@ -38,6 +38,7 @@ Ok( fork_strategy: RequiresPython, exclude_newer: ExcludeNewerWire { exclude_newer: None, + exclude_newer_span: None, exclude_newer_package: ExcludeNewerPackage( {}, ), diff --git a/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__missing_dependency_version_unambiguous.snap b/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__missing_dependency_version_unambiguous.snap index 4414974a0..0345c2f48 100644 --- a/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__missing_dependency_version_unambiguous.snap +++ b/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__missing_dependency_version_unambiguous.snap @@ -38,6 +38,7 @@ Ok( fork_strategy: RequiresPython, exclude_newer: ExcludeNewerWire { exclude_newer: None, + exclude_newer_span: None, exclude_newer_package: ExcludeNewerPackage( {}, ), diff --git a/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__source_direct_has_subdir.snap b/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__source_direct_has_subdir.snap index 454db2287..d2f27b7a0 100644 --- a/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__source_direct_has_subdir.snap +++ b/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__source_direct_has_subdir.snap @@ -38,6 +38,7 @@ Ok( fork_strategy: RequiresPython, exclude_newer: ExcludeNewerWire { exclude_newer: None, + exclude_newer_span: None, exclude_newer_package: ExcludeNewerPackage( {}, ), diff --git a/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__source_direct_no_subdir.snap b/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__source_direct_no_subdir.snap index 70d7b0e5c..8599d68ea 100644 --- a/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__source_direct_no_subdir.snap +++ b/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__source_direct_no_subdir.snap @@ -38,6 +38,7 @@ Ok( fork_strategy: RequiresPython, exclude_newer: ExcludeNewerWire { exclude_newer: None, + exclude_newer_span: None, exclude_newer_package: ExcludeNewerPackage( {}, ), diff --git a/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__source_directory.snap b/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__source_directory.snap index 614e25dab..0cb69e5d8 100644 --- a/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__source_directory.snap +++ b/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__source_directory.snap @@ -38,6 +38,7 @@ Ok( fork_strategy: RequiresPython, exclude_newer: ExcludeNewerWire { exclude_newer: None, + exclude_newer_span: None, exclude_newer_package: ExcludeNewerPackage( {}, ), diff --git a/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__source_editable.snap b/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__source_editable.snap index a54bf70f8..4d0505f98 100644 --- a/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__source_editable.snap +++ b/crates/uv-resolver/src/lock/snapshots/uv_resolver__lock__tests__source_editable.snap @@ -38,6 +38,7 @@ Ok( fork_strategy: RequiresPython, exclude_newer: ExcludeNewerWire { exclude_newer: None, + exclude_newer_span: None, exclude_newer_package: ExcludeNewerPackage( {}, ), diff --git a/crates/uv-resolver/src/version_map.rs b/crates/uv-resolver/src/version_map.rs index 77415022d..a35e47220 100644 --- a/crates/uv-resolver/src/version_map.rs +++ b/crates/uv-resolver/src/version_map.rs @@ -23,7 +23,7 @@ use uv_types::HashStrategy; use uv_warnings::warn_user_once; use crate::flat_index::FlatDistributions; -use crate::{ExcludeNewer, ExcludeNewerTimestamp, yanks::AllowedYanks}; +use crate::{ExcludeNewer, ExcludeNewerValue, yanks::AllowedYanks}; /// A map from versions to distributions. #[derive(Debug)] @@ -390,7 +390,7 @@ struct VersionMapLazy { /// in the current environment. tags: Option, /// Whether files newer than this timestamp should be excluded or not. - exclude_newer: Option, + exclude_newer: Option, /// Which yanked versions are allowed allowed_yanks: AllowedYanks, /// The hashes of allowed distributions. diff --git a/crates/uv-settings/src/combine.rs b/crates/uv-settings/src/combine.rs index 00c597655..15a63e1dc 100644 --- a/crates/uv-settings/src/combine.rs +++ b/crates/uv-settings/src/combine.rs @@ -16,7 +16,7 @@ use uv_pypi_types::{SchemaConflicts, SupportedEnvironments}; use uv_python::{PythonDownloads, PythonPreference, PythonVersion}; use uv_redacted::DisplaySafeUrl; use uv_resolver::{ - AnnotationStyle, ExcludeNewer, ExcludeNewerPackage, ExcludeNewerTimestamp, ForkStrategy, + AnnotationStyle, ExcludeNewer, ExcludeNewerPackage, ExcludeNewerValue, ForkStrategy, PrereleaseMode, ResolutionMode, }; use uv_torch::TorchMode; @@ -85,7 +85,7 @@ macro_rules! impl_combine_or { impl_combine_or!(AddBoundsKind); impl_combine_or!(AnnotationStyle); impl_combine_or!(ExcludeNewer); -impl_combine_or!(ExcludeNewerTimestamp); +impl_combine_or!(ExcludeNewerValue); impl_combine_or!(ExportFormat); impl_combine_or!(ForkStrategy); impl_combine_or!(Index); @@ -230,7 +230,7 @@ impl Combine for ExcludeNewer { } else { // Merge package-specific timestamps, with self taking precedence for (pkg, timestamp) in &other.package { - self.package.entry(pkg.clone()).or_insert(*timestamp); + self.package.entry(pkg.clone()).or_insert(timestamp.clone()); } } } diff --git a/crates/uv-settings/src/settings.rs b/crates/uv-settings/src/settings.rs index c25d5f0ed..7169f32d2 100644 --- a/crates/uv-settings/src/settings.rs +++ b/crates/uv-settings/src/settings.rs @@ -19,7 +19,7 @@ use uv_pypi_types::{SupportedEnvironments, VerbatimParsedUrl}; use uv_python::{PythonDownloads, PythonPreference, PythonVersion}; use uv_redacted::DisplaySafeUrl; use uv_resolver::{ - AnnotationStyle, ExcludeNewer, ExcludeNewerPackage, ExcludeNewerTimestamp, ForkStrategy, + AnnotationStyle, ExcludeNewer, ExcludeNewerPackage, ExcludeNewerValue, ForkStrategy, PrereleaseMode, ResolutionMode, }; use uv_torch::TorchMode; @@ -340,7 +340,7 @@ pub struct InstallerOptions { pub index_strategy: Option, pub keyring_provider: Option, pub config_settings: Option, - pub exclude_newer: Option, + pub exclude_newer: Option, pub link_mode: Option, pub compile_bytecode: Option, pub reinstall: Option, @@ -401,7 +401,7 @@ pub struct ResolverInstallerOptions { pub build_isolation: Option, pub extra_build_dependencies: Option, pub extra_build_variables: Option, - pub exclude_newer: Option, + pub exclude_newer: Option, pub exclude_newer_package: Option, pub link_mode: Option, pub compile_bytecode: Option, @@ -815,7 +815,7 @@ pub struct ResolverInstallerSchema { exclude-newer = "2006-12-02T02:07:43Z" "# )] - pub exclude_newer: Option, + pub exclude_newer: Option, /// Limit candidate packages for specific packages to those that were uploaded prior to the given date. /// /// Accepts package-date pairs in a dictionary format. @@ -1576,7 +1576,7 @@ pub struct PipOptions { exclude-newer = "2006-12-02T02:07:43Z" "# )] - pub exclude_newer: Option, + pub exclude_newer: Option, /// Limit candidate packages for specific packages to those that were uploaded prior to the given date. /// /// Accepts package-date pairs in a dictionary format. @@ -1958,7 +1958,7 @@ pub struct ToolOptions { pub build_isolation: Option, pub extra_build_dependencies: Option, pub extra_build_variables: Option, - pub exclude_newer: Option, + pub exclude_newer: Option, pub exclude_newer_package: Option, pub link_mode: Option, pub compile_bytecode: Option, @@ -2074,7 +2074,7 @@ pub struct OptionsWire { no_build_isolation_package: Option>, extra_build_dependencies: Option, extra_build_variables: Option, - exclude_newer: Option, + exclude_newer: Option, exclude_newer_package: Option, link_mode: Option, compile_bytecode: Option, diff --git a/crates/uv-static/src/env_vars.rs b/crates/uv-static/src/env_vars.rs index 128e2c655..69451f386 100644 --- a/crates/uv-static/src/env_vars.rs +++ b/crates/uv-static/src/env_vars.rs @@ -1085,6 +1085,12 @@ impl EnvVars { #[attr_added_in("0.5.29")] pub const UV_TEST_NO_CLI_PROGRESS: &'static str = "UV_TEST_NO_CLI_PROGRESS"; + /// Used to mock the current timestamp for relative `--exclude-newer` times in tests. + /// Should be set to an RFC 3339 timestamp (e.g., `2025-11-21T12:00:00Z`). + #[attr_hidden] + #[attr_added_in("0.9.8")] + pub const UV_TEST_CURRENT_TIMESTAMP: &'static str = "UV_TEST_CURRENT_TIMESTAMP"; + /// `.env` files from which to load environment variables when executing `uv run` commands. #[attr_added_in("0.4.30")] pub const UV_ENV_FILE: &'static str = "UV_ENV_FILE"; diff --git a/crates/uv/src/commands/project/lock.rs b/crates/uv/src/commands/project/lock.rs index 12843bb34..ef9537efa 100644 --- a/crates/uv/src/commands/project/lock.rs +++ b/crates/uv/src/commands/project/lock.rs @@ -1054,37 +1054,13 @@ impl ValidatedLock { ); return Ok(Self::Unusable(lock)); } - let lock_exclude_newer = lock.exclude_newer(); - let options_exclude_newer = &options.exclude_newer; - - match ( - lock_exclude_newer.is_empty(), - options_exclude_newer.is_empty(), - ) { - (true, true) => (), - (false, false) if lock_exclude_newer == *options_exclude_newer => (), - (false, false) => { + if let Some(change) = lock.exclude_newer().compare(&options.exclude_newer) { + // If a relative value is used, we won't invalidate on every tick of the clock unless + // the span duration changed or some other operation causes a new resolution + if !change.is_relative_timestamp_change() { let _ = writeln!( printer.stderr(), - "Ignoring existing lockfile due to change in timestamp cutoff: `{}` vs. `{}`", - lock_exclude_newer.cyan(), - options_exclude_newer.cyan() - ); - return Ok(Self::Unusable(lock)); - } - (false, true) => { - let _ = writeln!( - printer.stderr(), - "Ignoring existing lockfile due to removal of timestamp cutoff: `{}`", - lock_exclude_newer.cyan(), - ); - return Ok(Self::Unusable(lock)); - } - (true, false) => { - let _ = writeln!( - printer.stderr(), - "Ignoring existing lockfile due to addition of timestamp cutoff: `{}`", - options_exclude_newer.cyan() + "Ignoring existing lockfile due to {change}", ); return Ok(Self::Unusable(lock)); } diff --git a/crates/uv/src/commands/tool/upgrade.rs b/crates/uv/src/commands/tool/upgrade.rs index 330673c24..11cec23f3 100644 --- a/crates/uv/src/commands/tool/upgrade.rs +++ b/crates/uv/src/commands/tool/upgrade.rs @@ -121,7 +121,7 @@ pub(crate) async fn upgrade( let mut errors = Vec::new(); for (name, constraints) in &names { debug!("Upgrading tool: `{name}`"); - let result = upgrade_tool( + let result = Box::pin(upgrade_tool( name, constraints, interpreter.as_ref(), @@ -135,7 +135,7 @@ pub(crate) async fn upgrade( installer_metadata, concurrency, preview, - ) + )) .await; match result { diff --git a/crates/uv/tests/it/common/mod.rs b/crates/uv/tests/it/common/mod.rs index 0b7667fd8..46ea71091 100644 --- a/crates/uv/tests/it/common/mod.rs +++ b/crates/uv/tests/it/common/mod.rs @@ -987,7 +987,9 @@ impl TestContext { // Installations are not allowed by default; see `Self::with_managed_python_dirs` .env(EnvVars::UV_PYTHON_DOWNLOADS, "never") .env(EnvVars::UV_TEST_PYTHON_PATH, self.python_path()) + // Lock to a point in time view of the world .env(EnvVars::UV_EXCLUDE_NEWER, EXCLUDE_NEWER) + .env(EnvVars::UV_TEST_CURRENT_TIMESTAMP, EXCLUDE_NEWER) // When installations are allowed, we don't want to write to global state, like the // Windows registry .env(EnvVars::UV_PYTHON_INSTALL_REGISTRY, "0") diff --git a/crates/uv/tests/it/lock_exclude_newer_relative.rs b/crates/uv/tests/it/lock_exclude_newer_relative.rs new file mode 100644 index 000000000..648cc6ff1 --- /dev/null +++ b/crates/uv/tests/it/lock_exclude_newer_relative.rs @@ -0,0 +1,1141 @@ +use anyhow::Result; +use assert_fs::fixture::{FileWriteStr, PathChild}; +use insta::assert_snapshot; +use uv_static::EnvVars; + +use crate::common::{TestContext, uv_snapshot}; + +/// Lock with a relative exclude-newer value. +#[test] +fn lock_exclude_newer_relative() -> Result<()> { + let context = TestContext::new("3.12"); + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["iniconfig"] + "#, + )?; + + uv_snapshot!(context.filters(), context + .lock() + .env_remove(EnvVars::UV_EXCLUDE_NEWER) + .arg("--exclude-newer") + .arg("2 weeks"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 2 packages in [TIME] + "###); + + let lock = context.read("uv.lock"); + assert_snapshot!(lock, @r#" + version = 1 + revision = 3 + requires-python = ">=3.12" + + [options] + exclude-newer = "2024-03-11T00:00:00Z" + exclude-newer-span = "P2W" + + [[package]] + name = "iniconfig" + version = "2.0.0" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646, upload-time = "2023-01-07T11:08:11.254Z" } + wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892, upload-time = "2023-01-07T11:08:09.864Z" }, + ] + + [[package]] + name = "project" + version = "0.1.0" + source = { virtual = "." } + dependencies = [ + { name = "iniconfig" }, + ] + + [package.metadata] + requires-dist = [{ name = "iniconfig" }] + "#); + + // Changing the current time should not result in a new lockfile + let current_timestamp = "2024-04-01T00:00:00Z"; + uv_snapshot!(context.filters(), context + .lock() + .env_remove(EnvVars::UV_EXCLUDE_NEWER) + .env(EnvVars::UV_TEST_CURRENT_TIMESTAMP, current_timestamp) + .arg("--exclude-newer") + .arg("2 weeks") + .arg("--locked"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 2 packages in [TIME] + "); + + // Changing the span, however, should cause a new resolution + uv_snapshot!(context.filters(), context + .lock() + .env_remove(EnvVars::UV_EXCLUDE_NEWER) + .env(EnvVars::UV_TEST_CURRENT_TIMESTAMP, current_timestamp) + .arg("--exclude-newer") + .arg("1 week"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Ignoring existing lockfile due to change of exclude newer span from `P2W` to `P1W` + Resolved 2 packages in [TIME] + "); + + // Both `exclude-newer` values in the lockfile should be changed + let lock = context.read("uv.lock"); + assert_snapshot!(lock, @r#" + version = 1 + revision = 3 + requires-python = ">=3.12" + + [options] + exclude-newer = "2024-03-25T00:00:00Z" + exclude-newer-span = "P1W" + + [[package]] + name = "iniconfig" + version = "2.0.0" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646, upload-time = "2023-01-07T11:08:11.254Z" } + wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892, upload-time = "2023-01-07T11:08:09.864Z" }, + ] + + [[package]] + name = "project" + version = "0.1.0" + source = { virtual = "." } + dependencies = [ + { name = "iniconfig" }, + ] + + [package.metadata] + requires-dist = [{ name = "iniconfig" }] + "#); + + // Similarly, using something like `--upgrade` should cause a new resolution + let current_timestamp = "2024-05-01T00:00:00Z"; + uv_snapshot!(context.filters(), context + .lock() + .env_remove(EnvVars::UV_EXCLUDE_NEWER) + .env(EnvVars::UV_TEST_CURRENT_TIMESTAMP, current_timestamp) + .arg("--exclude-newer") + .arg("1 week") + .arg("--upgrade"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 2 packages in [TIME] + "); + + // And the `exclude-newer` timestamp value in the lockfile should be changed + let lock = context.read("uv.lock"); + assert_snapshot!(lock, @r#" + version = 1 + revision = 3 + requires-python = ">=3.12" + + [options] + exclude-newer = "2024-04-24T00:00:00Z" + exclude-newer-span = "P1W" + + [[package]] + name = "iniconfig" + version = "2.0.0" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646, upload-time = "2023-01-07T11:08:11.254Z" } + wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892, upload-time = "2023-01-07T11:08:09.864Z" }, + ] + + [[package]] + name = "project" + version = "0.1.0" + source = { virtual = "." } + dependencies = [ + { name = "iniconfig" }, + ] + + [package.metadata] + requires-dist = [{ name = "iniconfig" }] + "#); + + // Similarly, using something like `--refresh` should cause a new resolution + let current_timestamp = "2024-06-01T00:00:00Z"; + uv_snapshot!(context.filters(), context + .lock() + .env_remove(EnvVars::UV_EXCLUDE_NEWER) + .env(EnvVars::UV_TEST_CURRENT_TIMESTAMP, current_timestamp) + .arg("--exclude-newer") + .arg("1 week") + .arg("--refresh"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 2 packages in [TIME] + "); + + Ok(()) +} + +/// Lock with a relative exclude-newer-package value. +#[test] +fn lock_exclude_newer_package_relative() -> Result<()> { + let context = TestContext::new("3.12"); + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["iniconfig"] + "#, + )?; + + uv_snapshot!(context.filters(), context + .lock() + .env_remove(EnvVars::UV_EXCLUDE_NEWER) + .arg("--exclude-newer-package") + .arg("iniconfig=2 weeks"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 2 packages in [TIME] + "###); + + let lock = context.read("uv.lock"); + assert_snapshot!(lock, @r#" + version = 1 + revision = 3 + requires-python = ">=3.12" + + [options] + + [options.exclude-newer-package] + iniconfig = { timestamp = "2024-03-11T00:00:00Z", span = "P2W" } + + [[package]] + name = "iniconfig" + version = "2.0.0" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646, upload-time = "2023-01-07T11:08:11.254Z" } + wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892, upload-time = "2023-01-07T11:08:09.864Z" }, + ] + + [[package]] + name = "project" + version = "0.1.0" + source = { virtual = "." } + dependencies = [ + { name = "iniconfig" }, + ] + + [package.metadata] + requires-dist = [{ name = "iniconfig" }] + "#); + + // Changing the current time should not result in a new lockfile + let current_timestamp = "2024-04-01T00:00:00Z"; + uv_snapshot!(context.filters(), context + .lock() + .env_remove(EnvVars::UV_EXCLUDE_NEWER) + .env(EnvVars::UV_TEST_CURRENT_TIMESTAMP, current_timestamp) + .arg("--exclude-newer-package") + .arg("iniconfig=2 weeks") + .arg("--locked"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 2 packages in [TIME] + "); + + // Changing the span, however, should cause a new resolution + uv_snapshot!(context.filters(), context + .lock() + .env_remove(EnvVars::UV_EXCLUDE_NEWER) + .env(EnvVars::UV_TEST_CURRENT_TIMESTAMP, current_timestamp) + .arg("--exclude-newer-package") + .arg("iniconfig=1 week"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Ignoring existing lockfile due to change of exclude newer span from `P2W` to `P1W` for package `iniconfig` + Resolved 2 packages in [TIME] + "); + + // Both `exclude-newer-package` values in the lockfile should be changed + let lock = context.read("uv.lock"); + assert_snapshot!(lock, @r#" + version = 1 + revision = 3 + requires-python = ">=3.12" + + [options] + + [options.exclude-newer-package] + iniconfig = { timestamp = "2024-03-25T00:00:00Z", span = "P1W" } + + [[package]] + name = "iniconfig" + version = "2.0.0" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646, upload-time = "2023-01-07T11:08:11.254Z" } + wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892, upload-time = "2023-01-07T11:08:09.864Z" }, + ] + + [[package]] + name = "project" + version = "0.1.0" + source = { virtual = "." } + dependencies = [ + { name = "iniconfig" }, + ] + + [package.metadata] + requires-dist = [{ name = "iniconfig" }] + "#); + + // Similarly, using something like `--upgrade` should cause a new resolution + let current_timestamp = "2024-05-01T00:00:00Z"; + uv_snapshot!(context.filters(), context + .lock() + .env_remove(EnvVars::UV_EXCLUDE_NEWER) + .env(EnvVars::UV_TEST_CURRENT_TIMESTAMP, current_timestamp) + .arg("--exclude-newer-package") + .arg("iniconfig=1 week") + .arg("--upgrade"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 2 packages in [TIME] + "); + + // And the `exclude-newer-package` timestamp value in the lockfile should be changed + let lock = context.read("uv.lock"); + assert_snapshot!(lock, @r#" + version = 1 + revision = 3 + requires-python = ">=3.12" + + [options] + + [options.exclude-newer-package] + iniconfig = { timestamp = "2024-04-24T00:00:00Z", span = "P1W" } + + [[package]] + name = "iniconfig" + version = "2.0.0" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646, upload-time = "2023-01-07T11:08:11.254Z" } + wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892, upload-time = "2023-01-07T11:08:09.864Z" }, + ] + + [[package]] + name = "project" + version = "0.1.0" + source = { virtual = "." } + dependencies = [ + { name = "iniconfig" }, + ] + + [package.metadata] + requires-dist = [{ name = "iniconfig" }] + "#); + + Ok(()) +} + +/// Lock with a relative exclude-newer value from the `pyproject.toml`. +#[test] +fn lock_exclude_newer_relative_pyproject() -> Result<()> { + let context = TestContext::new("3.12"); + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["iniconfig"] + + [tool.uv] + exclude-newer = "2 weeks" + "#, + )?; + + uv_snapshot!(context.filters(), context + .lock() + .env_remove(EnvVars::UV_EXCLUDE_NEWER), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 2 packages in [TIME] + "###); + + let lock = context.read("uv.lock"); + assert_snapshot!(lock, @r#" + version = 1 + revision = 3 + requires-python = ">=3.12" + + [options] + exclude-newer = "2024-03-11T00:00:00Z" + exclude-newer-span = "P2W" + + [[package]] + name = "iniconfig" + version = "2.0.0" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646, upload-time = "2023-01-07T11:08:11.254Z" } + wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892, upload-time = "2023-01-07T11:08:09.864Z" }, + ] + + [[package]] + name = "project" + version = "0.1.0" + source = { virtual = "." } + dependencies = [ + { name = "iniconfig" }, + ] + + [package.metadata] + requires-dist = [{ name = "iniconfig" }] + "#); + + Ok(()) +} + +/// Lock with a relative exclude-newer-package value from the `pyproject.toml`. +#[test] +fn lock_exclude_newer_package_relative_pyproject() -> Result<()> { + let context = TestContext::new("3.12"); + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["iniconfig"] + + [tool.uv] + exclude-newer-package = { iniconfig = "2 weeks" } + "#, + )?; + + uv_snapshot!(context.filters(), context + .lock() + .env_remove(EnvVars::UV_EXCLUDE_NEWER), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 2 packages in [TIME] + "###); + + let lock = context.read("uv.lock"); + assert_snapshot!(lock, @r#" + version = 1 + revision = 3 + requires-python = ">=3.12" + + [options] + + [options.exclude-newer-package] + iniconfig = { timestamp = "2024-03-11T00:00:00Z", span = "P2W" } + + [[package]] + name = "iniconfig" + version = "2.0.0" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646, upload-time = "2023-01-07T11:08:11.254Z" } + wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892, upload-time = "2023-01-07T11:08:09.864Z" }, + ] + + [[package]] + name = "project" + version = "0.1.0" + source = { virtual = "." } + dependencies = [ + { name = "iniconfig" }, + ] + + [package.metadata] + requires-dist = [{ name = "iniconfig" }] + "#); + + Ok(()) +} + +/// Lock with both global and per-package relative exclude-newer values. +#[test] +fn lock_exclude_newer_relative_global_and_package() -> Result<()> { + let context = TestContext::new("3.12"); + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["iniconfig", "typing-extensions"] + "#, + )?; + + // Lock with both global exclude-newer and package-specific override using relative durations + uv_snapshot!(context.filters(), context + .lock() + .env_remove(EnvVars::UV_EXCLUDE_NEWER) + .arg("--exclude-newer") + .arg("2 weeks") + .arg("--exclude-newer-package") + .arg("typing-extensions=1 week"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 3 packages in [TIME] + "###); + + let lock = context.read("uv.lock"); + assert_snapshot!(lock, @r#" + version = 1 + revision = 3 + requires-python = ">=3.12" + + [options] + exclude-newer = "2024-03-11T00:00:00Z" + exclude-newer-span = "P2W" + + [options.exclude-newer-package] + typing-extensions = { timestamp = "2024-03-18T00:00:00Z", span = "P1W" } + + [[package]] + name = "iniconfig" + version = "2.0.0" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646, upload-time = "2023-01-07T11:08:11.254Z" } + wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892, upload-time = "2023-01-07T11:08:09.864Z" }, + ] + + [[package]] + name = "project" + version = "0.1.0" + source = { virtual = "." } + dependencies = [ + { name = "iniconfig" }, + { name = "typing-extensions" }, + ] + + [package.metadata] + requires-dist = [ + { name = "iniconfig" }, + { name = "typing-extensions" }, + ] + + [[package]] + name = "typing-extensions" + version = "4.10.0" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/16/3a/0d26ce356c7465a19c9ea8814b960f8a36c3b0d07c323176620b7b483e44/typing_extensions-4.10.0.tar.gz", hash = "sha256:b0abd7c89e8fb96f98db18d86106ff1d90ab692004eb746cf6eda2682f91b3cb", size = 77558, upload-time = "2024-02-25T22:12:49.693Z" } + wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/de/dc04a3ea60b22624b51c703a84bbe0184abcd1d0b9bc8074b5d6b7ab90bb/typing_extensions-4.10.0-py3-none-any.whl", hash = "sha256:69b1a937c3a517342112fb4c6df7e72fc39a38e7891a5730ed4985b5214b5475", size = 33926, upload-time = "2024-02-25T22:12:47.72Z" }, + ] + "#); + + // Changing the current time should not invalidate the lockfile + let current_timestamp = "2024-04-01T00:00:00Z"; + uv_snapshot!(context.filters(), context + .lock() + .env_remove(EnvVars::UV_EXCLUDE_NEWER) + .env(EnvVars::UV_TEST_CURRENT_TIMESTAMP, current_timestamp) + .arg("--exclude-newer") + .arg("2 weeks") + .arg("--exclude-newer-package") + .arg("typing-extensions=1 week") + .arg("--locked"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 3 packages in [TIME] + "); + + // Changing the global span should invalidate the lockfile + uv_snapshot!(context.filters(), context + .lock() + .env_remove(EnvVars::UV_EXCLUDE_NEWER) + .env(EnvVars::UV_TEST_CURRENT_TIMESTAMP, current_timestamp) + .arg("--exclude-newer") + .arg("1 week") + .arg("--exclude-newer-package") + .arg("typing-extensions=1 week"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Ignoring existing lockfile due to change of exclude newer span from `P2W` to `P1W` + Resolved 3 packages in [TIME] + "); + + // Changing the package-specific span should also invalidate the lockfile + uv_snapshot!(context.filters(), context + .lock() + .env_remove(EnvVars::UV_EXCLUDE_NEWER) + .env(EnvVars::UV_TEST_CURRENT_TIMESTAMP, current_timestamp) + .arg("--exclude-newer") + .arg("1 week") + .arg("--exclude-newer-package") + .arg("typing-extensions=3 days"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Ignoring existing lockfile due to change of exclude newer span from `P1W` to `P3D` for package `typing-extensions` + Resolved 3 packages in [TIME] + "); + + // Use an absolute global value and relative package value + uv_snapshot!(context.filters(), context + .lock() + .env_remove(EnvVars::UV_EXCLUDE_NEWER) + .arg("--exclude-newer") + .arg("2024-03-01T00:00:00Z") + .arg("--exclude-newer-package") + .arg("typing-extensions=1 week"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Ignoring existing lockfile due to removal of exclude newer span + Resolved 3 packages in [TIME] + "); + + let lock = context.read("uv.lock"); + assert_snapshot!(lock, @r#" + version = 1 + revision = 3 + requires-python = ">=3.12" + + [options] + exclude-newer = "2024-03-01T00:00:00Z" + + [options.exclude-newer-package] + typing-extensions = { timestamp = "2024-03-18T00:00:00Z", span = "P1W" } + + [[package]] + name = "iniconfig" + version = "2.0.0" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646, upload-time = "2023-01-07T11:08:11.254Z" } + wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892, upload-time = "2023-01-07T11:08:09.864Z" }, + ] + + [[package]] + name = "project" + version = "0.1.0" + source = { virtual = "." } + dependencies = [ + { name = "iniconfig" }, + { name = "typing-extensions" }, + ] + + [package.metadata] + requires-dist = [ + { name = "iniconfig" }, + { name = "typing-extensions" }, + ] + + [[package]] + name = "typing-extensions" + version = "4.10.0" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/16/3a/0d26ce356c7465a19c9ea8814b960f8a36c3b0d07c323176620b7b483e44/typing_extensions-4.10.0.tar.gz", hash = "sha256:b0abd7c89e8fb96f98db18d86106ff1d90ab692004eb746cf6eda2682f91b3cb", size = 77558, upload-time = "2024-02-25T22:12:49.693Z" } + wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/de/dc04a3ea60b22624b51c703a84bbe0184abcd1d0b9bc8074b5d6b7ab90bb/typing_extensions-4.10.0-py3-none-any.whl", hash = "sha256:69b1a937c3a517342112fb4c6df7e72fc39a38e7891a5730ed4985b5214b5475", size = 33926, upload-time = "2024-02-25T22:12:47.72Z" }, + ] + "#); + + // Use a relative global value and absolute package value + uv_snapshot!(context.filters(), context + .lock() + .env_remove(EnvVars::UV_EXCLUDE_NEWER) + .arg("--exclude-newer") + .arg("2 weeks") + .arg("--exclude-newer-package") + .arg("typing-extensions=2024-03-01T00:00:00Z"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Ignoring existing lockfile due to addition of exclude newer span `P2W` + Resolved 3 packages in [TIME] + "); + + let lock = context.read("uv.lock"); + assert_snapshot!(lock, @r#" + version = 1 + revision = 3 + requires-python = ">=3.12" + + [options] + exclude-newer = "2024-03-11T00:00:00Z" + exclude-newer-span = "P2W" + + [options.exclude-newer-package] + typing-extensions = "2024-03-01T00:00:00Z" + + [[package]] + name = "iniconfig" + version = "2.0.0" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646, upload-time = "2023-01-07T11:08:11.254Z" } + wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892, upload-time = "2023-01-07T11:08:09.864Z" }, + ] + + [[package]] + name = "project" + version = "0.1.0" + source = { virtual = "." } + dependencies = [ + { name = "iniconfig" }, + { name = "typing-extensions" }, + ] + + [package.metadata] + requires-dist = [ + { name = "iniconfig" }, + { name = "typing-extensions" }, + ] + + [[package]] + name = "typing-extensions" + version = "4.10.0" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/16/3a/0d26ce356c7465a19c9ea8814b960f8a36c3b0d07c323176620b7b483e44/typing_extensions-4.10.0.tar.gz", hash = "sha256:b0abd7c89e8fb96f98db18d86106ff1d90ab692004eb746cf6eda2682f91b3cb", size = 77558, upload-time = "2024-02-25T22:12:49.693Z" } + wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/de/dc04a3ea60b22624b51c703a84bbe0184abcd1d0b9bc8074b5d6b7ab90bb/typing_extensions-4.10.0-py3-none-any.whl", hash = "sha256:69b1a937c3a517342112fb4c6df7e72fc39a38e7891a5730ed4985b5214b5475", size = 33926, upload-time = "2024-02-25T22:12:47.72Z" }, + ] + "#); + + Ok(()) +} + +/// Lock with various relative exclude newer value formats. +#[test] +fn lock_exclude_newer_relative_values() -> Result<()> { + let context = TestContext::new("3.12"); + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["iniconfig"] + "#, + )?; + + uv_snapshot!(context.filters(), context + .lock() + .env_remove(EnvVars::UV_EXCLUDE_NEWER) + .arg("--exclude-newer") + .arg("1 day"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 2 packages in [TIME] + "###); + + uv_snapshot!(context.filters(), context + .lock() + .env_remove(EnvVars::UV_EXCLUDE_NEWER) + .arg("--exclude-newer") + .arg("30days"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Ignoring existing lockfile due to change of exclude newer span from `P1D` to `P30D` + Resolved 2 packages in [TIME] + "); + + uv_snapshot!(context.filters(), context + .lock() + .env_remove(EnvVars::UV_EXCLUDE_NEWER) + .arg("--exclude-newer") + .arg("P1D"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Ignoring existing lockfile due to change of exclude newer span from `P30D` to `P1D` + Resolved 2 packages in [TIME] + "); + + uv_snapshot!(context.filters(), context + .lock() + .env_remove(EnvVars::UV_EXCLUDE_NEWER) + .arg("--exclude-newer") + .arg("1 week"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Ignoring existing lockfile due to change of exclude newer span from `P1D` to `P1W` + Resolved 2 packages in [TIME] + "); + + uv_snapshot!(context.filters(), context + .lock() + .env_remove(EnvVars::UV_EXCLUDE_NEWER) + .arg("--exclude-newer") + .arg("1 week ago"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Ignoring existing lockfile due to change of exclude newer span from `P1W` to `-P1W` + Resolved 2 packages in [TIME] + "); + + uv_snapshot!(context.filters(), context + .lock() + .env_remove(EnvVars::UV_EXCLUDE_NEWER) + .arg("--exclude-newer") + .arg("3 months"), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: invalid value '3 months' for '--exclude-newer ': Duration `3 months` uses 'months' which is not allowed; use days instead, e.g., `90 days`. + + For more information, try '--help'. + "); + + uv_snapshot!(context.filters(), context + .lock() + .env_remove(EnvVars::UV_EXCLUDE_NEWER) + .arg("--exclude-newer") + .arg("2 months ago"), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: invalid value '2 months ago' for '--exclude-newer ': Duration `2 months ago` uses 'months' which is not allowed; use days instead, e.g., `60 days`. + + For more information, try '--help'. + "); + + uv_snapshot!(context.filters(), context + .lock() + .env_remove(EnvVars::UV_EXCLUDE_NEWER) + .arg("--exclude-newer") + .arg("1 year"), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: invalid value '1 year' for '--exclude-newer ': Duration `1 year` uses unit 'years' which is not allowed; use days instead, e.g., `365 days`. + + For more information, try '--help'. + "); + + uv_snapshot!(context.filters(), context + .lock() + .env_remove(EnvVars::UV_EXCLUDE_NEWER) + .arg("--exclude-newer") + .arg("1 year ago"), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: invalid value '1 year ago' for '--exclude-newer ': Duration `1 year ago` uses unit 'years' which is not allowed; use days instead, e.g., `365 days`. + + For more information, try '--help'. + "); + + uv_snapshot!(context.filters(), context + .lock() + .arg("--exclude-newer") + .arg("invalid span"), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: invalid value 'invalid span' for '--exclude-newer ': `invalid span` could not be parsed as a valid exclude-newer value (expected a date like `2024-01-01`, a timestamp like `2024-01-01T00:00:00Z`, or a duration like `3 days` or `P3D`) + + For more information, try '--help'. + "); + + uv_snapshot!(context.filters(), context + .lock() + .arg("--exclude-newer") + .arg("P4Z"), @r#" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: invalid value 'P4Z' for '--exclude-newer ': `P4Z` could not be parsed as an ISO 8601 duration: failed to parse "P4Z" as an ISO 8601 duration string: expected to find date unit designator suffix (Y, M, W or D), but found "Z" instead + + For more information, try '--help'. + "#); + + uv_snapshot!(context.filters(), context + .lock() + .arg("--exclude-newer") + .arg("2006-12-02T02:07:43"), @r" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + Ignoring existing lockfile due to removal of exclude newer span + × No solution found when resolving dependencies: + ╰─▶ Because there are no versions of iniconfig and your project depends on iniconfig, we can conclude that your project's requirements are unsatisfiable. + "); + + uv_snapshot!(context.filters(), context + .lock() + .arg("--exclude-newer") + .arg("12/02/2006"), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: invalid value '12/02/2006' for '--exclude-newer ': `12/02/2006` could not be parsed as a valid exclude-newer value (expected a date like `2024-01-01`, a timestamp like `2024-01-01T00:00:00Z`, or a duration like `3 days` or `P3D`) + + For more information, try '--help'. + "); + + uv_snapshot!(context.filters(), context + .lock() + .arg("--exclude-newer") + .arg("2 weak"), @r#" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: invalid value '2 weak' for '--exclude-newer ': `2 weak` could not be parsed as a duration: failed to parse "2 weak" in the "friendly" format: parsed value 'P2W', but unparsed input "eak" remains (expected no unparsed input) + + For more information, try '--help'. + "#); + + uv_snapshot!(context.filters(), context + .lock() + .arg("--exclude-newer") + .arg("30"), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: invalid value '30' for '--exclude-newer ': `30` could not be parsed as a valid exclude-newer value (expected a date like `2024-01-01`, a timestamp like `2024-01-01T00:00:00Z`, or a duration like `3 days` or `P3D`) + + For more information, try '--help'. + "); + + uv_snapshot!(context.filters(), context + .lock() + .arg("--exclude-newer") + .arg("1000000 years"), @r#" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: invalid value '1000000 years' for '--exclude-newer ': `1000000 years` could not be parsed as a duration: failed to parse "1000000 years" in the "friendly" format: failed to set value 1000000 as year unit on span: parameter 'years' with value 1000000 is not in the required range of -19998..=19998 + + For more information, try '--help'. + "#); + + Ok(()) +} + +/// Lock with various relative exclude newer value formats in a `pyproject.toml`. +#[test] +fn lock_exclude_newer_relative_values_pyproject() -> Result<()> { + let context = TestContext::new("3.12"); + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["iniconfig"] + + [tool.uv] + exclude-newer = "invalid span" + "#, + )?; + + uv_snapshot!(context.filters(), context + .lock(), @r#" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: Failed to parse `pyproject.toml` during settings discovery: + TOML parse error at line 9, column 25 + | + 9 | exclude-newer = "invalid span" + | ^^^^^^^^^^^^^^ + `invalid span` could not be parsed as a valid exclude-newer value (expected a date like `2024-01-01`, a timestamp like `2024-01-01T00:00:00Z`, or a duration like `3 days` or `P3D`) + + Resolved 2 packages in [TIME] + "#); + + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["iniconfig"] + + [tool.uv] + exclude-newer = "2 foos" + "#, + )?; + + uv_snapshot!(context.filters(), context + .lock(), @r#" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: Failed to parse `pyproject.toml` during settings discovery: + TOML parse error at line 9, column 25 + | + 9 | exclude-newer = "2 foos" + | ^^^^^^^^ + `2 foos` could not be parsed as a duration: failed to parse "2 foos" in the "friendly" format: expected to find unit designator suffix (e.g., 'years' or 'secs'), but found input beginning with "foos" instead + + Resolved 2 packages in [TIME] + "#); + + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["iniconfig"] + + [tool.uv] + exclude-newer = "P4Z" + "#, + )?; + + uv_snapshot!(context.filters(), context + .lock(), @r#" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: Failed to parse `pyproject.toml` during settings discovery: + TOML parse error at line 9, column 25 + | + 9 | exclude-newer = "P4Z" + | ^^^^^ + `P4Z` could not be parsed as an ISO 8601 duration: failed to parse "P4Z" as an ISO 8601 duration string: expected to find date unit designator suffix (Y, M, W or D), but found "Z" instead + + Resolved 2 packages in [TIME] + "#); + + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["iniconfig"] + + [tool.uv] + exclude-newer = "10" + "#, + )?; + + uv_snapshot!(context.filters(), context + .lock(), @r#" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: Failed to parse `pyproject.toml` during settings discovery: + TOML parse error at line 9, column 25 + | + 9 | exclude-newer = "10" + | ^^^^ + `10` could not be parsed as a valid exclude-newer value (expected a date like `2024-01-01`, a timestamp like `2024-01-01T00:00:00Z`, or a duration like `3 days` or `P3D`) + + Resolved 2 packages in [TIME] + "#); + + Ok(()) +} diff --git a/crates/uv/tests/it/main.rs b/crates/uv/tests/it/main.rs index 8a383bf13..b81292b49 100644 --- a/crates/uv/tests/it/main.rs +++ b/crates/uv/tests/it/main.rs @@ -45,6 +45,9 @@ mod lock; #[cfg(all(feature = "python", feature = "pypi"))] mod lock_conflict; +#[cfg(all(feature = "python", feature = "pypi"))] +mod lock_exclude_newer_relative; + mod lock_scenarios; mod network; diff --git a/crates/uv/tests/it/pip_compile.rs b/crates/uv/tests/it/pip_compile.rs index 8a199ceb0..9d01ae9b3 100644 --- a/crates/uv/tests/it/pip_compile.rs +++ b/crates/uv/tests/it/pip_compile.rs @@ -3403,7 +3403,7 @@ fn compile_exclude_newer() -> Result<()> { .env_remove(EnvVars::UV_EXCLUDE_NEWER) .arg("requirements.in") .arg("--exclude-newer") - .arg("2022-04-04+02:00"), @r###" + .arg("2022-04-04+02:00"), @r#" success: false exit_code: 2 ----- stdout ----- @@ -3412,7 +3412,7 @@ fn compile_exclude_newer() -> Result<()> { error: invalid value '2022-04-04+02:00' for '--exclude-newer ': `2022-04-04+02:00` could not be parsed as a valid date: parsed value '2022-04-04', but unparsed input "+02:00" remains (expected no unparsed input) For more information, try '--help'. - "### + "# ); // Check the error message for the case of @@ -3423,7 +3423,7 @@ fn compile_exclude_newer() -> Result<()> { .env_remove(EnvVars::UV_EXCLUDE_NEWER) .arg("requirements.in") .arg("--exclude-newer") - .arg("2022-04-04T26:00:00+00"), @r###" + .arg("2022-04-04T26:00:00+00"), @r#" success: false exit_code: 2 ----- stdout ----- @@ -3432,7 +3432,7 @@ fn compile_exclude_newer() -> Result<()> { error: invalid value '2022-04-04T26:00:00+00' for '--exclude-newer ': `2022-04-04T26:00:00+00` could not be parsed as a valid date: failed to parse hour in time "26:00:00+00": hour is not valid: parameter 'hour' with value 26 is not in the required range of 0..=23 For more information, try '--help'. - "### + "# ); Ok(()) @@ -3577,16 +3577,16 @@ fn compile_exclude_newer_package_errors() -> Result<()> { .env_remove(EnvVars::UV_EXCLUDE_NEWER) .arg("requirements.in") .arg("--exclude-newer-package") - .arg("tqdm=invalid-date"), @r#" + .arg("tqdm=invalid-date"), @r" success: false exit_code: 2 ----- stdout ----- ----- stderr ----- - error: invalid value 'tqdm=invalid-date' for '--exclude-newer-package ': Invalid `exclude-newer-package` timestamp `invalid-date`: `invalid-date` could not be parsed as a valid date: failed to parse year in date "invalid-date": failed to parse "inva" as year (a four digit integer): invalid digit, expected 0-9 but got i + error: invalid value 'tqdm=invalid-date' for '--exclude-newer-package ': Invalid `exclude-newer-package` timestamp `invalid-date`: `invalid-date` could not be parsed as a valid exclude-newer value (expected a date like `2024-01-01`, a timestamp like `2024-01-01T00:00:00Z`, or a duration like `3 days` or `P3D`) For more information, try '--help'. - "# + " ); Ok(()) diff --git a/crates/uv/tests/it/snapshots/it__ecosystem__home-assistant-core-uv-lock-output.snap b/crates/uv/tests/it/snapshots/it__ecosystem__home-assistant-core-uv-lock-output.snap index a827fc153..16a93567e 100644 --- a/crates/uv/tests/it/snapshots/it__ecosystem__home-assistant-core-uv-lock-output.snap +++ b/crates/uv/tests/it/snapshots/it__ecosystem__home-assistant-core-uv-lock-output.snap @@ -1,5 +1,5 @@ --- -source: crates/uv/tests/ecosystem.rs +source: crates/uv/tests/it/ecosystem.rs expression: snapshot --- success: true diff --git a/crates/uv/tests/it/snapshots/it__ecosystem__saleor-uv-lock-output.snap b/crates/uv/tests/it/snapshots/it__ecosystem__saleor-uv-lock-output.snap index f6ace74a1..c5aaef2cf 100644 --- a/crates/uv/tests/it/snapshots/it__ecosystem__saleor-uv-lock-output.snap +++ b/crates/uv/tests/it/snapshots/it__ecosystem__saleor-uv-lock-output.snap @@ -1,5 +1,5 @@ --- -source: crates/uv/tests/ecosystem.rs +source: crates/uv/tests/it/ecosystem.rs expression: snapshot --- success: true diff --git a/crates/uv/tests/it/sync.rs b/crates/uv/tests/it/sync.rs index 654048a2d..35df3d50b 100644 --- a/crates/uv/tests/it/sync.rs +++ b/crates/uv/tests/it/sync.rs @@ -12536,7 +12536,7 @@ dependencies = [ ----- stdout ----- ----- stderr ----- - Ignoring existing lockfile due to change in timestamp cutoff: `global: 2022-04-04T12:00:00Z` vs. `global: 2022-04-04T12:00:00Z, tqdm: 2022-09-04T00:00:00Z` + Ignoring existing lockfile due to addition of exclude newer `2022-09-04T00:00:00Z` for package `tqdm` Resolved [N] packages in [TIME] Prepared [N] packages in [TIME] Uninstalled [N] packages in [TIME] @@ -12619,7 +12619,7 @@ exclude-newer-package = { tqdm = "2022-09-04T00:00:00Z" } ----- stdout ----- ----- stderr ----- - Ignoring existing lockfile due to change in timestamp cutoff: `global: 2022-04-04T12:00:00Z` vs. `global: 2022-04-04T12:00:00Z, tqdm: 2022-09-04T00:00:00Z` + Ignoring existing lockfile due to addition of exclude newer `2022-09-04T00:00:00Z` for package `tqdm` Resolved [N] packages in [TIME] Prepared [N] packages in [TIME] Uninstalled [N] packages in [TIME] diff --git a/docs/concepts/resolution.md b/docs/concepts/resolution.md index 9b89afc0c..65db075c5 100644 --- a/docs/concepts/resolution.md +++ b/docs/concepts/resolution.md @@ -654,10 +654,12 @@ may be specified as an [RFC 3339](https://www.rfc-editor.org/rfc/rfc3339.html) t `2006-12-02T02:07:43Z`) or a local date in the same format (e.g., `2006-12-02`) in your system's configured time zone. -Note the package index must support the `upload-time` field as specified in -[`PEP 700`](https://peps.python.org/pep-0700/). If the field is not present for a given -distribution, the distribution will be treated as unavailable. PyPI provides `upload-time` for all -packages. +!!! important + + The package index must support the `upload-time` field as specified in + [`PEP 700`](https://peps.python.org/pep-0700/). If the field is not present for a given + distribution, the distribution will be treated as unavailable. PyPI provides `upload-time` for + all packages. To ensure reproducibility, messages for unsatisfiable resolutions will not mention that distributions were excluded due to the `--exclude-newer` flag — newer distributions will be treated @@ -665,9 +667,69 @@ as if they do not exist. !!! note - The `--exclude-newer` option is only applied to packages that are read from a registry (as opposed to, e.g., Git - dependencies). Further, when using the `uv pip` interface, uv will not downgrade previously installed packages - unless the `--reinstall` flag is provided, in which case uv will perform a new resolution. + The `--exclude-newer` option is only applied to packages that are read from a registry (as + opposed to, e.g., Git dependencies). Further, when using the `uv pip` interface, uv will not + downgrade previously installed packages unless the `--reinstall` flag is provided, in which case + uv will perform a new resolution. + +This option is also supported in the `pyproject.toml`, e.g.: + +```pyproject.toml +[tool.uv] +exclude-newer = "2006-12-02T02:07:43Z" +``` + +When specified in persistent configuration, local date times are not allowed. + +Values may also be specified for specific packages, e.g., +`--exclude-newer-package setuptools=2006-12-02`, or: + +```pyproject.toml +[tool.uv] +exclude-newer-package = { setuptools = "2006-12-02T02:07:43Z" } +``` + +Package-specific values will take precedence over global values. + +## Dependency cooldowns + +uv also supports dependency "cooldowns" in which resolution will ignore packages newer than a +duration. This is a good way to improve security posture by delaying package updates until the +community has had the opportunity to vet new versions of packages. + +This feature is available via the [`exclude-newer` option](#reproducible-resolutions) and shares the +same semantics. + +Define a dependency cooldown by specifying a duration instead of an absolute value. Either a +"friendly" duration (e.g., `24 hours`, `1 week`, `30 days`) or an ISO 8601 duration (e.g., `PT24H`, +`P7D`, `P30D`) can be used. + +!!! note + + Durations do not respect semantics of the local time zone and are always resolved to a fixed + number of seconds assuming that a day is 24 hours (e.g., DST transitions are ignored). Calendar + units such as months and years are not allowed since they are inherently inconsistent lengths. + +When a duration is used for resolution, a timestamp is calculated relative to the current time. When +using a `uv.lock` file, the timestamp is included in the lockfile. uv will not update the lockfile +when the current time changes, instead, uv will update the timestamp when a new resolution is +performed, e.g., when `--upgrade` or `--refresh` is used. + +This option is also supported in the `pyproject.toml`, e.g.: + +```pyproject.toml +[tool.uv] +exclude-newer = "1 week" +``` + +Values may also be specified for specific packages, e.g., +`--exclude-newer-package "setuptools=30 days"`, or: + +```pyproject.toml +[tool.uv] +exclude-newer = "1 week" +exclude-newer-package = { setuptools = "30 days" } +``` ## Source distribution diff --git a/docs/guides/scripts.md b/docs/guides/scripts.md index 26d85e76d..a4d358166 100644 --- a/docs/guides/scripts.md +++ b/docs/guides/scripts.md @@ -296,7 +296,7 @@ of inline script metadata to limit uv to only considering distributions released date. This is useful for improving the reproducibility of your script when run at a later point in time. -The date must be specified as an [RFC 3339](https://www.rfc-editor.org/rfc/rfc3339.html) timestamp +The date should be specified as an [RFC 3339](https://www.rfc-editor.org/rfc/rfc3339.html) timestamp (e.g., `2006-12-02T02:07:43Z`). ```python title="example.py" diff --git a/uv.schema.json b/uv.schema.json index 724503c07..a5d3a04f2 100644 --- a/uv.schema.json +++ b/uv.schema.json @@ -892,7 +892,7 @@ } }, "ExcludeNewerTimestamp": { - "description": "Exclude distributions uploaded after the given timestamp.\n\nAccepts both RFC 3339 timestamps (e.g., `2006-12-02T02:07:43Z`) and local dates in the same format (e.g., `2006-12-02`).", + "description": "Exclude distributions uploaded after the given timestamp.\n\nAccepts both RFC 3339 timestamps (e.g., `2006-12-02T02:07:43Z`) and local dates in the same format (e.g., `2006-12-02`), as well as relative durations (e.g., `1 week`, `30 days`, `6 months`). Relative durations are resolved to a timestamp at lock time.", "type": "string", "pattern": "^\\d{4}-\\d{2}-\\d{2}(T\\d{2}:\\d{2}:\\d{2}(Z|[+-]\\d{2}:\\d{2}))?$" },