pyebus/types.py
"""Type Engine."""
import datetime
import re
from .util import repr_
# pylint: disable=abstract-method
class Type:
"""Abstract Type."""
def __init__(self):
pass
def __repr__(self):
return repr_(self, self._getargs(), self._getkwargs())
def _ident(self):
return self._getargs(), self._getkwargs()
def __hash__(self):
return hash(self._ident())
def __eq__(self, other):
if self.__class__ is other.__class__:
return self._ident() == other._ident()
return NotImplemented
def _getargs(self):
return tuple()
def _getkwargs(self):
return tuple()
def with_divider(self, divider):
"""Return copy and apply `divider`."""
raise NotImplementedError(self)
def decode(self, value):
"""Decode `value`."""
raise NotImplementedError(self)
def encode(self, value):
"""Encode `value`."""
raise NotImplementedError(self)
@property
def comment(self):
"""Get Comment on allowed values."""
raise NotImplementedError(self)
class EnumType(Type):
"""
Enumeration of `values`.
Pure integer values are passed through, as they have an unknown coding.
>>> t = EnumType(('on', 'auto', 'off'))
>>> t
EnumType(('on', 'auto', 'off'))
>>> t.values
('on', 'auto', 'off')
>>> t.comment
'on, auto, off'
>>> t.encode('auto')
'auto'
>>> t.encode(5)
'5'
>>> t.encode('5')
'5'
>>> t.encode('super')
Traceback (most recent call last):
...
ValueError: Unknown value 'super'. Allowed values are on, auto, off.
>>> t.encode(None)
'-'
>>> t.decode('-') is None
True
>>> t.decode('auto')
'auto'
>>> t.decode('7')
7
>>> t.decode('super')
Traceback (most recent call last):
...
ValueError: Unknown value 'super'. Allowed values are on, auto, off.
"""
_re_digit = re.compile(r"^\d+$")
def __init__(self, values):
super().__init__()
self._values = values
@property
def values(self):
"""Enumeration Values."""
return self._values
def _getargs(self):
return (self._values,)
def decode(self, value):
"""Decode `value`."""
if value == "-":
value = None
elif self._re_digit.match(value):
value = int(value)
# It does not matter to loop up the value, as it would have been found by ebusd
elif value not in self._values:
values = ", ".join(self._values)
raise ValueError(f"Unknown value '{value}'. Allowed values are {values}.")
return value
def encode(self, value):
"""Encode `value`."""
if value is None:
value = "-"
elif isinstance(value, int) or self._re_digit.match(str(value)):
value = str(value)
elif value not in self._values:
values = ", ".join(self._values)
raise ValueError(f"Unknown value '{value}'. Allowed values are {values}.")
return value
@property
def comment(self):
"""Get Comment on allowed values."""
return ", ".join(self._values)
class StrType(Type):
"""
String with optional maximum `length`.
>>> t = StrType()
>>> t
StrType()
>>> t.length is None
True
>>> t.encode('a string')
'a string'
>>> t.decode('a string')
'a string'
>>> t.comment is None
True
With length:
>>> t = StrType(10)
>>> t
StrType(length=10)
>>> t.length
10
>>> t.encode('a string')
'a string'
>>> t.decode('a string')
'a string'
>>> t.encode('a very long string')
Traceback (most recent call last):
...
ValueError: a very long string exceeds maximum length 10.
>>> t.decode('a very long string')
Traceback (most recent call last):
...
ValueError: a very long string exceeds maximum length 10.
>>> t.comment
'Up to 10 characters'
"""
def __init__(self, length=None):
super().__init__()
self._length = length
@property
def length(self):
"""Length."""
return self._length
def _getkwargs(self):
return (("length", self.length, None),)
def decode(self, value):
"""Decode `value`."""
if self.length is not None and len(value) > self.length:
raise ValueError(f"{value} exceeds maximum length {self.length}.")
return value
def encode(self, value):
"""Encode `value`."""
if self.length is not None and len(value) > self.length:
raise ValueError(f"{value} exceeds maximum length {self.length}.")
return value
@property
def comment(self):
"""Get Comment on allowed values."""
if self._length:
return f"Up to {self._length} characters"
return None
class HexType(Type):
"""
Space Separated Hex Value with an optional `length` in number of Bytes.
>>> t = HexType()
>>> t.length is None
True
>>> t
HexType()
>>> t.decode('11 22 FF')
(0x11, 0x22, 0xFF)
>>> t.encode('0x11 0x22 0xFF')
'11 22 FF'
>>> t.encode('11 22 FF')
'11 22 FF'
>>> t.encode((0x11, 0x22, 0xFF))
'11 22 FF'
>>> t.comment
'Hex Bytes'
With length:
>>> t = HexType(3)
>>> t
HexType(length=3)
>>> t.length
3
>>> t.decode('11 22 FF')
(0x11, 0x22, 0xFF)
>>> t.decode('11 22')
Traceback (most recent call last):
...
ValueError: Hex value 11 22 has not expected length of 3
>>> t.encode((0x11, 0x22, 0xFF))
'11 22 FF'
>>> t.encode((0x11, 0x22))
Traceback (most recent call last):
...
ValueError: Hex value (17, 34) has not expected length of 3
>>> t.comment
'3 Hex Bytes'
"""
def __init__(self, length=None):
super().__init__()
self._length = length
@property
def length(self):
"""Width."""
return self._length
def _getkwargs(self):
return (("length", self.length, None),)
def decode(self, value):
"""Decode `value`."""
values = value.split(" ")
if self.length is not None:
if len(values) != self.length:
raise ValueError(f"Hex value {value} has not expected length of {self.length}")
return tuple(Hex(int(value, 16)) for value in values)
def encode(self, value):
"""Decode `value`."""
if isinstance(value, str):
value = [int(v, 16) for v in value.split(" ")]
if self.length is not None:
if len(value) != self.length:
raise ValueError(f"Hex value {value} has not expected length of {self.length}")
return " ".join((f"{v:02X}" for v in value))
@property
def comment(self):
"""Get Comment on allowed values."""
if self._length:
return f"{self._length} Hex Bytes"
return "Hex Bytes"
class IntType(Type):
"""
Integer in the range of [min_, max_] with granularity of `1 / divider`.
>>> t = IntType(-4, 3)
>>> t
IntType(-4, 3)
>>> t.min_
-4
>>> t.max_
3
>>> t.divider is None
True
>>> t.frac is None
True
>>> t.comment
'Integer within [-4:3]'
>>> t.decode('2')
2
>>> t.decode('-') is None
True
>>> t.decode('7')
Traceback (most recent call last):
...
ValueError: Value 7 exceeds upper limit of 3
>>> t.decode('-5')
Traceback (most recent call last):
...
ValueError: Value -5 deceeds lower limit of -4
>>> t.encode('2')
2
>>> t.encode(2)
2
>>> t.encode(None)
'-'
>>> t.encode(7)
Traceback (most recent call last):
...
ValueError: Value 7 exceeds upper limit of 3
>>> t.encode(-5)
Traceback (most recent call last):
...
ValueError: Value -5 deceeds lower limit of -4
With divider:
>>> t = IntType(-4, 3, divider=2)
>>> t
IntType(-4, 3, divider=2)
>>> t.min_
-4
>>> t.max_
3
>>> t.divider
2
>>> t.frac
0.5
>>> t.comment
'Float within [-4:3] with 0.5 fraction'
>>> t.decode('2')
2.0
>>> t.decode('-') is None
True
>>> t.decode('7')
Traceback (most recent call last):
...
ValueError: Value 7.0 exceeds upper limit of 3
>>> t.decode('-5')
Traceback (most recent call last):
...
ValueError: Value -5.0 deceeds lower limit of -4
>>> t.encode(2)
2.0
>>> t.encode(None)
'-'
>>> t.encode(7)
Traceback (most recent call last):
...
ValueError: Value 7.0 exceeds upper limit of 3
>>> t.encode(-5)
Traceback (most recent call last):
...
ValueError: Value -5.0 deceeds lower limit of -4
"""
def __init__(self, min_, max_, divider=None):
super().__init__()
self._min = min_
self._max = max_
self._divider = divider
@property
def min_(self):
"""Lower Limit."""
return self._min
@property
def max_(self):
"""Upper Limit."""
return self._max
@property
def divider(self):
"""Divider."""
return self._divider
@property
def frac(self):
"""Fraction."""
if self._divider and self._divider > 0:
return 1 / self.divider
return None
def _getargs(self):
return (self.min_, self.max_)
def _getkwargs(self):
return (("divider", self.divider, None),)
def with_divider(self, divider):
"""Return copy and apply `divider`."""
divider = _try_int(divider * (self.divider or 1))
min_ = _try_int(self.min_ / divider)
max_ = _try_int(self.max_ / divider)
return IntType(min_, max_, divider=divider)
def decode(self, value):
"""Decode `value`."""
if value not in ("-", ""):
if self.divider and self.divider > 0:
value = float(value)
else:
value = int(value)
if value < self.min_:
raise ValueError(f"Value {value} deceeds lower limit of {self.min_}")
if value > self.max_:
raise ValueError(f"Value {value} exceeds upper limit of {self.max_}")
return value
return None
def encode(self, value):
"""Encode `value`."""
if value is not None:
if self.divider and self.divider > 0:
value = float(value)
else:
value = int(value)
if value < self.min_:
raise ValueError(f"Value {value} deceeds lower limit of {self.min_}")
if value > self.max_:
raise ValueError(f"Value {value} exceeds upper limit of {self.max_}")
return value
return "-"
@property
def comment(self):
"""Get Comment on allowed values."""
if self.divider and self.divider > 0:
return f"Float within [{self.min_}:{self.max_}] with {self.frac} fraction"
return f"Integer within [{self.min_}:{self.max_}]"
class BoolType(Type):
"""
Boolean Type.
>>> t = BoolType()
>>> t
BoolType()
>>> t.comment
'0 or 1'
>>> t.decode('0')
False
>>> t.decode('1')
True
>>> t.decode('-') is None
True
>>> t.encode(0)
0
>>> t.encode(1)
1
>>> t.encode('0')
0
>>> t.encode('1')
1
>>> t.encode('false')
0
>>> t.encode('true')
1
>>> t.encode('FALSE')
0
>>> t.encode('TRUE')
1
>>> t.encode(None)
'-'
>>> t.encode('blub')
Traceback (most recent call last):
...
ValueError: blub is not a valid boolean
"""
def decode(self, value):
"""Decode `value`."""
if value not in ("-", ""):
return bool(int(value))
return None
def encode(self, value):
"""Encode `value`."""
if value is not None:
valuestr = str(value).lower()
if valuestr in ("1", "true"):
return 1
if valuestr in ("0", "false"):
return 0
raise ValueError(f"{value} is not a valid boolean")
return "-"
@property
def comment(self):
"""Get Comment on allowed values."""
return "0 or 1"
class FloatType(Type):
"""
Floating Type.
>>> t = FloatType()
>>> t
FloatType()
>>> t.comment
'Float'
>>> t.decode('-') is None
True
>>> t.decode('0')
0.0
>>> t.decode('1.456')
1.456
>>> t.encode(15.6)
'15.6'
>>> t.encode(None)
'-'
"""
def decode(self, value):
"""Decode `value`."""
if value not in ("-", ""):
value = float(value)
return value
return None
def encode(self, value):
"""Encode `value`."""
if value is not None:
value = float(value)
return str(value)
return "-"
@property
def comment(self):
"""Get Comment on allowed values."""
return "Float"
class DateType(Type):
"""
Date Type.
>>> import datetime
>>> t = DateType()
>>> t
DateType()
>>> t.comment
'DAY.MONTH.YEAR'
>>> t.decode('30.12.2020')
datetime.date(2020, 12, 30)
>>> t.decode('-.-.-') is None
True
>>> t.encode('30.12.2020')
'30.12.2020'
>>> t.encode(datetime.date(2020, 12, 30))
'30.12.2020'
>>> t.encode(None)
'-.-.-'
"""
_NONE = "-.-.-"
def decode(self, value):
"""Decode `value`."""
if value != self._NONE:
return datetime.datetime.strptime(value, "%d.%m.%Y").date()
return None
def encode(self, value):
"""Encode `value`."""
if isinstance(value, str):
value = datetime.datetime.strptime(value, "%d.%m.%Y").date()
if value is not None:
return f"{value.day}.{value.month}.{value.year}"
return self._NONE
@property
def comment(self):
"""Get Comment on allowed values."""
return "DAY.MONTH.YEAR"
class TimeType(Type):
"""
Time.
>>> t = TimeType()
>>> t
TimeType()
>>> t.comment
'HOUR:MINUTE:SECOND'
>>> t.decode('23:59:59')
Time(23, 59, 59)
>>> t.decode('-:-:-') is None
True
>>> t.encode('23:59:59')
'23:59:59'
>>> t.encode(Time(23, 59, 59))
'23:59:59'
>>> t.encode(None)
'-:-:-'
"""
_NONE = "-:-:-"
def decode(self, value):
"""Decode `value`."""
if value != self._NONE:
tstamp = datetime.datetime.strptime(value, "%H:%M:%S")
return Time(tstamp.hour, tstamp.minute, tstamp.second)
return None
def encode(self, value):
"""Encode `value`."""
if isinstance(value, str):
value = datetime.datetime.strptime(value, "%H:%M:%S")
if value is not None:
return f"{value.hour:02d}:{value.minute:02d}:{value.second:02d}"
return self._NONE
@property
def comment(self):
"""Get Comment on allowed values."""
return "HOUR:MINUTE:SECOND"
class HourMinuteType(TimeType):
"""
Time.
Keyword Args:
minres: Minute Resolution
>>> t = HourMinuteType()
>>> t
HourMinuteType()
>>> t.minres is None
True
>>> t.comment
'HOUR:MINUTE'
>>> t.decode('23:59')
HourMinute(23, 59)
>>> t.decode('-:-') is None
True
>>> t.encode('23:59')
'23:59'
>>> t.encode(HourMinute(23, 59))
'23:59'
>>> t.encode(None)
'-:-'
With Minimum Resolution
>>> t = HourMinuteType(minres=10)
>>> t
HourMinuteType(minres=10)
>>> t.minres
10
>>> t.comment
'HOUR:MINUTE with 10min granularity'
>>> t.decode('23:50')
HourMinute(23, 50)
>>> t.decode('-:-') is None
True
>>> t.decode('23:51')
Traceback (most recent call last):
...
ValueError: Minute of 23:51 must be multiple of 10
>>> t.encode('23:50')
'23:50'
>>> t.encode(HourMinute(23, 50))
'23:50'
>>> t.encode(HourMinute(23, 52))
Traceback (most recent call last):
...
ValueError: Minute of 23:52 must be multiple of 10
>>> t.encode(None)
'-:-'
"""
_NONE = "-:-"
def __init__(self, minres=None):
super().__init__()
self._minres = minres
@property
def minres(self):
"""Minute Resolution."""
return self._minres
def _getkwargs(self):
return (("minres", self._minres, None),)
def decode(self, value):
"""Decode `value`."""
if value != self._NONE:
value = datetime.datetime.strptime(value, "%H:%M")
value = HourMinute(value.hour, value.minute)
if self._minres and (value.minute % self._minres) != 0:
raise ValueError(f"Minute of {value} must be multiple of {self._minres}")
return value
return None
def encode(self, value):
"""Encode `value`."""
if isinstance(value, str):
value = datetime.datetime.strptime(value, "%H:%M")
if value is not None:
if self._minres and (value.minute % self._minres) != 0:
raise ValueError(f"Minute of {value} must be multiple of {self._minres}")
return f"{value.hour:02d}:{value.minute:02d}"
return self._NONE
@property
def comment(self):
"""Get Comment on allowed values."""
if self.minres:
return f"HOUR:MINUTE with {self._minres}min granularity"
return "HOUR:MINUTE"
class DateTimeType(Type):
"""
Date Time.
>>> t = DateTimeType()
>>> t
DateTimeType()
>>> t.comment
'DAY.MONTH.YEAR HOUR:MINUTE:SECOND'
>>> t.decode('30.12.2020 23:59:59')
DateTime(2020, 12, 30, 23, 59, 59)
>>> t.decode('-.-.- -:-:-') is None
True
>>> t.encode('30.12.2020 23:59:59')
'30.12.2020 23:59:59'
>>> t.encode(DateTime(2020, 12, 30, 23, 59, 59))
'30.12.2020 23:59:59'
>>> t.encode(None)
'-.-.- -:-:-'
"""
_NONE = "-.-.- -:-:-"
def decode(self, value):
"""Decode `value`."""
if value != self._NONE:
return DateTime.strptime(value, "%d.%m.%Y %H:%M:%S")
return None
def encode(self, value):
"""Encode `value`."""
if isinstance(value, str):
value = DateTime.strptime(value, "%d.%m.%Y %H:%M:%S")
if value is not None:
return f"{value.day}.{value.month}.{value.year} {value.hour:02d}:{value.minute:02d}:{value.second:02d}"
return self._NONE
@property
def comment(self):
"""Get Comment on allowed values."""
return "DAY.MONTH.YEAR HOUR:MINUTE:SECOND"
class WeekdayType(Type):
"""
Weekday Type.
>>> t = WeekdayType()
>>> t.comment
''
>>> t.decode('a')
'a'
>>> t.encode('a')
'a'
"""
def decode(self, value):
"""Decode `value`."""
return value
def encode(self, value):
"""Encode `value`."""
return value
@property
def comment(self):
"""Get Comment on allowed values."""
return ""
class PinType(Type):
"""
Pin.
>>> t = PinType()
>>> t.comment
''
>>> t.decode('a')
'a'
>>> t.encode('a')
'a'
"""
def decode(self, value):
"""Decode `value`."""
return value
def encode(self, value):
"""Encode `value`."""
return value
@property
def comment(self):
"""Get Comment on allowed values."""
return ""
class DateTime(datetime.datetime):
"""
DateTime.
>>> t = DateTime(2020, 12, 31, 23, 59, 59)
>>> t
DateTime(2020, 12, 31, 23, 59, 59)
>>> str(t)
'31.12.2020 23:59:59'
"""
def __str__(self):
return self.strftime("%d.%m.%Y %H:%M:%S")
class Time(datetime.time):
"""
Time.
>>> t = Time(23, 59, 59)
>>> t
Time(23, 59, 59)
>>> str(t)
'23:59:59'
"""
def __str__(self):
return self.strftime("%H:%M:%S")
class HourMinute(datetime.time):
"""
Time without Seconds.
>>> t = HourMinute(23, 59)
>>> t
HourMinute(23, 59)
>>> str(t)
'23:59'
"""
def __str__(self):
return self.strftime("%H:%M")
class Hex(int):
"""Integer with Hex Representation."""
def __repr__(self):
return f"0x{self:02X}"
__str__ = __repr__
def _try_int(value):
intvalue = int(value)
if float(value) == float(intvalue):
value = intvalue
return value