"""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)