From b536446bef1ee136d3e1efd981409cd0908b9b68 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Tue, 7 Apr 2026 12:54:11 -0400 Subject: [PATCH] Move and rename `exclude-newer` override type (#18838) ## Summary We want to reuse this in `[[tool.uv.index]]` definitions, so it both (1) needs to be more generic (rather than `ExcludeNewerPackage`) and (2) needs to be accessible to more crates (so it's now in `uv-distribution-types`). --- .../src/exclude_newer.rs | 393 ++++++++++++ crates/uv-distribution-types/src/lib.rs | 2 + crates/uv-resolver/src/exclude_newer.rs | 560 +++--------------- crates/uv-resolver/src/lib.rs | 8 +- crates/uv-resolver/src/lock/mod.rs | 9 +- uv.schema.json | 26 +- 6 files changed, 487 insertions(+), 511 deletions(-) create mode 100644 crates/uv-distribution-types/src/exclude_newer.rs diff --git a/crates/uv-distribution-types/src/exclude_newer.rs b/crates/uv-distribution-types/src/exclude_newer.rs new file mode 100644 index 000000000..d2d331149 --- /dev/null +++ b/crates/uv-distribution-types/src/exclude_newer.rs @@ -0,0 +1,393 @@ +use std::borrow::Cow; +use std::str::FromStr; + +use jiff::{Span, Timestamp, ToSpan, Unit, tz::TimeZone}; +use serde::Deserialize; +use serde::de::value::MapAccessDeserializer; + +#[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 PartialOrd for ExcludeNewerSpan { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for ExcludeNewerSpan { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.0.to_string().cmp(&other.0.to_string()) + } +} + +impl std::hash::Hash for ExcludeNewerSpan { + fn hash(&self, state: &mut H) { + self.0.to_string().hash(state); + } +} + +impl serde::Serialize for ExcludeNewerSpan { + 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 = >::deserialize(deserializer)?; + let span: Span = s.parse().map_err(serde::de::Error::custom)?; + Ok(Self(span)) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct ExcludeNewerValue { + timestamp: Timestamp, + span: Option, +} + +impl ExcludeNewerValue { + pub fn into_parts(self) -> (Timestamp, Option) { + (self.timestamp, self.span) + } + + /// Return the [`Timestamp`] in milliseconds. + pub fn timestamp_millis(&self) -> i64 { + 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 [`ExcludeNewerValue`]. + pub fn new(timestamp: Timestamp, span: Option) -> Self { + Self { timestamp, span } + } + + /// If this value was derived from a relative span, recompute the timestamp relative to now. + /// + /// Returns `self` unchanged if there is no span (i.e., the timestamp is absolute). + #[must_use] + pub fn recompute(self) -> Self { + let Some(span) = self.span else { + return self; + }; + + 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) + }; + + let Ok(cutoff) = now.checked_sub(span.0.abs()) else { + return Self { + timestamp: self.timestamp, + span: Some(span), + }; + }; + + Self { + timestamp: cutoff.into(), + span: Some(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>, + { + #[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 From for ExcludeNewerValue { + fn from(timestamp: Timestamp) -> Self { + Self { + timestamp, + span: None, + } + } +} + +fn format_exclude_newer_error( + input: &str, + date_err: &jiff::Error, + span_err: &jiff::Error, +) -> String { + let trimmed = input.trim(); + + 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}"); + } + + let after_sign_trimmed = after_sign.trim_start(); + let mut chars = after_sign_trimmed.chars().peekable(); + if chars.peek().is_some_and(char::is_ascii_digit) { + while chars.peek().is_some_and(char::is_ascii_digit) { + chars.next(); + } + while chars.peek().is_some_and(|c| c.is_whitespace()) { + chars.next(); + } + if chars.peek().is_some_and(char::is_ascii_alphabetic) { + return format!("`{input}` could not be parsed as a duration: {span_err}"); + } + } + + 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}"); + } + + 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; + + fn from_str(input: &str) -> Result { + if let Ok(timestamp) = input.parse::() { + return Ok(Self::new(timestamp, None)); + } + + 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, + }; + + let span_err = match input.parse::() { + Ok(span) => { + 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) + }; + + 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`." + )); + } + + 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, + }; + + Err(format_exclude_newer_error(input, &date_err, &span_err)) + } +} + +impl std::fmt::Display for ExcludeNewerValue { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.timestamp.fmt(f) + } +} + +#[cfg(feature = "schemars")] +impl schemars::JsonSchema for ExcludeNewerValue { + fn schema_name() -> Cow<'static, str> { + Cow::Borrowed("ExcludeNewerValue") + } + + fn json_schema(_generator: &mut schemars::generate::SchemaGenerator) -> schemars::Schema { + schemars::json_schema!({ + "type": "string", + "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.", + }) + } +} + +/// Whether `exclude-newer` is disabled or enabled with an explicit cutoff. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ExcludeNewerOverride { + /// Disable exclude-newer (allow all versions regardless of upload date). + Disabled, + /// Enable exclude-newer with this cutoff. + Enabled(Box), +} + +#[cfg(feature = "schemars")] +impl schemars::JsonSchema for ExcludeNewerOverride { + fn schema_name() -> Cow<'static, str> { + Cow::Borrowed("ExcludeNewerOverride") + } + + fn json_schema(generator: &mut schemars::generate::SchemaGenerator) -> schemars::Schema { + schemars::json_schema!({ + "oneOf": [ + { + "type": "boolean", + "const": false, + "description": "Disable exclude-newer." + }, + generator.subschema_for::(), + ] + }) + } +} + +impl<'de> serde::Deserialize<'de> for ExcludeNewerOverride { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + struct Visitor; + + impl<'de> serde::de::Visitor<'de> for Visitor { + type Value = ExcludeNewerOverride; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str( + "a date/timestamp/duration string, false to disable exclude-newer, or a table \ + with timestamp/span", + ) + } + + fn visit_str(self, v: &str) -> Result + where + E: serde::de::Error, + { + ExcludeNewerValue::from_str(v) + .map(|ts| ExcludeNewerOverride::Enabled(Box::new(ts))) + .map_err(|e| E::custom(format!("failed to parse exclude-newer value: {e}"))) + } + + fn visit_bool(self, v: bool) -> Result + where + E: serde::de::Error, + { + if v { + Err(E::custom( + "expected false to disable exclude-newer, got true", + )) + } else { + Ok(ExcludeNewerOverride::Disabled) + } + } + + fn visit_map(self, map: A) -> Result + where + A: serde::de::MapAccess<'de>, + { + Ok(ExcludeNewerOverride::Enabled(Box::new( + ExcludeNewerValue::deserialize(MapAccessDeserializer::new(map))?, + ))) + } + } + + deserializer.deserialize_any(Visitor) + } +} + +impl serde::Serialize for ExcludeNewerOverride { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + match self { + Self::Enabled(timestamp) => timestamp.to_string().serialize(serializer), + Self::Disabled => serializer.serialize_bool(false), + } + } +} diff --git a/crates/uv-distribution-types/src/lib.rs b/crates/uv-distribution-types/src/lib.rs index 126be39c0..b2ee398a6 100644 --- a/crates/uv-distribution-types/src/lib.rs +++ b/crates/uv-distribution-types/src/lib.rs @@ -63,6 +63,7 @@ pub use crate::dependency_metadata::*; pub use crate::diagnostic::*; pub use crate::dist_error::*; pub use crate::error::*; +pub use crate::exclude_newer::*; pub use crate::file::*; pub use crate::hash::*; pub use crate::id::*; @@ -94,6 +95,7 @@ mod dependency_metadata; mod diagnostic; mod dist_error; mod error; +mod exclude_newer; mod file; mod hash; mod id; diff --git a/crates/uv-resolver/src/exclude_newer.rs b/crates/uv-resolver/src/exclude_newer.rs index bf8bae4d7..9750d406a 100644 --- a/crates/uv-resolver/src/exclude_newer.rs +++ b/crates/uv-resolver/src/exclude_newer.rs @@ -1,14 +1,12 @@ -use std::borrow::Cow; use std::{ ops::{Deref, DerefMut}, str::FromStr, }; -use jiff::{Span, Timestamp, ToSpan, Unit, tz::TimeZone}; +use jiff::Timestamp; use rustc_hash::FxHashMap; -use serde::Deserialize; -use serde::de::value::MapAccessDeserializer; use serde::ser::SerializeMap; +use uv_distribution_types::{ExcludeNewerOverride, ExcludeNewerSpan, ExcludeNewerValue}; use uv_normalize::PackageName; #[derive(Debug, Clone, PartialEq, Eq)] @@ -97,9 +95,9 @@ impl std::fmt::Display for ExcludeNewerChange { #[derive(Debug, Clone, PartialEq, Eq)] pub enum ExcludeNewerPackageChange { - PackageAdded(PackageName, PackageExcludeNewer), + PackageAdded(PackageName, ExcludeNewerOverride), PackageRemoved(PackageName), - PackageChanged(PackageName, Box), + PackageChanged(PackageName, Box), } impl ExcludeNewerPackageChange { @@ -114,14 +112,14 @@ impl ExcludeNewerPackageChange { impl std::fmt::Display for ExcludeNewerPackageChange { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - Self::PackageAdded(name, PackageExcludeNewer::Enabled(value)) => { + Self::PackageAdded(name, ExcludeNewerOverride::Enabled(value)) => { write!( f, "addition of exclude newer `{}` for package `{name}`", value.as_ref() ) } - Self::PackageAdded(name, PackageExcludeNewer::Disabled) => { + Self::PackageAdded(name, ExcludeNewerOverride::Disabled) => { write!( f, "addition of exclude newer exclusion for package `{name}`" @@ -134,88 +132,28 @@ impl std::fmt::Display for ExcludeNewerPackageChange { } } } -/// A timestamp that excludes files newer than it. -#[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 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, +fn compare_exclude_newer_value( + this: &ExcludeNewerValue, + other: &ExcludeNewerValue, +) -> Option { + match (this.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 this.timestamp() != other.timestamp() => { + Some(ExcludeNewerValueChange::RelativeTimestampChanged( + this.timestamp(), + other.timestamp(), + *span, + )) } - } -} - -#[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 = >::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) + (None, None) if this.timestamp() != other.timestamp() => Some( + ExcludeNewerValueChange::AbsoluteTimestampChanged(this.timestamp(), other.timestamp()), + ), + (Some(_), Some(_)) | (None, None) => None, } } @@ -237,299 +175,12 @@ impl serde::Serialize for ExcludeNewerValueWithSpanRef<'_> { } } -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.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 [`ExcludeNewerValue`]. - pub fn new(timestamp: Timestamp, span: Option) -> Self { - Self { timestamp, span } - } - - /// If this value was derived from a relative span, recompute the timestamp relative to now. - /// - /// Returns `self` unchanged if there is no span (i.e., the timestamp is absolute). - #[must_use] - pub fn recompute(self) -> Self { - let Some(span) = self.span else { - return self; - }; - - 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) - }; - - let Ok(cutoff) = now.checked_sub(span.0.abs()) else { - return Self { - timestamp: self.timestamp, - span: Some(span), - }; - }; - - Self { - timestamp: cutoff.into(), - span: Some(span), - } - } -} - -impl From for ExcludeNewerValue { - fn from(timestamp: Timestamp) -> Self { - Self { - timestamp, - span: None, - } - } -} - -/// 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 [`ExcludeNewerValue`] from a string. - /// - /// 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 { - // Try parsing as a timestamp first - if let Ok(timestamp) = input.parse::() { - return Ok(Self::new(timestamp, None)); - } - - // 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 ExcludeNewerValue { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - self.timestamp.fmt(f) - } -} - -/// Per-package exclude-newer setting. -/// -/// This enum represents whether exclude-newer should be disabled for a package, -/// or if a specific cutoff (absolute or relative) should be used. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum PackageExcludeNewer { - /// Disable exclude-newer for this package (allow all versions regardless of upload date). - Disabled, - /// Enable exclude-newer with this cutoff for this package. - Enabled(Box), -} - -impl PackageExcludeNewer { - /// Recompute the relative span timestamp relative to the current time, if applicable. - #[must_use] - pub fn recompute(self) -> Self { - match self { - Self::Disabled => Self::Disabled, - Self::Enabled(value) => Self::Enabled(Box::new((*value).recompute())), - } - } -} - -#[cfg(feature = "schemars")] -impl schemars::JsonSchema for PackageExcludeNewer { - fn schema_name() -> Cow<'static, str> { - Cow::Borrowed("PackageExcludeNewer") - } - - fn json_schema(generator: &mut schemars::generate::SchemaGenerator) -> schemars::Schema { - schemars::json_schema!({ - "oneOf": [ - { - "type": "boolean", - "const": false, - "description": "Disable exclude-newer for this package." - }, - generator.subschema_for::(), - ] - }) - } -} - /// A package-specific exclude-newer entry. #[derive(Debug, Clone, PartialEq, Eq)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] pub struct ExcludeNewerPackageEntry { pub package: PackageName, - pub setting: PackageExcludeNewer, + pub setting: ExcludeNewerOverride, } impl FromStr for ExcludeNewerPackageEntry { @@ -548,9 +199,9 @@ impl FromStr for ExcludeNewerPackageEntry { })?; let setting = if value == "false" { - PackageExcludeNewer::Disabled + ExcludeNewerOverride::Disabled } else { - PackageExcludeNewer::Enabled(Box::new(ExcludeNewerValue::from_str(value).map_err( + ExcludeNewerOverride::Enabled(Box::new(ExcludeNewerValue::from_str(value).map_err( |err| format!("Invalid `exclude-newer-package` value `{value}`: {err}"), )?)) }; @@ -559,8 +210,8 @@ impl FromStr for ExcludeNewerPackageEntry { } } -impl From<(PackageName, PackageExcludeNewer)> for ExcludeNewerPackageEntry { - fn from((package, setting): (PackageName, PackageExcludeNewer)) -> Self { +impl From<(PackageName, ExcludeNewerOverride)> for ExcludeNewerPackageEntry { + fn from((package, setting): (PackageName, ExcludeNewerOverride)) -> Self { Self { package, setting } } } @@ -569,72 +220,7 @@ impl From<(PackageName, ExcludeNewerValue)> for ExcludeNewerPackageEntry { fn from((package, timestamp): (PackageName, ExcludeNewerValue)) -> Self { Self { package, - setting: PackageExcludeNewer::Enabled(Box::new(timestamp)), - } - } -} - -impl<'de> serde::Deserialize<'de> for PackageExcludeNewer { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - struct Visitor; - - impl<'de> serde::de::Visitor<'de> for Visitor { - type Value = PackageExcludeNewer; - - fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { - formatter.write_str( - "a date/timestamp/duration string, false to disable exclude-newer, or a table \ - with timestamp/span", - ) - } - - fn visit_str(self, v: &str) -> Result - where - E: serde::de::Error, - { - ExcludeNewerValue::from_str(v) - .map(|ts| PackageExcludeNewer::Enabled(Box::new(ts))) - .map_err(|e| E::custom(format!("failed to parse exclude-newer value: {e}"))) - } - - fn visit_bool(self, v: bool) -> Result - where - E: serde::de::Error, - { - if v { - Err(E::custom( - "expected false to disable exclude-newer, got true", - )) - } else { - Ok(PackageExcludeNewer::Disabled) - } - } - - fn visit_map(self, map: A) -> Result - where - A: serde::de::MapAccess<'de>, - { - Ok(PackageExcludeNewer::Enabled(Box::new( - ExcludeNewerValue::deserialize(MapAccessDeserializer::new(map))?, - ))) - } - } - - deserializer.deserialize_any(Visitor) - } -} - -impl serde::Serialize for PackageExcludeNewer { - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - match self { - Self::Enabled(timestamp) => timestamp.to_string().serialize(serializer), - Self::Disabled => serializer.serialize_bool(false), + setting: ExcludeNewerOverride::Enabled(Box::new(timestamp)), } } } @@ -653,8 +239,8 @@ where let mut map = serializer.serialize_map(Some(value.len()))?; for (name, setting) in value { match setting { - PackageExcludeNewer::Disabled => map.serialize_entry(name, &false)?, - PackageExcludeNewer::Enabled(value) => { + ExcludeNewerOverride::Disabled => map.serialize_entry(name, &false)?, + ExcludeNewerOverride::Enabled(value) => { map.serialize_entry(name, &ExcludeNewerValueWithSpanRef(value.as_ref()))?; } } @@ -663,13 +249,13 @@ where } #[derive(Debug, Clone, PartialEq, Eq)] -pub enum PackageExcludeNewerChange { +pub enum ExcludeNewerOverrideChange { Disabled { was: ExcludeNewerValue }, Enabled { now: ExcludeNewerValue }, TimestampChanged(ExcludeNewerValueChange), } -impl PackageExcludeNewerChange { +impl ExcludeNewerOverrideChange { pub fn is_relative_timestamp_change(&self) -> bool { match self { Self::Disabled { .. } | Self::Enabled { .. } => false, @@ -678,7 +264,7 @@ impl PackageExcludeNewerChange { } } -impl std::fmt::Display for PackageExcludeNewerChange { +impl std::fmt::Display for ExcludeNewerOverrideChange { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Disabled { was } => { @@ -694,10 +280,10 @@ impl std::fmt::Display for PackageExcludeNewerChange { #[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 @@ -721,8 +307,8 @@ impl FromIterator for ExcludeNewerPackage { } impl IntoIterator for ExcludeNewerPackage { - type Item = (PackageName, PackageExcludeNewer); - type IntoIter = std::collections::hash_map::IntoIter; + type Item = (PackageName, ExcludeNewerOverride); + type IntoIter = std::collections::hash_map::IntoIter; fn into_iter(self) -> Self::IntoIter { self.0.into_iter() @@ -730,8 +316,8 @@ impl IntoIterator for ExcludeNewerPackage { } impl<'a> IntoIterator for &'a ExcludeNewerPackage { - type Item = (&'a PackageName, &'a PackageExcludeNewer); - type IntoIter = std::collections::hash_map::Iter<'a, PackageName, PackageExcludeNewer>; + type Item = (&'a PackageName, &'a ExcludeNewerOverride); + type IntoIter = std::collections::hash_map::Iter<'a, PackageName, ExcludeNewerOverride>; fn into_iter(self) -> Self::IntoIter { self.0.iter() @@ -740,7 +326,7 @@ 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 } @@ -755,7 +341,15 @@ impl ExcludeNewerPackage { Self( self.0 .into_iter() - .map(|(name, setting)| (name, setting.recompute())) + .map(|(name, setting)| { + let setting = match setting { + ExcludeNewerOverride::Disabled => ExcludeNewerOverride::Disabled, + ExcludeNewerOverride::Enabled(value) => { + ExcludeNewerOverride::Enabled(Box::new((*value).recompute())) + } + }; + (name, setting) + }) .collect(), ) } @@ -764,39 +358,41 @@ impl ExcludeNewerPackage { for (package, setting) in self { match (setting, other.get(package)) { ( - PackageExcludeNewer::Enabled(self_timestamp), - Some(PackageExcludeNewer::Enabled(other_timestamp)), + ExcludeNewerOverride::Enabled(self_timestamp), + Some(ExcludeNewerOverride::Enabled(other_timestamp)), ) => { - if let Some(change) = self_timestamp.compare(other_timestamp) { + if let Some(change) = + compare_exclude_newer_value(self_timestamp, other_timestamp) + { return Some(ExcludeNewerPackageChange::PackageChanged( package.clone(), - Box::new(PackageExcludeNewerChange::TimestampChanged(change)), + Box::new(ExcludeNewerOverrideChange::TimestampChanged(change)), )); } } ( - PackageExcludeNewer::Enabled(self_timestamp), - Some(PackageExcludeNewer::Disabled), + ExcludeNewerOverride::Enabled(self_timestamp), + Some(ExcludeNewerOverride::Disabled), ) => { return Some(ExcludeNewerPackageChange::PackageChanged( package.clone(), - Box::new(PackageExcludeNewerChange::Disabled { + Box::new(ExcludeNewerOverrideChange::Disabled { was: self_timestamp.as_ref().clone(), }), )); } ( - PackageExcludeNewer::Disabled, - Some(PackageExcludeNewer::Enabled(other_timestamp)), + ExcludeNewerOverride::Disabled, + Some(ExcludeNewerOverride::Enabled(other_timestamp)), ) => { return Some(ExcludeNewerPackageChange::PackageChanged( package.clone(), - Box::new(PackageExcludeNewerChange::Enabled { + Box::new(ExcludeNewerOverrideChange::Enabled { now: other_timestamp.as_ref().clone(), }), )); } - (PackageExcludeNewer::Disabled, Some(PackageExcludeNewer::Disabled)) => {} + (ExcludeNewerOverride::Disabled, Some(ExcludeNewerOverride::Disabled)) => {} (_, None) => { return Some(ExcludeNewerPackageChange::PackageRemoved(package.clone())); } @@ -858,8 +454,8 @@ impl ExcludeNewer { /// exclude-newer is configured. pub fn exclude_newer_package(&self, package_name: &PackageName) -> Option { match self.package.get(package_name) { - Some(PackageExcludeNewer::Enabled(timestamp)) => Some(timestamp.as_ref().clone()), - Some(PackageExcludeNewer::Disabled) => None, + Some(ExcludeNewerOverride::Enabled(timestamp)) => Some(timestamp.as_ref().clone()), + Some(ExcludeNewerOverride::Disabled) => None, None => self.global.clone(), } } @@ -883,7 +479,7 @@ impl ExcludeNewer { 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) { + if let Some(change) = compare_exclude_newer_value(self_global, other_global) { return Some(ExcludeNewerChange::GlobalChanged(change)); } } @@ -913,10 +509,10 @@ impl std::fmt::Display for ExcludeNewer { write!(f, ", ")?; } match setting { - PackageExcludeNewer::Enabled(timestamp) => { + ExcludeNewerOverride::Enabled(timestamp) => { write!(f, "{name}: {}", timestamp.as_ref())?; } - PackageExcludeNewer::Disabled => { + ExcludeNewerOverride::Disabled => { write!(f, "{name}: disabled")?; } } @@ -925,17 +521,3 @@ impl std::fmt::Display for ExcludeNewer { Ok(()) } } - -#[cfg(feature = "schemars")] -impl schemars::JsonSchema for ExcludeNewerValue { - fn schema_name() -> Cow<'static, str> { - Cow::Borrowed("ExcludeNewerValue") - } - - fn json_schema(_generator: &mut schemars::generate::SchemaGenerator) -> schemars::Schema { - schemars::json_schema!({ - "type": "string", - "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.", - }) - } -} diff --git a/crates/uv-resolver/src/lib.rs b/crates/uv-resolver/src/lib.rs index c52fb7202..4e9de8099 100644 --- a/crates/uv-resolver/src/lib.rs +++ b/crates/uv-resolver/src/lib.rs @@ -1,10 +1,9 @@ pub use dependency_mode::DependencyMode; pub use error::{ErrorTree, NoSolutionError, NoSolutionHeader, ResolveError, SentinelRange}; pub use exclude_newer::{ - ExcludeNewer, ExcludeNewerChange, ExcludeNewerPackage, ExcludeNewerPackageChange, - ExcludeNewerPackageEntry, ExcludeNewerSpan, ExcludeNewerValue, ExcludeNewerValueChange, - ExcludeNewerValueWithSpanRef, PackageExcludeNewer, PackageExcludeNewerChange, - serialize_exclude_newer_package_with_spans, + ExcludeNewer, ExcludeNewerChange, ExcludeNewerOverrideChange, ExcludeNewerPackage, + ExcludeNewerPackageChange, ExcludeNewerPackageEntry, ExcludeNewerValueChange, + ExcludeNewerValueWithSpanRef, serialize_exclude_newer_package_with_spans, }; pub use exclusions::Exclusions; pub use flat_index::{FlatDistributions, FlatIndex}; @@ -30,6 +29,7 @@ pub use resolver::{ }; pub use universal_marker::{ConflictMarker, UniversalMarker}; pub use upgrade::UpgradePackages; +pub use uv_distribution_types::{ExcludeNewerOverride, ExcludeNewerSpan, ExcludeNewerValue}; pub use version_map::VersionMap; pub use yanks::AllowedYanks; diff --git a/crates/uv-resolver/src/lock/mod.rs b/crates/uv-resolver/src/lock/mod.rs index 4667530ba..836a5f0c7 100644 --- a/crates/uv-resolver/src/lock/mod.rs +++ b/crates/uv-resolver/src/lock/mod.rs @@ -54,7 +54,6 @@ 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; @@ -65,8 +64,8 @@ pub use crate::lock::tree::TreeDisplay; use crate::resolution::{AnnotatedDist, ResolutionGraphNode}; use crate::universal_marker::{ConflictMarker, UniversalMarker}; use crate::{ - ExcludeNewer, ExcludeNewerPackage, ExcludeNewerValue, InMemoryIndex, MetadataResponse, - PackageExcludeNewer, PrereleaseMode, ResolutionMode, ResolverOutput, + ExcludeNewer, ExcludeNewerOverride, ExcludeNewerPackage, ExcludeNewerSpan, ExcludeNewerValue, + InMemoryIndex, MetadataResponse, PrereleaseMode, ResolutionMode, ResolverOutput, }; mod export; @@ -1173,7 +1172,7 @@ impl Lock { let mut package_table = toml_edit::Table::new(); for (name, setting) in &exclude_newer.package { match setting { - PackageExcludeNewer::Enabled(exclude_newer_value) => { + ExcludeNewerOverride::Enabled(exclude_newer_value) => { if let Some(span) = exclude_newer_value.span() { // Serialize as inline table with timestamp and span let mut inline = toml_edit::InlineTable::new(); @@ -1191,7 +1190,7 @@ impl Lock { ); } } - PackageExcludeNewer::Disabled => { + ExcludeNewerOverride::Disabled => { package_table.insert(name.as_ref(), value(false)); } } diff --git a/uv.schema.json b/uv.schema.json index 9e0d5b23f..61910d780 100644 --- a/uv.schema.json +++ b/uv.schema.json @@ -838,10 +838,22 @@ "type": "string", "format": "uri" }, + "ExcludeNewerOverride": { + "oneOf": [ + { + "description": "Disable exclude-newer.", + "type": "boolean", + "const": false + }, + { + "$ref": "#/definitions/ExcludeNewerValue" + } + ] + }, "ExcludeNewerPackage": { "type": "object", "additionalProperties": { - "$ref": "#/definitions/PackageExcludeNewer" + "$ref": "#/definitions/ExcludeNewerOverride" } }, "ExcludeNewerValue": { @@ -1136,18 +1148,6 @@ "$ref": "#/definitions/ConfigSettings" } }, - "PackageExcludeNewer": { - "oneOf": [ - { - "description": "Disable exclude-newer for this package.", - "type": "boolean", - "const": false - }, - { - "$ref": "#/definitions/ExcludeNewerValue" - } - ] - }, "PackageName": { "description": "The normalized name of a package.\n\nConverts the name to lowercase and collapses runs of `-`, `_`, and `.` down to a single `-`.\nFor example, `---`, `.`, and `__` are all converted to a single `-`.\n\nSee: ", "type": "string"