Repository: desihub/desiutil

Issue:
maskbit failure with numpy.bool_
The `_MaskBit.__ufunc_array__` changes in PR #219 to support numpy 2 appear to have broken desitarget tests, which are now failing with
```
py/desitarget/test/test_cuts.py:257: in _test_table_row
    desi, bgs, mws = cuts.apply_cuts(targets[0], tcnames=tc)
py/desitarget/cuts.py:2962: in apply_cuts
    desi_target, bgs_target, mws_target = targcuts.set_target_bits(
...
        # Construct the targetflag bits for DECaLS (i.e. South).
>       desi_target = (lrg_south & res_south) * desi_mask.LRG_SOUTH
E       TypeError: operand type(s) all returned NotImplemented from __array_ufunc__(<ufunc 'multiply'>, '__call__', False, 65536): 'bool_', '_MaskBit'

py/desitarget/cuts.py:2663: TypeError
```
This can be reproduced by running `pytest py/desitarget/test/test_cuts.py` in a desitarget checkout with the latest desiutil.

In this case `(lrg_south & res_south)` evaluates to a `np.bool_`, which triggers the incompatibility with `_MaskBit`; a normal bool is fine:
```
import numpy as np
from desitarget.targetmask import desi_mask
print(f'{type(desi_mask.LRG_SOUTH)=}')
b1 = True
b2 = np.bool_(b1)
print(f'Regular bool {b1 * desi_mask.LRG_SOUTH=}')
print(f'numpy.bool_ {b2 * desi_mask.LRG_SOUTH=}')
```
Results:
```
type(desi_mask.LRG_SOUTH)=<class 'desiutil.bitmask._MaskBit'>
Regular bool b1 * desi_mask.LRG_SOUTH=65536
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[11], line 7
      5 b2 = np.bool_(b1)
      6 print(f'Regular bool {b1 * desi_mask.LRG_SOUTH=}')
----> 7 print(f'numpy.bool_ {b2 * desi_mask.LRG_SOUTH=}')

TypeError: operand type(s) all returned NotImplemented from __array_ufunc__(<ufunc 'multiply'>, '__call__', True, 65536): 'bool_', '_MaskBit'
```

@weaverba137 please investigate an update for `_MaskBit` that would support this case.

@geordie666 heads up, desitarget is broken with desiutil/main and desiutil/3.5.1, but it will works with tag desiutil/3.5.0 and prior.

Hints from issue comments:
@weaverba137: Honestly, this looks like a very sketchy mathematical operation to me. Could you please provide some context for why this operation is written the way it is?@sbailey: I'll defer to @geordie666 re: the original code, though he might be on a plane right now.  I agree that the form is somewhat unusual, but I suspect that
```python
desi_target = (lrg_south & res_south) * desi_mask.LRG_SOUTH
```
is a one-liner implementation of the equivalent
```python
if lrg_south & res_south:
    desi_target = desi_mask.LRG_SOUTH
else:
    desi_target = 0
```@weaverba137: OK, thanx. I think the solution is actually fairly easy, so I'm working on it.

Solution patch:
diff --git a/doc/changes.rst b/doc/changes.rst
index d260df4..11ce1ec 100644
--- a/doc/changes.rst
+++ b/doc/changes.rst
@@ -14,7 +14,12 @@ Change Log
 3.5.2 (unreleased)
 ------------------
 
-* No changes yet.
+* Fix operations involving :class:`desiutil.bitmask._MaskBit` and `NumPy scalars`_;
+  remove long-deprecated import of ``setuptools.sandbox`` and associated test code
+  (PR `#221`_).
+
+.. _`#221`: https://github.com/desihub/desiutil/pull/221
+.. _`NumPy scalars`: https://numpy.org/doc/stable/reference/arrays.scalars.html
 
 3.5.1 (2025-04-25)
 ------------------
diff --git a/py/desiutil/bitmask.py b/py/desiutil/bitmask.py
index b2530a6..8c25876 100644
--- a/py/desiutil/bitmask.py
+++ b/py/desiutil/bitmask.py
@@ -135,11 +135,14 @@ def __array_ufunc__(self, ufunc, method, *inputs, **kwargs):
         """
         #
         # Filter inputs and replace any _MaskBit with a Python int.
+        # np.generic is the superclass of all NumPy scalars.
         #
         args = []
         for input_ in inputs:
             if isinstance(input_, _MaskBit):
                 args.append(input_.mask)
+            elif isinstance(input_, np.generic):
+                args.append(np.asarray(input_))
             else:
                 args.append(input_)
         #


Test patch:
diff --git a/py/desiutil/test/test_bitmask.py b/py/desiutil/test/test_bitmask.py
index d8c29f1..95065c9 100644
--- a/py/desiutil/test/test_bitmask.py
+++ b/py/desiutil/test/test_bitmask.py
@@ -7,6 +7,7 @@
 import yaml
 import numpy as np
 
+
 _bitdefyaml = """\
 ccdmask:
   - [BAD,              0, "Pre-determined bad pixel (any reason)"]
@@ -224,3 +225,20 @@ def test_int_behavior(self):
         """Test behavior of _MaskBit when explicitly casting to int.
         """
         self.assertEqual(self.ccdmask.HOT.mask, int(self.ccdmask.HOT))
+
+    def test_numpy_scalar(self):
+        """Test behavior of _MaskBit when other operand is a numpy scalar.
+        """
+        for b in (False, True):
+            for t in (np.bool_, np.int16, np.int32, np.int64, np.uint16, np.uint32, np.uint64):
+                result = t(b) * self.ccdmask.COSMIC
+                self.assertEqual(result, int(b) * self.ccdmask.COSMIC)
+                if t is np.bool_:
+                    self.assertIs(type(result), np.int64)
+                elif np.__version__[0] == '1':
+                    if t is np.uint64:
+                        self.assertIs(type(result), np.float64)
+                    else:
+                        self.assertIs(type(result), np.int64)
+                else:
+                    self.assertIs(type(result), t)
diff --git a/py/desiutil/test/test_setup.py b/py/desiutil/test/test_setup.py
index 4454bb3..dd1026e 100644
--- a/py/desiutil/test/test_setup.py
+++ b/py/desiutil/test/test_setup.py
@@ -9,7 +9,6 @@
 from unittest.mock import call, patch
 from tempfile import mkdtemp
 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
 
@@ -22,9 +21,6 @@ class TestSetup(unittest.TestCase):
     def setUpClass(cls):
         cls.fake_name = 'frobulate'
         cls.original_dir = os.getcwd()
-        # Workaround for https://github.com/astropy/astropy-helpers/issues/124
-        if hasattr(sandbox, 'hide_setuptools'):
-            sandbox.hide_setuptools = lambda: None
 
     @classmethod
     def tearDownClass(cls):
@@ -45,70 +41,6 @@ def tearDown(self):
         os.chdir(self.original_dir)
         shutil.rmtree(self.setup_dir, ignore_errors=True)
 
-    def run_setup(self, *args, **kwargs):
-        """In Python 3, on MacOS X, the import cache has to be invalidated
-        otherwise new extensions built with ``run_setup`` do not always get
-        picked up.
-        """
-        try:
-            return sandbox.run_setup(*args, **kwargs)
-        finally:
-            import importlib
-            importlib.invalidate_caches()
-
-    def test_version(self):
-        """Test python setup.py version.
-        """
-        path_index = int(sys.path[0] == '')
-        sys.path.insert(path_index, os.path.abspath('./py'))
-        package_dir = os.path.join(self.setup_dir, self.fake_name)
-        os.mkdir(package_dir)
-        os.mkdir(os.path.join(package_dir, self.fake_name))
-        os.mkdir(os.path.join(package_dir, '.git'))
-        setup = """#!/usr/bin/env python
-from setuptools import setup
-from desiutil.setup import DesiModule, DesiVersion, get_version
-CMDCLASS = {{'version': DesiVersion}}
-VERSION = get_version("{0.fake_name}")
-setup(name="{0.fake_name}",
-    version=VERSION,
-    packages=["{0.fake_name}"],
-    cmdclass=CMDCLASS,
-    zip_safe=False)
-""".format(self)
-        with open(os.path.join(package_dir, 'setup.py'), 'w') as s:
-            s.write(setup)
-        init = """from ._version import __version__
-"""
-        with open(os.path.join(package_dir, self.fake_name,
-                               '__init__.py'), 'w') as i:
-            i.write(init)
-        os.chdir(package_dir)
-        v_file = os.path.join(package_dir, self.fake_name, '_version.py')
-        with patch('distutils.cmd.Command.announce') as mock_announce:
-            with patch('distutils.log.info') as mock_info:
-                self.run_setup('setup.py', ['version'])
-                self.assertTrue(os.path.exists(v_file))
-                self.assertListEqual(mock_announce.mock_calls,
-                                     [call('WARNING: This functionality is deprecated and will be removed from a future version of desiutil.', level=WARN),
-                                      call('WARNING: Use the command-line script desi_update_version instead.', level=WARN),
-                                      call('Version is now 0.0.1.dev0.', level=INFO)])
-                # self.assertListEqual(mock_info.mock_calls, [call('running %s', 'version')])
-        with patch('distutils.cmd.Command.announce') as mock_announce:
-            with patch('distutils.log.info') as mock_info:
-                self.run_setup('setup.py', ['version', '--tag', '1.2.3'])
-                with open(v_file) as v:
-                    data = v.read()
-                self.assertEqual(data, "__version__ = '1.2.3'\n")
-                self.assertListEqual(mock_announce.mock_calls,
-                                     [call('WARNING: This functionality is deprecated and will be removed from a future version of desiutil.', level=WARN),
-                                      call('WARNING: Use the command-line script desi_update_version instead.', level=WARN),
-                                      call('Version is now 1.2.3.', level=INFO)])
-                # self.assertListEqual(mock_info.mock_calls, [call('running %s', 'version')])
-
-        os.chdir(self.original_dir)
-        del sys.path[path_index]
-
     def test_find_version_directory(self):
         """Test the search for a _version.py file.
         """