repomatic.deps.shippable module¶

Refuse to release while a dependency resolves from anywhere but the index.

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. A new rule goes into scan_pyproject() or scan_lock(), never into a call site, so every place that runs the gate enforces it. See DepFinding for the failure classes, and docs/dependencies.md § Shippable sources for the worked example.

The lint-deps command renders the findings, and the release PR opens with build_release_readiness().

repomatic.deps.shippable.LINT_DEPS_HEADER_DEFS: tuple[ColumnSpec, ...] = (ColumnSpec(id='package', label='Package', description='', max_width=None, optional=False, sortable=True), ColumnSpec(id='kind', label='Source', description='', max_width=None, optional=False, sortable=True), ColumnSpec(id='location', label='Declared in', description='', max_width=None, optional=False, sortable=True), ColumnSpec(id='verdict', label='Verdict', description='', max_width=None, optional=False, sortable=True))¶

Column definitions for the repomatic lint-deps table.

Lives beside DepFinding, whose row fills the columns, so the two 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.shippable.BLOCKER_NEEDS_EDIT = 'needs an edit'¶

Countdown placeholder for a blocker no automation ever lifts.

The counterpart to BYPASS_NEEDS_RELEASE, and what most failure classes get: a path source, a workspace member, a direct reference and a floor-less git track all wait on a maintainer rather than on a date. Naming that beats an empty cell, which reads as a date the report failed to compute. DepFinding.remedy names the edit.

repomatic.deps.shippable.COOLDOWN_PR_BRANCH = 'sync-uv-lock'¶

Branch of the pull request that reports a cooldown clearing.

Its ❄️ Cooldown bypasses table forecasts the same day from the same two numbers a blocking floor does: the locked release’s upload-time, plus the exclude-newer span. So a dated blocker and that table always agree, and the banner links one to the other.

repomatic.deps.shippable.SWAP_PR_BRANCH = 'sync-dep-sources'¶

Branch of the pull request that lands a git-to-release swap.

Where a BYPASS_NEEDS_RELEASE blocker resolves: the job watches the index and opens the swap once the awaited release ships.

repomatic.deps.shippable.clearing_queue_url(repo_url, branch)[source]¶

Link to the open pull request an updater has on its own branch.

A search over the branch rather than a pull request number, because the number changes every cycle: sync-uv-lock opened eight in the 24 days to 2026-09-04 and closed seven of them, so a number written into a release PR body is stale within days. The branch never moves, and the query costs no API call to build, which keeps scan_project() offline.

Restricted to open pull requests, because a merged one carries the cooldown forecast it computed on its last push, and the date can have moved since. An empty result answers the reader instead: nothing is pending on that branch, so the lock has converged.

Parameters:
  • repo_url (str) – Repository homepage, without a trailing slash.

  • branch (str) – The updater’s fixed pull request branch.

Return type:

str

Returns:

A GitHub pull request search URL.

class repomatic.deps.shippable.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() 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.

classmethod classify(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.

class repomatic.deps.shippable.DepFinding(package, kind, location, detail, consequence, remedy, level=AnnotationLevel.ERROR, allowed='', clears='')[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.

clears: str = ''¶

When this finding lifts without anyone touching pyproject.toml.

Either a date with a countdown (2026-09-10 (in 3 days)) for a cooldown the clock ages out, or BYPASS_NEEDS_RELEASE for a swap sync-dep-sources opens once upstream publishes. Empty when no automation clears it and a maintainer has to edit the declaration. countdown renders it.

property blocking: bool¶

Whether this finding stops a release.

property awaits_release: bool¶

Whether the finding lifts once upstream publishes a release.

property countdown: str¶

clears as a table cell, marked with what the reader waits on.

Three states, and the reader’s next move differs for each: wait for a date, wait for someone else’s release, or go edit the declaration. The two undated ones carry the marker the sync-uv-lock cooldown tables already use for them, italicized so a marker never reads as a date.

property clearing_job: str¶

Pull request branch of the job that lifts this finding.

Empty for a finding waiting on a maintainer, since no job opens a pull request for it. Keyed off clears rather than off kind, so the job and the marker a reader sees can never disagree about who they are waiting for.

property verdict: str¶

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

property message: str¶

The finding as a single annotation line.

property row: tuple[str, str, str, str]¶

The finding as a row of LINT_DEPS_HEADER_DEFS.

class repomatic.deps.shippable.Declaration(array, requirement)[source]¶

Bases: NamedTuple

A requirement string naming a package, and the array declaring it.

Create new instance of Declaration(array, requirement)

array: RequirementArray¶

The array the requirement sits in.

requirement: str¶

The requirement, as written.

repomatic.deps.shippable.declared_requirements(doc, name)[source]¶

Every requirement string declaring name, with the array it sits in.

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 (Mapping[str, Any]) – Parsed pyproject.toml.

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

Return type:

list[Declaration]

Returns:

The declarations, empty when the package is not declared directly.

repomatic.deps.shippable.scan_pyproject(doc, 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:
  • doc (Mapping[str, Any]) – Parsed pyproject.toml.

  • allow (Mapping[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.shippable.scan_lock(lock, 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 (LockFile) – The parsed uv.lock.

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

  • doc (Mapping[str, Any] | 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.

class repomatic.deps.shippable.StaleFloor(floor, clears)[source]¶

Bases: object

A dependency floor no cooldown-gated resolution can satisfy.

What floors_inside_cooldown() reports: the offending bound, and the day the clock alone lifts it.

floor: str¶

The offending lower bound, as the requirement writes it.

clears: str¶

Date the locked release ages past the cooldown, with a countdown (2026-09-10 (in 3 days)), or empty when the window is an absolute cutoff nothing ages past on its own.

repomatic.deps.shippable.floors_inside_cooldown(doc, lock, window, now=None)[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.self_pin.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:
  • doc (Mapping[str, Any]) – Parsed pyproject.toml.

  • lock (LockFile) – The parsed uv.lock.

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

  • now (datetime | None) – The instant the window counts back from. Read from the clock when None.

Return type:

dict[str, StaleFloor]

Returns:

Mapping of canonical package name to the offending floor and the day it clears, empty when every floor resolves without an exemption.

repomatic.deps.shippable.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. Each file is parsed once.

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 (Mapping[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.shippable.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.shippable.format_blocker_section(findings)[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:

findings (list[DepFinding]) – Findings from scan_project().

Return type:

str

Returns:

A markdown string, or an empty string when nothing blocks.

repomatic.deps.shippable.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.shippable.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.shippable.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, names what to open, and says when the block lifts. 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.shippable.BANNER_COLUMNS = ('Package', 'Clears')¶

Columns of the release PR’s blocker table.

Two, where format_blocker_section() renders five. The banner answers the one question a maintainer has while looking at a merge button: is this still blocked, and until when. Nothing else on the page answers either half; the diagnosis stays in the lint-deps report that section renders.

repomatic.deps.shippable.build_release_readiness(pyproject_path, lock_path, window, allow=None, source_url=None, repo_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 and release the next one. 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.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 (Mapping[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.

  • repo_url (str | None) – Repository homepage, without a trailing slash. Each dated or awaited countdown links to the pull request queue of the job that clears it. Omitted, the countdowns render as plain text.

Return type:

str

Returns:

RELEASE_READY_SENTENCE, or a GitHub-flavored markdown [!CAUTION] blockquote naming what blocks the release and when each blocker lifts.