7.5. Metaprogramming Metaclass

  • Object is an instance of a class

  • Class is an instance of a Metaclass

  • Class defines how an object behaves

  • Metaclass defines how a class behaves

Metaclasses are deeper magic than 99% of users should ever worry about. If you wonder whether you need them, you don't. The people who actually need them know with certainty that they need them, and don't need an explanation about why.

—Tim Peters

../../_images/oop-metaclass-inheritance.png

Figure 7.2. Object is an instance of a Class. Class is an instance of a Metaclass. Metaclass is an instance of a type. Type is an instance of a type.

When a class definition is executed, the following steps occur:

  1. MRO entries are resolved;

  2. the appropriate metaclass is determined;

  3. the class namespace is prepared;

  4. the class body is executed;

  5. the class object is created.

When using the default metaclass type, or any metaclass that ultimately calls type.__new__, the following additional customisation steps are invoked after creating the class object:

  1. type.__new__ collects all of the descriptors in the class namespace that define a __set_name__() method;

  2. all of these __set_name__ methods are called with the class being defined and the assigned name of that particular descriptor;

  3. the __init_subclass__() hook is called on the immediate parent of the new class in its method resolution order. [2]

7.5.1. Recap

  • Functions are instances of a function class

Before we go into metaclasses, let's recap a fact about functions. Consider a typical function definition:

>>> def add(a, b):
...     return a + b

Functions are instances of a function class:

>>> type(add)
<class 'function'>

By extent they are effectively an instance of a type class.

7.5.2. Class Creation

  • type(str, tuple, dict) will create a class object

We need to recall how classes are created:

>>> User = type('User', (), {})

7.5.3. Function Based Metaclass

  • Metaclass is a function which returns a class

>>> def mytype(clsname, bases, attrs):
...     return type('User', (), {})

Then we can use it, instead of a regular type():

>>> User = mytype('User', (), {})

7.5.4. Class Based Metaclass

  • Typically Metaclasses are defined as a class

  • Metaclass is a class which returns a class from it's constructor

>>> class MyType(type):
...     def __new__(metacls, clsname, bases, attrs):
...         return type(clsname, bases, attrs)

Then we can use it, instead of a regular type():

>>> User = MyType('User', (), {})

7.5.5. Using Metaclasses

  • Metaclasses are used by setting the metaclass keyword argument

  • This works for both function and class based metaclasses

  • For future, we will use the class based metaclasses and metaclass=... syntax

  • It is exactly equivalent to other ones, but it is more common in practice

Class based metaclass:

>>> class MyType(type):
...     def __new__(metacls, clsname, bases, attrs):
...         return type(clsname, bases, attrs)
>>>
>>> class User(metaclass=MyType):
...     pass

Function based metaclass:

>>> def mytype(clsname, bases, attrs):
...     return type('User', (), {})
>>>
>>> class User(metaclass=mytype):
...     pass

Both are equivalent and will create a class object.

7.5.6. Rationale

  • Metaclasses allow you to do 'extra things' when creating a class

  • Allow customization of class instantiation

  • Most commonly used as a class-factory

  • Registering the new class with some registry

  • Replace the class with something else entirely

  • Inject logger instance

  • Injecting static fields

  • Ensure subclass implementation

  • Metaclasses run when Python defines class (even if no instance is created)

The potential uses for metaclasses are boundless. Some ideas that have been explored include enum, logging, interface checking, automatic delegation, automatic property creation, proxies, frameworks, and automatic resource locking/synchronization. [2]

>>> class MyType(type):
...     def __new__(metacls, clsname, bases, attrs):
...         print('before')
...         cls = super().__new__(metacls, clsname, bases, attrs)
...         print('after')
...         return cls
>>> class User(metaclass=MyType):
...     pass
...
before
after

Metaclasses allow you to do 'extra things' when creating a class. You can do something before, or after class creation. You can modify a class, add new fields or methods, ensure name is correct, ensure it inherits from some other class, register it in some registry such as event listener, inject named logger instance, or even replace it with something else entirely.

7.5.7. Example - 1

  • Ensure class name starts with uppercase letter

  • I have chosen NameError exception, but you can choose any other

>>> import string
>>>
>>>
>>> class MyType(type):
...     def __new__(metacls, clsname, bases, attrs):
...         lowercase_letters = tuple(string.ascii_lowercase)
...         if clsname.startswith(lowercase_letters):
...             raise NameError('Class name must start with uppercase letter')
...         return super().__new__(metacls, clsname, bases, attrs)

This code will work without any problems:

>>> class User(metaclass=MyType):
...     pass

But if we change a name of a class to starts with lowercase u, then we will receive an exception:

>>> class user(metaclass=MyType):
...     pass
...
Traceback (most recent call last):
NameError: Class name must start with uppercase letter

7.5.8. Example - 2

  • Ensure class inherits from some other class

  • I have chosen TypeError exception, but you can choose any other

>>> class Account:
...     pass
>>> class MyType(type):
...     def __new__(metacls, clsname, bases, attrs):
...         if Account not in bases:
...             raise TypeError('All classes must inherit from Account')
...         return super().__new__(metacls, clsname, bases, attrs)

This code will work without any problems:

>>> class User(Account, metaclass=MyType):
...     pass

If we forget to inherit from Account class, then we will receive an exception:

>>> class User(metaclass=MyType):
...     pass
...
Traceback (most recent call last):
TypeError: All classes must inherit from Account

7.5.9. Example - 3

  • Ensure __init__ method is always present in a class

  • I have chosen TypeError exception, but you can choose any other

>>> class MyType(type):
...     def __new__(metacls, clsname, bases, attrs):
...         if '__init__' not in attrs:
...             raise TypeError('All classes must have __init__ method')
...         return super().__new__(metacls, clsname, bases, attrs)

This code will work without any problems:

>>> class User(metaclass=MyType):
...     def __init__(self):
...         pass

If we forget to add __init__ method, then we will receive an exception:

>>> class User(metaclass=MyType):
...     pass
...
Traceback (most recent call last):
TypeError: All classes must have __init__ method

7.5.10. Hiding Metaclass

  • Metaclasses can be hidden by using inheritance

  • This is a common pattern in Python frameworks

>>> class MyType(type):
...     def __new__(metacls, name, bases, attrs):
...         uppercase = tuple(string.ascii_uppercase)
...         if not name.startswith(uppercase):
...             raise ValueError(f'Class name must start with uppercase letter')
...         cls = super().__new__(metacls, name, bases, attrs)
...         return cls
>>>
>>> class Account(metaclass=MyType):
...     pass

Usage:

>>> class User(Account):
...     pass
>>> class user(Account):
...     pass
...
Traceback (most recent call last):
ValueError: Class name must start with uppercase letter

7.5.11. Metaclass Arguments

  • Metaclasses can accept additional arguments

  • Arguments can be passed to metaclass using metaclass=... syntax

  • This behavior allows for more customization

>>> from pprint import pprint
>>> import logging
>>> from datetime import datetime, timezone
>>> from uuid import uuid4
>>> class MyType(type):
...     def __new__(metacls, clsname, bases, attrs, debug=False):
...         cls = super().__new__(metacls, clsname, bases, attrs)
...         if debug:
...             cls._since = datetime.now(timezone.utc)
...             cls._uuid = uuid4()
...             cls._log = logging.getLogger(cls.__name__)
...         return cls

Creating an object without additional arguments, will create a normal class with typical attributes:

>>> class User(metaclass=MyType):
...     pass
>>>
>>> pprint(vars(User))
mappingproxy({'__dict__': <attribute '__dict__' of 'User' objects>,
              '__doc__': None,
              '__firstlineno__': 1,
              '__module__': '__main__',
              '__static_attributes__': (),
              '__weakref__': <attribute '__weakref__' of 'User' objects>})

Setting debug to True, will create a class with additional attributes, that is _since, _uuid, and _log, which can enable better debugging:

>>> class User(metaclass=MyType, debug=True):
...     pass
>>>
>>> pprint(vars(User))  
mappingproxy({'__dict__': <attribute '__dict__' of 'User' objects>,
              '__doc__': None,
              '__module__': '__main__',
              '__weakref__': <attribute '__weakref__' of 'User' objects>,
              '_log': <Logger User (INFO)>,
              '_since': datetime.datetime(2024, 8, 21, 10, 11, 16, 244761, tzinfo=datetime.timezone.utc),
              '_uuid': UUID('8eb20432-38dc-4a3b-b798-079285fabde0')})

7.5.12. Metaclass Protocol

  • __prepare__(metacls, name, bases, **kwargs) -> dict - on class namespace initialization

  • __new__(metacls, classname, bases, attrs) -> cls - before class creation

  • __init__(self, name, bases, attrs) -> None - after class creation

  • __call__(self, *args, **kwargs) - when the class is called (instantiated)

>>> class MyType(type):
...     @classmethod
...     def __prepare__(metacls, name, bases) -> 'mappingproxy': ...
...     def __new__(metacls, classname, bases, attrs, *args, **kwargs) -> type: ...
...     def __init__(self, *args, **kwargs) -> None: ...
...     def __call__(self, *args, **kwargs) -> object: ...

Once the appropriate metaclass has been identified, then the class namespace is prepared. If the metaclass has a __prepare__ attribute, it is called as namespace = metaclass.__prepare__(name, bases, **kwds) (where the additional keyword arguments, if any, come from the class definition). The __prepare__ method should be implemented as a classmethod(). The namespace returned by __prepare__ is passed in to __new__, but when the final class object is created the namespace is copied into a new dict. If the metaclass has no __prepare__ attribute, then the class namespace is initialised as an empty ordered mapping. [1]

>>> class MyType(type):
...     @classmethod
...     def __prepare__(metacls, name, bases):
...         print('prepare: before')
...         result = super().__prepare__(metacls, name, bases)
...         print('prepare: after')
...         return result
...
...     def __new__(metacls, clsname, bases, attrs):
...         print('new: before')
...         result = super().__new__(metacls, clsname, bases, attrs)
...         print('new: after')
...         return result
...
...     def __init__(self, *args, **kwargs):
...         print('init: before')
...         super().__init__(*args, **kwargs)
...         print('init: after')
...
...     def __call__(self, *args, **kwargs):
...         print('call: before')
...         result = super().__call__(*args, **kwargs)
...         print('call: after')
...         return result
>>> class User(metaclass=MyType):
...     pass
prepare: before
prepare: after
new: before
new: after
init: before
init: after
>>> mark = User()
call: before
call: after

7.5.13. Case Study

class MyType(type):
    def __new__(metacls, clsname, bases, attrs):
        if Account not in bases:
            raise TypeError('All classes must inherit from Account')
        return super().__new__(metacls, clsname, bases, attrs)


class Account:
    pass

class User(Account, metaclass=MyType):
    pass

class Admin(metaclass=MyType):
    pass
# Traceback (most recent call last):
# TypeError: All classes must inherit from Account
from string import ascii_uppercase

class MyType(type):
    def __new__(metacls, clsname, bases, attrs):
        uppercase = tuple(ascii_uppercase)
        if not clsname.startswith(uppercase):
            raise NameError('All class names must starts with uppercase letter')
        return super().__new__(metacls, clsname, bases, attrs)


class User(metaclass=MyType):
    pass

class admin(metaclass=MyType):
    pass
# Traceback (most recent call last):
# NameError: All class names must starts with uppercase letter
class MyType(type):
    def __new__(metacls, clsname, bases, attrs):
        if ('AGE_MIN' not in attrs) or ('AGE_MAX' not in attrs):
            raise TypeError('All classes must have AGE_MIN and AGE_MAX defined')
        return super().__new__(metacls, clsname, bases, attrs)


class User(metaclass=MyType):
    AGE_MIN = 18
    AGE_MAX = 65


class Admin(metaclass=MyType):
    pass
# Traceback (most recent call last):
# TypeError: All classes must have AGE_MIN and AGE_MAX defined
import logging
from datetime import timezone, datetime
from uuid import uuid4


class MyType(type):
    def __new__(metacls, clsname, bases, attrs, trace=False):
        cls = super().__new__(metacls, clsname, bases, attrs)
        if trace:
            cls._uuid4 = uuid4()
            cls._since = datetime.now(timezone.utc)
            cls._log = logging.getLogger(clsname)
        return cls


class User(metaclass=MyType):
    pass

class Admin(metaclass=MyType, trace=True):
    pass


pprint(vars(User))
# mappingproxy({'__dict__': <attribute '__dict__' of 'User' objects>,
#               '__doc__': None,
#               '__module__': '__main__',
#               '__weakref__': <attribute '__weakref__' of 'User' objects>})


pprint(vars(Admin))
# mappingproxy({'__dict__': <attribute '__dict__' of 'Admin' objects>,
#               '__doc__': None,
#               '__module__': '__main__',
#               '__weakref__': <attribute '__weakref__' of 'Admin' objects>,
#               '_log': <Logger Admin (INFO)>,
#               '_since': datetime.datetime(2024, 8, 7, 8, 15, 58, 999871, tzinfo=datetime.timezone.utc),
#               '_uuid4': UUID('9b7bedd0-51d8-44bc-93bb-85fc298299d1')})
from datetime import datetime, timezone
from uuid import uuid4


class Register(type):
    listeners = {}

    @classmethod
    def on_create(cls, *names):
        def wrapper(func):
            for name in names:
                if name not in cls.listeners:
                    cls.listeners[name] = []
                cls.listeners[name] += [func]
        return wrapper

    def __new__(cls, name, bases, attrs):
        for listener in cls.listeners.get(name, []):
            name, bases, attrs = listener(name, bases, attrs)
        return super().__new__(cls, name, bases, attrs)


@Register.on_create('User', 'Admin')
def debug(name, bases, attrs):
    print(f'DEBUG: {name=}')
    print(f'DEBUG: {bases=}')
    print(f'DEBUG: {attrs=}')
    return name, bases, attrs

@Register.on_create('User', 'Admin')
def debug(name, bases, attrs):
    print(f'INFO: {name=}')
    return name, bases, attrs

@Register.on_create('User')
def trace(name, bases, attrs):
    attrs['_uuid4'] = uuid4()
    attrs['_since'] = datetime.now(timezone.utc)
    return name, bases, attrs


class Account(metaclass=Register):
    pass


class User(Account):
    pass
# DEBUG: name='User'
# DEBUG: bases=(<class '__main__.Account'>,)
# DEBUG: attrs={'__module__': '__main__', '__qualname__': 'User'}
# INFO: name='User'


class Admin(Account):
    pass
# DEBUG: name='Admin'
# DEBUG: bases=(<class '__main__.Account'>,)
# DEBUG: attrs={'__module__': '__main__', '__qualname__': 'Admin'}
# INFO: name='Admin'


pprint(vars(User))
# mappingproxy({'__doc__': None,
#               '__module__': '__main__',
#               '_since': datetime.datetime(2024, 8, 21, 10, 1, 58, 442815, tzinfo=datetime.timezone.utc),
#               '_uuid4': UUID('dd17cdb6-6aec-4afb-9434-54f4297715af')})


pprint(vars(Admin))
# mappingproxy({'__module__': '__main__', '__doc__': None})



# listeners = {
#     'User': [debug, trace],
#     'Admin': [debug],
# }

7.5.14. Use Case - 1

  • Injecting a named logger instance

>>> import logging
>>>
>>>
>>> class Logger(type):
...     def __init__(cls, *args, **kwargs):
...         cls._logger = logging.getLogger(cls.__name__)
>>> class User(metaclass=Logger):
...     pass
>>>
>>> class Admin(metaclass=Logger):
...     pass
>>> print(User._logger)
<Logger User (WARNING)>
>>>
>>> print(Admin._logger)
<Logger Admin (WARNING)>

7.5.15. Instance vs. Class vs. Metaclass

  • MRO - Method Resolution Order

  • MRO is a list of classes that Python will use to search for methods

  • MRO is defined by C3 Linearization algorithm

  • MRO is defined by __mro__ attribute

Let's consider a normal (without a metaclass) class:

>>> class User:
...     pass

Object is an instance of a class, and an object:

>>> mark = User()
>>>
>>> isinstance(mark, User)
True
>>>
>>> isinstance(mark, object)
True

This is because User class is an instance of a type class:

>>> User.__mro__
(<class '__main__.User'>, <class 'object'>)

Now, consider a case with using metaclasses:

>>> class MyType(type):
...     pass
>>>
>>> class User(metaclass=MyType):
...     pass

Instances for User class are still instances of a User class:

>>> mark = User()
>>>
>>> isinstance(mark, User)
True
>>>
>>> isinstance(mark, object)
True

But, they are not an instances of a metaclass:

>>> isinstance(mark, MyType)
False
>>>
>>> isinstance(User, MyType)
True

And a class still inherits from object:

>>> User.__mro__
(<class '__main__.User'>, <class 'object'>)

The following diagram displays the relationship between an instance, class and a metaclass:

../../_images/oop-metaclass-diagram.png

Figure 7.3. Object is an instance of a Class. Class is an instance of a Metaclass. Metaclass is an instance of a type. Type is an instance of a type.

7.5.16. Class Bases

  • Bases for an instance stays the same

  • Bases of a class changes when using a metaclass

>>> class User:
...     pass
>>>
>>>
>>> User.__class__.__bases__
(<class 'object'>,)
>>>
>>> mark = User()
>>> mark.__class__.__bases__
(<class 'object'>,)

Bases for an instance stays the same, but note, how bases of a class changes when using a metaclass:

>>> class MyType(type):
...     def __new__(metacls, classname, bases, attrs):
...         return type(classname, bases, attrs)
>>>
>>> class User(metaclass=MyType):
...     pass
>>>
>>>
>>> User.__class__.__bases__
(<class 'object'>,)
>>>
>>> mark = User()
>>> mark.__class__.__bases__
(<class 'object'>,)

7.5.17. Type Metaclass

  • All types (user-defined and built-in) are instances of a type class

  • This is also true for an object type

  • Even type type is an instance of a type

User defined classes are instances of a type class:

>>> class User:
...     pass
>>>
>>> type(User)
<class 'type'>

All built-in types are instances of a type class:

>>> type(int)
<class 'type'>
>>>
>>> type(float)
<class 'type'>
>>>
>>> type(bool)
<class 'type'>
>>>
>>> type(str)
<class 'type'>
>>>
>>> type(bytes)
<class 'type'>
>>>
>>> type(list)
<class 'type'>
>>>
>>> type(tuple)
<class 'type'>
>>>
>>> type(set)
<class 'type'>
>>>
>>> type(frozenset)
<class 'type'>
>>>
>>> type(dict)
<class 'type'>

Also an object is an instance of a type:

>>> type(object)
<class 'type'>

Even type type is an instance of a type:

>>> type(type)
<class 'type'>

7.5.18. Metaclass replacements

  • Metaclasses allows for more customization, but at a cost of complexity

  • There are several ways, how you can achieve the same effect

SetUp:

>>> import logging

Inheritance and __init__() method. This requires calling super() in a subclass if __init__() is overridden:

>>> class Logger:
...     def __init__(self):
...         self._logger = logging.getLogger(self.__class__.__name__)
>>>
>>> class User(Logger):
...     pass
>>>
>>>
>>> mark = User()
>>> print(mark._logger)
<Logger User (WARNING)>

Inheritance and __new__() method. Overriding __new__ method is less common, but it is possible. In such case, super() is needed:

>>> class Logger:
...     def __new__(cls, *args, **kwargs):
...         obj = super().__new__(cls)
...         obj._logger = logging.getLogger(obj.__class__.__name__)
...         return obj
>>>
>>> class User(Logger):
...     pass
>>>
>>>
>>> mark = User()
>>> print(mark._logger)
<Logger User (WARNING)>

Class Decorator. This is a more common way to achieve the same effect. The problem with decorators is that they substitute the class with a new class Wrapper, which can lead to problems with inheritance:

>>> def logger(cls):
...     class Wrapper(cls):
...         _logger = logging.getLogger(cls.__name__)
...     return Wrapper
>>>
>>>
>>> @logger
... class User:
...     pass
>>>
>>>
>>> mark = User()
>>> print(mark._logger)
<Logger User (WARNING)>

7.5.19. Case Study

>>> import logging
>>> from datetime import datetime, timezone
>>> from uuid import uuid4

Let's create a metaclass EventListener which enables to register listeners for class creation.

>>> class OnCreate(type):
...     listeners = {}
...
...     def __new__(metacls, clsname, bases, attrs):
...         for listener in metacls.listeners.get(clsname, []):
...             clsname, bases, attrs = listener(clsname, bases, attrs)
...         return super().__new__(metacls, clsname, bases, attrs)
...
...     @classmethod
...     def of(metacls, *clsnames):
...         def wrapper(func):
...             for clsname in clsnames:
...                 if clsname not in metacls.listeners:
...                     metacls.listeners[clsname] = []
...                 metacls.listeners[clsname] += [func]
...         return wrapper
>>> @OnCreate.of('User')
... def trace(clsname, bases, attrs):
...     attrs['_since'] = datetime.now(timezone.utc)
...     attrs['_uuid'] = uuid4()
...     attrs['_log'] = logging.getLogger(clsname)
...     return clsname, bases, attrs
>>>
>>> @OnCreate.of('User', 'Admin')
... def info(clsname, bases, attrs):
...     print(f'Info: {clsname=}')
...     return clsname, bases, attrs
>>>
>>> @OnCreate.of('User', 'Admin')
... def debug(clsname, bases, attrs):
...     print(f'Debug: {clsname=}')
...     print(f'Debug: {bases=}')
...     print(f'Debug: {attrs=}')
...     return clsname, bases, attrs

We can inspect what listeners are already registered to class creations:

>>> result = OnCreate.listeners
>>> pprint(result, width=30)  
{'Admin': [<function info at 0x14c9db740>,
           <function debug at 0x14c9db7e0>],
 'User': [<function trace at 0x14c9db6a0>,
          <function info at 0x14c9db740>,
          <function debug at 0x14c9db7e0>]}

Let's create a Base class to hide the metaclass complexity:

>>> class Base(metaclass=OnCreate):
...     pass

Finally, let's create classes:

>>> class User(Base):  
...     pass
...
Info: clsname='User'
Debug: clsname='User'
Debug: bases=(<class '__main__.Base'>,)
Debug: attrs={'__module__': '__main__', '__qualname__': 'User', '_since': datetime.datetime(2024, 7, 9, 19, 30, 3, 181585, tzinfo=datetime.timezone.utc), '_uuid': UUID('a20292f4-23c4-4a76-934b-1f2c3fd4967c'), '_log': <Logger User (INFO)>}
>>> class Admin(Base):
...     pass
...
Info: clsname='Admin'
Debug: clsname='Admin'
Debug: bases=(<class '__main__.Base'>,)
Debug: attrs={'__module__': '__main__', '__qualname__': 'Admin', '__firstlineno__': 1, '__static_attributes__': ()}

Now we can inspect created classes:

>>> pprint(vars(User))  
mappingproxy({'__doc__': None,
              '__module__': '__main__',
              '_log': <Logger User (INFO)>,
              '_since': datetime.datetime(2024, 7, 9, 19, 30, 3, 181585, tzinfo=datetime.timezone.utc),
              '_uuid': UUID('a20292f4-23c4-4a76-934b-1f2c3fd4967c')})
>>> pprint(vars(Admin))
mappingproxy({'__doc__': None,
              '__firstlineno__': 1,
              '__module__': '__main__',
              '__static_attributes__': ()})

7.5.20. Use Case - 1

  • Abstract Base Classes are great example of using metaclasses

  • ABCMeta will ensure, that you cannot create an instance of an abstract class

  • ABCMeta at creation ensures that the implementing class covers all abstract methods

SetUp:

>>> from abc import ABCMeta, abstractmethod

ABCMeta will ensure, that you cannot create an instance of an abstract class:

>>> class Account(metaclass=ABCMeta):
...     @abstractmethod
...     def login(self):
...         ...
>>>
>>>
>>> mark = Account()
Traceback (most recent call last):
TypeError: Can't instantiate abstract class Account without an implementation for abstract method 'login'

ABCMeta at creation ensures that the implementing class covers all abstract methods: If not, then an exception is raised:

>>> class Account(metaclass=ABCMeta):
...     @abstractmethod
...     def login(self):
...         ...
>>>
>>> class User(Account):
...     pass
>>>
>>>
>>> mark = User()
Traceback (most recent call last):
TypeError: Can't instantiate abstract class User without an implementation for abstract method 'login'

If those two conditions are met, the instance will be created:

>>> class Account(metaclass=ABCMeta):
...     @abstractmethod
...     def login(self):
...         ...
>>>
>>> class User(Account):
...     def login(self):
...         print('User login')
>>>
>>>
>>> mark = User()
>>> mark.login()
User login

7.5.21. Use Case - 2

  • Metaclasses can force a class to inherit from a base class

  • They can automatically inject a base class to the bases

>>> class Account:
...     pass
>>>
>>> class MyType(type):
...     def __new__(metacls, clsname, bases, attrs):
...         if Account not in bases:
...             bases += (Account,)
...         cls = type(clsname, bases, attrs)
...         return cls

Define a class:

>>> class User(metaclass=MyType):
...     pass
>>>
>>>
>>> User.mro()
[<class '__main__.User'>, <class '__main__.Account'>, <class 'object'>]

7.5.22. Use Case - 3

  • Event Listener

>>> class EventListener(type):
...     listeners = {}
...
...     @classmethod
...     def register(metacls, *clsnames):
...         def wrapper(func):
...             for clsname in clsnames:
...                 if not clsname in metacls.listeners:
...                     metacls.listeners[clsname] = []
...                 metacls.listeners[clsname] += [func]
...             return func
...         return wrapper
...
...     def __new__(metacls, clsname, bases, attrs):
...         for listener in metacls.listeners.get(clsname, []):
...             clsname, bases, attrs = listener(clsname, bases, attrs)
...         return super().__new__(metacls, clsname, bases, attrs)
>>> @EventListener.register('User')
... def debug(clsname, bases, attrs):
...     print(f'Debug: {clsname=}')
...     print(f'Debug: {bases=}')
...     print(f'Debug: {attrs=}')
...     return clsname, bases, attrs
...
...
>>> @EventListener.register('User', 'Admin')
... def info(clsname, bases, attrs):
...     print(f'Info: {clsname=}')
...     return clsname, bases, attrs
>>> EventListener.listeners  
{'User': [<function debug at 0x1051c0d60>, <function info at 0x1051c0e00>],
 'Admin': [<function info at 0x1051c0e00>]}
>>> class User(metaclass=EventListener):
...     pass
...
Debug: clsname='User'
Debug: bases=()
Debug: attrs={'__module__': '__main__', '__qualname__': 'User', '__firstlineno__': 1, '__static_attributes__': ()}
Info: clsname='User'
>>> class Admin(metaclass=EventListener):
...     pass
...
Info: clsname='Admin'

7.5.23. Use Case - 5

>>> from datetime import datetime, timezone
>>> import logging
>>> from uuid import uuid4
>>>
>>>
>>> class EventListener(type):
...     listeners = {}
...
...     @classmethod
...     def register(metacls, *clsnames):
...         def decorator(func):
...             for clsname in clsnames:
...                 if clsname not in metacls.listeners:
...                     metacls.listeners[clsname] = []
...                 metacls.listeners[clsname] += [func]
...         return decorator
...
...     def __new__(metacls, clsname, bases, attrs):
...         listeners = metacls.listeners.get(clsname, [])
...         cls = type(clsname, bases, attrs)
...         for listener in listeners:
...             cls = listener.__call__(cls)
...         return cls

Create listener functions and register them for class creation:

>>> @EventListener.register('User', 'Admin')
... def add_logger(cls):
...     cls._log = logging.getLogger(cls.__name__)
...     return cls
>>>
>>>
>>> @EventListener.register('User')
... def add_trace(cls):
...     cls._uuid = str(uuid4())
...     cls._since = datetime.now(tz=timezone.utc)
...     return cls

Now, define classes with EventListener metaclass.

>>> class User(metaclass=EventListener):
...     pass
>>>
>>> class Admin(metaclass=EventListener):
...     pass
>>> pprint(vars(User))  
mappingproxy({'__dict__': <attribute '__dict__' of 'User' objects>,
              '__doc__': None,
              '__firstlineno__': 1,
              '__module__': '__main__',
              '__static_attributes__': (),
              '__weakref__': <attribute '__weakref__' of 'User' objects>,
              '_log': <Logger User (WARNING)>,
              '_since': datetime.datetime(2024, 12, 1, 11, 32, 45, 680739, tzinfo=datetime.timezone.utc),
              '_uuid': 'aa237774-d5a7-448f-bb04-034c06419332'})
>>> pprint(vars(Admin))
mappingproxy({'__dict__': <attribute '__dict__' of 'Admin' objects>,
              '__doc__': None,
              '__firstlineno__': 1,
              '__module__': '__main__',
              '__static_attributes__': (),
              '__weakref__': <attribute '__weakref__' of 'Admin' objects>,
              '_log': <Logger Admin (WARNING)>})

7.5.24. Use Case - 6

  • Singleton

>>> class Singleton(type):
...     _instances = {}
...     def __call__(cls, *args, **kwargs):
...         if cls not in cls._instances:
...             cls._instances[cls] = super().__call__(*args, **kwargs)
...         return cls._instances[cls]
>>>
>>>
>>> class MyClass(metaclass=Singleton):
...     pass
>>> a = MyClass()
>>> b = MyClass()
>>>
>>> a is b
True
>>> id(a)  
4375248416
>>>
>>> id(b)  
4375248416

7.5.25. Use Case - 7

  • Final

>>> class Final(type):
...     def __new__(metacls, classname, base, attrs):
...         for cls in base:
...             if isinstance(cls, Final):
...                 raise TypeError(f'{cls.__name__} is final and cannot inherit from it')
...         return type.__new__(metacls, classname, base, attrs)
>>>
>>>
>>> class MyClass(metaclass=Final):
...     pass
>>>
>>> class SomeOtherClass(MyClass):
...    pass
Traceback (most recent call last):
TypeError: MyClass is final and cannot inherit from it

7.5.26. Use Case - 8

  • Django

Access static fields of a class, before creating instance:

>>> 
... from django.db import models
...
... # class Model(metaclass=...)
... #     ...
...
...
... class User(models.Model):
...     firstname = models.CharField(max_length=255)
...     lastname = models.CharField(max_length=255)

7.5.27. References

7.5.28. Assignments