Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3864e5249b | ||
|
|
d1fb54fd09 | ||
|
|
acd2350f51 | ||
|
|
e24907862f |
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
[tool.bumpversion]
|
[tool.bumpversion]
|
||||||
current_version = "0.3.22"
|
current_version = "0.3.23"
|
||||||
parse = "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)"
|
parse = "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)"
|
||||||
serialize = ["{major}.{minor}.{patch}"]
|
serialize = ["{major}.{minor}.{patch}"]
|
||||||
search = "{current_version}"
|
search = "{current_version}"
|
||||||
|
|||||||
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 }}
|
||||||
@@ -1,5 +1,13 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [0.3.23] - 2026-09-07
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Fix(deps): silence polars explode() empty_as_null deprecation warnings
|
||||||
|
|
||||||
|
- Feat(ci): add scheduled dependency-bump workflows (Renovate-lite)
|
||||||
|
|
||||||
## [0.3.22] - 2026-09-07
|
## [0.3.22] - 2026-09-07
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
@@ -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.
|
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
|
## Compute environment
|
||||||
|
|
||||||
Work on this repo happens across three kinds of machine:
|
Work on this repo happens across three kinds of machine:
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ calorimeter showers.
|
|||||||
|
|
||||||
[](pyproject.toml)
|
[](pyproject.toml)
|
||||||
[](pyproject.toml)
|
[](pyproject.toml)
|
||||||
[](CHANGELOG.md)
|
[](CHANGELOG.md)
|
||||||
[](tests/)
|
[](tests/)
|
||||||
[](https://git.larsbogner.de/lars/giant/actions)
|
[](https://git.larsbogner.de/lars/giant/actions)
|
||||||
[](#license)
|
[](#license)
|
||||||
|
|
||||||
|
|||||||
@@ -275,7 +275,7 @@ def prediction_secondaries(lf: pl.LazyFrame, prefix: str) -> pl.LazyFrame:
|
|||||||
lists = [f"{col_prefix}sec_{c}_list" for c in ("E", "pdg", "dx", "dy", "dz")]
|
lists = [f"{col_prefix}sec_{c}_list" for c in ("E", "pdg", "dx", "dy", "dz")]
|
||||||
return (
|
return (
|
||||||
lf.select("event_id", *lists)
|
lf.select("event_id", *lists)
|
||||||
.explode(lists)
|
.explode(lists, empty_as_null=False)
|
||||||
.drop_nulls(lists[0])
|
.drop_nulls(lists[0])
|
||||||
.select(
|
.select(
|
||||||
"event_id",
|
"event_id",
|
||||||
@@ -307,7 +307,7 @@ def paired_secondaries(lf: pl.LazyFrame) -> pl.LazyFrame:
|
|||||||
.with_columns(pl.min_horizontal("_n_true", "_n_pred").alias("_n_paired"))
|
.with_columns(pl.min_horizontal("_n_true", "_n_pred").alias("_n_paired"))
|
||||||
.filter(pl.col("_n_paired") > 0)
|
.filter(pl.col("_n_paired") > 0)
|
||||||
.with_columns(pl.int_ranges(0, pl.col("_n_paired")).alias("_rank"))
|
.with_columns(pl.int_ranges(0, pl.col("_n_paired")).alias("_rank"))
|
||||||
.explode("_rank")
|
.explode("_rank", empty_as_null=False)
|
||||||
.select(
|
.select(
|
||||||
pl.col("true_sec_pdg_list").list.get(pl.col("_rank")).cast(pl.Int64).alias("true_pdg"),
|
pl.col("true_sec_pdg_list").list.get(pl.col("_rank")).cast(pl.Int64).alias("true_pdg"),
|
||||||
pl.col("sec_pdg_list").list.get(pl.col("_rank")).cast(pl.Int64).alias("pred_pdg"),
|
pl.col("sec_pdg_list").list.get(pl.col("_rank")).cast(pl.Int64).alias("pred_pdg"),
|
||||||
|
|||||||
@@ -230,7 +230,7 @@ def secondaries(lf: pl.LazyFrame, side: Side) -> pl.LazyFrame:
|
|||||||
lists = ["sec_E_list", "sec_pdg_list", "sec_dx_list", "sec_dy_list", "sec_dz_list"]
|
lists = ["sec_E_list", "sec_pdg_list", "sec_dx_list", "sec_dy_list", "sec_dz_list"]
|
||||||
return (
|
return (
|
||||||
lf.select("event_id", *lists)
|
lf.select("event_id", *lists)
|
||||||
.explode(lists)
|
.explode(lists, empty_as_null=False)
|
||||||
.drop_nulls("sec_E_list")
|
.drop_nulls("sec_E_list")
|
||||||
.select(
|
.select(
|
||||||
"event_id",
|
"event_id",
|
||||||
@@ -268,7 +268,7 @@ def secondaries_by_step(lf: pl.LazyFrame, side: Side) -> pl.LazyFrame:
|
|||||||
return (
|
return (
|
||||||
lf.select("sec_pdg_list")
|
lf.select("sec_pdg_list")
|
||||||
.with_row_index("_row")
|
.with_row_index("_row")
|
||||||
.explode("sec_pdg_list")
|
.explode("sec_pdg_list", empty_as_null=False)
|
||||||
.drop_nulls("sec_pdg_list")
|
.drop_nulls("sec_pdg_list")
|
||||||
.select(pl.struct("_row").alias("step_key"), pl.col("sec_pdg_list").cast(pl.Int64).alias("pdg"))
|
.select(pl.struct("_row").alias("step_key"), pl.col("sec_pdg_list").cast(pl.Int64).alias("pdg"))
|
||||||
)
|
)
|
||||||
|
|||||||
+5
-1
@@ -76,7 +76,11 @@ def _pooled_pdg_lazy(path: Path, has_sec_pdg_list: bool) -> pl.LazyFrame:
|
|||||||
lf = pl.scan_parquet(path, row_index_name="__row")
|
lf = pl.scan_parquet(path, row_index_name="__row")
|
||||||
parts = [lf.select(pl.col("pdg").alias("__val"), "__row")]
|
parts = [lf.select(pl.col("pdg").alias("__val"), "__row")]
|
||||||
if has_sec_pdg_list:
|
if has_sec_pdg_list:
|
||||||
parts.append(lf.select(pl.col("sec_pdg_list").alias("__val"), "__row").explode("__val").drop_nulls("__val"))
|
parts.append(
|
||||||
|
lf.select(pl.col("sec_pdg_list").alias("__val"), "__row")
|
||||||
|
.explode("__val", empty_as_null=False)
|
||||||
|
.drop_nulls("__val")
|
||||||
|
)
|
||||||
combined = pl.concat(parts)
|
combined = pl.concat(parts)
|
||||||
return combined.group_by("__val").agg(pl.len().alias("__count"), pl.col("__row").min().alias("__first_row"))
|
return combined.group_by("__val").agg(pl.len().alias("__count"), pl.col("__row").min().alias("__first_row"))
|
||||||
|
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ def _add_secondary_attributes(df: pl.DataFrame) -> tuple[pl.DataFrame, int]:
|
|||||||
exploded = (
|
exploded = (
|
||||||
df.select(["event_id", "child_track_ids"])
|
df.select(["event_id", "child_track_ids"])
|
||||||
.with_row_index("_step_row")
|
.with_row_index("_step_row")
|
||||||
.explode("child_track_ids")
|
.explode("child_track_ids", empty_as_null=False)
|
||||||
.rename({"child_track_ids": "child_track_id"})
|
.rename({"child_track_ids": "child_track_id"})
|
||||||
.drop_nulls("child_track_id")
|
.drop_nulls("child_track_id")
|
||||||
)
|
)
|
||||||
|
|||||||
+4
-4
@@ -1,12 +1,12 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "giant"
|
name = "giant"
|
||||||
version = "0.3.22"
|
version = "0.3.23"
|
||||||
description = "Geant4 step-function surrogate via conditional flow matching"
|
description = "Geant4 step-function surrogate via conditional flow matching"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"numpy>=1.26,<3",
|
"numpy>=1.26,<3",
|
||||||
"polars>=1.0,<2",
|
"polars>=1.43,<2",
|
||||||
"pyarrow>=16,<26",
|
"pyarrow>=16,<26",
|
||||||
"tqdm>=4.60,<5",
|
"tqdm>=4.60,<5",
|
||||||
"typer>=0.12,<1",
|
"typer>=0.12,<1",
|
||||||
@@ -43,11 +43,11 @@ wandb = [
|
|||||||
convert = [
|
convert = [
|
||||||
"uproot>=5.3,<6",
|
"uproot>=5.3,<6",
|
||||||
"awkward>=2.6,<3",
|
"awkward>=2.6,<3",
|
||||||
"polars>=1.0,<2",
|
"polars>=1.43,<2",
|
||||||
]
|
]
|
||||||
analysis = [
|
analysis = [
|
||||||
"matplotlib>=3.8,<4",
|
"matplotlib>=3.8,<4",
|
||||||
"polars>=1.0,<2",
|
"polars>=1.43,<2",
|
||||||
"ipykernel>=7.3.0",
|
"ipykernel>=7.3.0",
|
||||||
# KIT matplotlib theme, published from git.larsbogner.de. Only the local
|
# KIT matplotlib theme, published from git.larsbogner.de. Only the local
|
||||||
# `giant analyze render` step imports it; compute workers never do.
|
# `giant analyze render` step imports it; compute workers never do.
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -825,7 +825,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "giant"
|
name = "giant"
|
||||||
version = "0.3.22"
|
version = "0.3.23"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "numpy" },
|
{ name = "numpy" },
|
||||||
@@ -892,9 +892,9 @@ requires-dist = [
|
|||||||
{ name = "pandas", marker = "extra == 'dev'", specifier = ">=2.2,<4" },
|
{ name = "pandas", marker = "extra == 'dev'", specifier = ">=2.2,<4" },
|
||||||
{ name = "particle", specifier = ">=1.0,<2" },
|
{ name = "particle", specifier = ">=1.0,<2" },
|
||||||
{ name = "plotstyle", marker = "extra == 'analysis'", specifier = ">=1.0.0", index = "https://git.larsbogner.de/api/packages/lars/pypi/simple/" },
|
{ name = "plotstyle", marker = "extra == 'analysis'", specifier = ">=1.0.0", index = "https://git.larsbogner.de/api/packages/lars/pypi/simple/" },
|
||||||
{ name = "polars", specifier = ">=1.0,<2" },
|
{ name = "polars", specifier = ">=1.43,<2" },
|
||||||
{ name = "polars", marker = "extra == 'analysis'", specifier = ">=1.0,<2" },
|
{ name = "polars", marker = "extra == 'analysis'", specifier = ">=1.43,<2" },
|
||||||
{ name = "polars", marker = "extra == 'convert'", specifier = ">=1.0,<2" },
|
{ name = "polars", marker = "extra == 'convert'", specifier = ">=1.43,<2" },
|
||||||
{ name = "pyarrow", specifier = ">=16,<26" },
|
{ name = "pyarrow", specifier = ">=16,<26" },
|
||||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8,<10" },
|
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8,<10" },
|
||||||
{ name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=5,<8" },
|
{ name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=5,<8" },
|
||||||
|
|||||||
Reference in New Issue
Block a user