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.