DEV Community

Cover image for Python Metaprogramming Unleashed: Full Control Over Everything
Leapcell
Leapcell

Posted on

Python Metaprogramming Unleashed: Full Control Over Everything

Image description

Leapcell: The Best Serverless Platform for Web Hosting

Exploration of Metaprogramming in Python

Many people are unfamiliar with the concept of "metaprogramming", and there isn't a highly precise definition for it. This article centers on metaprogramming within Python. However, in reality, what is discussed here may not fully adhere to the strict definition of "metaprogramming". It's just that I couldn't find a more apt term to represent the theme of this article, so I borrowed this one.

The subtitle is "Control Everything You Want to Control". Essentially, this article focuses on one thing: leveraging the features provided by Python to make the code as elegant and concise as possible. Specifically, through programming techniques, we modify the characteristics of an abstraction at a higher level of abstraction.

First and foremost, it's a well - known cliché that everything in Python is an object. Additionally, Python offers numerous "metaprogramming" mechanisms, such as special methods and metaclasses. Operations like dynamically adding attributes and methods to an object are not regarded as "metaprogramming" in Python at all. But in some static languages, achieving this requires certain skills. Let's discuss some aspects that can easily perplex Python programmers.

Let's start by classifying objects into different levels. Commonly, we know that an object has its type, and Python has long implemented types as objects. Thus, we have instance objects and class objects, which are two levels. Readers with a basic understanding will be aware of the existence of metaclasses. In brief, a metaclass is the "class" of a "class", meaning it is at a higher level than a class. This adds another level. Are there more?

ImportTime vs RunTime

If we view it from a different perspective and don't need to apply the same criteria as the previous three levels, we can distinguish between two concepts: ImportTime and RunTime. The boundaries between them are not distinct. As the names suggest, they refer to two moments: the time of import and the time of execution.

What occurs when a module is imported? Statements in the global scope (non - definitional statements) are executed. What about function definitions? A function object is created, but the code within it is not executed. For class definitions, a class object is created, the code in the class definition scope is executed, and the code in the class methods is not executed naturally.

What about during execution? The code in functions and methods will be executed. Of course, you need to call them first.

Metaclasses

So, we can say that metaclasses and classes belong to ImportTime. After a module is imported, they are created. Instance objects belong to RunTime. Simply importing a module won't create instance objects. However, we can't be too dogmatic because if you instantiate a class within the module scope, instance objects will also be created. It's just that we usually write the instantiation inside functions, hence this classification.

If you want to control the characteristics of the created instance objects, what should you do? It's quite simple. Override the __init__ method in the class definition. Then, what if we want to control some properties of the class? Is there such a need? Definitely!

Regarding the classic singleton pattern, everyone knows there are multiple ways to implement it. The requirement is that a class can only have one instance.

The simplest implementation is as follows:

class _Spam:
    def __init__(self):
        print("Spam!!!")

_spam_singleton = None

def Spam():
    global _spam_singleton
    if _spam_singleton is not None:
        return _spam_singleton
    else:
        _spam_singleton = _Spam()
        return _spam_singleton
Enter fullscreen mode Exit fullscreen mode

This factory - like pattern isn't very elegant. Let's review the requirement once more. We desire a class to have only one instance. The methods we define in a class are the behaviors of instance objects. So, if we want to change the behavior of a class, we need something at a higher level. This is where metaclasses come into play. As mentioned earlier, a metaclass is the class of a class. That is to say, the __init__ method of a metaclass is the initialization method of a class. We know there is also the __call__ method, which enables an instance to be called like a function. Then, this method of a metaclass is the one called when a class is instantiated.

The code can be written like this:

class Singleton(type):
    def __init__(self, *args, **kwargs):
        self._instance = None
        super().__init__(*args, **kwargs)

    def __call__(self, *args, **kwargs):
        if self._instance is None:
            self._instance = super().__call__(*args, **kwargs)
            return self._instance
        else:
            return self._instance


class Spam(metaclass = Singleton):
    def __init__(self):
        print("Spam!!!")
Enter fullscreen mode Exit fullscreen mode

There are two main differences compared to a general class definition. One is that the base class of Singleton is type, and the other is that there is a metaclass = Singleton in the definition of Spam. What is type? It is a subclass of object, and object is its instance. That is to say, type is the class of all classes, the most fundamental metaclass. It stipulates some operations that all classes need when they are created. So, our custom metaclass needs to subclass type. At the same time, type is also an object, so it is a subclass of object. It's a bit hard to grasp, but just get a general idea.

Decorators

Let's talk about decorators. Most people consider decorators one of the most challenging concepts to understand in Python. In fact, it's just syntactic sugar. Once you understand that functions are also objects, you can easily write your own decorators.

from functools import wraps


def print_result(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        result = func(*args, **kwargs)
        print(result)
        return result

    return wrapper


@print_result
def add(x, y):
    return x + y


# Equivalent to:
# add = print_result(add)

add(1, 3)
Enter fullscreen mode Exit fullscreen mode

Here, we also use a decorator @wraps, which is used to make the returned inner function wrapper have the same function signature as the original function. Basically, we should add it when writing decorators.

As I wrote in the comments, the form of @decorator is equivalent to func = decorator(func). Understanding this point allows us to write more types of decorators. For example, class decorators, and writing a decorator as a class.

def attr_upper(cls):
    for attrname, value in cls.__dict__.items():
        if isinstance(value, str):
            if not value.startswith('__'):
                setattr(cls, attrname, bytes.decode(str.encode(value).upper()))
    return cls


@attr_upper
class Person:
    sex ='man'


print(Person.sex)  # MAN
Enter fullscreen mode Exit fullscreen mode

Pay attention to the differences between the implementation of ordinary decorators and class decorators.

Data Abstraction - Descriptors

If we want some classes to possess certain common characteristics or be able to exercise control over them within the class definition, we can customize a metaclass and make it the metaclass of these classes. If we want some functions to have certain common functions and avoid code duplication, we can define a decorator. Then, what if we want the attributes of instances to have some common characteristics? Some may say we can use property, and indeed we can. But this logic must be written in each class definition. If we want some attributes of the instances of these classes to have the same characteristics, we can customize a descriptor class.

Regarding descriptors, this article https://docs.python.org/3/howto/descriptor.html explains it very well. At the same time, it also elaborates on how descriptors are hidden behind functions to achieve the unification and differences between functions and methods. Here are some examples.

class TypedField:
    def __init__(self, _type):
        self._type = _type

    def __get__(self, instance, cls):
        if instance is None:
            return self
        else:
            return getattr(instance, self.name)

    def __set_name__(self, cls, name):
        self.name = name

    def __set__(self, instance, value):
        if not isinstance(value, self._type):
            raise TypeError('Expected' + str(self._type))
        instance.__dict__[self.name] = value


class Person:
    age = TypedField(int)
    name = TypedField(str)

    def __init__(self, age, name):
        self.age = age
        self.name = name


jack = Person(15, 'Jack')
jack.age = '15'  # Will raise an error
Enter fullscreen mode Exit fullscreen mode

There are several roles here. TypedField is a descriptor class, and the attributes of Person are instances of the descriptor class. It seems that the descriptor exists as an attribute of Person, that is, a class attribute rather than an instance attribute. But in fact, once an instance of Person accesses an attribute with the same name, the descriptor will take effect. It should be noted that in Python 3.5 and earlier versions, there is no __set_name__ special method. This means that if you want to know what name the descriptor is given in the class definition, you need to explicitly pass it to the descriptor when instantiating it, that is, you need one more parameter. However, in Python 3.6, this problem is solved. You just need to override the __set_name__ method in the descriptor class definition. Also, note the writing of __get__. Basically, the judgment of instance is necessary, otherwise it will raise an error. The reason isn't difficult to understand, so I won't go into details.

Controlling Subclass Creation - An Alternative to Metaclasses

In Python 3.6, we can customize the creation of subclasses by implementing the __init_subclass__ special method. In this way, we can avoid using the somewhat cumbersome metaclasses in some cases.

class PluginBase:
    subclasses = []

    def __init_subclass__(cls, **kwargs):
        super().__init_subclass__(**kwargs)
        cls.subclasses.append(cls)


class Plugin1(PluginBase):
    pass


class Plugin2(PluginBase):
    pass
Enter fullscreen mode Exit fullscreen mode

Summary

Metaprogramming techniques such as metaclasses are somewhat obscure and difficult to comprehend for most people, and most of the time, we don't need to use them. However, the implementation of most frameworks utilizes these techniques so that the code written by users can be concise and easy to understand. If you want to gain a deeper understanding of these techniques, you can refer to some books such as Fluent Python and Python Cookbook (some content of this article is referenced from them), or read some chapters in the official documentation, such as the descriptor How - To mentioned above, and the Data Model section, etc. Or directly examine the Python source code, including the source code written in Python and the CPython source code.

Remember, only use these techniques after fully understanding them, and don't attempt to use them everywhere.

Leapcell: The Best Serverless Platform for Web Hosting

Image description

Finally, I'd like to recommend a platform Leapcell that is highly suitable for deploying Python services.

1. Multi - Language Support

  • Develop with JavaScript, Python, Go, or Rust.

2. Deploy unlimited projects for free

  • Pay only for usage — no requests, no charges.

3. Unbeatable Cost Efficiency

  • Pay - as - you - go with no idle charges.
  • Example: $25 supports 6.94M requests at a 60ms average response time.

4. Streamlined Developer Experience

  • Intuitive UI for effortless setup.
  • Fully automated CI/CD pipelines and GitOps integration.
  • Real - time metrics and logging for actionable insights.

5. Effortless Scalability and High Performance

  • Auto - scaling to handle high concurrency with ease.
  • Zero operational overhead — just focus on building.

Image description

Explore more in the documentation!

Leapcell Twitter: https://x.com/LeapcellHQ

Top comments (0)