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-depstable.Lives beside
DepFinding, whoserowfills the columns, so the two cannot drift apart. The CLI derives its--sort-bychoices from it.DepFinding.consequenceandDepFinding.remedyare deliberately not columns: each runs to a couple of sentences, which in a fifth column pushes the other four off the side of any terminal. They are printed as the annotation line under the table instead, where the width is the screen’s rather than the widest cell’s.
- repomatic.deps.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.remedynames the edit.
- repomatic.deps.shippable.COOLDOWN_PR_BRANCH = 'sync-uv-lock'¶
Branch of the pull request that reports a cooldown clearing.
Its
❄️ Cooldown bypassestable forecasts the same day from the same two numbers a blocking floor does: the locked release’supload-time, plus theexclude-newerspan. 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_RELEASEblocker 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-lockopened 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 keepsscan_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.
- class repomatic.deps.shippable.SourceKind(*values)[source]¶
Bases:
StrEnumWhere a dependency is resolved from.
The vocabulary is shared by
[tool.uv.sources]anduv.lock, which name the same concepts with the same keys, soclassify()reads both. OnlyREGISTRYdescribes something an installer of the published artifact can reach on its own.- DIRECT_REFERENCE = 'direct reference'¶
A PEP 508
name @ urlclause written into the requirement itself.
- GIT = 'git'¶
A git repository, whether tracked by branch, tag or commit.
- INDEX = 'index'¶
A named
[[tool.uv.index]]other than PyPI.
- PATH = 'path'¶
A local directory, including an editable install or a lock
directoryentry.
- REGISTRY = 'registry'¶
A package index. Shippable when the index is PyPI.
- URL = 'url'¶
A direct artifact URL (a wheel or sdist served over HTTP).
- WORKSPACE = 'workspace'¶
Another member of the same uv workspace.
- classmethod classify(value)[source]¶
Read a
[tool.uv.sources]entry or auv.locksource table.Both spell the same concepts with the same keys, so one classifier serves the declaration and its resolution. Checked most-specific first: a
{ path = "…", editable = true }entry is a path source, not two.- Parameters:
value (
object) – The mapping sitting under a source key.- Return type:
- Returns:
The kind, or
Nonefor anything unrecognized (a bare marker table, a future uv key). Unknown shapes are not reported: a gate that guesses would block releases over syntax it does not understand.
- class repomatic.deps.shippable.DepFinding(package, kind, location, detail, consequence, remedy, level=AnnotationLevel.ERROR, allowed='', clears='')[source]¶
Bases:
objectOne reason the project cannot be published as it stands.
Findings are what
scan_project()returns, and they carry their own explanation rather than a code the caller has to map: the CLI table, the GitHub annotation and the release PR banner all render the same three sentences, so a maintainer reads one wording wherever they meet it.- kind: SourceKind¶
How that package is resolved.
- level: AnnotationLevel = 'error'¶
Severity. Only
ERRORblocks a release.
- allowed: str = ''¶
The
[tool.repomatic] lint-deps.allowreason, 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, orBYPASS_NEEDS_RELEASEfor a swapsync-dep-sourcesopens once upstream publishes. Empty when no automation clears it and a maintainer has to edit the declaration.countdownrenders it.
- property countdown: str¶
clearsas 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-lockcooldown 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
clearsrather than offkind, so the job and the marker a reader sees can never disagree about who they are waiting for.
- class repomatic.deps.shippable.Declaration(array, requirement)[source]¶
Bases:
NamedTupleA 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.
- 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.
- 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].requiresincluded,a
[[tool.uv.index]]markeddefaultthat is not PyPI,a non-empty
override-dependenciesorconstraint-dependencies, which is reported as a warning rather than a block: those name a version rather than an unreleased artifact, so what they change is the tested resolution, not the installability of the result.
- Parameters:
- Return type:
- Returns:
Findings, unsorted;
scan_project()orders them.
- repomatic.deps.shippable.scan_lock(lock, allow=None, doc=None)[source]¶
Report every package
uv.lockresolves from outside PyPI.The complement to
scan_pyproject(), and the reason the gate is not a list of hand-written rules: the lock records the resolved source of every package in the tree, so a git dependency pulled in by another git dependency shows up here even though no table inpyproject.tomlnames it.The project’s own entry is skipped. uv writes it as
{ editable = "." }for a package and{ virtual = "." }for a virtual project, and neither describes a dependency. A workspace member is a different path (like{ editable = "packages/mango" }) and is reported, since publishing this project does not publish that one.- Parameters:
lock (
LockFile) – The parseduv.lock.allow (
Mapping[str,str] |None) – Package name to the reason it may ship from a non-index source.doc (
Mapping[str,Any] |None) – Parsedpyproject.toml, when the caller has one. Supplying it lets each finding say whether the package is declared directly, which is what separates “every install fails” from “a transitive dependency was swapped underneath the resolver”.
- Return type:
- Returns:
Findings, unsorted.
- class repomatic.deps.shippable.StaleFloor(floor, clears)[source]¶
Bases:
objectA dependency floor no cooldown-gated resolution can satisfy.
What
floors_inside_cooldown()reports: the offending bound, and the day the clock alone lifts it.
- 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 runninguvx repomatic) gets a tool environment, which reads neitheruv.locknor[tool.uv] exclude-newer-package. Since uv exposes no environment variable for a per-package exemption either, there is nowhere for them to record the bypass.Caution
This repository cannot feel the breakage it would ship. Its own workflows install from
uv.lock(seerepomatic.release.self_pin.local_cli_invocation()), which resolves through the localexclude-newer-packageexemption and stays green. The failure lands only on whoever installs the release, which is why it needs a gate here rather than a red CI run to catch it.Wait for a release to age out of the window before raising a floor onto it.
The comparison runs against the locked version’s upload time, which
uv.lockrecords, so the check needs no network. A floor is reported when the locked version sits inside the window and the floor demands at least that version: releases reach an index in version order, so nothing satisfying such a floor can be older than what is already locked.- Parameters:
- Return type:
- Returns:
Mapping of canonical package name to the offending floor 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.tomldeclares (scan_pyproject()), whatuv.lockresolved (scan_lock()), and which floors no cooldown-gated resolution can satisfy (floors_inside_cooldown()). Entirely offline, so it costs nothing to run on every push and cannot fail on a flaky index. Each file is parsed once.A package flagged by both halves is reported once, keeping the
pyproject.tomlfinding: that is where the reader has something to edit, the lock being a derived file.- Parameters:
- Return type:
- Returns:
Findings sorted by package, then by location.
- repomatic.deps.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-depsblocker section.
- repomatic.deps.shippable.format_blocker_section(findings)[source]¶
Format blocking findings as a markdown section.
The long form, for the
lint-depsreport: a reader who opened that report came for the diagnosis, so it carries the note and the full table. The release PR getsbuild_release_readiness()instead, which is the same findings at banner length.- Parameters:
findings (
list[DepFinding]) – Findings fromscan_project().- Return type:
- 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.lockfor a source the resolver picked,pyproject.tomlfor everything the project wrote itself, dependency floors included.- Parameters:
finding (
DepFinding) – The finding to locate.pyproject_path (
Path) – Path to thepyproject.tomlfile.lock_path (
Path) – Path to theuv.lockfile.
- Return type:
- Returns:
The file name, suffixed with
#L{n}once the declaring line is found. A line that cannot be found degrades to the bare file rather than to a guess: an anchor pointing at the wrong line costs the reader more than no anchor at all.
- repomatic.deps.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-depsreportformat_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 thelint-depsreport 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-releasechecklist has always opened with “This PR is ready to be merged”, and that sentence is a lie while a dependency resolves from a git branch, a fork or a local path. So the opening is owned here rather than hard-coded in the template: it stays that sentence while the project is releasable, and becomes a[!CAUTION]block naming every offending dependency when it is not.This is the layer that matters, even though the release lane carries a hard gate of its own. By the time that gate fires the freeze commit is already on
main, and the recovery is to burn the version and release the next one. This body is regenerated on every push tomain, so it carries the same answer days earlier, in the one place a maintainer reads before deciding to merge.Note
Lives here rather than beside the other
pr-bodytemplate-argument builders inrepomatic.github.pr_body, which is where it would otherwise belong: that module is imported byrepomatic.deps.report, so reachingscan_project()from it closes an import cycle.- Parameters:
pyproject_path (
Path) – Path to thepyproject.tomlfile.lock_path (
Path) – Path to theuv.lockfile.window (
str) – Cooldown window, from[tool.repomatic] minimum-release-age.allow (
Mapping[str,str] |None) – Package name to itslint-deps.allowreason.source_url (
str|None) – Blob URL the declarations hang off, without a trailing slash (like{repo_url}/blob/{sha}). Each package links into it. A caller with no commit to point at passes nothing, and the packages render with their file and line as plain text instead.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:
- Returns:
RELEASE_READY_SENTENCE, or a GitHub-flavored markdown[!CAUTION]blockquote naming what blocks the release and when each blocker lifts.