Skip to content

Fix MaskedColumn TypeError from stale fill_value after dtype-changing ufuncs - #20265

Merged
pllim merged 5 commits into
astropy:mainfrom
jdavies-st:bugfix-issue20257-maskedcolumn-fillvalue
Sep 8, 2026
Merged

pllim merged 5 commits into
astropy:mainfrom
jdavies-st:bugfix-issue20257-maskedcolumn-fillvalue

Conversation

@jdavies-st

@jdavies-st jdavies-st commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Description

This fixes a np.ma.MaskedArray quirk where __array_wrap__ copies the input's raw fill_value onto the output without checking it still matches the possibly changed output dtype.

MaskedColumn.__array_wrap__ now re-validates _fill_value against the ufunc output's dtype, resetting it to the default when invalid.

This bug exists since numpy 2.0, which returns a masked array for these ufuncs. Before that, a standard ndarray was returned.

Fixes #20257

  • By checking this box, the PR author has requested that maintainers do NOT use the "Squash and Merge" button. Maintainers should respect this when possible; however, the final decision is at the discretion of the maintainer that merges the PR.

@github-actions

Copy link
Copy Markdown
Contributor

Thank you for your contribution to Astropy! 🌌 This checklist is meant to remind the package maintainers who will review this pull request of some common things to look for.

  • Do the proposed changes actually accomplish desired goals?
  • Do the proposed changes follow the Astropy coding guidelines?
  • Are tests added/updated as required? If so, do they follow the Astropy testing guidelines?
  • Are docs added/updated as required? If so, do they follow the Astropy documentation guidelines?
  • Is rebase and/or squash necessary? If so, please provide the author with appropriate instructions. Also see instructions for rebase and squash.
  • Did the CI pass? If no, are the failures related? If you need to run daily and weekly cron jobs as part of the PR, please apply the "Extra CI" label. Codestyle issues can be fixed by the bot.
  • Is a change log needed? If yes, did the change log check pass? If no, add the "no-changelog-entry-needed" label. If this is a manual backport, use the "skip-changelog-checks" label unless special changelog handling is necessary.
  • Is this a big PR that makes a "What's new?" entry worthwhile and if so, is (1) a "what's new" entry included in this PR and (2) the "whatsnew-needed" label applied?
  • At the time of adding the milestone, if the milestone set requires a backport to release branch(es), apply the appropriate "backport-X.Y.x" label(s) before merge.

@pllim pllim added this to the v7.2.3 milestone Aug 24, 2026
@pllim pllim added Bug backport-v7.2.x on-merge: backport to v7.2.x backport-v8.0.x on-merge: backport to v8.0.x labels Aug 24, 2026
@pllim

This comment was marked as resolved.

@pllim

This comment was marked as resolved.

@jdavies-st
jdavies-st force-pushed the bugfix-issue20257-maskedcolumn-fillvalue branch from a2ac391 to c7af2c4 Compare August 24, 2026 16:07
@jdavies-st

Copy link
Copy Markdown
Contributor Author

No, upon further inspection that issue is more just a problem of how np.histogram deals with masked values in MaskedArrays, not an issue with the dtype between the array itself and the fill_value being different, as here.

Yes, but I suspect there's a low chance of actually fixing that bug, given the sheer number of similar-ish bugs in numpy.ma dealing with strange behavior of fill_value. That said, I will try.

@CAOShurong CAOShurong 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.

Independent exact-head verification review (AI assistance used and disclosed; tested locally on Windows, Python 3.13.1 / NumPy 2.5.2).

RED confirmed at base 3011554f (pristine main worktree): the reporter's exact snippet runs, but the failure is deferred, which matches why it looks "obscure":

col = MaskedColumn(["foo","bar","baz"], mask=False, fill_value="N/A", dtype="<U8")
r = np.strings.find(col, "foo")   # returns int64 [0, -1, -1] — no error yet
r._fill_value                     # -> array('N/A', dtype='<U8')  ← stale string fill on an int64 array
repr(r)                           # -> TypeError: Cannot convert fill_value N/A to dtype int64

The TypeError also fires in realistic downstream use, not just repr: filtering a Table column by np.strings.find(t["uri"], ".jpg") == -1 (exactly the astroquery/MAST workflow from #20257) raises the same TypeError at base, and succeeds at this PR's head.

GREEN confirmed at head c7af2c41: np.strings.find and np.char.find both return [0, -1, -1] (int64) with _fill_value reset to None → materialized default 999999; repr/filled()/tolist() all fine. The masked-rows variant (mask=[False, True, False]) is cured too — at base that path leaves np.str_('N/A') as the fill of an int64 result (same latent bug, different trigger), while the patched tree resets it to the int default. So the fix covers more than the issue's minimal repro.

No-behavior-change checks (identical output on base vs head, i.e. the guard does not over-trigger):

  • comparison ufunc col > "bar" (U→bool dtype change): fill stays array(True) on both trees;
  • non-dtype-changing ufuncs preserve custom fills exactly: np.sin keeps -1.0, np.abs keeps 99;
  • scalar reductions (sum, mean) unchanged; plain (non-masked) Column unaffected.

Suites (head, editable-free detached worktrees with copied build artifacts): astropy/table/tests/test_column.py 147 passed / 2 xfailed (incl. the new regression test), full astropy/table 2425 passed / 201 skipped / 14 xfailed, astropy/utils/masked 1192 passed / 5 skipped / 2 xfailed. CI matrix fully green including towncrier.

Two minor, non-blocking notes:

  1. ma.core._check_fill_value is private NumPy API, and this appears to be its first use in the astropy codebase (git grep over main finds no prior use). It is stable across the supported numpy>=2.0 range today, but if astropy would rather not grow private-API surface, a small local validator (attempt out_arr.fill_value = fill_value inside the same try/except) would behave equivalently — happy to test either way. Not a merge blocker from my side.
  2. Test-gap suggestion: the new regression test covers the mask=False case only. Two cheap additions would lock in behavior I verified manually: (a) a masked-rows variant of the same ufunc (base currently leaves 'N/A' as the fill there too), and (b) an assertion that a valid custom numeric fill survives a dtype-changing ufunc (e.g. MaskedColumn([1,2,3], fill_value=999999) through np.sqrt keeps 999999) — that pins down that the guard resets only invalid fills.

On pllim's questions: yes — this is precisely a downstream mitigation for numpy/numpy#32401 (still open, zero comments as of today). Given the number of released NumPy 2.x versions users run, fixing it astropy-side is justified independently of whatever happens upstream in numpy.ma. And I agree with the author's read that #14558 is a separate root cause (masked-value handling inside np.histogram, not fill-value dtype propagation).

@taldcroft
taldcroft requested a review from mhvk August 27, 2026 10:24
@taldcroft

Copy link
Copy Markdown
Member

@jdavies-st - thanks! I added @mhvk for review since this is really his expertise. From what I saw here and in the numpy issue, it looks like we want to wait for the numpy fix to converge and then basically apply the same here.

The other point would be to make the new code conditional on the numpy version so that it only runs for numpy < 2.6.0 (presuming the numpy fix gets into 2.6) and eventually we can remove the workaround.

@jdavies-st

Copy link
Copy Markdown
Contributor Author

Thanks @taldcroft. Agree. Let's wait to see what happens with the numpy PR. I don't know what their backport policy is, so will have to see what is done for numpy < 2.6.

@mhvk

mhvk commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Agreed with waiting and backporting your numpy fix once it is in. As the PR is labelled as a backport candidate, it probably will go to current numpy, so we'll need an if NUMPY_LT_2_5_? clause.

@jdavies-st

Copy link
Copy Markdown
Contributor Author

@mhvk I think the numpy PR is complete and ready. Feel free to have a look over there (and review if you like), and if all looks good, I will update this PR to have MaskedColumn behave the same way.

@jdavies-st

Copy link
Copy Markdown
Contributor Author

@mhvk I've updated this PR to align with the fix in Numpy. I used the NUMPY_LT_2_6 clause because even if it is backported to 2.5.x or 2.4.x, some minor versions of 2.5 and 2.4 will still have the bug. This should catch all of them.

@mhvk mhvk 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.

@jdavies-st - only a comment on the changelog entry, otherwise it looks good.

Note the readthedocs failure -- I cannot really see why that would happen but maybe just in case rebase on current main when you make the change.

Comment thread docs/changes/table/20265.bugfix.rst Outdated
@mhvk

mhvk commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Hmm, saw the readthedocs failure in another recent PR now -- it's definitely unrelated, so don't worry about that!

@jdavies-st

Copy link
Copy Markdown
Contributor Author

I see also that the devdeps job failed, and that is real. There seems to be something else going on in MaskedColumn.fill_value. Hmm.

@jdavies-st

Copy link
Copy Markdown
Contributor Author

Oh, I see the devdeps numpy build being used actually lags from my commit fixing the bug on the numpy side, which means NUMPY_LT_2_6 correctly skips the fix here, but it also isn't in the numpy nightly build yet. So I think that's the issue. I would assume the nightly build will eventually catch up, but numpy==2.6.0.dev0 currently doesn't have my fix in it.

@mhvk mhvk 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.

Thanks! Approving now, not sure why not all the tests are running.

@mhvk

mhvk commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Will close and reopen to trigger a new test run.

@mhvk mhvk closed this Sep 3, 2026
@mhvk mhvk reopened this Sep 3, 2026
@jdavies-st
jdavies-st force-pushed the bugfix-issue20257-maskedcolumn-fillvalue branch from 62cbf5e to 6b54099 Compare September 3, 2026 14:06
@jdavies-st
jdavies-st force-pushed the bugfix-issue20257-maskedcolumn-fillvalue branch from 04a3c88 to 8997e88 Compare September 8, 2026 11:49
@jdavies-st

Copy link
Copy Markdown
Contributor Author

So I rebased to main, and still see the failed test, same reason. The test running is using an out-of-date version of the numpy 2.6.0.dev0 from the scientific python nightly wheels. Something in the action must be preserving the uv cache (either metadata or the wheel itself) and indicating to the runner that it doesn't not need to get a fresh copy from anaconda. That fresh copy has existed since Sunday, but the old code is clearly being used in the CI job.

Perhaps this is a bug with the astropy CI infrastructure? Is there some pip or uv package cache that is preserved across runs?

It is caused at least partially by the fact that numpy doesn't rename their artifacts (or the versions for that matter). It is always .dev0, but this week's dev0 is not the same as last week's. It is usually good practice to append either the number of commits (.dev1, dev42, etc) or the git hash, so it is obvious in the CI runner logs what actual commit version is being used, but I suspect numpy doesn't do this so that they don't have to worry about old nightly wheel cleanup over at anaconda. Just rename to the same filename twice a week.

Anyway, I will investigate further.

@mhvk

mhvk commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Let me ping @neutrinoceros about the UV cache, as we discussed this just a little while ago (in the reverse direction, that it was a pity the cache was not used...); @neutrinoceros - see message just above.

@jdavies-st

jdavies-st commented Sep 8, 2026

Copy link
Copy Markdown
Contributor Author

Ah, ok!

Well I just went down this rabbit hole, so let me report.

the setup-uv cache is actually getting left to the default auto in the OpenAstronomy action. For this run the log looks like:

2026-09-08T11:50:51.8795658Z ##[group]Run astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9
2026-09-08T11:50:51.8796057Z with:
2026-09-08T11:50:51.8796270Z   python-version: 3.14
2026-09-08T11:50:51.8796532Z   activate-environment: true
2026-09-08T11:50:51.8796788Z   ignore-empty-workdir: true
2026-09-08T11:50:51.8797032Z   no-project: false
2026-09-08T11:50:51.8797299Z   working-directory: /home/runner/work/astropy/astropy
2026-09-08T11:50:51.8800048Z   github-token: ***
2026-09-08T11:50:51.8800280Z   enable-cache: auto
2026-09-08T11:50:51.8800850Z   cache-dependency-glob: **/*requirements*.txt
**/*requirements*.in
**/*constraints*.txt
**/*constraints*.in
**/pyproject.toml
**/uv.lock
**/*.py.lock

2026-09-08T11:50:51.8801452Z   restore-cache: true

And then later

2026-09-08T11:50:53.9152201Z Set UV_CACHE_DIR to /home/runner/work/_temp/setup-uv-cache
2026-09-08T11:50:53.9152964Z Successfully installed uv version 0.12.10
2026-09-08T11:50:53.9154470Z Searching files using cache dependency glob: /home/runner/work/astropy/astropy/**/*requirements*.txt,/home/runner/work/astropy/astropy/**/*requirements*.in,/home/runner/work/astropy/astropy/**/*constraints*.txt,/home/runner/work/astropy/astropy/**/*constraints*.in,/home/runner/work/astropy/astropy/**/pyproject.toml,/home/runner/work/astropy/astropy/**/uv.lock,/home/runner/work/astropy/astropy/**/*.py.lock
2026-09-08T11:50:54.1541656Z /home/runner/work/astropy/astropy/pyproject.toml
2026-09-08T11:50:54.1572328Z Found 1 files to hash.
2026-09-08T11:50:54.1640207Z Trying to restore cache from GitHub Actions cache with key: setup-uv-2-x86_64-unknown-linux-gnu-ubuntu-24.04-3.14-d974a9c2c7ab5aa1f6c936cb4a942a8f1492120f684178dd7b592e4a8a6812d8
2026-09-08T11:50:54.2229654Z Cache hit for: setup-uv-2-x86_64-unknown-linux-gnu-ubuntu-24.04-3.14-d974a9c2c7ab5aa1f6c936cb4a942a8f1492120f684178dd7b592e4a8a6812d8
2026-09-08T11:50:54.9817223Z Received 157939586 of 157939586 (100.0%), 206.9 MBs/sec
2026-09-08T11:50:54.9818092Z Cache Size: ~151 MB (157939586 B)
2026-09-08T11:50:54.9936529Z [command]/usr/bin/tar -xf /home/runner/work/_temp/4b553bc3-5b71-4e54-a779-b03469813e44/cache.tzst -P -C /home/runner/work/astropy/astropy --use-compress-program unzstd
2026-09-08T11:50:56.0796449Z Cache restored successfully
2026-09-08T11:50:56.0875949Z cache restored from GitHub Actions cache with key: setup-uv-2-x86_64-unknown-linux-gnu-ubuntu-24.04-3.14-d974a9c2c7ab5aa1f6c936cb4a942a8f1492120f684178dd7b592e4a8a6812d8

And since for this repo pyproject.toml has not changed here, the hash of the above restores the cache.

And since the version for numpy (and possibly other devdeps) is .dev0 always, then it just uses the cached version, and it's impossible to tell in the log.

I would argue that caching should be turned off for all devdeps jobs, because it's not testing against what you think it is.

@jdavies-st

jdavies-st commented Sep 8, 2026

Copy link
Copy Markdown
Contributor Author

I'll do a quick check in this PR by setting UV_NO_CACHE on the devdeps jobs in tox.ini.

EDIT: I realize this is the same as #20263.

@jdavies-st

jdavies-st commented Sep 8, 2026

Copy link
Copy Markdown
Contributor Author

Ok, that worked!

To be clear, this one-line change to tox.ini doesn't prevent the CI runner from restoring the cache in the jobs, it just prevents uv from using it. You could move the fix upstream to OpenAstronomy actions, exposing the enable-cache param on setup-uv, but then that would make the tox devdeps jobs behave differently locally vs on the CI runner. But you know your system better than me, so please advise.

Also I turned off uv caching for all four deps in the one-line commit, but maybe it only needs to be turned off for the nightly wheel jobs?

I also noticed that

UV_INDEX_STRATEGY = unsafe-best-match

is set for predeps twice. Harmless, but worth fixing.

@pllim

pllim commented Sep 8, 2026

Copy link
Copy Markdown
Member

Alas the UV stuff might get in the way of auto backports, but let's see... 🤞

Thanks!

@pllim
pllim merged commit dd8cd5e into astropy:main Sep 8, 2026
39 checks passed
@lumberbot-app

This comment was marked as resolved.

@jdavies-st

Copy link
Copy Markdown
Contributor Author

Happy to pull the UV tox.ini stuff off into a separate PR to make this one more back-portable if you like.

@pllim

This comment was marked as resolved.

@neutrinoceros

Copy link
Copy Markdown
Contributor

I already have PRs in the air to address caching friction, no need to open new ones !

@neutrinoceros

Copy link
Copy Markdown
Contributor

For the record: #20285

@pllim

pllim commented Sep 8, 2026

Copy link
Copy Markdown
Member

I also opened #20336 as a follow-up. FYI.

Sorry, @neutrinoceros . Hopefully the one liner here is easy for you to follow up on in your relevant PRs.

@neutrinoceros

Copy link
Copy Markdown
Contributor

yeah. It's now cleaned up in #19886. I'm considering self-merging it as it's becoming more work for everyon to keep it open than not

pllim pushed a commit that referenced this pull request Sep 8, 2026
pllim added a commit that referenced this pull request Sep 8, 2026
…after dtype-changing ufuncs (#20335)

Co-authored-by: James Davies <[email protected]>
@pllim

pllim commented Sep 8, 2026

Copy link
Copy Markdown
Member

For completeness: The rest of backport is at #20338

pllim pushed a commit to pllim/astropy that referenced this pull request Sep 9, 2026
…skedcolumn-fillvalue

Fix MaskedColumn TypeError from stale fill_value after dtype-changing ufuncs

(cherry picked from commit dd8cd5e)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backport-v7.2.x on-merge: backport to v7.2.x backport-v8.0.x on-merge: backport to v8.0.x Bug table

Projects

None yet

Development

Successfully merging this pull request may close these issues.

np.strings.find raises ValueError for a table MaskedColumn

6 participants