Add support for relative durations in `exclude-newer` (#16814)

Adds support for "friendly" durations like, 1 week, 7 days, 24 hours
using Jiff's parser. During resolution, we calculate this relative to
the current time and resolve it into a concrete timestamp for the
lockfile. If the span has not changed, e.g., to another relative value,
then locking again will not change the lockfile. The locked timestamp
will only be updated when the lockfile is invalidated, e.g., with
`--upgrade`. This prevents the lockfile from repeatedly churning when a
relative value is used.
This commit is contained in:
Zanie Blue 2025-12-09 13:52:14 -06:00 committed by GitHub
parent 7b6b02a7d1
commit d0a6f5d13f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
31 changed files with 1937 additions and 178 deletions

View File

@ -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<ExcludeNewerTimestamp>,
/// 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<ExcludeNewerValue>,
/// 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<ExcludeNewerTimestamp>,
/// 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<ExcludeNewerValue>,
/// 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<ExcludeNewerTimestamp>,
/// 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<ExcludeNewerValue>,
/// 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<ExcludeNewerTimestamp>,
/// 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<ExcludeNewerValue>,
/// 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<ExcludeNewerTimestamp>,
/// 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<ExcludeNewerValue>,
/// 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<ExcludeNewerTimestamp>,
pub exclude_newer: Option<ExcludeNewerValue>,
}
#[derive(Args)]

View File

@ -5,53 +5,347 @@ 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<ExcludeNewerSpan>,
}
impl ExcludeNewerTimestamp {
/// Returns the timestamp in milliseconds.
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,
}
}
}
#[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 = String::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)
}
}
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.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<ExcludeNewerSpan>) -> Self {
Self { timestamp, span }
}
}
impl From<Timestamp> for ExcludeNewerTimestamp {
impl From<Timestamp> 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<Self, Self::Err> {
// 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::<Timestamp>() {
return Ok(Self(timestamp));
return Ok(Self::new(timestamp, None));
}
let date = input
.parse::<jiff::civil::Date>()
.map_err(|err| format!("`{input}` could not be parsed as a valid date: {err}"))?;
// 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()))
@ -62,13 +356,72 @@ impl FromStr for ExcludeNewerTimestamp {
be converted to a timestamp: {err}",
)
})?;
Ok(Self(timestamp))
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 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<PackageName, ExcludeNewerTimestamp>);
pub struct ExcludeNewerPackage(FxHashMap<PackageName, ExcludeNewerValue>);
impl Deref for ExcludeNewerPackage {
type Target = FxHashMap<PackageName, ExcludeNewerTimestamp>;
type Target = FxHashMap<PackageName, ExcludeNewerValue>;
fn deref(&self) -> &Self::Target {
&self.0
@ -136,8 +489,8 @@ impl FromIterator<ExcludeNewerPackageEntry> for ExcludeNewerPackage {
}
impl IntoIterator for ExcludeNewerPackage {
type Item = (PackageName, ExcludeNewerTimestamp);
type IntoIter = std::collections::hash_map::IntoIter<PackageName, ExcludeNewerTimestamp>;
type Item = (PackageName, ExcludeNewerValue);
type IntoIter = std::collections::hash_map::IntoIter<PackageName, ExcludeNewerValue>;
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<PackageName, ExcludeNewerTimestamp> {
pub fn into_inner(self) -> FxHashMap<PackageName, ExcludeNewerValue> {
self.0
}
pub fn compare(&self, other: &Self) -> Option<ExcludeNewerPackageChange> {
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<ExcludeNewerTimestamp>,
pub global: Option<ExcludeNewerValue>,
/// 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<ExcludeNewerTimestamp>, package: ExcludeNewerPackage) -> Self {
pub fn new(global: Option<ExcludeNewerValue>, package: ExcludeNewerPackage) -> Self {
Self { global, package }
}
/// Create from CLI arguments.
pub fn from_args(
global: Option<ExcludeNewerTimestamp>,
global: Option<ExcludeNewerValue>,
package: Vec<ExcludeNewerPackageEntry>,
) -> 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<ExcludeNewerTimestamp> {
self.package.get(package_name).copied().or(self.global)
pub fn exclude_newer_package(&self, package_name: &PackageName) -> Option<ExcludeNewerValue> {
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<ExcludeNewerChange> {
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
}
}

View File

@ -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};

View File

@ -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<ExcludeNewerTimestamp>,
exclude_newer: Option<Timestamp>,
exclude_newer_span: Option<ExcludeNewerSpan>,
#[serde(default, skip_serializing_if = "ExcludeNewerPackage::is_empty")]
exclude_newer_package: ExcludeNewerPackage,
}
@ -2143,7 +2163,9 @@ struct ExcludeNewerWire {
impl From<ExcludeNewerWire> 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<ExcludeNewerWire> for ExcludeNewer {
impl From<ExcludeNewer> 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,
}
}

View File

@ -38,6 +38,7 @@ Ok(
fork_strategy: RequiresPython,
exclude_newer: ExcludeNewerWire {
exclude_newer: None,
exclude_newer_span: None,
exclude_newer_package: ExcludeNewerPackage(
{},
),

View File

@ -38,6 +38,7 @@ Ok(
fork_strategy: RequiresPython,
exclude_newer: ExcludeNewerWire {
exclude_newer: None,
exclude_newer_span: None,
exclude_newer_package: ExcludeNewerPackage(
{},
),

View File

@ -38,6 +38,7 @@ Ok(
fork_strategy: RequiresPython,
exclude_newer: ExcludeNewerWire {
exclude_newer: None,
exclude_newer_span: None,
exclude_newer_package: ExcludeNewerPackage(
{},
),

View File

@ -38,6 +38,7 @@ Ok(
fork_strategy: RequiresPython,
exclude_newer: ExcludeNewerWire {
exclude_newer: None,
exclude_newer_span: None,
exclude_newer_package: ExcludeNewerPackage(
{},
),

View File

@ -38,6 +38,7 @@ Ok(
fork_strategy: RequiresPython,
exclude_newer: ExcludeNewerWire {
exclude_newer: None,
exclude_newer_span: None,
exclude_newer_package: ExcludeNewerPackage(
{},
),

View File

@ -38,6 +38,7 @@ Ok(
fork_strategy: RequiresPython,
exclude_newer: ExcludeNewerWire {
exclude_newer: None,
exclude_newer_span: None,
exclude_newer_package: ExcludeNewerPackage(
{},
),

View File

@ -38,6 +38,7 @@ Ok(
fork_strategy: RequiresPython,
exclude_newer: ExcludeNewerWire {
exclude_newer: None,
exclude_newer_span: None,
exclude_newer_package: ExcludeNewerPackage(
{},
),

View File

@ -38,6 +38,7 @@ Ok(
fork_strategy: RequiresPython,
exclude_newer: ExcludeNewerWire {
exclude_newer: None,
exclude_newer_span: None,
exclude_newer_package: ExcludeNewerPackage(
{},
),

View File

@ -38,6 +38,7 @@ Ok(
fork_strategy: RequiresPython,
exclude_newer: ExcludeNewerWire {
exclude_newer: None,
exclude_newer_span: None,
exclude_newer_package: ExcludeNewerPackage(
{},
),

View File

@ -38,6 +38,7 @@ Ok(
fork_strategy: RequiresPython,
exclude_newer: ExcludeNewerWire {
exclude_newer: None,
exclude_newer_span: None,
exclude_newer_package: ExcludeNewerPackage(
{},
),

View File

@ -38,6 +38,7 @@ Ok(
fork_strategy: RequiresPython,
exclude_newer: ExcludeNewerWire {
exclude_newer: None,
exclude_newer_span: None,
exclude_newer_package: ExcludeNewerPackage(
{},
),

View File

@ -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<Tags>,
/// Whether files newer than this timestamp should be excluded or not.
exclude_newer: Option<ExcludeNewerTimestamp>,
exclude_newer: Option<ExcludeNewerValue>,
/// Which yanked versions are allowed
allowed_yanks: AllowedYanks,
/// The hashes of allowed distributions.

View File

@ -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());
}
}
}

View File

@ -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<IndexStrategy>,
pub keyring_provider: Option<KeyringProviderType>,
pub config_settings: Option<ConfigSettings>,
pub exclude_newer: Option<ExcludeNewerTimestamp>,
pub exclude_newer: Option<ExcludeNewerValue>,
pub link_mode: Option<LinkMode>,
pub compile_bytecode: Option<bool>,
pub reinstall: Option<Reinstall>,
@ -401,7 +401,7 @@ pub struct ResolverInstallerOptions {
pub build_isolation: Option<BuildIsolation>,
pub extra_build_dependencies: Option<ExtraBuildDependencies>,
pub extra_build_variables: Option<ExtraBuildVariables>,
pub exclude_newer: Option<ExcludeNewerTimestamp>,
pub exclude_newer: Option<ExcludeNewerValue>,
pub exclude_newer_package: Option<ExcludeNewerPackage>,
pub link_mode: Option<LinkMode>,
pub compile_bytecode: Option<bool>,
@ -815,7 +815,7 @@ pub struct ResolverInstallerSchema {
exclude-newer = "2006-12-02T02:07:43Z"
"#
)]
pub exclude_newer: Option<ExcludeNewerTimestamp>,
pub exclude_newer: Option<ExcludeNewerValue>,
/// 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<ExcludeNewerTimestamp>,
pub exclude_newer: Option<ExcludeNewerValue>,
/// 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<BuildIsolation>,
pub extra_build_dependencies: Option<ExtraBuildDependencies>,
pub extra_build_variables: Option<ExtraBuildVariables>,
pub exclude_newer: Option<ExcludeNewerTimestamp>,
pub exclude_newer: Option<ExcludeNewerValue>,
pub exclude_newer_package: Option<ExcludeNewerPackage>,
pub link_mode: Option<LinkMode>,
pub compile_bytecode: Option<bool>,
@ -2074,7 +2074,7 @@ pub struct OptionsWire {
no_build_isolation_package: Option<Vec<PackageName>>,
extra_build_dependencies: Option<ExtraBuildDependencies>,
extra_build_variables: Option<ExtraBuildVariables>,
exclude_newer: Option<ExcludeNewerTimestamp>,
exclude_newer: Option<ExcludeNewerValue>,
exclude_newer_package: Option<ExcludeNewerPackage>,
link_mode: Option<LinkMode>,
compile_bytecode: Option<bool>,

View File

@ -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";

View File

@ -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));
}

View File

@ -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 {

View File

@ -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")

File diff suppressed because it is too large Load Diff

View File

@ -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;

View File

@ -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 <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 <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 <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 <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(())

View File

@ -1,5 +1,5 @@
---
source: crates/uv/tests/ecosystem.rs
source: crates/uv/tests/it/ecosystem.rs
expression: snapshot
---
success: true

View File

@ -1,5 +1,5 @@
---
source: crates/uv/tests/ecosystem.rs
source: crates/uv/tests/it/ecosystem.rs
expression: snapshot
---
success: true

View File

@ -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]

View File

@ -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

View File

@ -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"

2
uv.schema.json generated
View File

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