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}))?$" },