Skip to content

Hoist None/tuple axis handling into _nanfuncs._setup - #1006

Merged
mwcraig merged 3 commits into
astropy:mainfrom
mwcraig:nanfuncs-axis-hoist
Aug 31, 2026
Merged

Hoist None/tuple axis handling into _nanfuncs._setup#1006
mwcraig merged 3 commits into
astropy:mainfrom
mwcraig:nanfuncs-axis-hoist

Conversation

@mwcraig

@mwcraig mwcraig commented Aug 31, 2026

Copy link
Copy Markdown
Member

Follow-up agreed in the #1000 review; closes #1004.

Moves the flatten-for-None and permute-and-merge steps from core._mad_fallback into the shared _nanfuncs._setup, which now accepts a single integer, None, or a tuple/list of axes and returns a restore callable mapping a full-shape array back to the caller's layout (identity for a single integer axis).

What this buys:

  • every reduction fallback in _nanfuncs (nansum, nanmean, nanstd, nanmedian, median, nanmad) gains tuple-axis and axis=None support for free;
  • _mad_fallback shrinks to the median tier plus a delegation to nanmad;
  • Combiner.sigma_clipping's array-API path lifts its single-integer-axis restriction and now accepts the same axis forms astropy.stats.sigma_clip does, with the clip mask handed back in the data's own shape via restore.

Behavior note: a bare-bool or non-integer axis now raises TypeError instead of NotImplementedError, matching the TypeError a bool entry in a tuple already raised.

Tests: the differential grids in test_nanfuncs.py / test_combiner.py gained None, tuple (including negative entries), and single-entry-tuple cases checked against numpy/astropy, a list-equals-tuple equivalence test, expanded bad-axis cases, and Combiner-level axis-form tests. Full suites pass locally: numpy 943, dask 676/676, jax 676/676, array-api-strict 664 passed + 12 pre-existing xfails on the three affected test files.

🤖 Generated with Claude Code

https://claude.ai/code/session_01LVxnTLrWKStBdPwcmDNhxA

Move the flatten-for-None and permute-and-merge steps from
core._mad_fallback into the shared _nanfuncs._setup, as agreed in the
review of astropy#1000. _setup now accepts a single integer, None, or a
tuple/list of axes, and returns a restore callable that maps a
full-shape array back to the caller's layout (the identity for a
single integer axis).

This gives every reduction fallback in _nanfuncs (nansum, nanmean,
nanstd, nanmedian, median, nanmad) tuple-axis and axis=None support
for free, shrinks _mad_fallback to the median tier plus a delegation,
and lifts the single-integer-axis restriction of
Combiner.sigma_clipping's array-API path, which now accepts the same
axis forms astropy.stats.sigma_clip does (the restore callable hands
the clip mask back in the data's own shape).

Bare-bool and non-integer axes now raise TypeError instead of
NotImplementedError, matching the TypeError a bool entry in a tuple
already raised.

Closes astropy#1004

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVxnTLrWKStBdPwcmDNhxA
@codecov

codecov Bot commented Aug 31, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 97.97%. Comparing base (fd5aab8) to head (9d18599).
⚠️ Report is 1 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1006      +/-   ##
==========================================
+ Coverage   97.96%   97.97%   +0.01%     
==========================================
  Files           9        9              
  Lines        1912     1927      +15     
==========================================
+ Hits         1873     1888      +15     
  Misses         39       39              
Flag Coverage Δ
dask 97.19% <100.00%> (+0.02%) ⬆️
jax 97.34% <100.00%> (+0.02%) ⬆️
numpy 97.87% <100.00%> (+0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Comment thread ccdproc/combiner.py Outdated
which :func:`~astropy.stats.sigma_clip` accepts, raise
`NotImplementedError` there. ``masked`` and ``return_bounds``
are never accepted, on any array namespace -- this method
honoured for every array namespace; like

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

American spelling, please

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed — and this diff has more of them than this line: honoured here, Normalise/normalised/axis normalisation in _nanfuncs.py (module comment and _setup docstring), and normalised/honour in test_combiner.py comments. Will switch all of them to American spelling on the branch (honored, normalize/normalized/normalization).

Written by Claude at @mwcraig's direction.

Comment thread ccdproc/tests/test_nanfuncs.py
Comment thread ccdproc/_nanfuncs.py Outdated
Validate ``axis``, resolve the namespace and device, promote to float.
Normalise ``axis``, resolve the namespace and device, promote to float.

``axis`` may be a single integer, ``None`` or a tuple/list of integers.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This goes in notes

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed — the paragraph explaining how the axis forms are handled (flatten for None, permute-and-merge for a tuple/list) is extended discussion, not signature summary, and numpydoc puts that in Notes. Will move it into a Notes section of _setup's docstring, keeping the one-line summary up top.

Written by Claude at @mwcraig's direction.

Comment thread ccdproc/_nanfuncs.py Outdated
Axis along which to sum. Default is 0. ``None`` and tuples of axes
are not supported.
axis : int, tuple of int, list of int or None, optional
Axis or axes along which to sum. Default is 0. ``None`` sums over

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this be pulled out some how since it is repeated so many times? Or maybe factor out most of the docstring with function-specific substitutions?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes — the x/axis/xp Parameters blocks of the six public functions are identical up to the verb ("to sum" / "over which to average" / ...) and the default, so they can be generated. Concrete shape (the scipy doccer pattern, host-side only):

_COMMON_PARAMS = """x : array
    Input array. Integer and boolean inputs are promoted to the
    namespace's default real floating dtype.
axis : int, tuple of int, list of int or None, optional
    Axis or axes along which {verb}. Default is 0. ``None`` {verb_none}
    over every axis; a tuple or list {verb_plural} over all the listed
    axes at once.
xp : array namespace, optional
    Namespace to use. Defaults to ``array_api_compat.array_namespace(x)``."""

def _fill_doc(**subs):
    def deco(func):
        if func.__doc__:  # python -OO strips docstrings
            func.__doc__ = func.__doc__.format(params=_COMMON_PARAMS.format(**subs))
        return func
    return deco

with each function keeping its own summary line and Returns block inline — those differ meaningfully (all-NaN slices sum to zero for nansum but give NaN for nanmean/nanstd, median propagates NaN, ...), and templating them would obscure exactly the part a reader needs.

Trade-off to sign off on: the raw source (and editors that read source rather than __doc__) shows {params} at the definition; Sphinx/numpydoc and help() render the filled version. If that's acceptable, happy to implement; if not, my honest alternative is leaving the six short blocks as-is — they're repetitive but each is self-contained.

Written by Claude at @mwcraig's direction.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Implement it

Comment thread ccdproc/_nanfuncs.py Outdated
return xp.reshape(x, (-1,)), 0, xp, device, restore

if isinstance(axis, tuple | list):
# normalize_axis_tuple would silently treat True as 1.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Explain in more detail what is going on in this block -- no comments in the code, just explain in the reply

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Step by step:

  1. The bool guard. normalize_axis_tuple converts entries with operator.index, and a Python True passes that (operator.index(True) == 1), so axis=(0, True) would silently reduce axes (0, 1). The guard turns that into a TypeError before the conversion runs.
  2. normalize_axis_tuple(axis, ndim) does the host-side validation and normalisation in one call: each entry through operator.index, negatives wrapped mod ndim (so -1 on a 3-D array becomes 2), AxisError on out-of-bounds, ValueError on duplicates — including duplicates via a negative alias, e.g. (0, -3) on 3-D. Out comes a tuple of unique non-negative ints.
  3. kept is the axes not being reduced, in their original order; order = kept + axes is then a permutation of range(ndim) that moves every reduced axis to the end. permute_dims(x, order) rearranges the array into that layout, and the reshape collapses the trailing reduced axes into one axis whose length is the product of their sizes. Every reduction in this module (sum, mean, std, median, mad) is insensitive to the order of elements within the reduced set, so how the merge interleaves them is irrelevant to the result.
  4. The caller then runs its ordinary single-axis reduction over axis len(kept) — the merged trailing axis — which reduces over all the requested axes at once.
  5. inverse is the permutation that undoes order (inverse[ax] says where original axis ax ended up), and restore maps a full-shape array in the permuted-merged layout back to the caller's layout: un-merge (reshape to permuted_shape), then un-permute (permute_dims with inverse). The reductions drop the axis and never use it; combiner._sigma_clip_mask computes its mask at full shape in the working layout and uses restore to hand it back in the shape and axis order of the input.

Written by Claude at @mwcraig's direction.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add brief comments along these lines to the code

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Hoists multi-axis handling into the shared reduction setup and extends array-API sigma clipping accordingly.

Changes:

  • Supports None, tuple, list, and negative axes in reduction fallbacks.
  • Restores sigma-clipping masks to the original layout.
  • Expands tests and documentation for new axis forms.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
CHANGES.rst Documents expanded axis support.
ccdproc/_nanfuncs.py Centralizes axis normalization and layout restoration.
ccdproc/combiner.py Enables multi-axis array-API sigma clipping.
ccdproc/core.py Delegates MAD axis handling to _nanfuncs.
ccdproc/tests/test_nanfuncs.py Tests supported and invalid axis forms.
ccdproc/tests/test_combiner.py Tests multi-axis clipping and mask restoration.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread ccdproc/_nanfuncs.py Outdated
kept = tuple(ax for ax in range(ndim) if ax not in axes)
order = kept + axes
permuted_shape = tuple(x.shape[ax] for ax in order)
x = xp.reshape(xp.permute_dims(x, order), permuted_shape[: len(kept)] + (-1,))

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed — this is the same defect as #1006 (comment), and your framing of the mechanism is right: with total size 0 the -1 is genuinely ambiguous to reshape (any merged length satisfies 0 × n = 0), even though the intended value — the product of the reduced axes' sizes — is well-defined. Reproduced on this branch: nansum(np.zeros((0, 3, 4)), axis=(1, 2))ValueError: cannot reshape array of size 0 into shape (0,newaxis) where np.nansum returns shape (0,). Will fix by reshaping with the explicit product instead of -1, and add an empty-array regression row.

Written by Claude at @mwcraig's direction.

@mwcraig mwcraig left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adversarial self-review (multi-agent, via Claude Code): every finding below was verified by running code against this branch — differential harnesses vs numpy/astropy on numpy + array-api-strict + dask, plus full runs of the changed test files (all pass). Two confirmed edge-case bugs and a handful of simplifications, all as inline comments; two findings fall outside the diff:

test_ccdproc.py:450-466test_mad_fallback_rejects_bad_axis_tuple now duplicates test_bad_axis. Post-hoist, both grids exercise the identical _setup lines, which test_nanfuncs.py::test_bad_axis already covers 5-fold. It could shrink to a single case proving _mad_fallback propagates _setup's error, with its match= strings moved into test_bad_axis (which currently asserts only exception types).

Unrelated hunks: core.py carries five string-concatenation cleanups (~lines 1014, 1728, 2062, 2750, 2757) with no connection to the axis work.

Probed and clean: restore round-trips exactly for arbitrary tuple orders on 4-D; all six reductions match numpy/astropy for None/tuple/negative/all-NaN cases; _sigma_clip_mask masks are element-identical to astropy's across axis forms; no in-place mutation, boolean indexing, or data-dependent shapes (backend-safe); duplicate/out-of-range axes raise correctly; no NotImplementedError catchers existed anywhere, and the TypeError change is asserted in test_bad_axis. Simplifications considered and rejected: inlining _mad_fallback (still owns the tiered med closure and is a test seam), routing single-int axes through the tuple branch (permute/copy on the hot combiner path), and passing tuple axes straight to xp.sum (forks _setup's contract while median/mad still need the merge).

Comment thread ccdproc/_nanfuncs.py Outdated

if isinstance(axis, tuple | list):
# normalize_axis_tuple would silently treat True as 1.
if any(isinstance(ax, bool) for ax in axis):

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug (found independently by two reviewers): np.bool_ slips through this guard. np.True_ is not a subclass of Python bool, so isinstance(ax, bool) is False for it and normalize_axis_tuple then silently treats it as axis 1 — nansum(x, axis=(0, np.True_)) reduces axes (0, 1), which is exactly the hazard the comment above says this rejects. numpy itself raises TypeError here. Same hole in the scalar guard at line 152 (operator.index(np.True_) returns 1, currently with a DeprecationWarning that numpy will turn into an error — and the test suite's filterwarnings = ["error", ...] means a test_bad_axis row for this fails on the warning, not the intended TypeError). The scalar hole is pre-existing; the tuple-entry one is new here. Fix in both places: isinstance(ax, bool | np.bool_) — numpy is already imported host-side. This also falsifies _setup's docstring claim that booleans are rejected while numpy scalars are accepted.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed, with one version nuance worth recording. On the oldest supported numpy (2.0.x — pyproject.toml floor is numpy>=2.0): normalize_axis_tuple((0, np.True_), 2) returns (0, 1) with only a DeprecationWarning — exactly the silent wrong-axes reduction described (and under the suite's warnings-as-errors config, the wrong failure mode for a test row). On current numpy (verified 2.5.2) that deprecation has expired: operator.index(np.True_) now raises TypeError, so both the tuple entry and the scalar already fail there, just with numpy's message instead of ours.

So the fix is still exactly right for the supported range: isinstance(ax, bool | np.bool_) in both guards gives uniform behaviour and our clearer message on every numpy from 2.0 up, and makes the docstring's "booleans are rejected" claim true again. Will apply, plus test_bad_axis rows for np.True_ as a scalar and as a tuple entry (safe on all supported numpys once the guard is explicit).

Written by Claude at @mwcraig's direction.

Comment thread ccdproc/_nanfuncs.py Outdated
kept = tuple(ax for ax in range(ndim) if ax not in axes)
order = kept + axes
permuted_shape = tuple(x.shape[ax] for ax in order)
x = xp.reshape(xp.permute_dims(x, order), permuted_shape[: len(kept)] + (-1,))

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: the -1 merge reshape crashes when a kept dimension has size 0, where numpy returns an empty result. nansum(np.zeros((0, 3, 4)), axis=(1, 2))ValueError: cannot reshape array of size 0 into shape (0,newaxis) on every backend, while np.nansum gives shape (0,). Inherited from the old _mad_fallback merge, but the hoist spreads it to all six reductions plus sigma_clipping/mad_std. One-line fix: reshape with the explicit product instead of -1, e.g. permuted_shape[: len(kept)] + (math.prod(permuted_shape[len(kept):]),). Rare input in practice, but it's a genuine numpy divergence in the newly advertised feature.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed — reproduced verbatim on this branch: nansum(np.zeros((0, 3, 4)), axis=(1, 2))ValueError: cannot reshape array of size 0 into shape (0,newaxis) on numpy, where np.nansum returns shape (0,). (Copilot found the same bug independently in its comment above.) Will fix with the explicit product as suggested — math.prod needs a new import math, everything else is one line — and add the empty-array regression row. Bonus: the explicit product also makes axis=() on an empty array work (prod(()) == 1), where -1 would hit the same ambiguity.

Written by Claude at @mwcraig's direction.

Comment thread ccdproc/_nanfuncs.py Outdated
Comment on lines +123 to +129
if axis is None:
shape = x.shape

def restore(a):
return xp.reshape(a, shape)

return xp.reshape(x, (-1,)), 0, xp, device, restore

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Simplification: this whole branch folds into the tuple branch. Replacing it with

if axis is None:
    axis = tuple(range(ndim))

produces identical results: kept=() makes the permutation the identity, the merge reshape becomes reshape(x, (-1,)), the returned axis is len(kept) == 0, and restore is permute_dims(reshape(a, shape), identity). Verified with a differential harness (all six public functions + _sigma_clip_mask, numpy and array-api-strict, 11 axis forms including 0-d input): every value, shape, mask, and error identical. Only cost is a no-op identity permute_dims on this path (a view on numpy). Removes one of _setup's three restore definitions and an early return.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed — the mechanism checks out from the code: axis=None → tuple(range(ndim)) gives kept=(), so the permutation is the identity, the merge reshape degenerates to reshape(x, (-1,)) (or the full-size product once the explicit-product fix from the other thread lands — the two compose cleanly), the returned axis is len(kept) == 0, and restore is the same reshape the dedicated branch builds, behind a no-op permute_dims. Your differential harness covering the 0-d case settles the rest. Will apply, dropping the dedicated None branch and its restore.

Written by Claude at @mwcraig's direction.

Comment thread ccdproc/_nanfuncs.py Outdated
Comment on lines +162 to +165
if not -ndim <= axis < ndim:
raise ValueError(f"axis {axis} is out of bounds for array of dimension {ndim}")
axis = axis % ndim

device = array_api_compat.device(x)
x = _promote_to_real(x, xp, device)

return x, axis, xp, device
return x, axis % ndim, xp, device, lambda a: a

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Simplification: this hand-rolled bounds check is normalize_axis_tuple(axis, ndim)[0]. The AxisError it raises carries a character-for-character identical message and subclasses ValueError, so test_bad_axis still passes unchanged. It also removes an existing asymmetry: the tuple branch already raises AxisError for out-of-bounds entries while this branch raises plain ValueError. (Keep the bool guard and the operator.index try/except above — their message is better.)

return x, normalize_axis_tuple(axis, ndim)[0], xp, device, lambda a: a

Together with the axis is None fold, _setup's body goes from ~43 to ~34 lines with proven-identical behavior.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified both claims: normalize_axis_tuple(2, 2) raises AxisError('axis 2 is out of bounds for array of dimension 2') — character-identical to the hand-rolled message — and AxisError subclasses ValueError, so test_bad_axis passes unchanged. The asymmetry point is real too: the tuple branch already surfaces AxisError for out-of-bounds entries while this branch raises bare ValueError, and this change removes that. Will apply, keeping the bool guard and the operator.index try/except above for their better TypeError message, together with the axis is None fold.

Written by Claude at @mwcraig's direction.

Comment thread ccdproc/tests/test_nanfuncs.py
Comment thread ccdproc/tests/test_combiner.py Outdated
Comment on lines +1916 to +1918
ref_axis = axis
if isinstance(axis, tuple):
ref_axis = tuple(ax % np_data.ndim for ax in axis)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test simplification: this normalization is duplicated. _sigma_clip_reference already computes the same ax % ndim normalization for its bounds shape (line ~1761). Normalizing the tuple once inside _sigma_clip_reference before calling sigma_clip (astropy's bottleneck path can't take negative tuple entries anyway) lets this ref_axis block and its comment disappear, and the helper under test still receives the tuple exactly as written.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed — _sigma_clip_reference already computes the same ax % ndim normalisation for its bounds shape, so normalising the tuple once at the top of the helper (before its sigma_clip call, whose bottleneck dispatch can't take negative tuple entries) serves both uses, and this ref_axis block and its comment disappear while _sigma_clip_mask still receives the axis exactly as the parametrize row writes it. Will apply.

Written by Claude at @mwcraig's direction.

Comment thread ccdproc/combiner.py Outdated
are never accepted, on any array namespace -- this method
honoured for every array namespace; like
:func:`~astropy.stats.sigma_clip`, ``axis`` may be a single
integer, ``None`` or a tuple of axes on every one. ``masked``

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: "a tuple of axes" — _setup and the _nanfuncs docstrings say "tuple or list", and a list is accepted here too (there's a test for it). One word for consistency.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. Double-checked that "or list" is true on both dispatch paths before changing the doc: astropy.stats.sigma_clip accepts axis=[1, 2] (verified), so the numpy path is fine, and _setup handles a list off-numpy (test_nanfuncs.py::test_list_axis_matches_tuple). Will make it "a tuple or list of axes".

Written by Claude at @mwcraig's direction.

Comment thread CHANGES.rst Outdated
NaN-aware reductions or ccdproc's fallbacks. [#1001]
- ``Combiner.sigma_clipping`` outside NumPy now accepts ``axis=None`` and a
tuple of axes, as ``astropy.stats.sigma_clip`` does on the NumPy path, and
the reduction fallbacks in ``ccdproc._nanfuncs`` gained the same axis

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The NotImplementedError→TypeError change for bad axis isn't mentioned. Verified non-breaking (_nanfuncs landed in unreleased #986, latest tag 2.5.1), so strictly optional — but one clause here, e.g. "an invalid axis now raises TypeError rather than NotImplementedError", makes the entry airtight.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed — verified the non-breaking claim independently (_nanfuncs first appears in unreleased #986; latest release tag is 2.5.1, which has no _nanfuncs module), so it's a doc-completeness fix, not a compat note. Will append the clause to the #1006 entry.

Written by Claude at @mwcraig's direction.

@mwcraig

mwcraig commented Aug 31, 2026

Copy link
Copy Markdown
Member Author

Replies to the two outside-diff findings from the self-review (each inline finding has an answer in its own thread — every one verified and accepted; the two edge-case bugs reproduce exactly as described):

test_mad_fallback_rejects_bad_axis_tuple duplication — agreed. Post-hoist both grids land on the same _setup lines, and test_nanfuncs.py::test_bad_axis covers them 5-fold. Will shrink it to a single case pinning that _mad_fallback propagates _setup's error, and move the match= strings ("repeated axis", "out of bounds", "not bool") into test_bad_axis, which currently asserts only exception types — strictly more coverage, less duplication.

Unrelated core.py hunks — confirmed: the five hunks (~1014, 1728, 2062, 2750, 2757) are implicit-string-concatenation collapses with no connection to the axis work, evidently formatter fallout. Will revert them from this branch; they can land separately as a trivial cleanup if wanted.

Planned change set from all threads, for one push: the two bug fixes (np.bool_ guards + explicit-product merge reshape) with regression rows, the three _setup simplifications (None→tuple fold, normalize_axis_tuple scalar path, ~9 lines net), the two test-grid rows (axis=(), numpy-int tuple), the ref_axis dedupe, Notes section + expanded test docstring, American spellings throughout the diff, "tuple or list" wording, the CHANGES clause, and the test_ccdproc.py shrink + core.py revert above. The docstring-templating question (thread on _nanfuncs.py:248) awaits a call on the {params}-in-source trade-off.

Written by Claude at @mwcraig's direction.

mwcraig and others added 2 commits August 31, 2026 08:50
…document

Bug fixes, both found in review:

- Reject np.bool_ axes explicitly, as scalars and as tuple/list entries:
  on the oldest supported numpy (2.0) operator.index still converts
  np.True_ to 1 with only a DeprecationWarning, so axis=(0, np.True_)
  silently reduced axes (0, 1) there. Newer numpy raises TypeError
  itself; the guard makes the behaviour and message uniform.
- Spell out the merged-axis length in _setup's tuple merge instead of
  reshaping with -1, which is ambiguous (and raises) when a kept axis
  has size 0; numpy's reductions return an empty result there. Found
  independently by two reviewers.

Simplifications from review, all behaviour-preserving:

- Fold the axis=None branch into the tuple branch (None is
  tuple(range(ndim))).
- Replace the hand-rolled scalar bounds check with
  normalize_axis_tuple, whose AxisError is a ValueError with an
  identical message; the tuple branch already surfaced AxisError.
- Normalize tuple axes once inside test_combiner's
  _sigma_clip_reference instead of at its call site.
- Shrink test_ccdproc's bad-axis grid to a single delegation check;
  the messages are now pinned in test_nanfuncs::test_bad_axis.

Docs and tests: move _setup's axis-handling discussion into a Notes
section, add brief comments to the permute-and-merge block, explain the
list-equals-tuple test, document that sigma_clipping takes a list of
axes too, note the NotImplementedError->TypeError change in CHANGES,
switch the diff's British spellings to American, and drop the unrelated
string-concatenation cleanups from core.py. New differential rows cover
axis=(), numpy ints in a tuple, and the two empty-array shapes (where
numpy.median itself trips over the same -1 merge; the test substitutes
np.nanmedian as the reference there).

Full runs of the three affected files: numpy 697, dask 701, jax full
suite 968, array-api-strict 689 + 12 pre-existing xfails; ruff and
black clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVxnTLrWKStBdPwcmDNhxA
Requested in review on astropy#1006: the six public reductions repeated the
same three Parameters entries with incidental wording drift. They are
now written once in _COMMON_PARAMS and filled into each docstring's
{params} placeholder by the _fill_doc decorator (the scipy doccer
pattern), with only the axis action phrase substituted per function.
Function-specific behaviour -- what an all-NaN slice yields, NaN
propagation -- stays inline in each Returns section, and nanmad keeps
its extra ``median`` entry after the template.

The filled docstrings render identically to the hand-written ones
(help(), Sphinx, and numpydoc all read __doc__, which is complete
after import); the trade-off is that raw source shows the {params}
placeholder. python -OO strips docstrings, so the decorator fills only
when __doc__ exists. The unified axis entry also carries the
booleans-rejected/numpy-scalars-accepted note everywhere, which
previously appeared in only three of the six.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVxnTLrWKStBdPwcmDNhxA
@mwcraig

mwcraig commented Aug 31, 2026

Copy link
Copy Markdown
Member Author

The change set promised across the review threads has landed in two commits:

  • c801431 — both edge-case bug fixes (np.bool_ guards, explicit-product merge reshape) with regression rows, the three _setup simplifications, the new grid rows, the ref_axis dedupe, the Notes section + merge-block comments, the test-explanation docstrings, American spellings, "tuple or list" wording, the CHANGES clause, the test_ccdproc.py shrink, and the core.py cleanup revert.
  • 9d18599 — the docstring templating (_COMMON_PARAMS + _fill_doc, per the thread).

One find from adding the empty-array rows: numpy.median itself crashes on a tuple axis with a size-0 kept dimension — the very same -1 merge in numpy/lib/_function_base_impl.py — so the differential test substitutes np.nanmedian as the reference for that row (commented in the test). Might be worth an upstream report.

Local runs after the changes: numpy 964 (full suite), dask 701, jax 968 (full suite), array-api-strict 689 + 12 pre-existing xfails; ruff, black, and pre-commit clean.

Written by Claude at @mwcraig's direction.

@mwcraig
mwcraig merged commit cfdbdd4 into astropy:main Aug 31, 2026
18 checks passed
@mwcraig
mwcraig deleted the nanfuncs-axis-hoist branch August 31, 2026 14:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Hoist None/tuple axis handling into _nanfuncs._setup

2 participants