Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

hasattr telling lies? (AttributeError: 'method' object has no attribute '__annotations__')

Tags:

python-3.x

The following code

class Foo:
    def bar(self) -> None:
        pass

foo = Foo()
if hasattr(foo.bar, '__annotations__'):
    foo.bar.__annotations__ = 'hi'

crashes with

AttributeError: 'method' object has no attribute '__annotations__'

How can this happen?

like image 900
Tobias Hermann Avatar asked Nov 23 '18 17:11

Tobias Hermann


2 Answers

The attribute error here is raised because you can't set any attribute on a method object:

>>> foo.bar.baz = 42
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: 'method' object has no attribute 'baz'

The exception here is perhaps confusing because method objects wrap a function object and proxy attribute read access to that underlying function object. So when attributes on the function exist, then hasattr() on the method will return True:

>>> hasattr(foo.bar, 'baz')
False
>>> foo.bar.__func__.baz = 42
>>> hasattr(foo.bar, 'baz')
True
>>> foo.bar.baz
42

However, you still can't set those attributes via the method, regardless:

>>> hasattr(foo.bar, 'baz')
True
>>> foo.bar.baz = 42
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: 'method' object has no attribute 'baz'

So, just because the attribute can be read doesn't mean you can set it. hasattr() is speaking the truth, you just interpreted it to mean something different.

Now, if you tried to set the __annotations__ attribute directly on the underlying function object you'd get another error message:

>>> foo.bar.__func__.__annotations__ = 'hi'
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: __annotations__ must be set to a dict object

You would want to use a dictionary object here:

>>> foo.bar.__func__.__annotations__ = {'return': 'hi'}
>>> foo.bar.__annotations__
{'return': 'hi'}

However, because __annotations__ is a mutable dictionary, it is just easier to directly manipulate the keys and values to that object, which is perfectly feasible to do via the method wrapper:

>>> foo.bar.__annotations__['return'] = 'int'
>>> foo.bar.__annotations__
{'return': 'int'}

Now, if you were hoping to set per instance annotations, you can't get away with setting attributes on method objects, because method objects are ephemeral, they are created just for the call, then usually discarded right after.

You would have to use custom method descriptor objects via a metaclass and re-create the __annotations__ attribute for those each time, or you could instead pre-bind methods with a new function object that would be given their own attributes. You then have to pay a larger memory price:

import functools

foo.bar = lambda *args, **kwargs: Foo.bar(foo, *args, **kwargs)
functools.update_wrapper(foo.bar, Foo.bar)  # copy everything over to the new wrapper
foo.bar.__annotations__['return'] = 'hi'

Either way you completely kill important speed optimisations made in Python 3.7 this way.

And tools that operate on the most important use case for __annatotions__, type hints, do not actually execute code, they read code statically and would completely miss these runtime alterations.

like image 74
Martijn Pieters Avatar answered Nov 20 '22 20:11

Martijn Pieters


You're getting an error. because __annotations__ is a dictionary. If you want to change values you'll have to do it like this:

if hasattr(foo.bar, '__annotations__'):
    foo.bar.__annotations__['return'] = 'hi'

This will make the return value of your foo.bar be hi instead of None. The only thing I'm not sure about is how the __annotations__ are protected, not allowing you to change them from a dict to string, but I suppose it's some internal check in the source.

UPDATE

For more control over the signature you can use the inspect module and get the Signature object of your class(or method) and edit it from there. For example

import inspect
sig = inspect.signature(foo.bar)
sig.return_annotation  # prints None (before modifying)
sig.replace(return_annotation="anything you want")

More on that here

like image 38
Borisu Avatar answered Nov 20 '22 21:11

Borisu