diff --git a/README.rst b/README.rst index fc146a9..085277a 100644 --- a/README.rst +++ b/README.rst @@ -1,15 +1,13 @@ -Class for go to and from binary data +Dataclass for go to and from binary data -It's designed to be easy to create mappings by just having a -``_datafields`` class attribute. - +It follows dataclass pattern with typehinting as the binary format. Temperature with one unsigned byte: .. code-block:: python - class Temperature(Binmap): - _datafields = {"temp": "B"} + class Temperature(BinmapDataclass): + temp: unsignedchar = 0 t = Temperature() t.temp = 22 @@ -25,8 +23,9 @@ one unsiged byte for humidity: .. code-block:: python - class TempHum(Binmap): - _datafields = {"temp": "b", "hum": "B"} + class TempHum(BinmapDataclass): + temp: signedchar = 0 + hum: unsignedchar = 0 th = TempHum() th.temp = -10 diff --git a/binmap/__init__.py b/binmap/__init__.py index 5c6f988..6fdd720 100644 --- a/binmap/__init__.py +++ b/binmap/__init__.py @@ -1,5 +1,11 @@ +import dataclasses import struct -from inspect import Parameter, Signature +from enum import IntEnum +from typing import Dict, Tuple, Type, TypeVar, Union, get_type_hints + +from binmap import types + +T = TypeVar("T") class BaseDescriptor: @@ -7,7 +13,10 @@ class BaseDescriptor: :param name: Variable name""" - def __init__(self, name): + def __set_name__(self, obj, name): + self.name = name + + def __init__(self, name=""): self.name = name @@ -19,7 +28,8 @@ def __get__(self, obj, owner): return obj.__dict__[self.name] def __set__(self, obj, value): - struct.pack(obj._datafields[self.name], value) + type_hints = get_type_hints(obj) + struct.pack(datatypemapping[type_hints[self.name]][1], value) obj.__dict__[self.name] = value @@ -35,191 +45,180 @@ def __set__(self, obj, value): pass -class EnumField(BaseDescriptor): +class EnumField(BinField): """EnumField descriptor uses "enum" to map to and from strings. Accepts both strings and values when setting. Only values that has a corresponding string is allowed.""" - def __get__(self, obj, owner): - value = obj.__dict__[f"_{self.name}"] - return obj._enums[self.name][value] - def __set__(self, obj, value): - if value in obj._enums[self.name]: - obj.__dict__[f"_{self.name}"] = value + datafieldsmap = {f.name: f for f in dataclasses.fields(obj)} + if type(value) is str: + datafieldsmap[self.name].metadata["enum"][value] else: - for k, v in obj._enums[self.name].items(): - if v == value: - obj.__dict__[f"_{self.name}"] = k - return - - raise ValueError("Unknown enum or value") + datafieldsmap[self.name].metadata["enum"](value) + obj.__dict__[self.name] = value class ConstField(BinField): """ConstField descriptor keeps it's value""" def __set__(self, obj, value): - raise AttributeError(f"{self.name} is a constant") - - -class BinmapMetaclass(type): - """Metaclass for :class:`Binmap` and all subclasses of :class:`Binmap`. - - :class:`BinmapMetaclass` responsibility is to add for adding variables from - _datafields, _enums, _constants and add keyword only parameters. - - _datafields starting with ``_pad`` does't get any instance variable mapped. - - _enums get a variable called _{name} which has the binary data.""" - - def __new__(cls, clsname, bases, clsdict): - clsobject = super().__new__(cls, clsname, bases, clsdict) - keys = clsobject._datafields.keys() - sig = Signature( - [ - Parameter( - "binarydata", - Parameter.POSITIONAL_OR_KEYWORD, - default=Parameter.default, - ) - ] - + [ - Parameter(name, Parameter.KEYWORD_ONLY, default=Parameter.default) - for name in keys - ] - ) - setattr(clsobject, "__signature__", sig) - for enum in clsobject._enums: - for value, const in clsobject._enums[enum].items(): - if hasattr(clsobject, const.upper()): - raise ValueError(f"{const} already defined") - setattr(clsobject, const.upper(), value) - for name in keys: - if name.startswith("_pad"): - setattr(clsobject, name, PaddingField(name=name)) - elif name in clsobject._constants: - setattr(clsobject, name, ConstField(name=name)) - elif name in clsobject._enums: - setattr(clsobject, name, EnumField(name=name)) - setattr(clsobject, f"_{name}", BinField(name=f"_{name}")) - else: - setattr(clsobject, name, BinField(name=name)) - return clsobject + if self.name in obj.__dict__: + raise AttributeError(f"{self.name} is a constant") + else: + obj.__dict__[self.name] = value + + +datatypemapping: Dict[type, Tuple[Type[BaseDescriptor], str]] = { + types.char: (BinField, "c"), + types.signedchar: (BinField, "b"), + types.unsignedchar: (BinField, "B"), + types.boolean: (BinField, "?"), + bool: (BinField, "?"), + types.short: (BinField, "h"), + types.unsignedshort: (BinField, "H"), + types.integer: (BinField, "i"), + int: (BinField, "i"), + types.unsignedinteger: (BinField, "I"), + types.long: (BinField, "l"), + types.unsignedlong: (BinField, "L"), + types.longlong: (BinField, "q"), + types.unsignedlonglong: (BinField, "Q"), + types.halffloat: (BinField, "e"), + types.floating: (BinField, "f"), + float: (BinField, "f"), + types.double: (BinField, "d"), + types.string: (BinField, "s"), + str: (BinField, "s"), + types.pascalstring: (BinField, "p"), + types.pad: (PaddingField, "x"), +} + + +def padding(length: int = 1) -> dataclasses.Field: + """ + Field generator function for padding elements + :param int lenght: Number of bytes of padded field + :return: dataclass field + """ + return dataclasses.field(default=length, repr=False, metadata={"padding": True}) -class Binmap(metaclass=BinmapMetaclass): - """A class that maps to and from binary data using :py:class:`struct`. - All fields in :attr:`binmap.Binmap._datafields` gets a corresponding - variable in instance, except variables starting with ``_pad``, which is - just padding. - To create an enum mapping you could add :attr:`binmap.Binmap._enums` with a - map to your corresponing datafield: +def constant(value: Union[int, float, str]) -> dataclasses.Field: + """ + Field generator function for constant elements - .. code-block:: python + :param value: Constant value for the field. + :return: dataclass field + """ + return dataclasses.field(default=value, init=False, metadata={"constant": True}) - class TempWind(Binmap): - _datafields = {"temp": "b", "wind": "B"} - _enums = {"wind": {0: "North", 1: "East", 2: "South", 4: "West"}} - tw = TempWind() - tw.temp = 3 - tw.wind = "South" - print(bytes(tw)) - b'\\x03\\x02' +def stringfield( + length: int = 1, default: bytes = b"", fillchar: bytes = b" " +) -> dataclasses.Field: + """ + Field generator function for string fields. + :param int lenght: lengt of the string. + :param bytes default: default value of the string + :param bytes fillchar: char to pad the string with + :return: dataclass field + """ + if default == b"": + _default = b"\x00" * length + else: + _default = bytes(f"{default:{fillchar}<{length}}") + return dataclasses.field(default=_default, metadata={"length": length}) + +def enumfield(enumclass: IntEnum, default: IntEnum = None) -> dataclasses.Field: """ + Field generator function for enum field - #: _byteorder: charcter with byteorder - _byteorder = ">" - #: _datafields: dict with variable name as key and :py:class:`struct` `format strings`_ as value - _datafields = {} - #: _enums: dict of dicts containing maps of strings - _enums = {} - #: _constants: dict of constants. This creates a variable that is allways - #: the same value. It won't accept binary data with any other value - _constants = {} - - def __init__(self, *args, **kwargs): - self._formatstring = self._byteorder - for fmt in self._datafields.values(): - self._formatstring += fmt - - bound = self.__signature__.bind(*args, **kwargs) - - if "binarydata" in bound.arguments: - self._binarydata = bound.arguments["binarydata"] - self._unpacker(self._binarydata) - - for param in self.__signature__.parameters.values(): - if param.name == "binarydata": - continue - if param.name in bound.arguments: - if param.name in self._constants: - raise AttributeError(f"{param.name} is a constant") - setattr(self, param.name, bound.arguments[param.name]) - elif param.name in self._constants: - self.__dict__[param.name] = self._constants[param.name] - elif param.name not in self.__dict__: - if self._datafields[param.name] in "BbHhIiLlQq": - setattr(self, param.name, 0) - elif self._datafields[param.name] in "efd": - setattr(self, param.name, 0.0) - elif self._datafields[param.name] == "c": - setattr(self, param.name, b"\x00") - else: - setattr(self, param.name, b"") - - if len(args) == 1: - self._binarydata = args[0] - self._unpacker(self._binarydata) - else: - self._binarydata = b"" + :param IntEnum enumclass: Class with enums. + :param IntEnum default: default value + :return: dataclass field + """ + return dataclasses.field(default=default, metadata={"enum": enumclass}) + + +@dataclasses.dataclass +class BinmapDataclass: + """ + Dataclass that does the converting to and from binary data + """ - def _unpacker(self, value): + _formatstring = "" + _binarydata: dataclasses.InitVar[bytes] = b"" + + def __init_subclass__(cls, byteorder: str = ">"): + """ + Subclass initiator. This makes the inheriting class a dataclass. + :param str byteorder: byteorder for binary data + """ + dataclasses.dataclass(cls) + type_hints = get_type_hints(cls) + + cls._datafields = [] + cls._datafieldsmap = {} + cls._formatstring = byteorder + + for field_ in dataclasses.fields(cls): + _base, _type = datatypemapping[type_hints[field_.name]] + if "constant" in field_.metadata: + _base = ConstField + elif "enum" in field_.metadata: + _base = EnumField + setattr(cls, field_.name, _base(name=field_.name)) + if type_hints[field_.name] is types.pad: + _type = field_.default * _type + if type_hints[field_.name] in (types.string, types.pascalstring, str): + _type = str(field_.metadata["length"]) + _type + cls._formatstring += _type + + def __bytes__(self): + """ + Packs the class' fields to a binary string + :return: Binary string packed. + :rtype: bytes + """ + return struct.pack( + # TODO: use datclass.fields + self._formatstring, + *(v for k, v in self.__dict__.items() if k not in ["_formatstring"]), + ) + + def __post_init__(self, _binarydata: bytes): + """ + Initialises fields from a binary string + :param bytes _binarydata: Binary string that will be unpacked. + """ + if _binarydata != b"": + self.frombytes(_binarydata) + # Kludgy hack to keep order + for f in dataclasses.fields(self): + self._datafieldsmap.update({f.name: f}) + if "padding" in f.metadata: + continue + if "constant" in f.metadata: + self.__dict__.update({f.name: f.default}) + else: + val = getattr(self, f.name) + del self.__dict__[f.name] + self.__dict__.update({f.name: val}) + self._datafields.append(f.name) + + def frombytes(self, value: bytes): + """ + Unpacks value to each field + :param bytes value: binary string to unpack + """ args = struct.unpack(self._formatstring, value) - datafields = [ - field for field in self._datafields if not field.startswith("_pad") - ] - for arg, name in zip(args, datafields): - if name in self._constants: - if arg != self._constants[name]: + for arg, name in zip(args, self._datafields): + if "constant" in self._datafieldsmap[name].metadata: + if arg != self._datafieldsmap[name].default: raise ValueError("Constant doesn't match binary data") - else: - setattr(self, name, arg) - def __bytes__(self): - """packs or unpacks all variables to a binary structure defined by - _datafields' format values""" - datas = [] - for var in self._datafields: - if not var.startswith("_pad"): - if var in self._enums: - datas.append(getattr(self, f"_{var}")) - else: - datas.append(getattr(self, var)) - return struct.pack(self._formatstring, *datas) - - def frombytes(self, value): - self._unpacker(value) - self._binarydata = value - - def __eq__(self, other): - if self.__signature__ != other.__signature__: - return False - for field in self._datafields: - v1 = getattr(self, field) - v2 = getattr(other, field) - if v1 != v2: - return False - return True - - def __str__(self): - retval = f"{self.__class__.__name__}" - if self._datafields: - for key in self._datafields: - if not key.startswith("_pad"): - retval += ", %s=%s" % (key, getattr(self, key)) - return retval + setattr(self, name, arg) diff --git a/binmap/st.py b/binmap/st.py new file mode 100644 index 0000000..70ab462 --- /dev/null +++ b/binmap/st.py @@ -0,0 +1,20 @@ +from binmap import types + +_b = types.boolean +c = types.char +d = types.double +f = types.floating +e = types.halffloat +i = types.integer +l = types.long # noqa: E741 +q = types.longlong +x = types.pad +p = types.pascalstring +h = types.short +b = types.signedchar +s = types.string +B = types.unsignedchar +I = types.unsignedinteger # noqa: E741 +L = types.unsignedlong +Q = types.unsignedlonglong +H = types.unsignedshort diff --git a/binmap/types.py b/binmap/types.py new file mode 100644 index 0000000..4abdd29 --- /dev/null +++ b/binmap/types.py @@ -0,0 +1,20 @@ +from typing import NewType + +char = NewType("char", int) +signedchar = NewType("signedchar", int) +unsignedchar = NewType("unsignedchar", int) +boolean = NewType("boolean", bool) +short = NewType("short", int) +unsignedshort = NewType("unsignedshort", int) +integer = NewType("integer", int) +unsignedinteger = NewType("unsignedinteger", int) +long = NewType("long", int) +unsignedlong = NewType("unsignedlong", int) +longlong = NewType("longlong", int) +unsignedlonglong = NewType("unsignedlonglong", int) +halffloat = NewType("halffloat", float) +floating = NewType("floating", float) +double = NewType("double", float) +string = NewType("string", str) +pascalstring = NewType("pascalstring", str) +pad = NewType("pad", int) diff --git a/tests/test_binmap.py b/tests/test_binmap.py index 1c1d27b..4f16284 100644 --- a/tests/test_binmap.py +++ b/tests/test_binmap.py @@ -1,56 +1,31 @@ import struct +from enum import IntEnum import pytest import binmap +from binmap import types def test_baseclass(): - b = binmap.Binmap() - assert type(b) == binmap.Binmap - assert str(b) == "Binmap" + b = binmap.BinmapDataclass() + assert type(b) == binmap.BinmapDataclass + assert str(b) == "BinmapDataclass()" def test_baseclass_with_keyword(): with pytest.raises(TypeError) as excinfo: - binmap.Binmap(temp=10) + binmap.BinmapDataclass(temp=10) assert "got an unexpected keyword argument 'temp'" in str(excinfo) -def test_illegal_fieldnames(): - with pytest.raises(ValueError) as excinfo: +class Temp(binmap.BinmapDataclass): + temp: types.unsignedchar = 0 - class Space(binmap.Binmap): - _datafields = { - " ": "B", - } - assert "' ' is not a valid parameter name" in str(excinfo) - - with pytest.raises(ValueError) as excinfo: - - class BadName(binmap.Binmap): - _datafields = { - "-a": "B", - } - - assert "'-a' is not a valid parameter name" in str(excinfo) - with pytest.raises(ValueError) as excinfo: - - class Number(binmap.Binmap): - _datafields = { - "1": "B", - } - - assert "'1' is not a valid parameter name" in str(excinfo) - - -class Temp(binmap.Binmap): - _datafields = {"temp": "B"} - - -class TempHum(binmap.Binmap): - _datafields = {"temp": "B", "humidity": "B"} +class TempHum(binmap.BinmapDataclass): + temp: types.unsignedchar = 0 + humidity: types.unsignedchar = 0 def test_different_classes_eq(): @@ -60,17 +35,15 @@ def test_different_classes_eq(): assert t.temp == th.temp -class Bigendian(binmap.Binmap): - _datafields = {"value": "q"} +class Bigendian(binmap.BinmapDataclass): + value: types.longlong = 0 -class Littleedian(binmap.Binmap): - _byteorder = "<" - _datafields = {"value": "q"} +class Littleedian(binmap.BinmapDataclass, byteorder="<"): + value: types.longlong = 0 def test_dataformats(): - be = Bigendian(value=-10) le = Littleedian(value=-10) @@ -94,7 +67,8 @@ class TestTempClass: def test_with_argument(self): t = Temp(temp=10) assert t.temp == 10 - assert str(t) == "Temp, temp=10" + assert bytes(t) == b"\x0a" + assert str(t) == "Temp(temp=10)" def test_without_argument(self): t = Temp() @@ -156,7 +130,7 @@ def test_with_argument(self): th = TempHum(temp=10, humidity=60) assert th.temp == 10 assert th.humidity == 60 - assert str(th) == "TempHum, temp=10, humidity=60" + assert str(th) == "TempHum(temp=10, humidity=60)" def test_without_argument(self): th = TempHum() @@ -196,36 +170,36 @@ def test_compare_not_equal(self): assert th2 != th4 -class Pad(binmap.Binmap): - _datafields = {"temp": "B", "_pad1": "xx", "humidity": "B"} +class Pad(binmap.BinmapDataclass): + temp: types.unsignedchar = 0 + pad: types.pad = binmap.padding(2) + humidity: types.unsignedchar = 0 -class AdvancedPad(binmap.Binmap): - _datafields = { - "temp": "B", - "_pad1": "xx", - "humidity": "B", - "_pad2": "3x", - "_pad3": "x", - } +class AdvancedPad(binmap.BinmapDataclass): + temp: types.unsignedchar = 0 + _pad1: types.pad = binmap.padding(2) + humidity: types.unsignedchar = 0 + _pad2: types.pad = binmap.padding(3) + _pad3: types.pad = binmap.padding(1) class TestPadClass: def test_create_pad(self): p = Pad(temp=10, humidity=60) with pytest.raises(AttributeError) as excinfo: - p._pad1 - assert "Padding (_pad1) is not readable" in str(excinfo) + p.pad + assert "Padding (pad) is not readable" in str(excinfo) assert p.temp == 10 assert p.humidity == 60 - assert str(p) == "Pad, temp=10, humidity=60" + assert str(p) == "Pad(temp=10, humidity=60)" def test_parse_data(self): p = Pad(b"\x0a\x10\x20\x3c") with pytest.raises(AttributeError) as excinfo: - p._pad1 + p.pad + assert "Padding (pad) is not readable" in str(excinfo) assert p.temp == 10 - assert "Padding (_pad1) is not readable" in str(excinfo) assert p.humidity == 60 def test_pack_data(self): @@ -258,7 +232,7 @@ def test_advanced_parse_data(self): assert "Padding (_pad2) is not readable" in str(excinfo) assert p.humidity == 60 assert p.temp == 10 - assert str(p) == "AdvancedPad, temp=10, humidity=60" + assert str(p) == "AdvancedPad(temp=10, humidity=60)" def test_advanced_pack_data(self): p = AdvancedPad() @@ -267,71 +241,60 @@ def test_advanced_pack_data(self): assert bytes(p) == b"\n\x00\x00<\x00\x00\x00\x00" -class EnumClass(binmap.Binmap): - _datafields = { - "temp": "B", - "wind": "B", - } +class WindEnum(IntEnum): + North = 0 + East = 1 + South = 2 + West = 3 + - _enums = {"wind": {0: "North", 1: "East", 2: "South", 4: "West"}} +class EnumClass(binmap.BinmapDataclass): + temp: types.unsignedchar = 0 + wind: types.unsignedchar = binmap.enumfield(WindEnum, default=WindEnum.East) class TestEnumClass: def test_create_class(self): - pc = EnumClass() - assert pc - assert EnumClass.SOUTH == 2 + ec = EnumClass() + assert ec def test_get_enum(self): - pc = EnumClass(temp=10, wind=2) - assert pc.wind == "South" - assert str(pc) == "EnumClass, temp=10, wind=South" + ec = EnumClass(temp=10, wind=2) + assert ec.wind == WindEnum.South + assert str(ec) == "EnumClass(temp=10, wind=2)" def test_enum_binary(self): - pc = EnumClass(b"\x0a\x02") - assert pc.wind == "South" - assert str(pc) == "EnumClass, temp=10, wind=South" + ec = EnumClass(b"\x0a\x02") + assert ec.wind == WindEnum.South + assert str(ec) == "EnumClass(temp=10, wind=2)" def test_set_named_enum(self): - pc = EnumClass() - pc.wind = "South" - assert bytes(pc) == b"\x00\x02" + ec = EnumClass() + ec.wind = WindEnum.South + assert bytes(ec) == b"\x00\x02" - with pytest.raises(ValueError) as excinfo: - pc.wind = "Norhtwest" - assert "Unknown enum or value" in str(excinfo) - - with pytest.raises(ValueError) as excinfo: - pc.wind = 1.2 - assert "Unknown enum or value" in str(excinfo) + with pytest.raises(KeyError) as excinfo: + ec.wind = "Norhtwest" + assert "'Norhtwest'" in str(excinfo) - def test_colliding_enums(self): with pytest.raises(ValueError) as excinfo: + ec.wind = 1.2 + assert "1.2 is not a valid WindEnum" in str(excinfo) - class EnumCollide(binmap.Binmap): - _datafields = { - "wind1": "B", - "wind2": "B", - } - _enums = { - "wind1": {0: "North"}, - "wind2": {2: "North"}, - } - assert "North already defined" in str(excinfo) - - -class ConstValues(binmap.Binmap): - _datafields = {"datatype": "B", "status": "B"} - _constants = {"datatype": 0x15} +class ConstValues(binmap.BinmapDataclass): + datatype: types.unsignedchar = binmap.constant(0x15) + status: types.unsignedchar = 0 class TestConstValues: def test_create_class(self): c = ConstValues() - with pytest.raises(AttributeError) as excinfo: + with pytest.raises(TypeError) as excinfo: ConstValues(datatype=0x14, status=1) - assert "datatype is a constant" in str(excinfo) + assert "__init__() got an unexpected keyword argument 'datatype'" in str( + excinfo + ) assert c.datatype == 0x15 def test_set_value(self): @@ -352,27 +315,25 @@ def test_binary_data(self): assert c.status == 1 -class AllDatatypes(binmap.Binmap): - _datafields = { - "_pad": "x", - "char": "c", - "signedchar": "b", - "unsignedchar": "B", - "boolean": "?", - "short": "h", - "unsignedshort": "H", - "integer": "i", - "unsignedint": "I", - "long": "l", - "unsignedlong": "L", - "longlong": "q", - "unsignedlonglong": "Q", - "halffloat": "e", - "floating": "f", - "double": "d", - "string": "10s", - "pascalstring": "15p", - } +class AllDatatypes(binmap.BinmapDataclass): + _pad: types.pad = binmap.padding(1) + char: types.char = b"\x00" + signedchar: types.signedchar = 0 + unsignedchar: types.unsignedchar = 0 + boolean: types.boolean = False + short: types.short = 0 + unsignedshort: types.unsignedshort = 0 + integer: types.integer = 0 + unsignedint: types.unsignedinteger = 0 + long: types.long = 0 + unsignedlong: types.unsignedlong = 0 + longlong: types.longlong = 0 + unsignedlonglong: types.unsignedlonglong = 0 + halffloat: types.halffloat = 0.0 + floating: types.floating = 0.0 + double: types.double = 0.0 + string: types.string = binmap.stringfield(10) + pascalstring: types.pascalstring = binmap.stringfield(15) class TestAllDatatypes: diff --git a/tox.ini b/tox.ini index 65d6dda..aa3ee94 100644 --- a/tox.ini +++ b/tox.ini @@ -3,6 +3,12 @@ jobs = 1 max-line-length = 160 [isort] +multi_line_output=3 +include_trailing_comma=True +force_grid_wrap=0 +use_parentheses=True +line_length=88 + [tox] envlist = py37