repomatic.deps.dep_sources module¶
Swap git-tracked dependencies back to their released versions, and refuse to release while one is still in place.
Two halves, both about where a dependency actually comes from.
The sync-dep-sources updater manages one precise idiom: a dependency
temporarily consumed from a git branch while its next release is awaited.
The idiom is machine-recognizable because it pairs two declarations in
pyproject.toml:
a
[tool.uv.sources]entry tracking a branch (not arevortagpin), anda dev-version floor on the same package (like
mango>=2.1.0.dev0), whose base version names the awaited release.
Once the awaited release ships on the index, the swap rewrites the project
back to released artifacts: the source override is dropped, the .dev floor
is tightened to its base release, and a cooldown-bypass freeze adopts the
release through the exclude-newer window (the same deliberate-bypass
mechanism audit --fix uses for security fixes). The freeze then ages out
and is pruned by the ordinary sync-uv-lock lifecycle.
Note
The dev floor is authoritative, deliberately: the project declares that
anything from the awaited release onward satisfies it. If the project
quietly grew a dependency on branch commits newer than the release, the swap
PR’s CI run exposes the stale declaration, and the correction (bumping the
floor to the next .dev version, which retracts the swap on the next run)
is exactly the fix the project needed anyway. Overrides outside the idiom
(path or workspace sources, rev/tag pins, floor-less branch tracks) are
never touched.
The lint-deps gate is the other half, and it covers what the swap does not.
A dependency is shippable when whoever installs the published artifact from
an index gets the same code the release was tested against. scan_project()
reports every way that breaks, and the release lane refuses to build a package
while one stands. See DepFinding for the failure classes, and
docs/dependencies.md § Shippable sources for the worked example.
- repomatic.deps.dep_sources.LINT_DEPS_HEADER_DEFS: tuple[tuple[str, str], ...] = (('Package', 'package'), ('Source', 'kind'), ('Declared in', 'location'), ('Verdict', 'verdict'))¶
Column definitions for the
repomatic lint-depstable.Lives beside
DepFindingso the columns and the fields they render cannot drift apart; the CLI derives its--sort-bychoices from it.DepFinding.consequenceandDepFinding.remedyare deliberately not columns: each runs to a couple of sentences, which in a fifth column pushes the other four off the side of any terminal. They are printed as the annotation line under the table instead, where the width is the screen’s rather than the widest cell’s.
- repomatic.deps.dep_sources.WHEEL_METADATA_TABLES = ('project.dependencies', 'project.optional-dependencies')¶
Requirement arrays whose entries land in the published
Requires-Dist.[dependency-groups](PEP 735) is deliberately absent: it never reaches distribution metadata. That is what separates a finding an installer trips over from one only a contributor does, which the report says out loud even though both block.
- repomatic.deps.dep_sources.LOWER_BOUND_OPERATORS = frozenset({'>', '>='})¶
PEP 440 operators that put a floor under a requirement.
The one spelling of “lower bound” this module tests specifiers against. Three checks used to each carry their own operator tuple, and the sets had drifted: a
>-style floor was seen by the dev-floor discovery but escaped the cooldown gate entirely. A site that also treats an exact pin as a bound unionsPIN_OPERATORin explicitly, so the difference stays a decision rather than an accident.
- repomatic.deps.dep_sources.PIN_OPERATOR = frozenset({'=='})¶
The PEP 440 operator pinning a requirement to one version.
A pin demands its version the way a floor demands its minimum, so the checks asking “can PyPI serve what this declaration requires” test both; the checks looking for the managed git-branch floor idiom deliberately do not.
- repomatic.deps.dep_sources.DEV_BOUND_PATTERN = re.compile('(?P<op>>=?)\\s*(?P<version>[0-9][A-Za-z0-9.!+]*)')¶
Lower-bound clauses in a PEP 508 requirement string.
Captures the operator and the version literal so
strip_dev_bounds()can rewrite>=2.1.0.dev0into>=2.1.0in place, leaving extras, markers, and every other clause byte-for-byte untouched.
- repomatic.deps.dep_sources.TOML_TABLE_HEADER = re.compile('\\s*\\[{1,2}\\s*(?P<path>[^]]+?)\\s*\\]{1,2}\\s*$')¶
A
[table]or[[array of tables]]header, capturing its dotted path.Enough TOML parsing for
declaration_anchor()to tell which table a line sits in. The parsed document cannot answer that:tomllibandtomlkitboth return values, and a line number is what a link needs.
- class repomatic.deps.dep_sources.ReleaseSwap(name, source_key, branch, floor, release, released)[source]¶
Bases:
objectA git-tracked dependency whose awaited release has shipped.
Built by
find_ready_swaps(); consumed byapply_release_swaps()(thepyproject.tomlrewrite) andformat_swap_section()(the PR report).- source_key: str¶
The entry key as written in
[tool.uv.sources](may differ fromnamein case or separators).
- property freeze_cutoff: str¶
The
exclude-newer-packagecutoff adoptingrelease.Delegates the margin policy to
repomatic.deps.uv.freeze_cutoff_after(): every distribution file of the adopted release sits inside the window even when its uploads straddle midnight, while the global cooldown still shields anything newer.
- repomatic.deps.dep_sources.tracked_git_overrides(pyproject_path)[source]¶
Read the
[tool.uv.sources]entries tracking a git branch.Only single-source entries carrying both a
gitURL and abranchare returned: arevortagpin is a deliberate point-in-time choice, a path or workspace source is a local development arrangement, and a multi-source list (per-platform markers) is too bespoke to rewrite. None of those encode “waiting for the next release”.
- repomatic.deps.dep_sources.requirement_arrays(doc)[source]¶
Yield every requirement array in a parsed
pyproject.toml, labelled.Covers
[project.dependencies], each[project.optional-dependencies]extra, each[dependency-groups]group, and[build-system].requires. Non-list values and non-string items (like{include-group = …}entries) are the callers’ concern.The label is the TOML path the array sits at, so a finding can name where a declaration lives rather than just which package it names.
lint-depsreports it, and it is also what separates the tables that reach the published wheel’sRequires-Distfrom the ones that never leave the repository.
- repomatic.deps.dep_sources.parse_requirement(item)[source]¶
Parse a requirement array item, returning
Nonefor anything else.
- repomatic.deps.dep_sources.dev_floor(pyproject_path, name)[source]¶
The highest
.devlower bound declared for name, if any.Scans every requirement array for lower-bound clauses (
>=or>) whose version is a dev release. The highest one is the project’s declared “awaited release” threshold.
- repomatic.deps.dep_sources.floors_inside_cooldown(pyproject_path, lock_path, window)[source]¶
Dependency floors that no cooldown-gated resolution can satisfy.
A floor naming a version published inside the cooldown window makes the published package uninstallable. Anyone resolving it from an index (a downstream repo running a frozen workflow’s
uvx 'repomatic==X.Y.Z', or an end user runninguvx repomatic) gets a tool environment, which reads neitheruv.locknor[tool.uv] exclude-newer-package. Since uv exposes no environment variable for a per-package exemption either, there is nowhere for them to record the bypass.Caution
This repository cannot feel the breakage it would ship. Its own workflows install from
uv.lock(seerepomatic.release.prepare_release.LOCAL_CLI_INVOCATION), which resolves through the localexclude-newer-packageexemption and stays green. The failure lands only on whoever installs the release, which is why it needs a gate here rather than a red CI run to catch it.Wait for a release to age out of the window before raising a floor onto it.
The comparison runs against the locked version’s upload time, which
uv.lockrecords, so the check needs no network. A floor is reported when the locked version sits inside the window and the floor demands at least that version: releases reach an index in version order, so nothing satisfying such a floor can be older than what is already locked.- Parameters:
- Return type:
- Returns:
Mapping of canonical package name to the offending floor version, empty when every floor resolves without an exemption.
- repomatic.deps.dep_sources.find_ready_swaps(pyproject_path)[source]¶
Probe the index for git-tracked packages whose awaited release shipped.
For each branch-tracking override inside the managed idiom, the awaited release is considered shipped once PyPI carries a stable (non-prerelease, non-yanked) version satisfying the dev floor. The newest such release is adopted. Index misses (an unpublished package, a network failure) read as “not ready”: a swap needs positive confirmation, so the failure mode is always a skipped run, never a wrong rewrite.
- Parameters:
pyproject_path (
Path) – Path to thepyproject.tomlfile.- Return type:
- Returns:
Ready swaps sorted by package name; empty when there is nothing to do.
- repomatic.deps.dep_sources.strip_dev_bounds(requirement, release)[source]¶
Tighten a requirement string’s
.devlower bounds to their release.Rewrites only the version literal of
>=/>clauses whose version is a dev release older than or equal to release, replacing it with its base version (>=2.1.0.dev0becomes>=2.1.0). Everything else in the string (extras, markers, other clauses, spacing) is preserved byte-for-byte.
- repomatic.deps.dep_sources.apply_release_swaps(pyproject_path, swaps)[source]¶
Rewrite
pyproject.tomlfor the given swaps, in one pass.Two of the three swap edits happen here: the
[tool.uv.sources]override is removed (and the emptied table with it), and every.devfloor on the swapped packages is tightened to its base release. The third edit, the cooldown-bypass freeze atReleaseSwap.freeze_cutoff, goes throughrepomatic.deps.uv.upsert_exclude_newer_packages()so the insertion position and inline-table formatting stay canonical.- Parameters:
pyproject_path (
Path) – Path to thepyproject.tomlfile.swaps (
list[ReleaseSwap]) – Ready swaps fromfind_ready_swaps().
- Return type:
- repomatic.deps.dep_sources.SWAP_SECTION_NOTE = 'Dependencies tracked from a git branch while awaiting a release, swapped back to the package index: the `[tool.uv.sources]` override is dropped, the `.dev` version floor is tightened to its release form, and a cooldown bypass freezes the adoption until it ages past the [`exclude-newer`](https://docs.astral.sh/uv/reference/settings/#exclude-newer) cutoff.'¶
Intro paragraph for the
sync-dep-sourcesswap section.
- repomatic.deps.dep_sources.format_swap_section(swaps, *, name_urls=None, reference_date=None)[source]¶
Format the release swaps as a markdown section.
The
sync-dep-sourcesreport section explaining thepyproject.tomlhunks: one row per swapped package, with the branch it tracked, the release it adopted, and when that release shipped.- Parameters:
swaps (
list[ReleaseSwap]) – Ready swaps fromfind_ready_swaps().name_urls (
dict[str,str] |None) – Optional mapping of names to a URL the name links to. Names absent from the mapping render plain.reference_date (
date|None) – When set, the “Released” date gains a relative hint measured from this date.
- Return type:
- Returns:
A markdown string with a
## 🔀 Source swapsheading and table, or an empty string when swaps is empty.
- class repomatic.deps.dep_sources.SourceKind(*values)[source]¶
Bases:
StrEnumWhere a dependency is resolved from.
The vocabulary is shared by
[tool.uv.sources]anduv.lock, which name the same concepts with the same keys, soclassify_source()reads both. OnlyREGISTRYdescribes something an installer of the published artifact can reach on its own.- DIRECT_REFERENCE = 'direct reference'¶
A PEP 508
name @ urlclause written into the requirement itself.
- GIT = 'git'¶
A git repository, whether tracked by branch, tag or commit.
- INDEX = 'index'¶
A named
[[tool.uv.index]]other than PyPI.
- PATH = 'path'¶
A local directory, including an editable install or a lock
directoryentry.
- REGISTRY = 'registry'¶
A package index. Shippable when the index is PyPI.
- URL = 'url'¶
A direct artifact URL (a wheel or sdist served over HTTP).
- WORKSPACE = 'workspace'¶
Another member of the same uv workspace.
- class repomatic.deps.dep_sources.DepFinding(package, kind, location, detail, consequence, remedy, level=AnnotationLevel.ERROR, allowed='')[source]¶
Bases:
objectOne reason the project cannot be published as it stands.
Findings are what
scan_project()returns, and they carry their own explanation rather than a code the caller has to map: the CLI table, the GitHub annotation and the release PR banner all render the same three sentences, so a maintainer reads one wording wherever they meet it.- kind: SourceKind¶
How that package is resolved.
- level: AnnotationLevel = 'error'¶
Severity. Only
ERRORblocks a release.
- repomatic.deps.dep_sources.classify_source(value)[source]¶
Read a
[tool.uv.sources]entry or auv.locksource table.Both spell the same concepts with the same keys, so one classifier serves the declaration and its resolution. Checked most-specific first: a
{ path = "…", editable = true }entry is a path source, not two.- Parameters:
value (
object) – The mapping sitting under a source key.- Return type:
- Returns:
The kind, or
Nonefor anything unrecognized (a bare marker table, a future uv key). Unknown shapes are not reported: a gate that guesses would block releases over syntax it does not understand.
- repomatic.deps.dep_sources.declared_requirements(doc, name)[source]¶
Every requirement string declaring name, with its TOML location.
A
[tool.uv.sources]entry says where a package comes from but not whether anyone downstream will feel it. That answer lives in the requirement arrays, and it is what decides the consequence: a package named in[project.dependencies]ships a requirement the index must satisfy, one named only in[dependency-groups]ships nothing at all, and one named nowhere is a transitive dependency being swapped underneath the resolver.
- repomatic.deps.dep_sources.scan_pyproject(pyproject_path, allow=None)[source]¶
Report every unshippable declaration in
pyproject.toml.Covers what the project says, which is the half a reader can act on directly:
a
[tool.uv.sources]entry resolving from anywhere but PyPI,a PEP 508 direct reference (
name @ git+…) in any requirement array,[build-system].requiresincluded,a
[[tool.uv.index]]markeddefaultthat is not PyPI,a non-empty
override-dependenciesorconstraint-dependencies, which is reported as a warning rather than a block: those name a version rather than an unreleased artifact, so what they change is the tested resolution, not the installability of the result.
- Parameters:
- Return type:
- Returns:
Findings, unsorted;
scan_project()orders them.
- repomatic.deps.dep_sources.scan_lock(lock_path, allow=None, doc=None)[source]¶
Report every package
uv.lockresolves from outside PyPI.The complement to
scan_pyproject(), and the reason the gate is not a list of hand-written rules: the lock records the resolved source of every package in the tree, so a git dependency pulled in by another git dependency shows up here even though no table inpyproject.tomlnames it.The project’s own entry is skipped. uv writes it as
{ editable = "." }for a package and{ virtual = "." }for a virtual project, and neither describes a dependency. A workspace member is a different path (like{ editable = "packages/mango" }) and is reported, since publishing this project does not publish that one.- Parameters:
lock_path (
Path) – Path to theuv.lockfile.allow (
dict[str,str] |None) – Package name to the reason it may ship from a non-index source.doc (
dict|None) – Parsedpyproject.toml, when the caller has one. Supplying it lets each finding say whether the package is declared directly, which is what separates “every install fails” from “a transitive dependency was swapped underneath the resolver”.
- Return type:
- Returns:
Findings, unsorted.
- repomatic.deps.dep_sources.scan_project(pyproject_path, lock_path, window, allow=None)[source]¶
Every reason this project cannot be released as it stands.
Folds the three checks into one ordered report: what
pyproject.tomldeclares (scan_pyproject()), whatuv.lockresolved (scan_lock()), and which floors no cooldown-gated resolution can satisfy (floors_inside_cooldown()). Entirely offline, so it costs nothing to run on every push and cannot fail on a flaky index.A package flagged by both halves is reported once, keeping the
pyproject.tomlfinding: that is where the reader has something to edit, the lock being a derived file.- Parameters:
- Return type:
- Returns:
Findings sorted by package, then by location.
- repomatic.deps.dep_sources.BLOCKER_SECTION_NOTE = 'A dependency is shippable when whoever installs the published artifact gets the code this release was tested against. These do not clear that bar, so the release lane refuses to build a package while they stand. See [Dependency management § Shippable sources](https://repomatic.net/dependencies#shippable-sources).'¶
Intro paragraph for the
lint-depsblocker section.
- repomatic.deps.dep_sources.format_blocker_section(findings, *, heading='🚧 Unshippable dependencies')[source]¶
Format blocking findings as a markdown section.
The long form, for the
lint-depsreport: a reader who opened that report came for the diagnosis, so it carries the note and the full table. The release PR getsbuild_release_readiness()instead, which is the same findings at banner length.- Parameters:
findings (
list[DepFinding]) – Findings fromscan_project().heading (
str) – Section heading, emoji included.
- Return type:
- Returns:
A markdown string, or an empty string when nothing blocks.
- repomatic.deps.dep_sources.declaration_anchor(finding, pyproject_path, lock_path)[source]¶
Locate the declaration behind a finding, as a repository-relative link.
Only two files can hold one:
uv.lockfor a source the resolver picked,pyproject.tomlfor everything the project wrote itself, dependency floors included.- Parameters:
finding (
DepFinding) – The finding to locate.pyproject_path (
Path) – Path to thepyproject.tomlfile.lock_path (
Path) – Path to theuv.lockfile.
- Return type:
- Returns:
The file name, suffixed with
#L{n}once the declaring line is found. A line that cannot be found degrades to the bare file rather than to a guess: an anchor pointing at the wrong line costs the reader more than no anchor at all.
- repomatic.deps.dep_sources.RELEASE_READY_SENTENCE = 'This PR is ready to be merged. '¶
How the release checklist opens when nothing blocks.
Trailing space included: it runs inline into the sentence the template follows it with, where the blocked form is a standalone blockquote instead.
- repomatic.deps.dep_sources.UNSHIPPABLE_BANNER_LEAD = 'Do not merge yet. This release would ship dependencies its users cannot install:'¶
Opening of the blocked form of the release PR’s verdict.
The banner is a verdict, not a report: it says what is wrong and names what to open. Everything else the finding carries (why the source is unshippable, what to do about it, the general rule) reads as chatter in a pull request whose body is otherwise a five-step checklist, and it is one click away in the
lint-depsreportformat_blocker_section()renders.
- repomatic.deps.dep_sources.build_release_readiness(pyproject_path, lock_path, window, allow=None, source_url=None)[source]¶
Build the release PR’s opening verdict.
The
prepare-releasechecklist has always opened with “This PR is ready to be merged”, and that sentence is a lie while a dependency resolves from a git branch, a fork or a local path. So the opening is owned here rather than hard-coded in the template: it stays that sentence while the project is releasable, and becomes a[!CAUTION]block naming every offending dependency when it is not.This is the layer that matters, even though the release lane carries a hard gate of its own. By the time that gate fires the freeze commit is already on
main, and the recovery is to burn the version perclaude.md§ Skip and move forward. This body is regenerated on every push tomain, so it carries the same answer days earlier, in the one place a maintainer reads before deciding to merge.Note
Lives here rather than beside the other
pr-bodytemplate-argument builders inrepomatic.github.pr_body, which is where it would otherwise belong: that module is imported byrepomatic.deps.dep_report, so reachingscan_project()from it closes an import cycle.- Parameters:
pyproject_path (
Path) – Path to thepyproject.tomlfile.lock_path (
Path) – Path to theuv.lockfile.window (
str) – Cooldown window, from[tool.repomatic] minimum-release-age.allow (
dict[str,str] |None) – Package name to itslint-deps.allowreason.source_url (
str|None) – Blob URL the declarations hang off, without a trailing slash (like{repo_url}/blob/{sha}). Each package links into it. A caller with no commit to point at passes nothing, and the packages render with their file and line as plain text instead.
- Return type:
- Returns:
RELEASE_READY_SENTENCE, or a one-line GitHub-flavored markdown[!CAUTION]blockquote naming what blocks the release.