repomatic.deps.cooldown module¶

The exclude-newer cooldown and its per-package bypasses.

[tool.uv] exclude-newer holds every resolution a window behind the index, and an exclude-newer-package entry lets one package through ahead of it. This module owns that lifecycle:

  • ExemptionValue reads one entry, whatever form it is written in.

  • UvSettings edits the [tool.uv] table: each command reads pyproject.toml once, and writes it once.

  • The prune and freeze passes age the entries against the lock.

  • The forecasts date the day each freeze clears.

  • compute_held_back_packages() probes the releases the window holds back.

  • sync_uv_lock() drives all of it for sync-uv-lock.

The markdown rendering of these results lives in repomatic.deps.report.

repomatic.deps.cooldown.EXEMPTION_KEY = 'exclude-newer-package'¶

The [tool.uv] key holding the per-package bypasses.

repomatic.deps.cooldown.LIFT_SPAN = '0 day'¶

The entry value that lifts the cooldown for a package for good.

Written for a package the lock holds from a git or path source: it has no upload time, so no freeze can hold it.

repomatic.deps.cooldown.resolve_exclude_newer_span(value)[source]¶

Resolve a [tool.uv].exclude-newer value to the width of its window.

The rolling half of resolve_exclude_newer_cutoff(). Two of the three forms uv accepts name a span, so the cutoff moves with every resolution and a release inside the window ages out of it on a date anyone can compute: that date is what the cooldown forecasts report. The third form, an absolute timestamp, names a fixed instant no release ever ages past, so it has no span and nothing to forecast.

Parameters:

value (str) – The string read from [tool.uv].exclude-newer.

Return type:

timedelta | None

Returns:

The window width, or None when value is empty or absolute.

repomatic.deps.cooldown.resolve_exclude_newer_cutoff(value, now=None)[source]¶

Resolve a [tool.uv].exclude-newer value to an absolute cutoff datetime.

uv accepts three forms in this field:

  • A “friendly” duration (24 hours, 30 minutes, 1 day, 1 week): subtracted from the current UTC time.

  • An ISO 8601 duration (PT24H, P7D, P30D, P1W, combinations like P1DT2H): subtracted from the current UTC time.

  • An RFC 3339 / ISO 8601 timestamp (2026-03-18T16:39:02Z): returned verbatim as the cutoff.

Forms are tried in the order above so a duration is never mistaken for a timestamp.

Parameters:
  • value (str) – The string read from [tool.uv].exclude-newer in pyproject.toml.

  • now (datetime | None) – The instant a span counts back from. Read from the clock when None.

Return type:

datetime | None

Returns:

An absolute cutoff datetime, or None if value is empty or matches none of the recognized forms.

repomatic.deps.cooldown.date_to_utc_cutoff(day)[source]¶

Render an exclude-newer-package cutoff date as an explicit UTC instant.

Warning

uv reads a bare YYYY-MM-DD in exclude-newer-package as the start of the following day in the locking machine’s local timezone, then writes that absolute instant into uv.lock’s [options.exclude-newer-package] block. The same date therefore lands as a different timestamp depending on where uv lock ran: 2026-06-13 becomes 2026-06-14T00:00:00Z on a UTC CI runner but 2026-06-13T20:00:00Z on a UTC+4 laptop. Every local lock then flips the value one way and every CI lock flips it back: an endless sync-uv-lock ping-pong.

Pinning the cutoff to that same next-day-midnight boundary expressed in UTC removes the ambiguity: uv stores a full RFC 3339 timestamp verbatim, identically on every machine.

Parameters:

day (date) – The cutoff date (the bare date uv would otherwise expand).

Return type:

str

Returns:

A YYYY-MM-DDT00:00:00Z timestamp at the start of the day after day, matching uv’s exclusive end-of-day expansion pinned to UTC.

repomatic.deps.cooldown.freeze_cutoff_after(day)[source]¶

The exclude-newer-package cutoff that holds a version uploaded on day.

A cooldown bypass must hold the locked version, not track the latest release, which is what a "0 day" span does: it disables the cooldown, so uv lock --upgrade keeps pulling newer releases and the entry never ages out. The cutoff this returns sits a little past the upload, so uv keeps that version and rejects releases published after the window.

The cutoff rounds up to a whole-day UTC boundary through date_to_utc_cutoff(): the second UTC midnight after the upload, so a version uploaded on 2026-07-01 freezes at 2026-07-03T00:00:00Z. A full UTC timestamp, rather than a bare YYYY-MM-DD date that uv re-expands per locking-machine timezone, stops uv.lock from ping-ponging between local and CI locks. Rounding up by a day keeps the held version inside its own window: uv’s cutoff is exclusive (“uploaded prior to”) and PyPI upload times carry sub-second precision, so a cutoff pinned at the upload instant risks landing before it and excluding the very version meant to be held.

Note

The margin makes the freeze hold a window, not a single version: a release published later the same day, or anywhere on the following calendar day, falls before the cutoff and is adopted on the next uv lock --upgrade. This is deliberate and accepted: the absorbed release is a strictly newer build of a package just chosen to bypass the global cooldown, so taking its immediate follow-up is low-risk, and the dependency floor still sets the minimum acceptable version. Holding exactly the locked version would mean pinning the cutoff to the upload instant plus an epsilon, trading this safety margin for the precision fragility above.

The single source of that policy, shared with repomatic.deps.sources.ReleaseSwap.

Parameters:

day (date) – The held version’s upload date.

Return type:

str

Returns:

A YYYY-MM-DDT00:00:00Z cutoff timestamp.

class repomatic.deps.cooldown.ExemptionKind(*values)[source]¶

Bases: Enum

The forms an exclude-newer-package value takes.

LIFT = 'lift'¶

The false boolean: the cooldown is lifted for the package.

uv’s spelling of what a zero span says, so every pass reads it as one.

SPAN = 'span'¶

A relative span (0 day, P7D), counted back from each resolution.

DATE = 'date'¶

A bare YYYY-MM-DD date, which uv expands per locking-machine timezone.

See date_to_utc_cutoff().

TIMESTAMP = 'timestamp'¶

A full RFC 3339 instant: a freeze.

class repomatic.deps.cooldown.ExemptionValue(raw, kind, span=None, day=None, instant=None)[source]¶

Bases: object

One exclude-newer-package value, read the way uv reads it.

The one place that tells the forms apart, so the prune, the freeze, the forecasts and the admission check all classify an entry the same way.

raw: object¶

The value as written: a string, or the false boolean.

kind: ExemptionKind | None¶

The form of raw, or None when no form matches.

span: timedelta | None = None¶

The width of a ExemptionKind.SPAN value.

day: date | None = None¶

The date of a ExemptionKind.DATE value.

instant: datetime | None = None¶

The UTC instant a ExemptionKind.TIMESTAMP value names, or the UTC end of the day a ExemptionKind.DATE value names.

classmethod parse(raw)[source]¶

Classify a raw value read from [tool.uv].exclude-newer-package.

Return type:

ExemptionValue

property tracks_latest: bool¶

Whether the entry follows the latest release, rather than holding one.

A lift and a span both do. That is what separates a bypass, which the freeze pass pins, from a freeze, which already holds.

property fixed_cutoff: datetime | None¶

The instant a freeze stops admitting releases.

Only a full timestamp has one this package trusts. A bare date names an instant that depends on the timezone of the locking machine, so the freeze pass pins it to UTC first, and the next run reads the pinned form. A lift and a span track the latest release, and have none.

property pinned: str | None¶

The explicit UTC timestamp of a bare date, None for other kinds.

admits(released, now)[source]¶

Whether the entry admits a release dated released.

Reads each form the way uv does, so the answer matches what an install under the entry resolves:

  • A lift admits every release.

  • A span admits every upload older than the span.

  • A bare date admits uploads up to the end of that day, read in UTC as date_to_utc_cutoff() explains.

  • A full timestamp admits uploads before that instant.

A datasource reports the day of a release, not the instant of its upload. So the entry admits the release only when its cutoff is at or after the end of that day, or at or after now when now comes first: no upload of the release is later than either. A cutoff inside the release day can admit the release or not, and it counts as not.

Parameters:
  • released (date) – The release date the datasource reports.

  • now (datetime) – The current instant, which a span counts back from.

Return type:

bool

Returns:

True when every upload of the release is older than the cutoff. False for an earlier cutoff, and for a value no form matches.

repomatic.deps.cooldown.exemption_admits(value, released, now)[source]¶

Whether a raw exclude-newer-package value admits a release.

For a caller holding the value as tomlrt read it. See ExemptionValue.admits().

Return type:

bool

class repomatic.deps.cooldown.UvSettings(path, original, text=None)[source]¶

Bases: object

The [tool.uv] table of one pyproject.toml, read once, written once.

Every pass of the cooldown lifecycle edits the same in-memory document, and save() writes it back. A command therefore reads the file once and writes it at most once, however many passes it runs.

A missing file reads as an empty one, and is never created: there is no table to edit.

path¶

The pyproject.toml the settings are read from and saved to.

doc: Any¶

The editable document, formatting trivia included.

classmethod load(path, text=None)[source]¶

Read the settings of path.

Parameters:
  • path (Path) – The pyproject.toml file.

  • text (str | None) – The content to edit, when the caller already changed it in memory (the bundled [tool.uv] policy pins, say). Defaults to the file’s own content. save() compares against the file either way.

Return type:

UvSettings

property table: Any¶

The [tool.uv] table, or an empty mapping when the file has none.

property window: str¶

The project’s own exclude-newer window, verbatim, or empty when unset.

Caution

Always pass this back to uv lock and uv sync as an explicit --exclude-newer flag rather than letting uv pick the value up from pyproject.toml on its own. CI exports a UV_EXCLUDE_NEWER covering every ad-hoc install (see docs/workflows.md § Install-time cooldown), and that environment variable outranks [tool.uv]: left implicit, a CI lock would resolve against the ambient window while a developer running the same command locally resolves against this one, and sync-uv-lock would churn between the two. A CLI flag outranks the environment, which pins the project’s own policy. lock_command() does it.

entries()[source]¶

The exclude-newer-package entries, keyed as the project spells them.

Return type:

dict[str, ExemptionValue]

lock_command(*extra, exclude_newer_packages=None)[source]¶

A uv lock argv carrying the project’s own window. See window.

Parameters:
  • extra (str) – Extra uv lock arguments.

  • exclude_newer_packages (Mapping[str, str] | None) – Per-package windows for this run only.

Return type:

list[str]

prune(lock, now)[source]¶

Remove the stale exclude-newer-package entries.

Todo

Delete this pruning pass once uv prunes stale exclude-newer-package entries natively: uv#18792.

An entry is stale when its locked version’s upload time falls before the exclude-newer cutoff, meaning uv lock --upgrade would resolve to the same (or newer) version without the override.

Packages the lock holds without an upload time (git or path sources) are treated as permanent exemptions and never pruned.

A package the lock does not hold at all has no locked version to age: a tool the registry pins outside the lockfile, or a dependency the project dropped. Its entry ages by its own cutoff instead. A fixed timestamp is stale once the exclude-newer cutoff has passed it, because the entry then admits no release the cooldown still withholds. A span or a lift never ages, and stays.

Caution

A fixed cutoff replaces the rolling window for its package: uv resolves nothing uploaded after it. An entry left in place past its date therefore blocks every later release of that package, which is why this pass removes it and does not leave the cleanup to a reviewer.

Parameters:
  • lock (LockFile) – The lock the entries hold their versions in.

  • now (datetime) – The instant the rolling window counts back from.

Return type:

dict[str, ExemptionValue]

Returns:

The pruned entries, keyed by name, with the values they had.

freeze(lock)[source]¶

Rewrite the bypasses that track the latest release into freezes.

A span or a lift tells uv to ignore the cooldown and resolve to the latest release, so the package keeps moving and prune() never sees its locked version age out. Rewriting the entry as the freeze_cutoff_after() cutoff of the locked version instead holds the package: releases past the freeze window are excluded until the held version ages past the global cooldown, at which point the entry is pruned and the package rejoins normal resolution.

Also pins any bare YYYY-MM-DD entry to the equivalent explicit UTC timestamp (see date_to_utc_cutoff()), so uv stops re-expanding it per locking-machine timezone. Entries already carrying a full timestamp are left untouched, which keeps the pass idempotent. Packages with no upload time in the lock (git or path sources) keep their span: they have no release to freeze against. An entry finds its locked release in any PEP 503 spelling of its key, and keeps the key as written.

Parameters:

lock (LockFile) – The lock holding the versions to freeze at.

Return type:

set[str]

Returns:

The names of the entries rewritten.

upsert(entries)[source]¶

Insert or replace exclude-newer-package entries.

Parameters:

entries (Mapping[str, str]) – Package name to cutoff value (a freeze timestamp or a span). Existing entries for the same names are overwritten.

Return type:

bool

Returns:

True when the table changed. False when nothing needed to, and when the project has no [tool.uv] cooldown to attach the entries to.

exempt(names, lock, now)[source]¶

Freeze each of names whose locked release is still inside the window.

A package needs an entry only when its locked version was uploaded after the exclude-newer cutoff, so that a regular uv lock --upgrade would not resolve it. Each new entry holds that version: see freeze_cutoff_after(). A package with no upload time in the lock (a git or path source) gets a permanent LIFT_SPAN instead. A project with no window, or one that does not parse, exempts every name to be safe. A name that already has an entry keeps it.

Parameters:
  • names (Iterable[str]) – Package names, in any PEP 503 spelling. Each entry keeps the spelling it is given.

  • lock (LockFile) – The lock holding the versions to freeze at.

  • now (datetime) – The instant the rolling window counts back from.

Return type:

set[str]

Returns:

The names of the entries added.

comment_mentions(names)[source]¶

Return the names the comments on exclude-newer-package still mention.

Pruning rebuilds the inline table but keeps the comment block above its key, because that block describes the table as a whole. A sentence written to justify one entry therefore outlives the entry, and reads as a live exemption to the next person who opens the file. Rewriting prose is not safe to automate, so sync_uv_lock() reports these names for a human to edit. A table pruned to nothing loses its comments along with its key, so only a partial prune leaves one behind.

A name matches case-insensitively and with any separator PEP 503 treats as equal: lemon-curd also finds lemon_curd.

Parameters:

names (Iterable[str]) – Package names to look for, typically the entries pruned.

Return type:

list[str]

Returns:

The names the comments still mention, sorted.

save()[source]¶

Write the document back, when it differs from the file on disk.

Return type:

bool

Returns:

True when the file was written.

repomatic.deps.cooldown.active_forecasts(entries, lock, now)[source]¶

Forecast when each active freeze clears.

Covers the freezes alone. A span or a lift tracks the latest release: it is a permanent exemption for a package with no release to age against (a git or path source), so it never expires and would repeat a static row in every report. Auditing those is left to the dependency review (see docs/dependencies.md).

Parameters:
  • entries (Mapping[str, ExemptionValue]) – The exclude-newer-package entries.

  • lock (LockFile) – The lock that describes the state the report is about.

  • now (datetime) – Reference instant for the relative hints.

Return type:

list[BypassForecast]

Returns:

Forecasts sorted by package name.

repomatic.deps.cooldown.pruned_forecasts(pruned, lock, now)[source]¶

Snapshot the entries a prune cleared, for their (cleared) rows.

lock must be the pre-upgrade lock: once an entry is pruned the package rejoins normal resolution, so the post-upgrade lock may hold a newer version whose upload time would misstate what the freeze held and when it aged out.

Parameters:
Return type:

list[BypassForecast]

Returns:

One record per entry, sorted by name, with the version the freeze held and the (past) day it expired.

repomatic.deps.cooldown.compute_held_back_packages(lock_path, lock, bypassed)[source]¶

Find releases withheld from the lock only by the cooldown.

Re-resolves the lock with the cooldown lifted and diffs the result against the in-cooldown lock. Both the global exclude-newer cutoff and every per-package exclude-newer-package freeze are raised to the current instant, so a release blocked by a cooldown-bypass freeze is reported like any cooldown-blocked one. That keeps the section’s wording and “Eligible” math honest: UvSettings.prune() drops a freeze as soon as its held version exits the window, so any release a freeze still blocks is necessarily inside the global window too, and becomes lockable on its own cooldown-exit date. Versions pinned by a specifier or capped by a requires-python bound resolve identically with and without the lift, so they are excluded.

The probe writes uv.lock and restores it byte-for-byte in a finally, so the canonical in-cooldown lock is left untouched even when resolution or parsing fails. Its output is captured, since the probe runs under the sync-deps progress trail.

Note

This runs a second uv lock resolution. It is the report’s only cost and is skipped by sync-uv-lock --no-held-back.

Parameters:
  • lock_path (Path) – Path to the uv.lock file.

  • lock (LockFile) – The parsed lock on disk, the in-cooldown state.

  • bypassed (Iterable[str]) – The packages carrying an exclude-newer-package entry.

Return type:

list[HeldBackPackage]

Returns:

Held-back packages sorted by name. Empty when the probe fails or nothing is withheld.

class repomatic.deps.cooldown.SyncResult(changes, lock, pruned_bypasses=<factory>, frozen_bypasses=<factory>, stale_bypass_comments=<factory>, bypass_forecasts=<factory>)[source]¶

Bases: object

The outcome of sync_uv_lock().

changes: list[tuple[str, str, str]]¶

Version changes as (name, old_version, new_version) tuples.

lock: LockFile¶

The lock the run leaves on disk.

pruned_bypasses: list[BypassForecast]¶

Expired exclude-newer-package entries removed from pyproject.toml, each with the version and (past) expiry the freeze had, snapshot against the pre-upgrade lock by pruned_forecasts().

frozen_bypasses: list[str]¶

exclude-newer-package entries rewritten into freeze cutoffs.

stale_bypass_comments: list[str]¶

Pruned entries the comment above exclude-newer-package still names, found by UvSettings.comment_mentions().

bypass_forecasts: list[BypassForecast]¶

Active cooldown-bypass freezes with their expiry forecasts (post-run state).

repomatic.deps.cooldown.sync_uv_lock(lock_path, settings=None)[source]¶

Re-lock with --upgrade and report version changes.

First prunes the stale exclude-newer-package entries from pyproject.toml and freezes the survivors at their locked version, then runs uv lock --upgrade to update transitive dependencies.

Note

When the upgrade changes no package versions and was not driven by a pyproject.toml cooldown edit, the pre-upgrade lock is restored byte-for-byte. uv lock --upgrade otherwise rewrites semantically equivalent environment markers in a form that varies by uv version and by whether the resolution ran fresh or incrementally: a transitive dependency reachable only below Python 3.11 has its python_full_version < '3.13' marker flipped to the equivalent < '3.11', or back, with no change to the resolved package set. Committed by one machine and re-flipped by the next, that cosmetic churn drives an endless sync-uv-lock ping-pong of empty PRs. Since the job exists only to move dependency versions forward, a run that moves none has nothing to contribute and is discarded. This mirrors the timezone-pinning fix in date_to_utc_cutoff().

Parameters:
  • lock_path (Path) – Path to the uv.lock file.

  • settings (UvSettings | None) – The project’s settings, when the caller already loaded them. Read from the pyproject.toml beside the lock otherwise.

Return type:

SyncResult

Returns:

The version changes, the lock left on disk, and the cooldown-bypass lifecycle: entries pruned, frozen, and still active with their expiry forecasts.