CI / Sync project version with tag (hand-pushed tags only) (pull_request) Skipped
CI / Publish package to Gitea package registry (pull_request) Skipped
CI / Lint (ruff check) (pull_request) Successful in 1m28s
CI / Type check (ty) (pull_request) Successful in 1m28s
CI / Format (ruff format) (pull_request) Successful in 1m28s
CI / Tests (pull_request) Successful in 3m34s
CI / Release (bump, changelog, badges, tag) on merge to master (pull_request) Skipped
Adds two Gitea Actions scheduled workflows, each opening/updating one standing pull request rather than pushing to master or opening an issue, so the existing pull_request CI (lint/format/type-check/tests) gates every change before a human merges: - deps-lock.yml (weekly, Mondays): `uv lock --upgrade` within the existing pyproject.toml constraints, PR'd on chore/uv-lock-upgrade. - deps-bounds.yml (monthly): raises pyproject.toml upper bounds that have fallen behind the latest PyPI release, re-locks, PR'd on chore/dep-bounds. torch (pinned <2.4 for portal-machine driver support), plotstyle (private index), and the giant[...] self-references are permanently excluded. Both branches are force-pushed fresh from master each run (no history accumulation, at most one open PR per job), sharing PR-upsert mechanics in deps-pr.sh. Both support workflow_dispatch for manual testing. No changes to ci.yml, release-commit.sh, .bumpversion.toml, or cliff.toml — merging either PR flows through the existing release job unchanged. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KhEer5EWzFU6NJi8hTnDe1
148 lines
5.4 KiB
Python
148 lines
5.4 KiB
Python
"""Tests for the scheduled dependency-bump automation (weekly uv.lock
|
|
refresh, monthly pyproject upper-bound raise). The workflow YAMLs themselves
|
|
can only be exercised by a real scheduled/dispatched run (same reasoning as
|
|
tests/test_release_tooling.py for the release workflow), so this checks the
|
|
script logic they drive plus the one piece of cross-file coupling that would
|
|
silently misbehave if it drifted: each workflow's script must target the
|
|
same standing branch that deps-pr.sh (invoked by that script) manages.
|
|
"""
|
|
|
|
import importlib.util
|
|
import re
|
|
import sys
|
|
import tomllib
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
import yaml
|
|
from packaging.requirements import Requirement
|
|
from packaging.version import Version
|
|
|
|
_ROOT = Path(__file__).resolve().parents[1]
|
|
_SCRIPTS = _ROOT / ".gitea" / "scripts"
|
|
_WORKFLOWS = _ROOT / ".gitea" / "workflows"
|
|
|
|
|
|
def _load_check_dep_bounds():
|
|
spec = importlib.util.spec_from_file_location("check_dep_bounds", _SCRIPTS / "check_dep_bounds.py")
|
|
assert spec is not None and spec.loader is not None
|
|
module = importlib.util.module_from_spec(spec)
|
|
sys.modules[spec.name] = module
|
|
spec.loader.exec_module(module)
|
|
return module
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def cdb():
|
|
return _load_check_dep_bounds()
|
|
|
|
|
|
def test_canonical_requirements_finds_real_dependencies_and_skips_excluded(cdb):
|
|
pyproject = tomllib.loads((_ROOT / "pyproject.toml").read_text())
|
|
requirements = cdb.canonical_requirements(pyproject)
|
|
|
|
assert "numpy" in requirements
|
|
assert "polars" in requirements
|
|
# polars appears in [project.dependencies], [convert], and [analysis]
|
|
# with the same specifier — canonicalization must dedupe by name.
|
|
assert isinstance(requirements["polars"], Requirement)
|
|
|
|
for skipped in ("torch", "plotstyle", "giant"):
|
|
assert skipped in cdb.SKIP_REASONS, f"{skipped} must stay on the never-auto-raise skip list"
|
|
|
|
|
|
def test_next_ceiling_pre_and_post_1_0():
|
|
from importlib import import_module
|
|
|
|
cdb = import_module("check_dep_bounds")
|
|
# Post-1.0: next ceiling is the major above latest.
|
|
assert cdb.next_ceiling(Version("2.5.3")) == "<3"
|
|
assert cdb.next_ceiling(Version("25.0.1")) == "<26"
|
|
# Pre-1.0: next ceiling is the minor above latest (matches this repo's
|
|
# own pins, e.g. ruff>=0.15,<1 and ty>=0.0.50,<0.1).
|
|
assert cdb.next_ceiling(Version("0.16.2")) == "<0.17"
|
|
|
|
|
|
def test_find_findings_flags_out_of_range_and_skips_excluded(cdb, monkeypatch):
|
|
requirements = {
|
|
"numpy": Requirement("numpy>=1.26,<3"),
|
|
"widget": Requirement("widget>=1,<2"),
|
|
"torch": Requirement("torch>=2.3,<2.4"),
|
|
}
|
|
fake_latest = {"numpy": "2.5.3", "widget": "3.1.0", "torch": "2.9.0"}
|
|
monkeypatch.setattr(cdb, "fetch_latest_version", lambda name: fake_latest[name])
|
|
|
|
findings = cdb.find_findings(requirements, {"torch": "pinned deliberately"})
|
|
|
|
assert len(findings) == 1
|
|
finding = findings[0]
|
|
assert finding.name == "widget"
|
|
assert set(finding.old_specifier.split(",")) == {">=1", "<2"}
|
|
# SpecifierSet doesn't guarantee clause order, so compare as a set.
|
|
assert set(finding.new_specifier.split(",")) == {">=1", "<4"}
|
|
assert finding.latest == "3.1.0"
|
|
|
|
|
|
def test_apply_findings_rewrites_every_occurrence_and_nothing_else(cdb):
|
|
text = (
|
|
"[project]\n"
|
|
"dependencies = [\n"
|
|
' "polars>=1.43,<2",\n'
|
|
"]\n\n"
|
|
"[project.optional-dependencies]\n"
|
|
"convert = [\n"
|
|
' "polars>=1.43,<2",\n'
|
|
"]\n"
|
|
"other = [\n"
|
|
' "numpy>=1.26,<3",\n'
|
|
"]\n"
|
|
)
|
|
finding = cdb.Finding(name="polars", old_specifier=">=1.43,<2", new_specifier=">=1.43,<3", latest="2.0.0")
|
|
|
|
new_text = cdb.apply_findings(text, [finding])
|
|
|
|
assert new_text.count('"polars>=1.43,<3"') == 2
|
|
assert '"polars>=1.43,<2"' not in new_text
|
|
# Untouched dependency (numpy) survives byte-for-byte.
|
|
assert '"numpy>=1.26,<3"' in new_text
|
|
|
|
|
|
def test_apply_findings_on_real_pyproject_is_a_noop_when_no_findings(cdb):
|
|
text = (_ROOT / "pyproject.toml").read_text()
|
|
assert cdb.apply_findings(text, []) == text
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("workflow_file", "script_file"),
|
|
[
|
|
("deps-lock.yml", "deps-lock-pr.sh"),
|
|
("deps-bounds.yml", "deps-bounds-pr.sh"),
|
|
],
|
|
)
|
|
def test_workflow_yaml_parses_and_calls_matching_script(workflow_file, script_file):
|
|
workflow = yaml.safe_load((_WORKFLOWS / workflow_file).read_text())
|
|
assert "schedule" in workflow["on"]
|
|
assert "workflow_dispatch" in workflow["on"]
|
|
|
|
workflow_text = (_WORKFLOWS / workflow_file).read_text()
|
|
assert script_file in workflow_text, f"{workflow_file} must invoke .gitea/scripts/{script_file}"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("script_file", "branch"),
|
|
[
|
|
("deps-lock-pr.sh", "chore/uv-lock-upgrade"),
|
|
("deps-bounds-pr.sh", "chore/dep-bounds"),
|
|
],
|
|
)
|
|
def test_driver_script_branch_matches_deps_pr_invocations(script_file, branch):
|
|
"""Each driver script must define BRANCH as the expected literal, then
|
|
route every git/deps-pr.sh call through that one $BRANCH variable — a
|
|
hardcoded mismatch would silently leave a stray branch/PR or open a
|
|
second one each run."""
|
|
text = (_SCRIPTS / script_file).read_text()
|
|
assert f'BRANCH="{branch}"' in text, f"{script_file} should set BRANCH={branch!r}"
|
|
assert 'git checkout -B "$BRANCH"' in text
|
|
assert re.search(r'deps-pr\.sh open "\$BRANCH"', text)
|
|
assert re.search(r'deps-pr\.sh close "\$BRANCH"', text)
|