repomatic.deps.uv module

uv lock file operations.

Utilities for managing uv.lock files: parsing versions, computing version diffs and cooldown forecasts (held-back releases, bypass expiries), and managing exclude-newer-package cooldown overrides. The shared markdown rendering of these results lives in repomatic.deps.dep_report.

repomatic.deps.uv.uv_executable()[source]

The uv binary every command this package builds shells out to.

One seam rather than a literal in each argv, mirroring repomatic.github.gh.gh_executable(): today it answers $PATH’s uv, and a future registry-pinned build would change every call site by changing this function.

Return type:

str

repomatic.deps.uv.uv_cmd(subcommand, *, frozen=False, no_project=False, exclude_newer=None)[source]

Build a uv <subcommand> command prefix with standard flags.

Always includes --no-progress. Adds --frozen when requested (appropriate for run, export, sync — not for lock). Adds --no-project to skip project discovery entirely, and --exclude-newer (a YYYY-MM-DD date) to gate an unlocked resolution by the minimum-release-age cooldown, mirroring uvx_cmd().

Return type:

list[str]

repomatic.deps.uv.uvx_cmd(exclude_newer=None)[source]

Build a uvx command prefix with standard flags.

When exclude_newer is set (a YYYY-MM-DD date), adds --exclude-newer so the isolated resolution honors the minimum-release-age cooldown, gating the tool’s transitive dependencies by upload date.

Return type:

list[str]

repomatic.deps.uv.LOCK_TIMESTAMP_SENTINEL = '0001-01-01T00:00:00Z'

Placeholder uv writes to options.exclude-newer in uv.lock when the user-configured value is a relative span. The real cutoff is in options.exclude-newer-span as an ISO 8601 duration.

repomatic.deps.uv.load_pyproject_doc(pyproject_path)[source]

Parse pyproject.toml into an editable, round-trippable document.

The counterpart to repomatic.pyproject.read_pyproject_toml(), which returns plain data for reading. This one keeps tomlrt’s formatting trivia, so the document can be edited and written back with the rest of the file byte-identical.

Parameters:

pyproject_path (Path) – Path to the pyproject.toml file.

Return type:

Any

Returns:

The parsed document.

repomatic.deps.uv.uv_table(doc)[source]

Return the [tool.uv] table of a parsed pyproject.toml.

Parameters:

doc (Any) – Document from load_pyproject_doc().

Return type:

Any

Returns:

The [tool.uv] table, or an empty mapping when the project declares none. Reading a key off the result is therefore always safe; writing one back requires the caller to check the table exists first, since the empty fallback is not attached to doc.

repomatic.deps.uv.resolve_exclude_newer_cutoff(value)[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.

Return type:

datetime | None

Returns:

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

repomatic.deps.uv.project_exclude_newer(pyproject_path)[source]

Read the project’s own [tool.uv] exclude-newer window.

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 claude.md § Cooldown on every install), 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.

Parameters:

pyproject_path (Path) – Path to the pyproject.toml file.

Return type:

str

Returns:

The configured window verbatim (a friendly duration, an ISO 8601 span or an absolute timestamp), or an empty string when unset.

repomatic.deps.uv.uv_lock_command(pyproject_path, *extra)[source]

Build a uv lock argv carrying the project’s own cooldown window.

The one builder behind every re-lock this package runs (sync-uv-lock, the dep-sources swap, audit --fix), so none of them can forget the explicit --exclude-newer that keeps CI’s ambient UV_EXCLUDE_NEWER from retiming the lock: see project_exclude_newer().

Parameters:
  • pyproject_path (Path) – Path to the project’s pyproject.toml. A missing file or an unset window leaves the flag off.

  • extra (str) – Extra uv lock arguments (--upgrade, --upgrade-package, …), appended before the window flag.

Return type:

list[str]

Returns:

The argv to run, with cwd set to the project directory.

repomatic.deps.uv.packages_outside_cooldown(pyproject_path, lock_path, packages)[source]

Return the subset of packages whose upload time exceeds the cooldown.

A package needs an exclude-newer-package exemption only when its locked version was uploaded after the exclude-newer cutoff, meaning a regular uv lock --upgrade would not resolve it.

Parameters:
  • pyproject_path (Path) – Path to the pyproject.toml file.

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

  • packages (set[str]) – Candidate package names.

Return type:

set[str]

Returns:

The subset that actually requires a "0 day" override.

repomatic.deps.uv.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.uv.freeze_cutoff_after(day)[source]

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

One day of margin, rounded to a whole-day UTC boundary: see _freeze_cutoff() for the full margin and timezone rationale. The single source of that policy, shared with repomatic.deps.dep_sources.ReleaseSwap.

Parameters:

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

Return type:

str

Returns:

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

repomatic.deps.uv.upsert_exclude_newer_packages(pyproject_path, entries)[source]

Insert or replace [tool.uv].exclude-newer-package entries.

The write primitive shared by add_exclude_newer_packages() (which computes freeze cutoffs from the lock and never overwrites) and sync-dep-sources (which supplies exact cutoffs and must replace the stale value a git-tracking era left behind).

Parameters:
  • pyproject_path (Path) – Path to the pyproject.toml file.

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

Return type:

bool

Returns:

True if the file was updated, False if no changes were needed.

repomatic.deps.uv.add_exclude_newer_packages(pyproject_path, packages, lock_path)[source]

Add packages to [tool.uv].exclude-newer-package in pyproject.toml.

Persists for each package the _freeze_cutoff of its currently-locked version (a whole-day boundary just past that version’s upload) so that subsequent uv lock --upgrade runs (the sync-uv-lock job) hold the package within that freeze window instead of tracking the latest release, until it ages past the exclude-newer cooldown and prune_stale_exclude_newer_packages() drops the entry. See _freeze_cutoff for the window’s width and its same-day-patch caveat. Packages with no upload time in the lock (git or path sources) fall back to a permanent "0 day" span.

Skips packages that already have an entry. Returns True if the file was modified.

Parameters:
  • pyproject_path (Path) – Path to the pyproject.toml file.

  • packages (set[str]) – Package names to add.

  • lock_path (Path) – Path to the uv.lock file, read to resolve each package’s locked-version upload time.

Return type:

bool

Returns:

True if the file was updated, False if no changes were needed.

repomatic.deps.uv.freeze_exclude_newer_packages(pyproject_path, lock_path)[source]

Convert relative-span cooldown bypasses into fixed freeze cutoffs.

A "0 day" (or any relative-span) exclude-newer-package entry tells uv to ignore the cooldown and resolve to the latest release, so the package keeps moving and prune_stale_exclude_newer_packages() never sees its locked version age out. Rewriting the span as the _freeze_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 migrates any legacy bare YYYY-MM-DD fixed 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 (idempotent). Packages with no upload time in the lock (git or path sources) keep their span: they have no PyPI release to freeze against.

Parameters:
  • pyproject_path (Path) – Path to the pyproject.toml file.

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

Return type:

set[str]

Returns:

The names of the packages whose entry was rewritten (span frozen or bare date pinned); empty when no entry needed rewriting (the file is then left untouched).

repomatic.deps.uv.prune_stale_exclude_newer_packages(pyproject_path, lock_path)[source]

Remove stale entries from [tool.uv].exclude-newer-package.

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 "0 day" override.

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

Parameters:
  • pyproject_path (Path) – Path to the pyproject.toml file.

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

Return type:

set[str]

Returns:

The names of the pruned packages; empty when nothing was stale (the file is then left untouched).

class repomatic.deps.uv.LockFile(versions=<factory>, upload_times=<factory>, exclude_newer='', cooldown_span=None)[source]

Bases: object

Everything the cooldown machinery reads out of a uv.lock, parsed once.

A lock is a large TOML document (hundreds of kilobytes on a real project) and a round-trip parse of it is not cheap. The four views below used to be four independent functions that each re-opened the file, so a single sync-uv-lock run parsed the same bytes nine to twelve times. Loading once and passing the result around keeps that to two: the pre-upgrade state and the post-upgrade one.

The parse_lock_* functions remain as thin wrappers for callers holding only a path.

versions: dict[str, str]

Package name to locked version.

upload_times: dict[str, str]

Package name to the ISO 8601 upload-time of its sdist entry.

Packages with no sdist or no upload time are absent: a git or path source has no release to date.

exclude_newer: str = ''

Effective options.exclude-newer cutoff, as an ISO 8601 instant.

When the project configures a relative span, uv writes LOCK_TIMESTAMP_SENTINEL here and the real width to options.exclude-newer-span; the cutoff is then resolved to now - span at load time. Empty when neither field is present, or when the sentinel carries no parseable span.

cooldown_span: timedelta | None = None

Width of the rolling cooldown, from options.exclude-newer-span.

None when the lock records an absolute cutoff instead of a span, which leaves the cooldown-expiry forecasts nothing to project against.

classmethod load(lock_path)[source]

Read every cooldown-relevant field out of lock_path in one parse.

The parse itself comes from load_lock_data()’s cache, so calling this repeatedly against an unchanged file only rebuilds the cheap views, never re-reads the document.

Parameters:

lock_path (Path) – Path to the uv.lock file.

Return type:

LockFile

Returns:

The parsed views, or an all-empty instance when the file does not exist. A missing lock is not an error: several callers run before the first uv lock.

repomatic.deps.uv.parse_lock_versions(lock_path)[source]

Parse a uv.lock file and return a mapping of package names to versions.

Parameters:

lock_path (Path) – Path to the uv.lock file.

Return type:

dict[str, str]

Returns:

A dict mapping normalized package names to their version strings.

repomatic.deps.uv.parse_lock_upload_times(lock_path)[source]

Parse a uv.lock file and return a mapping of package names to upload times.

Extracts the upload-time field from each package’s sdist entry.

Parameters:

lock_path (Path) – Path to the uv.lock file.

Return type:

dict[str, str]

Returns:

A dict mapping normalized package names to ISO 8601 upload-time strings. Packages without an sdist or upload-time are omitted.

repomatic.deps.uv.load_lock_data(lock_path=None)[source]

Load and parse a uv.lock file, memoized per file identity.

The one reader of the lock: LockFile.load() builds its views from this parse, so however many passes a command makes over the same lock, the document is decoded once. Treat the result as read-only.

Parameters:

lock_path (Path | None) – Path to uv.lock file. If None, looks in current directory.

Return type:

dict[str, Any]

Returns:

Parsed TOML data as a dict, or empty dict if the file does not exist.

repomatic.deps.uv.EXTRA_MARKER_RE = re.compile("\\bextra\\s*==\\s*'([^']+)'")

Match the extra a requires-dist marker gates its dependency behind.

Searched rather than anchored: uv writes the bare extra == 'x' form most of the time, but combines it with a version guard (python_full_version >= ‘3.11’ and extra == ‘x’) when the declaration carries one. Anchoring would read those as unconditional dependencies.

class repomatic.deps.uv.LockSpecifiers(by_package, by_subgraph, by_main=<factory>)[source]

Bases: object

Dependency specifiers extracted from a uv.lock file.

Three views of the same data, built in a single pass over the lock packages:

by_package

{package_name: {dep_name: specifier}}. Every dependency declared by a package (main and dev) keyed by the declaring package name. Used for edge labels in dependency graphs.

by_subgraph

{subgraph_name: {dep_name: specifier}}. Primary dependencies keyed by dev-group name or extra name. Used for node labels inside subgraphs.

by_main

{package_name: {dep_name: specifier}}. Only the dependencies a package declares unconditionally, behind neither an extra nor a dev group. This is the authoritative answer to “what does installing this project pull in by default”, which a CycloneDX SBOM does not reliably give. See filter_root_edges(). A package with no metadata table is absent from the mapping entirely, telling “declares nothing unconditionally” apart from “not described here”.

by_package: dict[str, dict[str, str]]
by_subgraph: dict[str, dict[str, str]]
by_main: dict[str, dict[str, str]]
repomatic.deps.uv.parse_lock_specifiers(lock_path=None, *, lock_data=None)[source]

Parse uv.lock and extract dependency specifiers.

A single pass builds two complementary indexes from [package.metadata].requires-dist and [package.metadata.requires-dev]. See LockSpecifiers for the two views returned.

Parameters:
  • lock_path (Path | None) – Path to uv.lock file. If None, looks in current directory. Ignored when lock_data is provided.

  • lock_data (dict[str, Any] | None) – Pre-loaded lock data from load_lock_data(). When provided, skips file I/O.

Return type:

LockSpecifiers

repomatic.deps.uv.diff_lock_versions(before, after)[source]

Compare two version mappings and return the list of changes.

Parameters:
  • before (dict[str, str]) – Package versions before the upgrade.

  • after (dict[str, str]) – Package versions after the upgrade.

Return type:

list[tuple[str, str, str]]

Returns:

A sorted list of (name, old_version, new_version) tuples. old_version is empty for added packages; new_version is empty for removed packages.

repomatic.deps.uv.compute_held_back_packages(lock_path)[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: prune_stale_exclude_newer_packages() 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.

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.

Return type:

list[HeldBackPackage]

Returns:

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

repomatic.deps.uv.compute_bypass_forecasts(pyproject_path, lock_path, lock=None)[source]

Forecast when each active cooldown-bypass freeze self-clears.

Covers only the fixed-timestamp exclude-newer-package entries. Relative spans ("0 day") are permanent exemptions for packages with no PyPI release to age against (git or path sources), so they never expire and would repeat a static row in every report; auditing them is left to the dependency review (see docs/dependencies.md). Entries for packages absent from the lock (dropped dependencies) are skipped for the same reason.

The expiry mirrors the prune_stale_exclude_newer_packages() condition: the held version’s upload time plus the rolling exclude-newer span, which is the day the next sync-uv-lock run prunes the entry.

Parameters:
  • pyproject_path (Path) – Path to the pyproject.toml file.

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

  • lock (LockFile | None) – Pre-parsed lock_path, to skip re-reading it. It must describe the state the report is about: sync_uv_lock() passes the pre-upgrade lock when it discarded a cosmetic-only re-lock, and the post-upgrade one otherwise.

Return type:

list[BypassForecast]

Returns:

Forecasts sorted by package name; empty when there is no freeze.

repomatic.deps.uv.compute_pruned_forecasts(names, lock_path, lock=None)[source]

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

Must run against the pre-upgrade uv.lock: once the 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:
  • names (set[str]) – Names of the pruned entries, as returned by prune_stale_exclude_newer_packages().

  • lock_path (Path) – Path to the uv.lock file, still pre-upgrade.

  • lock (LockFile | None) – Pre-parsed lock_path, to skip re-reading it. Must be the pre-upgrade state, for the reason above.

Return type:

list[BypassForecast]

Returns:

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

class repomatic.deps.uv.SyncResult(changes, upload_times, exclude_newer, reverted=False, pruned_bypasses=<factory>, frozen_bypasses=<factory>, bypass_forecasts=<factory>)[source]

Bases: object

Result of a sync-uv-lock operation.

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

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

upload_times: dict[str, str]

Package name to ISO 8601 upload-time mapping from the lock file.

exclude_newer: str

The exclude-newer cutoff from the lock file, or empty string.

reverted: bool = False

Whether a cosmetic-only re-lock was discarded.

True when uv lock --upgrade changed no package versions and was not driven by a pyproject.toml cooldown edit, so sync_uv_lock() restored the pre-upgrade lock verbatim. See that function for why such a run is dropped.

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 compute_pruned_forecasts().

frozen_bypasses: list[str]

exclude-newer-package entries rewritten into freeze cutoffs.

bypass_forecasts: list[BypassForecast]

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

repomatic.deps.uv.sync_uv_lock(lock_path)[source]

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

First prunes stale exclude-newer-package entries from pyproject.toml (entries whose locked version was uploaded before the exclude-newer cutoff), 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.

Return type:

SyncResult

Returns:

A SyncResult with structured version change data and the cooldown-bypass lifecycle (entries pruned, frozen, and still active with their expiry forecasts).