DEV Community

Cover image for Using Descriptors and Decorators to Create class-only Methods in Python
Fredrik Sjöstrand
Fredrik Sjöstrand

Posted on • Edited on • Originally published at fronkan.hashnode.dev

Using Descriptors and Decorators to Create class-only Methods in Python

I ran across a post asking how you can create class-only methods in Python. A class-only method should only be callable as a static method and not work from an instance of the class. I think this is quite a niche feature, but it is a neat example of how to implement a simple descriptor. Spoiler, the cover-image is the entire implementation. I would also like to explain my process for solving this problem, as I didn't really know from the start descriptors would be the solution.

To start off I threw out some ideas for where to start:

  • Create a decorator similar to @staticmethod and @classmethod?
  • Could meta-classes be used to solve this?
  • Maybe I could have a list of class-only functions and remove them from the instance as a part of the __init__ method?

The first rule in the Zen of Python is beatiful is better than ugly and using a decorator would be the most pretty solution. Also, we have @statimethod which does one of the two features we want for class-only methods. This being the possibility to call it directly from the class without an instance. It, therefore, seems like the best place to start. First, let's look at an example of @staticmethod:

class MyClass:
    @staticmethod
    def dev():
        print("Hello Dev!")

MyClass.dev()
my_obj = MyClass()
my_obj.dev()
Enter fullscreen mode Exit fullscreen mode

output:

Hello Dev!
Hello Dev!
Enter fullscreen mode Exit fullscreen mode

Without the decorator the second call, the one using the instance, my_obj, would have crashed with a type error: TypeError: dev() takes 0 positional arguments but 1 was given. This is caused by python automatically passing the instance as the first argument to the function. Now you might think the problem is solved, we have a method which can't be called from an instance. Isn't that a class-only method? It is close but not quite, as when creating a method with parameters it starts to get a bit more complicated. The first parameter passed to the function will still be self, even if we call it something else. Also, the errors might be very non-descriptive or even confusing. In the end, there is a much better solution to the problem.

So, I went to github to look for the source code for @staticmethod. However, it is implemented inside the Python interpreter, in C..., and I do not feel like writing a C-extension that much I can tell you. Luckily, after some googling, I found this documentation on descriptors. Here they show how both decorators would be implemented in pure Python using the descriptor protocol. Descriptors fit this problem extremely well, as you will see in the example.

As with other protocols, descriptors use double underscore methods. We will only need to implement one of them, the __get__ method. It takes three arguments:

  • self, the descriptor instance
  • instance, the instance of the class to which the descriptor is attached
  • owner, the class which contains the descriptor

Let's get to implementing the class-only descriptor:

class classonlymethod:
    def __init__(self, func):
        self.func = func

    def __get__(self, instance, owner=None):
        if instance is not None:
            raise AttributeError(f'{owner.__name__}.{self.func.__name__} is a class-only method.')
        return self.func
Enter fullscreen mode Exit fullscreen mode

Here I am breaking the convention of using camel-case for the class name to be compliant with the other decorators. Swapping the @staticmethod for @classonlymethod in our original example, we get this:

class MyClass:
    @classonlymethod
    def dev():
        print("Hello Dev!")

MyClass.dev()
my_obj = MyClass()
my_obj.dev()
Enter fullscreen mode Exit fullscreen mode

Which gives the output:

Hello Dev!
Traceback (most recent call last):
  File "...\example.py", line 18, in <module>
    my_obj.dev()
  File "...\example.py", line 8, in __get__
    raise AttributeError(f'{owner.__name__}.{self.func.__name__} is a class-only method.')
AttributeError: MyClass.dev is a class-only method.
Enter fullscreen mode Exit fullscreen mode

Success! This was the behavior we were looking for. If you call the function as a static method it works and if you call it from an instance it raises a helpful error.

Let us dig into how it all works. The decorator-syntax is just syntactic sugar and has the same effect as this snippet:

def dev():
    print("Hello Dev!")
dev = classonlymethod(dev)
Enter fullscreen mode Exit fullscreen mode

So using the class classonlymethod as a decorator passes the decorated function automatically to its __init__ method. Then when we try to access the method using MyClass.dev or my_obj.dev the __get__ method is called. In the first case, MyClass.dev, the parameter instance is None and we just return the function. However, in the second case, my_obj.dev, the parameter has the value <my_obj> which instead raises the error. I want you to note that __get__ method returns the function and not the result of the function. Think of it as implementing the dot-operator for this specific field.

Descriptors give us a powerful mechanism for changing the behavior of how fields and methods are interacted with by a class. Although it was not covered in this post, they allow us to modify not only the get but also the set and delete behaviors. Even if descriptors are rarely used, some problems, like this one, would have been much harder to solve without them. Therefore, one of the most important takeaways from this post is to know descriptors exist and have a feeling for their purpose. For a deeper look into descriptors and how they work I recommend this realpython post. If you have any questions feel free to leave a comment and I will do my best to answer them.

Top comments (2)

Collapse
 
rpalo profile image
Ryan Palo

This is a really neat post! And on top of that, I think you did an amazing job of considering what arguments or complaints argumentation readers might have and pre-answering their questions at just the right time. A really nicely crafted article, thanks for sharing!

Collapse
 
fronkan profile image
Fredrik Sjöstrand

Thank you for your kind words!