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`).
This commit is contained in:
Charlie Marsh
2026-04-07 12:54:11 -04:00
committed by GitHub
parent f40124f1ee
commit b536446bef
6 changed files with 487 additions and 511 deletions
@@ -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<std::cmp::Ordering> {
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<H: std::hash::Hasher>(&self, state: &mut H) {
self.0.to_string().hash(state);
}
}
impl serde::Serialize for ExcludeNewerSpan {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(&self.0.to_string())
}
}
impl<'de> serde::Deserialize<'de> for ExcludeNewerSpan {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let s = <Cow<'_, str>>::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<ExcludeNewerSpan>,
}
impl ExcludeNewerValue {
pub fn into_parts(self) -> (Timestamp, Option<ExcludeNewerSpan>) {
(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<ExcludeNewerSpan>) -> 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::<Timestamp>()
.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<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
self.timestamp.serialize(serializer)
}
}
impl<'de> serde::Deserialize<'de> for ExcludeNewerValue {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(serde::Deserialize)]
struct TableForm {
timestamp: Timestamp,
span: Option<ExcludeNewerSpan>,
}
#[derive(serde::Deserialize)]
#[serde(untagged)]
enum Helper {
String(String),
Table(Box<TableForm>),
}
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<Timestamp> 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<Self, Self::Err> {
if let Ok(timestamp) = input.parse::<Timestamp>() {
return Ok(Self::new(timestamp, None));
}
let date_err = match input.parse::<jiff::civil::Date>() {
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::<Span>() {
Ok(span) => {
let now = if let Ok(test_time) = std::env::var("UV_TEST_CURRENT_TIMESTAMP") {
test_time
.parse::<Timestamp>()
.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<ExcludeNewerValue>),
}
#[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::<ExcludeNewerValue>(),
]
})
}
}
impl<'de> serde::Deserialize<'de> for ExcludeNewerOverride {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
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<E>(self, v: &str) -> Result<Self::Value, E>
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<E>(self, v: bool) -> Result<Self::Value, E>
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<A>(self, map: A) -> Result<Self::Value, A::Error>
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<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
match self {
Self::Enabled(timestamp) => timestamp.to_string().serialize(serializer),
Self::Disabled => serializer.serialize_bool(false),
}
}
}
+2
View File
@@ -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;
+71 -489
View File
@@ -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<PackageExcludeNewerChange>),
PackageChanged(PackageName, Box<ExcludeNewerOverrideChange>),
}
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<ExcludeNewerSpan>,
}
impl ExcludeNewerValue {
pub fn into_parts(self) -> (Timestamp, Option<ExcludeNewerSpan>) {
(self.timestamp, self.span)
}
pub fn compare(&self, other: &Self) -> Option<ExcludeNewerValueChange> {
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<ExcludeNewerValueChange> {
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<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(&self.0.to_string())
}
}
impl<'de> serde::Deserialize<'de> for ExcludeNewerSpan {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let s = <Cow<'_, str>>::deserialize(deserializer)?;
let span: Span = s.parse().map_err(serde::de::Error::custom)?;
Ok(Self(span))
}
}
impl serde::Serialize for ExcludeNewerValue {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
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<D>(deserializer: D) -> Result<Self, D::Error>
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<ExcludeNewerSpan>,
}
#[derive(serde::Deserialize)]
#[serde(untagged)]
enum Helper {
String(String),
Table(Box<TableForm>),
}
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<ExcludeNewerSpan>) -> 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::<Timestamp>()
.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<Timestamp> 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<Self, Self::Err> {
// Try parsing as a timestamp first
if let Ok(timestamp) = input.parse::<Timestamp>() {
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::<jiff::civil::Date>() {
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::<Span>() {
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::<Timestamp>()
.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<ExcludeNewerValue>),
}
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::<ExcludeNewerValue>(),
]
})
}
}
/// 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<D>(deserializer: D) -> Result<Self, D::Error>
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<E>(self, v: &str) -> Result<Self::Value, E>
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<E>(self, v: bool) -> Result<Self::Value, E>
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<A>(self, map: A) -> Result<Self::Value, A::Error>
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<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
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<PackageName, PackageExcludeNewer>);
pub struct ExcludeNewerPackage(FxHashMap<PackageName, ExcludeNewerOverride>);
impl Deref for ExcludeNewerPackage {
type Target = FxHashMap<PackageName, PackageExcludeNewer>;
type Target = FxHashMap<PackageName, ExcludeNewerOverride>;
fn deref(&self) -> &Self::Target {
&self.0
@@ -721,8 +307,8 @@ impl FromIterator<ExcludeNewerPackageEntry> for ExcludeNewerPackage {
}
impl IntoIterator for ExcludeNewerPackage {
type Item = (PackageName, PackageExcludeNewer);
type IntoIter = std::collections::hash_map::IntoIter<PackageName, PackageExcludeNewer>;
type Item = (PackageName, ExcludeNewerOverride);
type IntoIter = std::collections::hash_map::IntoIter<PackageName, ExcludeNewerOverride>;
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<PackageName, PackageExcludeNewer> {
pub fn into_inner(self) -> FxHashMap<PackageName, ExcludeNewerOverride> {
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<ExcludeNewerValue> {
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<ExcludeNewerChange> {
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.",
})
}
}
+4 -4
View File
@@ -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;
+4 -5
View File
@@ -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));
}
}
+13 -13
View File
@@ -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: <https://packaging.python.org/en/latest/specifications/name-normalization/>",
"type": "string"