From 191e5f5bf473755fd9f00dbbed9f36146c75e297 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Tue, 7 Apr 2026 11:32:04 -0400 Subject: [PATCH] Store relative timestamps in tool receipts (#18901) ## Summary Tool receipts were only storing the absolute timestamp, not the relative span. So upgrades, `--outdated`, etc., were operating off the fixed cutoff. We now follow the approach used in the lockfile, whereby we store the cutoff and the relative span, and use that to recompute offsets. --- crates/uv-resolver/src/exclude_newer.rs | 42 +++++++ crates/uv-resolver/src/lib.rs | 5 +- crates/uv-settings/src/settings.rs | 142 +++++++++++++++++++++++- crates/uv-tool/src/tool.rs | 14 ++- crates/uv/src/commands/tool/list.rs | 10 +- crates/uv/src/commands/tool/upgrade.rs | 1 + crates/uv/tests/it/tool_install.rs | 37 ++++++ crates/uv/tests/it/tool_list.rs | 61 ++++++++-- crates/uv/tests/it/tool_upgrade.rs | 58 ++++++++++ uv.schema.json | 4 +- 10 files changed, 347 insertions(+), 27 deletions(-) diff --git a/crates/uv-resolver/src/exclude_newer.rs b/crates/uv-resolver/src/exclude_newer.rs index c2f10c60e6..bf8bae4d72 100644 --- a/crates/uv-resolver/src/exclude_newer.rs +++ b/crates/uv-resolver/src/exclude_newer.rs @@ -8,6 +8,7 @@ use jiff::{Span, Timestamp, ToSpan, Unit, tz::TimeZone}; use rustc_hash::FxHashMap; use serde::Deserialize; use serde::de::value::MapAccessDeserializer; +use serde::ser::SerializeMap; use uv_normalize::PackageName; #[derive(Debug, Clone, PartialEq, Eq)] @@ -218,6 +219,24 @@ impl serde::Serialize for ExcludeNewerValue { } } +pub struct ExcludeNewerValueWithSpanRef<'a>(pub &'a ExcludeNewerValue); + +impl serde::Serialize for ExcludeNewerValueWithSpanRef<'_> { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + if let Some(span) = self.0.span() { + let mut map = serializer.serialize_map(Some(2))?; + map.serialize_entry("timestamp", &self.0.timestamp())?; + map.serialize_entry("span", span)?; + map.end() + } else { + self.0.timestamp().serialize(serializer) + } + } +} + impl<'de> serde::Deserialize<'de> for ExcludeNewerValue { fn deserialize(deserializer: D) -> Result where @@ -620,6 +639,29 @@ impl serde::Serialize for PackageExcludeNewer { } } +pub fn serialize_exclude_newer_package_with_spans( + value: &Option, + serializer: S, +) -> Result +where + S: serde::Serializer, +{ + let Some(value) = value else { + return serializer.serialize_none(); + }; + + 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) => { + map.serialize_entry(name, &ExcludeNewerValueWithSpanRef(value.as_ref()))?; + } + } + } + map.end() +} + #[derive(Debug, Clone, PartialEq, Eq)] pub enum PackageExcludeNewerChange { Disabled { was: ExcludeNewerValue }, diff --git a/crates/uv-resolver/src/lib.rs b/crates/uv-resolver/src/lib.rs index 27cb2512c9..f7ac1e8a47 100644 --- a/crates/uv-resolver/src/lib.rs +++ b/crates/uv-resolver/src/lib.rs @@ -2,8 +2,9 @@ pub use dependency_mode::DependencyMode; pub use error::{ErrorTree, NoSolutionError, NoSolutionHeader, ResolveError, SentinelRange}; pub use exclude_newer::{ ExcludeNewer, ExcludeNewerChange, ExcludeNewerPackage, ExcludeNewerPackageChange, - ExcludeNewerPackageEntry, ExcludeNewerValue, ExcludeNewerValueChange, PackageExcludeNewer, - PackageExcludeNewerChange, + ExcludeNewerPackageEntry, ExcludeNewerSpan, ExcludeNewerValue, ExcludeNewerValueChange, + ExcludeNewerValueWithSpanRef, PackageExcludeNewer, PackageExcludeNewerChange, + serialize_exclude_newer_package_with_spans, }; pub use exclusions::Exclusions; pub use flat_index::{FlatDistributions, FlatIndex}; diff --git a/crates/uv-settings/src/settings.rs b/crates/uv-settings/src/settings.rs index 5521cc14ef..13e93f5967 100644 --- a/crates/uv-settings/src/settings.rs +++ b/crates/uv-settings/src/settings.rs @@ -19,8 +19,8 @@ use uv_pypi_types::{SupportedEnvironments, VerbatimParsedUrl}; use uv_python::{PythonDownloads, PythonPreference, PythonVersion}; use uv_redacted::DisplaySafeUrl; use uv_resolver::{ - AnnotationStyle, ExcludeNewer, ExcludeNewerPackage, ExcludeNewerValue, ForkStrategy, - PrereleaseMode, ResolutionMode, + AnnotationStyle, ExcludeNewer, ExcludeNewerPackage, ExcludeNewerSpan, ExcludeNewerValue, + ForkStrategy, PrereleaseMode, ResolutionMode, serialize_exclude_newer_package_with_spans, }; use uv_torch::TorchMode; use uv_workspace::pyproject::ExtraBuildDependencies; @@ -499,6 +499,25 @@ pub struct ResolverInstallerOptions { pub no_binary_package: Option>, } +impl ResolverInstallerOptions { + /// Recompute any relative exclude-newer values against the current time. + #[must_use] + pub fn recompute_exclude_newer(mut self) -> Self { + let exclude_newer = ExcludeNewer::new( + self.exclude_newer.take(), + self.exclude_newer_package.take().unwrap_or_default(), + ) + .recompute(); + self.exclude_newer = exclude_newer.global; + self.exclude_newer_package = if exclude_newer.package.is_empty() { + None + } else { + Some(exclude_newer.package) + }; + self + } +} + impl From for ResolverInstallerOptions { fn from(value: ResolverInstallerSchema) -> Self { let ResolverInstallerSchema { @@ -2130,6 +2149,41 @@ pub struct ToolOptions { pub torch_backend: Option, } +/// The on-disk representation of [`ToolOptions`] in a tool receipt. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "kebab-case")] +pub struct ToolOptionsWire { + pub index: Option>, + pub index_url: Option, + pub extra_index_url: Option>, + pub no_index: Option, + pub find_links: Option>, + pub index_strategy: Option, + pub keyring_provider: Option, + pub resolution: Option, + pub prerelease: Option, + pub fork_strategy: Option, + pub dependency_metadata: Option>, + pub config_settings: Option, + pub config_settings_package: Option, + pub build_isolation: Option, + pub extra_build_dependencies: Option, + pub extra_build_variables: Option, + pub exclude_newer: Option, + pub exclude_newer_span: Option, + #[serde(serialize_with = "serialize_exclude_newer_package_with_spans")] + pub exclude_newer_package: Option, + pub link_mode: Option, + pub compile_bytecode: Option, + pub no_sources: Option, + pub no_sources_package: Option>, + pub no_build: Option, + pub no_build_package: Option>, + pub no_binary: Option, + pub no_binary_package: Option>, + pub torch_backend: Option, +} + impl From for ToolOptions { fn from(value: ResolverInstallerOptions) -> Self { Self { @@ -2169,6 +2223,90 @@ impl From for ToolOptions { } } +impl From for ToolOptions { + fn from(value: ToolOptionsWire) -> Self { + let exclude_newer = value.exclude_newer.map(|exclude_newer| { + if exclude_newer.span().is_none() { + ExcludeNewerValue::new(exclude_newer.timestamp(), value.exclude_newer_span) + } else { + exclude_newer + } + }); + + Self { + index: value.index, + index_url: value.index_url, + extra_index_url: value.extra_index_url, + no_index: value.no_index, + find_links: value.find_links, + index_strategy: value.index_strategy, + keyring_provider: value.keyring_provider, + resolution: value.resolution, + prerelease: value.prerelease, + fork_strategy: value.fork_strategy, + dependency_metadata: value.dependency_metadata, + config_settings: value.config_settings, + config_settings_package: value.config_settings_package, + build_isolation: value.build_isolation, + extra_build_dependencies: value.extra_build_dependencies, + extra_build_variables: value.extra_build_variables, + exclude_newer, + exclude_newer_package: value.exclude_newer_package, + link_mode: value.link_mode, + compile_bytecode: value.compile_bytecode, + no_sources: value.no_sources, + no_sources_package: value.no_sources_package, + no_build: value.no_build, + no_build_package: value.no_build_package, + no_binary: value.no_binary, + no_binary_package: value.no_binary_package, + torch_backend: value.torch_backend, + } + } +} + +impl From for ToolOptionsWire { + fn from(value: ToolOptions) -> Self { + let (exclude_newer, exclude_newer_span) = value + .exclude_newer + .map(ExcludeNewerValue::into_parts) + .map_or((None, None), |(timestamp, span)| { + (Some(ExcludeNewerValue::from(timestamp)), span) + }); + + Self { + index: value.index, + index_url: value.index_url, + extra_index_url: value.extra_index_url, + no_index: value.no_index, + find_links: value.find_links, + index_strategy: value.index_strategy, + keyring_provider: value.keyring_provider, + resolution: value.resolution, + prerelease: value.prerelease, + fork_strategy: value.fork_strategy, + dependency_metadata: value.dependency_metadata, + config_settings: value.config_settings, + config_settings_package: value.config_settings_package, + build_isolation: value.build_isolation, + extra_build_dependencies: value.extra_build_dependencies, + extra_build_variables: value.extra_build_variables, + exclude_newer, + exclude_newer_span, + exclude_newer_package: value.exclude_newer_package, + link_mode: value.link_mode, + compile_bytecode: value.compile_bytecode, + no_sources: value.no_sources, + no_sources_package: value.no_sources_package, + no_build: value.no_build, + no_build_package: value.no_build_package, + no_binary: value.no_binary, + no_binary_package: value.no_binary_package, + torch_backend: value.torch_backend, + } + } +} + impl From for ResolverInstallerOptions { fn from(value: ToolOptions) -> Self { Self { diff --git a/crates/uv-tool/src/tool.rs b/crates/uv-tool/src/tool.rs index e61d4bc6df..02498c8038 100644 --- a/crates/uv-tool/src/tool.rs +++ b/crates/uv-tool/src/tool.rs @@ -9,7 +9,7 @@ use uv_fs::{PortablePath, Simplified}; use uv_normalize::PackageName; use uv_pypi_types::VerbatimParsedUrl; use uv_python::PythonRequest; -use uv_settings::ToolOptions; +use uv_settings::{ToolOptions, ToolOptionsWire}; /// A tool entry. #[derive(Debug, Clone, Deserialize)] @@ -49,7 +49,7 @@ struct ToolWire { python: Option, entrypoints: Vec, #[serde(default)] - options: ToolOptions, + options: ToolOptionsWire, } #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] @@ -76,7 +76,7 @@ impl From for ToolWire { build_constraint_dependencies: tool.build_constraints, python: tool.python, entrypoints: tool.entrypoints, - options: tool.options, + options: tool.options.into(), } } } @@ -100,7 +100,7 @@ impl TryFrom for Tool { build_constraints: tool.build_constraint_dependencies, python: tool.python, entrypoints: tool.entrypoints, - options: tool.options, + options: tool.options.into(), }) } } @@ -333,8 +333,10 @@ impl Tool { }); if self.options != ToolOptions::default() { - let serialized = - serde::Serialize::serialize(&self.options, toml_edit::ser::ValueSerializer::new())?; + let serialized = serde::Serialize::serialize( + &ToolOptionsWire::from(self.options.clone()), + toml_edit::ser::ValueSerializer::new(), + )?; let Value::InlineTable(serialized) = serialized else { return Err(toml_edit::ser::Error::Custom( "Expected an inline table".to_string(), diff --git a/crates/uv/src/commands/tool/list.rs b/crates/uv/src/commands/tool/list.rs index 8f8d3e26f6..fece0afcde 100644 --- a/crates/uv/src/commands/tool/list.rs +++ b/crates/uv/src/commands/tool/list.rs @@ -130,9 +130,13 @@ pub(crate) async fn list( let filesystem = filesystem.clone(); async move { let capabilities = IndexCapabilities::default(); - let settings = ResolverInstallerSettings::from(args.combine( - ResolverInstallerOptions::from(tool.options().clone()).combine(filesystem), - )); + let settings = ResolverInstallerSettings::from( + args.combine( + ResolverInstallerOptions::from(tool.options().clone()) + .recompute_exclude_newer() + .combine(filesystem), + ), + ); let interpreter = tool_env.environment().interpreter(); let client = RegistryClientBuilder::new( diff --git a/crates/uv/src/commands/tool/upgrade.rs b/crates/uv/src/commands/tool/upgrade.rs index bf4813b7b3..767d8dd4d8 100644 --- a/crates/uv/src/commands/tool/upgrade.rs +++ b/crates/uv/src/commands/tool/upgrade.rs @@ -318,6 +318,7 @@ async fn upgrade_tool( // Resolve the appropriate settings, preferring: CLI > receipt > user. let options = args.clone().combine( ResolverInstallerOptions::from(existing_tool_receipt.options().clone()) + .recompute_exclude_newer() .combine(filesystem.clone()), ); let settings = ResolverInstallerSettings::from(options.clone()); diff --git a/crates/uv/tests/it/tool_install.rs b/crates/uv/tests/it/tool_install.rs index fcf9f29fe4..0b72bd36dc 100644 --- a/crates/uv/tests/it/tool_install.rs +++ b/crates/uv/tests/it/tool_install.rs @@ -180,6 +180,43 @@ fn tool_install() { }); } +#[test] +fn tool_install_relative_exclude_newer_receipt_preserves_span() { + let context = uv_test::test_context!("3.12").with_filtered_exe_suffix(); + let tool_dir = context.temp_dir.child("tools"); + let bin_dir = context.temp_dir.child("bin"); + + context + .tool_install() + .arg("black==24.2.0") + .arg("--exclude-newer") + .arg("3 weeks") + .env_remove(EnvVars::UV_EXCLUDE_NEWER) + .env(EnvVars::UV_TEST_CURRENT_TIMESTAMP, "2024-05-01T00:00:00Z") + .env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str()) + .env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str()) + .env(EnvVars::PATH, bin_dir.as_os_str()) + .assert() + .success(); + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!(fs_err::read_to_string(tool_dir.join("black").join("uv-receipt.toml")).unwrap(), @r#" + [tool] + requirements = [{ name = "black", specifier = "==24.2.0" }] + entrypoints = [ + { name = "black", install-path = "[TEMP_DIR]/bin/black", from = "black" }, + { name = "blackd", install-path = "[TEMP_DIR]/bin/blackd", from = "black" }, + ] + + [tool.options] + exclude-newer = "2024-04-10T00:00:00Z" + exclude-newer-span = "P3W" + "#); + }); +} + #[test] fn tool_install_python_from_global_version_file() { let context = uv_test::test_context_with_versions!(&["3.11", "3.12", "3.13"]) diff --git a/crates/uv/tests/it/tool_list.rs b/crates/uv/tests/it/tool_list.rs index 8d55ad937a..f86a533b67 100644 --- a/crates/uv/tests/it/tool_list.rs +++ b/crates/uv/tests/it/tool_list.rs @@ -150,19 +150,19 @@ fn tool_list_outdated() { .success(); // With `--outdated`, the installed (older) version should be listed with the latest version. - let result = context - .tool_list() - .arg("--outdated") - .env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str()) - .env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str()) - .assert() - .success(); + uv_snapshot!(context.filters(), context.tool_list() + .arg("--outdated") + .env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str()) + .env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str()), @" + success: true + exit_code: 0 + ----- stdout ----- + black v24.2.0 [latest: 24.3.0] + - black + - blackd - let stdout = String::from_utf8(result.get_output().stdout.clone()).unwrap(); - assert!( - stdout.contains("black v24.2.0") && stdout.contains("[latest:"), - "Expected outdated output with latest version, got: {stdout}" - ); + ----- stderr ----- + "); } #[test] @@ -196,6 +196,43 @@ fn tool_list_outdated_respects_exclude_newer() { "); } +#[test] +fn tool_list_outdated_recomputes_relative_exclude_newer() { + let context = uv_test::test_context!("3.12").with_filtered_exe_suffix(); + let tool_dir = context.temp_dir.child("tools"); + let bin_dir = context.temp_dir.child("bin"); + + // Install `black` with a relative `exclude-newer` cutoff that initially resolves to 2024-03-01. + context + .tool_install() + .arg("black") + .arg("--exclude-newer") + .arg("3 weeks") + .env_remove(EnvVars::UV_EXCLUDE_NEWER) + .env(EnvVars::UV_TEST_CURRENT_TIMESTAMP, "2024-03-22T00:00:00Z") + .env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str()) + .env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str()) + .assert() + .success(); + + // Recompute the stored span at a later time so `black` is considered outdated. + uv_snapshot!(context.filters(), context.tool_list() + .arg("--outdated") + .env_remove(EnvVars::UV_EXCLUDE_NEWER) + .env(EnvVars::UV_TEST_CURRENT_TIMESTAMP, "2024-04-15T00:00:00Z") + .env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str()) + .env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str()), @" + success: true + exit_code: 0 + ----- stdout ----- + black v24.2.0 [latest: 24.3.0] + - black + - blackd + + ----- stderr ----- + "); +} + #[test] fn tool_list_outdated_cli_exclude_newer() { let context = uv_test::test_context!("3.12").with_filtered_exe_suffix(); diff --git a/crates/uv/tests/it/tool_upgrade.rs b/crates/uv/tests/it/tool_upgrade.rs index 2d42c0ab5d..566dd09dd4 100644 --- a/crates/uv/tests/it/tool_upgrade.rs +++ b/crates/uv/tests/it/tool_upgrade.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use assert_cmd::assert::OutputAssertExt; use assert_fs::prelude::*; use insta::assert_snapshot; @@ -141,6 +142,63 @@ fn tool_upgrade_name() { "); } +#[test] +fn tool_upgrade_recomputes_relative_exclude_newer() { + let context = uv_test::test_context!("3.12").with_filtered_exe_suffix(); + let tool_dir = context.temp_dir.child("tools"); + let bin_dir = context.temp_dir.child("bin"); + + context + .tool_install() + .arg("black") + .arg("--exclude-newer") + .arg("3 weeks") + .env_remove(EnvVars::UV_EXCLUDE_NEWER) + .env(EnvVars::UV_TEST_CURRENT_TIMESTAMP, "2024-03-22T00:00:00Z") + .env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str()) + .env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str()) + .env(EnvVars::PATH, bin_dir.as_os_str()) + .assert() + .success(); + + uv_snapshot!(context.filters(), context.tool_upgrade() + .arg("black") + .env_remove(EnvVars::UV_EXCLUDE_NEWER) + .env(EnvVars::UV_TEST_CURRENT_TIMESTAMP, "2024-04-15T00:00:00Z") + .env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str()) + .env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str()) + .env(EnvVars::PATH, bin_dir.as_os_str()), @" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Updated black v24.2.0 -> v24.3.0 + - black==24.2.0 + + black==24.3.0 + - packaging==23.2 + + packaging==24.0 + Installed 2 executables: black, blackd + "); + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!(fs_err::read_to_string(tool_dir.join("black").join("uv-receipt.toml")).unwrap(), @r#" + [tool] + requirements = [{ name = "black" }] + entrypoints = [ + { name = "black", install-path = "[TEMP_DIR]/bin/black", from = "black" }, + { name = "blackd", install-path = "[TEMP_DIR]/bin/blackd", from = "black" }, + ] + + [tool.options] + exclude-newer = "2024-03-25T00:00:00Z" + exclude-newer-span = "P3W" + "#); + }); +} + #[test] fn tool_upgrade_multiple_names() { let context = uv_test::test_context!("3.12") diff --git a/uv.schema.json b/uv.schema.json index 0542973568..9e0d5b23fc 100644 --- a/uv.schema.json +++ b/uv.schema.json @@ -185,7 +185,7 @@ } }, "exclude-newer": { - "description": "Limit candidate packages to those that were uploaded prior to the given date.\n\nAccepts RFC 3339 timestamps (e.g., `2006-12-02T02:07:43Z`), a \"friendly\" duration (e.g.,\n`24 hours`, `1 week`, `30 days`), or an ISO 8601 duration (e.g., `PT24H`, `P7D`, `P30D`).\n\nDurations do not respect semantics of the local time zone and are always resolved to a fixed\nnumber of seconds assuming that a day is 24 hours (e.g., DST transitions are ignored).\nCalendar units such as months and years are not allowed.", + "description": "Limit candidate packages to those that were uploaded prior to the given date.\n\nThe date is compared against the upload time of each individual distribution artifact\n(i.e., when each file was uploaded to the package index), not the release date of the\npackage version.\n\nAccepts RFC 3339 timestamps (e.g., `2006-12-02T02:07:43Z`), a \"friendly\" duration (e.g.,\n`24 hours`, `1 week`, `30 days`), or an ISO 8601 duration (e.g., `PT24H`, `P7D`, `P30D`).\n\nDurations do not respect semantics of the local time zone and are always resolved to a fixed\nnumber of seconds assuming that a day is 24 hours (e.g., DST transitions are ignored).\nCalendar units such as months and years are not allowed.", "anyOf": [ { "$ref": "#/definitions/ExcludeNewerValue" @@ -1255,7 +1255,7 @@ "type": ["boolean", "null"] }, "exclude-newer": { - "description": "Limit candidate packages to those that were uploaded prior to a given point in time.\n\nAccepts a superset of [RFC 3339](https://www.rfc-editor.org/rfc/rfc3339.html) (e.g.,\n`2006-12-02T02:07:43Z`). A full timestamp is required to ensure that the resolver will\nbehave consistently across timezones.", + "description": "Limit candidate packages to those that were uploaded prior to a given point in time.\n\nThe date is compared against the upload time of each individual distribution artifact\n(i.e., when each file was uploaded to the package index), not the release date of the\npackage version.\n\nAccepts a superset of [RFC 3339](https://www.rfc-editor.org/rfc/rfc3339.html) (e.g.,\n`2006-12-02T02:07:43Z`). A full timestamp is required to ensure that the resolver will\nbehave consistently across timezones.", "anyOf": [ { "$ref": "#/definitions/ExcludeNewerValue"