Effective Python 46 - 50

Click here for the first post, which contains the context of this series.

Item #46: Use descriptors for reusable @property methods.

Consider

class GradeBook:
    def __init__(self, grade_1=0, grade_2=0):
        self._grade_1 = grade_1
        self._grade_2 = grade_2
    @staticmethod
    def is_valid(value):
        if not 0 <= value <= 100:
            raise ValueError
    @property
    def grade_1(self):
        return self._grade_1
    @grade_1.setter
    def grade_1(self, value):
        self.is_valid(value)
        self._grade_1 = value
    @property
    def grade_2(self):
        return self._grade_2
    @grade_2.setter
    def grade_2(self, value):
        self.is_valid(value)
        self._grade_2 = value

Adding grade_3, grade_4, ... requires duplicating code, and creating a new class with similar functionality requires also duplicating is_valid.

This can be addressed using a descriptor:

from weakref import WeakKeyDictionary
class Grade:
    def __init__(self):
        self._values = WeakKeyDictionary()
    def __get__(self, instance, instance_type):
        return self._values.get(instance, 0)
    def __set__(self, instance, value):
        if not 0 <= value <= 100:
            raise ValueError
        self._values[instance] = value
class GradeBook:
    grade_1 = Grade()
    grade_2 = Grade()

weakref prevents memory leaks.

Item #47: Use __getattr__, __getattribute__, and __setattr__ for lazy attributes.

Note that

class I47:
    def __getattr__(self, name):
        self.__setattr__(name, None)
        return None
i47 = I47()
i47.test # Calls __getattr__.
i47.test # Doesn't call __getattr__.

Also note that

class I47:
    def __getattribute__(self, name):
        self.__setattr__(name, None)
        return None
i47 = I47()
i47.test # Calls __getattribute__.
i47.test # Calls __getattribute__.

There are interesting use cases for these overloads, like logging. As before, be mindful of the use of super() to avoid infinite recursion.

Item #48: Validate subclasses with __init_subclass__.

Consider

class Polygon:
    sides = None
    def __init_subclass__(cls):
        super().__init_subclass__()
        if cls.sides is None or cls.sides < 3:
            raise ValueError('Polygons must have more than 2 sides.')
class Triangle(Polygon):
    sides = 3
print(1)
class Line(Polygon):
    print(2)
    sides = 2
    print(3)
print(4)

This throws an exception after printing 3 but before printing 4.

Although the use of super().__init_subclass__() is unnecessary here, it is recommended in order to handle multiple inheritance with classes that implement __init_subclass__.

This is only one use case of __init_subclass__.

Item #49: Register class existence with __init_subclass__.

Here is another interesting use case of __init_subclass__:

import json
class_registry = {}
def deserialize(data):
    params = json.loads(data)
    return class_registry[params['class']](*params['args'])
class Serializable:
    def __init__(self, *args):
        self.args = args
    def serialize(self):
        return json.dumps({
            'class': self.__class__.__name__,
            'args': self.args
        })
    def __init_subclass__(cls):
        class_registry[cls.__name__] = cls
class Point3D(Serializable):
    def __init__(self, x, y, z):
        super().__init__(x, y, z)
        self.x = x
        self.y = y
        self.z = z

For such reasons, keeping a class registry is often useful.

Item #50: Annotate class attributes with __set_name__.

Consider

class Grade:
    def __set_name__(self, _, name):
        self.name = name
        self.protected_name = '_' + name
    def __get__(self, instance, _):
        return getattr(instance, self.protected_name, 0)
    def __set__(self, instance, value):
        if not 0 <= value <= 100:
            raise ValueError
        setattr(instance, self.protected_name, value)
class GradeBook:
    grade_1 = Grade()
    grade_2 = Grade()
gb = GradeBook()
print(f'{gb.grade_1}, {gb.grade_2}, {gb.__dict__}')
gb.grade_1 = 91
gb.grade_2 = 98
print(f'{gb.grade_1}, {gb.grade_2}, {gb.__dict__}')

Compare this to Item 46.