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
uvbinary 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’suv, and a future registry-pinned build would change every call site by changing this function.- Return type:
- 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--frozenwhen requested (appropriate forrun,export,sync— not forlock). Adds--no-projectto skip project discovery entirely, and--exclude-newer(aYYYY-MM-DDdate) to gate an unlocked resolution by theminimum-release-agecooldown, mirroringuvx_cmd().
- repomatic.deps.uv.uvx_cmd(exclude_newer=None)[source]¶
Build a
uvxcommand prefix with standard flags.When exclude_newer is set (a
YYYY-MM-DDdate), adds--exclude-newerso the isolated resolution honors theminimum-release-agecooldown, gating the tool’s transitive dependencies by upload date.
- repomatic.deps.uv.LOCK_TIMESTAMP_SENTINEL = '0001-01-01T00:00:00Z'¶
Placeholder uv writes to
options.exclude-newerinuv.lockwhen the user-configured value is a relative span. The real cutoff is inoptions.exclude-newer-spanas an ISO 8601 duration.
- repomatic.deps.uv.load_pyproject_doc(pyproject_path)[source]¶
Parse
pyproject.tomlinto an editable, round-trippable document.The counterpart to
repomatic.pyproject.read_pyproject_toml(), which returns plain data for reading. This one keepstomlrt’s formatting trivia, so the document can be edited and written back with the rest of the file byte-identical.
- repomatic.deps.uv.uv_table(doc)[source]¶
Return the
[tool.uv]table of a parsedpyproject.toml.- Parameters:
doc (
Any) – Document fromload_pyproject_doc().- Return type:
- 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-newervalue 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 likeP1DT2H): 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.
- repomatic.deps.uv.project_exclude_newer(pyproject_path)[source]¶
Read the project’s own
[tool.uv] exclude-newerwindow.Caution
Always pass this back to
uv lockanduv syncas an explicit--exclude-newerflag rather than letting uv pick the value up frompyproject.tomlon its own. CI exports aUV_EXCLUDE_NEWERcovering every ad-hoc install (seeclaude.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, andsync-uv-lockwould churn between the two. A CLI flag outranks the environment, which pins the project’s own policy.
- repomatic.deps.uv.uv_lock_command(pyproject_path, *extra)[source]¶
Build a
uv lockargv 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-newerthat keeps CI’s ambientUV_EXCLUDE_NEWERfrom retiming the lock: seeproject_exclude_newer().- Parameters:
- Return type:
- Returns:
The argv to run, with
cwdset 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-packageexemption only when its locked version was uploaded after theexclude-newercutoff, meaning a regularuv lock --upgradewould not resolve it.
- repomatic.deps.uv.date_to_utc_cutoff(day)[source]¶
Render an
exclude-newer-packagecutoff date as an explicit UTC instant.Warning
uv reads a bare
YYYY-MM-DDinexclude-newer-packageas the start of the following day in the locking machine’s local timezone, then writes that absolute instant intouv.lock’s[options.exclude-newer-package]block. The same date therefore lands as a different timestamp depending on whereuv lockran:2026-06-13becomes2026-06-14T00:00:00Zon a UTC CI runner but2026-06-13T20:00:00Zon a UTC+4 laptop. Every local lock then flips the value one way and every CI lock flips it back: an endlesssync-uv-lockping-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.
- repomatic.deps.uv.freeze_cutoff_after(day)[source]¶
The
exclude-newer-packagecutoff 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 withrepomatic.deps.dep_sources.ReleaseSwap.
- repomatic.deps.uv.upsert_exclude_newer_packages(pyproject_path, entries)[source]¶
Insert or replace
[tool.uv].exclude-newer-packageentries.The write primitive shared by
add_exclude_newer_packages()(which computes freeze cutoffs from the lock and never overwrites) andsync-dep-sources(which supplies exact cutoffs and must replace the stale value a git-tracking era left behind).- Parameters:
- Return type:
- Returns:
Trueif the file was updated,Falseif no changes were needed.
- repomatic.deps.uv.add_exclude_newer_packages(pyproject_path, packages, lock_path)[source]¶
Add packages to
[tool.uv].exclude-newer-packageinpyproject.toml.Persists for each package the
_freeze_cutoffof its currently-locked version (a whole-day boundary just past that version’s upload) so that subsequentuv lock --upgraderuns (thesync-uv-lockjob) hold the package within that freeze window instead of tracking the latest release, until it ages past theexclude-newercooldown andprune_stale_exclude_newer_packages()drops the entry. See_freeze_cutofffor 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
Trueif the file was modified.- Parameters:
- Return type:
- Returns:
Trueif the file was updated,Falseif 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-packageentry tells uv to ignore the cooldown and resolve to the latest release, so the package keeps moving andprune_stale_exclude_newer_packages()never sees its locked version age out. Rewriting the span as the_freeze_cutoffof 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-DDfixed entry to the equivalent explicit UTC timestamp (seedate_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:
- Return type:
- 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-packageentries natively: uv#18792.An entry is stale when its locked version’s upload time falls before the
exclude-newercutoff, meaninguv lock --upgradewould 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.
- class repomatic.deps.uv.LockFile(versions=<factory>, upload_times=<factory>, exclude_newer='', cooldown_span=None)[source]¶
Bases:
objectEverything 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-lockrun 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.- upload_times: dict[str, str]¶
Package name to the ISO 8601
upload-timeof itssdistentry.Packages with no
sdistor no upload time are absent: a git or path source has no release to date.
- exclude_newer: str = ''¶
Effective
options.exclude-newercutoff, as an ISO 8601 instant.When the project configures a relative span, uv writes
LOCK_TIMESTAMP_SENTINELhere and the real width tooptions.exclude-newer-span; the cutoff is then resolved tonow - spanat 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.Nonewhen 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.
- repomatic.deps.uv.parse_lock_versions(lock_path)[source]¶
Parse a
uv.lockfile and return a mapping of package names to versions.
- repomatic.deps.uv.parse_lock_upload_times(lock_path)[source]¶
Parse a
uv.lockfile and return a mapping of package names to upload times.Extracts the
upload-timefield from each package’ssdistentry.
- repomatic.deps.uv.load_lock_data(lock_path=None)[source]¶
Load and parse a
uv.lockfile, 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.
- repomatic.deps.uv.EXTRA_MARKER_RE = re.compile("\\bextra\\s*==\\s*'([^']+)'")¶
Match the extra a
requires-distmarker 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:
objectDependency specifiers extracted from a
uv.lockfile.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. Seefilter_root_edges(). A package with nometadatatable is absent from the mapping entirely, telling “declares nothing unconditionally” apart from “not described here”.
- repomatic.deps.uv.parse_lock_specifiers(lock_path=None, *, lock_data=None)[source]¶
Parse
uv.lockand extract dependency specifiers.A single pass builds two complementary indexes from
[package.metadata].requires-distand[package.metadata.requires-dev]. SeeLockSpecifiersfor the two views returned.- Parameters:
- Return type:
- repomatic.deps.uv.diff_lock_versions(before, after)[source]¶
Compare two version mappings and return the list of changes.
- Parameters:
- Return type:
- Returns:
A sorted list of
(name, old_version, new_version)tuples.old_versionis empty for added packages;new_versionis 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-newercutoff and every per-packageexclude-newer-packagefreeze 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 arequires-pythonbound resolve identically with and without the lift, so they are excluded.The probe writes
uv.lockand restores it byte-for-byte in afinally, so the canonical in-cooldown lock is left untouched even when resolution or parsing fails.Note
This runs a second
uv lockresolution. It is the report’s only cost and is skipped bysync-uv-lock --no-held-back.- Parameters:
lock_path (
Path) – Path to theuv.lockfile.- Return type:
- 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-packageentries. 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 (seedocs/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 rollingexclude-newerspan, which is the day the nextsync-uv-lockrun prunes the entry.- Parameters:
pyproject_path (
Path) – Path to thepyproject.tomlfile.lock_path (
Path) – Path to theuv.lockfile.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:
- 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:
- Return type:
- 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:
objectResult of a
sync-uv-lockoperation.- reverted: bool = False¶
Whether a cosmetic-only re-lock was discarded.
Truewhenuv lock --upgradechanged no package versions and was not driven by apyproject.tomlcooldown edit, sosync_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-packageentries removed frompyproject.toml, each with the version and (past) expiry the freeze had, snapshot against the pre-upgrade lock bycompute_pruned_forecasts().
- 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
--upgradeand report version changes.First prunes stale
exclude-newer-packageentries frompyproject.toml(entries whose locked version was uploaded before theexclude-newercutoff), then runsuv lock --upgradeto update transitive dependencies.Note
When the upgrade changes no package versions and was not driven by a
pyproject.tomlcooldown edit, the pre-upgrade lock is restored byte-for-byte.uv lock --upgradeotherwise 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 itspython_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 endlesssync-uv-lockping-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 indate_to_utc_cutoff().- Parameters:
lock_path (
Path) – Path to theuv.lockfile.- Return type:
- Returns:
A
SyncResultwith structured version change data and the cooldown-bypass lifecycle (entries pruned, frozen, and still active with their expiry forecasts).