Add array-api-strict test backend and array-escape triage tooling#942
Conversation
Wire array-api-strict (on a non-default device) into the CCDPROC_ARRAY_LIBRARY test-backend mechanism as a CPU-only proxy for CuPy's implicit device-to-host conversion errors, with a py313-strict tox environment and CI job (astropy#937). Add triage tooling for array-library escapes (astropy#939): - an escape-site dedupe report (CCDPROC_TRIAGE_ESCAPES=1) that groups test failures by the innermost non-test ccdproc frame - backend_xfail/backend_skip markers applied per array backend - an escape logger (CCDPROC_LOG_ARRAY_ESCAPES=1) that warns whenever a non-numpy array-API array is passed to a numpy coercion function Mark tests of documented limitations (astroscrappy, scipy.ndimage, reproject, block_reduce/block_replicate) as backend_xfail for array-api-strict. Closes astropy#937 Closes astropy#939 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Qri8rDgkWjCT7Yb7QKJrhf
There was a problem hiding this comment.
Pull request overview
This PR adds an array-api-strict test backend (as a CPU-only proxy for CuPy-style device/host conversion failures) and introduces pytest tooling to triage “array-API escapes” and manage per-backend expected failures.
Changes:
- Add
array-api-strictas a selectable test array backend (including non-default device placement) plus a dedicated tox env / CI job. - Add triage tooling: escape-site traceback deduping, per-backend
backend_xfail/backend_skipmarkers, and optional logging of numpy coercion “escapes”. - Document the new backend and tooling in docs and release notes, and mark known limitations as backend-specific xfails in relevant tests.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
tox.ini |
Adds py313-strict factor wiring and dependency for array-api-strict. |
pyproject.toml |
Registers new pytest markers (backend_xfail, backend_skip). |
docs/array_api.rst |
Documents the array-api-strict backend and device behavior. |
CHANGES.rst |
Adds release notes entries for the new backend and triage tooling. |
ccdproc/tests/test_cosmicray.py |
Marks known numpy-only dependencies as backend-specific xfails for array-api-strict. |
ccdproc/tests/test_ccdproc.py |
Adds backend-specific xfails for documented limitations (block ops, reproject, scipy median filter). |
ccdproc/tests/pytest_fixtures.py |
Places fixture arrays on the configured test backend device. |
ccdproc/tests/_escape_triage.py |
New pytest hook module to dedupe failures by “escape site” and print a summary. |
ccdproc/conftest.py |
Implements array-api-strict backend selection, backend-specific markers, and optional escape logging. |
.github/workflows/ci_tests.yml |
Adds CI job to run tox py313-strict. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
The py313-strict job fails by design until the remaining array-API bugs are fixed; with the default fail-fast it would cancel the whole matrix. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Qri8rDgkWjCT7Yb7QKJrhf
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #942 +/- ##
=======================================
Coverage 96.56% 96.56%
=======================================
Files 8 8
Lines 1571 1571
=======================================
Hits 1517 1517
Misses 54 54 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
The py313-strict job is expected to fail until the remaining array-API bugs are fixed; continue-on-error keeps its failure from failing the workflow or cancelling the other matrix jobs, while restoring fail-fast for real failures elsewhere. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Qri8rDgkWjCT7Yb7QKJrhf
The py313-strict job is expected to fail until the remaining array-API bugs are fixed. In its own matrix its failure stays visible but cannot fail-fast-cancel the main test matrix. Reverts the earlier continue-on-error approach, which hid the failure entirely. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Qri8rDgkWjCT7Yb7QKJrhf
- Fix CCDPROC_ARRAY_DEVICE=default to select the library CPU device as documented; unset still selects device1 - Reword the device comment in conftest to match the actual behavior - Require array-api-strict>=2.0 in the strict tox factor - Shorten the changelog entries - Add docstrings to _is_foreign_array and the escape-triage pytest hooks - Deduplicate _env_truthy (now imported from _escape_triage) 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Qri8rDgkWjCT7Yb7QKJrhf
The py313-strict CI job existed to surface the escape-site report, but the strict tox factor never set CCDPROC_TRIAGE_ESCAPES, so the summary was not generated. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Qri8rDgkWjCT7Yb7QKJrhf
On CI the repository checkout is itself named ccdproc, so testing for /ccdproc/ anywhere in a frame's path classified every frame -- including tox's site-packages under the workspace -- as a ccdproc frame, and the triage report pointed at array_api_strict internals instead of the ccdproc call site. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Qri8rDgkWjCT7Yb7QKJrhf
ReviewOverview: This PR adds an I verified the happy paths locally: the default numpy backend still passes 331/331 (5 skipped), the strict backend applies the markers correctly, and the triage summary groups failures by sensible sites. However, the escape logger has three real bugs, one of which makes its main output wrong in every case. Bug 1 (high): the escape logger always reports its own wrapper as the escape site
(Reproduced by wrapping Since the call site is the entire point of the logger, this defeats the feature. Dropping two frames ( Bug 2 (medium): dask escapes are never detected, contradicting the stated purposeThe PR body and the comments in Suggested fix: detect via Bug 3 (medium): enabling both env vars corrupts the triage reportWhen These are exactly the failures the dedupe report is supposed to localize (the ones that would raise on CuPy), so grouping them under Bug 4 (medium, usability): logger output is invisible in the scenario it targets
Options: emit Minor notes
What checks out
Bugs 1–3 share one root cause (frame/namespace detection in the logger) and are small fixes; the triage report and markers — the parts the strict CI job actually exercises — are solid. 🤖 Generated with Claude Code |
|
Re-verified all four escape-logger bugs independently on
Two refinements to the proposed fixes:
One small extra found while reproducing Bug 1: The minor notes all look right too; the — Written by Claude at @mwcraig's direction. |
- Classify ccdproc/conftest.py as test infrastructure in the escape-site frame classifiers, so the escape logger's own monkeypatch wrapper is never reported as the escape site and no longer corrupts the triage summary when both flags are enabled. - Detect foreign arrays via array_api_compat.array_namespace() instead of the raw __array_namespace__ dunder, so dask arrays (which lack the dunder) are detected; return the namespace from the helper so the log message no longer re-invokes third-party code unguarded. - Convert the triage hook to a new-style pluggy wrapper. - Document the triage/escape-logger flags and backend markers in docs/array_api.rst, including the -o log_cli=true requirement for seeing escapes from passing tests, and correct the description of array-api-strict. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qri8rDgkWjCT7Yb7QKJrhf
|
All four escape-logger bugs are fixed in a9313a6 (pushed), along with the agreed minor notes. Each fix was verified end-to-end before committing:
Minor notes also addressed: the array-api-strict description in — Written by Claude at @mwcraig's direction. |
The jax CI job's failure cancels the rest of the test matrix via fail-fast. Mark the seven tests that currently fail there (jax 0.10 / astropy 8) as non-strict expected failures so the matrix completes: - Six combiner tests fail because astropy's nddata arithmetic passes a jax array as dtype=, which raises jax's implicit-array-to-dtype DeprecationWarning as an error under the suite's warning filters. - test_generator_ccds_without_unit does not raise the expected ValueError on the jax backend. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qri8rDgkWjCT7Yb7QKJrhf
The escapes factor sets CCDPROC_LOG_ARRAY_ESCAPES (warns on silent conversion of foreign arrays to numpy, even in passing tests) and CCDPROC_TRIAGE_ESCAPES (groups test failures by escape site), and turns on pytest live logging via PYTEST_ADDOPTS so the escape warnings are visible for passing tests. Enables environments like py312-test-dask-escapes and py312-test-jax-escapes for local triage. Not added to CI. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BMP3e8QVgzf932GvPP2mTJ
Extend the escape tooling so silent numpy coercions on non-numpy backends (dask/jax) can be gated in CI, not just eyeballed in a log. - The live escape logger now tallies every coercion and prints a deduplicated "escape log summary" (counts per file:line function) at the end of the session. The conftest wrapper passes the blamed FrameSummary to record_escape_log() so the summary and the ratchet share one source. - New baseline ratchet: ccdproc/tests/array_escape_baseline.txt lists the known library escape sites keyed on (file, function, coercion) -- no line numbers, to avoid churn. CCDPROC_ENFORCE_ESCAPE_BASELINE=1 fails the session (via pytest_sessionfinish) if a new library escape appears; CCDPROC_WRITE_ESCAPE_BASELINE=1 (re)seeds the file, preserving hand-written reason tags. Test-frame escapes are excluded via _is_library_site. - The 13 current dask sites are all third-party numpy-only boundaries (astropy CCDData/stats/nddata/modeling, scipy.ndimage), tagged BOUNDARY with reasons; no pure-ccdproc coercions remain, so the ratchet is a regression gate. - tox: new `enforce` factor (compose e.g. py312-alldeps-dask-enforce) turns on the logger and enforcement. pyproject ships the baseline in the wheel so tox's installed-package run finds it. CI: new must-pass job ubuntu-py312-dask-escape-baseline runs the gate. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GHqzFdpg3qqiBb29ETq35D
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JBbsEbiGm6MgTeRfCqdHi3
Job-level continue-on-error only greens the workflow run conclusion; the job's own check run still reports failure, so the PR rollup stayed red. Move continue-on-error to the test step (job and rollup go green), report the real outcome as a warning annotation and step-summary line, and upload it as an artifact that a new workflow_run workflow turns into a check run (neutral when failing, success when passing). The follow-up workflow runs from the default branch, so the extra check only appears once this merges. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G6gS7Qb6gnFCmo6n7BvkW8
mwcraig
left a comment
There was a problem hiding this comment.
Critical review
Summary
The mechanism design is solid in the places people usually get wrong — outcome vs conclusion with continue-on-error, cross-workflow artifact download (run-id + github-token), the workflows: ["CI"] name match, checks: write/actions: read permissions, __file__-anchored baseline path surviving tox changedir, line-number-free baseline keys, and a missing-baseline-file that fails loud rather than silent. Those are all correct.
The real problems are in the tooling's failure modes and the CI plumbing's edge cases: the baseline regeneration mode can silently destroy the baseline file, enforcement silently stops enforcing if one env var is missing, and the strict-status pipeline conflates infrastructure failure with "expected test failure" (or reports nothing at all). Plus a changelog pointing at PRs that don't exist, a stale jax xfail that never fires, and the ratchet workflow — the heart of the PR — is undocumented in docs/array_api.rst.
All reproductions below were run against the PR head (b564834) in a fresh py3.12 venv (jax 0.10.2, dask 2026.7.1).
Bugs (confirmed, with reproductions)
B1. CCDPROC_WRITE_ESCAPE_BASELINE=1 silently wipes or truncates the baseline — ccdproc/tests/_escape_triage.py:412-414, 170-206
_write_escape_baseline() rewrites the file from _observed_library_sites(), which reads _ESCAPE_LOG_COUNTS — populated only by the _log_array_escapes fixture in conftest.py, which returns immediately unless CCDPROC_LOG_ARRAY_ESCAPES is truthy (conftest.py:266). Write mode neither enables the logger nor checks that it ran. The file header says only "Regenerate with CCDPROC_WRITE_ESCAPE_BASELINE=1 (preserves tags)."
Two failure modes:
- Follow the header's instruction literally (no
CCDPROC_LOG_ARRAY_ESCAPES, or on the default numpy backend): observed set is empty → the entire baseline is replaced with the header plus# (no library escapes observed this run)(_escape_triage.py:192-194). - Even with everything set correctly, running a subset of tests drops every baseline entry for code the subset didn't exercise — "preserves tags" preserves tags only for sites still observed.
Verified live:
CCDPROC_WRITE_ESCAPE_BASELINE=1 python -m pytest ccdproc/tests/test_ccdproc.py::test_create_deviation -q
# → 7 passed in 0.95s; array_escape_baseline.txt reduced from 13 entries to
# "# (no library escapes observed this run)" (git diff: 1 insertion, 13 deletions)
Suggested fix: refuse to write when the logger is inactive (if not _LOG_ARRAY_ESCAPES: raise UsageError(...)) and warn (or refuse) when the new site set is smaller than the existing baseline / empty.
B2. Enforcement silently no-ops without CCDPROC_LOG_ARRAY_ESCAPES — _escape_triage.py:415-416, 371-377
CCDPROC_ENFORCE_ESCAPE_BASELINE and CCDPROC_LOG_ARRAY_ESCAPES are coupled only in the tox enforce factor, not in code. Set enforce without the logger and _observed_library_sites() is empty → _new_escapes() is empty → session passes, and the terminal summary prints the reassuring "No foreign-array escapes observed (numpy backend?); baseline not checked." A CI config that copies only the enforce var gets a permanently-green, non-enforcing gate.
Verified live (deleted the combiner.py average_combine baseline line to simulate a new escape):
CCDPROC_ENFORCE_ESCAPE_BASELINE=1 CCDPROC_ARRAY_LIBRARY=dask python -m pytest ccdproc/tests/test_combiner.py -q
# → 74 passed, exit 0: "No foreign-array escapes observed (numpy backend?); baseline not checked."
CCDPROC_LOG_ARRAY_ESCAPES=1 CCDPROC_ENFORCE_ESCAPE_BASELINE=1 CCDPROC_ARRAY_LIBRARY=dask python -m pytest ccdproc/tests/test_combiner.py -q
# → exit 1: "NEW escapes not in baseline (1) -- these fail the session: + combiner.py average_combine numpy.asarray"
Note the message even says "numpy backend?" while running on dask — the diagnostic itself misleads. Suggested fix: in pytest_sessionfinish (or at import), error out if ENFORCE_BASELINE and not _LOG_ARRAY_ESCAPES.
B3. Changelog cites nonexistent PRs — CHANGES.rst:8,12
The two new entries are tagged [#937] and [#939]; neither PR exists. Both should be [#942]. Also, the new section heading is bare Unreleased, where this repo's convention is x.y.z (unreleased).
B4. Hardcoded artifact name breaks the moment the expected-failures matrix grows — ci_tests.yml:172-176, strict_status.yml:32,41
ci-tests-expected-failures is deliberately a matrix, but the artifact name is the constant strict-job-outcome. upload-artifact v4+ (v6.0.0 here) errors when an artifact of the same name already exists in the run — it is no longer last-write-wins. Add a second include: row and its upload fails with "an artifact with this name already exists". strict_status.yml likewise downloads the one fixed name and reads a single file, so the whole reporting path is structurally single-entry. Inert today (one row), guaranteed breakage on the first extension.
Suggested fix: name: strict-job-outcome-${{ matrix.name }}, download by pattern in strict_status.yml, iterate; or document that the job must stay single-entry.
B5. Infra failures are reported as "expected test failure" — or produce no check at all — ci_tests.yml:158-176, strict_status.yml:36
Two related holes:
- Conflation:
steps.tests.outcome == 'failure'for any nonzero tox exit — env-creation failure,array-api-strict>=2.0failing to resolve, tox internal error — not just failing tests. All get labeled ":warning: tests failed (expected until the remaining array-API bugs are fixed)" and a grey neutral check. A genuinely broken strict environment is rebranded as routine. - Silent gap: "Record test outcome" and "Upload test outcome" have no
if: always(). If an earlier step fails ("Install base dependencies"), they're skipped → no artifact →strict_status.yml's download fails (tolerated) → "Create check run" is skipped → no strict check is posted for that commit at all.
Note the naive fix interacts badly: with bare if: always(), a skipped tests step writes skipped to the file, which strict_status.yml maps to passed === false → "failing (expected)". The pipeline needs a third state ("infra error", e.g. conclusion: failure on the check run), or at minimum a comment acknowledging the limitation.
B6. Check-run name encodes the outcome — strict_status.yml:47
py313-strict: passing vs py313-strict: failing (expected) are two different checks to GitHub. Consequences: it can never be a branch-protection required check; the check "identity" flips names across pushes as the outcome changes; anything keyed on a stable check name breaks. If informational-only is the intent, say so in a comment — better, use a stable name: 'py313-strict' and vary only conclusion and output.title.
B7. The jax xfail on test_generator_ccds_without_unit is stale — the test XPASSes — ccdproc/tests/test_image_collection.py:~386
The reason says "ccds() on the jax backend does not raise ValueError when the files have no unit" — but that ValueError comes from fits_ccddata_reader at image_collection.py:960, before the backend conversion block (:963-973), so the backend cannot affect whether it raises. Verified empirically:
CCDPROC_ARRAY_LIBRARY=jax python -m pytest ccdproc/tests/test_image_collection.py -q -rX
# → 76 passed, 1 xpassed (this test) — same result in isolation and full-module runs
The marker is wrong (or was recorded against an environment quirk that no longer exists) and, because backend_xfail maps to strict=False, it XPASSes silently forever — a live demonstration of D5 below. Remove the marker; if it originally failed for someone, the failure was a different exception (e.g. a warning promoted to error), not the stated mechanism. (The other backend_xfails — astroscrappy/scipy/reproject/block_reduce — all sit on genuine numpy-only boundaries; this is the one outlier.)
Bugs (plausible — mechanism verified, occurrence depends on conditions)
P1. Duplicate strict checks on same-repo PRs — ci_tests.yml:3-13, strict_status.yml
on: push (all branches) + pull_request both fire for a branch pushed to the main repo; the concurrency group keys on github.ref, which differs (refs/heads/… vs refs/pull/N/merge), so neither run cancels the other. Two completed "CI" runs → two workflow_run events → two checks.create calls on the same head_sha → two (possibly contradictory, since the merge-ref run can differ) strict checks on the PR. Fix: gate strict_status.yml on github.event.workflow_run.event == 'pull_request' (which also stops noise checks from schedule/push-to-main runs).
P2. Baseline keys use frame function names → fragile across Python versions and comprehensions — _escape_triage.py (record_escape_log keying on frame.name)
An escape inside a genexpr/lambda keys as <genexpr>/<lambda> on every version; list comprehensions key as <listcomp> on py<3.12 but as the enclosing function on 3.12+ (PEP 709). The enforce CI job is py312; the documented refresh is "locally", unpinned. A contributor regenerating on 3.11 produces keys that mismatch CI → spurious NEW-escape failure + spurious stale entry. Fix: document a Python floor for regeneration, or normalize <listcomp>-style names to the enclosing frame.
P3. Escape logging is not thread-safe; the reentrancy guard is process-global — conftest.py (_ReentrancyGuard, shared across all three wrappers)
Under dask's threaded scheduler, thread A inside a wrapped np.asarray sets the guard; a concurrent escape on thread B sees active and is skipped entirely; and the defaultdict[key] += 1 first-insert isn't atomic. Both drop real escapes (ratchet false-negatives), never false alarms. Fix: threading.local() guard + a lock around the tally.
Design limitations that should at least be documented
D1. The logger only sees Python-level np.asarray/np.asanyarray/np.ma.asanyarray module-attribute calls. C-level coercions inside scipy/astroscrappy/reproject (exactly the BOUNDARY deps the baseline enumerates) and any from numpy import asarray bound before the patch are invisible — the ratchet has structural false negatives. The module docstring ("catch silent conversion back to numpy") oversells it.
D2. xdist silently defeats the ratchet. _ESCAPE_LOG_COUNTS is a per-process global; with pytest -n, workers accumulate and the controller sees nothing → "baseline not checked", green. Not live today (no -n in tox.ini) — worth an assert or comment before someone adds it.
D3. Strict-job device mismatch noise. Fixture data is created on device1 (pytest_fixtures.py), but inline xp.ones(...)/xp.asarray(...) in test bodies land on the default device; array-api-strict raises on cross-device ops. Part of the 109 strict failures are harness artifacts, not ccdproc bugs — diluting the signal the job exists to produce. Worth a helper (or fixture-provided make_array) that pins the device.
D4. py313-strict installs only extras = test + array-api-strict — no alldeps (tox.ini), so numpy-boundary paths through scipy/reproject/astroscrappy aren't exercised there. Probably intentional; one comment line would prevent someone "fixing" it.
D5. strict=False backend_xfails never flag stale markers. Correct choice given xfail_strict = true globally, but a fixed backend bug becomes a silent XPASS forever (see B7). A note in the marker docs ("prune on XPASS") is enough.
Refactoring suggestions
R1. _escape_triage.py is three modules in a trenchcoat: (1) failure-triage grouping (pytest_runtest_makereport + _ESCAPE_SITES report), (2) live escape-log tally + reporting, (3) baseline load/write/enforce. Meanwhile the monkeypatching lives in conftest.py but its helpers (locate_escape_site, record_escape_log) live here, creating a conftest↔module ping-pong. Suggest _escape_log.py (patching + recording + summary) and _escape_ratchet.py (load/write/enforce/report); "triage" would then honestly name what's left.
R2. Hook registration via from ._escape_triage import pytest_sessionfinish # noqa (conftest.py:27-33) works and is commented, but pytest_plugins = ["ccdproc.tests._escape_triage"] is the idiomatic, less-fragile spelling (the noqa'd imports are load-bearing and an overzealous linter autofix could silently remove them).
R3. Implicit contract between the two workflows — the outcome file format (success/failure text) is shared between ci_tests.yml and strict_status.yml with no shared definition; a one-line schema comment in both places (or writing JSON with an explicit state: field, which also solves B5's third state) would harden it.
R4. tox factor coupling: enforce sets both CCDPROC_LOG_ARRAY_ESCAPES and CCDPROC_ENFORCE_ESCAPE_BASELINE — currently the only place the B2 invariant is maintained. After fixing B2 in code, keep the factor as the single source of the pairing. Also: nothing explains that the CI strict/enforce envs deliberately omit the test factor so they run from the source tree (not .tmp/{envname}); a future "normalization" to py312-test-… would silently change import paths.
Documentation
docs/array_api.rst documents the strict backend, CCDPROC_TRIAGE_ESCAPES, CCDPROC_LOG_ARRAY_ESCAPES, and the markers — but has zero mention of the ratchet (grep -iE 'baseline|enforce|ratchet' docs/array_api.rst → nothing). Missing, in rough priority order:
- Regenerating the baseline — that it requires all three of
CCDPROC_WRITE_ESCAPE_BASELINE=1+CCDPROC_LOG_ARRAY_ESCAPES=1+ a non-numpyCCDPROC_ARRAY_LIBRARY, over the full suite, in the source tree. Today that's split between a tox.ini comment and the baseline-file header, and the header's one-liner is the B1 footgun. - What to do when the ratchet fires on your PR (
py312-alldeps-dask-enforcegoes red): read the "NEW escapes" section of the log, either migrate the call site or (deliberately) add a baseline line. - How to read the strict CI signal: the
ubuntu-py313-strictcheck is always green (carve-out); the real outcome is the greypy313-strict: failing (expected)/ greenpy313-strict: passingcheck (only after this PR merges, sinceworkflow_runexecutes from the default branch), plus the run's step summary. Nothing user-facing explains the grey check. CCDPROC_ENFORCE_ESCAPE_BASELINEandCCDPROC_WRITE_ESCAPE_BASELINEaren't listed alongside the other env vars at all.
Verified non-issues (so nobody re-litigates them)
Backend-name spellings in all backend_xfail/backend_skip marks match _normalize_backend_name (no silent never-applies typos); markers registered for --strict-markers; no filterwarnings loosening; no weakened assertions anywhere in the test diff; action SHAs/versions all real and current; cross-workflow artifact download inputs correct; outcome (not conclusion) correctly read under continue-on-error; session.exitstatus = 1 in pytest_sessionfinish genuinely fails the run; baseline path is __file__-anchored (changedir-safe); missing baseline file fails loud; _env_truthy handles unset/"0"/"true".
Suggested fix priority
- B1 + B2 (one-line guards; they undermine the tool's core promise) and B3 (trivial).
- B5 + B6 (strict-status robustness: third state, stable check name,
if: always()with skipped-handling). - B4 + P1 (artifact-per-matrix-name; gate
workflow_runonpull_request). - B7 (delete the stale jax marker).
- Docs section (regeneration + ratchet-fired + grey-check).
- P2/P3/D1–D5/R1–R4 as follow-ups or comments.
🤖 Generated with Claude Code
Both CCDPROC_WRITE_ESCAPE_BASELINE and CCDPROC_ENFORCE_ESCAPE_BASELINE read the sites tallied by the CCDPROC_LOG_ARRAY_ESCAPES logger, but nothing coupled them in code: write mode with the logger off silently truncated the baseline to an empty stub, and enforce mode vacuously passed while printing a reassuring message. Fail the session with a UsageError before any test runs when either mode is set without the logger, refuse to rewrite the baseline when zero escapes were observed, and warn loudly (listing the entries) when a rewrite drops baseline entries, since a subset run drops sites its tests never exercised. Also remove the stale jax backend_xfail on test_generator_ccds_without_unit: the ValueError it references is raised by fits_ccddata_reader before the backend conversion block, so the backend cannot affect it, and the test xpasses on jax. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M5FNYqVHbKLbVSGJGftLrL
- Record the strict-job outcome as JSON with an explicit third state
("error") so an infrastructure failure (skipped/cancelled tests step,
broken env creation) is reported as a red failure check instead of
being conflated with "failing (expected)"; the record and upload
steps run under if: always() so an artifact exists even when an
earlier step fails.
- Name the outcome artifact per matrix entry and download by pattern:
upload-artifact v4+ errors on duplicate names, so the previous fixed
name would break the moment the expected-failures matrix grew.
- Use a stable check-run name (the matrix entry name) and carry the
outcome in the conclusion and output instead of encoding it in the
check name.
- Only report on workflow runs triggered by pull_request, so same-repo
PR branches (which trigger both push and pull_request CI runs) do not
get duplicate, possibly contradictory, checks on the same head SHA.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M5FNYqVHbKLbVSGJGftLrL
Add docs for the escape-baseline ratchet (what the baseline file is, how to regenerate it, what to do when the enforce CI job fires) and for reading the strict CI signal (the always-green carve-out job vs the separate check run posted by the strict-status workflow), and list the CCDPROC_ENFORCE_ESCAPE_BASELINE / CCDPROC_WRITE_ESCAPE_BASELINE env vars alongside the other developer tooling. Fix the changelog: both new entries belong to PR astropy#942 (not astropy#937/astropy#939), and the section heading follows the x.y.z (unreleased) convention. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M5FNYqVHbKLbVSGJGftLrL
Removing this marker as stale broke the ubuntu-py313-jax CI job: the test genuinely fails there (ccds() does not raise ValueError for unit-less files), and the previous CI run confirms the marker was xfailing in that environment all along. The staleness verification had been done on macOS, where the test passes on jax with identical astropy/jax/numpy versions, so the behavior is platform-dependent. Restore the marker with a reason that records what is actually known; the Linux-side root cause still needs investigation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M5FNYqVHbKLbVSGJGftLrL
Review follow-ups on PR astropy#942 (items D1, D2, D4, D5, R4): - tox.ini: explain why the strict env installs no optional deps, why the CI strict/enforce envs omit the "test" factor, and why the enforce factor sets both escape-tooling variables. - _escape_triage.py / conftest.py: note that only Python-level module-attribute calls to np.asarray/np.asanyarray/np.ma.asanyarray are visible to the escape logger; C-level coercions and pre-patch imports are structural false negatives. - _escape_triage.py: reject baseline write/enforce modes under pytest-xdist -- the per-process escape tally never reaches the controller, so the baseline would be truncated or enforcement would pass vacuously. - docs/array_api.rst: backend_xfail markers are non-strict, so fixed backend bugs become silent XPASSes; check for them with -rX and confirm against CI logs (not just locally) before pruning a marker. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M5FNYqVHbKLbVSGJGftLrL
- _write_escape_baseline: collapse the three per-column max() comprehensions into one transpose over the site tuples. - pytest_collection_modifyitems: add a docstring explaining how the backend_skip/backend_xfail markers are applied at collection time. - _make_escape_logging_wrapper: document the wrapper's log-and-tally behavior and why the reentrancy guard is needed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M5FNYqVHbKLbVSGJGftLrL
Trivial change to verify the post-merge strict-status check flow from PR #942 (a single ubuntu-py313-strict check run with a neutral conclusion is expected on this PR). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M5FNYqVHbKLbVSGJGftLrL
Closes #937
Closes #939
Adds the array-api-strict test backend and the array-escape triage tooling from the array-API review follow-ups. The triage results produced by this tooling are recorded in #941.
array-api-strict backend (#937)
CCDPROC_ARRAY_LIBRARY=array-api-strict(orarray_api_strict) selectsarray_api_strictas the testing array library, with fixture arrays placed on the non-defaultDevice("device1"). Conversion to numpy then raises — the same failure signal as CuPy's implicit device-to-host conversion, with no GPU required. The device can be overridden viaCCDPROC_ARRAY_DEVICE.py313-stricttox environment andubuntu-py313-strictCI job.docs/array_api.rst.Triage tooling (#939)
CCDPROC_TRIAGE_ESCAPES=1): groups test failures by the innermost non-test ccdproc frame and prints a ranked summary at session end (ccdproc/tests/_escape_triage.py).@pytest.mark.backend_xfail("cupy", "array-api-strict", reason=...)and@pytest.mark.backend_skip(...), applied viapytest_collection_modifyitems, so backend runs report only regressions rather than known gaps.CCDPROC_LOG_ARRAY_ESCAPES=1): monkeypatchesnp.asarray/np.asanyarray/np.ma.asanyarrayto warn (with the ccdproc call site) whenever they receive a non-numpy array-API array — catches backends like dask and JAX that convert silently instead of raising like CuPy.Tests of documented limitations (astroscrappy,
scipy.ndimage.median_filter, reproject,block_reduce/block_replicate) are markedbackend_xfailfor array-api-strict.Verification
ruff checkand pre-commit hooks pass.🤖 Generated with Claude Code
https://claude.ai/code/session_01Qri8rDgkWjCT7Yb7QKJrhf