4

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
Community
  • 1
  • 1
Aran-Fey
  • 39,665
  • 11
  • 104
  • 149
  • You could use in addition a class decorator or metaclass to inspect the methods and save their metadata on the class. – BrenBarn Feb 19 '17 at 01:57
  • `getattr(inspect.getmodule(f), f.__qualname__.rsplit('.', 1)[0])` might be hacky, but saves you the trouble of writing a metaclass. The string `f.__qualname__.rsplit('.', 1)[0]` might already suffice as a key for your `defaultdict` – user2390182 Feb 19 '17 at 02:05
  • `f.__qualname__.split('.')[0]` works though and gives you the class name which might be enough as a dict key. – user2390182 Feb 19 '17 at 02:15
  • @schwobaseggl That could work. It doesn't feel good to code it that way, but I guess it wouldn't cause problems in most real-world scenarios and it's a lot easier/nicer to use than a metaclass. – Aran-Fey Feb 19 '17 at 02:19
  • I just realized that if I only use the name of the class, it'll cause problems with inheritance. Classes wouldn't inherit any metadata from their parents. Maybe a metaclass is the way to go after all. – Aran-Fey Feb 19 '17 at 09:37

2 Answers2

3

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'}}})
zvone
  • 18,045
  • 3
  • 49
  • 77
0

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()
Aran-Fey
  • 39,665
  • 11
  • 104
  • 149