Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/python-package-pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
python-version: ["3.11", "3.12", "3.13", "3.14"]

steps:
- uses: actions/checkout@v3
Expand All @@ -34,3 +34,5 @@ jobs:
pytest
pip install fitsio>=1.0.5
pytest
pip install rustfits
pytest
2 changes: 2 additions & 0 deletions .github/workflows/python-package-push.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,5 @@ jobs:
pytest
pip install fitsio>=1.0.5
pytest
pip install rustfits
pytest
86 changes: 75 additions & 11 deletions healsparse/fits_shim.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,25 @@
import copy
import numpy as np
import mmap
from .utils import is_integer_value
# We need this for compression before a newer version of fitsio arrives
import astropy.io.fits as fits

use_rustfits = False
use_fitsio = False
try:
import fitsio
use_fitsio = True
import rustfits
use_rustfits = True
except ImportError:
pass

if not use_rustfits:
try:
import fitsio
use_fitsio = True
except ImportError:
pass


_image_bitpix2npy = {
8: 'u1',
Expand All @@ -29,12 +38,12 @@
'ZPCOUNT', 'ZGCOUNT', 'ZTILE1', 'ZCMPTYPE',
'ZNAME1', 'ZVAL1', 'ZQUANTIZ',
'SIMPLE', 'BITPIX', 'NAXIS', 'NAXIS1', 'NAXIS2',
'PCOUNT', 'GCOUNT']
'PCOUNT', 'GCOUNT', 'XTENSION']


class HealSparseFits(object):
"""
Wrapper class to handle fitsio or astropy.io.fits
Wrapper class to handle rustfits, fitsio, or astropy.io.fits
"""
def __init__(self, filename, mode='r'):
"""
Expand All @@ -54,7 +63,19 @@ def __init__(self, filename, mode='r'):
self._filename = filename
self._mode = mode

if use_fitsio:
if use_rustfits:
if mode == "r":
rustfits_mode = "r"
elif mode == "rw":
rustfits_mode = "r+"

self.fits_object = rustfits.FITS(str(filename), mode=rustfits_mode)

try:
_ = self.fits_object[0]
except ValueError:
raise IOError("File %s does not appear to be a fits file." % (filename))
elif use_fitsio:
self.fits_object = fitsio.FITS(filename, mode=mode)
else:
if mode == 'r':
Expand All @@ -80,6 +101,7 @@ def read_ext_header(self, extension):
if use_fitsio:
return self.fits_object[extension].read_header()
else:
# This works for rustfits and astropy fits.
return self.fits_object[extension].header

def get_ext_dtype(self, extension):
Expand All @@ -95,9 +117,15 @@ def get_ext_dtype(self, extension):
-------
dtype : `np.dtype`
"""
if use_fitsio:
if use_rustfits:
hdu = self.fits_object[extension]
if hdu.get_exttype() == 'IMAGE_HDU':
if self.ext_is_image(extension):
return hdu.dtype.str
else:
return hdu.dtype
elif use_fitsio:
hdu = self.fits_object[extension]
if self.ext_is_image(extension):
return _image_bitpix2npy[hdu.get_info()['img_equiv_type']]
else:
return self.fits_object[extension].get_rec_dtype()[0]
Expand Down Expand Up @@ -126,7 +154,7 @@ def read_ext_data(self, extension, row_range=None, col_range=None):
-------
data : `np.ndarray`
"""
if use_fitsio:
if use_rustfits or use_fitsio:
hdu = self.fits_object[extension]
if row_range is None:
return hdu.read()
Expand Down Expand Up @@ -169,7 +197,9 @@ def ext_is_image(self, extension):
is_image : `bool`
"""
hdu = self.fits_object[extension]
if use_fitsio:
if use_rustfits:
return isinstance(hdu, (rustfits.CompressedImageHDU, rustfits.ImageHDU))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

You can just check rustfits.ImageHDU

elif use_fitsio:
if hdu.get_exttype() == 'IMAGE_HDU':
return True
else:
Expand All @@ -192,7 +222,14 @@ def append_extension(self, extension, data):
raise RuntimeError("Appending only allowed in rw mode")

hdu = self.fits_object[extension]
if use_fitsio:
if use_rustfits:
if hasattr(hdu, 'extend'):
# We can append
hdu.extend(data)
else:
firstrow = (hdu.get_dims()[0], )
hdu.write(data, start=firstrow)
elif use_fitsio:
if hasattr(hdu, 'append'):
# A recarray that we can append to
hdu.append(data)
Expand Down Expand Up @@ -260,7 +297,26 @@ def _write_filename(filename, c_hdr, s_hdr, cov_index_map, sparse_map,
_tile_shape = (compress_tilesize, )
s_hdr['RESHAPED'] = False

if use_fitsio and integer_map:
if use_rustfits:
with rustfits.FITS(str(filename), mode="w+") as f:
f.write_image(cov_index_map, extname=c_hdr["EXTNAME"], header=c_hdr)

if compress:
if compression == "GZIP_2":
comp = rustfits.Gzip2(tile_shape=_tile_shape)
elif compression == "RICE_1":
comp = rustfits.Rice1(tile_shape=_tile_shape)

f.write_image(
_sparse_map,
extname=s_hdr["EXTNAME"],
header=s_hdr,
compress=comp,
)
else:
f.write(sparse_map, extname=s_hdr["EXTNAME"], header=s_hdr)

elif use_fitsio and integer_map:
# Preferred because it is faster for integer writes.
# Floating point writing with compression has only just
# been fixed and I don't want to put a lower limit on
Expand Down Expand Up @@ -355,6 +411,14 @@ def _make_header(metadata, force_astropy=False):
-------
header : `fitsio.FITSHDR` or `astropy.io.fits.Header`
"""
if use_rustfits and not force_astropy:
if metadata is None:
return {}
else:
hdr = copy.copy(metadata)
for reserved in FITS_RESERVED:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

rustfits will ignore protected fits header keys when you write to a header from an existing FITSHeader using the update method, but not when writing from a dict, so it is correct to skip protected keys.

rustfits provides rustfits.is_protected_key(name) for the above, which could replace your check against FITS_RESERVED.

Note, if this metadata was itself created from a rustfits FITSHeader, you can do header.to_dict(skip_protected=True) to provide the clean dict.

It probably makes sense to provide a method to clean a dict of protected keys so you don't need the loop.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Because of the way that the tests do reading/writing, and there's no way to directly create a rustfits header in python, sometimes this code gets a rustfits header and sometimes a dict. But I will make use of the is_protected_key().

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Hmm. I don't want to loop over all keys ... just the protected ones. I just don't know how to do this without a bunch more special cases.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

There is no need to change what you are doing, I was just giving information, sorry to imply otherwise

Here is what rustfits does

https://github.com/esheldon/rustfits/blob/5c959d88ceaebefa3fd29cf4ee144adcd22e0757/src/header.rs#L440

hdr.pop(reserved, None)
return hdr
if use_fitsio and not force_astropy:
hdr = fitsio.FITSHDR(metadata)
else:
Expand Down
2 changes: 1 addition & 1 deletion healsparse/io_coverage_fits.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ def _read_coverage_fits(coverage_class, filename_or_fits):

try:
cov_index_map = fits.read_ext_data('COV')
except (OSError, KeyError):
except (OSError, KeyError, ValueError):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

What is the situation here? It is a fits file but doesn't have the 'COV' extension?

raise RuntimeError("File is not a HealSparseMap")

s_hdr = fits.read_ext_header('SPARSE')
Expand Down
3 changes: 2 additions & 1 deletion healsparse/io_map.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,10 @@ def _read_map(healsparse_class, filename, nside_coverage=None, pixels=None, head
fits = HealSparseFits(filename)
is_fits_file = True
fits.close()
except (OSError, UnicodeDecodeError):
except (OSError, UnicodeDecodeError, ValueError):
pass
# UnicodeDecodeError occurs when trying to read an hdf5 file as if it's FITS
# ValueError occurs from rustfits when trying to read an hdf5 file as if it's FITS

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

What actual error is raised? I'm guessing it is ValueError because it tries to parse the primary header and finds the wrong values in the first bytes, but I want to see if a different error might be more appropriate to raise.

@erykoff erykoff Jul 3, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

So the following code snippet raises ValueError because it can't access extension 0. If the file nothing.notfits is not there then it raises an OSError (as expected). If the file isn't a fits (e.g. touch nothing.notfits) then rustfits will happily "open" the file but won't raise (ValueError) until it tries to access an extension. Should it be checking that it's a valid fits file on open?

with rustfits.FITS("nothing.notfits") as f:
    print(f[0])

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

rustfits.FITS should do eager header parsing on open. It is true that fitsio was lazy

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

But it doesn’t parse the header on an empty file?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

sounds like a bug, investigating

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

this is fixed and will be in the next release


if not is_fits_file:
is_parquet_file = check_parquet_dataset(filename)
Expand Down
4 changes: 2 additions & 2 deletions tests/test_cat_files.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ def test_cat_maps(self):
outfile = os.path.join(self.test_dir, 'test_%s_combined_%d.hs' %
(t, int(in_mem)))

if not healsparse.fits_shim.use_fitsio and not in_mem:
if not (healsparse.fits_shim.use_rustfits or healsparse.fits_shim.use_fitsio) and not in_mem:
# We cannot use out-of-memory option with astropy.io.fits
self.assertRaises(RuntimeError, cat_healsparse_files,
file_list, outfile, in_memory=in_mem,
Expand Down Expand Up @@ -209,7 +209,7 @@ def test_cat_maps_overlap(self):
outfile = os.path.join(self.test_dir, 'test_%s_combined_%d.hs' %
(t, int(in_mem)))

if not healsparse.fits_shim.use_fitsio and not in_mem:
if not (healsparse.fits_shim.use_rustfits or healsparse.fits_shim.use_fitsio) and not in_mem:
# We cannot use out-of-memory option with astropy.io.fits
self.assertRaises(RuntimeError, cat_healsparse_files,
file_list, outfile, in_memory=in_mem,
Expand Down
9 changes: 7 additions & 2 deletions tests/test_fits_shim.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ def test_append(self):
self.test_dir = tempfile.mkdtemp(dir='./', prefix='TestHealSparse-')
header = None

if healsparse.fits_shim.use_fitsio:
if healsparse.fits_shim.use_rustfits or healsparse.fits_shim.use_fitsio:
# Test appending to image and recarray
for t in ['array', 'recarray', 'widemask']:
filename = os.path.join(self.test_dir, 'test_%s.fits' % (t))
Expand Down Expand Up @@ -201,7 +201,12 @@ def write_testfile(self, filename, data0, data1, header):
"""
Write a testfile.
"""
if healsparse.fits_shim.use_fitsio:
if healsparse.fits_shim.use_rustfits:
healsparse.fits_shim.rustfits.write(filename, data0,
header=header, extname='COV')
healsparse.fits_shim.rustfits.write(filename, data1, mode='r+',
header=header, extname='SPARSE')
elif healsparse.fits_shim.use_fitsio:
healsparse.fits_shim.fitsio.write(filename, data0,
header=header, extname='COV')
healsparse.fits_shim.fitsio.write(filename, data1,
Expand Down
Loading