diff --git a/CHANGELOG.md b/CHANGELOG.md index b9ced94..5fa6cd0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # DeliciousBytes Library Change Log +## [1.0.3] - 2025-07-07 +### Added +- Input validation for the `.encode()` methods. +- Additional unit testing for the `UnsignedLong` type. +- A new `utilities` sub-module containing the `hexbytes` helper method that can be used +to format raw `bytes` or `bytearray` values into hexadecimal encoded strings for review, +and the `print_hexbytes` helper method that can be used to print such strings. + +### Changed +- Type casting of the decoded return value for the `Bytes` class when reversed back to a +`bytes` type, fixing a previous issue where the reversed return type was a `list` type. +- The `BytesView` class `split` length can now be equal to the length of the `data`. +- Corrected type hints for the `BytesView` class' `__iter__` and `__next__` methods. + ## [1.0.2] - 2025-06-17 ### Added - Added support for creating `Bytes` values from `bytes` values. diff --git a/docker-compose.yml b/docker-compose.yml index 5500957..1a5da9b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,7 +11,7 @@ services: build: dockerfile: ./Dockerfile context: ./ - image: xml-black + image: deliciousbytes-black environment: - SERVICE=black volumes: @@ -22,7 +22,7 @@ services: build: dockerfile: ./Dockerfile context: ./ - image: xml-tests + image: deliciousbytes-tests environment: - SERVICE=tests volumes: diff --git a/source/deliciousbytes/__init__.py b/source/deliciousbytes/__init__.py index e3bb552..ae95d25 100644 --- a/source/deliciousbytes/__init__.py +++ b/source/deliciousbytes/__init__.py @@ -11,6 +11,7 @@ from classicist import classproperty +from deliciousbytes.utilities import hexbytes logger = logging.getLogger(__name__) @@ -337,6 +338,11 @@ def MAX(cls) -> int: return cls._maximum def encode(self, order: ByteOrder = None) -> bytes: + if not isinstance(self, Int): + raise TypeError( + "Ensure the 'encode' method is being called on a class instance!" + ) + if order is None: order = self.order elif not isinstance(order, ByteOrder): @@ -346,7 +352,9 @@ def encode(self, order: ByteOrder = None) -> bytes: if self.length > 0: return self.to_bytes( - length=self.length, byteorder=order.value, signed=self.signed + length=self.length, + byteorder=order.value, + signed=self.signed, ) else: return self.to_bytes( @@ -372,7 +380,11 @@ def decode(cls, value: bytes | bytearray, order: ByteOrder = ByteOrder.MSB) -> I ) logger.debug( - "%s.decode(value: %r, order: %r) => %r", cls.__name__, value, order, decoded + "%s.decode(value: %r, order: %r) => %r", + cls.__name__, + hexbytes(value), + order, + decoded, ) return decoded @@ -892,6 +904,11 @@ def MAX(cls) -> float: return cls._maximum def encode(self, order: ByteOrder = ByteOrder.MSB) -> bytes: + if not isinstance(self, Float): + raise TypeError( + "Ensure the 'encode' method is being called on a class instance!" + ) + if not isinstance(order, ByteOrder): raise TypeError( "The 'order' argument must reference a ByteOrder enumeration option!" @@ -982,6 +999,11 @@ def __new__(cls, value: bytes | bytearray | Int, length: int = None): def encode( self, order: ByteOrder = ByteOrder.MSB, length: int = None, raises: bool = True ) -> bytes: + if not isinstance(self, Bytes): + raise TypeError( + "Ensure the 'encode' method is being called on a class instance!" + ) + if not isinstance(order, ByteOrder): raise TypeError( "The 'order' argument must have a ByteOrder enumeration value!" @@ -1030,7 +1052,7 @@ def decode(cls, value: bytes | bytearray, order: ByteOrder = None) -> Bytes: ) if order is ByteOrder.LSB: - value = reversed(value) + value = bytes(reversed(value)) return cls(value=bytes(value)) @@ -1076,6 +1098,11 @@ def encode( order: ByteOrder = ByteOrder.MSB, encoding: Encoding = None, ): + if not isinstance(self, String): + raise TypeError( + "Ensure the 'encode' method is being called on a class instance!" + ) + if encoding is None: encoding = self.encoding elif not isinstance(encoding, Encoding): @@ -1201,7 +1228,7 @@ def __init__( if not isinstance(split, int): raise TypeError("The 'split' argument must have a positive integer value!") - elif 1 <= split < self._size: + elif 1 <= split <= self._size: self._splits = split else: raise ValueError( @@ -1221,11 +1248,11 @@ def __len__(self) -> int: else: return math.floor(parts) - def __iter__(self) -> bytesview: + def __iter__(self) -> BytesView: self._index = 0 return self - def __next__(self) -> bytes | object: + def __next__(self) -> bytearray | object: if self._index + self._splits > self._size: raise StopIteration @@ -1242,7 +1269,7 @@ def __next__(self) -> bytes | object: return value def __getitem__(self, index: int | slice) -> bytearray | object: - # logger.debug("%s.__getitem__(index: %r)" % (self.__class__.__name__, index)) + logger.debug("%s.__getitem__(index: %r)", self.__class__.__name__, index) maxindex: int = math.floor(self._size / self._splits) - 1 reverse: bool = False @@ -1397,6 +1424,7 @@ def typed(self) -> type | None: self.type, typed, ) + return typed def split(self, split: int = None) -> BytesView: @@ -1530,6 +1558,11 @@ def decode( in the BytesView, but if the format string specifies more data types than held in the data, an error will be raised.""" + if not isinstance(self, BytesView): + raise TypeError( + "Ensure the 'decode' method is being called on a class instance!" + ) + if not isinstance(format, str): raise TypeError("The 'format' argument must have a string value!") elif not len(format := format.strip()) > 0: diff --git a/source/deliciousbytes/utilities/__init__.py b/source/deliciousbytes/utilities/__init__.py new file mode 100644 index 0000000..d763242 --- /dev/null +++ b/source/deliciousbytes/utilities/__init__.py @@ -0,0 +1,30 @@ +def hexbytes(data: bytes | bytearray, prefix: bool = False, limit: int = 0) -> str: + """Format a bytes or bytearray value into a human readable string for debugging.""" + + if not isinstance(data, (bytes, bytearray)): + raise TypeError("The 'data' argument must have a bytes or bytesarray value!") + + if not isinstance(prefix, bool): + raise TypeError("The 'prefix' argument must have a boolean value!") + + if not (isinstance(limit, int) and limit >= 0): + raise TypeError("The 'limit' argument must have a positive integer value!") + + hex_string = ("" if prefix else " ").join( + [ + (r"\x" if prefix else "") + f"{byte:02x}" + for (index, byte) in enumerate(data) + if limit == 0 or index < limit + ] + ) + + if limit > 0 and len(data) > limit: + hex_string += " ..." + + return ('b"' if prefix else "[> ") + hex_string + ('"' if prefix else " <]") + + +def print_hexbytes(data: bytes | bytearray, **kwargs) -> None: + """Print a bytes or bytearray value as a human readable string for debugging.""" + + print(hexbytes(data=data, **kwargs)) diff --git a/source/deliciousbytes/version.txt b/source/deliciousbytes/version.txt index e6d5cb8..e4c0d46 100644 --- a/source/deliciousbytes/version.txt +++ b/source/deliciousbytes/version.txt @@ -1 +1 @@ -1.0.2 \ No newline at end of file +1.0.3 \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py index e436803..1867b62 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,10 +7,3 @@ sys.path.insert(0, path) # add library path for importing into the tests import deliciousbytes - - -def print_hexbytes(data: bytes, prefix: bool = True): - hex_string = ("" if prefix else " ").join( - [(r"\x" if prefix else "") + f"{byte:02x}" for byte in data] - ) - print(('b"' if prefix else "") + hex_string + ('"' if prefix else "")) diff --git a/tests/test_library.py b/tests/test_library.py index 55675b6..d525a5b 100644 --- a/tests/test_library.py +++ b/tests/test_library.py @@ -51,7 +51,7 @@ ASCII, ) -from conftest import print_hexbytes +from deliciousbytes.utilities import print_hexbytes def test_byte_order_enumeration(): @@ -519,6 +519,28 @@ def test_unsigned_long(): assert isinstance(encoded, bytes) assert encoded == b"\x7f\x00\x00\x00" + # 5848 in big endian is \x16\xd8 + # 5848 in litte endian is \xd8\x16 + + data: Bytes = Bytes.decode(b"\xd8\x16", order=ByteOrder.LSB) + assert isinstance(data, Bytes) + assert data == b"\x16\xd8" # Bytes.decode() flips the bytes to MSB order + + decoded: UInt32 = UInt32.decode(data) + assert isinstance(decoded, UInt32) + assert isinstance(decoded, int) + assert decoded == 5848 + + decoded: UInt64 = UInt64.decode(data) + assert isinstance(decoded, UInt64) + assert isinstance(decoded, int) + assert decoded == 5848 + + decoded: UnsignedLong = UnsignedLong.decode(data) + assert isinstance(decoded, UnsignedLong) + assert isinstance(decoded, int) + assert decoded == 5848 + def test_signed_long(): """Test the SignedLong data type which is a fixed 4-byte, 32-bit signed integer type."""