The __bytes__() method

There are relatively few occasions when you will need to transform an object into bytes. Bytes representation is used for the serialization of objects for persistent storage or transfer. We'll look at this in detail in Chapter 10, Serializing and Saving - JSON, YAML, Pickle, CSV and XML through Chapter 14, Configuration Files and Persistence.

In the most common situation, an application will create a string representation, and the built-in encoding capabilities of the Python IO classes can be used to transform the string into bytes. This works perfectly for almost all situations. The main exception would be when we're defining a new kind of string. In which case, we'd need to define the encoding of that string.

The bytes() function does a variety of things, depending on the arguments:

  • bytes(integer): This returns an immutable bytes object with the given number of 0x00 values.
  • bytes(string): This will encode the given string into bytes. Additional parameters for encoding and error handling will define the details of the encoding process.
  • bytes(something): This will invoke something.__bytes__() to create a bytes object. The encoding of error arguments will not be used here.

The base object class does not define __bytes__(). This means our classes don't provide a __bytes__() method by default.

There are some exceptional cases where we might have an object that will need to be encoded directly into bytes before being written to a file. It's often simpler to work with strings and allow the str type to produce bytes for us. When working with bytes, it's important to note that there's no simple way to decode bytes from a file or interface. The built-in bytes class will only decode strings, not our unique, new objects. This means that we'll need to parse the strings that are decoded from the bytes. Or, we might need to explicitly parse the bytes using the struct module and create our unique objects from the parsed values.

We'll look at encoding and decoding the Card2 instance into bytes. As there are only 52 card values, each card could be packed into a single byte. However, we've elected to use a character to represent suit and a character to represent rank. Further, we'll need to properly reconstruct the subclass of Card2, so we have to encode several things:

  • The subclass of Card2 (AceCard2, NumberCard2, and FaceCard2)
  • The parameters to the subclass-defined __init__() methods.

Note that some of our alternative __init__() methods will transform a numeric rank into a string, losing the original numeric value. For the purposes of reversible byte encoding, we need to reconstruct this original numeric rank value.

The following is an implementation of __bytes__(), which returns a utf-8 encoding of the Card2 subclass name, rank, and suit:

def __bytes__(self) -> bytes:
class_code = self.__class__.__name__[0]
rank_number_str = {"A": "1", "J": "11", "Q": "12", "K": "13"}.get(
self.rank, self.rank
)
string = f"({' '.join([class_code, rank_number_str, self.suit])})"
return bytes(string, encoding="utf-8")

This works by creating a string representation of the Card2 object. The representation uses the () objects to surround three space-separated values: code that represents the class, a string that represents the rank, and the suit. This string is then encoded into bytes. 

The following snippet shows how bytes representation looks:

>>> c1 = AceCard2(1, Suit.Club)
>>> bytes(c1)
b'(A 1 xe2x99xa3
)'

When we are given a pile of bytes, we can decode the string from the bytes and then parse the string into a new Card2 object. The following is a method that can be used to create a Card2 object from bytes:

def card_from_bytes(buffer: bytes) -> Card2:
string = buffer.decode("utf8")
try:
if not (string[0] == "(" and string[-1] == ")"):
raise ValueError
code, rank_number, suit_value = string[1:-1].split()
if int(rank_number) not in range(1, 14):
raise ValueError
class_ = {"A": AceCard2, "N": NumberCard2, "F": FaceCard2}[code]
return class_(int(rank_number), Suit(suit_value))
except (IndexError, KeyError, ValueError) as ex:
raise ValueError(f"{buffer!r} isn't a Card2 instance")

In the preceding code, we've decoded the bytes into a string. We checked the string for required (). We've then parsed the string into three individual values using string[1:-1].split(). From those values, we converted the rank to an integer of the valid range, located the class, and built an original Card2 object.

We can reconstruct a Card2 object from a stream of bytes as follows:

>>> data = b'(N 5 xe2x99xa5)'
>>> c2 = card_from_bytes(data)
>>> c2
NumberCard2(suit=<Suit.Heart: '♥'>, rank='5')

It's important to note that an external bytes representation is often challenging to design. In all cases, the bytes are the representation of the state of an object. Python already has a number of representations that work well for a variety of class definitions.

It's often better to use the pickle or json modules than to invent a low-level bytes representation of an object. This will be the subject of Chapter 10, Serializing and Saving - JSON, YAML, Pickle, CSV, and XML.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset