Post

Python Metaclasses to the rescue

Using Python metaclasses to add special logic in a class

Python Metaclasses to the rescue

I recently faced the challenge of building a test suite for stress testing certain components of an application.

The idea was to generate a huge load of fake data and feed them to an internal system in order to evaluate the performance and scalability of the application.

Now, generating fake data is nothing really hard these days, however, there were a few requirements that bugged me for a while..

Firstly, the tests would have to run on a daily basis, via a scheduled job in an automated pipeline.

Secondly, all the jobs would have to run against the same environment, meaning that certain attributes of the fake data which were considered unique (such as ids) had to be transformed to avoid integrity errors downstream.

Long story short, the first thing that came to my mind was to create some custom transformers that can programmatically alter certain fields of the incoming data every time the tests run.

Nothing fancy, right?

Sure, but I also wanted the code to be as declarative as possible and written in a bulletproof way so that these transformers are actually enforced.

Sounds familiar? It’s more or less what the ABC library is doing to enforce the implementation of abstract methods by child classes.

This would ensure that if a future developer was ever required to expand the tests, they would not accidentally forget to add some of the required transformers.

Decorators are awesome

Python decorators are one of the most beautiful features in Python and I like using them a lot.

They not only “supercharge” your functions, but can also visually signal that a function has something special!

One of the most well known decorators is the built-in @abstractmethod of the ABC library, which essentially enforces a function to be implemented by any child class that inherits from the parent class/interface.

And this functionality is exactly what I wanted for my transformers!

I just had to figure out how it was implemented.

Welcome to Python metaclasses

Taking a quick look at the abc.py module, gave me an overall understanding of how that mechanism works under the hood.

In a nutshell, the library is using the ABCMeta, which is a type of Metaclass.

Metaclasses is an advanced Python feature and are essentially used to define and create other classes.

In this case, the ABCMeta is used to create abstract classes (Let’s call them interfaces for simplicity..)

When an interface inherits from the ABCMeta class, the ABCMeta will “register” all its abstract functions (decorated with @abstractmethod), prior to defining the interface (at import time).

Doing that is as simple as attaching an __isabstractmethod__ flag to the function objects.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
def abstractmethod(funcobj):
    """A decorator indicating abstract methods.

    Requires that the metaclass is ABCMeta or derived from it.  A
    class that has a metaclass derived from ABCMeta cannot be
    instantiated unless all of its abstract methods are overridden.
    The abstract methods can be called using any of the normal
    'super' call mechanisms.  abstractmethod() may be used to declare
    abstract methods for properties and descriptors.

    Usage:

        class C(metaclass=ABCMeta):
            @abstractmethod
            def my_abstract_method(self, arg1, arg2, argN):
                ...
    """
    funcobj.__isabstractmethod__ = True
    return funcobj

You can easily verify that with a simple example:

1
2
3
4
5
6
7
8
9
>>> from abc import ABCMeta, abstractmethod
>>>
>>> class Interface(metaclass=ABCMeta):
...     @abstractmethod
...     def test(self):
...         pass
...
>>> print(Interface.test.__isabstractmethod__)
True

Later on, when a child class that inherits from the interface is instantiated (at runtime), an evaluation will be performed, to check that all the abstract methods are implemented.

Back to the task

Now, I had a basic understanding of what I needed to do in order to “replicate” this behavior for my transformers.

I started by creating my decorator.

1
2
3
4
5
6
7
8
9
def requiredtransformer(fn) -> Callable:
    """ Decorator used to mark a function as required.
        It works in a similar way to abc.abstractmethod.
    """
    @functools.wraps(fn)
    def wrapper(*args, **kwargs):
        return fn(*args, **kwargs)
    setattr(wrapper, '__isrequiredtransformer__', True)
    return wrapper

Then, I created a metaclass that would allow me to mark specific transformers as “required”.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class EnforceTransformersMeta(type):
    """ Metaclass used to create classes with the additional
        option of marking their functions as 'required'.
    """
    def __new__(
            mcls: type, name: str, bases: Tuple[Any], namespace: Dict[Any, Any], /, **kwargs
    ) -> 'EnforceTransformersMeta':
        cls = super().__new__(mcls, name, bases, namespace, **kwargs)

        setattr(cls, '__requiredtransformers__', set())
        for name, value in namespace.items():
            if getattr(value, "__isrequiredtransformer__", False):
                cls.__requiredtransformers__.add(name)

        return cls

Given that I would also need to use some abstraction, I created a Mixin metaclass that combines the ABCMeta and the new EnforceTransformersMeta.

1
2
3
4
5
6
class MsgTransformerMeta(ABCMeta, EnforceTransformersMeta):
    """ Mixin metaclass that combines abstract class functionality with
        additional functionality of the EnforceTransformersMeta metaclass
        that allows to enforce transformers.
    """
    pass

And now, I was ready to create my actual class that would handle the logic to enforce the required transformers.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
class MsgTransformer(metaclass=MsgTransformerMeta):
    """ Transformer class used to transform a specific type of json """
    __requiredtransformers__: Set[str]

    @property
    @abstractmethod
    def json_type(self) -> str:
        ...

    @abstractmethod
    def _validate_json_type(self, message: dict):
        ...

    def _ensure_transform(
        self, message: dict, transformers: Optional[List[Callable]] = None
    ) -> None:
        """ Ensures the required message transformers were
            called upon the generation of a new message.
        """
        required_transformers = self.__requiredtransformers__

        missing_transformers = None
        if required_transformers and not transformers:
            missing_transformers = required_transformers

        called = set()
        if transformers:
            for func in transformers:
                if isinstance(func, functools.partial):
                    called.add(func.func.__name__)
                else:
                    called.add(func.__name__)

                func(message=message)

        if required_transformers != called:
            missing_transformers = required_transformers.difference(called)

        if missing_transformers:
            raise MissingTransformersError(self.__class__.__name__, missing_transformers)

    def transform(
        self, message: dict, transformers: Optional[List[Callable]] = None
    ) -> Dict[str, Any]:
        """ Validates that all the 'required' transformers
            were used and returns the transformed message.
        """
        self._validate_json_type(message)
        self._ensure_transform(message, transformers)
        return message

If you take a closer look, you will notice that the MsgTransformer class provides a transform function that takes a message and a number of optional callables.

Let’s move on to glue all the pieces together with an example.

Play time

Let’s assume we have the following bank message and we want to use it as a template for generating fake data.

1
2
3
4
5
6
example_json = {
    'correspondence': 'Fake Bank',
    'accountName': 'John Doe',
    'accountNumber': 'IDLBG431911934/2220123',
    'openingBalance': 3000,
}

Now, I am not working for a bank, but I’d assume that the unique identifier in this case would be the accountNumber. Hence, we’d need to alter this number to something unique every time we run the tests.

Let’s create a class with two transformers, replace_account_number and replace_account_name, the former being decorated as @requiredtransformer

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class MyBankTransformer(MsgTransformer):
    """ Transformer class for messages """
    json_type: str = 'FAKE'

    def _validate_json_type(self, message: Dict[str, Any]):
        if message.get('correspondence') != 'Fake Bank':
            raise Exception(f"Invalid json type {self.json_type}")

    @requiredtransformer
    def replace_account_number(
        self, message: Dict[str, Any], account_number: Optional[str] = None
    ) -> Dict[str, Any]:
        message['accountNumber'] = account_number
        return message

    def replace_account_name(
        self, message: Dict[str, Any], account_name: Optional[str] = None
    ) -> Dict[str, Any]:
        message['accountName'] = account_name
        return message

We can call the transform function and pass to it the two transformers.

1
2
3
4
5
6
7
8
9
10
11
msg_transformer = MyBankTransformer()

transformed_json = msg_transformer.transform(
    example_json,
    transformers=[
        functools.partial(msg_transformer.replace_account_number, account_number="ABC123"),
        functools.partial(msg_transformer.replace_account_name, account_name="Jane Doe")
    ]
)

pprint.pprint(transformed_json)
1
2
3
4
5
6
{
    'accountName': 'Jane Doe',
    'accountNumber': 'ABC123',
    'correspondence': 'Fake Bank',
    'openingBalance': 3000
}

The result is as expected. The accountNumber has changed to “ABC123” and the accountName has changed to “Jane Doe”.

And now to the interesting part. Let’s check that our transformers are enforced by removing the replace_account_number transformer.

1
2
3
4
5
6
7
8
9
10
msg_transformer = MyBankTransformer()

transformed_json = msg_transformer.transform(
    example_json,
    transformers=[
        functools.partial(msg_transformer.replace_account_name, account_name="Jane Doe")
    ]
)

pprint.pprint(transformed_json)

And voila! Now we get an exception.

1
2
3
4
5
6
7
8
Traceback (most recent call last):
  File "/home/state_machine/metaclasses.py", line 148, in <module>
    transformed_json = msg_transformer.transform(
  File "/home/state_machine/metaclasses.py", line 110, in transform
    self._ensure_transform(message, transformers)
  File "/home/state_machine/metaclasses.py", line 101, in _ensure_transform
    raise MissingTransformersError(self.__class__.__name__, missing_transformers)
__main__.MissingTransformersError: The following mandatory 'MyBankTransformer' transformers were not applied: {'replace_account_number'}

Conclusion

With a few lines of code, we created our own decorator that gives a special meaning in our classes and offers a decent form of validation.

However, I am still not convinced this is the best way to attack this problem, but time will tell..

Nevertheless, playing with metaclasses was certainly fun!

You can find the complete code in my github repository here

This post is licensed under CC BY 4.0 by the author.