# Copyright Kevin Deldycke <[email protected]> and contributors.
#
# This program is Free Software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
"""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:
- {class}`ExemptionValue` reads one entry, whatever form it is written in.
- {class}`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.
- {func}`compute_held_back_packages` probes the releases the window holds back.
- {func}`sync_uv_lock` drives all of it for `sync-uv-lock`.
The markdown rendering of these results lives in {mod}`repomatic.deps.report`.
"""
from __future__ import annotations
import logging
import re
import subprocess
from dataclasses import dataclass, field
from datetime import UTC, date, datetime, timedelta
from enum import Enum
import tomlrt
from click_extra import parse_friendly_duration, parse_iso8601_duration
from tomlrt import Table
from ..humanize import format_countdown, parse_iso_datetime, utc_midnight
from ..pyproject import uv_table
from ..versions import is_newer
from .lockfile import UTC_TIMESTAMP_FORMAT, LockFile, diff_lock_versions
from .report import (
BYPASS_NEEDS_RELEASE,
BypassForecast,
HeldBackPackage,
format_released,
)
from .requirements import pep503_pattern
from .uv import run_uv, uv_lock_command
TYPE_CHECKING = False
if TYPE_CHECKING:
from collections.abc import Iterable, Mapping
from pathlib import Path
from typing import Any
EXEMPTION_KEY = "exclude-newer-package"
"""The `[tool.uv]` key holding the per-package bypasses."""
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.
"""
_BARE_DATE_RE = re.compile(r"\d{4}-\d{2}-\d{2}")
"""A calendar date with no time of day."""
def _parse_span(value: str) -> timedelta | None:
"""Parse a relative span in either of the two forms uv accepts.
Each parser is tested against `None`: a `0 days` span parses to
`timedelta(0)`, which is falsy.
:param value: A friendly duration (`1 week`) or an ISO 8601 one (`P7D`).
:return: The span, or `None` when *value* is no span.
"""
span = parse_friendly_duration(value)
return span if span is not None else parse_iso8601_duration(value)
[docs]
def resolve_exclude_newer_span(value: str) -> timedelta | None:
"""Resolve a `[tool.uv].exclude-newer` value to the width of its window.
The rolling half of {func}`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.
:param value: The string read from `[tool.uv].exclude-newer`.
:return: The window width, or `None` when *value* is empty or absolute.
"""
if not value:
return None
return _parse_span(value)
[docs]
def resolve_exclude_newer_cutoff(
value: str, now: datetime | None = None
) -> datetime | None:
"""Resolve a `[tool.uv].exclude-newer` value to an absolute cutoff datetime.
[uv accepts three forms](https://github.com/astral-sh/uv/pull/19475)
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.
:param value: The string read from `[tool.uv].exclude-newer` in
`pyproject.toml`.
:param now: The instant a span counts back from. Read from the clock when
`None`.
:return: An absolute cutoff datetime, or `None` if *value* is empty
or matches none of the recognized forms.
"""
if not value:
return None
span = _parse_span(value)
if span is not None:
return (now or datetime.now(UTC)) - span
return parse_iso_datetime(value)
[docs]
def date_to_utc_cutoff(day: date) -> str:
"""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.
:param day: The cutoff date (the bare date uv would otherwise expand).
:return: 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.
"""
return utc_midnight(day + timedelta(days=1)).strftime(UTC_TIMESTAMP_FORMAT)
[docs]
def freeze_cutoff_after(day: date) -> str:
"""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
{func}`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
{class}`repomatic.deps.sources.ReleaseSwap`.
:param day: The held version's upload date.
:return: A `YYYY-MM-DDT00:00:00Z` cutoff timestamp.
"""
return date_to_utc_cutoff(day + timedelta(days=1))
[docs]
class ExemptionKind(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 {func}`date_to_utc_cutoff`.
"""
TIMESTAMP = "timestamp"
"""A full RFC 3339 instant: a freeze."""
[docs]
@dataclass(frozen=True)
class ExemptionValue:
"""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 {attr}`raw`, or `None` when no form matches."""
span: timedelta | None = None
"""The width of a {attr}`ExemptionKind.SPAN` value."""
day: date | None = None
"""The date of a {attr}`ExemptionKind.DATE` value."""
instant: datetime | None = None
"""The UTC instant a {attr}`ExemptionKind.TIMESTAMP` value names, or the UTC
end of the day a {attr}`ExemptionKind.DATE` value names."""
[docs]
@classmethod
def parse(cls, raw: object) -> ExemptionValue:
"""Classify a raw value read from `[tool.uv].exclude-newer-package`."""
if raw is False:
return cls(raw, ExemptionKind.LIFT)
text = str(raw).strip()
span = _parse_span(text)
if span is not None:
return cls(raw, ExemptionKind.SPAN, span=span)
if _BARE_DATE_RE.fullmatch(text):
try:
day = date.fromisoformat(text)
except ValueError:
return cls(raw, None)
return cls(
raw,
ExemptionKind.DATE,
day=day,
instant=utc_midnight(day + timedelta(days=1)),
)
instant = parse_iso_datetime(text)
if instant is None:
return cls(raw, None)
return cls(raw, ExemptionKind.TIMESTAMP, instant=instant)
@property
def tracks_latest(self) -> 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.
"""
return self.kind in (ExemptionKind.LIFT, ExemptionKind.SPAN)
@property
def fixed_cutoff(self) -> 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.
"""
return self.instant if self.kind is ExemptionKind.TIMESTAMP else None
@property
def pinned(self) -> str | None:
"""The explicit UTC timestamp of a bare date, `None` for other kinds."""
if self.day is None:
return None
return date_to_utc_cutoff(self.day)
[docs]
def admits(self, released: date, now: datetime) -> bool:
"""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
{func}`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.
:param released: The release date the datasource reports.
:param now: The current instant, which a span counts back from.
:return: `True` when every upload of the release is older than the
cutoff. `False` for an earlier cutoff, and for a value no form
matches.
"""
if self.kind is ExemptionKind.LIFT:
return True
cutoff = now - self.span if self.span is not None else self.instant
if cutoff is None:
return False
return cutoff >= min(utc_midnight(released + timedelta(days=1)), now)
[docs]
def exemption_admits(value: object, released: date, now: datetime) -> bool:
"""Whether a raw `exclude-newer-package` value admits a release.
For a caller holding the value as `tomlrt` read it. See
{meth}`ExemptionValue.admits`.
"""
return ExemptionValue.parse(value).admits(released, now)
def _inline_table(entries: Mapping[str, Any]) -> Table:
"""Build the inline table pyproject-fmt writes, keys sorted."""
return Table.inline({key: entries[key] for key in sorted(entries)})
def _mentions_package(text: str, name: str) -> bool:
"""Whether *text* names the package *name*, in any PEP 503 spelling."""
pattern = rf"(?<![\w.-]){pep503_pattern(name)}(?![\w-])"
return re.search(pattern, text, re.IGNORECASE) is not None
[docs]
class UvSettings:
"""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
{meth}`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.
"""
def __init__(
self, path: Path, original: str | None, text: str | None = None
) -> None:
self.path = path
"""The `pyproject.toml` the settings are read from and saved to."""
self._original = original
"""The text on disk, or `None` when the file does not exist."""
self.doc: Any = tomlrt.loads(text if text is not None else original or "")
"""The editable document, formatting trivia included."""
[docs]
@classmethod
def load(cls, path: Path, text: str | None = None) -> UvSettings:
"""Read the settings of *path*.
:param path: The `pyproject.toml` file.
:param text: 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. {meth}`save` compares against the file either
way.
"""
original = path.read_text(encoding="UTF-8") if path.exists() else None
return cls(path, original, text)
@property
def table(self) -> Any:
"""The `[tool.uv]` table, or an empty mapping when the file has none."""
return uv_table(self.doc)
@property
def window(self) -> 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. {meth}`lock_command`
does it.
```
"""
window = self.table.get("exclude-newer", "")
return str(window) if window else ""
[docs]
def entries(self) -> dict[str, ExemptionValue]:
"""The `exclude-newer-package` entries, keyed as the project spells them."""
table = self.table.get(EXEMPTION_KEY) or {}
return {name: ExemptionValue.parse(value) for name, value in table.items()}
[docs]
def lock_command(
self, *extra: str, exclude_newer_packages: Mapping[str, str] | None = None
) -> list[str]:
"""A `uv lock` argv carrying the project's own window. See {attr}`window`.
:param extra: Extra `uv lock` arguments.
:param exclude_newer_packages: Per-package windows for this run only.
"""
return uv_lock_command(
*extra,
exclude_newer=self.window,
exclude_newer_packages=exclude_newer_packages,
)
def _write_entries(self, entries: Mapping[str, Any]) -> None:
"""Replace the `exclude-newer-package` table, or drop it when empty."""
if entries:
self.table[EXEMPTION_KEY] = _inline_table(entries)
else:
del self.table[EXEMPTION_KEY]
[docs]
def prune(self, lock: LockFile, now: datetime) -> dict[str, ExemptionValue]:
"""Remove the stale `exclude-newer-package` entries.
```{todo}
Delete this pruning pass once uv prunes stale `exclude-newer-package`
entries natively:
[uv#18792](https://github.com/astral-sh/uv/issues/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.
```
:param lock: The lock the entries hold their versions in.
:param now: The instant the rolling window counts back from.
:return: The pruned entries, keyed by name, with the values they had.
"""
entries = self.entries()
window = self.window
if not entries or not window:
return {}
cutoff = resolve_exclude_newer_cutoff(window, now)
if cutoff is None:
logging.warning(
f"Cannot parse exclude-newer value {window!r}; skipping prune."
)
return {}
stale: dict[str, ExemptionValue] = {}
for name, value in entries.items():
if not lock.version_of(name):
# The lock holds no version of the package, so the entry ages
# by its own cutoff. See the docstring.
fixed = value.fixed_cutoff
if fixed is not None and fixed <= cutoff:
stale[name] = value
logging.info(
f"Pruning {name}: not in the lock, and its cutoff"
f" {value.raw} is before the cooldown cutoff."
)
else:
logging.debug(
f"Keeping {name}: not in the lock, cutoff {value.raw}."
)
continue
upload = lock.uploaded_at(name)
if upload is None:
# No upload time: git or path source, a permanent exemption.
logging.debug(f"Keeping {name}: no upload time in the lock.")
elif upload < cutoff:
stale[name] = value
logging.info(f"Pruning {name}: upload time {upload} is before cutoff.")
else:
logging.debug(f"Keeping {name}: upload time {upload} is after cutoff.")
if stale:
self._write_entries({
name: value.raw for name, value in entries.items() if name not in stale
})
logging.info(
f"Pruned {', '.join(sorted(stale))} from {EXEMPTION_KEY}"
f" in {self.path}."
)
else:
logging.debug(f"No stale {EXEMPTION_KEY} entries.")
return stale
[docs]
def freeze(self, lock: LockFile) -> set[str]:
"""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 {meth}`prune` never
sees its locked version age out. Rewriting the entry as the
{func}`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 {func}`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.
:param lock: The lock holding the versions to freeze at.
:return: The names of the entries rewritten.
"""
entries = self.entries()
rewritten: dict[str, str] = {}
for name, value in entries.items():
if value.tracks_latest:
upload = lock.uploaded_at(name)
if upload is not None:
rewritten[name] = freeze_cutoff_after(upload.date())
logging.info(
f"Freezing {name} cooldown bypass at {rewritten[name]}."
)
elif value.pinned is not None:
rewritten[name] = value.pinned
logging.info(
f"Pinning {name} cooldown date {value.raw} to {value.pinned}."
)
if rewritten:
self._write_entries({
name: rewritten.get(name, value.raw) for name, value in entries.items()
})
return set(rewritten)
[docs]
def upsert(self, entries: Mapping[str, str]) -> bool:
"""Insert or replace `exclude-newer-package` entries.
:param entries: Package name to cutoff value (a freeze timestamp or a
span). Existing entries for the same names are overwritten.
:return: `True` when the table changed. `False` when nothing needed to,
and when the project has no `[tool.uv]` cooldown to attach the
entries to.
"""
uv = self.doc.get("tool", {}).get("uv")
if uv is None:
logging.warning(
f"No [tool.uv] found in {self.path}. Cannot persist cooldown"
" exemptions."
)
return False
table = uv.get(EXEMPTION_KEY)
if table is None and "exclude-newer" not in uv:
logging.warning(
f"No [tool.uv] exclude-newer or {EXEMPTION_KEY} found in"
f" {self.path}. Cannot persist cooldown exemptions."
)
return False
merged = dict(table) if table is not None else {}
if all(merged.get(name) == value for name, value in entries.items()):
logging.debug(f"All {EXEMPTION_KEY} entries already up to date.")
return False
merged.update(entries)
uv[EXEMPTION_KEY] = _inline_table(merged)
if table is None:
# Right after `exclude-newer`, which is where pyproject-fmt orders
# it in `[tool.uv]`. Appended at the end, the next format-pyproject
# run would move it, leaking this edit into a follow-up PR.
order = [key for key in uv if key != EXEMPTION_KEY]
order.insert(order.index("exclude-newer") + 1, EXEMPTION_KEY)
uv.sort(key=order.index)
logging.info(
f"Upserted {', '.join(sorted(entries))} in {EXEMPTION_KEY} in {self.path}."
)
return True
[docs]
def exempt(self, names: Iterable[str], lock: LockFile, now: datetime) -> set[str]:
"""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
{func}`freeze_cutoff_after`. A package with no upload time in the lock
(a git or path source) gets a permanent {data}`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.
:param names: Package names, in any PEP 503 spelling. Each entry keeps
the spelling it is given.
:param lock: The lock holding the versions to freeze at.
:param now: The instant the rolling window counts back from.
:return: The names of the entries added.
"""
cutoff = resolve_exclude_newer_cutoff(self.window, now)
needed = set()
for name in names:
upload = lock.uploaded_at(name)
if cutoff is None or upload is None or upload >= cutoff:
logging.info(
f"Exempting {name}: its locked release is inside the window."
)
needed.add(name)
else:
logging.info(
f"Skipping exemption for {name}: upload time {upload} already"
" clears the exclude-newer cutoff."
)
to_add = needed - set(self.entries())
if not to_add:
return set()
additions: dict[str, str] = {}
for name in to_add:
upload = lock.uploaded_at(name)
additions[name] = (
freeze_cutoff_after(upload.date()) if upload is not None else LIFT_SPAN
)
return to_add if self.upsert(additions) else set()
[docs]
def save(self) -> bool:
"""Write the document back, when it differs from the file on disk.
:return: `True` when the file was written.
"""
if self._original is None:
return False
text = tomlrt.dumps(self.doc)
if text == self._original:
return False
self.path.write_text(text, encoding="UTF-8")
self._original = text
return True
def _forecast(
lock: LockFile, name: str, value: ExemptionValue, now: datetime
) -> BypassForecast:
"""Forecast the day one `exclude-newer-package` entry clears.
Mirrors the two conditions {meth}`UvSettings.prune` applies. A package the
lock holds clears once its upload time plus the rolling span has passed: a
held version with no upload time can only clear by a release, which the
forecast marks {data}`~repomatic.deps.report.BYPASS_NEEDS_RELEASE`. A
package the lock does not hold clears once the entry's own cutoff plus the
span has passed.
:param lock: The lock the forecast is about.
:param name: The package the entry names, in any PEP 503 spelling.
:param value: The entry.
:param now: Reference instant for the relative hint. The prune it
forecasts compares instants, so a freeze lifting later today is hours
away, not a day.
:return: The forecast. Its held version is empty for a package the lock
does not hold, and its expiry is empty when the lock records no span.
"""
span = lock.cooldown_span
held_version = lock.version_of(name)
start = lock.uploaded_at(name) if held_version else value.fixed_cutoff
if held_version and start is None:
expires = BYPASS_NEEDS_RELEASE
elif start is None or span is None:
expires = ""
else:
expires = format_countdown(start + span, now)
return BypassForecast(name, held_version, expires)
[docs]
def active_forecasts(
entries: Mapping[str, ExemptionValue], lock: LockFile, now: datetime
) -> list[BypassForecast]:
"""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`).
:param entries: The `exclude-newer-package` entries.
:param lock: The lock that describes the state the report is about.
:param now: Reference instant for the relative hints.
:return: Forecasts sorted by package name.
"""
return [
_forecast(lock, name, entries[name], now)
for name in sorted(entries)
if not entries[name].tracks_latest
]
[docs]
def pruned_forecasts(
pruned: Mapping[str, ExemptionValue], lock: LockFile, now: datetime
) -> list[BypassForecast]:
"""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.
:param pruned: The entries {meth}`UvSettings.prune` removed.
:param lock: The lock before the upgrade.
:param now: Reference instant for the relative hints.
:return: One record per entry, sorted by name, with the version the freeze
held and the (past) day it expired.
"""
return [_forecast(lock, name, pruned[name], now) for name in sorted(pruned)]
[docs]
def compute_held_back_packages(
lock_path: Path, lock: LockFile, bypassed: Iterable[str]
) -> list[HeldBackPackage]:
"""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: {meth}`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`.
```
:param lock_path: Path to the `uv.lock` file.
:param lock: The parsed lock on disk, the in-cooldown state.
:param bypassed: The packages carrying an `exclude-newer-package` entry.
:return: Held-back packages sorted by name. Empty when the probe fails or
nothing is withheld.
"""
if not lock_path.exists():
return []
saved = lock_path.read_bytes()
now = datetime.now(UTC)
cutoff = now.strftime(UTC_TIMESTAMP_FORMAT)
# A command-line window beats the pyproject.toml entry of the same
# package, lifting each freeze for the duration of the probe.
probe = uv_lock_command(
"--upgrade",
exclude_newer=cutoff,
exclude_newer_packages=dict.fromkeys(bypassed, cutoff),
)
try:
run_uv(probe, cwd=lock_path.parent)
lifted = LockFile.load(lock_path)
latest = lifted.versions
uploads = lifted.upload_times
except subprocess.CalledProcessError as error:
logging.warning(
"Cooldown probe (uv lock --exclude-newer) failed; "
"skipping the held-back report."
)
logging.debug(str(error))
return []
finally:
lock_path.write_bytes(saved)
span = lock.cooldown_span
held = []
for name in sorted(lock.versions.keys() & latest.keys()):
old_version, new_version = lock.versions[name], latest[name]
if not is_newer(new_version, old_version):
continue
raw_upload = uploads.get(name, "")
eligible = ""
upload = parse_iso_datetime(raw_upload)
if upload is not None and span is not None:
eligible = format_countdown(upload + span, now)
held.append(
HeldBackPackage(
name,
old_version,
new_version,
format_released(raw_upload, now),
eligible,
)
)
return held
[docs]
@dataclass
class SyncResult:
"""The outcome of {func}`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] = field(default_factory=list)
"""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 {func}`pruned_forecasts`."""
frozen_bypasses: list[str] = field(default_factory=list)
"""`exclude-newer-package` entries rewritten into freeze cutoffs."""
stale_bypass_comments: list[str] = field(default_factory=list)
"""Pruned entries the comment above `exclude-newer-package` still names,
found by {meth}`UvSettings.comment_mentions`."""
bypass_forecasts: list[BypassForecast] = field(default_factory=list)
"""Active cooldown-bypass freezes with their expiry forecasts (post-run
state)."""
[docs]
def sync_uv_lock(lock_path: Path, settings: UvSettings | None = None) -> SyncResult:
"""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
{func}`date_to_utc_cutoff`.
```
:param lock_path: Path to the `uv.lock` file.
:param settings: The project's settings, when the caller already loaded
them. Read from the `pyproject.toml` beside the lock otherwise.
:return: The version changes, the lock left on disk, and the
cooldown-bypass lifecycle: entries pruned, frozen, and still active
with their expiry forecasts.
"""
project_dir = lock_path.parent
if settings is None:
settings = UvSettings.load(project_dir / "pyproject.toml")
now = datetime.now(UTC)
# Prune the bypasses whose hold has aged past the cooldown, then freeze the
# survivors at their locked version, so the upgrade below holds them
# instead of tracking newer releases. Both passes, and the snapshot of the
# cleared entries, read the lock as it stands before the upgrade.
pre = LockFile.load(lock_path)
pruned = settings.prune(pre, now)
frozen = settings.freeze(pre)
stale_comments = settings.comment_mentions(pruned)
if stale_comments:
logging.warning(
f"The comment above {EXEMPTION_KEY} in {settings.path} still names"
f" {', '.join(stale_comments)}, which this run cleared. Update it by"
" hand."
)
settings.save()
# The raw bytes let a cosmetic-only upgrade be rolled back verbatim.
lock_before = lock_path.read_bytes() if lock_path.exists() else None
lock_cmd = settings.lock_command("--upgrade")
logging.info(f"Running {' '.join(lock_cmd)} in {project_dir}...")
try:
run_uv(lock_cmd, cwd=project_dir)
except subprocess.CalledProcessError as error:
logging.error(str(error.stderr).strip())
raise
post = LockFile.load(lock_path)
changes = diff_lock_versions(pre.versions, post.versions)
# With no version change and no cooldown edit behind the run, any remaining
# diff is uv's marker re-normalization (see the note above). Keeping it
# would open an empty sync PR that the next machine reverts.
final = post
if (
not changes
and not (pruned or frozen)
and lock_before is not None
and lock_path.read_bytes() != lock_before
):
lock_path.write_bytes(lock_before)
final = pre
logging.info(
"Restored uv.lock: uv lock --upgrade changed no package versions, "
"only equivalent environment markers. Discarding cosmetic churn."
)
return SyncResult(
changes=changes,
lock=final,
pruned_bypasses=pruned_forecasts(pruned, pre, now),
frozen_bypasses=sorted(frozen),
stale_bypass_comments=stale_comments,
bypass_forecasts=active_forecasts(settings.entries(), final, now),
)