GrahamDumpleton / wrapt

A Python module for decorators, wrappers and monkey patching.
BSD 2-Clause "Simplified" License
2.04k stars 230 forks source link

`__set_name__` not called on class construction for `wrapt.decorator`-wrapped descriptors #183

Closed huzecong closed 3 years ago

huzecong commented 3 years ago

wrapt version: 1.12.1


I was using wrapt.decorator in combination with descriptors. However, I found that __set_name__ was not called on the descriptor when it's being decorated:

import wrapt

@wrapt.decorator
def decorator(wrapped, instance, args, kwargs):
    return wrapped(*args, **kwargs)

class descriptor_wrapper:
    def __init__(self, descriptor):
        self.__wrapped__ = descriptor

    def __set_name__(self, owner, name):
        print("__set_name__", self, owner, name)
        if hasattr(self.__wrapped__, "__set_name__"):
            self.__wrapped__.__set_name__(owner, name)

    def __get__(self, instance, owner=None):
        print("__get__", self, instance, owner)
        return self.__wrapped__.__get__(instance, owner)

class MyClass:
    @descriptor_wrapper
    def this_works(self):
        pass

    @decorator
    @descriptor_wrapper
    def this_doesnt_work(self):
        pass

The output is:

__set_name__ <__main__.descriptor_wrapper object at 0x7f66360bac40> <class '__main__.MyClass'> this_works

But it is expected that __set_name__ be called for both descriptors.

On the other hand, lookup on the decorated descriptor seems to work:

>>> hasattr(MyClass.__dict__["this_doesnt_work"], "__set_name__")
True
>>> MyClass.__dict__["this_doesnt_work"].__set_name__(MyClass, "this_doesnt_work")
__set_name__ <__main__.descriptor_wrapper object at 0x7f66360baa00> <class '__main__.MyClass'> this_doesnt_work

but it just doesn't get called during class construction.

GrahamDumpleton commented 3 years ago

From memory __set_name__ is one of a few rather special methods in the Python object model which you cannot override through inheritance. The only way of intercepting updates to it is to use:

    @property
    def __name__(self):
        return self.__wrapped__.__name__

    @__name__.setter
    def __name__(self, value):
        self.__wrapped__.__name__ = value

At least this is what wrapt itself had to do for it to work.

GrahamDumpleton commented 3 years ago

BTW, what exactly are you trying to do? Missing context of why you would want to change the name.

huzecong commented 3 years ago

Hmm, I'm not sure I understand. __set_name__ doesn't change the __name__ of the object, it's part of the descriptor protocol (since PEP 487, which was implemented since Python 3.6). __set_name__ is called during class construction (as part of type.__new__) to make the descriptor aware of its containing class and attribute name.

The reason I need __set_name__ is because I'm implementing something similar to Python 3.8's cached_property (code). I want to store the cached value as an attribute on the instance, and to avoid name clashes, I need to know the attribute name of the descriptor.

GrahamDumpleton commented 3 years ago

Is what you are expecting:

import wrapt

@wrapt.decorator
def decorator(wrapped, instance, args, kwargs):
    return wrapped(*args, **kwargs)

class descriptor_wrapper:
    def __init__(self, descriptor):
        self.__wrapped__ = descriptor

    def __set_name__(self, owner, name):
        print("__set_name__", self, owner, name)
        if hasattr(self.__wrapped__, "__set_name__"):
            self.__wrapped__.__set_name__(owner, name)

    def __get__(self, instance, owner=None):
        print("__get__", self, instance, owner)
        return self.__wrapped__.__get__(instance, owner)

class FunctionWrapper(wrapt.FunctionWrapper):
    def __set_name__(self, owner, name):
        print("FunctionWrapper:__set_name__", self, owner, name)
        if hasattr(self.__wrapped__, "__set_name__"):
            self.__wrapped__.__set_name__(owner, name)

def custom_decorator(wrapped, instance, args, kwargs):
    return wrapped(*args, **kwargs)

def custom_wrapper(func):
    return FunctionWrapper(func, custom_decorator)

class MyClass:
    @descriptor_wrapper
    def this_works(self):
        pass

    #@decorator
    @custom_wrapper
    @descriptor_wrapper
    def this_doesnt_work(self):
        print('this_doesnt_work', self)

a = MyClass()

a.this_doesnt_work()
huzecong commented 3 years ago

Ah hah, this is it! Would it make sense to add a __set_name__ to ObjectProxy? I can put up a PR if you agree this is the right thing to do.

Alternatively, is there anyway to supply a custom FunctionWrapper class into wrapt.decorator? I think this would have more benefits, e.g. custom __reduce__ methods to make decorated functions pickle-able.

GrahamDumpleton commented 3 years ago

Have made changes to add __set_name__() propagation to FunctionWrapper, but not ObjectProxy.

You can try develop branch if you want to verify it works for you.

pip install -U https://github.com/GrahamDumpleton/wrapt/archive/refs/heads/develop.zip
GrahamDumpleton commented 3 years ago

The __set_name__ support when using wrapt.decorator is included in 1.13.0.

BTW, if interested in playing with pickling further and want an easy way of overriding the builtin FunctionWrapper to add the pickle methods, in 1.13.0 you can do:

class MyBoundFunctionWrapper(wrapt.BoundFunctionWrapper):
    pass

class MyFunctionWrapper(wrapt.FunctionWrapper):
    __bound_function_wrapper__ = MyBoundFunctionWrapper

@wrapt.decorator(proxy=MyFunctionWrapper)
def wrapper(wrapped, instance, args, kwargs):
    return wrapped(args, kwargs)

Version 1.13.0 is available on PyPi at the moment as release candidates.

Anyway, going to close out this issue at this point.