├── .bumpversion.cfg ├── .github ├── dependabot.yml └── workflows │ ├── pythondist.yml │ └── pythonpackage.yml ├── .gitignore ├── .readthedocs.yml ├── LICENSE.txt ├── README.rst ├── binmap ├── __init__.py ├── st.py └── types.py ├── docs ├── Makefile ├── make.bat └── source │ ├── binmap.rst │ ├── conf.py │ └── index.rst ├── requirements-dev.txt ├── requirements-doc.txt ├── setup.py ├── tests ├── __init__.py └── test_binmap.py └── tox.ini /.bumpversion.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 1.2.1 3 | commit = True 4 | tag = True 5 | 6 | [bumpversion:file:setup.py] 7 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Set update schedule for GitHub Actions 2 | 3 | version: 2 4 | updates: 5 | 6 | - package-ecosystem: "github-actions" 7 | directory: "/" 8 | schedule: 9 | # Check for updates to GitHub Actions every week 10 | interval: "weekly" 11 | -------------------------------------------------------------------------------- /.github/workflows/pythondist.yml: -------------------------------------------------------------------------------- 1 | # This workflows will upload a Python Package using Twine when a release is created 2 | # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries 3 | 4 | name: Upload Python Package 5 | 6 | on: 7 | push: 8 | tags: 9 | - '*' 10 | 11 | jobs: 12 | deploy: 13 | 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | - name: Set up Python 19 | uses: actions/setup-python@v5 20 | with: 21 | python-version: '3.x' 22 | 23 | - name: Install setuptools 24 | run: | 25 | pip install setuptools 26 | 27 | - name: Build package 28 | run: | 29 | python setup.py sdist 30 | 31 | - name: Publish package 32 | uses: pypa/gh-action-pypi-publish@v1.12.4 33 | with: 34 | user: __token__ 35 | password: ${{ secrets.pypi }} 36 | -------------------------------------------------------------------------------- /.github/workflows/pythonpackage.yml: -------------------------------------------------------------------------------- 1 | name: Python package 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | python-version: ['3.9', '3.10', '3.11', '3.12', '3.13'] 12 | 13 | steps: 14 | - uses: actions/checkout@v4 15 | - name: Set up Python ${{ matrix.python-version }} 16 | uses: actions/setup-python@v5 17 | with: 18 | python-version: ${{ matrix.python-version }} 19 | - name: Install dependencies 20 | run: | 21 | python -m pip install --upgrade pip 22 | pip install -r requirements-dev.txt 23 | - name: Lint with flake8 24 | run: | 25 | # stop the build if there are Python syntax errors or undefined names 26 | flake8 binmap tests --count --select=E9,F63,F7,F82 --show-source --statistics 27 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 28 | flake8 binmap tests --count --exit-zero --max-complexity=10 --statistics 29 | - name: Check with mypy 30 | run: | 31 | mypy binmap 32 | 33 | - name: Test with pytest 34 | run: | 35 | python -m pytest -v 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.snap 2 | prime/ 3 | stage/ 4 | *~ 5 | # Byte-compiled / optimized / DLL files 6 | __pycache__/ 7 | *.py[cod] 8 | *$py.class 9 | 10 | # C extensions 11 | *.so 12 | 13 | # Distribution / packaging 14 | .Python 15 | build/ 16 | develop-eggs/ 17 | dist/ 18 | downloads/ 19 | eggs/ 20 | .eggs/ 21 | lib/ 22 | lib64/ 23 | parts/ 24 | sdist/ 25 | var/ 26 | wheels/ 27 | *.egg-info/ 28 | .installed.cfg 29 | *.egg 30 | MANIFEST 31 | 32 | # PyInstaller 33 | # Usually these files are written by a python script from a template 34 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 35 | *.manifest 36 | *.spec 37 | 38 | # Installer logs 39 | pip-log.txt 40 | pip-delete-this-directory.txt 41 | 42 | # Unit test / coverage reports 43 | htmlcov/ 44 | .tox/ 45 | .coverage 46 | .coverage.* 47 | .cache 48 | nosetests.xml 49 | coverage.xml 50 | *.cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | 63 | # Flask stuff: 64 | instance/ 65 | .webassets-cache 66 | 67 | # Scrapy stuff: 68 | .scrapy 69 | 70 | # Sphinx documentation 71 | docs/_build/ 72 | 73 | # PyBuilder 74 | target/ 75 | 76 | # Jupyter Notebook 77 | .ipynb_checkpoints 78 | 79 | # pyenv 80 | .python-version 81 | 82 | # celery beat schedule file 83 | celerybeat-schedule 84 | 85 | # SageMath parsed files 86 | *.sage.py 87 | 88 | # Environments 89 | .env 90 | .venv 91 | env/ 92 | venv/ 93 | ENV/ 94 | env.bak/ 95 | venv.bak/ 96 | 97 | # Spyder project settings 98 | .spyderproject 99 | .spyproject 100 | 101 | # Rope project settings 102 | .ropeproject 103 | 104 | # mkdocs documentation 105 | /site 106 | 107 | # mypy 108 | .mypy_cache/ 109 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yml 2 | # Read the Docs configuration file 3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 4 | 5 | # Required 6 | version: 2 7 | 8 | # Build documentation in the docs/ directory with Sphinx 9 | sphinx: 10 | configuration: docs/source/conf.py 11 | 12 | 13 | # Optionally set the version of Python and requirements required to build your docs 14 | python: 15 | version: 3.7 16 | install: 17 | - requirements: requirements-doc.txt 18 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | Copyright (c) 2020 Jimmy Hedman 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | The above copyright notice and this permission notice shall be included in all 10 | copies or substantial portions of the Software. 11 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 12 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 13 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 14 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 15 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 16 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 17 | SOFTWARE. 18 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Dataclass for go to and from binary data 2 | 3 | 4 | It follows dataclass pattern with typehinting as the binary format. 5 | Temperature with one unsigned byte: 6 | 7 | .. code-block:: python 8 | 9 | >>> class Temperature(BinmapDataclass): 10 | ... temp: unsignedchar = 0 11 | ... 12 | >>> t = Temperature() 13 | >>> t.temp = 22 14 | >>> print(bytes(t)) 15 | b'\x16' 16 | 17 | >>> t2 = Temperature(b'\x20') 18 | >>> print(t2.temp) 19 | 32 20 | 21 | Temperature and humidity consisting of one signed byte for temperature and 22 | one unsiged byte for humidity: 23 | 24 | .. code-block:: python 25 | 26 | >>> class TempHum(BinmapDataclass): 27 | ... temp: signedchar = 0 28 | ... hum: unsignedchar = 0 29 | ... 30 | >>> th = TempHum() 31 | >>> th.temp = -10 32 | >>> th.humidity = 60 33 | >>> print(bytes(th)) 34 | b'\xfc<' 35 | 36 | >>> th2 = TempHum(b'\xea\x41') 37 | >>> print(th2.temp) 38 | -22 39 | >>> print(th2.hum) 40 | 65 41 | 42 | 43 | 44 | Datatypes 45 | --------- 46 | Binmap supports all datatypes that standard library `struct `_ has. 47 | The types works as typehints in the dataclass. When giving an attribute a 48 | typehinted datatype it will be added to the binary output from the class. 49 | 50 | 51 | Padding 52 | ------- 53 | Padding is a field type and datatype that is `length` bytes long, and will not show up as 54 | attributes in the dataclass. Trying to assign it a value will be silently 55 | ignored and reading from will raise the `AttributeException` exception. 56 | 57 | .. code-block:: python 58 | 59 | >>> class PaddedData(BinmapDataclass): 60 | ... temp: signedchar = 0 61 | ... pad1: pad = padding(length = 5) 62 | ... 63 | >>> pd = PaddedData() 64 | >>> pd.temp = 14 65 | >>> print(bytes(pd)) 66 | b'\x0e\x00\x00\x00\x00\x00' 67 | 68 | Constant 69 | -------- 70 | Constant is fieldtype that always is the given value. Constant could be of any 71 | datatype. 72 | 73 | .. code-block:: python 74 | 75 | >>> class Constant(BinmapDataclass): 76 | ... signature: unsignedshort = constant(0x1313) 77 | ... temp: unsignedchar = 0 78 | ... 79 | >>> c = Constant() 80 | >>> c.temp = 18 81 | >>> print(bytes(c)) 82 | b'\x13\x13\x12' 83 | 84 | >>> print(c.signature) 85 | 4883 86 | >>> print(c.temp) 87 | 18 88 | 89 | >>> c.signature = 10 90 | AttributeError: signature is a constant 91 | 92 | Enums 93 | ----- 94 | Enumfield maps agaings IntEnum or IntFlag so that you could set the value 95 | either as the enum or as the numeric value. 96 | 97 | .. code-block:: python 98 | 99 | >>> class WindEnum(IntEnum): 100 | ... North = 0 101 | ... East = 1 102 | ... South = 2 103 | ... West = 3 104 | ... 105 | >>> class Wind(BinmapDataclass): 106 | ... speed: unsignedchar = 0 107 | ... direction: unsignedchar = enumfield(WindEnum, default=WindEnum.East) 108 | ... 109 | >>> w = Wind() 110 | >>> print(w) 111 | Wind(speed=0, direction=) 112 | >>> w.direction = WindEnum.West 113 | >>> print(w.direction) 114 | 115 | >>> w.direction = 2 116 | >>> print(w.direction) 117 | 118 | 119 | 120 | Autolength 121 | ---------- 122 | Autolenght field types counts number of bytes in the output, including the 123 | autolength field it self. You can't set an autolenght field. Autolength can be 124 | offseted, for example to ignore it's own length. 125 | 126 | .. code-block:: python 127 | 128 | >>> class MyBinStruct(BinmapDataclass): 129 | ... length: unsignedchar = autolength() 130 | ... temp: signedchar = 0 131 | ... 132 | >>> mb = MyBinStruct() 133 | >>> print(mb) 134 | MyBinStruct(length=2, temp=0) 135 | >>> mb.length = 10 136 | AttributeError: length is a constant 137 | 138 | Calculated fields 139 | ----------------- 140 | Calculated fields calls a function when data is converted to binary value. The 141 | function must be declared when the field is added. 142 | 143 | .. code-block:: python 144 | 145 | >>> class WithChecksum(BinmapDataclass): 146 | ... temp: signedchar = 0 147 | ... hum: unsignedchar = 0 148 | ... def chk(self) -> unsignedchar: 149 | ... return (self.temp + self.hum) & 0xFF 150 | ... checksum: unsignedchar = calculatedfield(chk) 151 | ... 152 | >>> wc = WithChecksum() 153 | >>> wc.temp = -20 154 | >>> wc.hum = 10 155 | >>> print(wc) 156 | WithChecksum(temp=-20, hum=10, checksum=246) 157 | >>> print(bytes(wc)) 158 | b'\xec\n\xf6' 159 | 160 | 161 | -------------------------------------------------------------------------------- /binmap/__init__.py: -------------------------------------------------------------------------------- 1 | import dataclasses 2 | import struct 3 | from enum import IntEnum, IntFlag 4 | from functools import partial 5 | from typing import Callable, ClassVar, Dict, List, Tuple, Type, Union, get_type_hints 6 | 7 | from binmap import types as b_types 8 | 9 | 10 | class BaseDescriptor: 11 | """Base class for all descriptors 12 | 13 | :param name: Variable name""" 14 | 15 | def __set_name__(self, obj, name): 16 | self.name = name 17 | 18 | def __init__(self, name=""): 19 | self.name = name 20 | 21 | 22 | class BinField(BaseDescriptor): 23 | """BinField descriptor tries to pack it into a struct before setting the 24 | value as a bounds checker""" 25 | 26 | def __get__(self, obj, owner): 27 | return obj.__dict__[self.name] 28 | 29 | def __set__(self, obj, value): 30 | type_hints = get_type_hints(obj) 31 | if self.name in type_hints: 32 | struct.pack(datatypemapping[type_hints[self.name]][1], value) 33 | else: 34 | found = False 35 | for base in type(obj).__bases__: 36 | type_hints = get_type_hints(base) 37 | if self.name in type_hints: 38 | struct.pack(datatypemapping[type_hints[self.name]][1], value) 39 | found = True 40 | 41 | if not found: 42 | raise ValueError(self.name) 43 | obj.__dict__[self.name] = value 44 | 45 | 46 | class PaddingField(BaseDescriptor): 47 | """PaddingField descriptor is used to "pad" data with values unused for real data 48 | 49 | :raises AttributeError: when trying to read, since it's only padding.""" 50 | 51 | def __get__(self, obj, owner): 52 | """Getting values fails""" 53 | raise AttributeError(f"Padding ({self.name}) is not readable") 54 | 55 | def __set__(self, obj, value): 56 | """Setting values does nothing""" 57 | 58 | 59 | class EnumField(BinField): 60 | """EnumField descriptor uses "enum" to map to and from strings. Accepts 61 | both strings and values when setting. Only values that has a corresponding 62 | string is allowed.""" 63 | 64 | def __set__(self, obj, value): 65 | datafieldsmap = {f.name: f for f in dataclasses.fields(obj)} 66 | if isinstance(value, str): 67 | datafieldsmap[self.name].metadata["enum"][value] 68 | else: 69 | datafieldsmap[self.name].metadata["enum"](value) 70 | obj.__dict__[self.name] = value 71 | 72 | 73 | class ConstField(BinField): 74 | """ConstField descriptor keeps it's value 75 | 76 | :raises AttributeError: Since it's a constant it raises and error when 77 | trying to set""" 78 | 79 | def __set__(self, obj, value): 80 | if self.name in obj.__dict__: 81 | raise AttributeError(f"{self.name} is a constant") 82 | obj.__dict__[self.name] = value 83 | 84 | 85 | class CalculatedField(BinField): 86 | """CalculatedField calls a function when it's converted to bytes 87 | 88 | :raises AttributeError: Trying to set the value is not allowed""" 89 | 90 | def __init__(self, name, function): 91 | self.name = name 92 | self.function = function 93 | 94 | def __get__(self, obj, owner): 95 | return self.function(obj) 96 | 97 | def __set__(self, obj, value): 98 | if self.name in obj.__dict__: 99 | raise AttributeError("Can't set a calculated field") 100 | 101 | 102 | datatypemapping: Dict[type, Tuple[Type[BaseDescriptor], str]] = { 103 | b_types.char: (BinField, "c"), 104 | b_types.signedchar: (BinField, "b"), 105 | b_types.unsignedchar: (BinField, "B"), 106 | b_types.boolean: (BinField, "?"), 107 | bool: (BinField, "?"), 108 | b_types.short: (BinField, "h"), 109 | b_types.unsignedshort: (BinField, "H"), 110 | b_types.integer: (BinField, "i"), 111 | int: (BinField, "i"), 112 | b_types.unsignedinteger: (BinField, "I"), 113 | b_types.long: (BinField, "l"), 114 | b_types.unsignedlong: (BinField, "L"), 115 | b_types.longlong: (BinField, "q"), 116 | b_types.unsignedlonglong: (BinField, "Q"), 117 | b_types.halffloat: (BinField, "e"), 118 | b_types.floating: (BinField, "f"), 119 | float: (BinField, "f"), 120 | b_types.double: (BinField, "d"), 121 | b_types.string: (BinField, "s"), 122 | str: (BinField, "s"), 123 | b_types.pascalstring: (BinField, "p"), 124 | b_types.pad: (PaddingField, "x"), 125 | } 126 | 127 | 128 | def padding(length: int = 1) -> dataclasses.Field: 129 | """ 130 | Field generator function for padding elements 131 | 132 | :param int lenght: Number of bytes of padded field 133 | :return: dataclass field 134 | """ 135 | return dataclasses.field(default=length, repr=False, metadata={"padding": True}) # type: ignore 136 | 137 | 138 | def constant(value: Union[int, float, str]) -> dataclasses.Field: 139 | """ 140 | Field generator function for constant elements 141 | 142 | :param value: Constant value for the field. 143 | :return: dataclass field 144 | """ 145 | return dataclasses.field(default=value, init=False, metadata={"constant": True}) # type: ignore 146 | 147 | 148 | def autolength(offset: int = 0) -> dataclasses.Field: 149 | """ 150 | Field generator function for autolength fields 151 | 152 | :param offset: offset for the lenght calculation 153 | :return: dataclass field 154 | """ 155 | return dataclasses.field(default=offset, init=False, metadata={"autolength": True}) # type: ignore 156 | 157 | 158 | def stringfield(length: int = 1, default: bytes = b"") -> dataclasses.Field: 159 | """ 160 | Field generator function for string fields. 161 | 162 | :param int lenght: lengt of the string. 163 | :param bytes default: default value of the string 164 | :param bytes fillchar: char to pad the string with 165 | :return: dataclass field 166 | """ 167 | if default == b"": 168 | default = b"\x00" * length 169 | return dataclasses.field(default=default, metadata={"length": length}) # type: ignore 170 | 171 | 172 | def enumfield( 173 | enumclass: Union[IntEnum, IntFlag], default: Union[IntEnum, IntFlag, int, None] = None 174 | ) -> dataclasses.Field: 175 | """ 176 | Field generator function for enum field 177 | 178 | :param IntEnum enumclass: Class with enums. 179 | :param IntEnum default: default value 180 | :return: dataclass field 181 | """ 182 | return dataclasses.field(default=default, metadata={"enum": enumclass}) # type: ignore 183 | 184 | 185 | def calculatedfield(function: Callable, last=False) -> dataclasses.Field: 186 | """ 187 | Field generator function for calculated fields 188 | 189 | :param Callable function: function that calculates the field. 190 | :return: dataclass field 191 | """ 192 | return dataclasses.field(default=0, metadata={"function": function, "last": last}) # type: ignore 193 | 194 | 195 | @dataclasses.dataclass 196 | class BinmapDataclass: 197 | """ 198 | Dataclass that does the converting to and from binary data 199 | """ 200 | 201 | __binarydata: dataclasses.InitVar[bytes] = b"" 202 | __datafields: ClassVar[List[str]] 203 | __datafieldsmap: ClassVar[Dict] 204 | __formatstring: ClassVar[str] 205 | 206 | def __init_subclass__(cls, byteorder: str = ">"): 207 | """ 208 | Subclass initiator. This makes the inheriting class a dataclass. 209 | :param str byteorder: byteorder for binary data 210 | """ 211 | dataclasses.dataclass(cls) 212 | type_hints = get_type_hints(cls) 213 | 214 | cls.__formatstring = byteorder 215 | cls.__datafieldsmap = {} 216 | cls.__datafields = [] 217 | 218 | lastfield = "" 219 | for field_ in dataclasses.fields(cls): 220 | if field_.name.startswith("_BinmapDataclass__"): 221 | continue 222 | _base, _type = datatypemapping[type_hints[field_.name]] 223 | if "constant" in field_.metadata: 224 | _base = ConstField 225 | elif "enum" in field_.metadata: 226 | _base = EnumField 227 | elif "autolength" in field_.metadata: 228 | _base = ConstField 229 | elif "function" in field_.metadata: 230 | _base = partial(CalculatedField, function=field_.metadata["function"]) # type: ignore 231 | setattr(cls, field_.name, _base(name=field_.name)) 232 | if type_hints[field_.name] is b_types.pad: 233 | _type = field_.default * _type # type: ignore 234 | if type_hints[field_.name] in (b_types.string, b_types.pascalstring, str): 235 | _type = str(field_.metadata["length"]) + _type 236 | if "last" in field_.metadata and field_.metadata["last"]: 237 | if lastfield != "": 238 | raise ValueError("Can't have more than one last") 239 | lastfield = _type 240 | else: 241 | cls.__formatstring += _type 242 | cls.__formatstring += lastfield 243 | 244 | def __bytes__(self): 245 | """ 246 | Packs the class' fields to a binary string 247 | :return: Binary string packed. 248 | :rtype: bytes 249 | """ 250 | values = [] 251 | lastvalue = None 252 | for k, v in self.__dict__.items(): 253 | if k.startswith("_BinmapDataclass__"): 254 | continue 255 | if callable(v): 256 | v = v(self) 257 | if ( 258 | "last" in self.__datafieldsmap[k].metadata 259 | and self.__datafieldsmap[k].metadata["last"] 260 | ): 261 | lastvalue = v 262 | continue 263 | values.append(v) 264 | if lastvalue is not None: 265 | values.append(lastvalue) 266 | 267 | return struct.pack( 268 | self.__formatstring, 269 | *values, 270 | ) 271 | 272 | def __post_init__(self, _binarydata: bytes): 273 | """ 274 | Initialises fields from a binary string 275 | :param bytes _binarydata: Binary string that will be unpacked. 276 | """ 277 | # Kludgy hack to keep order 278 | for f in dataclasses.fields(self): 279 | if f.name.startswith("_BinmapDataclass__"): 280 | continue 281 | self.__datafieldsmap.update({f.name: f}) 282 | if "padding" in f.metadata: 283 | continue 284 | if "constant" in f.metadata: 285 | self.__dict__.update({f.name: f.default}) 286 | if "autolength" in f.metadata: 287 | self.__dict__.update( 288 | {f.name: struct.calcsize(self.__formatstring) + f.default} # type: ignore 289 | ) 290 | if "function" in f.metadata: 291 | self.__dict__.update({f.name: f.metadata["function"]}) 292 | else: 293 | val = getattr(self, f.name) 294 | del self.__dict__[f.name] 295 | self.__dict__.update({f.name: val}) 296 | self.__datafields.append(f.name) 297 | if _binarydata != b"": 298 | self.frombytes(_binarydata) 299 | 300 | def frombytes(self, value: bytes): 301 | """ 302 | Unpacks value to each field 303 | :param bytes value: binary string to unpack 304 | """ 305 | args = struct.unpack(self.__formatstring, value) 306 | for arg, name in zip(args, self.__datafields): 307 | if "constant" in self.__datafieldsmap[name].metadata: 308 | if arg != self.__datafieldsmap[name].default: 309 | raise ValueError("Constant doesn't match binary data") 310 | elif "autolength" in self.__datafieldsmap[name].metadata: 311 | if arg != getattr(self, name): 312 | raise ValueError("Length doesn't match") 313 | elif "function" in self.__datafieldsmap[name].metadata: 314 | if arg != self.__datafieldsmap[name].metadata["function"](self): 315 | raise ValueError("Wrong calculated value") 316 | else: 317 | setattr(self, name, arg) 318 | -------------------------------------------------------------------------------- /binmap/st.py: -------------------------------------------------------------------------------- 1 | from binmap import types 2 | 3 | _b = types.boolean 4 | c = types.char 5 | d = types.double 6 | f = types.floating 7 | e = types.halffloat 8 | i = types.integer 9 | l = types.long # noqa: E741 10 | q = types.longlong 11 | x = types.pad 12 | p = types.pascalstring 13 | h = types.short 14 | b = types.signedchar 15 | s = types.string 16 | B = types.unsignedchar 17 | I = types.unsignedinteger # noqa: E741 18 | L = types.unsignedlong 19 | Q = types.unsignedlonglong 20 | H = types.unsignedshort 21 | -------------------------------------------------------------------------------- /binmap/types.py: -------------------------------------------------------------------------------- 1 | from typing import NewType 2 | 3 | char = NewType("char", int) 4 | signedchar = NewType("signedchar", int) 5 | unsignedchar = NewType("unsignedchar", int) 6 | boolean = NewType("boolean", bool) 7 | short = NewType("short", int) 8 | unsignedshort = NewType("unsignedshort", int) 9 | integer = NewType("integer", int) 10 | unsignedinteger = NewType("unsignedinteger", int) 11 | long = NewType("long", int) 12 | unsignedlong = NewType("unsignedlong", int) 13 | longlong = NewType("longlong", int) 14 | unsignedlonglong = NewType("unsignedlonglong", int) 15 | halffloat = NewType("halffloat", float) 16 | floating = NewType("floating", float) 17 | double = NewType("double", float) 18 | string = NewType("string", str) 19 | pascalstring = NewType("pascalstring", str) 20 | pad = NewType("pad", int) 21 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = source 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=source 11 | set BUILDDIR=build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /docs/source/binmap.rst: -------------------------------------------------------------------------------- 1 | binmap 2 | ====== 3 | 4 | .. automodule:: binmap 5 | :members: 6 | :show-inheritance: 7 | :private-members: 8 | 9 | .. _format strings: https://docs.python.org/3/library/struct.html#format-strings 10 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | import os 14 | import sys 15 | 16 | import sphinx_rtd_theme 17 | 18 | sys.path.insert(0, os.path.abspath("../..")) 19 | 20 | 21 | # -- Project information ----------------------------------------------------- 22 | 23 | project = "Binmap" 24 | copyright = "2020, Jimmy Hedman" 25 | author = "Jimmy Hedman" 26 | 27 | 28 | # -- General configuration --------------------------------------------------- 29 | master_doc = "index" 30 | 31 | # Add any Sphinx extension module names here, as strings. They can be 32 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 33 | # ones. 34 | extensions = [ 35 | "sphinx.ext.autodoc", 36 | "sphinx_rtd_theme", 37 | ] 38 | 39 | # Add any paths that contain templates here, relative to this directory. 40 | templates_path = ["_templates"] 41 | 42 | # List of patterns, relative to source directory, that match files and 43 | # directories to ignore when looking for source files. 44 | # This pattern also affects html_static_path and html_extra_path. 45 | exclude_patterns = [] 46 | 47 | 48 | # -- Options for HTML output ------------------------------------------------- 49 | 50 | # The theme to use for HTML and HTML Help pages. See the documentation for 51 | # a list of builtin themes. 52 | # 53 | html_theme = "sphinx_rtd_theme" 54 | 55 | # Add any paths that contain custom static files (such as style sheets) here, 56 | # relative to this directory. They are copied after the builtin static files, 57 | # so a file named "default.css" will overwrite the builtin "default.css". 58 | html_static_path = ["_static"] 59 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | .. Binmap documentation master file, created by 2 | sphinx-quickstart on Mon Feb 10 16:13:15 2020. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to Binmap's documentation! 7 | ================================== 8 | 9 | .. include:: ../../README.rst 10 | 11 | .. toctree:: 12 | :maxdepth: 2 13 | :caption: Contents: 14 | 15 | binmap 16 | 17 | 18 | 19 | Indices and tables 20 | ================== 21 | 22 | * :ref:`genindex` 23 | * :ref:`modindex` 24 | * :ref:`search` 25 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | black 2 | bump2version 3 | flake8 4 | flake8-black 5 | flake8-builtins 6 | flake8-isort 7 | ipython 8 | jedi 9 | mypy 10 | pylint>=2.6.1 11 | pytest 12 | tox 13 | twine 14 | requests>=2.32.0 # not directly required, pinned by Snyk to avoid a vulnerability 15 | setuptools>=78.1.1 # not directly required, pinned by Snyk to avoid a vulnerability 16 | -------------------------------------------------------------------------------- /requirements-doc.txt: -------------------------------------------------------------------------------- 1 | sphinx 2 | sphinx_rtd_theme>=0.3.1 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from os import path 2 | 3 | from setuptools import find_packages, setup 4 | 5 | this_directory = path.abspath(path.dirname(__file__)) 6 | with open(path.join(this_directory, "README.rst")) as f: 7 | long_description = f.read() 8 | 9 | setup( 10 | name="binmap", 11 | version="1.2.1", 12 | author="Jimmy Hedman", 13 | author_email="jimmy.hedman@gmail.com", 14 | description="A base class for creating binary parsing and packing classes", 15 | long_description=long_description, 16 | long_description_content_type="text/x-rst", 17 | url="https://github.com/HeMan/binmap", 18 | packages=find_packages(), 19 | classifiers=[ 20 | "Development Status :: 5 - Production/Stable", 21 | "Programming Language :: Python :: 3", 22 | "Programming Language :: Python :: 3.8", 23 | "Programming Language :: Python :: 3.9", 24 | "Programming Language :: Python :: 3.10", 25 | "Programming Language :: Python :: 3.11", 26 | "Programming Language :: Python :: 3.12", 27 | "Programming Language :: Python :: 3.13", 28 | ], 29 | ) 30 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HeMan/binmap/6870090ac9f2c9c8ded281c4c453184eedfadecd/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_binmap.py: -------------------------------------------------------------------------------- 1 | import struct 2 | from dataclasses import asdict, astuple 3 | from enum import IntEnum, IntFlag 4 | 5 | import pytest 6 | 7 | import binmap 8 | from binmap import b_types 9 | 10 | 11 | def test_baseclass(): 12 | b = binmap.BinmapDataclass() 13 | assert type(b) == binmap.BinmapDataclass 14 | assert str(b) == "BinmapDataclass()" 15 | 16 | 17 | def test_baseclass_with_keyword(): 18 | with pytest.raises(TypeError) as excinfo: 19 | binmap.BinmapDataclass(temp=10) 20 | assert "got an unexpected keyword argument 'temp'" in str(excinfo) 21 | 22 | 23 | class Temp(binmap.BinmapDataclass): 24 | temp: b_types.unsignedchar = 0 25 | 26 | 27 | class TempHum(binmap.BinmapDataclass): 28 | temp: b_types.unsignedchar = 0 29 | humidity: b_types.unsignedchar = 0 30 | 31 | 32 | def test_different_classes_eq(): 33 | t = Temp(temp=10) 34 | th = TempHum(temp=10, humidity=60) 35 | assert t != th 36 | assert t.temp == th.temp 37 | 38 | 39 | class Bigendian(binmap.BinmapDataclass): 40 | value: b_types.longlong = 0 41 | 42 | 43 | class Littleedian(binmap.BinmapDataclass, byteorder="<"): 44 | value: b_types.longlong = 0 45 | 46 | 47 | def test_dataformats(): 48 | be = Bigendian(value=-10) 49 | le = Littleedian(value=-10) 50 | 51 | assert be.value == le.value 52 | assert bytes(be) == b"\xff\xff\xff\xff\xff\xff\xff\xf6" 53 | assert bytes(le) == b"\xf6\xff\xff\xff\xff\xff\xff\xff" 54 | 55 | assert bytes(be) != bytes(le) 56 | 57 | be.frombytes(b"\xff\xff\xff\xff\xf4\xff\xff\xf6") 58 | le.frombytes(b"\xff\xff\xff\xff\xf4\xff\xff\xf6") 59 | 60 | assert be.value == -184549386 61 | assert le.value == -648518393585991681 62 | 63 | assert be.value != le.value 64 | assert bytes(be) == bytes(le) 65 | 66 | 67 | class TestTempClass: 68 | def test_with_argument(self): 69 | t = Temp(temp=10) 70 | assert t.temp == 10 71 | assert bytes(t) == b"\x0a" 72 | assert str(t) == "Temp(temp=10)" 73 | assert asdict(t) == {"temp": 10} 74 | assert astuple(t) == (10,) 75 | 76 | def test_without_argument(self): 77 | t = Temp() 78 | assert t.temp == 0 79 | assert bytes(t) == b"\x00" 80 | 81 | def test_unknown_argument(self): 82 | with pytest.raises(TypeError) as excinfo: 83 | Temp(hum=60) 84 | assert "got an unexpected keyword argument 'hum'" in str(excinfo) 85 | 86 | def test_value(self): 87 | t = Temp() 88 | t.temp = 10 89 | assert bytes(t) == b"\x0a" 90 | 91 | def test_raw(self): 92 | t = Temp(b"\x0a") 93 | assert t.temp == 10 94 | 95 | def test_update_binarydata(self): 96 | t = Temp(b"\x0a") 97 | assert t.temp == 10 98 | t.frombytes(b"\x14") 99 | assert t.temp == 20 100 | 101 | def test_change_value(self): 102 | t = Temp(temp=10) 103 | assert bytes(t) == b"\x0a" 104 | 105 | t.temp = 20 106 | assert bytes(t) == b"\x14" 107 | 108 | def test_value_bounds(self): 109 | t = Temp() 110 | with pytest.raises(struct.error) as excinfo: 111 | t.temp = 256 112 | assert "format requires 0 <= number <= 255" in str(excinfo) 113 | 114 | with pytest.raises(struct.error) as excinfo: 115 | t.temp = -1 116 | assert "format requires 0 <= number <= 255" in str(excinfo) 117 | 118 | def test_compare_equal(self): 119 | t1 = Temp(temp=10) 120 | t2 = Temp(temp=10) 121 | assert t1.temp == t2.temp 122 | assert t1 == t2 123 | 124 | def test_compare_not_equal(self): 125 | t1 = Temp(temp=10) 126 | t2 = Temp(temp=20) 127 | assert t1.temp != t2.temp 128 | assert t1 != t2 129 | 130 | 131 | class TestTempHumClass: 132 | def test_with_argument(self): 133 | th = TempHum(temp=10, humidity=60) 134 | assert th.temp == 10 135 | assert th.humidity == 60 136 | assert str(th) == "TempHum(temp=10, humidity=60)" 137 | assert asdict(th) == {"temp": 10, "humidity": 60} 138 | assert astuple(th) == (10, 60) 139 | 140 | def test_without_argument(self): 141 | th = TempHum() 142 | assert th.temp == 0 143 | assert th.humidity == 0 144 | assert bytes(th) == b"\x00\x00" 145 | 146 | def test_raw(self): 147 | th = TempHum(b"\x0a\x46") 148 | assert th.temp == 10 149 | assert th.humidity == 70 150 | 151 | def test_change_values(self): 152 | th = TempHum(temp=10, humidity=70) 153 | th.temp = 30 154 | th.humidity = 30 155 | assert th.temp == 30 156 | assert th.humidity == 30 157 | assert bytes(th) == b"\x1e\x1e" 158 | 159 | def test_compare_equal(self): 160 | th1 = TempHum(temp=10, humidity=70) 161 | th2 = TempHum(temp=10, humidity=70) 162 | assert th1.temp == th2.temp 163 | assert th1 == th2 164 | 165 | def test_compare_not_equal(self): 166 | th1 = TempHum(temp=10, humidity=70) 167 | th2 = TempHum(temp=20, humidity=60) 168 | th3 = TempHum(temp=10, humidity=60) 169 | th4 = TempHum(temp=20, humidity=70) 170 | assert (th1.temp != th2.temp) and (th1.humidity != th2.humidity) 171 | assert th1 != th2 172 | assert th1 != th3 173 | assert th1 != th4 174 | assert th2 != th3 175 | assert th2 != th4 176 | 177 | 178 | class Strings(binmap.BinmapDataclass): 179 | identity: b_types.string = binmap.stringfield(10) 180 | 181 | 182 | class StringWithDefault(binmap.BinmapDataclass): 183 | defaultstring: b_types.string = binmap.stringfield(10, default=b"hellohello") 184 | 185 | 186 | class TestStrings: 187 | def test_strings(self): 188 | s = Strings() 189 | assert ( 190 | str(s) 191 | == "Strings(identity=b'\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00')" 192 | ) 193 | s1 = Strings(identity=b"1234567890") 194 | assert s1.identity == b"1234567890" 195 | assert asdict(s1) == {"identity": b"1234567890"} 196 | assert astuple(s1) == (b"1234567890",) 197 | 198 | def test_defaultstring(self): 199 | sd = StringWithDefault() 200 | assert str(sd) == "StringWithDefault(defaultstring=b'hellohello')" 201 | sd1 = StringWithDefault(defaultstring=b"worldworld") 202 | assert sd1.defaultstring == b"worldworld" 203 | 204 | 205 | class Pad(binmap.BinmapDataclass): 206 | temp: b_types.unsignedchar = 0 207 | pad: b_types.pad = binmap.padding(2) 208 | humidity: b_types.unsignedchar = 0 209 | 210 | 211 | class AdvancedPad(binmap.BinmapDataclass): 212 | temp: b_types.unsignedchar = 0 213 | _pad1: b_types.pad = binmap.padding(2) 214 | humidity: b_types.unsignedchar = 0 215 | _pad2: b_types.pad = binmap.padding(3) 216 | _pad3: b_types.pad = binmap.padding(1) 217 | 218 | 219 | class TestPadClass: 220 | def test_create_pad(self): 221 | p = Pad(temp=10, humidity=60) 222 | with pytest.raises(AttributeError) as excinfo: 223 | p.pad 224 | assert "Padding (pad) is not readable" in str(excinfo) 225 | assert p.temp == 10 226 | assert p.humidity == 60 227 | assert str(p) == "Pad(temp=10, humidity=60)" 228 | # TODO: make it work with asdict/astuple 229 | return 230 | assert asdict(p) == {"temp": 10, "humidity": 60} 231 | assert astuple(p) == (10, 60) 232 | 233 | def test_parse_data(self): 234 | p = Pad(b"\x0a\x10\x20\x3c") 235 | with pytest.raises(AttributeError) as excinfo: 236 | p.pad 237 | assert "Padding (pad) is not readable" in str(excinfo) 238 | assert p.temp == 10 239 | assert p.humidity == 60 240 | 241 | def test_pack_data(self): 242 | p = Pad() 243 | p.temp = 10 244 | p.humidity = 60 245 | assert bytes(p) == b"\x0a\x00\x00\x3c" 246 | 247 | def test_advanced_pad(self): 248 | p = AdvancedPad(temp=10, humidity=60) 249 | with pytest.raises(AttributeError) as excinfo: 250 | p._pad1 251 | assert "Padding (_pad1) is not readable" in str(excinfo) 252 | with pytest.raises(AttributeError) as excinfo: 253 | p._pad2 254 | assert "Padding (_pad2) is not readable" in str(excinfo) 255 | with pytest.raises(AttributeError) as excinfo: 256 | p._pad3 257 | assert "Padding (_pad3) is not readable" in str(excinfo) 258 | assert p.temp == 10 259 | assert p.humidity == 60 260 | 261 | def test_advanced_parse_data(self): 262 | p = AdvancedPad(b"\n\x00\x00<\x00\x00\x00\x00") 263 | with pytest.raises(AttributeError) as excinfo: 264 | p._pad1 265 | assert "Padding (_pad1) is not readable" in str(excinfo) 266 | with pytest.raises(AttributeError) as excinfo: 267 | p._pad2 268 | assert "Padding (_pad2) is not readable" in str(excinfo) 269 | assert p.humidity == 60 270 | assert p.temp == 10 271 | assert str(p) == "AdvancedPad(temp=10, humidity=60)" 272 | 273 | def test_advanced_pack_data(self): 274 | p = AdvancedPad() 275 | p.temp = 10 276 | p.humidity = 60 277 | assert bytes(p) == b"\n\x00\x00<\x00\x00\x00\x00" 278 | 279 | 280 | class WindEnum(IntEnum): 281 | North = 0 282 | East = 1 283 | South = 2 284 | West = 3 285 | 286 | 287 | class FlagEnum(IntFlag): 288 | R = 4 289 | W = 2 290 | X = 1 291 | 292 | 293 | class EnumClass(binmap.BinmapDataclass): 294 | temp: b_types.unsignedchar = 0 295 | wind: b_types.unsignedchar = binmap.enumfield(WindEnum, default=WindEnum.East) 296 | 297 | 298 | class FlagClass(binmap.BinmapDataclass): 299 | perm: b_types.unsignedchar = binmap.enumfield(FlagEnum, default=0) 300 | 301 | 302 | class TestEnumClass: 303 | def test_create_class(self): 304 | ec = EnumClass() 305 | assert ec 306 | 307 | def test_get_enum(self): 308 | ec = EnumClass(temp=10, wind=2) 309 | assert ec.wind == WindEnum.South 310 | assert str(ec) == "EnumClass(temp=10, wind=2)" 311 | assert asdict(ec) == {"temp": 10, "wind": 2} 312 | assert astuple(ec) == (10, 2) 313 | 314 | def test_enum_binary(self): 315 | ec = EnumClass(b"\x0a\x02") 316 | assert ec.wind == WindEnum.South 317 | assert str(ec) == "EnumClass(temp=10, wind=2)" 318 | 319 | def test_set_named_enum(self): 320 | ec = EnumClass() 321 | ec.wind = WindEnum.South 322 | assert ec.wind == 2 323 | assert bytes(ec) == b"\x00\x02" 324 | 325 | with pytest.raises(KeyError) as excinfo: 326 | ec.wind = "Norhtwest" 327 | assert "'Norhtwest'" in str(excinfo) 328 | 329 | with pytest.raises(ValueError) as excinfo: 330 | ec.wind = 1.2 331 | assert "1.2 is not a valid WindEnum" in str(excinfo) 332 | 333 | def test_set_numeric_enum(self): 334 | ec = EnumClass() 335 | ec.wind = 2 336 | assert ec.wind == WindEnum.South 337 | assert bytes(ec) == b"\x00\x02" 338 | assert str(ec) == "EnumClass(temp=0, wind=2)" 339 | 340 | 341 | class TestFlagClass: 342 | def test_create_class(self): 343 | fc = FlagClass() 344 | assert fc 345 | 346 | def test_get_enum(self): 347 | fc = FlagClass(perm=6) 348 | assert fc.perm & FlagEnum.R 349 | assert fc.perm & (FlagEnum.R | FlagEnum.W) 350 | assert not fc.perm & FlagEnum.X 351 | assert str(fc) == "FlagClass(perm=6)" 352 | assert asdict(fc) == {"perm": 6} 353 | assert astuple(fc) == (6,) 354 | 355 | def test_enum_binary(self): 356 | fc = FlagClass(b"\x03") 357 | assert fc.perm & FlagEnum.W 358 | assert fc.perm & (FlagEnum.W | FlagEnum.X) 359 | assert not fc.perm & FlagEnum.R 360 | 361 | 362 | class ConstValues(binmap.BinmapDataclass): 363 | datatype: b_types.unsignedchar = binmap.constant(0x15) 364 | status: b_types.unsignedchar = 0 365 | 366 | 367 | class TestConstValues: 368 | def test_create_class(self): 369 | c = ConstValues() 370 | with pytest.raises(TypeError) as excinfo: 371 | ConstValues(datatype=0x14, status=1) 372 | assert "__init__() got an unexpected keyword argument 'datatype'" in str( 373 | excinfo 374 | ) 375 | assert c.datatype == 0x15 376 | 377 | def test_set_value(self): 378 | c = ConstValues(status=1) 379 | with pytest.raises(AttributeError) as excinfo: 380 | c.datatype = 0x14 381 | assert "datatype is a constant" in str(excinfo) 382 | assert c.datatype == 0x15 383 | assert c.status == 1 384 | assert bytes(c) == b"\x15\x01" 385 | assert asdict(c) == {"datatype": 0x15, "status": 1} 386 | 387 | def test_binary_data(self): 388 | c = ConstValues(b"\x15\x01") 389 | with pytest.raises(ValueError) as excinfo: 390 | ConstValues(b"\x14\x01") 391 | assert "Constant doesn't match binary data" in str(excinfo) 392 | assert c.datatype == 0x15 393 | assert c.status == 1 394 | 395 | 396 | class AllDatatypes(binmap.BinmapDataclass): 397 | _pad: b_types.pad = binmap.padding(1) 398 | char: b_types.char = b"\x00" 399 | signedchar: b_types.signedchar = 0 400 | unsignedchar: b_types.unsignedchar = 0 401 | boolean: b_types.boolean = False 402 | short: b_types.short = 0 403 | unsignedshort: b_types.unsignedshort = 0 404 | integer: b_types.integer = 0 405 | unsignedint: b_types.unsignedinteger = 0 406 | long: b_types.long = 0 407 | unsignedlong: b_types.unsignedlong = 0 408 | longlong: b_types.longlong = 0 409 | unsignedlonglong: b_types.unsignedlonglong = 0 410 | halffloat: b_types.halffloat = 0.0 411 | floating: b_types.floating = 0.0 412 | double: b_types.double = 0.0 413 | string: b_types.string = binmap.stringfield(10) 414 | pascalstring: b_types.pascalstring = binmap.stringfield(15) 415 | 416 | 417 | class TestAllDatatypes: 418 | def test_create_class(self): 419 | sc = AllDatatypes() 420 | assert sc 421 | 422 | def test_with_arguments(self): 423 | sc = AllDatatypes( 424 | char=b"%", 425 | signedchar=-2, 426 | unsignedchar=5, 427 | boolean=True, 428 | short=-7, 429 | unsignedshort=17, 430 | integer=-15, 431 | unsignedint=11, 432 | long=-2312, 433 | unsignedlong=2212, 434 | longlong=-1212, 435 | unsignedlonglong=4444, 436 | halffloat=3.5, 437 | floating=3e3, 438 | double=13e23, 439 | string=b"helloworld", 440 | pascalstring=b"hello pascal", 441 | ) 442 | assert sc.char == b"%" 443 | assert sc.signedchar == -2 444 | assert sc.unsignedchar == 5 445 | assert sc.boolean 446 | assert sc.short == -7 447 | assert sc.unsignedshort == 17 448 | assert sc.integer == -15 449 | assert sc.unsignedint == 11 450 | assert sc.long == -2312 451 | assert sc.unsignedlong == 2212 452 | assert sc.longlong == -1212 453 | assert sc.unsignedlonglong == 4444 454 | assert sc.halffloat == 3.5 455 | assert sc.floating == 3e3 456 | assert sc.double == 13e23 457 | assert sc.string == b"helloworld" 458 | assert sc.pascalstring == b"hello pascal" 459 | assert ( 460 | bytes(sc) 461 | == b"\x00%\xfe\x05\x01\xff\xf9\x00\x11\xff\xff\xff\xf1\x00\x00\x00\x0b\xff\xff\xf6\xf8\x00\x00" 462 | b"\x08\xa4\xff\xff\xff\xff\xff\xff\xfbD\x00\x00\x00\x00\x00\x00\x11\\C\x00E;\x80\x00D\xf14\x92Bg\x0c" 463 | b"\xe8helloworld\x0chello pascal\x00\x00" 464 | ) 465 | assert ( 466 | str(sc) 467 | == "AllDatatypes(char=b'%', signedchar=-2, unsignedchar=5, boolean=True, short=-7, unsignedshort=17, integer=-15, unsignedint=11, long=-2312, unsignedlong=2212, longlong=-1212, unsignedlonglong=4444, halffloat=3.5, floating=3000.0, double=1.3e+24, string=b'helloworld', pascalstring=b'hello pascal')" # noqa: E501 468 | ) 469 | 470 | def test_with_binarydata(self): 471 | sc = AllDatatypes( 472 | b"\x00W\xee\x15\x00\xf4\xf9\x10\x11\xff\xff\xff1\x00\x00\x01\x0b\xff\xff\xe6\xf8\x00\x00\x18" 473 | b"\xa4\xff\xff\xff\xff\xff\xff\xfbE\x00\x00\x00\x00\x00\x01\x11\\C\x01E;\x81\x00D\xf14\xa2Bg\x0c" 474 | b"\xe8hi world \x09hi pascal\x00\x00\x00\x00\x00" 475 | ) 476 | assert sc.char == b"W" 477 | assert sc.signedchar == -18 478 | assert sc.unsignedchar == 21 479 | assert not sc.boolean 480 | assert sc.short == -2823 481 | assert sc.unsignedshort == 4113 482 | assert sc.integer == -207 483 | assert sc.unsignedint == 267 484 | assert sc.long == -6408 485 | assert sc.unsignedlong == 6308 486 | assert sc.longlong == -1211 487 | assert sc.unsignedlonglong == 69980 488 | assert sc.halffloat == 3.501953125 489 | assert sc.floating == 3000.0625 490 | assert sc.double == 1.3000184467440736e24 491 | assert sc.string == b"hi world " 492 | assert sc.pascalstring == b"hi pascal" 493 | 494 | 495 | class TestInheritance: 496 | def test_simple_inheritance(self): 497 | class Child(Temp): 498 | humidity: b_types.unsignedchar = 0 499 | 500 | ch = Child() 501 | ch.temp = 10 502 | ch.humidity = 40 503 | 504 | assert ch.temp == 10 505 | assert ch.humidity == 40 506 | 507 | assert bytes(ch) == b"\x0a\x28" 508 | assert asdict(ch) == {"temp": 10, "humidity": 40} 509 | assert astuple(ch) == (10, 40) 510 | 511 | def test_simple_inheritance_binary(self): 512 | class Child(Temp): 513 | humidity: b_types.unsignedchar = 0 514 | 515 | ch = Child(b"\x10\x30") 516 | assert ch.temp == 16 517 | assert ch.humidity == 48 518 | 519 | def test_const_inheritance(self): 520 | class Child(ConstValues): 521 | humidity: b_types.unsignedchar = 0 522 | 523 | ch = Child() 524 | with pytest.raises(AttributeError) as excinfo: 525 | ch.datatype = 14 526 | assert "datatype is a constant" in str(excinfo) 527 | ch.status = 1 528 | ch.humidity = 40 529 | 530 | assert ch.datatype == 0x15 531 | assert ch.status == 1 532 | assert ch.humidity == 40 533 | assert bytes(ch) == b"\x15\x01\x28" 534 | 535 | def test_const_inheritance_binary(self): 536 | class Child(ConstValues): 537 | humidity: b_types.unsignedchar = 0 538 | 539 | ch = Child(b"\x15\x05\x30") 540 | assert ch.datatype == 0x15 541 | assert ch.status == 5 542 | assert ch.humidity == 48 543 | 544 | def test_enum_inheritanec(self): 545 | class Child(EnumClass): 546 | humidity: b_types.unsignedchar = 0 547 | 548 | ch = Child() 549 | ch.temp = 10 550 | ch.wind = WindEnum.West 551 | ch.humidity = 40 552 | 553 | assert ch.temp == 10 554 | assert ch.wind == WindEnum.West 555 | assert ch.humidity == 40 556 | assert bytes(ch) == b"\x0a\x03\x28" 557 | 558 | def test_enum_inheritance_binary(self): 559 | class Child(EnumClass): 560 | humidity: b_types.unsignedchar = 0 561 | 562 | ch = Child(b"\x12\x01\x25") 563 | assert ch.temp == 18 564 | assert ch.wind == WindEnum.East 565 | assert ch.humidity == 37 566 | 567 | 568 | class AutoLength(binmap.BinmapDataclass): 569 | length: b_types.unsignedchar = binmap.autolength() 570 | temp: b_types.signedchar = 0 571 | 572 | 573 | class AutoLengthOffset(binmap.BinmapDataclass): 574 | length: b_types.unsignedchar = binmap.autolength(offset=-1) 575 | temp: b_types.signedchar = 0 576 | 577 | 578 | class AutoLengthOffsetPositive(binmap.BinmapDataclass): 579 | length: b_types.unsignedchar = binmap.autolength(offset=1) 580 | temp: b_types.signedchar = 0 581 | 582 | 583 | class TestAutolength: 584 | def test_autolength(self): 585 | al = AutoLength() 586 | al.temp = 10 587 | 588 | assert al.length == 2 589 | assert str(al) == "AutoLength(length=2, temp=10)" 590 | assert asdict(al) == {"length": 2, "temp": 10} 591 | assert astuple(al) == (2, 10) 592 | assert bytes(al) == b"\x02\x0a" 593 | 594 | def test_autolength_bin(self): 595 | with pytest.raises(ValueError) as excinfo: 596 | AutoLength(b"\x01\x0a") 597 | assert "Length doesn't match" in str(excinfo) 598 | al = AutoLength(b"\x02\x0a") 599 | assert al.length == 2 600 | assert al.temp == 10 601 | 602 | def test_autolength_inheritance(self): 603 | class Child(AutoLength): 604 | humidity: b_types.unsignedchar = 0 605 | 606 | alc = Child() 607 | alc.temp = 20 608 | alc.humidity = 40 609 | assert bytes(alc) == b"\x03\x14\x28" 610 | 611 | assert alc.length == 3 612 | 613 | def test_autolength_offset(self): 614 | alo = AutoLengthOffset() 615 | alo.temp = 10 616 | 617 | assert alo.length == 1 618 | assert bytes(alo) == b"\x01\n" 619 | 620 | alop = AutoLengthOffsetPositive() 621 | alop.temp = 10 622 | assert bytes(alop) == b"\x03\n" 623 | 624 | assert alop.length == 3 625 | 626 | 627 | class CalculatedField(binmap.BinmapDataclass): 628 | temp: b_types.signedchar = 0 629 | hum: b_types.unsignedchar = 0 630 | 631 | def chk(self) -> b_types.unsignedchar: 632 | return (self.temp + self.hum) & 0xFF 633 | 634 | checksum: b_types.unsignedchar = binmap.calculatedfield(chk) 635 | 636 | 637 | class CalculatedFieldLast(binmap.BinmapDataclass): 638 | temp: b_types.signedchar = 0 639 | 640 | def chk_last(self): 641 | checksum = 0 642 | for k, v in self.__dict__.items(): 643 | if k.startswith("_") or callable(v): 644 | continue 645 | checksum += v 646 | return checksum & 0xFF 647 | 648 | checksum: b_types.unsignedchar = binmap.calculatedfield(chk_last, last=True) 649 | hum: b_types.unsignedchar = 0 650 | 651 | 652 | class TestCalculatedField: 653 | def test_calculated_field(self): 654 | cf = CalculatedField() 655 | cf.temp = -27 656 | cf.hum = 10 657 | 658 | assert str(cf) == "CalculatedField(temp=-27, hum=10, checksum=239)" 659 | assert asdict(cf) == {"temp": -27, "hum": 10, "checksum": 239} 660 | assert cf.checksum == 239 661 | assert bytes(cf) == b"\xe5\x0a\xef" 662 | 663 | def test_calculated_field_binary(self): 664 | cf = CalculatedField(b"\xe2\x12\xf4") 665 | assert cf.temp == -30 666 | assert cf.hum == 18 667 | assert cf.checksum == 244 668 | 669 | with pytest.raises(ValueError) as excinfo: 670 | CalculatedField(b"\xe4\x18\x00") 671 | assert "Wrong calculated value" in str(excinfo) 672 | 673 | def test_calculated_field_set(self): 674 | cf = CalculatedField() 675 | with pytest.raises(AttributeError) as excinfo: 676 | cf.checksum = 10 677 | assert "Can't set a calculated field" in str(excinfo) 678 | 679 | def test_calculated_field_last(self): 680 | 681 | cfl = CalculatedFieldLast() 682 | cfl.temp = 10 683 | cfl.hum = 20 684 | 685 | assert cfl.checksum == 30 686 | assert bytes(cfl) == b"\x0a\x14\x1e" 687 | 688 | def test_calculated_field_last_inherit(self): 689 | class CalculatedFieldLastInherit(CalculatedFieldLast): 690 | lux: b_types.unsignedinteger = 0 691 | 692 | cfli = CalculatedFieldLastInherit() 693 | cfli.temp = 10 694 | cfli.hum = 20 695 | cfli.lux = 401 696 | assert bytes(cfli) == b"\x0a\x14\x00\x00\x01\x91\xaf" 697 | 698 | with pytest.raises(ValueError) as excinfo: 699 | CalculatedFieldLastInherit(b"\x0b\x20\x00\x00\x01\x30\x00") 700 | assert "Wrong calculated value" in str(excinfo) 701 | 702 | def test_calculated_field_multi_last(self): 703 | with pytest.raises(ValueError) as excinfo: 704 | 705 | class CalculatedFieldMultiLast(binmap.BinmapDataclass): 706 | temp: b_types.unsignedchar = 0 707 | 708 | def chk(self): 709 | return 0 710 | 711 | checksum1: b_types.unsignedchar = binmap.calculatedfield(chk, last=True) 712 | checksum2: b_types.unsignedchar = binmap.calculatedfield(chk, last=True) 713 | 714 | assert "Can't have more than one last" in str(excinfo) 715 | 716 | def test_calculated_field_multi_last_inherit(self): 717 | with pytest.raises(ValueError) as excinfo: 718 | 719 | class CalculatedFieldMultiLastInherit(CalculatedFieldLast): 720 | def chk2(self): 721 | return 0 722 | 723 | checksum2: b_types.unsignedchar = binmap.calculatedfield( 724 | chk2, last=True 725 | ) 726 | 727 | assert "Can't have more than one last" in str(excinfo) 728 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [flake8] 2 | jobs = 1 3 | max-line-length = 160 4 | 5 | [isort] 6 | multi_line_output=3 7 | include_trailing_comma=True 8 | force_grid_wrap=0 9 | use_parentheses=True 10 | line_length=88 11 | 12 | 13 | [tox] 14 | skipsdist=True 15 | 16 | 17 | [testenv] 18 | commands = 19 | pip install -r requirements-dev.txt 20 | flake8 binmap tests 21 | mypy binmap 22 | python -m pytest -v 23 | --------------------------------------------------------------------------------