repomatic.deps package¶
Dependency analysis and management.
The dependency graph, policy and source gates, the update report, the vulnerability audit, and the uv lockfile machinery.
Submodules¶
repomatic.deps.dep_graph module¶
Generate Mermaid dependency graphs from uv lockfiles.
Every box in the graph (the primary dependencies rectangle and each
--group/--extra subgraph) only holds directly-declared dependencies,
drawn as hexagons: the packages under the project’s control, referenced in
pyproject.toml. Transitive dependencies always render outside the boxes,
as plain ovals.
Note
Uses uv export --format cyclonedx1.5 which provides structured JSON
with dependency relationships, replacing the need for pipdeptree.
Warning
The generated Mermaid syntax targets the version bundled with
sphinxcontrib-mermaid, currently 11.12.1. See the hard-coded
MERMAID_VERSION constant in sphinxcontrib-mermaid’s source.
Avoid using Mermaid features introduced after that version.
- repomatic.deps.dep_graph.STYLE_PRIMARY_DEPS_SUBGRAPH: str = 'fill:#1565C020,stroke:#42A5F5'¶
Mermaid style for the primary dependencies subgraph box.
Uses semi-transparent fill (8-digit hex) so the tint adapts to both light and dark page backgrounds.
- repomatic.deps.dep_graph.STYLE_EXTRA_SUBGRAPH: str = 'fill:#7B1FA220,stroke:#BA68C8'¶
Mermaid style for extra dependency subgraph boxes.
Uses semi-transparent fill (8-digit hex) so the tint adapts to both light and dark page backgrounds.
- repomatic.deps.dep_graph.STYLE_GROUP_SUBGRAPH: str = 'fill:#546E7A20,stroke:#90A4AE'¶
Mermaid style for group dependency subgraph boxes.
Uses semi-transparent fill (8-digit hex) so the tint adapts to both light and dark page backgrounds.
- repomatic.deps.dep_graph.STYLE_PRIMARY_NODE: str = 'stroke-width:3px'¶
Mermaid style for root and primary dependency nodes (thick border).
- repomatic.deps.dep_graph.STYLE_DUPLICATE_NODE: str = 'stroke-width:3px,stroke-dasharray:5 5'¶
Mermaid style for duplicate headline nodes (dashed thick border).
The dashes mark the node as a display-only mirror of the real node owned by another subgraph; a dotted identity link ties the two together. Derived from
STYLE_PRIMARY_NODEsince duplicates are always headline (primary) dependencies of their box.
- class repomatic.deps.dep_graph.SubgraphKind(*values)[source]¶
Bases:
EnumKind of dependency selector a subgraph box represents.
- GROUP = 'group'¶
- EXTRA = 'extra'¶
- available(project_root=None)[source]¶
Discover this kind’s declared names from
pyproject.toml.Groups come from the
[dependency-groups]table, extras from[project.optional-dependencies].
- class repomatic.deps.dep_graph.Subgraph(kind, name, owned, duplicates)[source]¶
Bases:
objectOne
--groupor--extrabox in the rendered graph.A box only holds the packages its group or extra declares directly: the dependencies under the project’s control, referenced in
pyproject.toml. Transitive dependencies always render outside the boxes, exactly like the transitive dependencies of the primary set.- kind: SubgraphKind¶
Whether the box represents a dependency group or an optional extra.
- duplicates: set[str]¶
Directly-declared packages owned by a sibling box.
Rendered as display-only duplicate nodes tied to the real node by a dotted identity link. See
attribute_subgraph_packages().
- repomatic.deps.dep_graph.MERMAID_RESERVED_KEYWORDS: frozenset[str] = frozenset({'C4Component', 'C4Container', 'C4Deployment', 'C4Dynamic', '_blank', '_parent', '_self', '_top', 'call', 'class', 'classDef', 'click', 'end', 'flowchart', 'flowchart-v2', 'graph', 'interpolate', 'linkStyle', 'style', 'subgraph'})¶
Mermaid keywords that cannot be used as node IDs.
- repomatic.deps.dep_graph.normalize_package_name(name)[source]¶
Normalize package name for use as Mermaid node ID.
Converts to lowercase and replaces non-alphanumeric characters with underscores. Appends
_0suffix to avoid conflicts with Mermaid reserved keywords.- Return type:
- repomatic.deps.dep_graph.resolve_subgraph_selection(kind, explicit, select_all, excluded, only, config_all, config_excluded)[source]¶
Resolve which groups or extras the graph should render.
Mirrors one selection axis of the
update-dep-graphcommand: explicit CLI values win over the[tool.repomatic] dependency-graphdefaults;--only-*replaces the explicit selection;--all-*expands to every name declared inpyproject.toml;--no-*prunes last.- Parameters:
kind (
SubgraphKind) – The axis to resolve, groups or extras.explicit (
tuple[str,...]) – Names selected one by one (--group/--extra).select_all (
bool) – Select every declared name (--all-groups/--all-extras).excluded (
tuple[str,...]) – Names to prune from the selection (--no-group/--no-extra).only (
tuple[str,...]) – Names selected in exclusive mode (--only-group/--only-extra).config_all (
bool) – Configured default for select_all, applied when no selection flag is passed.config_excluded (
Sequence[str]) – Configured default for excluded.
- Return type:
- Returns:
Selected names, or
Nonewhen the axis is not requested at all.
- repomatic.deps.dep_graph.get_cyclonedx_sbom(package=None, groups=None, extras=None, frozen=True)[source]¶
Run uv export and return the CycloneDX SBOM as a dictionary.
Results are cached to avoid redundant subprocess calls within the same process.
- Parameters:
package (
str|None) – Optional package name to focus the export on.groups (
tuple[str,...] |None) – Optional dependency groups to include (e.g., “test”, “typing”).extras (
tuple[str,...] |None) – Optional extras to include (e.g., “xml”, “json5”).frozen (
bool) – If True, use –frozen to skip lock file updates.
- Return type:
- Returns:
Parsed CycloneDX SBOM dictionary.
- Raises:
subprocess.CalledProcessError – If uv command fails.
json.JSONDecodeError – If output is not valid JSON.
- repomatic.deps.dep_graph.get_package_names_from_sbom(sbom)[source]¶
Extract all package names from a CycloneDX SBOM.
- repomatic.deps.dep_graph.build_dependency_graph(sbom)[source]¶
Build a dependency graph from CycloneDX SBOM data.
- Parameters:
- Return type:
- Returns:
Tuple of (root_name, package_names, edges_list) where: - root_name is the root package name - package_names is the set of all package names - edges_list is a list of (from_name, to_name) tuples
- repomatic.deps.dep_graph.filter_root_edges(root_name, edges, main_deps, subgraphs)[source]¶
Drop root edges that no
pyproject.tomldeclaration backs.uv’s CycloneDX export hangs a dependency-group package off the root as soon as that package lands in the resolved component set, whether or not the group was requested. Exporting click-extra with
--extra sphinxand no--groupis enough forrequeststo come back as a direct dependency of the project: Sphinx pulls it in, thetestgroup happens to declare it too, and the export conflates the two. Neither omitting--groupnor passing--no-default-groupssuppresses it.Left in place, such an edge lands the package in the primary dependencies box, labelled with the specifier of a group nobody asked for, claiming the project depends on something a plain install never installs. So the root’s direct dependencies are re-derived from
uv.lock, which records whatpyproject.tomldeclares rather than what resolution happened to produce.Edges into a box-owned package survive:
render_mermaid()turns those into the box’s dashed arrow. Edges that do not start at the root are never touched, so the dropped package keeps rendering as a transitive dependency of whatever actually pulls it in.- Parameters:
root_name (
str) – The root package name.edges (
list[tuple[str,str]]) – List of (from_name, to_name) edge tuples.main_deps (
set[str] |None) – Names the root declares as main dependencies, fromby_main.Nonewhen the lockfile describes no such package, in which case every edge is kept: missing data is not evidence that an edge is spurious.subgraphs (
Sequence[Subgraph]) – Boxes whose owned packages legitimately hang off the root.
- Return type:
- Returns:
The edge list, without the unbacked root edges.
- repomatic.deps.dep_graph.filter_graph_to_package(packages, edges, package)[source]¶
Filter the graph to only include dependencies of a specific package.
- repomatic.deps.dep_graph.trim_graph_to_depth(root_name, packages, edges, depth)[source]¶
Trim the graph to only include nodes within a given depth from the root.
Performs a breadth-first traversal from the root, keeping only nodes reachable within
depthhops and edges between those nodes.- Parameters:
- Return type:
- Returns:
Filtered (packages, edges) tuple.
- repomatic.deps.dep_graph.render_mermaid(root_name, packages, edges, subgraphs=None, lock_specs=None)[source]¶
Render the dependency graph as a Mermaid flowchart.
Warning
Output must stay compatible with the Mermaid version bundled in
sphinxcontrib-mermaid. See module docstring for details.Every box holds only directly-declared dependencies, drawn as hexagons with a thick border; transitive dependencies render outside the boxes as plain ovals. See the module docstring.
- Parameters:
root_name (
str) – The root package name (used to highlight it).edges (
list[tuple[str,str]]) – List of (from_name, to_name) edge tuples.subgraphs (
list[Subgraph] |None) – Boxes to render, in display order (extras before groups keeps them closer to the main dependencies). SeeSubgraph.lock_specs (
LockSpecifiers|None) – Optional specifiers extracted fromuv.lock. Provides edge labels (by_package) and subgraph node labels (by_subgraph).
- Return type:
- Returns:
Mermaid flowchart string.
- repomatic.deps.dep_graph.attribute_subgraph_packages(subgraph_closures, base_packages, direct_packages, edges, root_name)[source]¶
Attribute each directly-declared package to one owning subgraph box.
Boxes only hold the packages their group/extra declares directly; transitive dependencies stay outside every box (see the module docstring). A directly-declared package can still be claimed by several boxes, but a graph node can live in only one: the declarer whose closure holds the most dependents wins the real node (declaration order breaks ties), since arrows point where the package is consumed and the busiest box is its most natural home. The root is not a dependent, as it reaches every declared package by definition.
The losing declarers list the package as a duplicate headline so every box still shows the dependency it exists to install (rendered as a display-only duplicate node by
render_mermaid()). For example thecarapaceandyamlextras both declare onlypyyaml, which no other package depends on: the dependent counts tie at zero,carapaceowns the node by declaration order, andyamlcarriespyyamlas a duplicate.- Parameters:
subgraph_closures (
list[tuple[str,set[str]]]) – Ordered(name, closure_package_names)pairs. Order is the last-resort tie-break for shared packages (first wins).base_packages (
set[str]) – Packages in the base set, excluded from every box.direct_packages (
dict[str,set[str]]) – Map of subgraph name to the package names it declares directly (fromuv.lock), keyed by SBOM-normalized name.edges (
list[tuple[str,str]]) –(from_name, to_name)dependency edges from the full SBOM, used to count each declaring subgraph’s local dependents.root_name (
str) – The root package name, excluded from dependent counts.
- Return type:
- Returns:
(owned, duplicates). owned maps each subgraph to the declared packages it renders as real nodes; duplicates maps it to declared packages owned by a sibling box.
- repomatic.deps.dep_graph.generate_dependency_graph(package=None, groups=None, extras=None, frozen=True, depth=None, exclude_base=False)[source]¶
Generate a Mermaid dependency graph.
Each requested group/extra renders as a box holding only the packages it declares directly; the transitive dependencies they pull in render outside the boxes, like the transitive dependencies of the main set.
- Parameters:
package (
str|None) – Optional package name to focus on. If None, shows the entire project dependency tree.groups (
tuple[str,...] |None) – Optional dependency groups to include (e.g., “test”, “typing”).extras (
tuple[str,...] |None) – Optional extras to include (e.g., “xml”, “json5”).frozen (
bool) – If True, use –frozen to skip lock file updates.depth (
int|None) – Optional maximum depth from root. If None, shows the full tree.exclude_base (
bool) – If True, exclude main (base) dependencies from the graph, showing only packages unique to the requested groups/extras. Used by--only-groupand--only-extra.
- Return type:
- Returns:
The graph in Mermaid format.
repomatic.deps.dep_policy module¶
How a dependency is declared, as opposed to where it resolves from.
dep_sources answers “can this ship”: a git branch or a local
path breaks the install for whoever pulls the published artifact, so those
findings block a release. This module answers a narrower question that never
blocks anything: is the declaration written the way the project’s own version
policy says to write it.
The split is what keeps both halves honest. A style finding that could stop a release would eventually be silenced rather than fixed; a shippability finding that only warned would ship a broken wheel.
Only rules decidable from pyproject.toml alone live here. Whether a floor is
justified by the APIs the code actually calls is the judgment call
/repomatic-deps review exists for, and it stays there: no amount of parsing
settles it, and a checker that guessed would train people to ignore it.
The rules, and what each one costs the reader when broken:
An upper bound on a runtime dependency caps everyone downstream, and the cap outlives whatever release prompted it. See Should You Use Upper Bound Version Constraints?
A bare dependency pins nothing, so the install that passed CI and the one a user gets can differ by a major version.
An unsorted list makes every addition a merge conflict candidate and hides duplicates.
A type stub outside the ``typing`` group installs at runtime for users who will never type-check.
A floor with no comment cannot be audited: the next reader has no way to tell a deliberate API minimum from a number a bot last touched.
A floor comment that runs long has stopped justifying the floor and started narrating how it got there. Each bump appends a paragraph about a version no longer in force, and the one claim that matters (what breaks below the floor that is declared) ends up buried in superseded history the git log already keeps.
- repomatic.deps.dep_policy.RUNTIME_LOCATION = '[project] dependencies'¶
Where a runtime dependency is declared, as the report spells it.
- repomatic.deps.dep_policy.STUB_PREFIX = 'types-'¶
Distribution-name prefix marking a PEP 561 stub-only package.
- repomatic.deps.dep_policy.STUB_GROUP = 'typing'¶
Dependency group stub-only packages belong in.
They are build-time inputs to a type checker, so installing them anywhere a user’s runtime environment reaches is pure weight.
- repomatic.deps.dep_policy.UPPER_BOUND_OPERATORS = ('<', '<=', '==', '!=', '~=')¶
Specifier operators that cap a runtime dependency from above.
~=is included because it implies a ceiling:~=1.2is>=1.2, ==1.*. Conditional markers (python_version<'3.11') are not specifiers and never reach this list.
- class repomatic.deps.dep_policy.PolicyFinding(package, location, detail, consequence, remedy)[source]¶
Bases:
objectOne declaration that departs from the project’s version policy.
Deliberately not a
DepFinding: that type carries aSourceKindbecause every one of its findings is about where a package resolves from, and a style finding has no answer to give there.
- repomatic.deps.dep_policy.count_comment_words(comment)[source]¶
Count the words of a comment run, ignoring the
#markers.Everything else counts as written, URLs and inline code included: a rationale leaning on three links is still three links the reader walks past on the way to the floor.
- Return type:
- repomatic.deps.dep_policy.scan_policy(pyproject_path, comment_word_threshold=0)[source]¶
Every declaration in pyproject_path that departs from version policy.
Entirely offline, reading only
pyproject.toml, so it costs nothing to run on every push.- Parameters:
- Return type:
- Returns:
Findings sorted by location, then by package.
repomatic.deps.dep_report module¶
Shared rendering of dependency-update reports.
The markdown diff, held-back, and cooldown-bypass tables, the release-notes
sections, and the comparison URLs that every updater’s PR body and terminal
output route through: sync-uv-lock, sync-deps, sync-dep-sources, the
three version-sync bumpers (sync-tool-versions, sync-action-pins,
sync-workflow-pins), and audit --fix.
The computations stay with their datasources (repomatic.deps.uv for the lock,
repomatic.release.version_sync for GitHub/PyPI/npm); this module only renders
their results.
- repomatic.deps.dep_report.RELEASE_NOTES_MAX_LENGTH = 2000¶
Maximum characters per package release body before truncation.
- repomatic.deps.dep_report.link_name(name, name_urls)[source]¶
Render a table’s subject cell, linked when a URL is known for it.
- repomatic.deps.dep_report.markdown_section(heading, note, headers, rows)[source]¶
Assemble a report section: heading, optional intro, and a table.
The one place the section layout is defined, so every updater’s PR body keeps the same shape. The alignment row is derived from headers rather than written out, which is what stops a column from being added to one and not the other.
- Parameters:
heading (
str) – Full heading line, emoji included, without the##. Omitted when empty, for a caller embedding the table under a title of its own (the release PR’s blocker banner sits inside a[!CAUTION]blockquote that already says what it is).note (
str) – Intro paragraph shown between heading and table. Omitted when empty.rows (
list[tuple[str,...]]) – One tuple of pre-rendered cells per row, each as long as headers.
- Return type:
- Returns:
The rendered markdown, with no trailing newline.
- repomatic.deps.dep_report.format_upload_date(iso_datetime)[source]¶
Format an ISO 8601 datetime as a human-readable date string.
- repomatic.deps.dep_report.format_released(raw_upload, reference)[source]¶
Format an upload time as a date, optionally with a relative hint.
- Parameters:
- Return type:
- Returns:
A string like
2026-06-24 (2 days ago), the bare date when reference isNone, or empty when raw_upload is empty.
- repomatic.deps.dep_report.format_eligible(eligible, today)[source]¶
Render an eligibility date with a human-readable countdown.
- repomatic.deps.dep_report.pypi_name_urls(changes)[source]¶
Map each changed package name to its PyPI project URL.
Convenience for
format_diff_table()’sname_urlswhen the changes come from a PyPI-resolved source (sync-uv-lock,fix-vulnerable-deps).
- repomatic.deps.dep_report.format_exclude_newer_note(exclude_newer)[source]¶
Render the uv
exclude-newercutoff sentence for a diff table.The
format_diff_table()counterpart forsync-uv-lockandfix-vulnerable-deps, which gate on uv’s absoluteexclude-newertimestamp. The relative-cooldown updaters (repomatic.release.version_sync) render their ownminimum-release-agenote instead.- Parameters:
exclude_newer (
str) – ISO 8601 datetime from the lock’s[options].exclude-newer, as carried byrepomatic.deps.uv.LockFile.exclude_newer, or empty.- Return type:
- Returns:
A one-line markdown note, or empty when exclude_newer is empty.
- repomatic.deps.dep_report.format_diff_table(changes, upload_times=None, cooldown_note='', comparison_urls=None, reference_date=None, name_urls=None, heading='Updated packages', subject='Package', released_overrides=None)[source]¶
Format version changes as a markdown table with heading.
The shared PR-body table for every dependency updater (
sync-uv-lock,fix-vulnerable-deps,sync-tool-versions,sync-action-pins,sync-workflow-pins) so they all render identically.When
upload_timesis provided, a “Released” column is added so reviewers can visually verify that all updated packages respect the cooldown. A row whose version was decided outside that cooldown check (the upstream toolkit’s lockstep-aligned pin) marks itself throughreleased_overridesinstead of showing a date, so the exemption reads as deliberate rather than as missing data. Whencooldown_noteis provided, that pre-rendered sentence (the absoluteexclude-newercutoff for uv, or the relativeminimum-release-agecutoff for the version-sync updaters) is shown above the table.- Parameters:
changes (
list[tuple[str,str,str]]) – List of(name, old_version, new_version)tuples as returned byrepomatic.deps.uv.diff_lock_versions().upload_times (
dict[str,str] |None) – Optional mapping of package names to ISO 8601 upload-time strings, as returned byrepomatic.deps.uv.parse_lock_upload_times().cooldown_note (
str) – Optional pre-rendered markdown sentence describing the cooldown cutoff, shown above the table. Build it withformat_exclude_newer_note()(uv) orrepomatic.release.version_sync.format_cooldown_note()(version-sync).comparison_urls (
dict[str,str] |None) – Optional mapping of names to comparison URLs, linked on the change cell (seebuild_comparison_urls()).reference_date (
date|None) – When set, each “Released” date gains a relative hint (2026-06-24 (2 days ago)) measured from this date.name_urls (
dict[str,str] |None) – Optional mapping of names to a URL the name links to (PyPI, GitHub, npm). Names absent from the mapping render plain. Passpypi_name_urls()for PyPI-sourced changes.heading (
str) – Noun after## 🆙(e.g.Updated tools).subject (
str) – Header for the first (name) column (e.g.Tool,Action).released_overrides (
dict[str,str] |None) – Optional mapping of names to literal markdown replacing their “Released” cell. An override on a changed name also forces the column on, even withoutupload_times; entries for unchanged names are ignored.
- Return type:
- Returns:
A markdown string with a
## 🆙 {heading}heading and table, or an empty string if there are no changes.
- class repomatic.deps.dep_report.HeldBackPackage(name, locked_version, available_version, released, eligible)[source]¶
Bases:
objectA newer release withheld from the lock by the
exclude-newercooldown.Built by
repomatic.deps.uv.compute_held_back_packages()for the ## Held back by cooldown report section: a package has already published a newer version, but it is still inside the cooldown window, souv lock --upgradekeeps the olderlocked_version.- released: str¶
Upload date of
available_version(YYYY-MM-DD), or empty when the lock records no upload time (a git or path source).
- eligible: str¶
Date
available_versionleaves the cooldown and becomes lockable, with a human-readable countdown (2026-06-25 (in 4 days)), or empty when it cannot be computed.
- repomatic.deps.dep_report.EXCLUDE_NEWER_HELD_BACK_NOTE = 'Newer releases already published but withheld because they are still inside the [`exclude-newer`](https://docs.astral.sh/uv/reference/settings/#exclude-newer) cooldown window.'¶
Intro paragraph for the
sync-uv-lockheld-back section.The
repomatic.release.version_syncupdaters pass their ownminimum-release-agewording toformat_held_back_table()instead.
- repomatic.deps.dep_report.HELD_BACK_COLUMNS = ('Locked', 'Available', 'Released', 'Eligible')¶
Held-back columns following the caller-supplied subject column.
Shared with
repomatic.sync_ops.print_held_back_table(), so a run’s markdown PR body and its terminal table name the same columns in the same order instead of drifting apart as two hand-kept literals.
- repomatic.deps.dep_report.build_held_back(name, pinned, available, available_date, min_age, today)[source]¶
Assemble a
HeldBackPackagerow from raw selection data.The formatting half of the version-sync held-back report:
repomatic.release.version_sync.select_held_back()picks the withheld candidate, and this turns its raw version and upload date into the samereleased/eligiblestringsrepomatic.deps.uv.compute_held_back_packages()produces for uv, soformat_held_back_table()renders both identically. Unlike the uv path, no second resolution is needed: the candidates are already in hand from the datasource sweep.- Parameters:
name (
str) – Display name (package, action slug, or tool).pinned (
str) – Version this run settled on (held in place by the cooldown).available (
str) – The newer version still inside the cooldown window.available_date (
str) – Upload date of available (YYYY-MM-DD), or empty.min_age (
timedelta) – Theminimum-release-agecooldown width.today (
date) – Reference date for the relative countdown.
- Return type:
- Returns:
A populated
HeldBackPackage.
- repomatic.deps.dep_report.format_held_back_table(held_back, note='Newer releases already published but withheld because they are still inside the [`exclude-newer`](https://docs.astral.sh/uv/reference/settings/#exclude-newer) cooldown window.', *, name_urls=None, subject='Package')[source]¶
Format cooldown-withheld releases as a markdown section.
Shared by every cooldown-gated updater:
sync-uv-lock(rows fromrepomatic.deps.uv.compute_held_back_packages()) and the version-sync commands (rows frombuild_held_back()), so the section renders identically.- Parameters:
held_back (
list[HeldBackPackage]) – Withheld releases asHeldBackPackagerows.note (
str) – Intro paragraph describing the cooldown. Defaults to the uvexclude-newerwording; version-sync passes itsminimum-release-agewording.name_urls (
dict[str,str] |None) – Optional mapping of names to a URL the name links to (PyPI, GitHub, npm). Names absent from the mapping render plain.subject (
str) – Header for the first column (e.g.Action,Tool).
- Return type:
- Returns:
A markdown string with a
## ⏸️ Held back by cooldownheading and table, or an empty string when held_back is empty.
- repomatic.deps.dep_report.BYPASS_NEEDS_RELEASE = 'needs release'¶
Expiry placeholder for a freeze holding an unreleased version.
A fixed-timestamp
exclude-newer-packageentry whose held version has no upload time in the lock (a git, path, or otherwise unpublished source) can never age past the rollingexclude-newercutoff on its own: the freeze only ends once the package ships a release the lock can adopt. The markdown report renders the marker in italics to set it apart from real dates.
- class repomatic.deps.dep_report.BypassForecast(name, held_version, expires)[source]¶
Bases:
objectA cooldown-bypass freeze and the date it self-clears.
Built by
repomatic.deps.uv.compute_bypass_forecasts()(freezes still active) andrepomatic.deps.uv.compute_pruned_forecasts()(freezes the run just cleared) for the## ❄️ Cooldown bypassesreport section: a fixed-timestampexclude-newer-packageentry holdsnameatheld_versionuntil that version ages past theexclude-newercutoff, at which pointsync-uv-lockprunes the entry and the package resumes normal cooldown resolution.- expires: str¶
Date the freeze expires and the entry is pruned, with a human-readable countdown (
2026-07-08 (in 2 days), in the past for an already-cleared freeze),BYPASS_NEEDS_RELEASEwhen the held version has no upload time in the lock, or empty when there is no rollingexclude-newerspan to forecast against.
- repomatic.deps.dep_report.BYPASS_SECTION_NOTE = 'Packages pulled in ahead of the cooldown by an [`exclude-newer-package`](https://docs.astral.sh/uv/reference/settings/#exclude-newer-package) freeze. Each entry is cleared from `pyproject.toml` automatically once its held version ages past the `exclude-newer` cutoff.'¶
Intro paragraph for the
sync-uv-lockcooldown-bypasses section.
- repomatic.deps.dep_report.BYPASS_COLUMNS = ('Package', 'Held at', 'Held until')¶
Columns of the cooldown-bypass table.
Shared with
repomatic.sync_ops.print_bypass_table()for the reasonHELD_BACK_COLUMNSis.
- repomatic.deps.dep_report.format_bypass_section(forecasts, pruned=None, frozen=None, *, name_urls=None)[source]¶
Format the cooldown-bypass lifecycle as a single markdown table.
The
sync-uv-lockreport section coveringexclude-newer-packagefreezes. Every lifecycle state is a row in one table so the section scans like the## 🆙 Updated packagesone: freezes still active render plain, entries this run rewrote into freeze cutoffs are labelled📌 frozen:, and expired entries this run removed frompyproject.tomlare labelled🧹 cleared:, keeping the version and expiry data the freeze had. A freeze holding an unreleased version is labelled🚧 unreleased:and itsBYPASS_NEEDS_RELEASEexpiry renders in italics.- Parameters:
forecasts (
list[BypassForecast]) – Active freezes fromrepomatic.deps.uv.compute_bypass_forecasts().pruned (
list[BypassForecast] |None) – Expired entries the run removed, snapshot byrepomatic.deps.uv.compute_pruned_forecasts()before the prune.frozen (
list[str] |None) – Names of the entries the run rewrote into freeze cutoffs; their forecasts rows get the📌 frozen:label.name_urls (
dict[str,str] |None) – Optional mapping of names to a URL the name links to. Names absent from the mapping render plain.
- Return type:
- Returns:
A markdown string with a
## ❄️ Cooldown bypassesheading and table, or an empty string when there is no row to report.
- repomatic.deps.dep_report.fetch_release_notes(changes)[source]¶
Fetch release notes for all updated packages.
For each package with a new version, discovers the GitHub repository via PyPI and fetches the release notes from GitHub Releases for all versions in the range
(old, new]. Falls back to a changelog link from PyPIproject_urlswhen no GitHub Release exists.- Parameters:
changes (
list[tuple[str,str,str]]) – List of(name, old_version, new_version)tuples.- Return type:
- Returns:
A dict mapping package names to
(repo_url, versions)tuples whereversionsis a list of(tag, body)pairs sorted ascending. Only packages with at least one non-empty body are included. When a changelog URL is used as fallback,tagis empty andbodycontains a markdown link.
- repomatic.deps.dep_report.format_release_notes(notes)[source]¶
Render release notes as collapsible
<details>blocks.A
### Release notesheading (an h3, nesting the section under the PR body’s h2 update table) with one collapsible section per package, each version introduced by an h4 tag heading. Long release bodies are truncated toRELEASE_NOTES_MAX_LENGTHcharacters with a link to the full release.- Parameters:
notes (
dict[str,tuple[str,list[tuple[str,str]]]]) – A dict mapping package names to(repo_url, versions)tuples whereversionsis a list of(tag, body)pairs, as returned byfetch_release_notes().- Return type:
- Returns:
A markdown string with the release notes section, or an empty string if no notes are available.
- repomatic.deps.dep_report.build_comparison_urls(changes, notes)[source]¶
Build GitHub comparison URLs from version changes and release notes.
Uses the tag format discovered by
fetch_release_notes()to construct comparison URLs. Only packages with both old and new versions and a known GitHub repository are included.A package whose notes carry no tag at all is skipped. That happens when
fetch_release_notes()found no GitHub release for the range and fell back to a changelog link, which is positive evidence that the tags this URL would name do not exist: guessing avprefix there yields a 404 in the PR body.- Parameters:
- Return type:
- Returns:
Dict mapping package names to GitHub comparison URLs.
repomatic.deps.dep_sources module¶
Swap git-tracked dependencies back to their released versions, and refuse to release while one is still in place.
Two halves, both about where a dependency actually comes from.
The sync-dep-sources updater manages one precise idiom: a dependency
temporarily consumed from a git branch while its next release is awaited.
The idiom is machine-recognizable because it pairs two declarations in
pyproject.toml:
a
[tool.uv.sources]entry tracking a branch (not arevortagpin), anda dev-version floor on the same package (like
mango>=2.1.0.dev0), whose base version names the awaited release.
Once the awaited release ships on the index, the swap rewrites the project
back to released artifacts: the source override is dropped, the .dev floor
is tightened to its base release, and a cooldown-bypass freeze adopts the
release through the exclude-newer window (the same deliberate-bypass
mechanism audit --fix uses for security fixes). The freeze then ages out
and is pruned by the ordinary sync-uv-lock lifecycle.
Note
The dev floor is authoritative, deliberately: the project declares that
anything from the awaited release onward satisfies it. If the project
quietly grew a dependency on branch commits newer than the release, the swap
PR’s CI run exposes the stale declaration, and the correction (bumping the
floor to the next .dev version, which retracts the swap on the next run)
is exactly the fix the project needed anyway. Overrides outside the idiom
(path or workspace sources, rev/tag pins, floor-less branch tracks) are
never touched.
The lint-deps gate is the other half, and it covers what the swap does not.
A dependency is shippable when whoever installs the published artifact from
an index gets the same code the release was tested against. scan_project()
reports every way that breaks, and the release lane refuses to build a package
while one stands. See DepFinding for the failure classes, and
docs/dependencies.md § Shippable sources for the worked example.
- repomatic.deps.dep_sources.LINT_DEPS_HEADER_DEFS: tuple[tuple[str, str], ...] = (('Package', 'package'), ('Source', 'kind'), ('Declared in', 'location'), ('Verdict', 'verdict'))¶
Column definitions for the
repomatic lint-depstable.Lives beside
DepFindingso the columns and the fields they render cannot drift apart; the CLI derives its--sort-bychoices from it.DepFinding.consequenceandDepFinding.remedyare deliberately not columns: each runs to a couple of sentences, which in a fifth column pushes the other four off the side of any terminal. They are printed as the annotation line under the table instead, where the width is the screen’s rather than the widest cell’s.
- repomatic.deps.dep_sources.WHEEL_METADATA_TABLES = ('project.dependencies', 'project.optional-dependencies')¶
Requirement arrays whose entries land in the published
Requires-Dist.[dependency-groups](PEP 735) is deliberately absent: it never reaches distribution metadata. That is what separates a finding an installer trips over from one only a contributor does, which the report says out loud even though both block.
- repomatic.deps.dep_sources.LOWER_BOUND_OPERATORS = frozenset({'>', '>='})¶
PEP 440 operators that put a floor under a requirement.
The one spelling of “lower bound” this module tests specifiers against. Three checks used to each carry their own operator tuple, and the sets had drifted: a
>-style floor was seen by the dev-floor discovery but escaped the cooldown gate entirely. A site that also treats an exact pin as a bound unionsPIN_OPERATORin explicitly, so the difference stays a decision rather than an accident.
- repomatic.deps.dep_sources.PIN_OPERATOR = frozenset({'=='})¶
The PEP 440 operator pinning a requirement to one version.
A pin demands its version the way a floor demands its minimum, so the checks asking “can PyPI serve what this declaration requires” test both; the checks looking for the managed git-branch floor idiom deliberately do not.
- repomatic.deps.dep_sources.DEV_BOUND_PATTERN = re.compile('(?P<op>>=?)\\s*(?P<version>[0-9][A-Za-z0-9.!+]*)')¶
Lower-bound clauses in a PEP 508 requirement string.
Captures the operator and the version literal so
strip_dev_bounds()can rewrite>=2.1.0.dev0into>=2.1.0in place, leaving extras, markers, and every other clause byte-for-byte untouched.
- repomatic.deps.dep_sources.TOML_TABLE_HEADER = re.compile('\\s*\\[{1,2}\\s*(?P<path>[^]]+?)\\s*\\]{1,2}\\s*$')¶
A
[table]or[[array of tables]]header, capturing its dotted path.Enough TOML parsing for
declaration_anchor()to tell which table a line sits in. The parsed document cannot answer that:tomllibandtomlkitboth return values, and a line number is what a link needs.
- class repomatic.deps.dep_sources.ReleaseSwap(name, source_key, branch, floor, release, released)[source]¶
Bases:
objectA git-tracked dependency whose awaited release has shipped.
Built by
find_ready_swaps(); consumed byapply_release_swaps()(thepyproject.tomlrewrite) andformat_swap_section()(the PR report).- source_key: str¶
The entry key as written in
[tool.uv.sources](may differ fromnamein case or separators).
- property freeze_cutoff: str¶
The
exclude-newer-packagecutoff adoptingrelease.Delegates the margin policy to
repomatic.deps.uv.freeze_cutoff_after(): every distribution file of the adopted release sits inside the window even when its uploads straddle midnight, while the global cooldown still shields anything newer.
- repomatic.deps.dep_sources.tracked_git_overrides(pyproject_path)[source]¶
Read the
[tool.uv.sources]entries tracking a git branch.Only single-source entries carrying both a
gitURL and abranchare returned: arevortagpin is a deliberate point-in-time choice, a path or workspace source is a local development arrangement, and a multi-source list (per-platform markers) is too bespoke to rewrite. None of those encode “waiting for the next release”.
- repomatic.deps.dep_sources.requirement_arrays(doc)[source]¶
Yield every requirement array in a parsed
pyproject.toml, labelled.Covers
[project.dependencies], each[project.optional-dependencies]extra, each[dependency-groups]group, and[build-system].requires. Non-list values and non-string items (like{include-group = …}entries) are the callers’ concern.The label is the TOML path the array sits at, so a finding can name where a declaration lives rather than just which package it names.
lint-depsreports it, and it is also what separates the tables that reach the published wheel’sRequires-Distfrom the ones that never leave the repository.
- repomatic.deps.dep_sources.parse_requirement(item)[source]¶
Parse a requirement array item, returning
Nonefor anything else.
- repomatic.deps.dep_sources.dev_floor(pyproject_path, name)[source]¶
The highest
.devlower bound declared for name, if any.Scans every requirement array for lower-bound clauses (
>=or>) whose version is a dev release. The highest one is the project’s declared “awaited release” threshold.
- repomatic.deps.dep_sources.floors_inside_cooldown(pyproject_path, lock_path, window)[source]¶
Dependency floors that no cooldown-gated resolution can satisfy.
A floor naming a version published inside the cooldown window makes the published package uninstallable. Anyone resolving it from an index (a downstream repo running a frozen workflow’s
uvx 'repomatic==X.Y.Z', or an end user runninguvx repomatic) gets a tool environment, which reads neitheruv.locknor[tool.uv] exclude-newer-package. Since uv exposes no environment variable for a per-package exemption either, there is nowhere for them to record the bypass.Caution
This repository cannot feel the breakage it would ship. Its own workflows install from
uv.lock(seerepomatic.release.prepare_release.LOCAL_CLI_INVOCATION), which resolves through the localexclude-newer-packageexemption and stays green. The failure lands only on whoever installs the release, which is why it needs a gate here rather than a red CI run to catch it.Wait for a release to age out of the window before raising a floor onto it.
The comparison runs against the locked version’s upload time, which
uv.lockrecords, so the check needs no network. A floor is reported when the locked version sits inside the window and the floor demands at least that version: releases reach an index in version order, so nothing satisfying such a floor can be older than what is already locked.- Parameters:
- Return type:
- Returns:
Mapping of canonical package name to the offending floor version, empty when every floor resolves without an exemption.
- repomatic.deps.dep_sources.find_ready_swaps(pyproject_path)[source]¶
Probe the index for git-tracked packages whose awaited release shipped.
For each branch-tracking override inside the managed idiom, the awaited release is considered shipped once PyPI carries a stable (non-prerelease, non-yanked) version satisfying the dev floor. The newest such release is adopted. Index misses (an unpublished package, a network failure) read as “not ready”: a swap needs positive confirmation, so the failure mode is always a skipped run, never a wrong rewrite.
- Parameters:
pyproject_path (
Path) – Path to thepyproject.tomlfile.- Return type:
- Returns:
Ready swaps sorted by package name; empty when there is nothing to do.
- repomatic.deps.dep_sources.strip_dev_bounds(requirement, release)[source]¶
Tighten a requirement string’s
.devlower bounds to their release.Rewrites only the version literal of
>=/>clauses whose version is a dev release older than or equal to release, replacing it with its base version (>=2.1.0.dev0becomes>=2.1.0). Everything else in the string (extras, markers, other clauses, spacing) is preserved byte-for-byte.
- repomatic.deps.dep_sources.apply_release_swaps(pyproject_path, swaps)[source]¶
Rewrite
pyproject.tomlfor the given swaps, in one pass.Two of the three swap edits happen here: the
[tool.uv.sources]override is removed (and the emptied table with it), and every.devfloor on the swapped packages is tightened to its base release. The third edit, the cooldown-bypass freeze atReleaseSwap.freeze_cutoff, goes throughrepomatic.deps.uv.upsert_exclude_newer_packages()so the insertion position and inline-table formatting stay canonical.- Parameters:
pyproject_path (
Path) – Path to thepyproject.tomlfile.swaps (
list[ReleaseSwap]) – Ready swaps fromfind_ready_swaps().
- Return type:
- repomatic.deps.dep_sources.SWAP_SECTION_NOTE = 'Dependencies tracked from a git branch while awaiting a release, swapped back to the package index: the `[tool.uv.sources]` override is dropped, the `.dev` version floor is tightened to its release form, and a cooldown bypass freezes the adoption until it ages past the [`exclude-newer`](https://docs.astral.sh/uv/reference/settings/#exclude-newer) cutoff.'¶
Intro paragraph for the
sync-dep-sourcesswap section.
- repomatic.deps.dep_sources.format_swap_section(swaps, *, name_urls=None, reference_date=None)[source]¶
Format the release swaps as a markdown section.
The
sync-dep-sourcesreport section explaining thepyproject.tomlhunks: one row per swapped package, with the branch it tracked, the release it adopted, and when that release shipped.- Parameters:
swaps (
list[ReleaseSwap]) – Ready swaps fromfind_ready_swaps().name_urls (
dict[str,str] |None) – Optional mapping of names to a URL the name links to. Names absent from the mapping render plain.reference_date (
date|None) – When set, the “Released” date gains a relative hint measured from this date.
- Return type:
- Returns:
A markdown string with a
## 🔀 Source swapsheading and table, or an empty string when swaps is empty.
- class repomatic.deps.dep_sources.SourceKind(*values)[source]¶
Bases:
StrEnumWhere a dependency is resolved from.
The vocabulary is shared by
[tool.uv.sources]anduv.lock, which name the same concepts with the same keys, soclassify_source()reads both. OnlyREGISTRYdescribes something an installer of the published artifact can reach on its own.- DIRECT_REFERENCE = 'direct reference'¶
A PEP 508
name @ urlclause written into the requirement itself.
- GIT = 'git'¶
A git repository, whether tracked by branch, tag or commit.
- INDEX = 'index'¶
A named
[[tool.uv.index]]other than PyPI.
- PATH = 'path'¶
A local directory, including an editable install or a lock
directoryentry.
- REGISTRY = 'registry'¶
A package index. Shippable when the index is PyPI.
- URL = 'url'¶
A direct artifact URL (a wheel or sdist served over HTTP).
- WORKSPACE = 'workspace'¶
Another member of the same uv workspace.
- class repomatic.deps.dep_sources.DepFinding(package, kind, location, detail, consequence, remedy, level=AnnotationLevel.ERROR, allowed='')[source]¶
Bases:
objectOne reason the project cannot be published as it stands.
Findings are what
scan_project()returns, and they carry their own explanation rather than a code the caller has to map: the CLI table, the GitHub annotation and the release PR banner all render the same three sentences, so a maintainer reads one wording wherever they meet it.- kind: SourceKind¶
How that package is resolved.
- level: AnnotationLevel = 'error'¶
Severity. Only
ERRORblocks a release.
- repomatic.deps.dep_sources.classify_source(value)[source]¶
Read a
[tool.uv.sources]entry or auv.locksource table.Both spell the same concepts with the same keys, so one classifier serves the declaration and its resolution. Checked most-specific first: a
{ path = "…", editable = true }entry is a path source, not two.- Parameters:
value (
object) – The mapping sitting under a source key.- Return type:
- Returns:
The kind, or
Nonefor anything unrecognized (a bare marker table, a future uv key). Unknown shapes are not reported: a gate that guesses would block releases over syntax it does not understand.
- repomatic.deps.dep_sources.declared_requirements(doc, name)[source]¶
Every requirement string declaring name, with its TOML location.
A
[tool.uv.sources]entry says where a package comes from but not whether anyone downstream will feel it. That answer lives in the requirement arrays, and it is what decides the consequence: a package named in[project.dependencies]ships a requirement the index must satisfy, one named only in[dependency-groups]ships nothing at all, and one named nowhere is a transitive dependency being swapped underneath the resolver.
- repomatic.deps.dep_sources.scan_pyproject(pyproject_path, allow=None)[source]¶
Report every unshippable declaration in
pyproject.toml.Covers what the project says, which is the half a reader can act on directly:
a
[tool.uv.sources]entry resolving from anywhere but PyPI,a PEP 508 direct reference (
name @ git+…) in any requirement array,[build-system].requiresincluded,a
[[tool.uv.index]]markeddefaultthat is not PyPI,a non-empty
override-dependenciesorconstraint-dependencies, which is reported as a warning rather than a block: those name a version rather than an unreleased artifact, so what they change is the tested resolution, not the installability of the result.
- Parameters:
- Return type:
- Returns:
Findings, unsorted;
scan_project()orders them.
- repomatic.deps.dep_sources.scan_lock(lock_path, allow=None, doc=None)[source]¶
Report every package
uv.lockresolves from outside PyPI.The complement to
scan_pyproject(), and the reason the gate is not a list of hand-written rules: the lock records the resolved source of every package in the tree, so a git dependency pulled in by another git dependency shows up here even though no table inpyproject.tomlnames it.The project’s own entry is skipped. uv writes it as
{ editable = "." }for a package and{ virtual = "." }for a virtual project, and neither describes a dependency. A workspace member is a different path (like{ editable = "packages/mango" }) and is reported, since publishing this project does not publish that one.- Parameters:
lock_path (
Path) – Path to theuv.lockfile.allow (
dict[str,str] |None) – Package name to the reason it may ship from a non-index source.doc (
dict|None) – Parsedpyproject.toml, when the caller has one. Supplying it lets each finding say whether the package is declared directly, which is what separates “every install fails” from “a transitive dependency was swapped underneath the resolver”.
- Return type:
- Returns:
Findings, unsorted.
- repomatic.deps.dep_sources.scan_project(pyproject_path, lock_path, window, allow=None)[source]¶
Every reason this project cannot be released as it stands.
Folds the three checks into one ordered report: what
pyproject.tomldeclares (scan_pyproject()), whatuv.lockresolved (scan_lock()), and which floors no cooldown-gated resolution can satisfy (floors_inside_cooldown()). Entirely offline, so it costs nothing to run on every push and cannot fail on a flaky index.A package flagged by both halves is reported once, keeping the
pyproject.tomlfinding: that is where the reader has something to edit, the lock being a derived file.- Parameters:
- Return type:
- Returns:
Findings sorted by package, then by location.
- repomatic.deps.dep_sources.BLOCKER_SECTION_NOTE = 'A dependency is shippable when whoever installs the published artifact gets the code this release was tested against. These do not clear that bar, so the release lane refuses to build a package while they stand. See [Dependency management § Shippable sources](https://repomatic.net/dependencies#shippable-sources).'¶
Intro paragraph for the
lint-depsblocker section.
- repomatic.deps.dep_sources.format_blocker_section(findings, *, heading='🚧 Unshippable dependencies')[source]¶
Format blocking findings as a markdown section.
The long form, for the
lint-depsreport: a reader who opened that report came for the diagnosis, so it carries the note and the full table. The release PR getsbuild_release_readiness()instead, which is the same findings at banner length.- Parameters:
findings (
list[DepFinding]) – Findings fromscan_project().heading (
str) – Section heading, emoji included.
- Return type:
- Returns:
A markdown string, or an empty string when nothing blocks.
- repomatic.deps.dep_sources.declaration_anchor(finding, pyproject_path, lock_path)[source]¶
Locate the declaration behind a finding, as a repository-relative link.
Only two files can hold one:
uv.lockfor a source the resolver picked,pyproject.tomlfor everything the project wrote itself, dependency floors included.- Parameters:
finding (
DepFinding) – The finding to locate.pyproject_path (
Path) – Path to thepyproject.tomlfile.lock_path (
Path) – Path to theuv.lockfile.
- Return type:
- Returns:
The file name, suffixed with
#L{n}once the declaring line is found. A line that cannot be found degrades to the bare file rather than to a guess: an anchor pointing at the wrong line costs the reader more than no anchor at all.
- repomatic.deps.dep_sources.RELEASE_READY_SENTENCE = 'This PR is ready to be merged. '¶
How the release checklist opens when nothing blocks.
Trailing space included: it runs inline into the sentence the template follows it with, where the blocked form is a standalone blockquote instead.
- repomatic.deps.dep_sources.UNSHIPPABLE_BANNER_LEAD = 'Do not merge yet. This release would ship dependencies its users cannot install:'¶
Opening of the blocked form of the release PR’s verdict.
The banner is a verdict, not a report: it says what is wrong and names what to open. Everything else the finding carries (why the source is unshippable, what to do about it, the general rule) reads as chatter in a pull request whose body is otherwise a five-step checklist, and it is one click away in the
lint-depsreportformat_blocker_section()renders.
- repomatic.deps.dep_sources.build_release_readiness(pyproject_path, lock_path, window, allow=None, source_url=None)[source]¶
Build the release PR’s opening verdict.
The
prepare-releasechecklist has always opened with “This PR is ready to be merged”, and that sentence is a lie while a dependency resolves from a git branch, a fork or a local path. So the opening is owned here rather than hard-coded in the template: it stays that sentence while the project is releasable, and becomes a[!CAUTION]block naming every offending dependency when it is not.This is the layer that matters, even though the release lane carries a hard gate of its own. By the time that gate fires the freeze commit is already on
main, and the recovery is to burn the version perclaude.md§ Skip and move forward. This body is regenerated on every push tomain, so it carries the same answer days earlier, in the one place a maintainer reads before deciding to merge.Note
Lives here rather than beside the other
pr-bodytemplate-argument builders inrepomatic.github.pr_body, which is where it would otherwise belong: that module is imported byrepomatic.deps.dep_report, so reachingscan_project()from it closes an import cycle.- Parameters:
pyproject_path (
Path) – Path to thepyproject.tomlfile.lock_path (
Path) – Path to theuv.lockfile.window (
str) – Cooldown window, from[tool.repomatic] minimum-release-age.allow (
dict[str,str] |None) – Package name to itslint-deps.allowreason.source_url (
str|None) – Blob URL the declarations hang off, without a trailing slash (like{repo_url}/blob/{sha}). Each package links into it. A caller with no commit to point at passes nothing, and the packages render with their file and line as plain text instead.
- Return type:
- Returns:
RELEASE_READY_SENTENCE, or a one-line GitHub-flavored markdown[!CAUTION]blockquote naming what blocks the release.
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).
repomatic.deps.vulnerable_deps module¶
Vulnerability audit and remediation for locked dependencies.
Backs the audit command and the fix-vulnerable-deps job: queries the
advisory sources enabled in [tool.repomatic] vulnerable-deps.sources,
unions and deduplicates their findings into VulnerablePackage
records, and (--fix) upgrades each fixable package through uv.
Two advisory sources are consulted:
uv auditqueries the PyPA Advisory Database (OSV-backed).GitHub’s Dependabot alerts query the GitHub Advisory Database (GHSA).
Coverage diverges in practice: GHSA frequently lists a CVE before the PyPA
database mirrors it, and transitive lockfile vulnerabilities sometimes only
surface in GHSA. By unioning both sources, audit catches CVEs that either
database alone would miss.
- repomatic.deps.vulnerable_deps.AUDIT_HEADER_DEFS: tuple[tuple[str, str], ...] = (('Package', 'package'), ('Version', 'version'), ('Advisory', 'advisory'), ('Fixed', 'fixed'), ('Sources', 'sources'))¶
Column definitions for the
repomatic audittable.Lives beside the rows’ domain model so the columns and the fields they render cannot drift apart; the CLI derives its
--sort-bychoices from it.
- repomatic.deps.vulnerable_deps.MIN_UV_AUDIT_JSON_VERSION = <Version('0.11.15')>¶
Minimum
uvversion exposinguv audit --output-format json.The structured JSON output landed in uv 0.11.15 as a preview feature. Below this,
uv auditemits only human-readable text, so_run_uv_auditrefuses to run rather than silently scanning nothing.
- class repomatic.deps.vulnerable_deps.AdvisorySource(*values)[source]¶
Bases:
StrEnumWhere a vulnerability advisory was detected.
Each source has a distinct upstream database and ingestion pipeline, so coverage diverges in practice (e.g., GHSA frequently lists a CVE before the PyPA Advisory Database mirrors it). Tracking the source per
VulnerablePackagelets the union deduplicate by advisory ID while still attributing each entry to the database that produced it.- UV_AUDIT = 'uv-audit'¶
Detected by
uv audit(PyPA Advisory Database, OSV-backed).
- GITHUB_ADVISORIES = 'github-advisories'¶
Detected via the repository’s Dependabot alerts (GitHub Advisory Database).
- class repomatic.deps.vulnerable_deps.VulnerablePackage(name, current_version, advisory_id, advisory_title, fixed_version, advisory_url, aliases=<factory>, sources=<factory>, source_urls=<factory>)[source]¶
Bases:
objectA single vulnerability advisory for a Python package.
- aliases: set[str]¶
Alternate identifiers for the same advisory (CVE, GHSA, PYSEC, OSV).
Advisory databases cross-reference each other: the PyPA database (via
uv audit) keys records by OSV/PYSECIDs while listing the matchingGHSA/CVEIDs as aliases, and Dependabot keys byGHSAwhile listing theCVE.collect_vulnerable_packages()unions entries whose identifier sets overlap, so a shared alias deduplicates the same advisory reported under different primary IDs by different sources.
- sources: set[AdvisorySource]¶
Advisory databases that surfaced this entry.
A set rather than a single value because the same advisory can be reported by multiple sources after deduplication. Empty only for entries built without source attribution (test fixtures); every production code path records at least one source.
- source_urls: dict[AdvisorySource, str]¶
Per-source URL pointing to the advisory page in each database.
Each source has its own canonical URL even when reporting the same advisory ID (PyPA’s
osv.devpage vs. GitHub’s/advisories/page), so the rendered table can link the source name to the database that actually surfaced it.
- repomatic.deps.vulnerable_deps.parse_uv_audit_json(output)[source]¶
Parse
uv audit --output-format jsonoutput into vulnerability records.The structured contract avoids the regex fragility of scraping human-readable lines, and exposes the advisory
aliases(cross-referenced CVE/GHSA/PYSEC IDs) that letcollect_vulnerable_packages()deduplicate the same advisory across sources.- Parameters:
output (
str) – stdout fromuv audit --output-format json.- Return type:
- Returns:
A list of
VulnerablePackageentries (empty when the audit found nothing).- Raises:
RuntimeError – when the output is unusable as JSON (empty, malformed, or carrying an unrecognized
schema.version). Raising rather than returning an empty list keeps the scanner from silently passing when the preview schema changes under it.
- repomatic.deps.vulnerable_deps.format_vulnerability_table(vulns)[source]¶
Format vulnerability data as a markdown table.
Includes a
Sourcescolumn listing the advisory databases that surfaced each entry, so reviewers can see which database (PyPA Advisory DB, GitHub Advisory DB, or both) detected the vulnerability.- Parameters:
vulns (
list[VulnerablePackage]) – List ofVulnerablePackageentries.- Return type:
- Returns:
A markdown string with a
## Vulnerabilitiesheading and table, or an empty string if no vulnerabilities are provided.
- repomatic.deps.vulnerable_deps.collect_vulnerable_packages(lock_path, repo=None, sources=None)[source]¶
Collect vulnerability advisories from all configured sources.
Queries each enabled advisory database, then deduplicates entries per package by advisory identity: two entries merge when their identifier sets (
advisory_idplusaliases) overlap, so the same advisory reported under a PYSEC/OSV ID byuv auditand a GHSA ID by Dependabot collapses into one. Merging preserves the union ofsourcesso the rendered table credits both databases when they agree.Current versions reported by
uv audittake precedence over the empty placeholder produced by the GHSA path, sinceuv auditreads the actual locked version while Dependabot alerts only carry the vulnerable range. When the GHSA path encounters a package thatuv auditdid not surface, the current version is filled in from the lock file.- Parameters:
lock_path (
Path) – Path to theuv.lockfile.repo (
str|None) – Repository inowner/repoformat. Required for theAdvisorySource.GITHUB_ADVISORIESsource; passNoneto skip it (the result then reflectsuv auditonly).sources (
list[AdvisorySource] |None) – Advisory databases to consult. Defaults to all known sources.
- Return type:
- Returns:
Deduplicated list of
VulnerablePackageentries.
- repomatic.deps.vulnerable_deps.fix_vulnerable_deps(lock_path, repo=None, sources=None)[source]¶
Detect vulnerable packages and upgrade them in the lock file.
Queries every advisory source enabled by sources (defaults to all), then upgrades each fixable package with
uv lock --upgrade-packageusing--exclude-newer-packageto bypass theexclude-newercooldown for security fixes. Also persists the exemptions inpyproject.tomlso that subsequentuv lock --upgraderuns (e.g. from thesync-uv-lockjob) do not downgrade the fixed packages back within the cooldown window.An upgrade that resolves to the versions already locked leaves the file byte-identical to how it was found, because uv writes the overrides it was handed into the lock’s
[options]table even when they change nothing. See the restore in step 5.- Parameters:
lock_path (
Path) – Path to theuv.lockfile.repo (
str|None) – Repository inowner/repoformat. Required whenAdvisorySource.GITHUB_ADVISORIESis among sources.sources (
list[AdvisorySource] |None) – Advisory databases to consult. Defaults to all known sources.
- Return type:
- Returns:
A tuple of
(has_fixes, diff_table).has_fixesisTruewhen at least one vulnerable package was upgraded.diff_tableis a markdown-formatted string with vulnerability details and version changes, or an empty string if no fixable vulnerabilities were found.
- repomatic.deps.vulnerable_deps.fetch_dependabot_alerts(repo)[source]¶
Fetch open
pip-ecosystem Dependabot alerts for a repository.Calls
GET /repos/{repo}/dependabot/alerts?state=open&ecosystem=pipvia theghCLI, then maps each alert into aVulnerablePackagetagged withAdvisorySource.GITHUB_ADVISORIES.Returns an empty list when the API is unreachable, the token lacks the
Dependabot alertspermission, or the repository has no open alerts. A network or auth failure must not break the autofix workflow: theuv auditsource is still consulted independently.- Parameters:
repo (
str) – Repository inowner/repoformat.- Return type:
- Returns:
List of
VulnerablePackageentries with a known fixed version. Alerts withoutfirst_patched_versionare skipped (no upgrade target).