# 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
# ---------------------------------------------------------------------------
# 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_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_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]
@pytest.mark.parametrize("branch", (COOLDOWN_PR_BRANCH, SWAP_PR_BRANCH))
def test_countdown_links_a_job_that_still_exists(branch: str) -> None:
"""Each countdown link points at a job that still opens pull requests.
The link is a search over the job's pull request branch, and that branch
is the template name. Renaming or dropping a template would leave the
banner pointing at a query nothing ever matches, which reads as "nothing
is coming" rather than as a broken link.
"""
assert branch in get_template_names()
[docs]
def test_countdown_links_the_job_that_lifts_each_blocker(tmp_path: Path) -> None:
"""A dated blocker points at `sync-uv-lock`, an awaited one at the swap.
Both jobs report the same clearing date from the same two numbers the
banner reads: the locked release's upload time, and the `exclude-newer`
span. The link is what takes the reader from the verdict to that report.
"""
pyproject = tmp_path / "pyproject.toml"
pyproject.write_text(
SHIPPABLE_PYPROJECT.replace(
'test = [ "mango>=2" ]', 'test = [ "mango>=2.dev0" ]'
)
+ "[tool.uv.sources]\n"
+ 'cherry = { path = "../cherry" }\n'
+ 'mango = { git = "https://x/mango", branch = "main" }\n',
encoding="UTF-8",
)
banner = build_release_readiness(
pyproject, tmp_path / "uv.lock", "1 week", repo_url="https://x/repo"
)
swap_queue = f"https://x/repo/pulls?q=is%3Apr+is%3Aopen+head%3A{SWAP_PR_BRANCH}"
assert f"[🚧 *{BYPASS_NEEDS_RELEASE}*]({swap_queue})" in banner
# Nothing opens a pull request for a path source, so its cell stays plain
# rather than linking somewhere that will never answer.
assert f"✋ *{BLOCKER_NEEDS_EDIT}*" in banner
assert f"[✋ *{BLOCKER_NEEDS_EDIT}*]" not in banner
# A caller with no repository to point at renders every cell plain.
assert "pulls?q=" not in build_release_readiness(
pyproject, tmp_path / "uv.lock", "1 week"
)
[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]
)