Repository: desihub/desiutil

Issue:
annotate_fits warning about overriding units that aren't changing
When running annotate_fits on a file that already has units (e.g. to fill in missing units and/or to update columns), it reports warnings like:
```
WARNING:annotate.py:437:annotate_fits: Overriding units for column 'FLUX_G': 'nanomaggy' -> 'nanomaggy'.
WARNING:annotate.py:437:annotate_fits: Overriding units for column 'FLUX_R': 'nanomaggy' -> 'nanomaggy'.
WARNING:annotate.py:437:annotate_fits: Overriding units for column 'FLUX_Z': 'nanomaggy' -> 'nanomaggy'.
WARNING:annotate.py:437:annotate_fits: Overriding units for column 'FLUX_W1': 'nanomaggy' -> 'nanomaggy'.
WARNING:annotate.py:437:annotate_fits: Overriding units for column 'FLUX_W2': 'nanomaggy' -> 'nanomaggy'.
```
In this case, the overridden unit is identical to the unit that is already there and shouldn't generate a warning.  This should only be a warning if it is overriding it to something different.

I think this is a simple fix, but I'm filing a ticket to remember to do this later.

Hints from issue comments:

Solution patch:
diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml
index fe94e2c..b0e504d 100644
--- a/.github/workflows/python-package.yml
+++ b/.github/workflows/python-package.yml
@@ -9,6 +9,12 @@ on:
             - '*'
     pull_request:
 
+
+# 2025-01-07
+# NERSC has Python 3.10, Numpy 1.22.4, Scipy 1.8.1, matplotlib 3.8.4, astropy 6.0.1, healpy 1.16.6, PyYAML 6.0.1
+# PyPI has Python 3.13, Numpy 2.2.1, Scipy 1.15.0, matplotlib 3.10.0, astropy 7.0.0, healpy 1.18.0, PyYAML 6.0.2
+# Numpy 1.26.4 was the last pre-2.0 Numpy.
+#
 jobs:
     tests:
         name: Unit tests
@@ -17,9 +23,20 @@ jobs:
             fail-fast: true
             matrix:
                 os: [ubuntu-latest]
-                python-version: ['3.10']  # There are still issues with Numpy <1.23 and 3.11.
-                numpy-version: ['<1.23', '<1.24', '<2.0']
-                astropy-version: ['<6.0', '<6.1', '<7.0']
+                python-version: ['3.11', '3.12']
+                numpy-version: ['<2.0', '<2.1', '<2.2', '<3']
+                astropy-version: ['<6.1', '<7.0']  # , '<7.1'] 7 has an IERS issue
+                include:
+                    # Close to current NERSC configuration.
+                    - os: ubuntu-latest
+                      python-version: '3.10'
+                      numpy-version: '<1.23'
+                      astropy-version: '<6.1'
+                    # Stable, yet close to bleeding-edge.
+                    - os: ubuntu-latest
+                      python-version: '3.13'
+                      numpy-version: '<3'
+                      astropy-version: '<7.0'  # not <7.1 yet, see above
 
         steps:
             - name: Checkout code
@@ -33,8 +50,8 @@ jobs:
             - name: Install Python dependencies
               run: |
                 python -m pip install --upgrade pip setuptools wheel
-                python -m pip install --upgrade "numpy${{ matrix.numpy-version }}"
-                python -m pip install --upgrade "astropy${{ matrix.astropy-version }}"
+                python -m pip install --upgrade --upgrade-strategy only-if-needed "numpy${{ matrix.numpy-version }}"
+                python -m pip install --upgrade --upgrade-strategy only-if-needed "astropy${{ matrix.astropy-version }}"
                 python -m pip install --editable .[test]
             - name: Run the test
               run: pytest
@@ -47,7 +64,8 @@ jobs:
             matrix:
                 os: [ubuntu-latest]
                 python-version: ['3.10']
-                # astropy-version: ['<5.1'] not used at this time.
+                numpy-version: ['<1.23']
+                astropy-version: ['<6.1']
 
         steps:
             - name: Checkout code
@@ -61,6 +79,8 @@ jobs:
             - name: Install Python dependencies
               run: |
                 python -m pip install --upgrade pip setuptools wheel
+                python -m pip install --upgrade --upgrade-strategy only-if-needed "numpy${{ matrix.numpy-version }}"
+                python -m pip install --upgrade --upgrade-strategy only-if-needed "astropy${{ matrix.astropy-version }}"
                 python -m pip install --editable .[coverage]
             - name: Run the test with coverage
               run: pytest --cov
@@ -76,8 +96,10 @@ jobs:
         strategy:
             fail-fast: false
             matrix:
-                os: [ubuntu-latest]
-                python-version: ['3.10']
+                os:
+                    - ubuntu-latest
+                python-version:
+                    - '3.13'
 
         steps:
             - name: Checkout code
@@ -89,7 +111,9 @@ jobs:
               with:
                 python-version: ${{ matrix.python-version }}
             - name: Install Python dependencies
-              run: python -m pip install --upgrade pip setuptools wheel Sphinx sphinx-rtd-theme
+              run: |
+                python -m pip install --upgrade pip setuptools wheel
+                python -m pip install --editable .[doc]
             - name: Test the documentation
               run: sphinx-build -W --keep-going -b html doc doc/_build/html
 
@@ -99,8 +123,10 @@ jobs:
         strategy:
             fail-fast: false
             matrix:
-                os: [ubuntu-latest]
-                python-version: ['3.10']
+                os:
+                    - ubuntu-latest
+                python-version:
+                    - '3.13'
 
         steps:
             - name: Checkout code
@@ -120,14 +146,39 @@ jobs:
             - name: Compare generated api.rst to checked-in version
               run: diff --ignore-space-change --ignore-blank-lines ./api.rst ./doc/api.rst
 
+    scripts:
+        name: Script definition completeness test
+        runs-on: ${{ matrix.os }}
+        strategy:
+            fail-fast: false
+            matrix:
+                os:
+                    - ubuntu-latest
+                python-version:
+                    - '3.13'
+
+        steps:
+            - name: Checkout code
+              uses: actions/checkout@v4
+              with:
+                  fetch-depth: 0
+            - name: Set up Python ${{ matrix.python-version }}
+              uses: actions/setup-python@v5
+              with:
+                  python-version: ${{ matrix.python-version }}
+            - name: Compare contents of bin/ to options.scripts in setup.cfg
+              run: python -c 'import os; import sys; from configparser import ConfigParser; conf = ConfigParser(); foo = conf.read(["setup.cfg"]); scripts = [os.path.basename(s) for s in conf["options"]["scripts"].strip().split("\n")]; sys.exit(int(set(os.listdir("bin")) != set(scripts)))'
+
     style:
         name: Style check
         runs-on: ${{ matrix.os }}
         strategy:
             fail-fast: false
             matrix:
-                os: [ubuntu-latest]
-                python-version: ['3.10']
+                os:
+                    - ubuntu-latest
+                python-version:
+                    - '3.13'
 
         steps:
             - name: Checkout code
diff --git a/README.rst b/README.rst
index 3002a85..d069319 100644
--- a/README.rst
+++ b/README.rst
@@ -19,13 +19,14 @@ desiutil
 Introduction
 ============
 
-This is the place for general purpose tools for use by
-everyone.
+`desiutil` contains general purpose tools for use by any and all other `DESI packages`_.
+
+.. _`DESI packages`: https://github.com/desihub
 
 Full Documentation
 ==================
 
-Please visit `desiutil on Read the Docs`_
+Please visit `desiutil on Read the Docs`_.
 
 .. _`desiutil on Read the Docs`: https://desiutil.readthedocs.io/en/latest/
 
diff --git a/bin/update_iers_frozen b/bin/update_iers_frozen
index 0d089d4..04bea48 100755
--- a/bin/update_iers_frozen
+++ b/bin/update_iers_frozen
@@ -1,9 +1,9 @@
 #!/usr/bin/env python
 """Update the iers_frozen file.
 """
-from pkg_resources import resource_filename
+from importlib.resources import files
 from desiutil.iers import update_iers
 
-frozen = resource_filename('desiutil', 'data/iers_frozen.ecsv')
-print("Updating {0}".format(frozen))
+frozen = str(files('desiutil') / 'data' / 'iers_frozen.ecsv')
+print(f"Updating {frozen}.")
 update_iers(frozen)
diff --git a/doc/changes.rst b/doc/changes.rst
index df6ad1e..239521d 100644
--- a/doc/changes.rst
+++ b/doc/changes.rst
@@ -11,10 +11,16 @@ Change Log
 * Remove deprecated module :mod:`desiutil.census`.
 * Remove deprecated top-level ``setup.py``.
 
-3.4.4 (unreleased)
+3.5.0 (unreleased)
 ------------------
 
-* No changes yet.
+* Fix bug in :func:`~desiutil.names.radec_to_desiname` for values very close to zero;
+  don't issue warnings in :func:`~desiutil.annotate.annotate_fits` if the units
+  are not actually changing; general package clean-up in preparation for
+  deployment to PyPI_ (PR `#212`_).
+
+.. _PyPI: https://pypi.org
+.. _`#212`: https://github.com/desihub/desiutil/pull/212
 
 3.4.3 (2024-08-15)
 ------------------
@@ -397,7 +403,7 @@ remains compatible with 2.x.x
 * Fix :command:`desiInstall` on cori.
 * Add :mod:`desiutil.census` to calculate DESI disk space use.
 
-.. _basemap: http://matplotlib.org/basemap/
+.. _basemap: https://matplotlib.org/basemap/stable/
 .. _`#61`: https://github.com/desihub/desiutil/pull/61
 .. _`#63`: https://github.com/desihub/desiutil/pull/63
 .. _`#70`: https://github.com/desihub/desiutil/pull/70
@@ -569,7 +575,7 @@ remains compatible with 2.x.x
 * Update the list of NERSC hosts, including cori.
 * Code is now `PEP 8`_ compliant.
 
-.. _`PEP 8`: http://legacy.python.org/dev/peps/pep-0008/
+.. _`PEP 8`: https://legacy.python.org/dev/peps/pep-0008/
 
 1.1.0 (2015-11-06)
 ------------------
@@ -599,7 +605,7 @@ remains compatible with 2.x.x
 * fix_permissions.sh won't clobber executable bits.
 
 .. _`Travis build support`: https://travis-ci.org/desihub/desiutil
-.. _`Read the Docs support`: http://desiutil.readthedocs.org/en/latest/
+.. _`Read the Docs support`: https://desiutil.readthedocs.io/en/latest/
 
 0.6.0 (2015-10-13)
 ------------------
diff --git a/doc/conf.py b/doc/conf.py
index b7096d0..daac611 100644
--- a/doc/conf.py
+++ b/doc/conf.py
@@ -34,15 +34,16 @@
     'sphinx.ext.todo',
     'sphinx.ext.mathjax',
     'sphinx.ext.viewcode',
-    'sphinx.ext.napoleon'
+    'sphinx.ext.napoleon',
+    'sphinx_rtd_theme',
 ]
 
 # Configuration for intersphinx, copied from astropy.
 intersphinx_mapping = {
     'python': ('https://docs.python.org/3/', None),
     'numpy': ('https://numpy.org/doc/stable/', None),
-    'scipy': ('https://docs.scipy.org/doc/scipy/reference/', None),
-    'matplotlib': ('https://matplotlib.org/', None),
+    'scipy': ('https://docs.scipy.org/doc/scipy/', None),
+    'matplotlib': ('https://matplotlib.org/stable/', None),
     'astropy': ('https://docs.astropy.org/en/stable/', None),
     'h5py': ('https://docs.h5py.org/en/latest/', None)
     }
@@ -132,12 +133,7 @@
 # a list of builtin themes.
 #html_theme = 'default'
 #html_theme = 'haiku'
-try:
-    import sphinx_rtd_theme
-    html_theme = 'sphinx_rtd_theme'
-    html_theme_path = [sphinx_rtd_theme.get_html_theme_path()]
-except ImportError:
-    pass
+html_theme = 'sphinx_rtd_theme'
 
 # Theme options are theme-specific and customize the look and feel of a theme
 # further.  For a list of options available for each theme, see the
diff --git a/doc/index.rst b/doc/index.rst
index 1a30988..c1ee93b 100644
--- a/doc/index.rst
+++ b/doc/index.rst
@@ -7,7 +7,7 @@ Introduction
 
 desiutil is a set of low-level utilities used by all DESI_ packages.
 
-.. _DESI: https://desi.lbl.gov
+.. _DESI: https://www.desi.lbl.gov
 
 Dependencies
 ============
@@ -17,11 +17,11 @@ Required Dependencies
 
 These packages must be installed for desiutil to work properly:
 
-* `pyyaml <https://pyyaml.org/>`_
-* `requests <https://requests.readthedocs.io/>`_
-* `astropy <https://www.astropy.org/>`_
+* `PyYAML <https://pyyaml.org/>`_
+* `Requests <https://requests.readthedocs.io/>`_
+* `Astropy <https://www.astropy.org/>`_
 
-  - This implies a dependency on `NumPy <https://numpy.org>`_
+  - This implies a dependency on `NumPy <https://numpy.org>`_.
 
 Optional Dependencies
 +++++++++++++++++++++
@@ -29,13 +29,14 @@ Optional Dependencies
 If you want to use the plotting utilities in :mod:`desiutil.plots`, you will
 need:
 
-* `matplotlib <https://matplotlib.org/>`_
+* `Matplotlib <https://matplotlib.org/>`_
 * `healpy <https://healpy.readthedocs.io/en/latest/>`_
 
 If you want to work with the dust utilities in :mod:`desiutil.dust`, you will
 need:
 
-* `SciPy <https://scipy.org>`
+* `SciPy <https://scipy.org>`_
+* `speclite <https://speclite.readthedocs.io/en/latest/>`_
 
 Contents
 ========
diff --git a/py/desiutil/__init__.py b/py/desiutil/__init__.py
index aa678c8..eda5bb0 100644
--- a/py/desiutil/__init__.py
+++ b/py/desiutil/__init__.py
@@ -7,7 +7,7 @@
 
 This package provides low-level utilities for general use by DESI_.
 
-.. _DESI: http://desi.lbl.gov
+.. _DESI: https://www.desi.lbl.gov
 """
 #
 # Set version string.
diff --git a/py/desiutil/_version.py b/py/desiutil/_version.py
index 9081773..5b2cd40 100644
--- a/py/desiutil/_version.py
+++ b/py/desiutil/_version.py
@@ -1 +1 @@
-__version__ = '3.4.4.dev1122'
+__version__ = '3.5.0.dev1122'
diff --git a/py/desiutil/annotate.py b/py/desiutil/annotate.py
index 5310810..60b0bb9 100644
--- a/py/desiutil/annotate.py
+++ b/py/desiutil/annotate.py
@@ -426,16 +426,19 @@ def annotate_fits(filename, extension, output, units=None, comments=None,
             for colname in column_index:
                 ttype = f"TTYPE{column_index[colname]:d}"
                 if colname in column_comments:
-                    if hdu.header.comments[ttype].strip():
+                    current_comment = hdu.header.comments[ttype].strip()
+                    if current_comment and column_comments[colname] != current_comment:
                         log.warning("Overriding comment on column '%s': '%s' -> '%s'.",
-                                    colname, hdu.header.comments[ttype].strip(), column_comments[colname])
+                                    colname, current_comment, column_comments[colname])
                     hdu.header[ttype] = (colname, column_comments[colname])
                     log.debug('Set %s comment to "%s"', colname, column_comments[colname])
                 if colname in column_units:
                     tunit = f"TUNIT{column_index[colname]:d}"
-                    if tunit in hdu.header and hdu.header[tunit].strip():
-                        log.warning("Overriding units for column '%s': '%s' -> '%s'.",
-                                    colname, hdu.header[tunit].strip(), column_units[colname])
+                    if tunit in hdu.header:
+                        current_unit = hdu.header[tunit].strip()
+                        if current_unit and column_units[colname] != current_unit:
+                            log.warning("Overriding units for column '%s': '%s' -> '%s'.",
+                                        colname, current_unit, column_units[colname])
                         hdu.header[tunit] = (column_units[colname], colname+' units')
                     else:
                         hdu.header.insert(f"TFORM{column_index[colname]:d}",
@@ -465,11 +468,13 @@ def _options():
     parser.add_argument('-D', '--disable-comments', action='store_true', dest='disable_comments',
                         help='Do not add comments, even if they are defined in one of the inputs.')
     parser.add_argument('-e', '--extension', dest='extension', action='store', metavar='EXT', default='1',
-                        help="Update FITS extension EXT, which can be a number or an EXTNAME. If not specified, HDU 1 will be updated, which is standard for simple binary tables.")
+                        help=("Update FITS extension EXT, which can be a number or an EXTNAME. " +
+                              "If not specified, HDU 1 will be updated, which is standard for simple binary tables."))
     parser.add_argument('-o', '--overwrite', dest='overwrite', action='store_true',
                         help='Overwrite the input FITS file.')
     parser.add_argument('-T', '--truncate-comments', dest='truncate', action='store_true',
-                        help='Allow any long comments to be truncated when written out. Without this option, long comments will raise an error.')
+                        help=('Allow any long comments to be truncated when written out. ' +
+                              'Without this option, long comments will raise an error.'))
     parser.add_argument('-u', '--units', action='store', dest='units', metavar='UNITS',
                         help="UNITS should have the form COLUMN='unit':COLUMN='unit'.")
     parser.add_argument('-v', '--verbose', dest='verbose', action='store_true',
diff --git a/py/desiutil/bitmask.py b/py/desiutil/bitmask.py
index 7fcaa9b..85637e5 100644
--- a/py/desiutil/bitmask.py
+++ b/py/desiutil/bitmask.py
@@ -57,8 +57,9 @@
 'Cosmic ray'
 
 
-.. _desispec: http://desispec.readthedocs.io
+.. _desispec: https://desispec.readthedocs.io/en/latest/
 """
+import numpy as np
 
 
 class _MaskBit(int):
@@ -222,7 +223,8 @@ def names(self, mask=None):
         ----------
         mask : :class:`int`, optional
             The mask integer to convert to names. If not supplied,
-            return names of all known bits.
+            return names of all known bits. `mask` can also be a numpy scalar
+            or a numpy array of length 1.
 
         Returns
         -------
@@ -236,7 +238,17 @@ def names(self, mask=None):
             for bitnum in sorted(bitnums):
                 names.append(self._bits[bitnum].name)
         else:
-            mask = int(mask)  # workaround numpy issue #2955 for uint64
+            if isinstance(mask, int) or isinstance(mask, np.integer):
+                pass
+            elif isinstance(mask, np.ndarray) and mask.shape == (1, ):
+                mask = mask[0]
+            else:
+                raise ValueError('Unknown type or invalid shape for mask!')
+            if mask < 0:
+                mask = np.int64(mask).astype(np.uint64)
+            # For Numpy < 2, the operation (int & np.uint64) was not defined.
+            # For Numpy >= 2, this operation should be harmless.
+            mask = int(mask)
             bitnum = 0
             while 2**bitnum <= mask:
                 if (2**bitnum & mask):
diff --git a/py/desiutil/census.py b/py/desiutil/census.py
index e8ce16d..2382123 100644
--- a/py/desiutil/census.py
+++ b/py/desiutil/census.py
@@ -86,11 +86,11 @@ def get_options(test_args=None):
     from sys import argv
     from os.path import basename
     from argparse import ArgumentParser
-    from pkg_resources import resource_filename
+    from importlib.resources import files
     parser = ArgumentParser(description="Count number and size of DESI data files.",
                             prog=basename(argv[0]))
     parser.add_argument('-c', '--config-file', action='store', dest='config',
-                        metavar='FILE', default=resource_filename('desiutil', 'data/census.yaml'),
+                        metavar='FILE', default=str(files('desiutil') / 'data' / 'census.yaml'),
                         help="Read configuration from FILE (default %(default)s).")
     parser.add_argument('-o', '--output', action='store', metavar='FILE',
                         default='desi_data_census.csv',
diff --git a/py/desiutil/depend.py b/py/desiutil/depend.py
index a54bd93..a13c9a6 100644
--- a/py/desiutil/depend.py
+++ b/py/desiutil/depend.py
@@ -61,18 +61,14 @@
 #
 # default possible dependencies to check in add_dependencies()
 #
-possible_dependencies = [
-    'numpy', 'scipy', 'astropy', 'yaml', 'matplotlib',
-    'requests', 'fitsio', 'h5py', 'mpi4py', 'psycopg2', 'healpy',
-    'desiutil', 'desispec', 'desitarget', 'desimodel', 'desisim', 'desisurvey',
-    'specter', 'speclite', 'specsim', 'surveysim', 'redrock', 'desimeter',
-    'fiberassign', 'gpu_specter',
-    ]
-possible_envvars = [
-    'DESI_ROOT', 'DESI_SPECTRO_DATA', 'DESI_SPECTRO_REDUX', 'SPECPROD',
-    'DESI_SPECTRO_CALIB', 'DESI_BASIS_TEMPLATES',
-    'DESI_TARGET', 'DESIMODEL',
-    ]
+possible_dependencies = ['numpy', 'scipy', 'astropy', 'yaml', 'matplotlib',
+                         'requests', 'fitsio', 'h5py', 'mpi4py', 'psycopg2', 'healpy',
+                         'desiutil', 'desispec', 'desitarget', 'desimodel', 'desisim', 'desisurvey',
+                         'specter', 'speclite', 'specsim', 'surveysim', 'redrock', 'desimeter',
+                         'fiberassign', 'gpu_specter',]
+possible_envvars = ['DESI_ROOT', 'DESI_SPECTRO_DATA', 'DESI_SPECTRO_REDUX', 'SPECPROD',
+                    'DESI_SPECTRO_CALIB', 'DESI_BASIS_TEMPLATES',
+                    'DESI_TARGET', 'DESIMODEL',]
 
 
 def setdep(header, name, version):
diff --git a/py/desiutil/dust.py b/py/desiutil/dust.py
index d0aea2d..de114ff 100644
--- a/py/desiutil/dust.py
+++ b/py/desiutil/dust.py
@@ -7,7 +7,7 @@
 
 Get :math:`E(B-V)` values from the `Schlegel, Finkbeiner & Davis (1998; SFD98)`_ dust map.
 
-.. _`Schlegel, Finkbeiner & Davis (1998; SFD98)`: http://adsabs.harvard.edu/abs/1998ApJ...500..525S.
+.. _`Schlegel, Finkbeiner & Davis (1998; SFD98)`: https://ui.adsabs.harvard.edu/abs/1998ApJ...500..525S/abstract
 """
 import os
 import numpy as np
@@ -388,7 +388,7 @@ def ext_fitzpatrick(wave, R_V=3.1, avglmc=False, lmc2=False,
 
 
 #
-# based on the work from https://ui.adsabs.harvard.edu/abs/2011ApJ...737..103S
+# based on the work from https://ui.adsabs.harvard.edu/abs/2011ApJ...737..103S/abstract
 # note from Eddie: I recommend applying the SF11 calibration in the following way:
 # A(lambda, F99) = A(lambda, F99, rv)/A(1 micron, F99, rv) * SFD_EBV * 1.029.
 # That's a definition that only uses monochromatic extinctions,
@@ -584,7 +584,7 @@ class SFDMap(object):
     scaling : :class:`float`, optional, defaults to 1
         Scale all E(B-V) map values by this multiplicative factor.
         Pass scaling=0.86 for the recalibration from
-        `Schlafly & Finkbeiner (2011) <http://adsabs.harvard.edu/abs/2011ApJ...737..103S)>`_.
+        `Schlafly & Finkbeiner (2011) <https://ui.adsabs.harvard.edu/abs/2011ApJ...737..103S/abstract>`_.
 
     Notes
     -----
@@ -652,10 +652,7 @@ def ebv(self, *args, **kwargs):
             frame = 'fk5'
 
         # compatibility: treat single argument 2-tuple as (RA, Dec)
-        if (
-                (len(args) == 1) and (type(args[0]) is tuple)
-                and (len(args[0]) == 2)
-        ):
+        if (len(args) == 1) and (type(args[0]) is tuple) and (len(args[0]) == 2):
             args = args[0]
 
         if len(args) == 1:
@@ -772,7 +769,7 @@ def _get_ext_coeff(temp, photsys, band, ebv_sfd, rv=3.1):
     wave = np.linspace(2900, 11000, 4000)
     sed = 1. / (wave/6000)**5 / (np.exp(143877687. / wave / temp) - 1)
     # code to use Munari library
-    # http://cdsarc.u-strasbg.fr/ftp/J/A+A/442/1127/disp10A/fluxed_spectra/T_07000/
+    # https://cdsarc.u-strasbg.fr/ftp/J/A+A/442/1127/disp10A/fluxed_spectra/T_07000/
     # wave = np.loadtxt('LAMBDA_D10.DAT.txt')
     # wave = np.r_[wave, 10999]
     # sed = np.loadtxt('T07000G40M10V000K2SNWNVD10F.ASC')
@@ -814,7 +811,9 @@ def _main():
     import matplotlib.pyplot as plt
 
     parser = argparse.ArgumentParser(formatter_class=argparse.ArgumentDefaultsHelpFormatter,
-                                     description="Runs and displays a comparison between tabulated values of total to selective extinction ratios and values obtained with synthetic mags.")
+                                     description=("Runs and displays a comparison between " +
+                                                  "tabulated values of total to selective extinction " +
+                                                  "ratios and values obtained with synthetic mags."))
     args = parser.parse_args()
 
     plt.figure("reddening")
diff --git a/py/desiutil/iers.py b/py/desiutil/iers.py
index 5ac8908..9f1f3eb 100644
--- a/py/desiutil/iers.py
+++ b/py/desiutil/iers.py
@@ -52,7 +52,7 @@ def freeze_iers(name='iers_frozen.ecsv', ignore_warnings=True):
     :meth:`astropy.utils.iers.IERS._check_interpolate_indices` to prevent
     any IERSRangeError being raised.
 
-    See http://docs.astropy.org/en/stable/utils/iers.html for details.
+    See https://docs.astropy.org/en/stable/utils/iers.html for details.
 
     This function returns immediately after the first time it is called,
     so it it safe to insert anywhere that consistent IERS models are
@@ -105,7 +105,8 @@ def freeze_iers(name='iers_frozen.ecsv', ignore_warnings=True):
     # to prevent any IERSRangeError being raised.
     class IERS_Frozen(astropy.utils.iers.IERS_B):
         def _check_interpolate_indices(self, indices_orig, indices_clipped,
-                                       max_input_mjd): pass
+                                       max_input_mjd):
+            pass
 
     # Create and register an instance of this class from the table.
     iers = IERS_Frozen(table)
@@ -121,9 +122,15 @@ def _check_interpolate_indices(self, indices_orig, indices_clipped,
     else:
         astropy.utils.iers.conf.iers_degraded_accuracy = 'warn'
     # Sanity check.
-    auto_class = astropy.utils.iers.IERS_Auto.open()
-    if auto_class is not iers:
-        raise RuntimeError('Frozen IERS is not installed as the default ({0} v. {1}).'.format(auto_class.__class__, iers.__class__))
+    # In Astropy 7 this appears to be broken due to the iers_frozen.ecsv being out of date.
+    # The *format* of that file no longer matches what is expected by astropy.util.iers.
+    try:
+        auto_class = astropy.utils.iers.IERS_Auto.open()
+        if auto_class is not iers:
+            raise RuntimeError('Frozen IERS is not installed as the default ({0} v. {1}).'.format(auto_class.__class__, iers.__class__))
+    except KeyError:
+        # Temporary Astropy 7/IERS workaround.
+        warnings.warn("Temporarily skipping IERS integrity check.", UserWarning)
 
     if ignore_warnings:
         try:
diff --git a/py/desiutil/install.py b/py/desiutil/install.py
index 96fef1e..3cc4249 100644
--- a/py/desiutil/install.py
+++ b/py/desiutil/install.py
@@ -16,7 +16,7 @@
 from io import BytesIO
 from subprocess import Popen, PIPE
 from types import MethodType
-from pkg_resources import resource_filename
+from importlib.resources import files
 from .git import last_tag
 from .log import get_logger, DEBUG, INFO
 from .modules import (init_modules, configure_module,
@@ -70,9 +70,7 @@
     'tilepicker': 'https://github.com/desihub/tilepicker',
     'timedomain': 'https://github.com/desihub/timedomain',
     'plate_layout': 'https://desi.lbl.gov/svn/code/focalplane/plate_layout',
-    'positioner_control':
-        'https://desi.lbl.gov/svn/code/focalplane/positioner_control',
-    }
+    'positioner_control': 'https://desi.lbl.gov/svn/code/focalplane/positioner_control'}
 
 
 #
@@ -109,8 +107,8 @@ def dependencies(modulefile):
         raise ValueError("Modulefile {0} does not exist!".format(modulefile))
     with open(modulefile) as m:
         lines = m.readlines()
-    return [l.strip().split()[2] for l in lines if
-            l.strip().startswith('module load')]
+    return [ln.strip().split()[2] for ln in lines if
+            ln.strip().startswith('module load')]
 
 
 class DesiInstallException(Exception):
@@ -314,8 +312,7 @@ def get_product_version(self):
         try:
             self.fullproduct = known_products[self.baseproduct]
         except KeyError:
-            self.fullproduct = 'https://github.com/desihub/{}'.format(
-                    self.baseproduct)
+            self.fullproduct = f'https://github.com/desihub/{self.baseproduct}'
             self.log.warning('Guessing %s is at %s.',
                              self.baseproduct, self.fullproduct)
             self.log.warning('Add location to desiutil.install.known_products ' +
@@ -428,9 +425,7 @@ def get_code(self):
                                                   self.baseversion))
                     r.raise_for_status()
                 except requests.exceptions.HTTPError:
-                    message = ("Branch {0} does not appear to exist. " +
-                               "HTTP response was {1:d}.").format(
-                               self.baseversion, r.status_code)
+                    message = f"Branch {self.baseversion} does not appear to exist. HTTP response was {r.status_code:d}."
                     self.log.critical(message)
                     raise DesiInstallException(message)
                 command = ['git', 'clone', '-q', '-b', self.baseversion,
@@ -480,9 +475,7 @@ def get_code(self):
                         r = requests.get(self.product_url)
                         r.raise_for_status()
                     except requests.exceptions.HTTPError:
-                        message = ("Error while downloading {0}, " +
-                                   "HTTP response was {1:d}.").format(
-                                   self.product_url, r.status_code)
+                        message = f"Error while downloading {self.product_url}, HTTP response was {r.status_code:d}."
                         self.log.critical(message)
                         raise DesiInstallException(message)
                     try:
@@ -605,8 +598,7 @@ def set_install_dir(self):
         if self.options.root is None and self.nersc is not None:
             self.options.root = self.default_nersc_dir()
 
-        if ((self.options.root is None or not os.path.isdir(self.options.root))
-                and not self.options.test):
+        if ((self.options.root is None or not os.path.isdir(self.options.root)) and not self.options.test):
             message = "Root install directory is missing or not set."
             self.log.critical(message)
             raise DesiInstallException(message)
@@ -659,8 +651,7 @@ def module_dependencies(self):
         self.module_file = os.path.join(self.working_dir, 'etc',
                                         self.baseproduct + '.module')
         if not os.path.exists(self.module_file):
-            self.module_file = resource_filename('desiutil',
-                                                 'data/desiutil.module')
+            self.module_file = str(files('desiutil') / 'data' / 'desiutil.module')
         if self.options.test:
             self.log.debug('Test Mode. Skipping loading of dependencies.')
             self.deps = list()
diff --git a/py/desiutil/io.py b/py/desiutil/io.py
index 65d66c6..5b7ca6f 100644
--- a/py/desiutil/io.py
+++ b/py/desiutil/io.py
@@ -82,7 +82,7 @@ def yamlify(obj, debug=False):
         obj = int(obj)
     elif isinstance(obj, np.bool_):
         obj = bool(obj)
-    elif isinstance(obj, (np.string_, str)):
+    elif isinstance(obj, (np.str_, str)):
         obj = str(obj)
     # elif isinstance(obj, Quantity):
     #     obj = dict(value=obj.value, unit=obj.unit.to_string())
diff --git a/py/desiutil/modules.py b/py/desiutil/modules.py
index 76d1386..666ac3c 100644
--- a/py/desiutil/modules.py
+++ b/py/desiutil/modules.py
@@ -7,7 +7,7 @@
 
 This package contains code for processing and installing `Module files`_.
 
-.. _`Module files`: http://modules.sourceforge.net
+.. _`Module files`: https://modules.readthedocs.io/en/latest/
 """
 import os
 import re
@@ -16,7 +16,7 @@
 from shutil import which
 from stat import S_IRUSR, S_IRGRP, S_IROTH
 from configparser import ConfigParser
-from pkg_resources import resource_filename
+from importlib.resources import files
 from . import __version__ as desiutilVersion
 from .io import unlock_file
 from .log import log
@@ -183,18 +183,16 @@ def configure_module(product, version, product_root, working_dir=None, dev=False
     """
     if working_dir is None:
         working_dir = os.getcwd()
-    module_keywords = {
-        'name': product,
-        'version': version,
-        'product_root': product_root,
-        'needs_bin': '# ',
-        'needs_python': '# ',
-        'needs_trunk_py': '# ',
-        'trunk_py_dir': '/py',
-        'needs_ld_lib': '# ',
-        'needs_idl': '# ',
-        'pyversion': "python{0:d}.{1:d}".format(*sys.version_info)
-        }
+    module_keywords = {'name': product,
+                       'version': version,
+                       'product_root': product_root,
+                       'needs_bin': '# ',
+                       'needs_python': '# ',
+                       'needs_trunk_py': '# ',
+                       'trunk_py_dir': '/py',
+                       'needs_ld_lib': '# ',
+                       'needs_idl': '# ',
+                       'pyversion': "python{0:d}.{1:d}".format(*sys.version_info)}
     if os.path.isdir(os.path.join(working_dir, 'bin')):
         module_keywords['needs_bin'] = ''
     if os.path.isdir(os.path.join(working_dir, 'lib')):
@@ -347,7 +345,7 @@ def main():
 
     if not os.path.exists(module_file):
         log.warning("Could not find Module file: %s; using default.", module_file)
-        module_file = resource_filename('desiutil', 'data/desiutil.module')
+        module_file = str(files('desiutil') / 'data' / 'desiutil.module')
 
     process_module(module_file, module_keywords, options.modules)
 
diff --git a/py/desiutil/names.py b/py/desiutil/names.py
index 283efe5..79d93a9 100644
--- a/py/desiutil/names.py
+++ b/py/desiutil/names.py
@@ -65,15 +65,18 @@ def radec_to_desiname(target_ra, target_dec):
     #   IAU compliant term
     desinames = []
     for ra, dec in zip(ratrunc, dectrunc):
-        desiname = 'DESI J' + ra[:-precision].zfill(3) + '.' + ra[-precision:]
+        zra = ra.zfill(3 + precision)  # RA always has 3 leading digits.
+        desiname = 'DESI J' + zra[:-precision] + '.' + zra[-precision:]
         # Positive numbers need an explicit "+" while negative numbers
         #   already have a "-".
-        # zfill works properly with '-' but counts it in number of characters
-        #   so need one more
+        # zfill works properly with '-' but counts it in the number of characters
+        #   so need one extra character is needed.
         if dec.startswith('-'):
-            desiname += dec[:-precision].zfill(3) + '.' + dec[-precision:]
+            zdec = dec.zfill(3 + precision)  # 2 leading digits plus space for '-'.
+            desiname += zdec[:-precision] + '.' + zdec[-precision:]
         else:
-            desiname += '+' + dec[:-precision].zfill(2) + '.' + dec[-precision:]
+            zdec = dec.zfill(2 + precision)  # 2 leading digits and the '+' is added explicitly.
+            desiname += '+' + zdec[:-precision] + '.' + zdec[-precision:]
         desinames.append(desiname)
 
     return np.array(desinames)
diff --git a/py/desiutil/plots.py b/py/desiutil/plots.py
index 19e98ac..4eae691 100644
--- a/py/desiutil/plots.py
+++ b/py/desiutil/plots.py
@@ -92,7 +92,9 @@ def plot_slices(x, y, x_lo, x_hi, y_cut, num_slices=5, min_count=100, axis=None,
         axis.scatter(x, y, s=15, marker='.', lw=0, color='b', alpha=0.5, zorder=1)
 
     # Plot quantiles in slices with enough fits.
-    stepify = lambda y: np.vstack([y, y]).transpose().flatten()
+    def stepify(y):
+        return np.vstack([y, y]).transpose().flatten()
+
     y_m2 = stepify(limits[:, 0])
     y_m1 = stepify(limits[:, 1])
     y_med = stepify(limits[:, 2])
@@ -437,7 +439,7 @@ def projection_dec(self, dec):
     #
     base_tick_labels = np.array([150, 120, 90, 60, 30, 0, 330, 300, 270, 240, 210])
     base_tick_labels = np.remainder(base_tick_labels+360+ra_center, 360)
-    tick_labels = np.array(['{0}°'.format(l) for l in base_tick_labels])
+    tick_labels = np.array(['{0}°'.format(lab) for lab in base_tick_labels])
     #
     # Galactic plane.
     #
@@ -475,8 +477,8 @@ def projection_dec(self, dec):
     # Set RA labels.
     #
     labels = ax.get_xticklabels()
-    for l, item in enumerate(labels):
-        item.set_text(tick_labels[l])
+    for i, item in enumerate(labels):
+        item.set_text(tick_labels[i])
     ax.set_xticklabels(labels)
     #
     # Set axis labels.
diff --git a/py/desiutil/redirect.py b/py/desiutil/redirect.py
index 1123d42..ccb474a 100644
--- a/py/desiutil/redirect.py
+++ b/py/desiutil/redirect.py
@@ -58,8 +58,8 @@ def stdouterr_redirected(to=None, comm=None):
 
     The general technique is based on:
 
-    http://stackoverflow.com/questions/5081657
-    http://eli.thegreenplace.net/2015/redirecting-all-kinds-of-stdout-in-python/
+    https://stackoverflow.com/questions/5081657/how-do-i-prevent-a-c-shared-library-to-print-on-stdout-in-python
+    https://eli.thegreenplace.net/2015/redirecting-all-kinds-of-stdout-in-python/
 
     If the optional communicator is specified, then each process redirects to
     a different temporary file.  Upon exit from the context the rank zero
diff --git a/py/desiutil/setup.py b/py/desiutil/setup.py
index be416af..3e990c4 100644
--- a/py/desiutil/setup.py
+++ b/py/desiutil/setup.py
@@ -15,11 +15,8 @@
 import os
 import re
 import sys
-import unittest
 from argparse import ArgumentParser
 from setuptools import Command
-from setuptools.command.test import test as BaseTest
-from pkg_resources import _namespace_packages
 from distutils.log import DEBUG, INFO, WARN, ERROR
 from . import __version__ as desiutilVersion
 from .log import log
@@ -149,77 +146,6 @@ def run(self):
         return
 
 
-class DesiTest(BaseTest, object):
-    """Add coverage to test commands.
-    """
-    description = "run unit tests after in-place build"
-    user_options = [('test-module=', 'm',
-                     "Run 'test_suite' in specified module"),
-                    ('test-suite=', 's',
-                     "Test suite to run (e.g. 'some_module.test_suite')"),
-                    ('test-runner=', 'r', "Test runner to use"),
-                    ('coverage', 'c', ('Create a coverage report. ' +
-                     'Requires the coverage package.'))
-                    ]
-    boolean_options = ['coverage']
-
-    def initialize_options(self):
-        self.coverage = False
-        super(DesiTest, self).initialize_options()
-
-    def finalize_options(self):
-        if self.coverage:
-            try:
-                import coverage
-            except ImportError:
-                self.announce(("--coverage requires that the coverage " +
-                               "package is installed, disabling coverage" +
-                               "option."), level=WARN)
-                self.coverage = False
-        super(DesiTest, self).finalize_options()
-
-    def run_tests(self):
-        # Purge modules under test from sys.modules. The test loader will
-        # re-import them from the build location. Required when 2to3 is used
-        # with namespace packages.
-        self.announce("WARNING: This functionality is deprecated and will be removed from a future version of desiutil.", level=WARN)
-        self.announce("WARNING: Use pytest or pytest --cov (for test coverage) instead.", level=WARN)
-        if getattr(self.distribution, 'use_2to3', False):
-            module = self.test_args[-1].split('.')[0]
-            if module in _namespace_packages:
-                del_modules = []
-                if module in sys.modules:
-                    del_modules.append(module)
-                module += '.'
-                for name in sys.modules:
-                    if name.startswith(module):
-                        del_modules.append(name)
-                list(map(sys.modules.__delitem__, del_modules))
-        if self.coverage:
-            self.announce("Coverage selected!", level=INFO)
-            import coverage
-            cov = coverage.coverage(data_file=os.path.abspath(".coverage"),
-                                    config_file=os.path.abspath(".coveragerc"))
-            cov.start()
-
-        result = unittest.main(None, None,
-                               ([unittest.__file__] + self.test_args),
-                               testLoader=self._resolve_as_ep(self.test_loader),
-                               testRunner=self._resolve_as_ep(self.test_runner),
-                               exit=False)
-        if result.result.wasSuccessful():
-            if self.coverage:
-                cov.stop()
-                self.announce('Saving coverage data in .coverage...',
-                              level=INFO)
-                cov.save()
-                self.announce('Saving HTML coverage report in htmlcov...',
-                              level=INFO)
-                cov.html_report(directory=os.path.abspath('htmlcov'))
-        else:
-            exit(1)
-
-
 class DesiVersion(Command):
     """Allow users to easily update the package version with
     ``python setup.py version``.
diff --git a/setup.cfg b/setup.cfg
index d8995d4..1eb5756 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -28,15 +28,12 @@ package_dir =
     =py
 packages = find:
 include_package_data = True
-python_requires = >=3.9
+python_requires = >=3.10
 # setup_requires = setuptools_scm
 install_requires =
     requests
     pyyaml
-    numpy<2.0
-    astropy>=5.0
-    healpy
-    matplotlib
+    astropy<7
 scripts =
     bin/annotate_fits
     bin/desi_api_file
@@ -56,11 +53,16 @@ scripts =
 #     desi_update_version = desiutil.setup:main
 
 [options.extras_require]
+all =
+    healpy
+    matplotlib
+    scipy
+    speclite
 test =
+    desiutil[all]
     pytest
-    scipy
 coverage =
-    scipy
+    desiutil[all]
     pytest-cov
     coveralls
 doc =
@@ -115,9 +117,13 @@ exclude_lines =
 # These are normally ignored by default:
 # ignore = E121, E123, E126, E133, E226, E241, E242, E704, W503, W504
 #
-# In addition to the default set we add:
+# These are the explicitly ignored styles:
+# - E226: missing whitespace around arithmetic operator
+# - E241: multiple spaces after ','
+# - E741: ambiguous variable name 'l', see desutil.dust._Hemisphere.ebv().
+# - W504: line break after binary operator
+#
+# And finally this one is not ignored, but modified by max-line-length.
 # - E501: line too long (82 > 79 characters)
-# - E731: do not assign a lambda expression, use a def
-# - E741: do not use variables named 'l', 'O', or 'I' -- because, for example,
-#   'l' might refer to Galactic longitude.
-ignore = E121, E123, E126, E133, E226, E241, E242, E501, E704, E731, E741, W503, W504
+max-line-length = 160
+ignore = E226, E241, E741, W504


Test patch:
diff --git a/py/desiutil/test/test_annotate.py b/py/desiutil/test/test_annotate.py
index bfb5bd5..5d87314 100644
--- a/py/desiutil/test/test_annotate.py
+++ b/py/desiutil/test/test_annotate.py
@@ -100,30 +100,32 @@ def test_find_key_name(self):
     def test_validate_unit(self):
         """Test function to validate units.
         """
-        m = "'ergs' did not parse as fits unit: At col 0, Unit 'ergs' not supported by the FITS standard. Did you mean erg?"
-        d = "'1/deg^2' did not parse as fits unit: Numeric factor not supported by FITS"
-        f = "'1/nanomaggy^2' did not parse as fits unit: Numeric factor not supported by FITS"
-        l = " If this is meant to be a custom unit, define it with 'u.def_unit'. To have it recognized inside a file reader or other code, enable it with 'u.add_enabled_units'. For details, see https://docs.astropy.org/en/latest/units/combining_and_defining.html"
+        erg = "'ergs' did not parse as fits unit: At col 0, Unit 'ergs' not supported by the FITS standard. Did you mean erg?"
+        deg = "'1/deg^2' did not parse as fits unit: Numeric factor not supported by FITS"
+        mag = "'1/nanomaggy^2' did not parse as fits unit: Numeric factor not supported by FITS"
+        cust_unit = (" If this is meant to be a custom unit, define it with 'u.def_unit'. " +
+                     "To have it recognized inside a file reader or other code, enable it with " +
+                     "'u.add_enabled_units'. For details, see https://docs.astropy.org/en/latest/units/combining_and_defining.html")
         c = validate_unit(None)
         self.assertIsNone(c)
         c = validate_unit('erg')
         self.assertIsNone(c)
         with self.assertWarns(FITSUnitWarning) as w:
             c = validate_unit('ergs', error=False)
-        self.assertEqual(str(w.warning), m + l)
+        self.assertEqual(str(w.warning), erg + cust_unit)
         self.assertIsNone(c)
         with self.assertWarns(FITSUnitWarning) as w:
             c = validate_unit('1/deg^2')
-        self.assertEqual(str(w.warning), d + l)
+        self.assertEqual(str(w.warning), deg + cust_unit)
         self.assertIsNone(c)
         c = validate_unit('nanomaggies', error=True)
         self.assertEqual(c, "'nanomaggies'")
         with self.assertRaises(ValueError) as e:
             c = validate_unit('ergs', error=True)
-        self.assertEqual(str(e.exception), m + l)
+        self.assertEqual(str(e.exception), erg + cust_unit)
         with self.assertRaises(ValueError) as e:
             c = validate_unit('1/nanomaggy^2', error=True)
-        self.assertEqual(str(e.exception), f + l)
+        self.assertEqual(str(e.exception), mag + cust_unit)
 
     @patch('desiutil.annotate.get_logger')
     def test_check_comment_length(self, mock_log):
@@ -384,6 +386,16 @@ def test_annotate_fits(self, mock_log):
         self.assertEqual(new_hdulist[2].header['TUNIT2'], 'deg')
         self.assertEqual(new_hdulist[2].header['TUNIT3'], 'deg')
         new_hdulist_name = os.path.join(self.TMP, 'test_annotate_update2.fits')
+        #
+        # Changing the unit to the same unit should not raise a warning.
+        #
+        new_hdulist = annotate_fits(self.fits_file, 2, new_hdulist_name, units={'MAG': 'mag'}, overwrite=True)
+        self.assertIn('TUNIT4', new_hdulist[2].header)
+        self.assertEqual(new_hdulist[2].header['TUNIT4'], 'mag')
+        mock_log().warning.assert_not_called()
+        #
+        # Actually change the units.
+        #
         new_hdulist = annotate_fits(self.fits_file, 2, new_hdulist_name, units={'MAG': 'nJy'}, overwrite=True)
         self.assertIn('TUNIT4', new_hdulist[2].header)
         self.assertEqual(new_hdulist[2].header['TUNIT4'], 'nJy')
diff --git a/py/desiutil/test/test_bitmask.py b/py/desiutil/test/test_bitmask.py
index ece366c..37f3844 100644
--- a/py/desiutil/test/test_bitmask.py
+++ b/py/desiutil/test/test_bitmask.py
@@ -78,7 +78,7 @@ def test_access(self):
         self.assertEqual(self.ccdmask.HOT.mask, 2)
         self.assertEqual(self.ccdmask.HOT, 2)
         self.assertEqual(self.ccdmask.HOT.comment, "Hot pixel")
-        self.ccdmask.names()
+        self.assertListEqual(self.ccdmask.names(), ['BAD', 'HOT', 'DEAD', 'SATURATED', 'COSMIC'])
 
     def test_badname(self):
         """Test raising AttributeError for bad names.
@@ -97,7 +97,7 @@ def test_badname(self):
     def test_str(self):
         """Verify yaml-ness of string representation"""
         bitmask = BitMask('ccdmask', yaml.safe_load(str(self.ccdmask)))
-        self.assertEqual(bitmask.names(), self.ccdmask.names())
+        self.assertListEqual(bitmask.names(), self.ccdmask.names())
         for name in bitmask.names():
             self.assertEqual(bitmask[name].mask, self.ccdmask[name].mask)
             self.assertEqual(bitmask[name].comment, self.ccdmask[name].comment)
@@ -120,29 +120,44 @@ def test_uint64(self):
         _bitdefs['ccdmask'].append(['BIG31', 31, "blat31..."])
         _bitdefs['ccdmask'].append(['BIGGER32', 32, "blat32..."])
         _bitdefs['ccdmask'].append(['WOW62', 62, "blat62..."])
-        # _bitdefs['ccdmask'].append(['BIGGEST63', 63, "blat63..."])
+        _bitdefs['ccdmask'].append(['BIGGEST63', 63, "blat63..."])
 
+        num2name = dict([(m[1], m[0]) for m in _bitdefs['ccdmask']])
         mask = BitMask('ccdmask', _bitdefs)
 
-        self.assertEqual(mask.names(1), ['BAD'])
-        self.assertEqual(mask.names(2), ['HOT'])
-        self.assertEqual(mask.names(3), ['BAD', 'HOT'])
-        self.assertEqual(mask.names(4), ['UNKNOWN2'])
-        self.assertEqual(mask.names(8), ['UNKNOWN3'])
-        self.assertEqual(mask.names(2**16), ['TEST'])
-        self.assertEqual(mask.names(2**31), ['BIG31'])
-        self.assertEqual(mask.names(2**32), ['BIGGER32'])
-        self.assertEqual(mask.names(2**62), ['WOW62'])
-        # self.assertEqual(mask.names(2**63), ['BIGGEST63'])
+        allmasks = 2**63 | 2**62 | 2**32 | 2**31 | 2**16 | 2**1 | 2**0
+
+        self.assertListEqual(mask.names(allmasks),
+                             ['BAD', 'HOT', 'TEST', 'BIG31', 'BIGGER32', 'WOW62', 'BIGGEST63'])
+        self.assertListEqual(mask.names(np.uint64(allmasks)),
+                             ['BAD', 'HOT', 'TEST', 'BIG31', 'BIGGER32', 'WOW62', 'BIGGEST63'])
+        self.assertListEqual(mask.names(np.uint64(allmasks).astype(np.int64)),
+                             ['BAD', 'HOT', 'TEST', 'BIG31', 'BIGGER32', 'WOW62', 'BIGGEST63'])
 
         for i in range(64):
+            if i in num2name:
+                n = [num2name[i]]
+            else:
+                n = ['UNKNOWN' + str(i)]
             names = mask.names(2**i)
-            # names = mask.names(int(2**i))
+            self.assertListEqual(names, n)
             names = mask.names(np.uint64(2**i))
-            # Also happens to work with length-1 arrays; maybe it shouldn't
+            self.assertListEqual(names, n)
+            # Allow testing of negative numbers while avoiding overflow errors.
+            names = mask.names(np.uint64(2**i).astype(np.int64))
+            self.assertListEqual(names, n)
+            # Test with length 1 arrays.
             names = mask.names(np.array([2**i], dtype=np.uint64))
-            if i < 63:
-                names = mask.names(np.array([2**i], dtype=np.int64))
+            self.assertListEqual(names, n)
+            names = mask.names(np.array([2**i], dtype=np.uint64).astype(np.int64))
+            self.assertListEqual(names, n)
+
+        # Arrays of more than one element should throw ValueError.
+        with self.assertRaises(ValueError):
+            names = mask.names(np.array([2**10, 2**20], dtype=np.uint64))
+        # Try with an unexpected type
+        with self.assertRaises(ValueError):
+            names = mask.names(float(2**32))
 
     def test_print(self):
         """Test string representations.
diff --git a/py/desiutil/test/test_dust.py b/py/desiutil/test/test_dust.py
index 2df5a15..371a490 100644
--- a/py/desiutil/test/test_dust.py
+++ b/py/desiutil/test/test_dust.py
@@ -12,7 +12,7 @@
 import numpy as np
 from .. import dust
 # import desiutil.dust as dust
-from pkg_resources import resource_filename
+from importlib.resources import files
 from astropy.coordinates import SkyCoord
 from astropy import units as u
 
@@ -22,7 +22,7 @@ class TestDust(unittest.TestCase):
     """
 
     def setUp(self):
-        self.mapdir = resource_filename('desiutil.test', 't')
+        self.mapdir = str(files('desiutil.test') / 't')
         # ADM these RAs/DECs are set to be in the 0-10 pixel column
         # ADM in the dust map files to correspond to
         # ADM the test data made by make_testmaps.py
diff --git a/py/desiutil/test/test_iers.py b/py/desiutil/test/test_iers.py
index 5130d3a..cca5343 100644
--- a/py/desiutil/test/test_iers.py
+++ b/py/desiutil/test/test_iers.py
@@ -8,7 +8,7 @@
 import shutil
 import tempfile
 from unittest.mock import call, patch, MagicMock
-from pkg_resources import resource_filename
+from importlib.resources import files
 from astropy import __version__ as AstropyVersion
 import astropy.units as u
 import astropy.utils.iers
@@ -71,7 +71,7 @@ def test_update_iers_file(self, mock_logger):
         """Check the existing frozen file for correctness.
         """
         # save_name = os.path.join(self.tmpdir, 'iers.ecsv')
-        save_name = resource_filename('desiutil', 'data/iers_frozen.ecsv')
+        save_name = str(files('desiutil') / 'data' / 'iers_frozen.ecsv')
         # i.update_iers(save_name)
         self.assertTrue(os.path.exists(save_name))
         with open(save_name) as s:
@@ -85,7 +85,7 @@ def test_update_iers_file(self, mock_logger):
     def test_update_iers(self, mock_logger, mock_iers, mock_time):
         """Test updating the IERS table.
         """
-        real_name = resource_filename('desiutil', 'data/iers_frozen.ecsv')
+        real_name = str(files('desiutil') / 'data' / 'iers_frozen.ecsv')
         t = QTable.read(real_name, format='ascii.ecsv')
         mock_iers.return_value = t
         d = MagicMock()
@@ -96,7 +96,7 @@ def test_update_iers(self, mock_logger, mock_iers, mock_time):
         self.assertTrue(os.path.exists(save_name))
         with open(save_name) as s:
             data = s.readlines()
-        if self.ap2 < 3:
+        if self.ap2 < 3 or self.ap2 > 6:
             self.assertIn('# - {data_url: frozen}\n', data)
         else:
             self.assertIn('#   data_url: frozen\n', data)
diff --git a/py/desiutil/test/test_install.py b/py/desiutil/test/test_install.py
index ab401ce..7b591cf 100644
--- a/py/desiutil/test/test_install.py
+++ b/py/desiutil/test/test_install.py
@@ -11,7 +11,7 @@
 from argparse import Namespace
 from tempfile import mkdtemp
 from logging import getLogger
-from pkg_resources import resource_filename
+from importlib.resources import files
 from ..log import DEBUG
 from ..install import DesiInstall, DesiInstallException, dependencies
 from .test_log import NullMemoryHandler
@@ -35,7 +35,8 @@ class TestInstall(unittest.TestCase):
 
     @classmethod
     def setUpClass(cls):
-        pass
+        cls.pyver = f"python{sys.version_info.major:d}.{sys.version_info.minor:d}"
+        cls.cext = f"cpython-{sys.version_info.major:d}.{sys.version_info.minor:d}.pyc"
 
     @classmethod
     def tearDownClass(cls):
@@ -76,8 +77,7 @@ def test_dependencies(self):
         self.assertEqual(str(cm.exception),
                          "Modulefile foo/bar/baz.module does not exist!")
         # Standard dependencies.
-        deps = dependencies(resource_filename('desiutil.test',
-                                              't/generic_dependencies.txt'))
+        deps = dependencies(str(files('desiutil.test') / 't' / 'generic_dependencies.txt'))
         self.assertEqual(set(deps), set(['astropy', 'desiutil/1.0.0']))
 
     def test_get_options(self):
@@ -624,28 +624,41 @@ def test_permissions(self, mock_chmod, mock_walk):
         self.desiInstall.install_dir = join(self.data_dir, 'desiutil')
         self.desiInstall.is_branch = False
         mock_walk.return_value = iter([(join(self.desiInstall.install_dir, 'bin'), [], ['executable', 'README.txt']),
-                                       (join(self.desiInstall.install_dir, 'lib', 'python3.10', 'site-packages', 'desiutil-1.2.3.dist-info'), [], ['METADATA', 'LICENSE.rst']),
-                                       (join(self.desiInstall.install_dir, 'lib', 'python3.10', 'site-packages', 'desiutil', '__pycache__'), [], ['__init__.cpython-3.10.pyc', 'module.cpython-3.10.pyc']),
-                                       (join(self.desiInstall.install_dir, 'lib', 'python3.10', 'site-packages', 'desiutil'), ['__pycache__'], ['__init__.py', 'module.py']),
-                                       (join(self.desiInstall.install_dir, 'lib', 'python3.10', 'site-packages'), ['desiutil-1.2.3.dist-info', 'desiutil'], []),
-                                       (join(self.desiInstall.install_dir, 'lib', 'python3.10'), ['site-packages'], []),
-                                       (join(self.desiInstall.install_dir, 'lib'), ['python3.10'], []),
+                                       (join(self.desiInstall.install_dir, 'lib', self.pyver, 'site-packages',
+                                             'desiutil-1.2.3.dist-info'), [], ['METADATA', 'LICENSE.rst']),
+                                       (join(self.desiInstall.install_dir, 'lib', self.pyver, 'site-packages',
+                                             'desiutil', '__pycache__'), [], [f"__init__.{self.cext}", f"module.{self.cext}"]),
+                                       (join(self.desiInstall.install_dir, 'lib', self.pyver, 'site-packages',
+                                             'desiutil'), ['__pycache__'], ['__init__.py', 'module.py']),
+                                       (join(self.desiInstall.install_dir, 'lib', self.pyver, 'site-packages'),
+                                        ['desiutil-1.2.3.dist-info', 'desiutil'], []),
+                                       (join(self.desiInstall.install_dir, 'lib', self.pyver), ['site-packages'], []),
+                                       (join(self.desiInstall.install_dir, 'lib'), [self.pyver], []),
                                        (self.desiInstall.install_dir, ['bin', 'lib'], [])])
         self.desiInstall.permissions()
         mock_walk.assert_called_once_with(self.desiInstall.install_dir, topdown=False)
         mock_chmod.assert_has_calls([call(join(self.desiInstall.install_dir, 'bin', 'executable'), 0o555),
                                      call(join(self.desiInstall.install_dir, 'bin', 'README.txt'), 0o444),
-                                     call(join(self.desiInstall.install_dir, 'lib', 'python3.10', 'site-packages', 'desiutil-1.2.3.dist-info', 'METADATA'), 0o444),
-                                     call(join(self.desiInstall.install_dir, 'lib', 'python3.10', 'site-packages', 'desiutil-1.2.3.dist-info', 'LICENSE.rst'), 0o444),
-                                     call(join(self.desiInstall.install_dir, 'lib', 'python3.10', 'site-packages', 'desiutil', '__pycache__', '__init__.cpython-3.10.pyc'), 0o444),
-                                     call(join(self.desiInstall.install_dir, 'lib', 'python3.10', 'site-packages', 'desiutil', '__pycache__', 'module.cpython-3.10.pyc'), 0o444),
-                                     call(join(self.desiInstall.install_dir, 'lib', 'python3.10', 'site-packages', 'desiutil', '__init__.py'), 0o444),
-                                     call(join(self.desiInstall.install_dir, 'lib', 'python3.10', 'site-packages', 'desiutil', 'module.py'), 0o444),
-                                     call(join(self.desiInstall.install_dir, 'lib', 'python3.10', 'site-packages', 'desiutil', '__pycache__'), 0o2555),
-                                     call(join(self.desiInstall.install_dir, 'lib', 'python3.10', 'site-packages', 'desiutil-1.2.3.dist-info'), 0o2555),
-                                     call(join(self.desiInstall.install_dir, 'lib', 'python3.10', 'site-packages', 'desiutil'), 0o2555),
-                                     call(join(self.desiInstall.install_dir, 'lib', 'python3.10', 'site-packages'), 0o2555),
-                                     call(join(self.desiInstall.install_dir, 'lib', 'python3.10'), 0o2555),
+                                     call(join(self.desiInstall.install_dir, 'lib', self.pyver, 'site-packages',
+                                               'desiutil-1.2.3.dist-info', 'METADATA'), 0o444),
+                                     call(join(self.desiInstall.install_dir, 'lib', self.pyver, 'site-packages',
+                                               'desiutil-1.2.3.dist-info', 'LICENSE.rst'), 0o444),
+                                     call(join(self.desiInstall.install_dir, 'lib', self.pyver, 'site-packages',
+                                               'desiutil', '__pycache__', f"__init__.{self.cext}"), 0o444),
+                                     call(join(self.desiInstall.install_dir, 'lib', self.pyver, 'site-packages',
+                                               'desiutil', '__pycache__', f"module.{self.cext}"), 0o444),
+                                     call(join(self.desiInstall.install_dir, 'lib', self.pyver, 'site-packages',
+                                               'desiutil', '__init__.py'), 0o444),
+                                     call(join(self.desiInstall.install_dir, 'lib', self.pyver, 'site-packages',
+                                               'desiutil', 'module.py'), 0o444),
+                                     call(join(self.desiInstall.install_dir, 'lib', self.pyver, 'site-packages',
+                                               'desiutil', '__pycache__'), 0o2555),
+                                     call(join(self.desiInstall.install_dir, 'lib', self.pyver, 'site-packages',
+                                               'desiutil-1.2.3.dist-info'), 0o2555),
+                                     call(join(self.desiInstall.install_dir, 'lib', self.pyver, 'site-packages',
+                                               'desiutil'), 0o2555),
+                                     call(join(self.desiInstall.install_dir, 'lib', self.pyver, 'site-packages'), 0o2555),
+                                     call(join(self.desiInstall.install_dir, 'lib', self.pyver), 0o2555),
                                      call(join(self.desiInstall.install_dir, 'bin'), 0o2555),
                                      call(join(self.desiInstall.install_dir, 'lib'), 0o2555),
                                      call(self.desiInstall.install_dir, 0o2555)])
@@ -663,28 +676,41 @@ def test_permissions_with_branch(self, mock_chmod, mock_walk):
         self.desiInstall.install_dir = join(self.data_dir, 'desiutil')
         self.desiInstall.is_branch = True
         mock_walk.return_value = iter([(join(self.desiInstall.install_dir, 'bin'), [], ['executable', 'README.txt']),
-                                       (join(self.desiInstall.install_dir, 'lib', 'python3.10', 'site-packages', 'desiutil-1.2.3.dist-info'), [], ['METADATA', 'LICENSE.rst']),
-                                       (join(self.desiInstall.install_dir, 'lib', 'python3.10', 'site-packages', 'desiutil', '__pycache__'), [], ['__init__.cpython-3.10.pyc', 'module.cpython-3.10.pyc']),
-                                       (join(self.desiInstall.install_dir, 'lib', 'python3.10', 'site-packages', 'desiutil'), ['__pycache__'], ['__init__.py', 'module.py']),
-                                       (join(self.desiInstall.install_dir, 'lib', 'python3.10', 'site-packages'), ['desiutil-1.2.3.dist-info', 'desiutil'], []),
-                                       (join(self.desiInstall.install_dir, 'lib', 'python3.10'), ['site-packages'], []),
-                                       (join(self.desiInstall.install_dir, 'lib'), ['python3.10'], []),
+                                       (join(self.desiInstall.install_dir, 'lib', self.pyver, 'site-packages',
+                                             'desiutil-1.2.3.dist-info'), [], ['METADATA', 'LICENSE.rst']),
+                                       (join(self.desiInstall.install_dir, 'lib', self.pyver, 'site-packages',
+                                             'desiutil', '__pycache__'), [], [f"__init__.{self.cext}", f"module.{self.cext}"]),
+                                       (join(self.desiInstall.install_dir, 'lib', self.pyver, 'site-packages',
+                                             'desiutil'), ['__pycache__'], ['__init__.py', 'module.py']),
+                                       (join(self.desiInstall.install_dir, 'lib', self.pyver, 'site-packages'),
+                                        ['desiutil-1.2.3.dist-info', 'desiutil'], []),
+                                       (join(self.desiInstall.install_dir, 'lib', self.pyver), ['site-packages'], []),
+                                       (join(self.desiInstall.install_dir, 'lib'), [self.pyver], []),
                                        (self.desiInstall.install_dir, ['bin', 'lib'], [])])
         self.desiInstall.permissions()
         mock_walk.assert_called_once_with(self.desiInstall.install_dir, topdown=False)
         mock_chmod.assert_has_calls([call(join(self.desiInstall.install_dir, 'bin', 'executable'), 0o755),
                                      call(join(self.desiInstall.install_dir, 'bin', 'README.txt'), 0o644),
-                                     call(join(self.desiInstall.install_dir, 'lib', 'python3.10', 'site-packages', 'desiutil-1.2.3.dist-info', 'METADATA'), 0o644),
-                                     call(join(self.desiInstall.install_dir, 'lib', 'python3.10', 'site-packages', 'desiutil-1.2.3.dist-info', 'LICENSE.rst'), 0o644),
-                                     call(join(self.desiInstall.install_dir, 'lib', 'python3.10', 'site-packages', 'desiutil', '__pycache__', '__init__.cpython-3.10.pyc'), 0o644),
-                                     call(join(self.desiInstall.install_dir, 'lib', 'python3.10', 'site-packages', 'desiutil', '__pycache__', 'module.cpython-3.10.pyc'), 0o644),
-                                     call(join(self.desiInstall.install_dir, 'lib', 'python3.10', 'site-packages', 'desiutil', '__init__.py'), 0o644),
-                                     call(join(self.desiInstall.install_dir, 'lib', 'python3.10', 'site-packages', 'desiutil', 'module.py'), 0o644),
-                                     call(join(self.desiInstall.install_dir, 'lib', 'python3.10', 'site-packages', 'desiutil', '__pycache__'), 0o2755),
-                                     call(join(self.desiInstall.install_dir, 'lib', 'python3.10', 'site-packages', 'desiutil-1.2.3.dist-info'), 0o2755),
-                                     call(join(self.desiInstall.install_dir, 'lib', 'python3.10', 'site-packages', 'desiutil'), 0o2755),
-                                     call(join(self.desiInstall.install_dir, 'lib', 'python3.10', 'site-packages'), 0o2755),
-                                     call(join(self.desiInstall.install_dir, 'lib', 'python3.10'), 0o2755),
+                                     call(join(self.desiInstall.install_dir, 'lib', self.pyver, 'site-packages',
+                                               'desiutil-1.2.3.dist-info', 'METADATA'), 0o644),
+                                     call(join(self.desiInstall.install_dir, 'lib', self.pyver, 'site-packages',
+                                               'desiutil-1.2.3.dist-info', 'LICENSE.rst'), 0o644),
+                                     call(join(self.desiInstall.install_dir, 'lib', self.pyver, 'site-packages',
+                                               'desiutil', '__pycache__', f"__init__.{self.cext}"), 0o644),
+                                     call(join(self.desiInstall.install_dir, 'lib', self.pyver, 'site-packages',
+                                               'desiutil', '__pycache__', f"module.{self.cext}"), 0o644),
+                                     call(join(self.desiInstall.install_dir, 'lib', self.pyver, 'site-packages',
+                                               'desiutil', '__init__.py'), 0o644),
+                                     call(join(self.desiInstall.install_dir, 'lib', self.pyver, 'site-packages',
+                                               'desiutil', 'module.py'), 0o644),
+                                     call(join(self.desiInstall.install_dir, 'lib', self.pyver, 'site-packages',
+                                               'desiutil', '__pycache__'), 0o2755),
+                                     call(join(self.desiInstall.install_dir, 'lib', self.pyver, 'site-packages',
+                                               'desiutil-1.2.3.dist-info'), 0o2755),
+                                     call(join(self.desiInstall.install_dir, 'lib', self.pyver, 'site-packages',
+                                               'desiutil'), 0o2755),
+                                     call(join(self.desiInstall.install_dir, 'lib', self.pyver, 'site-packages'), 0o2755),
+                                     call(join(self.desiInstall.install_dir, 'lib', self.pyver), 0o2755),
                                      call(join(self.desiInstall.install_dir, 'bin'), 0o2755),
                                      call(join(self.desiInstall.install_dir, 'lib'), 0o2755),
                                      call(self.desiInstall.install_dir, 0o2755)])
@@ -702,28 +728,40 @@ def test_permissions_without_world(self, mock_chmod, mock_walk):
         self.desiInstall.install_dir = join(self.data_dir, 'desiutil')
         self.desiInstall.is_branch = False
         mock_walk.return_value = iter([(join(self.desiInstall.install_dir, 'bin'), [], ['executable', 'README.txt']),
-                                       (join(self.desiInstall.install_dir, 'lib', 'python3.10', 'site-packages', 'desiutil-1.2.3.dist-info'), [], ['METADATA', 'LICENSE.rst']),
-                                       (join(self.desiInstall.install_dir, 'lib', 'python3.10', 'site-packages', 'desiutil', '__pycache__'), [], ['__init__.cpython-3.10.pyc', 'module.cpython-3.10.pyc']),
-                                       (join(self.desiInstall.install_dir, 'lib', 'python3.10', 'site-packages', 'desiutil'), ['__pycache__'], ['__init__.py', 'module.py']),
-                                       (join(self.desiInstall.install_dir, 'lib', 'python3.10', 'site-packages'), ['desiutil-1.2.3.dist-info', 'desiutil'], []),
-                                       (join(self.desiInstall.install_dir, 'lib', 'python3.10'), ['site-packages'], []),
-                                       (join(self.desiInstall.install_dir, 'lib'), ['python3.10'], []),
+                                       (join(self.desiInstall.install_dir, 'lib', self.pyver, 'site-packages',
+                                             'desiutil-1.2.3.dist-info'), [], ['METADATA', 'LICENSE.rst']),
+                                       (join(self.desiInstall.install_dir, 'lib', self.pyver, 'site-packages',
+                                             'desiutil', '__pycache__'), [], [f"__init__.{self.cext}", f"module.{self.cext}"]),
+                                       (join(self.desiInstall.install_dir, 'lib', self.pyver, 'site-packages',
+                                             'desiutil'), ['__pycache__'], ['__init__.py', 'module.py']),
+                                       (join(self.desiInstall.install_dir, 'lib', self.pyver, 'site-packages'), ['desiutil-1.2.3.dist-info', 'desiutil'], []),
+                                       (join(self.desiInstall.install_dir, 'lib', self.pyver), ['site-packages'], []),
+                                       (join(self.desiInstall.install_dir, 'lib'), [self.pyver], []),
                                        (self.desiInstall.install_dir, ['bin', 'lib'], [])])
         self.desiInstall.permissions()
         mock_walk.assert_called_once_with(self.desiInstall.install_dir, topdown=False)
         mock_chmod.assert_has_calls([call(join(self.desiInstall.install_dir, 'bin', 'executable'), 0o550),
                                      call(join(self.desiInstall.install_dir, 'bin', 'README.txt'), 0o440),
-                                     call(join(self.desiInstall.install_dir, 'lib', 'python3.10', 'site-packages', 'desiutil-1.2.3.dist-info', 'METADATA'), 0o440),
-                                     call(join(self.desiInstall.install_dir, 'lib', 'python3.10', 'site-packages', 'desiutil-1.2.3.dist-info', 'LICENSE.rst'), 0o440),
-                                     call(join(self.desiInstall.install_dir, 'lib', 'python3.10', 'site-packages', 'desiutil', '__pycache__', '__init__.cpython-3.10.pyc'), 0o440),
-                                     call(join(self.desiInstall.install_dir, 'lib', 'python3.10', 'site-packages', 'desiutil', '__pycache__', 'module.cpython-3.10.pyc'), 0o440),
-                                     call(join(self.desiInstall.install_dir, 'lib', 'python3.10', 'site-packages', 'desiutil', '__init__.py'), 0o440),
-                                     call(join(self.desiInstall.install_dir, 'lib', 'python3.10', 'site-packages', 'desiutil', 'module.py'), 0o440),
-                                     call(join(self.desiInstall.install_dir, 'lib', 'python3.10', 'site-packages', 'desiutil', '__pycache__'), 0o2550),
-                                     call(join(self.desiInstall.install_dir, 'lib', 'python3.10', 'site-packages', 'desiutil-1.2.3.dist-info'), 0o2550),
-                                     call(join(self.desiInstall.install_dir, 'lib', 'python3.10', 'site-packages', 'desiutil'), 0o2550),
-                                     call(join(self.desiInstall.install_dir, 'lib', 'python3.10', 'site-packages'), 0o2550),
-                                     call(join(self.desiInstall.install_dir, 'lib', 'python3.10'), 0o2550),
+                                     call(join(self.desiInstall.install_dir, 'lib', self.pyver, 'site-packages',
+                                               'desiutil-1.2.3.dist-info', 'METADATA'), 0o440),
+                                     call(join(self.desiInstall.install_dir, 'lib', self.pyver, 'site-packages',
+                                               'desiutil-1.2.3.dist-info', 'LICENSE.rst'), 0o440),
+                                     call(join(self.desiInstall.install_dir, 'lib', self.pyver, 'site-packages',
+                                               'desiutil', '__pycache__', f"__init__.{self.cext}"), 0o440),
+                                     call(join(self.desiInstall.install_dir, 'lib', self.pyver, 'site-packages',
+                                               'desiutil', '__pycache__', f"module.{self.cext}"), 0o440),
+                                     call(join(self.desiInstall.install_dir, 'lib', self.pyver, 'site-packages',
+                                               'desiutil', '__init__.py'), 0o440),
+                                     call(join(self.desiInstall.install_dir, 'lib', self.pyver, 'site-packages',
+                                               'desiutil', 'module.py'), 0o440),
+                                     call(join(self.desiInstall.install_dir, 'lib', self.pyver, 'site-packages',
+                                               'desiutil', '__pycache__'), 0o2550),
+                                     call(join(self.desiInstall.install_dir, 'lib', self.pyver, 'site-packages',
+                                               'desiutil-1.2.3.dist-info'), 0o2550),
+                                     call(join(self.desiInstall.install_dir, 'lib', self.pyver, 'site-packages',
+                                               'desiutil'), 0o2550),
+                                     call(join(self.desiInstall.install_dir, 'lib', self.pyver, 'site-packages'), 0o2550),
+                                     call(join(self.desiInstall.install_dir, 'lib', self.pyver), 0o2550),
                                      call(join(self.desiInstall.install_dir, 'bin'), 0o2550),
                                      call(join(self.desiInstall.install_dir, 'lib'), 0o2550),
                                      call(self.desiInstall.install_dir, 0o2550)])
@@ -741,28 +779,40 @@ def test_permissions_with_branch_without_world(self, mock_chmod, mock_walk):
         self.desiInstall.install_dir = join(self.data_dir, 'desiutil')
         self.desiInstall.is_branch = True
         mock_walk.return_value = iter([(join(self.desiInstall.install_dir, 'bin'), [], ['executable', 'README.txt']),
-                                       (join(self.desiInstall.install_dir, 'lib', 'python3.10', 'site-packages', 'desiutil-1.2.3.dist-info'), [], ['METADATA', 'LICENSE.rst']),
-                                       (join(self.desiInstall.install_dir, 'lib', 'python3.10', 'site-packages', 'desiutil', '__pycache__'), [], ['__init__.cpython-3.10.pyc', 'module.cpython-3.10.pyc']),
-                                       (join(self.desiInstall.install_dir, 'lib', 'python3.10', 'site-packages', 'desiutil'), ['__pycache__'], ['__init__.py', 'module.py']),
-                                       (join(self.desiInstall.install_dir, 'lib', 'python3.10', 'site-packages'), ['desiutil-1.2.3.dist-info', 'desiutil'], []),
-                                       (join(self.desiInstall.install_dir, 'lib', 'python3.10'), ['site-packages'], []),
-                                       (join(self.desiInstall.install_dir, 'lib'), ['python3.10'], []),
+                                       (join(self.desiInstall.install_dir, 'lib', self.pyver, 'site-packages',
+                                             'desiutil-1.2.3.dist-info'), [], ['METADATA', 'LICENSE.rst']),
+                                       (join(self.desiInstall.install_dir, 'lib', self.pyver, 'site-packages',
+                                             'desiutil', '__pycache__'), [], [f"__init__.{self.cext}", f"module.{self.cext}"]),
+                                       (join(self.desiInstall.install_dir, 'lib', self.pyver, 'site-packages',
+                                             'desiutil'), ['__pycache__'], ['__init__.py', 'module.py']),
+                                       (join(self.desiInstall.install_dir, 'lib', self.pyver, 'site-packages'), ['desiutil-1.2.3.dist-info', 'desiutil'], []),
+                                       (join(self.desiInstall.install_dir, 'lib', self.pyver), ['site-packages'], []),
+                                       (join(self.desiInstall.install_dir, 'lib'), [self.pyver], []),
                                        (self.desiInstall.install_dir, ['bin', 'lib'], [])])
         self.desiInstall.permissions()
         mock_walk.assert_called_once_with(self.desiInstall.install_dir, topdown=False)
         mock_chmod.assert_has_calls([call(join(self.desiInstall.install_dir, 'bin', 'executable'), 0o750),
                                      call(join(self.desiInstall.install_dir, 'bin', 'README.txt'), 0o640),
-                                     call(join(self.desiInstall.install_dir, 'lib', 'python3.10', 'site-packages', 'desiutil-1.2.3.dist-info', 'METADATA'), 0o640),
-                                     call(join(self.desiInstall.install_dir, 'lib', 'python3.10', 'site-packages', 'desiutil-1.2.3.dist-info', 'LICENSE.rst'), 0o640),
-                                     call(join(self.desiInstall.install_dir, 'lib', 'python3.10', 'site-packages', 'desiutil', '__pycache__', '__init__.cpython-3.10.pyc'), 0o640),
-                                     call(join(self.desiInstall.install_dir, 'lib', 'python3.10', 'site-packages', 'desiutil', '__pycache__', 'module.cpython-3.10.pyc'), 0o640),
-                                     call(join(self.desiInstall.install_dir, 'lib', 'python3.10', 'site-packages', 'desiutil', '__init__.py'), 0o640),
-                                     call(join(self.desiInstall.install_dir, 'lib', 'python3.10', 'site-packages', 'desiutil', 'module.py'), 0o640),
-                                     call(join(self.desiInstall.install_dir, 'lib', 'python3.10', 'site-packages', 'desiutil', '__pycache__'), 0o2750),
-                                     call(join(self.desiInstall.install_dir, 'lib', 'python3.10', 'site-packages', 'desiutil-1.2.3.dist-info'), 0o2750),
-                                     call(join(self.desiInstall.install_dir, 'lib', 'python3.10', 'site-packages', 'desiutil'), 0o2750),
-                                     call(join(self.desiInstall.install_dir, 'lib', 'python3.10', 'site-packages'), 0o2750),
-                                     call(join(self.desiInstall.install_dir, 'lib', 'python3.10'), 0o2750),
+                                     call(join(self.desiInstall.install_dir, 'lib', self.pyver, 'site-packages',
+                                               'desiutil-1.2.3.dist-info', 'METADATA'), 0o640),
+                                     call(join(self.desiInstall.install_dir, 'lib', self.pyver, 'site-packages',
+                                               'desiutil-1.2.3.dist-info', 'LICENSE.rst'), 0o640),
+                                     call(join(self.desiInstall.install_dir, 'lib', self.pyver, 'site-packages',
+                                               'desiutil', '__pycache__', f"__init__.{self.cext}"), 0o640),
+                                     call(join(self.desiInstall.install_dir, 'lib', self.pyver, 'site-packages',
+                                               'desiutil', '__pycache__', f"module.{self.cext}"), 0o640),
+                                     call(join(self.desiInstall.install_dir, 'lib', self.pyver, 'site-packages',
+                                               'desiutil', '__init__.py'), 0o640),
+                                     call(join(self.desiInstall.install_dir, 'lib', self.pyver, 'site-packages',
+                                               'desiutil', 'module.py'), 0o640),
+                                     call(join(self.desiInstall.install_dir, 'lib', self.pyver, 'site-packages',
+                                               'desiutil', '__pycache__'), 0o2750),
+                                     call(join(self.desiInstall.install_dir, 'lib', self.pyver, 'site-packages',
+                                               'desiutil-1.2.3.dist-info'), 0o2750),
+                                     call(join(self.desiInstall.install_dir, 'lib', self.pyver, 'site-packages',
+                                               'desiutil'), 0o2750),
+                                     call(join(self.desiInstall.install_dir, 'lib', self.pyver, 'site-packages'), 0o2750),
+                                     call(join(self.desiInstall.install_dir, 'lib', self.pyver), 0o2750),
                                      call(join(self.desiInstall.install_dir, 'bin'), 0o2750),
                                      call(join(self.desiInstall.install_dir, 'lib'), 0o2750),
                                      call(self.desiInstall.install_dir, 0o2750)])
diff --git a/py/desiutil/test/test_io.py b/py/desiutil/test/test_io.py
index 180c9f1..170b587 100644
--- a/py/desiutil/test/test_io.py
+++ b/py/desiutil/test/test_io.py
@@ -74,7 +74,8 @@ def test_endecode_table(self):
         with self.assertRaises(UnicodeEncodeError):
             tx = encode_table(data, encoding='ascii')
         with self.assertRaises(UnicodeDecodeError):
-            with self.assertWarnsRegex(UserWarning, r"(?m)data\.metadata\['ENCODING'\]=='utf-8' does not match option 'ascii';\nuse encoding=None to use data\.metadata\['ENCODING'\] instead") as uw:
+            with self.assertWarnsRegex(UserWarning, (r"(?m)data\.metadata\['ENCODING'\]=='utf-8' does not match option 'ascii';\n" +
+                                                     r"use encoding=None to use data\.metadata\['ENCODING'\] instead")) as uw:
                 tx = decode_table(t1, encoding='ascii', native=False)
 
         # Table can specify encoding if option encoding=None
@@ -86,10 +87,12 @@ def test_endecode_table(self):
         self.assertEqual(t2.meta['ENCODING'], 'utf-8')
 
         # conflicting encodings print warning but still proceed
-        with self.assertWarnsRegex(UserWarning, r"(?m)data\.metadata\['ENCODING'\]=='utf-8' does not match option 'ascii';\nuse encoding=None to use data\.metadata\['ENCODING'\] instead") as uw:
+        with self.assertWarnsRegex(UserWarning, (r"(?m)data\.metadata\['ENCODING'\]=='utf-8' does not match option 'ascii';\n" +
+                                                 r"use encoding=None to use data\.metadata\['ENCODING'\] instead")) as uw:
             t1 = encode_table(data, encoding='ascii')
         self.assertEqual(t1.meta['ENCODING'], 'ascii')
-        with self.assertWarnsRegex(UserWarning, r"(?m)data\.metadata\['ENCODING'\]=='ascii' does not match option 'utf-8';\nuse encoding=None to use data\.metadata\['ENCODING'\] instead") as uw:
+        with self.assertWarnsRegex(UserWarning, (r"(?m)data\.metadata\['ENCODING'\]=='ascii' does not match option 'utf-8';\n" +
+                                                 r"use encoding=None to use data\.metadata\['ENCODING'\] instead")) as uw:
             t2 = decode_table(t1, encoding='utf-8', native=False)
         self.assertEqual(t2.meta['ENCODING'], 'utf-8')
 
@@ -125,6 +128,7 @@ def test_yamlify(self):
                  'num2': np.int64(4), 'bool': np.bool_(True),
                  'lst': ['tst2', np.int16(2)],
                  'tup': (1, 3), 'dct': {'a': 'tst3', 'b': np.float32(6.)},
+                 'npstring': np.str_('abcd'),
                  'array': np.zeros(10)}
         self.assertIsInstance(fdict['name'], str)
         # Run
diff --git a/py/desiutil/test/test_modules.py b/py/desiutil/test/test_modules.py
index 4844c19..42f7eea 100644
--- a/py/desiutil/test/test_modules.py
+++ b/py/desiutil/test/test_modules.py
@@ -10,7 +10,7 @@
 from sys import version_info
 from shutil import rmtree
 from tempfile import mkdtemp
-from pkg_resources import resource_filename
+from importlib.resources import files
 from ..modules import (init_modules, configure_module, process_module,
                        default_module, _write_module_data)
 
@@ -162,18 +162,16 @@ def test_configure_module(self):
         """Test detection of directories for module configuration.
         """
         test_dirs = ('bin', 'lib', 'pro', 'py')
-        results = {
-            'name': 'foo',
-            'version': 'bar',
-            'product_root': '/my/product/root',
-            'needs_bin': '',
-            'needs_python': '',
-            'needs_trunk_py': '# ',
-            'trunk_py_dir': '/py',
-            'needs_ld_lib': '',
-            'needs_idl': '',
-            'pyversion': "python{0:d}.{1:d}".format(*version_info)
-            }
+        results = {'name': 'foo',
+                   'version': 'bar',
+                   'product_root': '/my/product/root',
+                   'needs_bin': '',
+                   'needs_python': '',
+                   'needs_trunk_py': '# ',
+                   'trunk_py_dir': '/py',
+                   'needs_ld_lib': '',
+                   'needs_idl': '',
+                   'pyversion': "python{0:d}.{1:d}".format(*version_info)}
         for t in test_dirs:
             mkdir(join(self.data_dir, t))
         conf = configure_module('foo', 'bar', '/my/product/root',
@@ -254,7 +252,7 @@ def test_configure_module(self):
     def test_process_module(self):
         """Test processing of module file templates.
         """
-        module_file = resource_filename('desiutil.test', 't/test.module')
+        module_file = str(files('desiutil.test') / 't' / 'test.module')
         module_keywords = {'name': 'foo', 'version': 'bar'}
         process_module(module_file, module_keywords, self.data_dir)
         self.assertTrue(isdir(join(self.data_dir, 'foo')))
diff --git a/py/desiutil/test/test_names.py b/py/desiutil/test/test_names.py
index 7e6dd14..ec50c13 100644
--- a/py/desiutil/test/test_names.py
+++ b/py/desiutil/test/test_names.py
@@ -22,13 +22,16 @@ def tearDownClass(cls):
     def test_radec_to_desiname(self):
         """Test computation of desiname.
         """
-        ras = [6.2457354547234, 23.914121939862518, 36.23454570972834,
+        ras = [0.003456789, 6.2457354547234, 23.914121939862518, 36.23454570972834, 40.669625, 40.669625,
                235.25235223446, 99.9999999999999]
-        decs = [29.974787585945496, -42.945872347904356, -0.9968423456,
+        decs = [7.434432123, 29.974787585945496, -42.945872347904356, -0.9968423456, -0.013277777, 0.013277777,
                 8.45677345352345, 89.234958294953]
-        correct_names = np.array(['DESI J006.2457+29.9747',
+        correct_names = np.array(['DESI J000.0034+07.4344',
+                                  'DESI J006.2457+29.9747',
                                   'DESI J023.9141-42.9458',
                                   'DESI J036.2345-00.9968',
+                                  'DESI J040.6696-00.0132',
+                                  'DESI J040.6696+00.0132',
                                   'DESI J235.2523+08.4567',
                                   'DESI J099.9999+89.2349'])
         # Test scalar conversion
@@ -38,12 +41,12 @@ def test_radec_to_desiname(self):
 
         # Test list conversion
         outnames = radec_to_desiname(ras, decs)
-        self.assertTrue(np.alltrue(outnames == correct_names))
+        self.assertTrue((outnames == correct_names).all())
 
         # Test array conversion
         outnames = radec_to_desiname(np.array(ras),
                                      np.array(decs))
-        self.assertTrue(np.alltrue(outnames == correct_names))
+        self.assertTrue((outnames == correct_names).all())
 
     def test_radec_to_desiname_bad_values(self):
         """Test exceptions when running radec_to_desiname with bad values.
diff --git a/py/desiutil/test/test_setup.py b/py/desiutil/test/test_setup.py
index bb2f866..4454bb3 100644
--- a/py/desiutil/test/test_setup.py
+++ b/py/desiutil/test/test_setup.py
@@ -8,9 +8,7 @@
 import unittest
 from unittest.mock import call, patch
 from tempfile import mkdtemp
-from packaging import version
-from distutils.log import DEBUG, INFO, WARN, ERROR
-from setuptools import __version__ as setuptools_version
+from distutils.log import INFO, WARN
 from setuptools import sandbox
 from ..setup import find_version_directory, get_version, update_version
 from .. import __version__ as desiutil_version
@@ -69,7 +67,7 @@ def test_version(self):
         os.mkdir(os.path.join(package_dir, '.git'))
         setup = """#!/usr/bin/env python
 from setuptools import setup
-from desiutil.setup import DesiModule, DesiTest, DesiVersion, get_version
+from desiutil.setup import DesiModule, DesiVersion, get_version
 CMDCLASS = {{'version': DesiVersion}}
 VERSION = get_version("{0.fake_name}")
 setup(name="{0.fake_name}",
diff --git a/py/desiutil/test/test_sklearn.py b/py/desiutil/test/test_sklearn.py
index 911560a..11e4eef 100644
--- a/py/desiutil/test/test_sklearn.py
+++ b/py/desiutil/test/test_sklearn.py
@@ -4,7 +4,7 @@
 """
 import unittest
 from tempfile import NamedTemporaryFile
-from pkg_resources import resource_filename
+from importlib.resources import files
 import numpy as np
 from astropy.io import fits
 from ..sklearn import GaussianMixtureModel as GMM
@@ -16,7 +16,7 @@ class TestSKLearn(unittest.TestCase):
 
     @classmethod
     def setUpClass(cls):
-        cls.data = resource_filename('desiutil.test', 't/qso_gmm.fits')
+        cls.data = str(files('desiutil.test') / 't' / 'qso_gmm.fits')
         cls.weights = np.array([0.09399423, 0.02817785, 0.16842868,
                                 0.25130015, 0.18764636, 0.22785686,
                                 0.01427254, 0.02832333])
diff --git a/py/desiutil/test/test_svn.py b/py/desiutil/test/test_svn.py
index 0b58264..2b7f567 100644
--- a/py/desiutil/test/test_svn.py
+++ b/py/desiutil/test/test_svn.py
@@ -4,7 +4,6 @@
 """
 import unittest
 from unittest.mock import call, DEFAULT, patch, Mock, PropertyMock
-# from pkg_resources import resource_filename
 from ..svn import last_revision, last_tag, version
 
 
diff --git a/py/desiutil/test/test_top_level.py b/py/desiutil/test/test_top_level.py
index 3d142b7..ea1ae39 100644
--- a/py/desiutil/test/test_top_level.py
+++ b/py/desiutil/test/test_top_level.py
@@ -14,8 +14,7 @@ class TestTopLevel(unittest.TestCase):
 
     @classmethod
     def setUpClass(cls):
-        cls.versionre = re.compile(
-                r'([0-9]+!)?([0-9]+)(\.[0-9]+)*((a|b|rc|\.post|\.dev)[0-9]+)?')
+        cls.versionre = re.compile(r'([0-9]+!)?([0-9]+)(\.[0-9]+)*((a|b|rc|\.post|\.dev)[0-9]+)?')
 
     @classmethod
     def tearDownClass(cls):