Repository: Small-Bodies-Node/pds4_tools

Issue:
`numpy_to_pds_type` error with Numpy 2.0
```
../../.venv/lib/python3.10/site-packages/pds4_tools/reader/data_types.py:326: in numpy_to_pds_type
    elif np.issubdtype(dtype, np.string_):
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

attr = 'string_'

    def __getattr__(attr):
        # Warn for expired attributes
...
        if attr in __expired_attributes__:
>           raise AttributeError(
                f"`np.{attr}` was removed in the NumPy 2.0 release. "
                f"{__expired_attributes__[attr]}",
                name=None
            )
E           AttributeError: `np.string_` was removed in the NumPy 2.0 release. Use `np.bytes_` instead.

../../.venv/lib/python3.10/site-packages/numpy/__init__.py:400: AttributeError
```

I am using pds4_tools 1.4 and numpy 2.2.4

Discussion:
@LevN0: I see the affected method, `reader.data_types.numpy_to_pds_type` but I do not see anything ever calling it, which is why I think it was not caught by the test suite.

Are you calling the method as part of your own code?@mmebsout: Yes I am :) @LevN0: Thank you for reporting. A fix will be in the next release.

Solution patch:
diff --git a/pds4_tools/reader/data_types.py b/pds4_tools/reader/data_types.py
index 8c44f1b..9bf1cbb 100644
--- a/pds4_tools/reader/data_types.py
+++ b/pds4_tools/reader/data_types.py
@@ -310,8 +310,9 @@ def numpy_to_pds_type(dtype, ascii_numerics=False):
     dtype : np.dtype
         A NumPy data type.
     ascii_numerics
-        If True, the returned PDS4 data type will be an ASCII numeric type if the input dtype is numeric
-        or boolean. If False, the returned PDS4 data type will be a binary type. Defaults to False.
+        If True, the returned PDS4 data type will be an ASCII numeric or boolean type if the input dtype
+        is numeric or boolean. If False, the returned PDS4 data type will be a binary type. Defaults to
+        False.
 
     Returns
     -------
@@ -319,44 +320,52 @@ def numpy_to_pds_type(dtype, ascii_numerics=False):
         A PDS4 data type that could plausibly (see description above) correspond to the input dtype.
     """
 
+    # Ensure *dtype* is a NumPy dtype
+    dtype = np.dtype(dtype)
+
     # For string dtypes
     if np.issubdtype(dtype, np_unicode):
         data_type = 'UTF8_String'
 
-    elif np.issubdtype(dtype, np.string_):
+    elif np.issubdtype(dtype, np.bytes_):
         data_type = 'ASCII_String'
 
     # For datetime dtypes
     elif np.issubdtype(dtype, np.datetime64):
-        data_type = 'ASCII_Date_Time_YMD'
+
+        if dtype.name == PDS4_DATE_TYPES['ASCII_Date_YMD'][1]:
+            data_type = 'ASCII_Date_YMD'
+        else:
+            data_type = 'ASCII_Date_Time_YMD'
 
     # For numeric dtypes
     else:
 
-        # Get numeric ASCII types. We obtain these from builtin portion because if we attempt to match
-        # e.g. 'int16' to 'int64' it would fail but for ASCII types this should succeed.
-        ascii_types = dict((value[2], key)
-                           for key, value in six.iteritems(PDS_NUMERIC_TYPES)
-                           if ('ASCII' in key) and ('Numeric_Base' not in key))
+        # Get numeric ASCII types
+        # (compare via np.dtype.kind because kind is unique for each ASCII type supported here)
+        if ascii_numerics:
 
-        # Get numeric non-ASCII types, including the correct endianness.
-        non_ascii_types = dict((np.dtype(value[1]).newbyteorder(value[0]), key)
+            ascii_types = dict((np.dtype(value[1]).kind, key)
                                for key, value in six.iteritems(PDS_NUMERIC_TYPES)
-                               if ('ASCII' not in key) and ('Numeric_Base' not in key))
+                               if ('ASCII' in key) and ('Numeric_Base' not in key))
 
-        if ascii_numerics:
-
-            builtin_type = type(np.asscalar(np.array(0, dtype=dtype))).__name__
-            data_type = ascii_types.get(builtin_type, None)
+            data_type = ascii_types.get(dtype.kind, None)
 
+        # Get numeric non-ASCII types, including the correct endianness
+        # (compare via full np.dtype)
         else:
+
+            non_ascii_types = dict((np.dtype(value[1]).newbyteorder(value[0]), key)
+                                   for key, value in six.iteritems(PDS_NUMERIC_TYPES)
+                                   if ('ASCII' not in key) and ('Numeric_Base' not in key))
+
             data_type = non_ascii_types.get(dtype, None)
 
     # Raise error if we were unable to find a match
     if data_type is None:
 
         raise ValueError("Unable to convert NumPy data type, '{0}', to a PDS4 {1} data type.".
-                         format(dtype, 'ASCII' if ascii_numerics else 'binary'))
+                         format(dtype.name, 'ASCII' if ascii_numerics else 'binary'))
 
     return PDSdtype(data_type)
 


Test patch:
diff --git a/pds4_tools/tests/compat.py b/pds4_tools/tests/compat.py
index 8359699..c87f409 100644
--- a/pds4_tools/tests/compat.py
+++ b/pds4_tools/tests/compat.py
@@ -6,6 +6,16 @@ from __future__ import unicode_literals
 import sys
 import xml.etree.ElementTree as ET
 
+import numpy as np
+
+
 PY26 = sys.version_info[0:2] == (2, 6)
 
+# ElementTree compat (Python 2.7+ and 3.3+)
 ET_Element = ET._Element if PY26 else ET.Element
+
+# NumPy compat (NumPy 2.0+)
+try:
+    np_unicode = np.unicode_
+except AttributeError:
+    np_unicode = np.str_
diff --git a/pds4_tools/tests/test_core.py b/pds4_tools/tests/test_core.py
index d406432..9808f6a 100644
--- a/pds4_tools/tests/test_core.py
+++ b/pds4_tools/tests/test_core.py
@@ -13,7 +13,7 @@ from . import PDS4ToolsTestCase
 
 from pds4_tools import pds4_read
 from pds4_tools.reader.data import PDS_ndarray, PDS_marray
-from pds4_tools.reader.data_types import data_type_convert_dates
+from pds4_tools.reader.data_types import data_type_convert_dates, numpy_to_pds_type, PDSdtype
 from pds4_tools.reader.array_objects import ArrayStructure
 from pds4_tools.reader.table_objects import TableStructure, TableManifest
 from pds4_tools.reader.label_objects import Label
@@ -1463,6 +1463,43 @@ class TestDownloadFile(PDS4ToolsTestCase):
         assert xml_equal(structures_web2.label, structures_local.label)
 
 
+class TestLibraryFunctions(PDS4ToolsTestCase):
+
+    def test_numpy_to_pds_type(self):
+
+        # Test strings
+        assert PDSdtype('UTF8_String') == numpy_to_pds_type(compat.np_unicode)
+        assert PDSdtype('ASCII_String') == numpy_to_pds_type(np.bytes_)
+
+        # Test bool
+        assert PDSdtype('ASCII_Boolean') == numpy_to_pds_type(np.bool_, ascii_numerics=True)
+
+        # Test ASCII numbers
+        assert PDSdtype('ASCII_Real') == numpy_to_pds_type(np.float32, ascii_numerics=True)
+        assert PDSdtype('ASCII_Integer') == numpy_to_pds_type(np.int8, ascii_numerics=True)
+        assert PDSdtype('ASCII_NonNegative_Integer') == numpy_to_pds_type(np.uint32, ascii_numerics=True)
+
+        # Test binary numbers
+        np_lsb_float64 = np.dtype(np.float64).newbyteorder('<')
+        np_int8 = np.dtype(np.int8)
+        np_lsb_uint64 = np.dtype(np.uint64).newbyteorder('<')
+        np_msb_int32 = np.dtype(np.int32).newbyteorder('>')
+
+        assert PDSdtype('IEEE754LSBDouble') == numpy_to_pds_type(np_lsb_float64, ascii_numerics=False)
+        assert PDSdtype('SignedByte') == numpy_to_pds_type(np_int8, ascii_numerics=False)
+        assert PDSdtype('UnsignedLSB8') == numpy_to_pds_type(np_lsb_uint64, ascii_numerics=False)
+        assert PDSdtype('SignedMSB4') == numpy_to_pds_type(np_msb_int32, ascii_numerics=False)
+
+        # Test dates
+        np_date = np.datetime64("2000-01-01").dtype
+        np_specific_datetime = np.datetime64("2000-01-01 00:00").dtype
+        np_generic_datetime = np.datetime64
+
+        assert PDSdtype('ASCII_Date_YMD') == numpy_to_pds_type(np_date)
+        assert PDSdtype('ASCII_Date_Time_YMD') == numpy_to_pds_type(np_specific_datetime)
+        assert PDSdtype('ASCII_Date_Time_YMD') == numpy_to_pds_type(np_generic_datetime)
+
+
 class TestDeprecation(PDS4ToolsTestCase):
 
     def test_deprecated(self):