Repository: ska-sa/aiokatcp

Issue:
Improve support for integer-like types
The type registration system currently only uses `__mro__`, which means it doesn't work with ABCs like `numbers.Integral`. In Python 3, types like `numpy.int64` don't inherit from `int`, so they currently crash if set as sensor values or otherwise used in katcp messages.

One approach is to change the type registration system to work with `isinstance` and to register ABCs instead of concrete classes. That needs some investigation because mypy doesn't work well with the `numbers` ABCs.

An alternative is to modify the sensor class to cast value updates to the sensor's registered type. That would fix the problem of setting a sensor to a numpy integer (but with the side effect that reading back the value gives you a different type of object from what you set), but wouldn't help for other messages.

Comments:

Patch:
diff --git a/aiokatcp/core.py b/aiokatcp/core.py
index fe49fe8..d040270 100644
--- a/aiokatcp/core.py
+++ b/aiokatcp/core.py
@@ -30,6 +30,7 @@ import re
 import io
 import logging
 import ipaddress
+import numbers
 from typing import (
     Match, Any, Callable, Union, Type, Iterable, Tuple,
     Generic, TypeVar, Optional, cast)
@@ -178,17 +179,18 @@ class LogLevel(enum.IntEnum):
 
 class TypeInfo(Generic[_T_contra]):
     """Type database entry. Refer to :func:`register_type` for details."""
-    def __init__(self, name: str,
+    def __init__(self, type_: Type[_T_contra], name: str,
                  encode: Callable[[_T_contra], bytes],
                  decode: Callable[[Type[_T_contra], bytes], _T_contra],
                  default: Callable[[Type[_T_contra]], _T_contra]) -> None:
+        self.type_ = type_
         self.name = name
         self.encode = encode
         self.decode = decode
         self.default = default
 
 
-_types = {}     # type: Dict[type, TypeInfo]
+_types = []     # type: List[TypeInfo]
 
 
 def register_type(type_: Type[_T], name: str,
@@ -214,27 +216,28 @@ def register_type(type_: Type[_T], name: str,
         Function to generate a default value of this type (used by the sensor
         framework). It is given the actual derived class as the first argument.
     """
-    if type_ in _types:
-        raise ValueError('type {!r} is already registered')
     if default is None:
         default = _default_generic
-    _types[type_] = TypeInfo(name, encode, decode, default)
+    for info in _types:
+        if info.type_ == type_:
+            raise ValueError('{} is already registered'.format(type_))
+    _types.append(TypeInfo(type_, name, encode, decode, default))
 
 
 def get_type(type_: Type[_T]) -> TypeInfo[_T]:
     """Retrieve the type information previously registered with :func:`register_type`.
 
-    It returns the type info corresponding to `type_` or the most specific subclass
-    (according to method resolution order) for which there is a registration.
+    It returns the last type info registered that is a superclass of `type_` (according
+    to ``issubclass``.
 
     Raises
     ------
     TypeError
-        if neither `type_` nor any of its bases is registered
+        if none of the registrations match `type_`
     """
-    for t in type_.__mro__:
-        if t in _types:
-            return _types[t]
+    for info in reversed(_types):
+        if issubclass(type_, info.type_):
+            return info
     raise TypeError('{} is not registered'.format(type_))
 
 
@@ -278,12 +281,14 @@ def _default_enum(cls: Type[_E]) -> _E:
     return next(iter(cast(Iterable, cls)))
 
 
-register_type(int, 'integer',
-              lambda value: str(value).encode('ascii'),
-              lambda cls, raw: cls(raw.decode('ascii')))
-register_type(float, 'float',
-              lambda value: repr(value).encode('ascii'),
-              lambda cls, raw: cls(raw.decode('ascii')))
+# mypy doesn't allow an abstract class to be passed to Type[], hence the
+# suppressions.
+register_type(numbers.Real, 'float',                             # type: ignore
+              lambda value: repr(float(value)).encode('ascii'),
+              lambda cls, raw: cls(float(raw.decode('ascii'))))  # type: ignore
+register_type(numbers.Integral, 'integer',                       # type: ignore
+              lambda value: str(int(value)).encode('ascii'),
+              lambda cls, raw: cls(int(raw.decode('ascii'))))    # type: ignore
 register_type(bool, 'boolean',
               lambda value: b'1' if value else b'0', _decode_bool)
 register_type(bytes, 'string',
@@ -300,7 +305,6 @@ register_type(Timestamp, 'timestamp',
               lambda value: repr(value).encode('ascii'),
               lambda cls, raw: cls(raw.decode('ascii')))
 register_type(enum.Enum, 'discrete', _encode_enum, _decode_enum, _default_enum)
-register_type(enum.IntEnum, 'discrete', _encode_enum, _decode_enum, _default_enum)
 
 
 def encode(value: Any) -> bytes:


Test patch:
diff --git a/aiokatcp/test/test_core.py b/aiokatcp/test/test_core.py
index 5d6458d..b9c6004 100644
--- a/aiokatcp/test/test_core.py
+++ b/aiokatcp/test/test_core.py
@@ -28,6 +28,7 @@
 import enum
 import json
 import ipaddress
+from fractions import Fraction
 import unittest
 import unittest.mock
 from typing import Union
@@ -43,6 +44,11 @@ class MyEnum(enum.Enum):
     TWO_FACE = 3
 
 
+class MyIntEnum(enum.IntEnum):
+    A = 1
+    B = 2
+
+
 class OverrideEnum(enum.Enum):
     BATMAN = b'cheese'
     JOKER = b'carrot'
@@ -119,11 +125,13 @@ class TestEncodeDecode(unittest.TestCase):
         (bool, False, b'0'),
         (float, -123.5, b'-123.5'),
         (float, 1e+20, b'1e+20'),
+        (Fraction, Fraction(5, 4), b'1.25'),
         (Timestamp, Timestamp(123.5), b'123.5'),
         (TimestampOrNow, Timestamp(123.5), b'123.5'),
         (TimestampOrNow, Now.NOW, b'now'),
         (Address, Address(ipaddress.ip_address('127.0.0.1')), b'127.0.0.1'),
         (MyEnum, MyEnum.TWO_FACE, b'two-face'),
+        (MyIntEnum, MyIntEnum.A, b'a'),
         (OverrideEnum, OverrideEnum.JOKER, b'carrot')
     ]
 
@@ -132,10 +140,12 @@ class TestEncodeDecode(unittest.TestCase):
         (bool, b'2'),
         (int, b'123.0'),
         (float, b''),
+        (Fraction, b'5/4'),
         (Address, b'[127.0.0.1]'),
         (MyEnum, b'two_face'),
         (MyEnum, b'TWO-FACE'),
         (MyEnum, b'bad-value'),
+        (MyIntEnum, b'z'),
         (OverrideEnum, b'joker'),
         (TimestampOrNow, b'later'),
         (Union[int, float], b'123')
@@ -158,7 +168,7 @@ class TestEncodeDecode(unittest.TestCase):
                                    msg='{} should not be valid for {}'.format(value, type_)):
                 decode(type_, value)
 
-    @unittest.mock.patch.dict('aiokatcp.core._types')   # type: ignore
+    @unittest.mock.patch('aiokatcp.core._types', [])   # type: ignore
     def test_register_type(self) -> None:
         register_type(
             dict, 'string',