4 Commits
Author SHA1 Message Date
gitea-actions 3864e5249b chore: release v0.3.23
CI / Tests (push) Successful in 4m24s
CI / Lint (ruff check) (push) Successful in 1m38s
CI / Format (ruff format) (push) Successful in 1m37s
CI / Type check (ty) (push) Successful in 1m37s
CI / Sync project version with tag (hand-pushed tags only) (push) Skipped
CI / Publish package to Gitea package registry (push) Skipped
CI / Release (bump, changelog, badges, tag) on merge to master (push) Successful in 25s
2026-09-07 15:11:10 +00:00
lars d1fb54fd09 Merge pull request 'feat(ci): add scheduled dependency-bump workflows (Renovate-lite)' (#99) from feature/scheduled-dependency-bump into master
CI / Sync project version with tag (hand-pushed tags only) (push) Skipped
CI / Publish package to Gitea package registry (push) Skipped
CI / Lint (ruff check) (push) Successful in 1m17s
CI / Type check (ty) (push) Successful in 1m16s
CI / Format (ruff format) (push) Successful in 1m21s
CI / Tests (push) Successful in 3m6s
CI / Release (bump, changelog, badges, tag) on merge to master (push) Successful in 1m24s
Reviewed-on: #99
2026-09-07 17:05:39 +02:00
larsandClaude Sonnet 5 acd2350f51 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
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
2026-09-07 16:59:41 +02:00
larsandClaude Sonnet 5 e24907862f fix(deps): silence polars explode() empty_as_null deprecation warnings
CI / Sync project version with tag (hand-pushed tags only) (push) Skipped
CI / Publish package to Gitea package registry (push) Skipped
CI / Lint (ruff check) (push) Successful in 1m7s
CI / Format (ruff format) (push) Successful in 1m8s
CI / Type check (ty) (push) Successful in 1m12s
CI / Tests (push) Successful in 2m41s
CI / Release (bump, changelog, badges, tag) on merge to master (push) Successful in 8s
pytest emitted 113 DeprecationWarnings, all from the same source: polars
2.0 changes explode()'s default handling of empty lists from "explode to
null" to "drop the row". Every explode() call site in this repo already
follows the explode with drop_nulls() (or otherwise excludes empty
lists), so the new behavior is what we always wanted — pass
empty_as_null=False explicitly rather than suppressing the warning.

Bump the polars floor from >=1.0 to >=1.43, since the empty_as_null
kwarg doesn't exist before ~1.35.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012dE2r7cNo9Lbthh1JUW1NF
2026-09-07 16:25:19 +02:00
17 changed files with 600 additions and 17 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
[tool.bumpversion]
current_version = "0.3.22"
current_version = "0.3.23"
parse = "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)"
serialize = ["{major}.{minor}.{patch}"]
search = "{current_version}"
+184
View File
@@ -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())
+44
View File
@@ -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"
+48
View File
@@ -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"
+84
View File
@@ -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
+29
View File
@@ -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 }}
+29
View File
@@ -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 }}
+8
View File
@@ -1,5 +1,13 @@
# 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
### Changed
+6
View File
@@ -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:
+2 -2
View File
@@ -10,8 +10,8 @@ calorimeter showers.
[![python](https://img.shields.io/badge/python-3.12%2B-3776AB?logo=python&logoColor=white)](pyproject.toml)
[![torch](https://img.shields.io/badge/torch-2.3.x-EE4C2C?logo=pytorch&logoColor=white)](pyproject.toml)
[![version](https://img.shields.io/badge/version-0.3.22-informational)](CHANGELOG.md)
[![tests](https://img.shields.io/badge/tests-1167%20passing-brightgreen)](tests/)
[![version](https://img.shields.io/badge/version-0.3.23-informational)](CHANGELOG.md)
[![tests](https://img.shields.io/badge/tests-1176%20passing-brightgreen)](tests/)
[![CI](https://git.larsbogner.de/lars/giant/actions/workflows/ci.yml/badge.svg?branch=master)](https://git.larsbogner.de/lars/giant/actions)
[![license](https://img.shields.io/badge/license-unlicensed-lightgrey)](#license)
+2 -2
View File
@@ -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")]
return (
lf.select("event_id", *lists)
.explode(lists)
.explode(lists, empty_as_null=False)
.drop_nulls(lists[0])
.select(
"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"))
.filter(pl.col("_n_paired") > 0)
.with_columns(pl.int_ranges(0, pl.col("_n_paired")).alias("_rank"))
.explode("_rank")
.explode("_rank", empty_as_null=False)
.select(
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"),
+2 -2
View File
@@ -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"]
return (
lf.select("event_id", *lists)
.explode(lists)
.explode(lists, empty_as_null=False)
.drop_nulls("sec_E_list")
.select(
"event_id",
@@ -268,7 +268,7 @@ def secondaries_by_step(lf: pl.LazyFrame, side: Side) -> pl.LazyFrame:
return (
lf.select("sec_pdg_list")
.with_row_index("_row")
.explode("sec_pdg_list")
.explode("sec_pdg_list", empty_as_null=False)
.drop_nulls("sec_pdg_list")
.select(pl.struct("_row").alias("step_key"), pl.col("sec_pdg_list").cast(pl.Int64).alias("pdg"))
)
+5 -1
View File
@@ -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")
parts = [lf.select(pl.col("pdg").alias("__val"), "__row")]
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)
return combined.group_by("__val").agg(pl.len().alias("__count"), pl.col("__row").min().alias("__first_row"))
+1 -1
View File
@@ -58,7 +58,7 @@ def _add_secondary_attributes(df: pl.DataFrame) -> tuple[pl.DataFrame, int]:
exploded = (
df.select(["event_id", "child_track_ids"])
.with_row_index("_step_row")
.explode("child_track_ids")
.explode("child_track_ids", empty_as_null=False)
.rename({"child_track_ids": "child_track_id"})
.drop_nulls("child_track_id")
)
+4 -4
View File
@@ -1,12 +1,12 @@
[project]
name = "giant"
version = "0.3.22"
version = "0.3.23"
description = "Geant4 step-function surrogate via conditional flow matching"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"numpy>=1.26,<3",
"polars>=1.0,<2",
"polars>=1.43,<2",
"pyarrow>=16,<26",
"tqdm>=4.60,<5",
"typer>=0.12,<1",
@@ -43,11 +43,11 @@ wandb = [
convert = [
"uproot>=5.3,<6",
"awkward>=2.6,<3",
"polars>=1.0,<2",
"polars>=1.43,<2",
]
analysis = [
"matplotlib>=3.8,<4",
"polars>=1.0,<2",
"polars>=1.43,<2",
"ipykernel>=7.3.0",
# KIT matplotlib theme, published from git.larsbogner.de. Only the local
# `giant analyze render` step imports it; compute workers never do.
+147
View File
@@ -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)
Generated
+4 -4
View File
@@ -825,7 +825,7 @@ wheels = [
[[package]]
name = "giant"
version = "0.3.22"
version = "0.3.23"
source = { editable = "." }
dependencies = [
{ name = "numpy" },
@@ -892,9 +892,9 @@ requires-dist = [
{ name = "pandas", marker = "extra == 'dev'", specifier = ">=2.2,<4" },
{ 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 = "polars", specifier = ">=1.0,<2" },
{ name = "polars", marker = "extra == 'analysis'", specifier = ">=1.0,<2" },
{ name = "polars", marker = "extra == 'convert'", specifier = ">=1.0,<2" },
{ name = "polars", specifier = ">=1.43,<2" },
{ name = "polars", marker = "extra == 'analysis'", specifier = ">=1.43,<2" },
{ name = "polars", marker = "extra == 'convert'", specifier = ">=1.43,<2" },
{ name = "pyarrow", specifier = ">=16,<26" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8,<10" },
{ name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=5,<8" },