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.
This commit is contained in:
Charlie Marsh
2026-04-07 11:32:04 -04:00
committed by GitHub
parent 27dc5627d0
commit 191e5f5bf4
10 changed files with 347 additions and 27 deletions
+42
View File
@@ -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<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
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<D>(deserializer: D) -> Result<Self, D::Error>
where
@@ -620,6 +639,29 @@ impl serde::Serialize for PackageExcludeNewer {
}
}
pub fn serialize_exclude_newer_package_with_spans<S>(
value: &Option<ExcludeNewerPackage>,
serializer: S,
) -> Result<S::Ok, S::Error>
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 },
+3 -2
View File
@@ -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};
+140 -2
View File
@@ -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<Vec<PackageName>>,
}
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<ResolverInstallerSchema> for ResolverInstallerOptions {
fn from(value: ResolverInstallerSchema) -> Self {
let ResolverInstallerSchema {
@@ -2130,6 +2149,41 @@ pub struct ToolOptions {
pub torch_backend: Option<TorchMode>,
}
/// 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<Vec<Index>>,
pub index_url: Option<PipIndex>,
pub extra_index_url: Option<Vec<PipExtraIndex>>,
pub no_index: Option<bool>,
pub find_links: Option<Vec<PipFindLinks>>,
pub index_strategy: Option<IndexStrategy>,
pub keyring_provider: Option<KeyringProviderType>,
pub resolution: Option<ResolutionMode>,
pub prerelease: Option<PrereleaseMode>,
pub fork_strategy: Option<ForkStrategy>,
pub dependency_metadata: Option<Vec<StaticMetadata>>,
pub config_settings: Option<ConfigSettings>,
pub config_settings_package: Option<PackageConfigSettings>,
pub build_isolation: Option<BuildIsolation>,
pub extra_build_dependencies: Option<ExtraBuildDependencies>,
pub extra_build_variables: Option<ExtraBuildVariables>,
pub exclude_newer: Option<ExcludeNewerValue>,
pub exclude_newer_span: Option<ExcludeNewerSpan>,
#[serde(serialize_with = "serialize_exclude_newer_package_with_spans")]
pub exclude_newer_package: Option<ExcludeNewerPackage>,
pub link_mode: Option<LinkMode>,
pub compile_bytecode: Option<bool>,
pub no_sources: Option<bool>,
pub no_sources_package: Option<Vec<PackageName>>,
pub no_build: Option<bool>,
pub no_build_package: Option<Vec<PackageName>>,
pub no_binary: Option<bool>,
pub no_binary_package: Option<Vec<PackageName>>,
pub torch_backend: Option<TorchMode>,
}
impl From<ResolverInstallerOptions> for ToolOptions {
fn from(value: ResolverInstallerOptions) -> Self {
Self {
@@ -2169,6 +2223,90 @@ impl From<ResolverInstallerOptions> for ToolOptions {
}
}
impl From<ToolOptionsWire> 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<ToolOptions> 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<ToolOptions> for ResolverInstallerOptions {
fn from(value: ToolOptions) -> Self {
Self {
+8 -6
View File
@@ -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<PythonRequest>,
entrypoints: Vec<ToolEntrypoint>,
#[serde(default)]
options: ToolOptions,
options: ToolOptionsWire,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
@@ -76,7 +76,7 @@ impl From<Tool> 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<ToolWire> 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(),
+7 -3
View File
@@ -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(
+1
View File
@@ -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());
+37
View File
@@ -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"])
+49 -12
View File
@@ -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();
+58
View File
@@ -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")
+2 -2
View File
@@ -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"