Skip to content

Commit ee8b5f6

Browse files
soodokuclaude
andcommitted
Adopt the py-canon fleet standard
CI, docs and release all move onto py-canon's reusable workflows, and the build backend moves from hatchling to uv_build. The legacy CI had been red since numba 0.67 landed: numba now ships inline annotations in which `prange` has no `__iter__`, so mypy rejected all 27 `for i in prange(n)` loops. Canon retires mypy for pyright, which does not have that opinion, so the failure goes away rather than being suppressed. Notable pieces: - `test` group now carries pytest, pytest-cov and scipy alongside simcheck. The wheel job installs only that group, and test_newton.py compares the analytic Newton step against scipy's optimiser, so scipy is a test requirement rather than a dev convenience. - Ruff's canon ruleset over the whole tree, tests/ ms/ benchmarks/ included (they were excluded before). 385 findings to zero, with no E501 ignore: long lines are wrapped, and where a docstring summary could not be wrapped without tripping D205 it was reworded. N803/N806 are ignored per-directory because the package transcribes ms/paper.tex, where K is the kernel, S the smoother matrix and H the bandwidth matrix. - Docs build under `-W`: the theory notes are now reachable from a toctree, and the docstring examples import the names they call, so the doctest builder passes instead of failing on two NameErrors. - coverage-floor is 46 against a measured 47%. The number is low because coverage.py cannot see inside numba's JIT; with NUMBA_DISABLE_JIT=1 the two numba modules measure 96-97%. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 0379d2d commit ee8b5f6

30 files changed

Lines changed: 915 additions & 769 deletions

.copier-answers.yml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# Managed by copier — records template version for `copier update`. Do not edit.
2+
_commit: v1.1.1
3+
_src_path: gh:gojiplus/py-canon
4+
author_email: gsood07@gmail.com
5+
author_name: Gaurav Sood
6+
coverage_floor: 0
7+
default_branch: main
8+
description: Fast kernel bandwidth selection via analytic Hessian Newton optimization
9+
needs_cli: false
10+
org: finite-sample
11+
package_name: hbw
12+
project_name: hbw

.github/workflows/ci.yml

Lines changed: 21 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -4,35 +4,27 @@ on:
44
push:
55
branches: [main]
66
pull_request:
7+
schedule:
8+
- cron: "17 6 * * 1" # weekly: surface ecosystem drift on quiet repos
79

8-
jobs:
9-
test:
10-
runs-on: ubuntu-latest
11-
strategy:
12-
matrix:
13-
python-version: ["3.12", "3.13"]
14-
15-
steps:
16-
- uses: actions/checkout@v7
17-
18-
- uses: astral-sh/setup-uv@v7
19-
with:
20-
python-version: ${{ matrix.python-version }}
21-
22-
- name: Install dependencies
23-
run: uv sync --extra dev
10+
permissions:
11+
contents: read
2412

25-
- name: Lint with ruff
26-
run: uv run ruff check .
13+
concurrency:
14+
group: ${{ github.workflow }}-${{ github.ref }}
15+
cancel-in-progress: true
2716

28-
- name: Type check with mypy
29-
run: uv run mypy hbw/
30-
31-
- name: Check docstrings with pydoclint
32-
run: uv run pydoclint hbw/
33-
34-
- name: Check dead code with vulture
35-
run: uv run vulture hbw/
36-
37-
- name: Run tests
38-
run: uv run pytest
17+
jobs:
18+
ci:
19+
uses: gojiplus/py-canon/.github/workflows/reusable-ci.yml@v1
20+
with:
21+
# The canon default matrix starts at 3.11, which this package does not
22+
# support; numba is the ceiling constraint and 0.67 reaches 3.14.
23+
python-versions: '["3.12", "3.14"]'
24+
wheel-import: hbw
25+
# Measured 47%. The floor is low because coverage.py cannot see inside
26+
# numba's JIT-compiled kernels -- _numba_kde.py and _numba_nw.py report
27+
# 7-9% while tests/test_numba.py does exercise them. The pure-Python
28+
# layer that coverage can see sits at 94-100%.
29+
coverage-floor: 46
30+
run-preen: true
Lines changed: 5 additions & 257 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,7 @@
11
name: Dependabot auto-merge
22

3-
# Two entry points, one eligibility decision.
4-
#
5-
# pull_request decides eligibility once, records the verdict as a label, arms
6-
# schedule re-arms anything GitHub silently disarmed since
7-
#
8-
# The scheduled half exists because of a measured failure. On appeler/pranaam
9-
# PR #29 the workflow armed auto-merge at 17:27:11 and GitHub disabled it at
10-
# 17:42:38, four seconds after a PR-triggered docs workflow finished with a
11-
# skipped job. Nothing re-arms, `on: pull_request` never fires again, and the
12-
# PR sits green and unmerged while every workflow run reports success. Nine
13-
# PRs had accumulated that way. A sweep converges no matter what disarmed the
14-
# PR, which a `workflow_run` trigger racing the disarm event would not.
3+
# All the logic lives in py-canon, so a fix there reaches this repo on its
4+
# next run instead of waiting for someone to copy a file across.
155

166
on:
177
pull_request:
@@ -21,253 +11,11 @@ on:
2111
- cron: "37 */3 * * *"
2212
workflow_dispatch:
2313

24-
# Granted per job rather than here: a workflow-level write grant applies to
25-
# every job, which zizmor's excessive-permissions audit rejects once there is
26-
# more than one.
2714
permissions: {}
2815

29-
env:
30-
# Eligibility is derived from Dependabot metadata, which is only available in
31-
# a pull_request context. Rather than re-derive it from a branch name in the
32-
# scheduled run -- where major-versus-minor is not recoverable -- the verdict
33-
# is written once, here, and read back later. One decision, one place.
34-
ELIGIBLE_LABEL: automerge-eligible
35-
# How long an eligible PR may stay unmerged before the sweep says so out
36-
# loud. Silence is the failure mode this workflow exists to fix, so a sweep
37-
# that quietly does nothing must still leave a mark.
38-
STALE_AFTER_HOURS: 12
39-
# Neither job checks out the repo, so gh has no git remote to infer from.
40-
# Steps that pass a PR URL resolve the repo from the argument; `gh label
41-
# create` takes no URL, so it fell back to git and exited 1 -- on every
42-
# eligible PR the fleet ever saw. Set once here rather than per step: the
43-
# next gh call added to either job is then correct by default.
44-
GH_REPO: ${{ github.repository }}
45-
4616
jobs:
47-
classify:
48-
name: Classify and arm
49-
runs-on: ubuntu-latest
50-
timeout-minutes: 10
51-
permissions:
52-
contents: write # arm auto-merge
53-
pull-requests: write # label, comment
54-
if: >-
55-
github.event_name == 'pull_request' &&
56-
github.event.pull_request.user.login == 'dependabot[bot]'
57-
steps:
58-
- name: Fetch Dependabot metadata
59-
id: metadata
60-
uses: dependabot/fetch-metadata@25dd0e34f4fe68f24cc83900b1fe3fe149efef98 # v3.1.0
61-
with:
62-
github-token: ${{ secrets.GITHUB_TOKEN }}
63-
64-
# Auto-merge everything except Python-ecosystem majors:
65-
# - grouped PRs report the highest bump anywhere in the group (including
66-
# transitive lockfile updates), so trust our own minor-and-patch groups
67-
# - GitHub-Actions majors are CI-validated and low blast radius
68-
# An unrecognised or empty update-type is NOT eligible, so metadata
69-
# failures leave the PR open rather than merging it. Each branch is a
70-
# full if/case, never `test && var=true`: under `bash -e` a failing test
71-
# as the last statement of a step fails the whole step.
72-
- name: Decide eligibility
73-
id: gate
74-
env:
75-
ECOSYSTEM: ${{ steps.metadata.outputs.package-ecosystem }}
76-
GROUP: ${{ steps.metadata.outputs.dependency-group }}
77-
UPDATE_TYPE: ${{ steps.metadata.outputs.update-type }}
78-
run: |
79-
eligible=false
80-
if [ "$ECOSYSTEM" = "github_actions" ]; then
81-
eligible=true
82-
fi
83-
case "$GROUP" in
84-
*minor-and-patch*) eligible=true ;;
85-
esac
86-
case "$UPDATE_TYPE" in
87-
version-update:semver-minor|version-update:semver-patch) eligible=true ;;
88-
esac
89-
echo "ecosystem=$ECOSYSTEM group=$GROUP update-type=$UPDATE_TYPE eligible=$eligible"
90-
echo "eligible=$eligible" >> "$GITHUB_OUTPUT"
91-
92-
# The label is what makes the scheduled sweep possible without it having
93-
# to guess. Created with --force so the first run in a repo works.
94-
- name: Record the verdict on the PR
95-
if: steps.gate.outputs.eligible == 'true'
96-
env:
97-
PR_URL: ${{ github.event.pull_request.html_url }}
98-
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
99-
run: |
100-
gh label create "$ELIGIBLE_LABEL" --force --color 0e8a16 \
101-
--description "Auto-merge policy says yes; the sweep may arm or land this"
102-
gh pr edit "$PR_URL" --add-label "$ELIGIBLE_LABEL"
103-
104-
# No `gh pr review --approve`: it fails outright where the repo has
105-
# "Allow GitHub Actions to create and approve pull requests" off, which
106-
# aborted the step before the merge ever ran. Our rulesets require status
107-
# checks, not reviews, and GitHub's own documented example does not
108-
# approve either.
109-
- name: Enable auto-merge for eligible updates
110-
if: steps.gate.outputs.eligible == 'true'
111-
env:
112-
PR_URL: ${{ github.event.pull_request.html_url }}
113-
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
114-
run: |
115-
if err=$(gh pr merge --auto --squash "$PR_URL" 2>&1); then
116-
printf '%s\n' "$err"
117-
exit 0
118-
fi
119-
printf '%s\n' "$err"
120-
# GitHub refuses to arm auto-merge on a PR it currently considers
121-
# mergeable, and this job finishes in seconds -- often before the
122-
# required check runs exist. Do not merge directly here: "clean" at
123-
# t+4s does not mean the checks passed. The scheduled sweep picks it
124-
# up once the checks are terminal, which is the safe moment.
125-
case "$err" in
126-
*"clean status"*|*"not mergeable"*|*"Auto merge is not allowed"*)
127-
echo "::warning::auto-merge not armable yet; the sweep will retry"
128-
exit 0 ;;
129-
esac
130-
exit 1
131-
132-
- name: Flag ineligible updates for manual review
133-
if: steps.gate.outputs.eligible != 'true' && github.event.action == 'opened'
134-
env:
135-
PR_URL: ${{ github.event.pull_request.html_url }}
136-
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
137-
run: |
138-
gh pr comment "$PR_URL" --body \
139-
"Left open for manual review — auto-merge covers GitHub-Actions updates, our minor-and-patch groups, and patch/minor Python bumps."
140-
141-
sweep:
142-
name: Re-arm stranded PRs
143-
runs-on: ubuntu-latest
144-
timeout-minutes: 10
17+
auto-merge:
14518
permissions:
14619
contents: write # arm auto-merge, or squash-merge outright
147-
pull-requests: write # read labels, merge
148-
if: github.event_name != 'pull_request'
149-
steps:
150-
- name: Collect open Dependabot PRs
151-
env:
152-
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
153-
run: |
154-
gh pr list --author "app/dependabot" --state open --limit 100 \
155-
--json number,url,title,labels,autoMergeRequest,mergeStateStatus,statusCheckRollup,createdAt \
156-
> prs.json
157-
python3 -c "import json;print('collected',len(json.load(open('prs.json'))),'open Dependabot PRs')"
158-
159-
# Required contexts, so a check that never reported can be named. A
160-
# green rollup is not the same as a satisfied ruleset: on
161-
# appeler/pranaam#10 all seven reported checks passed while the
162-
# required `build` context never ran at all -- its workflow had been
163-
# cancelled by a concurrency collision -- and the PR sat BLOCKED for
164-
# weeks looking entirely green. Counting reported checks cannot see
165-
# that; comparing against the requirement can.
166-
gh api "repos/${GH_REPO}/rulesets" --jq '.[].id' 2>/dev/null \
167-
| while read -r id; do
168-
gh api "repos/${GH_REPO}/rulesets/${id}" --jq \
169-
'.rules[]? | select(.type=="required_status_checks")
170-
| .parameters.required_status_checks[].context' 2>/dev/null
171-
done | sort -u > required.txt || true
172-
echo "required contexts: $(tr '\n' ' ' < required.txt)"
173-
174-
# Decide per PR, print one line for every one of them, and act. Check
175-
# state is read from statusCheckRollup rather than from mergeStateStatus
176-
# alone: CLEAN is GitHub's opinion about mergeability, and this job needs
177-
# the stronger fact that every check has reached a terminal state and
178-
# none of them failed before it will merge anything directly.
179-
- name: Decide what to do with each
180-
id: plan
181-
run: |
182-
python3 - <<'PY' > actions.txt
183-
import json, os, datetime as dt
184-
185-
TERMINAL_OK = {"SUCCESS", "SKIPPED", "NEUTRAL"}
186-
label = os.environ["ELIGIBLE_LABEL"]
187-
stale_after = dt.timedelta(hours=float(os.environ["STALE_AFTER_HOURS"]))
188-
now = dt.datetime.now(dt.UTC)
189-
190-
def check_state(pr):
191-
"""Terminal-and-green, still-running, or failing."""
192-
rollup = pr.get("statusCheckRollup") or []
193-
if not rollup:
194-
return "no-checks"
195-
states = []
196-
for c in rollup:
197-
# CheckRun reports status/conclusion; StatusContext reports state.
198-
if c.get("__typename") == "StatusContext" or "state" in c:
199-
states.append(c.get("state") or "PENDING")
200-
elif (c.get("status") or "").upper() != "COMPLETED":
201-
states.append("PENDING")
202-
else:
203-
states.append((c.get("conclusion") or "PENDING").upper())
204-
if any(s == "PENDING" for s in states):
205-
return "running"
206-
return "green" if all(s in TERMINAL_OK for s in states) else "failing"
207-
208-
def never_reported(pr):
209-
"""Required contexts with no check run at all on this PR."""
210-
seen = {c.get("name") or c.get("context") for c in
211-
(pr.get("statusCheckRollup") or [])}
212-
return sorted(required - seen)
213-
214-
try:
215-
required = {ln.strip() for ln in open("required.txt") if ln.strip()}
216-
except OSError:
217-
required = set()
218-
219-
for pr in json.load(open("prs.json")):
220-
n = pr["number"]
221-
names = {l["name"] for l in pr.get("labels") or []}
222-
age = now - dt.datetime.fromisoformat(pr["createdAt"].replace("Z", "+00:00"))
223-
if label not in names:
224-
verdict, act = "ineligible (no policy label)", "none"
225-
elif pr.get("autoMergeRequest"):
226-
verdict, act = "already armed", "none"
227-
elif pr["mergeStateStatus"] in {"DIRTY", "BLOCKED", "DRAFT"}:
228-
# Name the missing requirement rather than only its symptom:
229-
# "BLOCKED" sends a reader looking for a failing check that
230-
# does not exist.
231-
absent = never_reported(pr)
232-
reason = (f"required never ran: {','.join(absent)}" if absent
233-
else pr["mergeStateStatus"])
234-
verdict, act = f"not mergeable ({reason})", "none"
235-
else:
236-
state = check_state(pr)
237-
verdict, act = {
238-
"green": ("checks terminal and green", "merge"),
239-
"running": ("checks still running", "arm"),
240-
"failing": ("checks failing", "none"),
241-
"no-checks": ("no checks reported", "none"),
242-
}[state]
243-
# Only shout when the sweep is unable to act. A PR being merged
244-
# on this very run is not stranded, however old it is.
245-
stale = act == "none" and label in names and age > stale_after
246-
print(f"{n}\t{act}\t{verdict}\t{int(age.total_seconds()//3600)}h\t{int(stale)}")
247-
PY
248-
printf '%-6s %-6s %-32s %6s\n' "PR" "ACTION" "VERDICT" "AGE"
249-
while IFS=$'\t' read -r n act verdict age stale; do
250-
printf '%-6s %-6s %-32s %6s\n' "#$n" "$act" "$verdict" "$age"
251-
if [ "$stale" = "1" ]; then
252-
echo "::warning::PR #$n is eligible but still unmerged after ${age} — automation has not been able to land it"
253-
fi
254-
done < actions.txt
255-
256-
- name: Arm or land
257-
env:
258-
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
259-
run: |
260-
acted=0
261-
while IFS=$'\t' read -r n act _rest; do
262-
case "$act" in
263-
arm)
264-
echo "arming #$n"
265-
gh pr merge "$n" --auto --squash || echo "::warning::could not arm #$n"
266-
acted=$((acted + 1)) ;;
267-
merge)
268-
echo "merging #$n"
269-
gh pr merge "$n" --squash --delete-branch || echo "::warning::could not merge #$n"
270-
acted=$((acted + 1)) ;;
271-
esac
272-
done < actions.txt
273-
echo "sweep acted on $acted PR(s)"
20+
pull-requests: write # label, comment, merge
21+
uses: gojiplus/py-canon/.github/workflows/reusable-dependabot-auto-merge.yml@v1

0 commit comments

Comments
 (0)