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_NODE since duplicates are always headline (primary) dependencies of their box.

class repomatic.deps.dep_graph.SubgraphKind(*values)[source]

Bases: Enum

Kind of dependency selector a subgraph box represents.

GROUP = 'group'
EXTRA = 'extra'
property flag: str

CLI flag selecting this kind, shown as the box title prefix.

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].

Parameters:

project_root (Path | None) – Directory holding pyproject.toml. Defaults to the current working directory.

Return type:

tuple[str, ...]

Returns:

Sorted tuple of group or extra names.

property mermaid_prefix: str

Namespace prefix keeping subgraph IDs distinct from node IDs.

Without it, a json5 extra box would collide with a json5 package node.

property style: str

Mermaid style for boxes of this kind.

class repomatic.deps.dep_graph.Subgraph(kind, name, owned, duplicates)[source]

Bases: object

One --group or --extra box 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.

name: str

Group or extra name, as declared in pyproject.toml.

owned: set[str]

Directly-declared packages this box renders as real hexagon nodes.

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

property mermaid_id: str

Mermaid subgraph ID, namespaced away from node IDs.

property title: str

Box title, echoing the CLI flag that pulls these packages in.

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 _0 suffix to avoid conflicts with Mermaid reserved keywords.

Return type:

str

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-graph command: explicit CLI values win over the [tool.repomatic] dependency-graph defaults; --only-* replaces the explicit selection; --all-* expands to every name declared in pyproject.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:

tuple[str, ...] | None

Returns:

Selected names, or None when 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:

dict[str, Any]

Returns:

Parsed CycloneDX SBOM dictionary.

Raises:
repomatic.deps.dep_graph.get_package_names_from_sbom(sbom)[source]

Extract all package names from a CycloneDX SBOM.

Parameters:

sbom (dict[str, Any]) – Parsed CycloneDX SBOM dictionary.

Return type:

set[str]

Returns:

Set of package names.

repomatic.deps.dep_graph.build_dependency_graph(sbom)[source]

Build a dependency graph from CycloneDX SBOM data.

Parameters:

sbom (dict[str, Any]) – Parsed CycloneDX SBOM dictionary.

Return type:

tuple[str, set[str], list[tuple[str, str]]]

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.toml declaration 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 sphinx and no --group is enough for requests to come back as a direct dependency of the project: Sphinx pulls it in, the test group happens to declare it too, and the export conflates the two. Neither omitting --group nor passing --no-default-groups suppresses 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 what pyproject.toml declares 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, from by_main. None when 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:

list[tuple[str, str]]

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.

Parameters:
  • packages (set[str]) – Set of all package names.

  • edges (list[tuple[str, str]]) – List of (from_name, to_name) edge tuples.

  • package (str) – Package name to filter to.

Return type:

tuple[set[str], list[tuple[str, str]]]

Returns:

Filtered (packages, edges) tuple.

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 depth hops and edges between those nodes.

Parameters:
  • root_name (str) – The root package name.

  • packages (set[str]) – Set of all package names.

  • edges (list[tuple[str, str]]) – List of (from_name, to_name) edge tuples.

  • depth (int) – Maximum depth from root. 0 = root only, 1 = root + primary deps, etc.

Return type:

tuple[set[str], list[tuple[str, str]]]

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

  • packages (set[str]) – Package names to render as nodes.

  • 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). See Subgraph.

  • lock_specs (LockSpecifiers | None) – Optional specifiers extracted from uv.lock. Provides edge labels (by_package) and subgraph node labels (by_subgraph).

Return type:

str

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 the carapace and yaml extras both declare only pyyaml, which no other package depends on: the dependent counts tie at zero, carapace owns the node by declaration order, and yaml carries pyyaml as 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 (from uv.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:

tuple[dict[str, set[str]], dict[str, set[str]]]

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-group and --only-extra.

Return type:

str

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.2 is >=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: object

One declaration that departs from the project’s version policy.

Deliberately not a DepFinding: that type carries a SourceKind because every one of its findings is about where a package resolves from, and a style finding has no answer to give there.

package: str

Normalized name of the package the finding is about.

location: str

The TOML path the declaration was read from.

detail: str

The declaration as written, for the reader to go find.

consequence: str

What it costs to leave this as it is.

remedy: str

The next action.

property message: str

The finding as a single annotation line.

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:

int

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:
  • pyproject_path (Path) – Path to the pyproject.toml file.

  • comment_word_threshold (int) – Word ceiling per floor comment. 0 (or less) disables the check, which is what a caller with no configuration to read gets.

Return type:

list[PolicyFinding]

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.

Render a table’s subject cell, linked when a URL is known for it.

Parameters:
  • name (str) – Package, action or tool name.

  • name_urls (dict[str, str] | None) – Mapping of names to their URL. Names absent from it (or a None mapping) render as plain text.

Return type:

str

Returns:

A markdown link, or the bare name.

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.

  • headers (tuple[str, ...]) – Column titles.

  • rows (list[tuple[str, ...]]) – One tuple of pre-rendered cells per row, each as long as headers.

Return type:

str

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.

Parameters:

iso_datetime (str) – An ISO 8601 datetime string (e.g., "2026-03-13T12:00:00Z").

Return type:

str

Returns:

A formatted date like 2026-03-13, or the raw string if parsing fails.

repomatic.deps.dep_report.format_released(raw_upload, reference)[source]

Format an upload time as a date, optionally with a relative hint.

Parameters:
  • raw_upload (str) – ISO 8601 upload-time string, or empty.

  • reference (date | None) – Date to measure the relative offset from. When None, only the absolute date is returned.

Return type:

str

Returns:

A string like 2026-06-24 (2 days ago), the bare date when reference is None, 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.

Parameters:
  • eligible (date) – The date a release leaves the cooldown window.

  • today (date) – The current date, for the relative offset.

Return type:

str

Returns:

A string like 2026-06-25 (in 4 days), ... (today), or the bare date once the window has elapsed.

repomatic.deps.dep_report.pypi_name_urls(changes)[source]

Map each changed package name to its PyPI project URL.

Convenience for format_diff_table()’s name_urls when the changes come from a PyPI-resolved source (sync-uv-lock, fix-vulnerable-deps).

Return type:

dict[str, str]

repomatic.deps.dep_report.format_exclude_newer_note(exclude_newer)[source]

Render the uv exclude-newer cutoff sentence for a diff table.

The format_diff_table() counterpart for sync-uv-lock and fix-vulnerable-deps, which gate on uv’s absolute exclude-newer timestamp. The relative-cooldown updaters (repomatic.release.version_sync) render their own minimum-release-age note instead.

Parameters:

exclude_newer (str) – ISO 8601 datetime from the lock’s [options].exclude-newer, as carried by repomatic.deps.uv.LockFile.exclude_newer, or empty.

Return type:

str

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_times is 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 through released_overrides instead of showing a date, so the exemption reads as deliberate rather than as missing data. When cooldown_note is provided, that pre-rendered sentence (the absolute exclude-newer cutoff for uv, or the relative minimum-release-age cutoff for the version-sync updaters) is shown above the table.

Parameters:
Return type:

str

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: object

A newer release withheld from the lock by the exclude-newer cooldown.

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, so uv lock --upgrade keeps the older locked_version.

name: str

Package name, as it appears on PyPI.

locked_version: str

Version held in the lock: the newest release outside the cooldown.

available_version: str

Newer version already on the index, still inside the cooldown window.

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_version leaves 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-lock held-back section.

The repomatic.release.version_sync updaters pass their own minimum-release-age wording to format_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 HeldBackPackage row 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 same released/eligible strings repomatic.deps.uv.compute_held_back_packages() produces for uv, so format_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) – The minimum-release-age cooldown width.

  • today (date) – Reference date for the relative countdown.

Return type:

HeldBackPackage

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 from repomatic.deps.uv.compute_held_back_packages()) and the version-sync commands (rows from build_held_back()), so the section renders identically.

Parameters:
  • held_back (list[HeldBackPackage]) – Withheld releases as HeldBackPackage rows.

  • note (str) – Intro paragraph describing the cooldown. Defaults to the uv exclude-newer wording; version-sync passes its minimum-release-age wording.

  • 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:

str

Returns:

A markdown string with a ## ⏸️ Held back by cooldown heading 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-package entry whose held version has no upload time in the lock (a git, path, or otherwise unpublished source) can never age past the rolling exclude-newer cutoff 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: object

A cooldown-bypass freeze and the date it self-clears.

Built by repomatic.deps.uv.compute_bypass_forecasts() (freezes still active) and repomatic.deps.uv.compute_pruned_forecasts() (freezes the run just cleared) for the ## ❄️ Cooldown bypasses report section: a fixed-timestamp exclude-newer-package entry holds name at held_version until that version ages past the exclude-newer cutoff, at which point sync-uv-lock prunes the entry and the package resumes normal cooldown resolution.

name: str

Package name, as it appears on PyPI.

held_version: str

Version the freeze holds in the lock.

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_RELEASE when the held version has no upload time in the lock, or empty when there is no rolling exclude-newer span 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-lock cooldown-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 reason HELD_BACK_COLUMNS is.

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-lock report section covering exclude-newer-package freezes. Every lifecycle state is a row in one table so the section scans like the ## 🆙 Updated packages one: freezes still active render plain, entries this run rewrote into freeze cutoffs are labelled 📌 frozen:, and expired entries this run removed from pyproject.toml are labelled 🧹 cleared:, keeping the version and expiry data the freeze had. A freeze holding an unreleased version is labelled 🚧 unreleased: and its BYPASS_NEEDS_RELEASE expiry renders in italics.

Parameters:
Return type:

str

Returns:

A markdown string with a ## ❄️ Cooldown bypasses heading 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 PyPI project_urls when no GitHub Release exists.

Parameters:

changes (list[tuple[str, str, str]]) – List of (name, old_version, new_version) tuples.

Return type:

dict[str, tuple[str, list[tuple[str, str]]]]

Returns:

A dict mapping package names to (repo_url, versions) tuples where versions is 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, tag is empty and body contains a markdown link.

repomatic.deps.dep_report.format_release_notes(notes)[source]

Render release notes as collapsible <details> blocks.

A ### Release notes heading (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 to RELEASE_NOTES_MAX_LENGTH characters 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 where versions is a list of (tag, body) pairs, as returned by fetch_release_notes().

Return type:

str

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 a v prefix there yields a 404 in the PR body.

Parameters:
Return type:

dict[str, str]

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 a rev or tag pin), and

  • a 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-deps table.

Lives beside DepFinding so the columns and the fields they render cannot drift apart; the CLI derives its --sort-by choices from it.

DepFinding.consequence and DepFinding.remedy are 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 unions PIN_OPERATOR in 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.dev0 into >=2.1.0 in 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: tomllib and tomlkit both 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: object

A git-tracked dependency whose awaited release has shipped.

Built by find_ready_swaps(); consumed by apply_release_swaps() (the pyproject.toml rewrite) and format_swap_section() (the PR report).

name: str

Normalized package name, as it appears on PyPI and in uv.lock.

source_key: str

The entry key as written in [tool.uv.sources] (may differ from name in case or separators).

branch: str

The git branch the override tracks.

floor: str

The .dev version floor that named the awaited release.

release: str

The adopted release version, the newest stable satisfying the floor.

released: str

Upload date of release (YYYY-MM-DD), from the index.

property freeze_cutoff: str

The exclude-newer-package cutoff adopting release.

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 git URL and a branch are returned: a rev or tag pin 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”.

Parameters:

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

Return type:

dict[str, str]

Returns:

Source-entry key to tracked branch name; empty when the file or the table is absent.

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-deps reports it, and it is also what separates the tables that reach the published wheel’s Requires-Dist from the ones that never leave the repository.

Parameters:

doc (dict) – Parsed pyproject.toml.

Return type:

Iterator[tuple[str, list]]

Returns:

(location, array) pairs, the array being the live list object so callers can rewrite items in place.

repomatic.deps.dep_sources.parse_requirement(item)[source]

Parse a requirement array item, returning None for anything else.

Parameters:

item (object) – One entry of a requirement array.

Return type:

Requirement | None

Returns:

The parsed requirement, or None for a non-string entry or an unparsable specifier.

repomatic.deps.dep_sources.dev_floor(pyproject_path, name)[source]

The highest .dev lower 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.

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

  • name (str) – Package name (any capitalization or separator style).

Return type:

str | None

Returns:

The floor version string, or None when the package has no dev floor (the override is then outside the managed idiom).

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 running uvx repomatic) gets a tool environment, which reads neither uv.lock nor [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 (see repomatic.release.prepare_release.LOCAL_CLI_INVOCATION), which resolves through the local exclude-newer-package exemption 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.lock records, 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:
  • pyproject_path (Path) – Path to the pyproject.toml file.

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

  • window (str) – Cooldown window, in any form [tool.uv] exclude-newer accepts.

Return type:

dict[str, str]

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 the pyproject.toml file.

Return type:

list[ReleaseSwap]

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 .dev lower 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.dev0 becomes >=2.1.0). Everything else in the string (extras, markers, other clauses, spacing) is preserved byte-for-byte.

Parameters:
  • requirement (str) – The PEP 508 requirement string.

  • release (str) – The adopted release; bounds newer than it are left alone (they await a later release).

Return type:

str

Returns:

The rewritten string, or the original when nothing matched.

repomatic.deps.dep_sources.apply_release_swaps(pyproject_path, swaps)[source]

Rewrite pyproject.toml for 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 .dev floor on the swapped packages is tightened to its base release. The third edit, the cooldown-bypass freeze at ReleaseSwap.freeze_cutoff, goes through repomatic.deps.uv.upsert_exclude_newer_packages() so the insertion position and inline-table formatting stay canonical.

Parameters:
Return type:

None

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-sources swap 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-sources report section explaining the pyproject.toml hunks: 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 from find_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:

str

Returns:

A markdown string with a ## 🔀 Source swaps heading and table, or an empty string when swaps is empty.

class repomatic.deps.dep_sources.SourceKind(*values)[source]

Bases: StrEnum

Where a dependency is resolved from.

The vocabulary is shared by [tool.uv.sources] and uv.lock, which name the same concepts with the same keys, so classify_source() reads both. Only REGISTRY describes something an installer of the published artifact can reach on its own.

DIRECT_REFERENCE = 'direct reference'

A PEP 508 name @ url clause 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 directory entry.

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: object

One 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.

package: str

Normalized name of the offending package.

kind: SourceKind

How that package is resolved.

location: str

The TOML path (or uv.lock) the declaration was read from.

detail: str

The declaration as written, for the reader to go find.

consequence: str

What breaks, for whom, if this ships.

remedy: str

The next action, naming the automation that already covers it.

level: AnnotationLevel = 'error'

Severity. Only ERROR blocks a release.

allowed: str = ''

The [tool.repomatic] lint-deps.allow reason, when one covers this package. A non-empty reason downgrades the finding to a notice that still renders, so an accepted exception stays visible instead of disappearing.

property blocking: bool

Whether this finding stops a release.

property verdict: str

One-word outcome, for the table’s last column.

property message: str

The finding as a single annotation line.

repomatic.deps.dep_sources.classify_source(value)[source]

Read a [tool.uv.sources] entry or a uv.lock source 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:

SourceKind | None

Returns:

The kind, or None for 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.

Parameters:
  • doc (dict) – Parsed pyproject.toml.

  • name (str) – Package name, in any capitalization or separator style.

Return type:

list[tuple[str, str]]

Returns:

(location, requirement string) pairs, empty when the package is not declared directly.

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].requires included,

  • a [[tool.uv.index]] marked default that is not PyPI,

  • a non-empty override-dependencies or constraint-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:
  • pyproject_path (Path) – Path to the pyproject.toml file.

  • allow (dict[str, str] | None) – Package name to the reason it may ship from a non-index source, from [tool.repomatic] lint-deps.allow.

Return type:

list[DepFinding]

Returns:

Findings, unsorted; scan_project() orders them.

repomatic.deps.dep_sources.scan_lock(lock_path, allow=None, doc=None)[source]

Report every package uv.lock resolves 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 in pyproject.toml names 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 the uv.lock file.

  • allow (dict[str, str] | None) – Package name to the reason it may ship from a non-index source.

  • doc (dict | None) – Parsed pyproject.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:

list[DepFinding]

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.toml declares (scan_pyproject()), what uv.lock resolved (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.toml finding: that is where the reader has something to edit, the lock being a derived file.

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

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

  • window (str) – Cooldown window, in any form [tool.uv] exclude-newer accepts.

  • allow (dict[str, str] | None) – Package name to the reason it may ship from a non-index source.

Return type:

list[DepFinding]

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-deps blocker 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-deps report: a reader who opened that report came for the diagnosis, so it carries the note and the full table. The release PR gets build_release_readiness() instead, which is the same findings at banner length.

Parameters:
Return type:

str

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.lock for a source the resolver picked, pyproject.toml for everything the project wrote itself, dependency floors included.

Parameters:
  • finding (DepFinding) – The finding to locate.

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

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

Return type:

str

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-deps report format_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-release checklist 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 per claude.md § Skip and move forward. This body is regenerated on every push to main, 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-body template-argument builders in repomatic.github.pr_body, which is where it would otherwise belong: that module is imported by repomatic.deps.dep_report, so reaching scan_project() from it closes an import cycle.

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

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

  • window (str) – Cooldown window, from [tool.repomatic] minimum-release-age.

  • allow (dict[str, str] | None) – Package name to its lint-deps.allow reason.

  • 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:

str

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 uv binary every command this package builds shells out to.

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

Return type:

str

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

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

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

Return type:

list[str]

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

Build a uvx command prefix with standard flags.

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

Return type:

list[str]

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

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

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

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

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

Parameters:

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

Return type:

Any

Returns:

The parsed document.

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

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

Parameters:

doc (Any) – Document from load_pyproject_doc().

Return type:

Any

Returns:

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

repomatic.deps.uv.resolve_exclude_newer_cutoff(value)[source]

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

uv accepts three forms in this field:

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

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

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

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

Parameters:

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

Return type:

datetime | None

Returns:

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

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

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

Caution

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

Parameters:

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

Return type:

str

Returns:

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

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

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

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

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

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

Return type:

list[str]

Returns:

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

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

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

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

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

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

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

Return type:

set[str]

Returns:

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

repomatic.deps.uv.date_to_utc_cutoff(day)[source]

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

Warning

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

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

Parameters:

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

Return type:

str

Returns:

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

repomatic.deps.uv.freeze_cutoff_after(day)[source]

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

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

Parameters:

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

Return type:

str

Returns:

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

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

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

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

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

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

Return type:

bool

Returns:

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

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

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

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

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

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

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

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

Return type:

bool

Returns:

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

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

Convert relative-span cooldown bypasses into fixed freeze cutoffs.

A "0 day" (or any relative-span) exclude-newer-package entry tells uv to ignore the cooldown and resolve to the latest release, so the package keeps moving and prune_stale_exclude_newer_packages() never sees its locked version age out. Rewriting the span as the _freeze_cutoff of the locked version instead holds the package: releases past the freeze window are excluded until the held version ages past the global cooldown, at which point the entry is pruned and the package rejoins normal resolution.

Also migrates any legacy bare YYYY-MM-DD fixed entry to the equivalent explicit UTC timestamp (see date_to_utc_cutoff()), so uv stops re-expanding it per locking-machine timezone. Entries already carrying a full timestamp are left untouched (idempotent). Packages with no upload time in the lock (git or path sources) keep their span: they have no PyPI release to freeze against.

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

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

Return type:

set[str]

Returns:

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

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

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

Todo

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

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

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

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

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

Return type:

set[str]

Returns:

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

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

Bases: object

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

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

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

versions: dict[str, str]

Package name to locked version.

upload_times: dict[str, str]

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

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

exclude_newer: str = ''

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

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

cooldown_span: timedelta | None = None

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

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

classmethod load(lock_path)[source]

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

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

Parameters:

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

Return type:

LockFile

Returns:

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

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

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

Parameters:

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

Return type:

dict[str, str]

Returns:

A dict mapping normalized package names to their version strings.

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

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

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

Parameters:

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

Return type:

dict[str, str]

Returns:

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

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

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

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

Parameters:

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

Return type:

dict[str, Any]

Returns:

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

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

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

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

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

Bases: object

Dependency specifiers extracted from a uv.lock file.

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

by_package

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

by_subgraph

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

by_main

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

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

Parse uv.lock and extract dependency specifiers.

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

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

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

Return type:

LockSpecifiers

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

Compare two version mappings and return the list of changes.

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

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

Return type:

list[tuple[str, str, str]]

Returns:

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

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

Find releases withheld from the lock only by the cooldown.

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

The probe writes uv.lock and restores it byte-for-byte in a finally, so the canonical in-cooldown lock is left untouched even when resolution or parsing fails.

Note

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

Parameters:

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

Return type:

list[HeldBackPackage]

Returns:

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

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

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

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

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

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

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

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

Return type:

list[BypassForecast]

Returns:

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

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

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

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

Parameters:
  • names (set[str]) – Names of the pruned entries, as returned by prune_stale_exclude_newer_packages().

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

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

Return type:

list[BypassForecast]

Returns:

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

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

Bases: object

Result of a sync-uv-lock operation.

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

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

upload_times: dict[str, str]

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

exclude_newer: str

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

reverted: bool = False

Whether a cosmetic-only re-lock was discarded.

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

pruned_bypasses: list[BypassForecast]

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

frozen_bypasses: list[str]

exclude-newer-package entries rewritten into freeze cutoffs.

bypass_forecasts: list[BypassForecast]

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

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

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

First prunes stale exclude-newer-package entries from pyproject.toml (entries whose locked version was uploaded before the exclude-newer cutoff), then runs uv lock --upgrade to update transitive dependencies.

Note

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

Parameters:

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

Return type:

SyncResult

Returns:

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

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:

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 audit table.

Lives beside the rows’ domain model so the columns and the fields they render cannot drift apart; the CLI derives its --sort-by choices from it.

repomatic.deps.vulnerable_deps.MIN_UV_AUDIT_JSON_VERSION = <Version('0.11.15')>

Minimum uv version exposing uv audit --output-format json.

The structured JSON output landed in uv 0.11.15 as a preview feature. Below this, uv audit emits only human-readable text, so _run_uv_audit refuses to run rather than silently scanning nothing.

class repomatic.deps.vulnerable_deps.AdvisorySource(*values)[source]

Bases: StrEnum

Where 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 VulnerablePackage lets 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: object

A single vulnerability advisory for a Python package.

name: str

Package name.

current_version: str

Currently resolved version.

advisory_id: str

Advisory identifier (e.g., GHSA-xxxx-xxxx-xxxx).

advisory_title: str

Short description of the vulnerability.

fixed_version: str

Version that contains the fix, or empty string if unknown.

advisory_url: str

URL to the advisory details.

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/PYSEC IDs while listing the matching GHSA/CVE IDs as aliases, and Dependabot keys by GHSA while listing the CVE. 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.dev page 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 json output 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 let collect_vulnerable_packages() deduplicate the same advisory across sources.

Parameters:

output (str) – stdout from uv audit --output-format json.

Return type:

list[VulnerablePackage]

Returns:

A list of VulnerablePackage entries (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 Sources column 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 of VulnerablePackage entries.

Return type:

str

Returns:

A markdown string with a ## Vulnerabilities heading 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_id plus aliases) overlap, so the same advisory reported under a PYSEC/OSV ID by uv audit and a GHSA ID by Dependabot collapses into one. Merging preserves the union of sources so the rendered table credits both databases when they agree.

Current versions reported by uv audit take precedence over the empty placeholder produced by the GHSA path, since uv audit reads the actual locked version while Dependabot alerts only carry the vulnerable range. When the GHSA path encounters a package that uv audit did not surface, the current version is filled in from the lock file.

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

  • repo (str | None) – Repository in owner/repo format. Required for the AdvisorySource.GITHUB_ADVISORIES source; pass None to skip it (the result then reflects uv audit only).

  • sources (list[AdvisorySource] | None) – Advisory databases to consult. Defaults to all known sources.

Return type:

list[VulnerablePackage]

Returns:

Deduplicated list of VulnerablePackage entries.

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-package using --exclude-newer-package to bypass the exclude-newer cooldown for security fixes. Also persists the exemptions in pyproject.toml so that subsequent uv lock --upgrade runs (e.g. from the sync-uv-lock job) 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:
Return type:

tuple[bool, str]

Returns:

A tuple of (has_fixes, diff_table). has_fixes is True when at least one vulnerable package was upgraded. diff_table is 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=pip via the gh CLI, then maps each alert into a VulnerablePackage tagged with AdvisorySource.GITHUB_ADVISORIES.

Returns an empty list when the API is unreachable, the token lacks the Dependabot alerts permission, or the repository has no open alerts. A network or auth failure must not break the autofix workflow: the uv audit source is still consulted independently.

Parameters:

repo (str) – Repository in owner/repo format.

Return type:

list[VulnerablePackage]

Returns:

List of VulnerablePackage entries with a known fixed version. Alerts without first_patched_version are skipped (no upgrade target).