feat(ci): add scheduled dependency-bump workflows (Renovate-lite)
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
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
This commit is contained in:
Executable
+184
@@ -0,0 +1,184 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check (and optionally raise) pyproject.toml's dependency upper bounds
|
||||
against the latest release on PyPI.
|
||||
|
||||
Used by the monthly deps-bounds workflow (see deps-bounds-pr.sh): a lockfile
|
||||
refresh (deps-lock.yml, weekly) only ever moves within the existing
|
||||
`>=..,<..` constraints, so a stale upper bound never gets touched by that
|
||||
job. This script closes that gap by proposing (or applying) a raised
|
||||
ceiling, always as its own reviewable PR — a deliberately excluded package
|
||||
(torch, pinned to a driver-compatible range; plotstyle, not on PyPI at all)
|
||||
never gets edited.
|
||||
|
||||
Standalone stdlib + `packaging` script (no project deps needed to run it):
|
||||
uv run --no-project --with packaging python check_dep_bounds.py --report -
|
||||
uv run --no-project --with packaging python check_dep_bounds.py --apply --report bounds.md
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import tomllib
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from packaging.requirements import Requirement
|
||||
from packaging.version import Version
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
PYPROJECT = ROOT / "pyproject.toml"
|
||||
|
||||
# Deliberately never touched by this script, with the reason surfaced in the
|
||||
# report footer.
|
||||
SKIP_REASONS = {
|
||||
"giant": "self-reference (extras-of-extras), not a real upper bound to check",
|
||||
"torch": "pinned <2.4 deliberately — newer torch needs newer NVIDIA drivers"
|
||||
" than the shared portal machines have (see CLAUDE.md)",
|
||||
"plotstyle": "served from the private `larsbogner` index, not PyPI",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Finding:
|
||||
name: str
|
||||
old_specifier: str
|
||||
new_specifier: str
|
||||
latest: str
|
||||
|
||||
|
||||
def iter_requirement_strings(pyproject: dict) -> list[str]:
|
||||
project = pyproject["project"]
|
||||
reqs = list(project.get("dependencies", []))
|
||||
for group_reqs in project.get("optional-dependencies", {}).values():
|
||||
reqs.extend(group_reqs)
|
||||
return reqs
|
||||
|
||||
|
||||
def canonical_requirements(pyproject: dict) -> dict[str, Requirement]:
|
||||
"""One Requirement per distinct package name (specifiers are expected to
|
||||
agree across groups — that's true today; a future mismatch would just
|
||||
mean the last-seen group's specifier gets checked, which is fine for a
|
||||
monthly advisory script)."""
|
||||
out: dict[str, Requirement] = {}
|
||||
for req_str in iter_requirement_strings(pyproject):
|
||||
req = Requirement(req_str)
|
||||
out[req.name] = req
|
||||
return out
|
||||
|
||||
|
||||
def fetch_latest_version(name: str) -> str | None:
|
||||
url = f"https://pypi.org/pypi/{name}/json"
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=10) as resp:
|
||||
data = json.load(resp)
|
||||
return data["info"]["version"]
|
||||
except Exception as exc: # noqa: BLE001 - network hiccup: degrade, don't fail the job
|
||||
print(f"warning: could not fetch latest version for {name}: {exc}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
|
||||
def next_ceiling(latest: Version) -> str:
|
||||
"""The upper-bound scheme already used by this repo's own pins:
|
||||
`<{major+1}` once a package is past 0.x (e.g. numpy>=1.26,<3), but
|
||||
`<0.{minor+1}` while still pre-1.0 (e.g. ruff>=0.15,<1 -> next would be
|
||||
<0.17 style if ruff were still 0.x; ty>=0.0.50,<0.1 is the same idea one
|
||||
level deeper). Only the major (or, pre-1.0, the minor) component of the
|
||||
latest release matters here — the point is "next breaking-change
|
||||
boundary", not "exactly latest.patch + epsilon".
|
||||
"""
|
||||
if latest.major == 0:
|
||||
return f"<0.{latest.minor + 1}"
|
||||
return f"<{latest.major + 1}"
|
||||
|
||||
|
||||
def find_findings(requirements: dict[str, Requirement], skip: dict[str, str]) -> list[Finding]:
|
||||
findings = []
|
||||
for name, req in sorted(requirements.items()):
|
||||
if name in skip:
|
||||
continue
|
||||
latest_str = fetch_latest_version(name)
|
||||
if latest_str is None:
|
||||
continue
|
||||
latest = Version(latest_str)
|
||||
if latest in req.specifier:
|
||||
continue
|
||||
new_upper = next_ceiling(latest)
|
||||
lower_clauses = [str(s) for s in req.specifier if s.operator != "<"]
|
||||
new_specifier = ",".join([*lower_clauses, new_upper])
|
||||
findings.append(
|
||||
Finding(name=name, old_specifier=str(req.specifier), new_specifier=new_specifier, latest=latest_str)
|
||||
)
|
||||
return findings
|
||||
|
||||
|
||||
def apply_findings(text: str, findings: list[Finding]) -> str:
|
||||
"""Textual, in-place replacement of each finding's specifier substring —
|
||||
deliberately not a TOML round-trip, so comments/formatting/key order in
|
||||
pyproject.toml survive untouched. Every occurrence is replaced (a name
|
||||
like polars appears in three separate dependency groups with identical
|
||||
specifiers, and all of them must move together)."""
|
||||
for finding in findings:
|
||||
# Matched by package name rather than the old specifier string
|
||||
# verbatim: packaging.requirements.Requirement's str(specifier)
|
||||
# doesn't preserve clause order (e.g. "numpy>=1.26,<3" round-trips
|
||||
# as "<3,>=1.26"), so an exact-string match on the old requirement
|
||||
# would rarely hit. subn with no count replaces every occurrence in
|
||||
# one pass, which is what a multi-group dependency (e.g. polars)
|
||||
# needs.
|
||||
new = f'"{finding.name}{finding.new_specifier}"'
|
||||
# Negative lookahead guards against matching a longer package name
|
||||
# sharing this one as a prefix (e.g. "numpy" must not match
|
||||
# "numpydoc>=...").
|
||||
pattern = re.compile(rf'"{re.escape(finding.name)}(?![\w.-])[^"]*"')
|
||||
text, n = pattern.subn(new, text)
|
||||
if n == 0:
|
||||
print(f"warning: could not locate {finding.name!r} requirement string to rewrite", file=sys.stderr)
|
||||
return text
|
||||
|
||||
|
||||
def render_report(findings: list[Finding], skip: dict[str, str]) -> str:
|
||||
lines = []
|
||||
if findings:
|
||||
lines.append("| package | old constraint | new constraint | latest on PyPI |")
|
||||
lines.append("| --- | --- | --- | --- |")
|
||||
for f in findings:
|
||||
lines.append(f"| {f.name} | `{f.old_specifier}` | `{f.new_specifier}` | {f.latest} |")
|
||||
else:
|
||||
lines.append("All checked dependency upper bounds already cover the latest PyPI release.")
|
||||
lines.append("")
|
||||
lines.append("Skipped (never auto-raised):")
|
||||
for name, reason in sorted(skip.items()):
|
||||
lines.append(f"- `{name}` — {reason}")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--apply", action="store_true", help="rewrite pyproject.toml in place")
|
||||
parser.add_argument("--report", default="-", help="write the markdown report here ('-' for stdout, default: -)")
|
||||
args = parser.parse_args()
|
||||
|
||||
pyproject_text = PYPROJECT.read_text()
|
||||
pyproject = tomllib.loads(pyproject_text)
|
||||
requirements = canonical_requirements(pyproject)
|
||||
findings = find_findings(requirements, SKIP_REASONS)
|
||||
|
||||
report = render_report(findings, SKIP_REASONS)
|
||||
if args.report == "-":
|
||||
print(report, end="")
|
||||
else:
|
||||
Path(args.report).write_text(report)
|
||||
|
||||
if args.apply and findings:
|
||||
new_text = apply_findings(pyproject_text, findings)
|
||||
PYPROJECT.write_text(new_text)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+44
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env bash
|
||||
# Monthly job: raise pyproject.toml upper bounds that have fallen behind the
|
||||
# latest PyPI release (see check_dep_bounds.py — torch/plotstyle/giant are
|
||||
# deliberately excluded there), re-lock, and open/update a PR with the
|
||||
# result. Separate from the weekly uv.lock-only refresh (deps-lock.yml)
|
||||
# because this one can legitimately break CI (a new major version), which
|
||||
# should never block the routine weekly lockfile bump.
|
||||
#
|
||||
# Preconditions: repo checked out on master with fetch-depth: 0, uv synced
|
||||
# (./.gitea/actions/setup), CI_TOKEN/GITHUB_* env set by Gitea Actions.
|
||||
set -euo pipefail
|
||||
|
||||
BRANCH="chore/dep-bounds"
|
||||
|
||||
git config user.name "gitea-actions"
|
||||
git config user.email "actions@git.larsbogner.de"
|
||||
git checkout -B "$BRANCH" origin/master
|
||||
|
||||
BODY_FILE=$(mktemp)
|
||||
uv run --no-project --with packaging python .gitea/scripts/check_dep_bounds.py \
|
||||
--apply --report "$BODY_FILE"
|
||||
|
||||
if git diff --quiet -- pyproject.toml; then
|
||||
echo "No upper bounds out of date; nothing to propose"
|
||||
.gitea/scripts/deps-pr.sh close "$BRANCH"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Re-lock so the PR carries a pyproject + uv.lock that agree.
|
||||
uv lock
|
||||
|
||||
N_RAISED=$(grep -c '^| ' "$BODY_FILE" || true)
|
||||
# Subtract the header + separator row from the markdown table, if present.
|
||||
if [ "$N_RAISED" -ge 2 ]; then
|
||||
N_RAISED=$((N_RAISED - 2))
|
||||
else
|
||||
N_RAISED=0
|
||||
fi
|
||||
|
||||
git add pyproject.toml uv.lock
|
||||
.gitea/scripts/deps-pr.sh open "$BRANCH" \
|
||||
"chore(deps): raise dependency upper bounds" \
|
||||
"chore(deps): raise dependency upper bounds ($N_RAISED packages)" \
|
||||
"$BODY_FILE"
|
||||
Executable
+48
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env bash
|
||||
# Weekly job: refresh uv.lock against the pinned constraints in
|
||||
# pyproject.toml (no constraint edits — see check_dep_bounds.py for the
|
||||
# separate monthly job that raises upper bounds) and open/update a PR with
|
||||
# the result. See deps-pr.sh for the commit/push/PR-upsert mechanics.
|
||||
#
|
||||
# Preconditions: repo checked out on master with fetch-depth: 0, uv synced
|
||||
# (./.gitea/actions/setup), CI_TOKEN/GITHUB_* env set by Gitea Actions.
|
||||
set -euo pipefail
|
||||
|
||||
BRANCH="chore/uv-lock-upgrade"
|
||||
|
||||
git config user.name "gitea-actions"
|
||||
git config user.email "actions@git.larsbogner.de"
|
||||
git checkout -B "$BRANCH" origin/master
|
||||
|
||||
UPDATES_FILE=$(mktemp)
|
||||
uv lock --upgrade 2>&1 | tee "$UPDATES_FILE"
|
||||
|
||||
if git diff --quiet -- uv.lock; then
|
||||
echo "uv.lock already up to date; nothing to propose"
|
||||
.gitea/scripts/deps-pr.sh close "$BRANCH"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
N_PACKAGES=$(grep -c '^Update ' "$UPDATES_FILE" || true)
|
||||
|
||||
BODY_FILE=$(mktemp)
|
||||
{
|
||||
if [ "$N_PACKAGES" -gt 0 ]; then
|
||||
echo "Weekly automated \`uv lock --upgrade\` — updates within the existing"
|
||||
echo "\`pyproject.toml\` constraints:"
|
||||
echo
|
||||
grep '^Update ' "$UPDATES_FILE" | sed 's/^/- /'
|
||||
else
|
||||
echo "Weekly automated \`uv lock --upgrade\` refreshed the lockfile (e.g. hash"
|
||||
echo "or metadata changes) without a visible version bump."
|
||||
fi
|
||||
echo
|
||||
echo "CI on this PR (lint/format/type-check/tests) is the gate; merge normally"
|
||||
echo "once green, which triggers the usual patch release."
|
||||
} > "$BODY_FILE"
|
||||
|
||||
git add uv.lock
|
||||
.gitea/scripts/deps-pr.sh open "$BRANCH" \
|
||||
"chore(deps): weekly uv.lock refresh" \
|
||||
"chore(deps): weekly uv.lock refresh ($N_PACKAGES packages)" \
|
||||
"$BODY_FILE"
|
||||
Executable
+84
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env bash
|
||||
# Shared commit/push/PR-upsert mechanics for the scheduled dependency-bump
|
||||
# workflows (deps-lock.yml, deps-bounds.yml). Both jobs stage some changes,
|
||||
# then delegate here to turn them into a standing pull request against
|
||||
# master — one stable branch per job, force-pushed every run, so the PR
|
||||
# stays a single commit and a single open proposal across weeks/months
|
||||
# instead of accumulating history or duplicate PRs.
|
||||
#
|
||||
# Usage:
|
||||
# deps-pr.sh open <branch> <commit-subject> <pr-title> <body-file>
|
||||
# Commit the currently staged changes, force-push <branch>, and
|
||||
# create-or-update an open PR from <branch> onto master.
|
||||
# deps-pr.sh close <branch>
|
||||
# Close any open PR from <branch> onto master (if one exists) and
|
||||
# delete the remote branch. Used when a run finds nothing to change.
|
||||
#
|
||||
# Preconditions: repo checked out with fetch-depth: 0, git user.name/email
|
||||
# already configured, GITHUB_SERVER_URL/GITHUB_REPOSITORY/CI_TOKEN set (all
|
||||
# provided by Gitea Actions), and — for "open" — the changes to publish are
|
||||
# already `git add`-ed.
|
||||
set -euo pipefail
|
||||
|
||||
MODE="${1:?usage: deps-pr.sh open|close ...}"
|
||||
BRANCH="${2:?branch name required}"
|
||||
|
||||
API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
|
||||
AUTH_HEADER="Authorization: token ${CI_TOKEN}"
|
||||
|
||||
# Look up the currently open PR (if any) from $BRANCH onto master.
|
||||
find_open_pr() {
|
||||
curl -sf -H "$AUTH_HEADER" "${API}/pulls?state=open&base=master" \
|
||||
| jq -r --arg ref "$BRANCH" '.[] | select(.head.ref == $ref) | .number' \
|
||||
| head -n1
|
||||
}
|
||||
|
||||
case "$MODE" in
|
||||
open)
|
||||
SUBJECT="${3:?commit subject required}"
|
||||
TITLE="${4:?PR title required}"
|
||||
BODY_FILE="${5:?PR body file required}"
|
||||
|
||||
git commit -F - <<EOF
|
||||
$SUBJECT
|
||||
|
||||
$(cat "$BODY_FILE")
|
||||
EOF
|
||||
git push --force origin "HEAD:refs/heads/$BRANCH"
|
||||
|
||||
BODY_JSON=$(jq -Rs '.' < "$BODY_FILE")
|
||||
PR_NUMBER=$(find_open_pr || true)
|
||||
if [ -n "$PR_NUMBER" ]; then
|
||||
echo "Updating existing PR #$PR_NUMBER from $BRANCH"
|
||||
curl -sf -X PATCH -H "$AUTH_HEADER" -H "Content-Type: application/json" \
|
||||
"${API}/pulls/${PR_NUMBER}" \
|
||||
-d "{\"title\": $(jq -Rs '.' <<<"$TITLE"), \"body\": ${BODY_JSON}}" \
|
||||
> /dev/null
|
||||
else
|
||||
echo "Opening new PR from $BRANCH"
|
||||
curl -sf -X POST -H "$AUTH_HEADER" -H "Content-Type: application/json" \
|
||||
"${API}/pulls" \
|
||||
-d "{\"head\": \"${BRANCH}\", \"base\": \"master\", \"title\": $(jq -Rs '.' <<<"$TITLE"), \"body\": ${BODY_JSON}}" \
|
||||
> /dev/null
|
||||
fi
|
||||
;;
|
||||
|
||||
close)
|
||||
PR_NUMBER=$(find_open_pr || true)
|
||||
if [ -n "$PR_NUMBER" ]; then
|
||||
echo "Closing PR #$PR_NUMBER from $BRANCH (nothing to update)"
|
||||
curl -sf -X PATCH -H "$AUTH_HEADER" -H "Content-Type: application/json" \
|
||||
"${API}/pulls/${PR_NUMBER}" -d '{"state": "closed"}' > /dev/null
|
||||
else
|
||||
echo "No open PR from $BRANCH to close"
|
||||
fi
|
||||
if git ls-remote --exit-code --heads origin "$BRANCH" > /dev/null 2>&1; then
|
||||
git push origin --delete "$BRANCH" || echo "Could not delete remote branch $BRANCH (already gone?)"
|
||||
fi
|
||||
;;
|
||||
|
||||
*)
|
||||
echo "Unknown mode: $MODE (expected 'open' or 'close')" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -0,0 +1,29 @@
|
||||
name: Monthly dependency upper-bound raise
|
||||
|
||||
"on":
|
||||
schedule:
|
||||
- cron: "0 5 1 * *"
|
||||
workflow_dispatch: {}
|
||||
|
||||
env:
|
||||
UV_CACHE_DIR: /uv-cache
|
||||
|
||||
jobs:
|
||||
bounds-upgrade:
|
||||
name: raise stale pyproject ceilings -> PR
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: docker.gitea.com/runner-images:ubuntu-latest
|
||||
volumes:
|
||||
- /srv/act-runner-cache/uv:/uv-cache
|
||||
steps:
|
||||
# CI_TOKEN needs write:repository (same scope the release job uses)
|
||||
# to push the refresh branch and open/update its PR.
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.CI_TOKEN }}
|
||||
fetch-depth: 0
|
||||
- uses: ./.gitea/actions/setup
|
||||
- run: .gitea/scripts/deps-bounds-pr.sh
|
||||
env:
|
||||
CI_TOKEN: ${{ secrets.CI_TOKEN }}
|
||||
@@ -0,0 +1,29 @@
|
||||
name: Weekly dependency lock refresh
|
||||
|
||||
"on":
|
||||
schedule:
|
||||
- cron: "0 4 * * 1"
|
||||
workflow_dispatch: {}
|
||||
|
||||
env:
|
||||
UV_CACHE_DIR: /uv-cache
|
||||
|
||||
jobs:
|
||||
lock-upgrade:
|
||||
name: uv lock --upgrade -> PR
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: docker.gitea.com/runner-images:ubuntu-latest
|
||||
volumes:
|
||||
- /srv/act-runner-cache/uv:/uv-cache
|
||||
steps:
|
||||
# CI_TOKEN needs write:repository (same scope the release job uses)
|
||||
# to push the refresh branch and open/update its PR.
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.CI_TOKEN }}
|
||||
fetch-depth: 0
|
||||
- uses: ./.gitea/actions/setup
|
||||
- run: .gitea/scripts/deps-lock-pr.sh
|
||||
env:
|
||||
CI_TOKEN: ${{ secrets.CI_TOKEN }}
|
||||
@@ -45,6 +45,12 @@ Part of the `dev` extra. Run these periodically (not just at commit time) to cat
|
||||
|
||||
Merges to `master` auto-bump the patch version, tag, and update `CHANGELOG.md` via the Gitea workflow in `.gitea/workflows/ci.yml` (bump-my-version + git-cliff). Don't hand-edit the version in `pyproject.toml` or write changelog entries by hand.
|
||||
|
||||
Two scheduled Gitea workflows keep dependencies current, each opening/updating one standing pull request (never editing `master` directly, never opening a Gitea issue) so the normal `pull_request` CI (lint/format/type-check/tests) gates every change before a human merges it:
|
||||
- **`deps-lock.yml`** (weekly, Mondays) — `uv lock --upgrade` within the existing `pyproject.toml` constraints, PR'd on branch `chore/uv-lock-upgrade` (`.gitea/scripts/deps-lock-pr.sh`).
|
||||
- **`deps-bounds.yml`** (monthly) — raises `pyproject.toml` upper bounds that have fallen behind the latest PyPI release, re-locks, and PRs on branch `chore/dep-bounds` (`.gitea/scripts/deps-bounds-pr.sh`, driving `.gitea/scripts/check_dep_bounds.py`). `torch` (pinned `<2.4` for portal-machine driver support), `plotstyle` (private index, not on PyPI), and the `giant[...]` self-references are never auto-raised — see `check_dep_bounds.py`'s `SKIP_REASONS`.
|
||||
|
||||
Both scripts share PR-upsert mechanics in `.gitea/scripts/deps-pr.sh`; both branches are force-pushed fresh from `master` each run rather than accumulated, so at most one open PR exists per job at a time. Both can be triggered by hand via `workflow_dispatch` in the Gitea Actions UI.
|
||||
|
||||
## Compute environment
|
||||
|
||||
Work on this repo happens across three kinds of machine:
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user