Source code for tests.test_dep_sources

# Copyright Kevin Deldycke <[email protected]> and contributors.
#
# This program is Free Software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA.
"""Tests for the dependency source swaps and the release gate.

The swaps are {mod}`repomatic.deps.sources`, the gate is
{mod}`repomatic.deps.shippable`.
"""

from __future__ import annotations

from datetime import UTC, datetime, timedelta
from pathlib import Path

import pytest
from click_extra.testing import CliRunner

from repomatic.cli.main import repomatic
from repomatic.config import Config
from repomatic.deps import sources as dep_sources
from repomatic.deps.cooldown import UvSettings
from repomatic.deps.lockfile import LockFile
from repomatic.deps.report import BYPASS_NEEDS_RELEASE
from repomatic.deps.requirements import dev_floor, read_project
from repomatic.deps.shippable import (
    BLOCKER_NEEDS_EDIT,
    BLOCKER_SECTION_NOTE,
    COOLDOWN_PR_BRANCH,
    LINT_DEPS_HEADER_DEFS,
    RELEASE_READY_SENTENCE,
    SWAP_PR_BRANCH,
    UNSHIPPABLE_BANNER_LEAD,
    SourceKind,
    build_release_readiness,
    floors_inside_cooldown,
    scan_project,
    unused_allow_warnings,
)
from repomatic.deps.sources import (
    ReleaseSwap,
    apply_release_swaps,
    find_ready_swaps,
    format_swap_section,
    strip_dev_bounds,
    tracked_git_overrides,
)
from repomatic.github.outputs import AnnotationLevel
from repomatic.pypi import PyPIRelease
from repomatic.templating import get_template_names, render_template

TYPE_CHECKING = False
if TYPE_CHECKING:
    from click.testing import Result

PYPROJECT = """\
[project]
name = "basket"
version = "1.0.0"
dependencies = [
  "cherry>=1.2",
  "mango[fresh]>=2.1.0.dev0; python_version >= '3.10'",
]

[project.optional-dependencies]
juice = [
  "mango[pressed]",
]

[dependency-groups]
docs = [
  "papaya>=3.0",
]
test = [
  "mango>=2.1.0.dev0",
  { include-group = "docs" },
]

[tool.uv]
exclude-newer = "1 week"
exclude-newer-package = { mango = "2026-01-05T00:00:00Z" }

[tool.uv.sources]
mango = { git = "https://github.com/acme/mango", branch = "main" }
papaya = { git = "https://github.com/acme/papaya", rev = "1234abcd" }
"""
"""A project tracking `mango`'s main branch while awaiting its `2.1.0` release.

`papaya` is pinned to an exact commit and `cherry` is a plain registry
dependency: both sit outside the managed idiom.
"""


def _write_pyproject(tmp_path: Path, content: str = PYPROJECT) -> Path:
    path = tmp_path / "pyproject.toml"
    path.write_text(content, encoding="UTF-8")
    return path


def _mock_releases(monkeypatch, releases: dict[str, dict[str, PyPIRelease]]) -> None:
    """Stub the PyPI lookup with canned release maps."""
    monkeypatch.setattr(
        dep_sources, "pypi_release_dates", lambda name: releases.get(name, {})
    )


[docs] def test_tracked_git_overrides_selects_branch_tracks(tmp_path): """Only single-source git entries with a branch are considered tracked.""" pyproject = _write_pyproject(tmp_path) assert tracked_git_overrides(read_project(pyproject)) == {"mango": "main"} # A missing file, or a project without overrides, yields nothing. assert tracked_git_overrides(read_project(tmp_path / "absent.toml")) == {} bare = _write_pyproject(tmp_path, '[project]\nname = "basket"\n') assert tracked_git_overrides(read_project(bare)) == {}
[docs] def test_dev_floor_takes_highest_dev_bound(tmp_path): """The floor is the highest `.dev` lower bound across every table.""" doc = read_project(_write_pyproject(tmp_path)) assert dev_floor(doc, "mango") == "2.1.0.dev0" # Any capitalization or separator style resolves to the same package. assert dev_floor(doc, "Mango") == "2.1.0.dev0" # Packages without a dev bound have no floor. assert dev_floor(doc, "cherry") is None assert dev_floor(doc, "papaya") is None
[docs] @pytest.mark.parametrize( ("requirement", "release", "expected"), ( ("mango>=2.1.0.dev0", "2.1.0", "mango>=2.1.0"), ( "mango[fresh]>=2.1.0.dev0; python_version >= '3.10'", "2.1.0", "mango[fresh]>=2.1.0; python_version >= '3.10'", ), ("mango>=2.1.0.dev0,<3", "2.1.0", "mango>=2.1.0,<3"), # A skipped version number still tightens the older dev floor. ("mango>=2.1.0.dev0", "2.1.1", "mango>=2.1.0"), # Non-dev bounds and floors awaiting a later release stay untouched. ("mango>=1.2", "2.1.0", "mango>=1.2"), ("mango>=2.2.0.dev0", "2.1.0", "mango>=2.2.0.dev0"), ("mango[pressed]", "2.1.0", "mango[pressed]"), ), ) def test_strip_dev_bounds(requirement: str, release: str, expected: str): """Only matching `.dev` lower bounds are tightened, byte-for-byte.""" assert strip_dev_bounds(requirement, release) == expected
[docs] def test_find_ready_swaps_adopts_newest_stable(tmp_path, monkeypatch): """A stable release satisfying the floor makes the swap ready.""" pyproject = _write_pyproject(tmp_path) _mock_releases( monkeypatch, { "mango": { "2.0.0": PyPIRelease("2026-01-01", False, "mango"), "2.1.0": PyPIRelease("2026-07-10", False, "mango"), "2.1.1": PyPIRelease("2026-07-12", False, "mango"), "2.2.0rc1": PyPIRelease("2026-07-14", False, "mango"), } }, ) swaps = find_ready_swaps(read_project(pyproject)) assert swaps == [ ReleaseSwap( name="mango", source_key="mango", branch="main", floor="2.1.0.dev0", release="2.1.1", released="2026-07-12", ) ] # The freeze cutoff carries a one-day margin past the release date, the # same convention as the automatic freezes. assert swaps[0].freeze_cutoff == "2026-07-14T00:00:00Z"
[docs] @pytest.mark.parametrize( "releases", ( # No release at all (unpublished package or index failure). {}, # Only releases below the floor. {"2.0.0": PyPIRelease("2026-01-01", False, "mango")}, # Only prereleases of the awaited version. {"2.1.0rc1": PyPIRelease("2026-07-10", False, "mango")}, # The awaited release shipped but was yanked. {"2.1.0": PyPIRelease("2026-07-10", True, "mango")}, ), ids=("no-release", "below-floor", "prerelease-only", "yanked"), ) def test_find_ready_swaps_needs_positive_confirmation( tmp_path, monkeypatch, releases: dict[str, PyPIRelease] ): """Anything short of a live stable release keeps the git track in place.""" pyproject = _write_pyproject(tmp_path) _mock_releases(monkeypatch, {"mango": releases}) assert find_ready_swaps(read_project(pyproject)) == []
[docs] def test_find_ready_swaps_skips_floorless_overrides(tmp_path, monkeypatch): """A branch track without a dev floor is outside the managed idiom.""" pyproject = _write_pyproject( tmp_path, '[project]\nname = "basket"\nversion = "1.0.0"\n' 'dependencies = [ "mango" ]\n\n' "[tool.uv.sources]\n" 'mango = { git = "https://github.com/acme/mango", branch = "main" }\n', ) _mock_releases( monkeypatch, {"mango": {"9.9.9": PyPIRelease("2026-07-01", False, "mango")}} ) assert find_ready_swaps(read_project(pyproject)) == []
def _apply_swaps(pyproject: Path, swaps: list[ReleaseSwap]) -> str: """Apply *swaps* to *pyproject* in memory, save it, and return its text.""" settings = UvSettings.load(pyproject) apply_release_swaps(settings.doc, swaps) settings.save() return pyproject.read_text(encoding="UTF-8")
[docs] def test_apply_release_swaps_rewrites_pyproject(tmp_path): """The override is dropped and every dev floor tightened, nothing else.""" pyproject = _write_pyproject(tmp_path) swap = ReleaseSwap( name="mango", source_key="mango", branch="main", floor="2.1.0.dev0", release="2.1.0", released="2026-07-10", ) content = _apply_swaps(pyproject, [swap]) # The override is gone; the rev-pinned sibling survives, so does the table. assert "mango = { git" not in content assert 'papaya = { git = "https://github.com/acme/papaya", rev' in content assert "[tool.uv.sources]" in content # Floors tightened in both tables, extras and markers preserved. assert "\"mango[fresh]>=2.1.0; python_version >= '3.10'\"" in content assert '"mango>=2.1.0"' in content assert ".dev0" not in content # Untouched bystanders. assert '"cherry>=1.2"' in content assert '"mango[pressed]"' in content assert '"papaya>=3.0"' in content
[docs] def test_apply_release_swaps_drops_emptied_sources_table(tmp_path): """Removing the last override removes the `[tool.uv.sources]` table too.""" pyproject = _write_pyproject( tmp_path, '[project]\nname = "basket"\nversion = "1.0.0"\n' 'dependencies = [ "mango>=2.1.0.dev0" ]\n\n' "[tool.uv]\n" 'exclude-newer = "1 week"\n\n' "[tool.uv.sources]\n" 'mango = { git = "https://github.com/acme/mango", branch = "main" }\n', ) swap = ReleaseSwap( name="mango", source_key="mango", branch="main", floor="2.1.0.dev0", release="2.1.0", released="2026-07-10", ) content = _apply_swaps(pyproject, [swap]) assert "sources" not in content assert '"mango>=2.1.0"' in content
[docs] def test_format_swap_section(): """One row per swap, with branch, adopted release, and ship date.""" section = format_swap_section( [ ReleaseSwap( name="mango", source_key="mango", branch="main", floor="2.1.0.dev0", release="2.1.0", released="2026-07-10", ) ], name_urls={"mango": "https://pypi.org/project/mango/"}, ) assert "## 🔀 Source swaps" in section assert "| Package | Tracked branch | Adopted | Released |" in section assert ( "| [mango](https://pypi.org/project/mango/) | `main` | `2.1.0` | 2026-07-10 |" ) in section # Empty input yields nothing; an unmapped name renders plain. assert format_swap_section([]) == "" section = format_swap_section([ ReleaseSwap("mango", "mango", "main", "2.1.0.dev0", "2.1.0", "2026-07-10") ]) assert "| mango |" in section
# --------------------------------------------------------------------------- # Dependency floors versus the install cooldown # --------------------------------------------------------------------------- REPO_ROOT = Path(__file__).parent.parent COOLDOWN_PYPROJECT = """\ [project] name = "basket" version = "1.0.0" dependencies = [ "{requirement}", ] """ COOLDOWN_LOCK = """\ version = 1 [[package]] name = "cherry" version = "{locked}" [package.sdist] upload-time = "{upload}" """
[docs] @pytest.mark.parametrize( ("requirement", "floor", "locked", "age_days", "flagged"), ( # Floor demands the locked release, which is still inside the window. ("cherry>=1.2", "1.2", "1.2.0", 1, True), # Same floor, but the release has aged out: uvx resolves it unaided. ("cherry>=1.2", "1.2", "1.2.0", 30, False), # Floor predates the locked release, so an older version satisfies it. ("cherry>=1.0", "1.0", "1.2.0", 1, False), # An exclusive bound is a lower bound too. This exercises the stale-lock # corner (a floor excluding the version still locked): with a fresh lock # the locked version always strictly exceeds a `>` floor, so the narrower # fresh-lock case cannot fire, and this guard is deliberately about the # operator set rather than that arithmetic. ("cherry>1.2", "1.2", "1.2.0", 1, True), ), ) def test_floors_inside_cooldown( tmp_path: Path, requirement: str, floor: str, locked: str, age_days: int, flagged: bool, ) -> None: """Only a floor demanding a release inside the window is reported.""" upload = datetime.now(UTC) - timedelta(days=age_days) pyproject = tmp_path / "pyproject.toml" pyproject.write_text( COOLDOWN_PYPROJECT.format(requirement=requirement), encoding="UTF-8" ) lock = tmp_path / "uv.lock" lock.write_text( COOLDOWN_LOCK.format( locked=locked, upload=upload.isoformat().replace("+00:00", "Z") ), encoding="UTF-8", ) offenders = floors_inside_cooldown( read_project(pyproject), LockFile.load(lock), "8 days" ) assert (set(offenders) == {"cherry"}) is flagged if flagged: stale = offenders["cherry"] assert stale.floor == floor # A rolling window lifts the floor on its own, on the day the locked # release turns 8 days old. expected = (upload + timedelta(days=8)).date() assert stale.clears.startswith(expected.isoformat())
COOLDOWN_GROUP_PYPROJECT = """\ [project] name = "basket" version = "1.0.0" dependencies = [ "{project}", ] [dependency-groups] docs = [ "cherry>=1.2", ] """ """A project whose `docs` group demands the locked `cherry` release.""" def _cooldown_group_project(tmp_path: Path, project: str) -> tuple[Path, Path]: """Write `COOLDOWN_GROUP_PYPROJECT`, with `cherry` 1.2.0 locked a day ago. :param tmp_path: Directory to write the two files into. :param project: The `cherry` requirement of `[project] dependencies`. :return: The paths of `pyproject.toml` and `uv.lock`. """ upload = datetime.now(UTC) - timedelta(days=1) pyproject = tmp_path / "pyproject.toml" pyproject.write_text( COOLDOWN_GROUP_PYPROJECT.format(project=project), encoding="UTF-8" ) lock = tmp_path / "uv.lock" lock.write_text( COOLDOWN_LOCK.format( locked="1.2.0", upload=upload.isoformat().replace("+00:00", "Z") ), encoding="UTF-8", ) return pyproject, lock
[docs] @pytest.mark.parametrize( ("project", "location", "published", "level"), ( # Only the group floor demands the release inside the window: it ships # nowhere, so it warns instead of blocking a release. ("cherry>=1.0", "dependency-groups.docs", False, AnnotationLevel.WARNING), # Both floors demand it: the one reaching published metadata is reported. ("cherry>=1.2", "project.dependencies", True, AnnotationLevel.ERROR), ), ) def test_floor_inside_cooldown_names_its_array( tmp_path: Path, project: str, location: str, published: bool, level: AnnotationLevel, ) -> None: """A floor inside the cooldown names its array, and blocks only if it ships.""" pyproject, lock = _cooldown_group_project(tmp_path, project) stale = floors_inside_cooldown( read_project(pyproject), LockFile.load(lock), "8 days" )["cherry"] assert (stale.location, stale.published) == (location, published) findings = scan_project(pyproject, lock, "8 days") assert [(f.package, f.location, f.level) for f in findings] == [ ("cherry", location, level) ]
[docs] def test_dependency_floors_clear_the_cooldown() -> None: """No dependency floor requires a release still inside the cooldown. Such a floor makes the published package uninstallable for 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`. Neither can see `uv.lock` or `[tool.uv] exclude-newer-package`, and uv has no environment variable for a per-package exemption, so they have nowhere to record a bypass. This repository's own CI cannot catch it, because it installs from `uv.lock` and resolves straight through the local exemption. That is what makes this a release gate rather than a CI symptom. Wait for a release to clear the window before raising a floor onto it. """ offenders = { name: stale for name, stale in floors_inside_cooldown( read_project(REPO_ROOT / "pyproject.toml"), LockFile.load(REPO_ROOT / "uv.lock"), Config.minimum_release_age, ).items() # A dependency group floor never reaches published metadata. if stale.published } listed = ", ".join( f"{name}>={stale.floor} (clears {stale.clears or 'never'})" for name, stale in sorted(offenders.items()) ) assert not offenders, ( f"Dependency floor(s) inside the {Config.minimum_release_age} cooldown: " f"{listed}. Releasing now ships a package that downstream repos and " "`uvx` users cannot resolve: they see neither uv.lock nor [tool.uv] " "exclude-newer-package, and uv has no env var for a per-package " "exemption. Wait for the release to age out of the window." )
# --------------------------------------------------------------------------- # Shippability gate (`lint-deps`) # --------------------------------------------------------------------------- SHIPPABLE_PYPROJECT = """\ [build-system] requires = [ "uv-build>=0.8" ] [project] name = "basket" version = "1.0.0" dependencies = [ "cherry>=1.2" ] [project.optional-dependencies] juice = [ "papaya>=3" ] [dependency-groups] test = [ "mango>=2" ] """ """A project whose every dependency resolves from PyPI. Each rule below appends its own offending declaration to this base, so a finding can only come from what the test added. """ def _scan(tmp_path: Path, extra: str = "", allow: dict[str, str] | None = None): """Scan a copy of `SHIPPABLE_PYPROJECT` with *extra* appended.""" pyproject = tmp_path / "pyproject.toml" pyproject.write_text(SHIPPABLE_PYPROJECT + extra, encoding="UTF-8") return scan_project(pyproject, tmp_path / "uv.lock", "1 week", allow=allow)
[docs] def test_clean_project_reports_nothing(tmp_path: Path) -> None: """A project resolving everything from PyPI has no findings.""" assert _scan(tmp_path) == []
[docs] @pytest.mark.parametrize( ("extra", "package", "kind"), ( pytest.param( ( "[tool.uv.sources]\n" 'cherry = { git = "https://x/cherry", branch = "main" }\n' ), "cherry", SourceKind.GIT, id="git-branch", ), pytest.param( ( "[tool.uv.sources]\n" 'cherry = { git = "https://x/cherry", rev = "abc123" }\n' ), "cherry", SourceKind.GIT, id="git-rev", ), pytest.param( '[tool.uv.sources]\ncherry = { git = "https://x/cherry", tag = "v1.2" }\n', "cherry", SourceKind.GIT, id="git-tag", ), pytest.param( '[tool.uv.sources]\ncherry = { path = "../cherry" }\n', "cherry", SourceKind.PATH, id="path", ), pytest.param( '[tool.uv.sources]\ncherry = { path = "../cherry", editable = true }\n', "cherry", SourceKind.PATH, id="editable-path", ), pytest.param( ( "[tool.uv.sources]\n" 'cherry = { url = "https://x/cherry-1.2-py3-none-any.whl" }\n' ), "cherry", SourceKind.URL, id="url", ), pytest.param( "[tool.uv.sources]\ncherry = { workspace = true }\n", "cherry", SourceKind.WORKSPACE, id="workspace", ), pytest.param( '[[tool.uv.index]]\nname = "internal"\nurl = "https://x/simple"\n' '[tool.uv.sources]\ncherry = { index = "internal" }\n', "cherry", SourceKind.INDEX, id="private-index", ), pytest.param( ( '[[tool.uv.index]]\nname = "mirror"\nurl = "https://x/simple"\n' "default = true\n" ), "mirror", SourceKind.INDEX, id="non-pypi-default-index", ), ), ) def test_unshippable_sources_block( tmp_path: Path, extra: str, package: str, kind: SourceKind, ) -> None: """Every way of resolving a dependency off-index blocks a release.""" findings = _scan(tmp_path, extra) assert [(f.package, f.kind, f.blocking) for f in findings] == [ (package, kind, True) ]
[docs] @pytest.mark.parametrize( "table", ( "project.dependencies", "project.optional-dependencies.juice", "dependency-groups.test", "build-system.requires", ), ) def test_direct_references_block_in_every_table(tmp_path: Path, table: str) -> None: """A PEP 508 direct reference blocks wherever it is declared. The `v5.0.0` release shipped one in an extra, which PyPI refused at upload after the tag and the GitHub release had already been created. """ direct = "melon @ git+https://x/melon@fix-ripening" doc = SHIPPABLE_PYPROJECT.replace( { "project.dependencies": '[ "cherry>=1.2" ]', "project.optional-dependencies.juice": '[ "papaya>=3" ]', "dependency-groups.test": '[ "mango>=2" ]', "build-system.requires": '[ "uv-build>=0.8" ]', }[table], f'[ "{direct}" ]', ) pyproject = tmp_path / "pyproject.toml" pyproject.write_text(doc, encoding="UTF-8") findings = scan_project(pyproject, tmp_path / "uv.lock", "1 week") assert [(f.package, f.kind, f.location, f.blocking) for f in findings] == [ ("melon", SourceKind.DIRECT_REFERENCE, table, True) ]
[docs] def test_per_platform_source_list_reports_once_per_kind(tmp_path: Path) -> None: """A marker-split override is one finding, not one per platform.""" extra = ( "[tool.uv.sources]\n" "cherry = [\n" ' { git = "https://x/cherry", branch = "main",' " marker = \"sys_platform == 'linux'\" },\n" ' { git = "https://x/cherry", branch = "main",' " marker = \"sys_platform == 'darwin'\" },\n" "]\n" ) findings = _scan(tmp_path, extra) assert [(f.package, f.kind) for f in findings] == [("cherry", SourceKind.GIT)]
[docs] def test_pypi_element_does_not_mask_a_private_one(tmp_path: Path) -> None: """Deduplication must not let a clean list element hide a dirty one. Both elements classify as an index source, and the PyPI one is skipped without reporting. Recording it as seen anyway would swallow the private index that follows it. """ extra = ( '[[tool.uv.index]]\nname = "pypi"\nurl = "https://pypi.org/simple"\n' '[[tool.uv.index]]\nname = "internal"\nurl = "https://x/simple"\n' "[tool.uv.sources]\n" "cherry = [\n" ' { index = "pypi", marker = "sys_platform == \'linux\'" },\n' ' { index = "internal", marker = "sys_platform == \'darwin\'" },\n' "]\n" ) findings = _scan(tmp_path, extra) assert [(f.package, f.kind) for f in findings] == [("cherry", SourceKind.INDEX)] assert "internal" in findings[0].detail
[docs] def test_pypi_index_source_is_shippable(tmp_path: Path) -> None: """An explicit index entry pointing at PyPI is not a finding.""" extra = ( '[[tool.uv.index]]\nname = "pypi"\nurl = "https://pypi.org/simple"\n' '[tool.uv.sources]\ncherry = { index = "pypi" }\n' ) assert _scan(tmp_path, extra) == []
[docs] def test_finding_row_follows_the_columns(tmp_path: Path) -> None: """The terminal row fills the `lint-deps` columns in their declared order.""" extra = "[tool.uv.sources]\ncherry = { workspace = true }\n" (finding,) = _scan(tmp_path, extra) assert len(finding.row) == len(LINT_DEPS_HEADER_DEFS) assert finding.row == ( "cherry", "workspace", "tool.uv.sources", "blocks release", )
[docs] def test_allowlist_downgrades_without_hiding(tmp_path: Path) -> None: """An allowed package still reports, carrying its reason, but stops blocking.""" extra = "[tool.uv.sources]\ncherry = { workspace = true }\n" reason = "monorepo member, published separately" findings = _scan(tmp_path, extra, allow={"cherry": reason}) assert len(findings) == 1 assert not findings[0].blocking assert findings[0].allowed == reason assert reason in findings[0].verdict
WORKSPACE_SOURCE = "[tool.uv.sources]\ncherry = { workspace = true }\n" """A source the allowlist can exempt, appended to `SHIPPABLE_PYPROJECT`."""
[docs] @pytest.mark.parametrize( ("extra", "allow", "expected"), ( pytest.param(WORKSPACE_SOURCE, {"cherry": "monorepo member"}, [], id="used"), pytest.param( "", {"cherry": "monorepo member"}, [ ( "cherry: `lint-deps.allow` exempts it, but no finding names it." " Remove the entry: it would also exempt a later finding of the" " package, a git source included." ) ], id="unused", ), pytest.param( WORKSPACE_SOURCE, {"Cherry": "monorepo member"}, [ ( "Cherry: `lint-deps.allow` names it, but findings use normalized" " names, so the entry exempts nothing. Spell it `cherry`." ) ], id="unnormalized", ), ), ) def test_unused_allow_entries_warn( tmp_path: Path, extra: str, allow: dict[str, str], expected: list[str] ) -> None: """An entry warns when no finding names it as written.""" findings = _scan(tmp_path, extra, allow=allow) assert unused_allow_warnings(findings, allow) == expected
[docs] def test_resolution_overrides_warn_without_blocking(tmp_path: Path) -> None: """`override-dependencies` diverges the tested tree without breaking installs.""" extra = '[tool.uv]\noverride-dependencies = [ "mango>=2.5" ]\n' findings = _scan(tmp_path, extra) assert len(findings) == 1 assert not findings[0].blocking
LOCK_WITH_GIT_SOURCE = """\ version = 1 [[package]] name = "basket" version = "1.0.0" source = { editable = "." } [[package]] name = "melon" version = "0.4.0" source = { git = "https://x/melon?branch=main#abc123" } """
[docs] def test_lock_catches_what_pyproject_never_names(tmp_path: Path) -> None: """A transitively-pulled git source shows up even with a clean pyproject. This is why the gate reads the lock as well as the declarations: a source override can name a package no requirement array mentions, and only the resolved tree records it. The project's own `editable = "."` entry is not a dependency and is skipped. """ pyproject = tmp_path / "pyproject.toml" pyproject.write_text(SHIPPABLE_PYPROJECT, encoding="UTF-8") lock = tmp_path / "uv.lock" lock.write_text(LOCK_WITH_GIT_SOURCE, encoding="UTF-8") findings = scan_project(pyproject, lock, "1 week") assert [(f.package, f.kind, f.location) for f in findings] == [ ("melon", SourceKind.GIT, "uv.lock") ]
[docs] def test_a_package_is_reported_once(tmp_path: Path) -> None: """A source flagged by both halves keeps the actionable pyproject finding.""" pyproject = tmp_path / "pyproject.toml" pyproject.write_text( SHIPPABLE_PYPROJECT + '[tool.uv.sources]\nmelon = { git = "https://x/melon", branch = "main" }\n', encoding="UTF-8", ) lock = tmp_path / "uv.lock" lock.write_text(LOCK_WITH_GIT_SOURCE, encoding="UTF-8") findings = scan_project(pyproject, lock, "1 week") assert [f.location for f in findings] == ["tool.uv.sources"]
GIT_SOURCE = ( '[tool.uv.sources]\ncherry = { git = "https://x/cherry", branch = "main" }\n' ) """A source override that blocks a release, appended to `SHIPPABLE_PYPROJECT`.""" def _lint_deps( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, doc: str, *args: str ) -> Result: """Run `lint-deps` in a project declaring *doc*, with no lockfile.""" (tmp_path / "pyproject.toml").write_text(doc, encoding="UTF-8") monkeypatch.chdir(tmp_path) return CliRunner().invoke(repomatic, ["lint-deps", *args])
[docs] def test_lint_deps_passes_a_project_resolving_from_the_index( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """A project with nothing to report gets the clean bill of health alone.""" result = _lint_deps(tmp_path, monkeypatch, SHIPPABLE_PYPROJECT) assert result.exit_code == 0, result.output assert "✓ Every dependency resolves from the public package index." in ( result.stdout ) assert "✗" not in result.stdout assert "⚠" not in result.stdout
[docs] @pytest.mark.parametrize(("fatal", "exit_code"), (("--fatal", 1), ("--no-fatal", 0))) def test_lint_deps_fails_on_a_blocker_only_when_fatal( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, fatal: str, exit_code: int ) -> None: """A blocking finding prints either way, and fails the run under `--fatal`.""" result = _lint_deps(tmp_path, monkeypatch, SHIPPABLE_PYPROJECT + GIT_SOURCE, fatal) assert result.exit_code == exit_code, result.output assert "✗ cherry: git source in tool.uv.sources" in result.stdout assert "✓" not in result.stdout
[docs] def test_lint_deps_reports_an_allowed_source_without_blocking( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """A source named in `lint-deps.allow` prints with its reason, and passes.""" doc = ( SHIPPABLE_PYPROJECT + "[tool.uv.sources]\ncherry = { workspace = true }\n\n" + '[tool.repomatic]\nlint-deps.allow = { cherry = "monorepo member" }\n' ) result = _lint_deps(tmp_path, monkeypatch, doc) assert result.exit_code == 0, result.output assert "allowed (monorepo member)" in result.stdout assert "ℹ cherry: workspace source in tool.uv.sources" in result.stdout
[docs] def test_lint_deps_warns_on_an_unused_allow_entry_without_blocking( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """An entry no finding names warns after the clean bill of health, and passes.""" doc = ( SHIPPABLE_PYPROJECT + '[tool.repomatic]\nlint-deps.allow = { cherry = "monorepo member" }\n' ) result = _lint_deps(tmp_path, monkeypatch, doc, "--fatal") assert result.exit_code == 0, result.output assert "✓ Every dependency resolves from the public package index." in ( result.stdout ) assert "⚠ cherry: `lint-deps.allow` exempts it, but no finding names it." in ( result.stdout )
[docs] def test_lint_deps_warns_on_a_resolution_override_without_blocking( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """An `override-dependencies` entry prints as a warning, and the gate passes.""" doc = SHIPPABLE_PYPROJECT + '[tool.uv]\noverride-dependencies = [ "cherry==1.2" ]\n' result = _lint_deps(tmp_path, monkeypatch, doc, "--no-policy") assert result.exit_code == 0, result.output assert "⚠ cherry: registry source in tool.uv.override-dependencies" in result.stdout assert "✗" not in result.stdout
[docs] @pytest.mark.parametrize( ("policy", "warned"), (("--policy", True), ("--no-policy", False)) ) def test_lint_deps_policy_warnings_never_block( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, policy: str, warned: bool ) -> None: """A policy departure prints after the clean bill of health, and exits 0.""" doc = SHIPPABLE_PYPROJECT.replace('"cherry>=1.2"', '"cherry>=1.2,<2"') result = _lint_deps(tmp_path, monkeypatch, doc, policy) assert result.exit_code == 0, result.output assert "✓ Every dependency resolves from the public package index." in ( result.stdout ) assert ("⚠ cherry: project.dependencies" in result.stdout) is warned
LOCK_WITH_AN_EMPTY_EXTRA = """\ version = 1 requires-python = ">=3.11" [[package]] name = "basket" version = "1.0.0" source = { editable = "." } dependencies = [ { name = "cherry" }, ] [package.metadata] requires-dist = [{ name = "cherry", extras = ["juice"], specifier = ">=1.2" }] [[package]] name = "cherry" version = "1.2.0" source = { registry = "https://pypi.org/simple" } """ """A lock resolving the `juice` extra of cherry to no package."""
[docs] def test_lint_deps_judges_extras_against_the_lock( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """The policy pass reads `uv.lock` to warn about an extra installing nothing.""" (tmp_path / "uv.lock").write_text(LOCK_WITH_AN_EMPTY_EXTRA, encoding="UTF-8") doc = SHIPPABLE_PYPROJECT.replace('"cherry>=1.2"', '"cherry[juice]>=1.2"') result = _lint_deps(tmp_path, monkeypatch, doc) assert result.exit_code == 0, result.output assert "✓ Every dependency resolves from the public package index." in ( result.stdout ) assert ( "⚠ cherry: project.dependencies (`cherry[juice]>=1.2`). `uv.lock` resolves" " its `juice` extra to no package" ) in result.stdout
[docs] def test_lint_deps_writes_its_blockers_to_a_report( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """`--output` receives the blocker table the release pull request shows.""" result = _lint_deps( tmp_path, monkeypatch, SHIPPABLE_PYPROJECT + GIT_SOURCE, "--no-fatal", "--output", "report.md", ) assert result.exit_code == 0, result.output report = (tmp_path / "report.md").read_text(encoding="UTF-8") assert "| `cherry` | `git` | `tool.uv.sources` |" in report
[docs] def test_lint_deps_refuses_a_missing_pyproject( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """A directory with no `pyproject.toml` fails the gate instead of passing it. Read as an empty project, the missing file would declare no dependency, and print the clean bill of health for a release it never checked. """ monkeypatch.chdir(tmp_path) result = CliRunner().invoke(repomatic, ["lint-deps"]) assert result.exit_code == 2, result.output assert "does not exist" in result.output assert "✓" not in result.output
[docs] @pytest.mark.parametrize( ("floor", "expected"), ( # An unreleased floor is what the index cannot serve at all. ("mango>=2.1.0.dev0", "every install of this release fails"), # A released floor installs, just not the code that was tested. ("mango>=2", "not the code this release was built and tested against"), ), ) def test_consequence_distinguishes_broken_from_divergent( tmp_path: Path, floor: str, expected: str, ) -> None: """The report separates a failing install from a silently different one.""" doc = SHIPPABLE_PYPROJECT.replace('dependencies = [ "cherry>=1.2" ]', "").replace( '[dependency-groups]\ntest = [ "mango>=2" ]', f'dependencies = [ "{floor}" ]', ) pyproject = tmp_path / "pyproject.toml" pyproject.write_text( doc + '[tool.uv.sources]\nmango = { git = "https://x/mango", branch = "main" }\n', encoding="UTF-8", ) findings = scan_project(pyproject, tmp_path / "uv.lock", "1 week") assert len(findings) == 1 assert expected in findings[0].consequence
[docs] def test_release_readiness_flips_the_pr_opening(tmp_path: Path) -> None: """The release PR announces readiness only while the project has it.""" pyproject = tmp_path / "pyproject.toml" lock = tmp_path / "uv.lock" pyproject.write_text(SHIPPABLE_PYPROJECT, encoding="UTF-8") assert build_release_readiness(pyproject, lock, "1 week") == RELEASE_READY_SENTENCE # Two blockers, so the banner has to scale past one package: `mango` # tracks a branch under the managed idiom (a `.dev` floor names the # awaited release), `cherry` tracks one without. pyproject.write_text( SHIPPABLE_PYPROJECT.replace( 'test = [ "mango>=2" ]', 'test = [ "mango>=2.dev0" ]' ) + "[tool.uv.sources]\n" + 'cherry = { git = "https://x/cherry", branch = "main" }\n' + 'mango = { git = "https://x/mango", branch = "main" }\n', encoding="UTF-8", ) banner = build_release_readiness(pyproject, lock, "1 week") assert banner.startswith("> [!CAUTION]") assert UNSHIPPABLE_BANNER_LEAD in banner # A verdict, not a report: the alert marker, the lead, and a table of one # row per package, with none of the prose or diagnosis `lint-deps` renders. assert BLOCKER_SECTION_NOTE not in banner assert "| Package | Clears |" in banner assert "Why it cannot ship" not in banner rows = [line for line in banner.splitlines() if line.startswith("> | `")] assert len(rows) == 2 # Every line stays quoted, or GitHub renders the table outside the # admonition. assert all(line.startswith(">") for line in banner.strip().splitlines()) # Each row says what the reader is waiting on. A git track paired with a # dev floor is what `sync-dep-sources` watches, so it names a release; # one without has nothing watching it, so it names an edit. assert f"🚧 *{BYPASS_NEEDS_RELEASE}*" in banner assert f"✋ *{BLOCKER_NEEDS_EDIT}*" in banner # The package points at the line declaring it, linked when the caller # supplies a commit to hang the blob URL off, plain text otherwise. declared_line = next( number for number, line in enumerate( pyproject.read_text(encoding="UTF-8").splitlines(), start=1 ) if line.startswith("cherry =") ) declaration = f"pyproject.toml#L{declared_line}" assert f"`cherry` ({declaration})" in banner linked = build_release_readiness( pyproject, lock, "1 week", source_url="https://x/repo/blob/deadbeef" ) assert f"[`cherry`](https://x/repo/blob/deadbeef/{declaration})" in linked
[docs] def test_release_pr_reserves_caution_for_the_unguarded_step(tmp_path: Path) -> None: """The release PR's two alerts rank by what else would catch the mistake. `Squash and merge` is a warning because the release lane catches it anyway: `squashed-release` in `_release-build.yaml` fires on the collapsed history and files the recovery. Merging a blocked release has no such backstop on the merge itself, and by the time `lint-deps` fails in the release lane the freeze commit is on `main` and the version has to be burned. So the caution is spent on the step nothing else guards, and the template must not spend it anywhere else. """ pyproject = tmp_path / "pyproject.toml" pyproject.write_text( SHIPPABLE_PYPROJECT + '[tool.uv.sources]\ncherry = { path = "../cherry" }\n', encoding="UTF-8", ) blocked = render_template( "prepare-release", version="1.2.3", dev_release_review="", changes_review="", release_readiness=build_release_readiness( pyproject, tmp_path / "uv.lock", "1 week" ), ) assert blocked.count("[!CAUTION]") == 1 assert blocked.index(UNSHIPPABLE_BANNER_LEAD) < blocked.index("[!WARNING]") assert "> [!WARNING]\n> Do not `Squash and merge`" in blocked
[docs] def test_project_ships_only_released_dependencies() -> None: """This repository can be released as it stands. The generalization of `test_dependency_floors_clear_the_cooldown` above: that one covers a floor the cooldown makes unreachable, this one covers every other way a dependency fails to reach the people installing the published artifact. Both are release gates rather than CI symptoms, since this repository's own workflows install from `uv.lock` and resolve straight past the problem. The same check runs in the release lane (`_release-build.yaml`'s `lint-deps` job) and in the release PR body, so this is the local copy of a gate that also holds downstream. Its value here is latency: a `pytest` run says so in milliseconds, where CI takes minutes and the release PR banner needs a push. """ findings = scan_project( REPO_ROOT / "pyproject.toml", REPO_ROOT / "uv.lock", Config.minimum_release_age, ) blocking = [finding for finding in findings if finding.blocking] assert not blocking, "\n".join( ["Unshippable dependencies would break this release:"] + [f" {finding.message}" for finding in blocking] )