From 6ec62eeaa2825eef34b6eee15226023895c91d62 Mon Sep 17 00:00:00 2001 From: Daniel Sissman Date: Tue, 9 Sep 2025 22:57:00 -0700 Subject: [PATCH] Utility Functions The `Bytes.encode()` and the `Bytes.decode()` methods now support reversing the order of the provided bytes via the new `reverse` boolean keyword argument; by default bytes are held and encoded in the order in which they are provided, regardless of the (byte) `order` keyword argument. Added the new `isinstantiable()` utility function, and documentation and unit tests for all of the utility functions, including `hexbytes` and `print_hexbytes`. --- CHANGELOG.md | 14 ++ README.md | 105 +++++++++- source/deliciousbytes/__init__.py | 45 ++++- source/deliciousbytes/utilities/__init__.py | 24 ++- source/deliciousbytes/version.txt | 2 +- tests/test_library.py | 79 ++++---- tests/test_utilities.py | 203 ++++++++++++++++++++ 7 files changed, 419 insertions(+), 53 deletions(-) create mode 100644 tests/test_utilities.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 2bcd731..21c97e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # DeliciousBytes Library Change Log +## [1.1.0] - 2025-09-09 +### Added +- The `Bytes.encode()` and the `Bytes.decode()` methods now support reversing the order +of the provided bytes via the `reverse` boolean keyword argument; by default bytes are +held and encoded in the order in which they are provided; if the `reverse` argument is +set to `True`, the order of will be reversed; the byte `order` argument has no impact on +the byte ordering of the data, as the `Bytes` type and its subtypes simply hold one or +more individual 8-bit byte values, which are meant to be unaffected by byte order. The +`reverse` argument can be used to reverse the ordering if needed. +- The new `isinstantiable()` utility function can be used to determine if a value can be +instantiated into the specified class type determined based on data type compatibility. +- Documentation for the utility functions in the `deliciousbytes.utilities` submodule. +- Unit testing for the utility functions in the `deliciousbytes.utilities` submodule. + ## [1.0.4] - 2025-08-01 ### Added - The `Bytes.encode()` method now pads the generated bytes to the specified length, if a diff --git a/README.md b/README.md index 2940773..abefcae 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ assert encoded == b"\x7f" The DeliciousBytes library provides the following data type classes, all of which are ultimately a subclass of one of the native Python data type classes so all instances of the classes below can be used interchangeably with their native Python data types; each -data type class is also a subclasses of the `deliciousbytes.Type` superclass, from which +data type class is also a subclass of the `deliciousbytes.Type` superclass, from which they inherit shared behaviour and class hierarchy membership: | Class | Description | Subclass Of | Format | @@ -153,6 +153,16 @@ its native data type value. The byte order defaults to most-significant bit firs is represented by the `ByteOrder` enumeration class which provides enumeration options to specify the endianness that is needed for the use case. +Furthermore, the `Bytes` type and it subtypes, `Bytes8`, `Bytes16`, `Bytes32`, `Bytes64`, +`Bytes128`, and `Bytes256` offer a `reverse` (`bool`) keyword argument that can be used to +reverse the order of bytes from those provided, as in the case of the `Bytes` type and +its subtypes, the classes expect to hold one or more individual bytes, were the order +of the bytes is not expected to affect the encoding of the underlying data, and as such +the value of the 'order' keyword argument, has no impact. Where it is useful to reverse +the order of the bytes being held, the `reverse` keyword argument can be set to `True`, +causing the order of the individual bytes to be reversed into the opposite order to that +in which they were provided. + The `deliciousbytes` class also provides a `ByteView` class which provides a method for iterating over bytes, either as individual bytes or in groups of bytes of the specified split size, where these bytes may be accessed as raw `bytes` values or cast into one of @@ -286,13 +296,102 @@ assert view.decode(">hhh") == (1, 2, 3) assert view.decode(">hhhh") == (1, 2, 3, 4) ``` + +### Utility Functions + +The DeliciousBytes library provides the following utility functions which are useful for +debugging and integration: + + * `hexbytes(value: bytes, prefix: bool = False, limit: int = 0)` (`str`) – The `hexbytes()` + function takes a `bytes` value as input and generates a string representation of the value, + that can be printed or stored and later reviewed. The output is primarily useful for + debugging purposes to help visualise raw byte data. + + + An example of the `hexbytes()` function's use is as follows: + + ```python + from deliciousbytes.utilities import hexbytes + + value: bytes = b"\x01\x02\x03\x04\x05\x06" + + assert hexbytes(value) == "[> 01 02 03 04 05 06 <]" + ``` + + The optional `prefix` option changes the output to look like a formatted bytes string: + + ```python + from deliciousbytes.utilities import hexbytes + + value: bytes = b"\x01\x02\x03\x04\x05\x06" + + assert hexbytes(value, prefix=True) == r'b"\x01\x02\x03\x04\x05\x06"' + ``` + + The optional `limit` option, limits how many bytes are included in the output: + + ```python + from deliciousbytes.utilities import hexbytes + + value: bytes = b"\x01\x02\x03\x04\x05\x06" + + assert hexbytes(value, limit=4) == "[> 01 02 03 04 ... <]" + ``` + + * `print_hexbytes` – The `print_hexbytes()` function takes a `bytes` value as input and + generates and prints a string representation of the value. The output is primarily useful for + debugging purposes to help visualise raw byte data. + + An example of the `print_hexbytes()` function's use is as follows: + + ```python + from deliciousbytes.utilities import print_hexbytes + + value: bytes = b"\x01\x02\x03\x04\x05\x06" + + print_hexbytes(value, limit=4) + ``` + + The above code outputs the following: + + ```text + [> 01 02 03 04 ... <] + ``` + + * `isinstantiable(value: object, klass: type)` (`bool`) – The `isinstantiable()` + function takes an `object` value, of any class, and a type class reference, and based + on the class type or superclass types of the type class reference, determines if the + value can be instantiated into an instance of the referenced type class. For example, + if a string value is provided, and the type class reference references a class that is + a subclass of the `str` type, then the function will return `True` otherwise `False`. + + An example of the `isinstantiable()` function's use is as follows: + + ```python + from deliciousbytes import String, ASCII, UTF8 + from deliciousbytes.utilities import isinstantiable + + value: str = "Hello World" + + assert isinstantiable(value, str) + assert isinstantiable(value, String) + assert isinstantiable(value, ASCII) + assert isinstantiable(value, UTF8) + ``` + + The above code outputs the following: + + ```text + [> 01 02 03 04 ... <] + ``` + ### Byte Order The byte order for each of the data type classes defaults to most-significant bit first, MSB, but may be changed to least-significant bit first, LSB, if needed. The `ByteOrder` -enumeration class value offers enumeration options to specify the endianness that is -needed for the use case, and for convenience provides the enumerations in a few flavours +enumeration class value offers enumeration options to specify the endianness needed for +a given use case, and for convenience provides the enumerations in a few naming flavours depending on how one prefers to refer to endianness: | Enumeration Option | Byte Order | Endianness | diff --git a/source/deliciousbytes/__init__.py b/source/deliciousbytes/__init__.py index 714c91b..aa18626 100644 --- a/source/deliciousbytes/__init__.py +++ b/source/deliciousbytes/__init__.py @@ -998,8 +998,20 @@ def __new__(cls, value: bytes | bytearray | Int, length: int = None): return self def encode( - self, order: ByteOrder = ByteOrder.MSB, length: int = None, raises: bool = True + self, + order: ByteOrder = ByteOrder.MSB, + reverse: bool = False, + length: int = None, + raises: bool = True, ) -> bytes: + """The encode method encodes the provided bytes into a bytes type, padding the + value up to the specified length. The byte order is ignored as the Bytes type + holds one of more individual bytes, similar to the ASCII type, where the values + being encoded already fit within single bytes so byte order has no impact. If + there is the need to reverse the order of the bytes, this can be achieved via + the 'reverse' argument, which defaults to False, but can be set to True to sort + and encode the bytes in reverse order.""" + if not isinstance(self, Bytes): raise TypeError( "Ensure the 'encode' method is being called on a class instance!" @@ -1010,6 +1022,9 @@ def encode( "The 'order' argument must have a ByteOrder enumeration value!" ) + if not isinstance(reverse, bool): + raise TypeError("The 'reverse' argument must have a boolean value!") + if length is None: length = self._length elif not (isinstance(length, int) and length >= 1): @@ -1022,14 +1037,14 @@ def encode( encoded: bytesarray = bytearray() - if order is ByteOrder.MSB: + if reverse is False: for index, byte in enumerate(self): # logger.debug("%s.encode(order: MSB) index => %s, byte => %s (%x)", self.__class__.__name__, index, byte, byte) encoded.append(byte) while length > 0 and len(encoded) < length: encoded.insert(0, 0) - elif order is ByteOrder.LSB: + elif reverse is True: for index, byte in enumerate(reversed(self)): # logger.debug("%s.encode(order: LSB) index => %s, byte => %s (%x)", self.__class__.__name__, index, byte, byte) encoded.append(byte) @@ -1046,13 +1061,33 @@ def encode( return bytes(encoded) @classmethod - def decode(cls, value: bytes | bytearray, order: ByteOrder = None) -> Bytes: + def decode( + cls, + value: bytes | bytearray, + order: ByteOrder = ByteOrder.MSB, + reverse: bool = False, + ) -> Bytes: + """The decode method decodes the provided value into a Bytes type; the byte + order is ignored as the Bytes type holds one of more individual bytes, similar + to the ASCII type, where the values being encoded already fit within single + bytes so byte order has no impact. If there is the need to reverse the order of + the bytes, this can be achieved via the 'reverse' argument, which defaults to + False, but can be set to True to sort and decode the bytes in reverse order.""" + if not isinstance(value, (bytes, bytearray)): raise TypeError( "The 'value' argument must have a bytes or bytearray value!" ) - if order is ByteOrder.LSB: + if not isinstance(order, ByteOrder): + raise TypeError( + "The 'order' argument must have a ByteOrder enumeration value!" + ) + + if not isinstance(reverse, bool): + raise TypeError("The 'reverse' argument must have a boolean value!") + + if reverse is True: value = bytes(reversed(value)) return cls(value=bytes(value)) diff --git a/source/deliciousbytes/utilities/__init__.py b/source/deliciousbytes/utilities/__init__.py index d763242..1a8c56d 100644 --- a/source/deliciousbytes/utilities/__init__.py +++ b/source/deliciousbytes/utilities/__init__.py @@ -1,8 +1,17 @@ +import builtins + + 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 hasattr(data, "__bytes__"): + data = bytes(data) + else: + raise TypeError( + "The 'data' argument must have a bytes or bytesarray value, not %s!" + % (type(data),) + ) if not isinstance(prefix, bool): raise TypeError("The 'prefix' argument must have a boolean value!") @@ -28,3 +37,16 @@ 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)) + + +def isinstantiable(value: object, klass: type) -> bool: + """Determine if the value can be instantiated as an instance of the noted class.""" + + if not isinstance(value, object): + raise TypeError("The 'value' argument must have an object value!") + + if not isinstance(klass, type): + raise TypeError("The 'klass' argument must have a type value!") + + # Determine if the value type class appears in the base classes of the noted class: + return builtins.type(value) in klass.mro() diff --git a/source/deliciousbytes/version.txt b/source/deliciousbytes/version.txt index a6a3a43..1cc5f65 100644 --- a/source/deliciousbytes/version.txt +++ b/source/deliciousbytes/version.txt @@ -1 +1 @@ -1.0.4 \ No newline at end of file +1.1.0 \ No newline at end of file diff --git a/tests/test_library.py b/tests/test_library.py index 11a423f..b20c7c3 100644 --- a/tests/test_library.py +++ b/tests/test_library.py @@ -522,9 +522,9 @@ def test_unsigned_long(): # 5848 in big endian is \x16\xd8 # 5848 in litte endian is \xd8\x16 - data: Bytes = Bytes.decode(b"\xd8\x16", order=ByteOrder.LSB) + data: Bytes = Bytes.decode(b"\xd8\x16", reverse=True) assert isinstance(data, Bytes) - assert data == b"\x16\xd8" # Bytes.decode() flips the bytes to MSB order + assert data == b"\x16\xd8" # Bytes.decode(reverse=True) reverses the order of bytes decoded: UInt32 = UInt32.decode(data) assert isinstance(decoded, UInt32) @@ -820,17 +820,29 @@ def test_float64(): assert math.isclose(decoded, 127.987) -def test_bytes8(): - """Test the Bytes8 data type which is a fixed 1-byte, 8-bit bytes type.""" +def test_bytes(): + """Test the Bytes data type which is an unlimited length, 8-bit byte type.""" - value: UInt8 = UInt8(50) + value: Bytes = Bytes(bytearray([0x01, 0x02, 0x03, 0x04, 0x05])) + + assert isinstance(value, Bytes) + assert isinstance(value, bytes) + + encoded: bytes = value.encode(order=ByteOrder.MSB) + assert isinstance(encoded, bytes) + assert len(encoded) == 5 + assert encoded == b"\x01\x02\x03\x04\x05" + + encoded: bytes = value.encode(order=ByteOrder.LSB) + assert isinstance(encoded, bytes) + assert len(encoded) == 5 + assert encoded == b"\x01\x02\x03\x04\x05" - assert isinstance(value, UInt8) - assert isinstance(value, UInt) - assert isinstance(value, Int) - assert isinstance(value, int) - value: Bytes8 = Bytes8(bytearray([byte for byte in bytes(value)])) +def test_bytes8(): + """Test the Bytes8 data type which is a fixed 1-byte, 8-bit bytes type.""" + + value: Bytes8 = Bytes8(bytearray([0x01])) assert isinstance(value, Bytes8) assert isinstance(value, Bytes) @@ -839,25 +851,18 @@ def test_bytes8(): encoded: bytes = value.encode(order=ByteOrder.MSB) assert isinstance(encoded, bytes) assert len(encoded) == 1 # 1 byte, 8-bits - assert encoded == b"\x32" + assert encoded == b"\x01" encoded: bytes = value.encode(order=ByteOrder.LSB) assert isinstance(encoded, bytes) assert len(encoded) == 1 # 1 byte, 8-bits - assert encoded == b"\x32" + assert encoded == b"\x01" def test_bytes16(): """Test the Bytes16 data type which is a fixed 2-byte, 16-bit bytes type.""" - value: UInt8 = UInt8(50) - - assert isinstance(value, UInt8) - assert isinstance(value, UInt) - assert isinstance(value, Int) - assert isinstance(value, int) - - value: Bytes16 = Bytes16(bytearray([byte for byte in bytes(value)])) + value: Bytes16 = Bytes16(bytearray([0x01, 0x02])) assert isinstance(value, Bytes16) assert isinstance(value, Bytes) @@ -866,25 +871,18 @@ def test_bytes16(): encoded: bytes = value.encode(order=ByteOrder.MSB) assert isinstance(encoded, bytes) assert len(encoded) == 2 # 2 bytes, 16-bits - assert encoded == b"\x00\x32" + assert encoded == b"\x01\x02" encoded: bytes = value.encode(order=ByteOrder.LSB) assert isinstance(encoded, bytes) assert len(encoded) == 2 # 2 bytes, 16-bits - assert encoded == b"\x32\x00" + assert encoded == b"\x01\x02" def test_bytes32(): - """Test the Bytes64 data type which is a fixed 4-byte, 32-bit bytes type.""" - - value: UInt16 = UInt16(40050) - - assert isinstance(value, UInt16) - assert isinstance(value, UInt) - assert isinstance(value, Int) - assert isinstance(value, int) + """Test the Bytes32 data type which is a fixed 4-byte, 32-bit bytes type.""" - value: Bytes32 = Bytes32(bytearray([byte for byte in bytes(value)])) + value: Bytes32 = Bytes32(bytearray([0x01, 0x02, 0x03, 0x04])) assert isinstance(value, Bytes32) assert isinstance(value, Bytes) @@ -893,25 +891,20 @@ def test_bytes32(): encoded: bytes = value.encode(order=ByteOrder.MSB) assert isinstance(encoded, bytes) assert len(encoded) == 4 # 4 bytes, 32-bits - assert encoded == b"\x00\x00\x9c\x72" + assert encoded == b"\x01\x02\x03\x04" encoded: bytes = value.encode(order=ByteOrder.LSB) assert isinstance(encoded, bytes) assert len(encoded) == 4 # 4 bytes, 32-bits - assert encoded == b"\x72\x9c\x00\x00" + assert encoded == b"\x01\x02\x03\x04" def test_bytes64(): """Test the Bytes64 data type which is a fixed 8-byte, 64-bit bytes type.""" - value: UInt32 = UInt32(4000050) - - assert isinstance(value, UInt32) - assert isinstance(value, UInt) - assert isinstance(value, Int) - assert isinstance(value, int) - - value: Bytes64 = Bytes64(bytearray([byte for byte in bytes(value)])) + value: Bytes64 = Bytes64( + bytearray([0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08]) + ) assert isinstance(value, Bytes64) assert isinstance(value, Bytes) @@ -920,12 +913,12 @@ def test_bytes64(): encoded: bytes = value.encode(order=ByteOrder.MSB) assert isinstance(encoded, bytes) assert len(encoded) == 8 # 8 bytes, 64-bits - assert encoded == b"\x00\x00\x00\x00\x00\x3d\x09\x32" + assert encoded == b"\x01\x02\x03\x04\x05\x06\x07\x08" encoded: bytes = value.encode(order=ByteOrder.LSB) assert isinstance(encoded, bytes) assert len(encoded) == 8 # 8 bytes, 64-bits - assert encoded == b"\x32\x09\x3d\x00\x00\x00\x00\x00" + assert encoded == b"\x01\x02\x03\x04\x05\x06\x07\x08" def test_string(): diff --git a/tests/test_utilities.py b/tests/test_utilities.py new file mode 100644 index 0000000..c3d8285 --- /dev/null +++ b/tests/test_utilities.py @@ -0,0 +1,203 @@ +from deliciousbytes import ( + String, + ASCII, + UTF8, + UTF16, + UTF32, + Unicode, + UInt, + UInt8, + UInt16, + UInt32, + UInt64, + Int, + Int8, + Int16, + Int32, + Int64, + Char, + UnsignedChar, + SignedChar, + Short, + UnsignedShort, + SignedShort, + Long, + UnsignedLong, + SignedLong, + LongLong, + UnsignedLongLong, + SignedLongLong, + Size, + UnsignedSize, + SignedSize, + Float, + Float16, + Float32, + Float64, + Double, + Bytes, + Bytes8, + Bytes16, + Bytes32, + Bytes64, + Bytes128, + Bytes256, +) + +from deliciousbytes.utilities import ( + hexbytes, + print_hexbytes, + isinstantiable, +) + + +def test_hexbytes(): + """Test the 'hexbytes' utility function.""" + + value: bytes = b"\x01\x02\x03\x04\x05\x06" + + assert hexbytes(value) == "[> 01 02 03 04 05 06 <]" + + +def test_hexbytes_with_prefixing_enabled(): + """Test the 'hexbytes' utility function with the optional 'prefix' option.""" + + value: bytes = b"\x01\x02\x03\x04\x05\x06" + + # The 'prefix' option changes the output to look like a formatted bytes string: + assert hexbytes(value, prefix=True) == r'b"\x01\x02\x03\x04\x05\x06"' + + +def test_hexbytes_with_limiting_enabled(): + """Test the 'hexbytes' utility function with the optional 'limit' option.""" + + value: bytes = b"\x01\x02\x03\x04\x05\x06" + + # The optional `limit` option, limits how many bytes are included in the output: + assert hexbytes(value, limit=4) == "[> 01 02 03 04 ... <]" + + +def test_print_hexbytes(capsys): + """Test the 'print_hexbytes' utility function.""" + + value: bytes = b"\x01\x02\x03\x04\x05\x06" + + print_hexbytes(value) + + captured = capsys.readouterr() + + assert captured.out == "[> 01 02 03 04 05 06 <]\n" + + +def test_print_hexbytes_with_prefixing_enabled(capsys): + """Test the 'print_hexbytes' utility function with the optional 'prefix' option.""" + + value: bytes = b"\x01\x02\x03\x04\x05\x06" + + print_hexbytes(value, prefix=True) + + captured = capsys.readouterr() + + assert captured.out == r'b"\x01\x02\x03\x04\x05\x06"' + "\n" + + +def test_print_hexbytes_with_limiting_enabled(capsys): + """Test the 'print_hexbytes' utility function with the optional 'limit' option.""" + + value: bytes = b"\x01\x02\x03\x04\x05\x06" + + print_hexbytes(value, limit=4) + + captured = capsys.readouterr() + + assert captured.out == "[> 01 02 03 04 ... <]\n" + + +def test_isinstantiable_string(): + """Test the 'isinstantiable' utility function with string types.""" + + value: str = "Hello World" + + assert isinstance(value, str) + assert value == "Hello World" + + assert isinstantiable(value, String) + assert isinstantiable(value, ASCII) + assert isinstantiable(value, UTF8) + assert isinstantiable(value, UTF16) + assert isinstantiable(value, UTF32) + assert isinstantiable(value, Unicode) + + +def test_isinstantiable_integer(): + """Test the 'isinstantiable' utility function with integer types.""" + + value: int = 123 + + assert isinstance(value, int) + assert value == 123 + + assert isinstantiable(value, Int) + assert isinstantiable(value, Int8) + assert isinstantiable(value, Int16) + assert isinstantiable(value, Int32) + assert isinstantiable(value, Int64) + + assert isinstantiable(value, UInt) + assert isinstantiable(value, UInt8) + assert isinstantiable(value, UInt16) + assert isinstantiable(value, UInt32) + assert isinstantiable(value, UInt64) + + assert isinstantiable(value, Char) + assert isinstantiable(value, UnsignedChar) + assert isinstantiable(value, SignedChar) + + assert isinstantiable(value, Short) + assert isinstantiable(value, UnsignedShort) + assert isinstantiable(value, SignedShort) + + assert isinstantiable(value, Long) + assert isinstantiable(value, UnsignedLong) + assert isinstantiable(value, SignedLong) + + assert isinstantiable(value, LongLong) + assert isinstantiable(value, UnsignedLongLong) + assert isinstantiable(value, SignedLongLong) + + assert isinstantiable(value, Size) + assert isinstantiable(value, UnsignedSize) + assert isinstantiable(value, SignedSize) + + +def test_isinstantiable_float(): + """Test the 'isinstantiable' utility function with float types.""" + + value: int = 123.456 + + assert isinstance(value, float) + assert value == 123.456 + + assert isinstantiable(value, Float) + assert isinstantiable(value, Float16) + assert isinstantiable(value, Float32) + assert isinstantiable(value, Float64) + + assert isinstantiable(value, Double) + + +def test_isinstantiable_bytes(): + """Test the 'isinstantiable' utility function with bytes types.""" + + value: bytes = b"\x01\x02\x03\x04" + + assert isinstance(value, bytes) + assert value == b"\x01\x02\x03\x04" + + assert isinstantiable(value, Bytes) + assert isinstantiable(value, Bytes8) + assert isinstantiable(value, Bytes16) + assert isinstantiable(value, Bytes32) + assert isinstantiable(value, Bytes64) + assert isinstantiable(value, Bytes128) + assert isinstantiable(value, Bytes256)