Search code examples
pythondecoratortypecheckingpython-descriptors

Check a type attribute with a Descriptor and a Decorator : "__get__() takes 2 positional arguments but 3 were given" when accessing the attribute


Goal

I try to implement a Decorator that could act like a typechecking function. I'm not there yet since the validation is working well but when I try to access the attribute I get the error : TypeError: __get__() takes 2 positional arguments but 3 were given.

I'm using Python 3.9.7.

Description

# Descriptor definition
class PositiveNumber:
    def __get__(self, obj):
        return self.value

    def __set__(self, obj, value):
        if not isinstance(value, (int, float)):
            raise TypeError('value must be a number')
        if value < 0:
            raise ValueError('Value cannot be negative.')
        self.value = value

# Decorator definition
def positive_number(attr):
    def decorator(cls):
        setattr(cls, attr, PositiveNumber())
        return cls
    return decorator

# class that actually check for the right type
@positive_number('number')
class MyClass(object):
    def __init__(self, value):
        self.number = value


def main():
    a = MyClass(3)
    print(a.number)


if __name__ == '__main__':
    main()

Do you see the way to fix this ?


Solution

  • You got the signature wrong. When the __get__ is called, 3 arguments are passed (including the type whether you use it or not):

    class PositiveNumber:
        def __get__(self, obj, objtype=None):
            return self.value
        # ...
    

    The other thing that is wrong: there is only one descriptor instance, so all instances of MyClass will share that object's value attribute!

    a = MyClass(3)
    b = MyClass(5)
    a.number
    # 5
    

    So you better fix this associating the set values with the instances:

    class PositiveNumber:
        def __get__(self, obj, objtype=None):
            return obj._value
    
        def __set__(self, obj, value):
            if not isinstance(value, (int, float)):
                raise TypeError('value must be a number')
            if value < 0:
                raise ValueError('Value cannot be negative.')
            obj._value = value
    

    You could also use a more specific attribute name in accordance with the name of the field on the actual class:

    class PositiveNumber:
        def __set_name__(self, owner, name):
            self.private_name = '_' + name  # e.g. "_number"
    
        def __get__(self, obj, objtype=None):
            return getattr(obj, self.private_name)
    
        def __set__(self, obj, value):
            if not isinstance(value, (int, float)):
                raise TypeError('value must be a number')
            if value < 0:
                raise ValueError('Value cannot be negative.')
            setattr(obj, self.private_name, value)
    

    The set_name method is only called when the descriptor is already there upon class creation. That means you cannot set it dynamically with setattr on the already existing class object, but you have to create a new class!

    def positive_number(attr):
        def decorator(cls):
            return type(cls.__name__, (cls,), {attr: PositiveNumber()})
        return decorator