Creating a class decorator

Analogous to decorating a function, we can write a class decorator to add features to a class definition. The essential rules are the same. The decorator is a function (or callable object); it receives a class object as an argument and returns a class object as a result.

We have a limited number of join points inside a class definition as a whole. For the most part, a class decorator can fold additional attributes into a class definition. While it's technically possible to create a new class that wraps an original class definition, this doesn't seem to be very useful as a design pattern. It's also possible to create a new class that is a subclass of the original decorated class definition. This may be baffling to users of the decorator. It's also possible to delete features from a class definition, but this seems perfectly awful.

One sophisticated class decorator was shown previously. The functools.Total_Ordering decorator injects a number of new method functions into the class definition. The technique used in this implementation is to create lambda objects and assign them to attributes of the class.

In general, adding attributes often leads to problems with mypy type hint checking. When we add attributes to a class in a decorator, they're essentially invisible to mypy.

As an example, consider the need to debug object creation. Often, we'd like to have a unique logger for each class.

We're often forced to do something like the following:

class UglyClass1:

def __init__(self) -> None:
self.logger = logging.getLogger(self.__class__.__qualname__)
self.logger.info("New thing")

def method(self, *args: Any) -> int:
self.logger.info("method %r", args)
return 42

This class has the disadvantage that it creates a logger instance variable that's really not part of the class's operation, but is a separate aspect of the class. We'd like to avoid polluting the class with this additional aspect. Even though logging.getLogger() is very efficient, the cost's non-zero. We'd like to avoid this additional overhead every time we create an instance of UglyClass1.

Here's a slightly better version. The logger is promoted to be a class-level instance variable and is separate from each individual object of the class:

class UglyClass2:
logger = logging.getLogger("UglyClass2")

def __init__(self) -> None:
self.logger.info("New thing")

def method(self, *args: Any) -> int:
self.logger.info("method %r", args)
return 42

This has the advantage that it implements logging.getLogger() just once. However, it suffers from a profound Don't Repeat Yourself (DRY) problem. We can't automatically set the class name within the class definition. The class hasn't been created yet, so we're forced to repeat the name.

The DRY problem can be partially solved by a small decorator as follows:

def logged(class_: Type) -> Type:
class_.logger = logging.getLogger(class_.__qualname__)
return class_

This decorator tweaks the class definition to add the logger reference as a class-level attribute. Now, each method can use self.logger to produce audit or debug information. When we want to use this feature, we can use the @logged decorator on the class as a whole.

This presents a profound problem for mypy, more easily solved with a mixin than a decorator.

Continuing to use the class decorator, the following is an example of a logged class, SomeClass:

@logged
class SomeClass:

def __init__(self) -> None:
self.logger.info("New thing") # mypy error

def method(self, *args: Any) -> int:
self.logger.info("method %r", args) # mypy error
return 42

The decorator guarantees that the class has a logger attribute that can be used by any method. The logger attribute is not a feature of each individual instance, but a feature of the class as a whole. This attribute has the added benefit that it creates the logger instances during module import, reducing the overhead of logging slightly. Let's compare this with UglyClass1, where logging.getLogger() was evaluated for each instance creation.

We've annotated two lines that will report mypy errors. The type hint checks whether attributes injected by decorators are not robust enough to detect the additional attribute. The decorator can't easily create attributes visible to mypy. It's better to use the following kind of mixin:

class LoggedWithHook:
def __init_subclass__(cls, name=None):
cls.logger = logging.getLogger(name or cls.__qualname__)

This mixin class defines the __init_subclass__() method to inject an additional attribute into the class definition. This is recognized by mypy, making the logger attribute visible and useful. If the name of the parameter is provided, it becomes the name of the logger, otherwise the name of the subclass will be used. Here's an example class making use of this mixin:

class SomeClass4(LoggedWithHook):

def __init__(self) -> None:
self.logger.info("New thing")

def method(self, *args: Any) -> int:
self.logger.info("method %r", args)
return 42

This class will have a logger built when the class is created. It will be shared by all instances of the class. And the additional attribute will be visible to mypy. In most ordinary application programming, class-level decorators are a rarity. Almost anything needed can be done using the __init_subclass__() method.

Some complex frameworks, such as the @dataclasses.dataclass decorator, involve extending the class from the available scaffolding. The code required to introduce names into the attributes used by mypy is unusual.

Let's see how to add methods to a class in the next section.

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

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