Repository: ramonhagenaars/nptyping

Issue:
Add support for structured types and structured arrays
I've found out that defining an `NDArray` like:

```python
from nptyping import NDArray
from typing import Any
import numpy as np

NDArray[(Any, ...), np.dtype([('x',int), ('y', int, 4)]))
```
results in an error.

It would be nice to have full support for structured types. I have an implementation that I will link here with a PR, I'm happy to discuss further.

Discussion:
codecov-io: # [Codecov](https://codecov.io/gh/ramonhagenaars/nptyping/pull/40?src=pr&el=h1) Report
> Merging [#40](https://codecov.io/gh/ramonhagenaars/nptyping/pull/40?src=pr&el=desc) (4855527) into [master](https://codecov.io/gh/ramonhagenaars/nptyping/commit/c813f6d235ed9eab0070bc51340ae0c58750f73e?el=desc) (c813f6d) will **decrease** coverage by `42.67%`.
> The diff coverage is `47.72%`.

[![Impacted file tree graph](https://codecov.io/gh/ramonhagenaars/nptyping/pull/40/graphs/tree.svg?width=650&height=150&src=pr&token=cXWtoZhIaH)](https://codecov.io/gh/ramonhagenaars/nptyping/pull/40?src=pr&el=tree)

```diff
@@             Coverage Diff              @@
##            master      #40       +/-   ##
============================================
- Coverage   100.00%   57.32%   -42.68%     
============================================
  Files           14       16        +2     
  Lines          393      478       +85     
============================================
- Hits           393      274      -119     
- Misses           0      204      +204     
```


| [Impacted Files](https://codecov.io/gh/ramonhagenaars/nptyping/pull/40?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [nptyping/\_\_init\_\_.py](https://codecov.io/gh/ramonhagenaars/nptyping/pull/40/diff?src=pr&el=tree#diff-bnB0eXBpbmcvX19pbml0X18ucHk=) | `14.28% <0.00%> (-85.72%)` | :arrow_down: |
| [nptyping/functions/\_get\_type.py](https://codecov.io/gh/ramonhagenaars/nptyping/pull/40/diff?src=pr&el=tree#diff-bnB0eXBpbmcvZnVuY3Rpb25zL19nZXRfdHlwZS5weQ==) | `47.69% <33.33%> (-52.31%)` | :arrow_down: |
| [nptyping/types/\_subarray\_type.py](https://codecov.io/gh/ramonhagenaars/nptyping/pull/40/diff?src=pr&el=tree#diff-bnB0eXBpbmcvdHlwZXMvX3N1YmFycmF5X3R5cGUucHk=) | `50.00% <50.00%> (ø)` | |
| [nptyping/types/\_structured\_type.py](https://codecov.io/gh/ramonhagenaars/nptyping/pull/40/diff?src=pr&el=tree#diff-bnB0eXBpbmcvdHlwZXMvX3N0cnVjdHVyZWRfdHlwZS5weQ==) | `51.35% <51.35%> (ø)` | |
| [nptyping/\_meta.py](https://codecov.io/gh/ramonhagenaars/nptyping/pull/40/diff?src=pr&el=tree#diff-bnB0eXBpbmcvX21ldGEucHk=) | `100.00% <100.00%> (ø)` | |
| [nptyping/types/\_nptype.py](https://codecov.io/gh/ramonhagenaars/nptyping/pull/40/diff?src=pr&el=tree#diff-bnB0eXBpbmcvdHlwZXMvX25wdHlwZS5weQ==) | `54.54% <0.00%> (-45.46%)` | :arrow_down: |
| [nptyping/types/\_ndarray\_meta.py](https://codecov.io/gh/ramonhagenaars/nptyping/pull/40/diff?src=pr&el=tree#diff-bnB0eXBpbmcvdHlwZXMvX25kYXJyYXlfbWV0YS5weQ==) | `57.69% <0.00%> (-42.31%)` | :arrow_down: |
| [nptyping/types/\_unicode.py](https://codecov.io/gh/ramonhagenaars/nptyping/pull/40/diff?src=pr&el=tree#diff-bnB0eXBpbmcvdHlwZXMvX3VuaWNvZGUucHk=) | `58.06% <0.00%> (-41.94%)` | :arrow_down: |
| ... and [10 more](https://codecov.io/gh/ramonhagenaars/nptyping/pull/40/diff?src=pr&el=tree-more) | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/ramonhagenaars/nptyping/pull/40?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/ramonhagenaars/nptyping/pull/40?src=pr&el=footer). Last update [c813f6d...4855527](https://codecov.io/gh/ramonhagenaars/nptyping/pull/40?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).

lyckantropen: Will address coverage issues now.

Patch:
diff --git a/README.md b/README.md
index ca6cb15..2ff39da 100644
--- a/README.md
+++ b/README.md
@@ -132,6 +132,15 @@ NDArray[(3, 3, 5), Int[32]]
 
 ```
 
+A structured array:
+```python
+>>> import numpy as np
+
+>>> NDArray[(Any,...), np.dtype([('x',np.int32), ('y',np.int32)])]
+NDArray[(typing.Any, ...), StructuredType[Int[32],Int[32]]]
+
+```
+
 #### (❒) Checking your instances
 You can use `NDArray` with `isinstance` to dynamically check your arrays.
 
@@ -275,6 +284,28 @@ Object
 
 ```
 
+### (❒) StructuredType
+An nptyping equivalent of numpy structured dtypes.
+
+```python
+>>> from nptyping import StructuredType, Int
+
+>>> StructuredType[Int[32], Int[32]]
+StructuredType[Int[32],Int[32]]
+
+```
+
+### (❒) SubArrayType
+An nptyping equivalent of numpy subarray dtypes.
+
+```python
+>>> from nptyping import SubArrayType, Int
+
+>>> SubArrayType[Int[16], (4,2)]
+SubArrayType[Int[16], (4, 2)]
+
+```
+
 ### (❒) get_type
 With `get_type` you can get `nptyping` equivalent types for your arguments:
 
@@ -285,6 +316,8 @@ With `get_type` you can get `nptyping` equivalent types for your arguments:
 Int[32]
 >>> get_type('some string')
 Unicode[11]
+>>> get_type(np.dtype([('x',np.int32), ('y',np.int32)]))
+StructuredType[Int[32],Int[32]]
 
 ```
 
diff --git a/nptyping/__init__.py b/nptyping/__init__.py
index e15aaac..ba09683 100644
--- a/nptyping/__init__.py
+++ b/nptyping/__init__.py
@@ -26,5 +26,7 @@ from nptyping.types._number import (
     Float64,
 )
 from nptyping.types._object import Object
+from nptyping.types._subarray_type import SubArrayType
+from nptyping.types._structured_type import StructuredType
 from nptyping.types._timedelta64 import Timedelta64
 from nptyping.types._unicode import Unicode
diff --git a/nptyping/functions/_get_type.py b/nptyping/functions/_get_type.py
index a1286a4..28dac84 100644
--- a/nptyping/functions/_get_type.py
+++ b/nptyping/functions/_get_type.py
@@ -19,6 +19,8 @@ from nptyping.types._number import (
     DEFAULT_FLOAT_BITS,
 )
 from nptyping.types._object import Object
+from nptyping.types._subarray_type import SubArrayType, is_subarray_type
+from nptyping.types._structured_type import StructuredType, is_structured_type
 from nptyping.types._timedelta64 import Timedelta64
 from nptyping.types._unicode import Unicode
 
@@ -43,6 +45,10 @@ def _get_type_type(type_: type) -> Type['NPType']:
 
 
 def _get_type_dtype(dtype: numpy.dtype) -> Type['NPType']:
+    if is_subarray_type(dtype):
+        return get_subarray_type(dtype)
+    if is_structured_type(dtype):
+        return get_structured_type(dtype)
     # Return the nptyping type of a numpy dtype.
     np_type_per_py_type = {
         type: _get_type_type,
@@ -184,6 +190,26 @@ def get_type_complex(_: Any) -> Type[Complex128]:
     return Complex128
 
 
+# Library private.
+def get_structured_type(dtype: numpy.dtype) -> Type[StructuredType]:
+    """
+    Return the NPType that corresponds to dtype of a structured array.
+    :param dtype: a dtype of a structured NumPy array
+    :return: a StructuredType type.
+    """
+    return StructuredType[dtype]
+
+
+# Library private.
+def get_subarray_type(dtype: numpy.dtype) -> Type[SubArrayType]:
+    """
+    Return the NPType that corresponds to dtype of a subarray.
+    :param dtype: a dtype of a NumPy subarray
+    :return: a SubArrayType type.
+    """
+    return SubArrayType[dtype]
+
+
 _delegates = [
     (NPType, lambda x: x),
     (type, _get_type_type),
diff --git a/nptyping/types/_structured_type.py b/nptyping/types/_structured_type.py
new file mode 100644
index 0000000..92b0bad
--- /dev/null
+++ b/nptyping/types/_structured_type.py
@@ -0,0 +1,61 @@
+from typing import Any
+
+import numpy as np
+from typish import SubscriptableType
+
+from nptyping.types._nptype import NPType
+
+
+def is_structured_type(dtype: np.dtype) -> bool:
+    """Detect if the type is a structured array type."""
+    return hasattr(dtype, 'fields') and dtype.fields is not None
+
+
+class _StructuredTypeMeta(SubscriptableType):
+    def __hash__(cls) -> int:
+        return hash(cls.fields)
+
+    def __repr__(cls) -> str:
+        if cls.fields is not None:
+            field_strs = ','.join([str(f) for f in cls.fields])
+            return 'StructuredType[{}]'.format(field_strs)
+        return 'StructuredType'
+
+    def __eq__(cls, instance: Any) -> bool:
+        if hasattr(instance, 'fields'):
+            return (instance.fields == cls.fields
+                    or tuple(instance.fields) == tuple(cls.fields))
+        return False
+
+    def __instancecheck__(cls, instance: Any) -> bool:
+        from nptyping.functions._get_type import get_type
+
+        if is_structured_type(instance):
+            if cls == instance:
+                return True
+            return cls == get_type(instance)
+        return False
+
+    __str__ = __repr__
+    __subclasscheck__ = __eq__
+
+
+class StructuredType(NPType, metaclass=_StructuredTypeMeta):
+    """
+    Corresponds to the dtype of a structured NumPy array.
+    """
+    fields = None
+
+    @classmethod
+    def _after_subscription(cls, args: Any) -> None:
+        from nptyping.functions._get_type import get_type
+
+        if isinstance(args, np.dtype):
+            cls.fields = tuple(get_type(args.fields[n][0]) for n in args.names)
+        elif isinstance(args, tuple):
+            cls.fields = tuple(get_type(t) for t in args)
+        elif isinstance(args, type):
+            cls.fields = (get_type(args),)
+        else:
+            raise Exception(
+                'Incompatible arguments to StructuredType: {}'.format(args))
diff --git a/nptyping/types/_subarray_type.py b/nptyping/types/_subarray_type.py
new file mode 100644
index 0000000..66d25ef
--- /dev/null
+++ b/nptyping/types/_subarray_type.py
@@ -0,0 +1,61 @@
+from typing import Any
+
+import numpy as np
+from typish import SubscriptableType
+
+from nptyping.types._nptype import NPType
+
+
+def is_subarray_type(dtype: np.dtype) -> bool:
+    """Detect if the type is a subarray."""
+    return (hasattr(dtype, 'shape')
+            and isinstance(dtype.shape, tuple)
+            and len(dtype.shape) != 0)
+
+
+class _SubArrayTypeMeta(SubscriptableType):
+    def __hash__(cls) -> int:
+        return hash((cls.base, cls.shape))
+
+    def __repr__(cls) -> str:
+        if cls.base is not None:
+            return 'SubArrayType[{0}, {1}]'.format(cls.base, cls.shape)
+        return 'SubArrayType'
+
+    def __eq__(cls, instance: Any) -> bool:
+        if hasattr(instance, 'base') and hasattr(instance, 'shape'):
+            return instance.base == cls.base and instance.shape == cls.shape
+        return False
+
+    def __instancecheck__(cls, instance: Any) -> bool:
+        from nptyping.functions._get_type import get_type
+
+        if is_subarray_type(instance):
+            if cls == instance:
+                return True
+            return cls == get_type(instance)
+        return False
+
+    __str__ = __repr__
+    __subclasscheck__ = __eq__
+
+
+class SubArrayType(NPType, metaclass=_SubArrayTypeMeta):
+    """
+    Corresponds to the dtype of a NumPy subarray.
+    """
+    base = None
+    shape = None
+
+    @classmethod
+    def _after_subscription(cls, args: Any) -> None:
+        from nptyping.functions._get_type import get_type
+
+        if isinstance(args, np.dtype):
+            cls.base = get_type(args.base)
+            cls.shape = args.shape
+        elif isinstance(args, tuple):
+            cls.base, cls.shape = args
+        else:
+            raise Exception(
+                'Incompatible arguments to SubArrayType: {}'.format(args))


Test patch:
diff --git a/tests/test_functions/test_get_type.py b/tests/test_functions/test_get_type.py
index f040e70..f32630b 100644
--- a/tests/test_functions/test_get_type.py
+++ b/tests/test_functions/test_get_type.py
@@ -24,6 +24,8 @@ from nptyping import (
     Bool,
     Datetime64,
     Complex128,
+    StructuredType,
+    SubArrayType,
 )
 from nptyping.types._object import Object
 from nptyping.types._timedelta64 import Timedelta64
@@ -118,3 +120,9 @@ class TestGetType(TestCase):
             ...
 
         self.assertEqual(Object, get_type(SomeRandomClass()))
+
+    def test_get_type_structured_type(self):
+        self.assertEqual(StructuredType[Int[32]], get_type(np.dtype([('x', np.int32)])))
+
+    def test_get_type_subarray_type(self):
+        self.assertEqual(SubArrayType[Int[32], (3,)], get_type(np.dtype((np.int32, 3))))
diff --git a/tests/test_types/test_ndarray.py b/tests/test_types/test_ndarray.py
index 0f9a1da..9b8f733 100644
--- a/tests/test_types/test_ndarray.py
+++ b/tests/test_types/test_ndarray.py
@@ -2,8 +2,9 @@ from typing import Any, Optional
 from unittest import TestCase
 
 import numpy as np
+from numpy.core.arrayprint import SubArrayFormat
 
-from nptyping import NDArray, DEFAULT_INT_BITS, Int, Bool, Datetime64
+from nptyping import NDArray, DEFAULT_INT_BITS, Int, Bool, Datetime64, StructuredType, SubArrayType
 from nptyping.types._timedelta64 import Timedelta64
 
 
@@ -104,7 +105,7 @@ class TestNDArray(TestCase):
 
     def test_instance_check_types(self):
         arr2x2x2_float = np.array([[[1.0, 2.0], [3.0, 4.0]],
-                                  [[5.0, 6.0], [7.0, 8.0]]])
+                                   [[5.0, 6.0], [7.0, 8.0]]])
         arr2x2x2 = np.array([[[1, 2], [3, 4]], [[5, 6], [7, 8]]])
 
         self.assertTrue(isinstance(arr2x2x2_float, NDArray[(2, 2, 2), float]))
@@ -112,7 +113,7 @@ class TestNDArray(TestCase):
 
     def test_instance_check_types_any(self):
         arr2x2x2_float = np.array([[[1.0, 2.0], [3.0, 4.0]],
-                                  [[5.0, 6.0], [7.0, 8.0]]])
+                                   [[5.0, 6.0], [7.0, 8.0]]])
         arr2x2x2 = np.array([[[1, 2], [3, 4]], [[5, 6], [7, 8]]])
         arr2x2 = np.array([[1, 2], [3, 4]])
 
@@ -202,3 +203,10 @@ class TestNDArray(TestCase):
         self.assertNotIsInstance(np.array([[True, False], [True, 42]]), NDArray[(Any, ...), Bool])
         self.assertIsInstance(np.array([np.datetime64()]), NDArray[(Any, ...), Datetime64])
         self.assertIsInstance(np.array([np.timedelta64()]), NDArray[(Any, ...), Timedelta64])
+
+    def test_instance_check_with_structured_types(self):
+        some_dtype = np.dtype([('x', np.int32), ('y', np.int32, 4)])
+        some_other_dtype = np.dtype([('x', np.int32), ('y', np.int32, 3)])
+        self.assertIsInstance(np.zeros((1,), dtype=some_dtype), NDArray[(Any, ...), some_dtype])
+        self.assertNotIsInstance(np.zeros((1,), dtype=some_dtype), NDArray[(Any, ...), some_other_dtype])
+        self.assertIsInstance(np.zeros((1,), dtype=some_dtype), NDArray[(Any, ...), StructuredType[Int[32], SubArrayType[Int[32], (4,)]]])
diff --git a/tests/test_types/test_structured_type.py b/tests/test_types/test_structured_type.py
new file mode 100644
index 0000000..4cdd53c
--- /dev/null
+++ b/tests/test_types/test_structured_type.py
@@ -0,0 +1,58 @@
+from unittest import TestCase
+
+import numpy as np
+from nptyping import Int, StructuredType, Unicode, SubArrayType
+from nptyping.types._structured_type import is_structured_type
+
+
+class TestStructuredType(TestCase):
+
+    def test_equality_of_basic_object_with_no_structure(self):
+        self.assertEqual(StructuredType, StructuredType)
+
+    def test_equality_of_two_structured_types(self):
+        self.assertEqual(StructuredType[Int[32], Unicode[8]], StructuredType[Int[32], Unicode[8]])
+
+    def test_inequality_of_two_structured_types(self):
+        self.assertNotEquals(StructuredType[Int[32], Unicode[8], Int[16]], StructuredType[Int[32], Unicode[8]])
+
+    def test_dtype_constructed_by_hand_is_an_instance_of_structured_type(self):
+        self.assertIsInstance(np.dtype([('x', np.int32), ('y', np.int32)]), StructuredType[Int[32], Int[32]])
+
+    def test_names_dont_matter_when_comparing_two_structured_types(self):
+        self.assertEqual(StructuredType[np.dtype([('x', np.int32), ('y', np.int32)])], StructuredType[np.dtype([('a', np.int32), ('b', np.int32)])])
+
+    def test_dtype_constructed_by_hand_is_not_an_instance_of_structured_type_if_fields_differ(self):
+        self.assertNotIsInstance(np.dtype([('x', np.int32), ('y', np.int16)]), StructuredType[Int[32], Int[32]])
+
+    def test_number_is_not_a_structured_type(self):
+        self.assertFalse(is_structured_type(np.int32))
+
+    def test_structured_array_is_a_structured_type(self):
+        self.assertTrue(is_structured_type(np.dtype([('x', np.int32), ('y', np.int32, (2, 3))])))
+
+    def test_structured_type_by_hand_instance_check(self):
+        some_dtype = np.dtype([('x', np.int32), ('y', np.int32, 4)])
+        self.assertIsInstance(some_dtype, StructuredType[Int[32], SubArrayType[Int[32], (4,)]])
+
+    def test_structured_type_repr_with_no_parameters(self):
+        self.assertEqual(str(StructuredType), 'StructuredType')
+
+    def test_structured_type_repr_with_parameters(self):
+        self.assertEqual(str(StructuredType[Int[16], SubArrayType[Int[32], (4,)]]), 'StructuredType[Int[16],SubArrayType[Int[32], (4,)]]')
+
+    def test_structured_type_single_field(self):
+        self.assertIsInstance(np.dtype([('x', np.int32)]), StructuredType[Int[32]])
+
+    def test_other_type_is_not_an_instance_of_structured_type(self):
+        self.assertNotIsInstance(Int[32], StructuredType[Int[32], Int[32]])
+
+    def test_structured_type_derived_from_dtype_passes_instance_check(self):
+        self.assertIsInstance(StructuredType[np.dtype([('x', np.int32), ('y', np.int32)])], StructuredType[Int[32], Int[32]])
+
+    def test_structured_type_from_heterogenous_base_types(self):
+        self.assertEqual(StructuredType[Int[32], np.int32], StructuredType[Int[32], Int[32]])
+
+    def test_incompatible_parameters_to_structured_type_raise_exception(self):
+        with self.assertRaises(Exception):
+            StructuredType['bad arg']
diff --git a/tests/test_types/test_subarray_type.py b/tests/test_types/test_subarray_type.py
new file mode 100644
index 0000000..051f687
--- /dev/null
+++ b/tests/test_types/test_subarray_type.py
@@ -0,0 +1,56 @@
+from unittest import TestCase
+
+import numpy as np
+from nptyping import Int, SubArrayType
+from nptyping.types._subarray_type import is_subarray_type
+
+
+class TestSubArrayType(TestCase):
+    def test_number_is_not_a_subarray_type(self):
+        self.assertFalse(is_subarray_type(np.int32))
+
+    def test_subarray_is_a_subarray_type(self):
+        self.assertTrue(is_subarray_type(np.dtype((int, 4))))
+
+    def test_structured_array_is_not_a_subarray_type(self):
+        self.assertFalse(is_subarray_type(np.dtype([('x', int), ('y', int, (2, 3))])))
+
+    def test_subarray_element_of_structured_array_is_a_structured_type(self):
+        some_type = np.dtype([('x', int), ('y', int, (2, 3))])
+        self.assertTrue(is_subarray_type(some_type.fields['y'][0]))
+
+    def test_subarray_type_constructed_by_hand_is_equal_to_constructed_from_dtype(self):
+        self.assertEqual(SubArrayType[Int[32], (4,)], SubArrayType[np.dtype((np.int32, 4))])
+
+    def test_subarray_type_constructed_by_hand_is_not_equal_to_constructed_from_dtype_if_shape_doesnt_match(self):
+        self.assertNotEquals(SubArrayType[np.int32, (4,)], SubArrayType[np.dtype((np.int32, (4, 1)))])
+
+    def test_subarray_type_constructed_by_hand_is_not_equal_to_constructed_from_dtype_if_base_doesnt_match(self):
+        self.assertNotEquals(SubArrayType[np.int16, (4,)], SubArrayType[np.dtype((np.int32, 4))])
+
+    def test_subarray_dtype_is_an_instance_of_subarray_type(self):
+        self.assertIsInstance(np.dtype((np.int32, 4)), SubArrayType[np.int32, (4,)])
+
+    def test_subarray_dtype_is_not_an_instance_of_subarray_type_if_shape_doesnt_match(self):
+        self.assertNotIsInstance(np.dtype((np.int32, 4)), SubArrayType[np.int32, (4, 1)])
+
+    def test_subarray_dtype_is_not_an_instance_of_subarray_type_if_base_doesnt_match(self):
+        self.assertNotIsInstance(np.dtype((np.int32, 4)), SubArrayType[np.int16, 4])
+
+    def test_subarray_type_repr_with_no_parameters(self):
+        self.assertEqual(str(SubArrayType), 'SubArrayType')
+
+    def test_subarray_type_repr_with_parameters(self):
+        self.assertEqual(str(SubArrayType[Int[16], (4,)]), 'SubArrayType[Int[16], (4,)]')
+
+    def test_other_type_is_not_an_instance_of_subarray_type(self):
+        self.assertNotIsInstance(Int[32], SubArrayType[Int[32], (1,)])
+
+    def test_other_type_does_not_equal_subarray_type(self):
+        self.assertNotEqual(SubArrayType[Int[32], (1,)], Int[32])
+
+    def test_incompatible_parameters_to_subarray_type_raise_exception(self):
+        with self.assertRaises(Exception):
+            SubArrayType[Int[32]]
+        with self.assertRaises(Exception):
+            SubArrayType['bad arg']