Python Metaclass Example

Summary: in this tutorial, you’ll learn about a Python metaclass example that creates classes with many features.

Introduction to the Python metaclass example

The following defines a Person class with two attributes name and age:

class Person: def __init__(self, name, age): self.name = name self.age = age @property def name(self): return self._name @name.setter def name(self, value): self._name = value @property def age(self): return self._age @age.setter def age(self, value): self._age = value def __eq__(self, other): return self.name == other.name and self.age == other.age def __hash__(self): return hash(f'{self.name, self.age}') def __str__(self): return f'Person(name={self.name},age={self.age})' def __repr__(self): return f'Person(name={self.name},age={self.age})'Code language: Python (python)

Typically, when defining a new class, you need to:

  • Define a list of object’s properties.
  • Define an __init__ method to initialize object’s attributes.
  • Implement the __str__ and __repr__ methods to represent the objects in human-readable and machine-readable formats.
  • Implement the __eq__ method to compare objects by values of all properties.
  • Implement the __hash__ method to use the objects of the class as keys of a dictionary or elements of a set.

As you can see, it requires a lot of code.

Imagine you want to define a Person class like this and automagically has all the functions above:

class Person: props = ['first_name', 'last_name', 'age']Code language: Python (python)

To do that, you can use a metaclass.

Define a metaclass

First, define the Data metaclass that inherits from the type class:class Data(type): passCode language: Python (python)

Second, override the __new__ method to return a new class object:

class Data(type): def __new__(mcs, name, bases, class_dict): class_obj = super().__new__(mcs, name, bases, class_dict) return class_objCode language: Python (python)

Note that the __new__ method is a static method of the Data metaclass. And you don’t need to use the @staticmethod decorator because Python treats it special.

Also, the __new__ method creates a new class like the Person class, not the instance of the Person class.

Create property objects

First, define a Prop class that accepts an attribute name and contains three methods for creating a property object(setget, and delete). The Data metaclass will use this Prop class for adding property objects to the class.

class Prop: def __init__(self, attr): self._attr = attr def get(self, obj): return getattr(obj, self._attr) def set(self, obj, value): return setattr(obj, self._attr, value) def delete(self, obj): return delattr(obj, self._attr)Code language: Python (python)

Second, create a new static method define_property() that creates a property object for each attribute from the props list:

class Data(type): def __new__(mcs, name, bases, class_dict): class_obj = super().__new__(mcs, name, bases, class_dict) Data.define_property(class_obj) return class_obj @staticmethod def define_property(class_obj): for prop in class_obj.props: attr = f'_{prop}' prop_obj = property( fget=Prop(attr).get, fset=Prop(attr).set, fdel=Prop(attr).delete ) setattr(class_obj, prop, prop_obj) return class_objCode language: Python (python)

The following defines the Person class that uses the Data metaclass:

class Person(metaclass=Data): props = ['name', 'age']Code language: Python (python)

The Person class has two properties name and age:

pprint(Person.__dict__)Code language: Python (python)

Output:

mappingproxy({'__dict__': <attribute '__dict__' of 'Person' objects>, '__doc__': None, '__module__': '__main__', '__weakref__': <attribute '__weakref__' of 'Person' objects>, 'age': <property object at 0x000002213CA92090>, 'name': <property object at 0x000002213C772A90>, 'props': ['name', 'age']})Code language: Python (python)

Define __init__ method

The following defines an init static method and assign it to the __init__ attribute of the class object:

class Data(type): def __new__(mcs, name, bases, class_dict): class_obj = super().__new__(mcs, name, bases, class_dict) # create property Data.define_property(class_obj) # define __init__ setattr(class_obj, '__init__', Data.init(class_obj)) return class_obj @staticmethod def init(class_obj): def _init(self, *obj_args, **obj_kwargs): if obj_kwargs: for prop in class_obj.props: if prop in obj_kwargs.keys(): setattr(self, prop, obj_kwargs[prop]) if obj_args: for kv in zip(class_obj.props, obj_args): setattr(self, kv[0], kv[1]) return _init # more methodsCode language: Python (python)

The following creates a new instance of the Person class and initialize its attributes:

p = Person('John Doe', age=25) print(p.__dict__)Code language: Python (python)

Output:

{'_age': 25, '_name': 'John Doe'}Code language: Python (python)

The p.__dict__ contains two attributes _name and _age based on the predefined names in the props list.

Define __repr__ method

The following defines the repr static method that returns a function and uses it for the __repr__ attribute of the class object:

class Data(type): def __new__(mcs, name, bases, class_dict): class_obj = super().__new__(mcs, name, bases, class_dict) # create property Data.define_property(class_obj) # define __init__ setattr(class_obj, '__init__', Data.init(class_obj)) # define __repr__ setattr(class_obj, '__repr__', Data.repr(class_obj)) return class_obj @staticmethod def repr(class_obj): def _repr(self): prop_values = (getattr(self, prop) for prop in class_obj.props) prop_key_values = (f'{key}={value}' for key, value in zip(class_obj.props, prop_values)) prop_key_values_str = ', '.join(prop_key_values) return f'{class_obj.__name__}({prop_key_values_str})' return _reprCode language: Python (python)

The following creates a new instance of the Person class and displays it:

p = Person('John Doe', age=25) print(p)Code language: Python (python)

Output:

Person(name=John Doe, age=25)Code language: Python (python)

Define __eq__ and __hash__ methods

The following defines the eq and hash methods and assigns them to the __eq__ and __hash__ of the class object of the metaclass:

class Data(type): def __new__(mcs, name, bases, class_dict): class_obj = super().__new__(mcs, name, bases, class_dict) # create property Data.define_property(class_obj) # define __init__ setattr(class_obj, '__init__', Data.init(class_obj)) # define __repr__ setattr(class_obj, '__repr__', Data.repr(class_obj)) # define __eq__ & __hash__ setattr(class_obj, '__eq__', Data.eq(class_obj)) setattr(class_obj, '__hash__', Data.hash(class_obj)) return class_obj @staticmethod def eq(class_obj): def _eq(self, other): if not isinstance(other, class_obj): return False self_values = [getattr(self, prop) for prop in class_obj.props] other_values = [getattr(other, prop) for prop in other.props] return self_values == other_values return _eq @staticmethod def hash(class_obj): def _hash(self): values = (getattr(self, prop) for prop in class_obj.props) return hash(tuple(values)) return _hashCode language: Python (python)

The following creates two instances of the Person and compares them. If the values of all properties are the same, they will be equal. Otherwise, they will not be equal:

p1 = Person('John Doe', age=25) p2 = Person('Jane Doe', age=25) print(p1 == p2) # False p2.name = 'John Doe' print(p1 == p2) # TrueCode language: Python (python)

Put it all together

from pprint import pprint class Prop: def __init__(self, attr): self._attr = attr def get(self, obj): return getattr(obj, self._attr) def set(self, obj, value): return setattr(obj, self._attr, value) def delete(self, obj): return delattr(obj, self._attr) class Data(type): def __new__(mcs, name, bases, class_dict): class_obj = super().__new__(mcs, name, bases, class_dict) # create property Data.define_property(class_obj) # define __init__ setattr(class_obj, '__init__', Data.init(class_obj)) # define __repr__ setattr(class_obj, '__repr__', Data.repr(class_obj)) # define __eq__ & __hash__ setattr(class_obj, '__eq__', Data.eq(class_obj)) setattr(class_obj, '__hash__', Data.hash(class_obj)) return class_obj @staticmethod def eq(class_obj): def _eq(self, other): if not isinstance(other, class_obj): return False self_values = [getattr(self, prop) for prop in class_obj.props] other_values = [getattr(other, prop) for prop in other.props] return self_values == other_values return _eq @staticmethod def hash(class_obj): def _hash(self): values = (getattr(self, prop) for prop in class_obj.props) return hash(tuple(values)) return _hash @staticmethod def repr(class_obj): def _repr(self): prop_values = (getattr(self, prop) for prop in class_obj.props) prop_key_values = (f'{key}={value}' for key, value in zip(class_obj.props, prop_values)) prop_key_values_str = ', '.join(prop_key_values) return f'{class_obj.__name__}({prop_key_values_str})' return _repr @staticmethod def init(class_obj): def _init(self, *obj_args, **obj_kwargs): if obj_kwargs: for prop in class_obj.props: if prop in obj_kwargs.keys(): setattr(self, prop, obj_kwargs[prop]) if obj_args: for kv in zip(class_obj.props, obj_args): setattr(self, kv[0], kv[1]) return _init @staticmethod def define_property(class_obj): for prop in class_obj.props: attr = f'_{prop}' prop_obj = property( fget=Prop(attr).get, fset=Prop(attr).set, fdel=Prop(attr).delete ) setattr(class_obj, prop, prop_obj) return class_obj class Person(metaclass=Data): props = ['name', 'age'] if __name__ == '__main__': pprint(Person.__dict__) p1 = Person('John Doe', age=25) p2 = Person('Jane Doe', age=25) print(p1 == p2) # False p2.name = 'John Doe' print(p1 == p2) # TrueCode language: Python (python)

Decorator

The following defines a class called Employee that uses the Data metaclass:

class Employee(metaclass=Data): props = ['name', 'job_title'] if __name__ == '__main__': e = Employee(name='John Doe', job_title='Python Developer') print(e)Code language: Python (python)

Output:

Employee(name=John Doe, job_title=Python Developer)Code language: Python (python)

It works as expected. However, specifying the metaclass is quite verbose. To improve this, you can use a function decorator.

First, define a function decorator that returns a new class which is an instance of the Data metaclass:

def data(cls): return Data(cls.__name__, cls.__bases__, dict(cls.__dict__))Code language: Python (python)

Second, use the @data decorator for any class that uses the Data as the metaclass:

@data class Employee: props = ['name', 'job_title']Code language: Python (python)

The following shows the complete code:

class Prop: def __init__(self, attr): self._attr = attr def get(self, obj): return getattr(obj, self._attr) def set(self, obj, value): return setattr(obj, self._attr, value) def delete(self, obj): return delattr(obj, self._attr) class Data(type): def __new__(mcs, name, bases, class_dict): class_obj = super().__new__(mcs, name, bases, class_dict) # create property Data.define_property(class_obj) # define __init__ setattr(class_obj, '__init__', Data.init(class_obj)) # define __repr__ setattr(class_obj, '__repr__', Data.repr(class_obj)) # define __eq__ & __hash__ setattr(class_obj, '__eq__', Data.eq(class_obj)) setattr(class_obj, '__hash__', Data.hash(class_obj)) return class_obj @staticmethod def eq(class_obj): def _eq(self, other): if not isinstance(other, class_obj): return False self_values = [getattr(self, prop) for prop in class_obj.props] other_values = [getattr(other, prop) for prop in other.props] return self_values == other_values return _eq @staticmethod def hash(class_obj): def _hash(self): values = (getattr(self, prop) for prop in class_obj.props) return hash(tuple(values)) return _hash @staticmethod def repr(class_obj): def _repr(self): prop_values = (getattr(self, prop) for prop in class_obj.props) prop_key_values = (f'{key}={value}' for key, value in zip(class_obj.props, prop_values)) prop_key_values_str = ', '.join(prop_key_values) return f'{class_obj.__name__}({prop_key_values_str})' return _repr @staticmethod def init(class_obj): def _init(self, *obj_args, **obj_kwargs): if obj_kwargs: for prop in class_obj.props: if prop in obj_kwargs.keys(): setattr(self, prop, obj_kwargs[prop]) if obj_args: for kv in zip(class_obj.props, obj_args): setattr(self, kv[0], kv[1]) return _init @staticmethod def define_property(class_obj): for prop in class_obj.props: attr = f'_{prop}' prop_obj = property( fget=Prop(attr).get, fset=Prop(attr).set, fdel=Prop(attr).delete ) setattr(class_obj, prop, prop_obj) return class_obj class Person(metaclass=Data): props = ['name', 'age'] def data(cls): return Data(cls.__name__, cls.__bases__, dict(cls.__dict__)) @data class Employee: props = ['name', 'job_title']Code language: Python (python)

Python 3.7 provided a @dataclass decorator specified in the PEP 557 that has some features like the Data metaclass. Also, the dataclass offers more features that help you save time when working with classes.


Comments

Leave a Reply

Your email address will not be published. Required fields are marked *