jamesfranco
jamesfranco

Reputation: 520

Class/metaclass method decorator for derived class

I have a metaclass that defines a class level attribute which should be unique for each subclass but shared across instances of each subclass.

class MetaValidator(type):
    def __new__(
             cls, name, bases, dct
    ):
        new_cls = super().__new__(cls, name, bases, dct)
        new_cls.valid_funcs = []
        return new_cls

Now I'd like to implement a decorator that appends the decorated class method to valid_funcs within the derived class. However because the derived class is still being defined I don't have a reference to the derived decorator so I end up appending to the base class. Here's my code:

class Validator(object, metaclass=MetaValidator):

    @classmethod
    def add(cls, f):
        cls.valid_funcs.append(f)
        return f

    def _validate(self, **kwargs):
        for f in self.valid_funcs:
            params = inspect.signature(f).parameters.keys()
            f_kwargs = {name: kwargs[name] for name in params}
            f(**f_kwargs)

    def validate(self, **kwargs):
        self._validate(**kwargs)

class A(Validator):

    @staticmethod
    @Validator.add
    def test_func(x):
        return x

class B(Validator):

    @staticmethod
    @Validator.add
    def test_func(x, y):
        return x, y

a = A()
a.validate(x="In A")
b = B()
b.validate(x="In B", y=" Called with arg y")

print(Validator.valid_funcs)
print(a.valid_funcs)
print(b.valid_funcs)

This prints:

[<function A.test_func at 0x7f0189d4fc80>, 
<function B.test_func at 0x7f0189d4fd08>]
[]
[]

I want:

[]
[<function A.test_func at 0x7f0189d4fc80>]
[<function B.test_func at 0x7f0189d4fd08>]

Upvotes: 3

Views: 570

Answers (2)

Martijn Pieters
Martijn Pieters

Reputation: 1121834

There is no class object yet when decorators on functions in the class body are executed. The class body is executed first, then the class is created.

Instead of having the decorator look for a class attribute to mutate, add an attribute to a decorated function object. The metaclass, or the _validate() implementation then looks for any objects with this attribute and adds them to the list once the class object has been created.

I'm going to assume you'd want to retain the order in which the decorators would have added the decorated items to the list:

from itertools import count

class Validator(metaclass=MetaValidator):
    @classmethod
    def add(cls, f):
        _count = getattr(Validator.add, '_count', None)
        if _count is None:
            _count = Validator.add.__func__._count = count()
        f._validator_function_id = next(_count)
        return f

and in the metaclass:

class MetaValidator(type):
    def __new__(cls, name, bases, dct):
        new_cls = super().__new__(cls, name, bases, dct)
        registered = []
        for v in dct.values():
            id = getattr(v, '_validator_function_id', None)
            if id is None and isinstance(v, (staticmethod, classmethod)):
                # unwrap staticmethod or classmethod decorators
                id = getattr(v.__func__, '_validator_function_id', None)
            if id is not None:
                registered.append((id, v))
        new_cls.valid_funcs = [f for _, f in sorted(registered)]
        return new_cls

Note that if you are using Python 3.6 or newer, then you don't need a metaclass at all any more. You can put the same logic into the class.__init_subclass__ method.

Note that this registers unbound objects. For staticmethod objects, that means the call will fail with:

TypeError: <staticmethod object at 0x10d1b7048> is not a callable object

You perhaps want to register the __func__ attribute in that case, or use .__get__ to 'bind' the object to something (a staticmethod ignores the binding context anyway)`.

If you bind explicitly, in the _validate() method, then you don't actually have to use staticmethod objects:

def _validate(self, **kwargs):
    for f in self.valid_funcs:
        bound = f.__get__(self)
        signature = inspect.signature(bound)
        bound(**{name: kwargs[name] for name in signature.parameters})

Now @validator.add will work with staticmethod, classmethod and regular functions.

And if you have the _validate() method look for the methods, then binding can be done for you. You can choose to support inheritance here by just using dir() and getattr():

from operator import itemgetter
from itertools import count


class Validator:
    @classmethod
    def add(cls, f):
        _count = getattr(Validator.add, '_count', None)
        if _count is None:
            _count = Validator.add.__func__._count = count()
        f._validator_function_id = next(_count)
        return f

    def _list_validators(self):
        objects = (getattr(self, name) for name in dir(self))
        return sorted(
            (o for o in objects if hasattr(o, '_validator_function_id')),
            key=attrgetter('_validator_function_id'))

    def _validate(self, **kwargs):
        for f in self._list_validators():
            signature = inspect.signature(f)
            f(**{name: kwargs[name] for name in signature.parameters})

getattr() gives you a bound object, no further binding necessary.

Upvotes: 1

user2357112
user2357112

Reputation: 280564

While having the metaclass __new__ handle adding functions to valid_funcs is an option, another option would be to inject valid_funcs into the namespace of the class body before the class even exists, using __prepare__:

class MetaValidator(type):
    @classmethod
    def __prepare__(cls, name, bases, **kwds):
        ns = super().__prepare__(name, bases, **kwds)
        ns['valid_funcs'] = []
        return ns

def register(func_list):
    def inner_register(func):
        func_list.append(func)
        return func
    return inner_register

class A(metaclass=MetaValidator):
    @register(valid_funcs)
    def method(self):
        ...

I'd probably skip all the metaclass stuff and require classes to do valid_funcs = [] themselves, though. The additional complexity of a metaclass isn't worth it just to save one line of boilerplate per class.

Upvotes: 0

Related Questions