Aran-Fey
Aran-Fey

Reputation: 43246

Alternatives to decorators for saving metadata about classes

I'm writing a GUI library, and I'd like to let the programmer provide meta-information about their program which I can use to fine-tune the GUI. I was planning to use function decorators for this purpose, for example like this:

class App:
    @Useraction(description='close the program', hotkey='ctrl+q')
    def quit(self):
        sys.exit()

The problem is that this information needs to be bound to the respective class. For example, if the program is an image editor, it might have an Image class which provides some more Useractions:

class Image:
    @Useraction(description='invert the colors')
    def invert_colors(self):
        ...

However, since the concept of unbound methods has been removed in python 3, there doesn't seem to be a way to find a function's defining class. (I found this old answer, but that doesn't work in a decorator.)

So, since it looks like decorators aren't going to work, what would be the best way to do this? I'd like to avoid having code like

class App:
    def quit(self):
        sys.exit()

Useraction(App.quit, description='close the program', hotkey='ctrl+q')

if at all possible.


For completeness' sake, the @Useraction decorator would look somewhat like this:

class_metadata= defaultdict(dict)
def Useraction(**meta):
    def wrap(f):
        cls= get_defining_class(f)
        class_metadata[cls][f]= meta
        return f
    return wrap

Upvotes: 5

Views: 1550

Answers (2)

zvone
zvone

Reputation: 19362

You are using decorators to add meta data to methods. That is fine. It can be done e.g. this way:

def user_action(description):
    def decorate(func):
        func.user_action = {'description': description}
        return func
    return decorate

Now, you want to collect that data and store it in a global dictionary in form class_metadata[cls][f]= meta. For that, you need to find all decorated methods and their classes.

The simplest way to do that is probably using metaclasses. In metaclass, you can define what happens when a class is created. In this case, go through all methods of the class, find decorated methods and store them in the dictionary:

class UserActionMeta(type):
    user_action_meta_data = collections.defaultdict(dict)

    def __new__(cls, name, bases, attrs):
        rtn = type.__new__(cls, name, bases, attrs)
        for attr in attrs.values():
            if hasattr(attr, 'user_action'):
                UserActionMeta.user_action_meta_data[rtn][attr] = attr.user_action
        return rtn

I have put the global dictionary user_action_meta_data in the meta class just because it felt logical. It can be anywhere.

Now, just use that in any class:

class X(metaclass=UserActionMeta):

    @user_action('Exit the application')
    def exit(self):
        pass

Static UserActionMeta.user_action_meta_data now contains the data you want:

defaultdict(<class 'dict'>, {<class '__main__.X'>: {<function exit at 0x00000000029F36C8>: {'description': 'Exit the application'}}})

Upvotes: 4

Aran-Fey
Aran-Fey

Reputation: 43246

I've found a way to make decorators work with the inspect module, but it's not a great solution, so I'm still open to better suggestions.

Basically what I'm doing is to traverse the interpreter stack until I find the current class. Since no class object exists at this time, I extract the class's qualname and module instead.

import inspect

def get_current_class():
    """
    Returns the name of the current module and the name of the class that is currently being created.
    Has to be called in class-level code, for example:

    def deco(f):
        print(get_current_class())
        return f

    def deco2(arg):
        def wrap(f):
            print(get_current_class())
            return f
        return wrap

    class Foo:
        print(get_current_class())

        @deco
        def f(self):
            pass

        @deco2('foobar')
        def f2(self):
            pass
    """
    frame= inspect.currentframe()
    while True:
        frame= frame.f_back
        if '__module__' in frame.f_locals:
            break
    dict_= frame.f_locals
    cls= (dict_['__module__'], dict_['__qualname__'])
    return cls

Then in a sort of post-processing step, I use the module and class names to find the actual class object.

def postprocess():
    global class_metadata

    def findclass(module, qualname):
        scope= sys.modules[module]
        for name in qualname.split('.'):
            scope= getattr(scope, name)
        return scope

    class_metadata= {findclass(cls[0], cls[1]):meta for cls,meta in class_metadata.items()}

The problem with this solution is the delayed class lookup. If classes are overwritten or deleted, the post-processing step will find the wrong class or fail altogether. Example:

class C:
    @Useraction(hotkey='ctrl+f')
    def f(self):
        print('f')

class C:
    pass

postprocess()

Upvotes: 0

Related Questions