enthought / traits

Observable typed attributes for Python classes
Other
432 stars 85 forks source link

Calling `hasattr`/`getattr` creates trait if missing. #1753

Open felixkol opened 1 year ago

felixkol commented 1 year ago

I have observed some weird behavior when calling hasattr with a nonexistent attribute. Assume the following as a minimal example:

class AClass(ta.HasTraits):
    pass

def observer(val):
    print(f"observed: {val}")

inst = AClass()
inst.observe(observer, "*")

print(f"trait_names: {inst.trait_names()}")  
print(f"hasattr('a'): {hasattr(inst, 'a')}")
print(f"trait_names: {inst.trait_names()}")
print(f"is 'a' in dir(inst)?: {'a' in dir(inst)}")
print(f"class traits of inst: {inst.__class_traits__.keys()}")

Yields:

trait_names: ['trait_added', 'trait_modified']
observed: TraitChangeEvent(object=<__main__.AClass object at 0x7f4031f304a0>, name='trait_added', old=<undefined>, new='a')
hasattr: False
trait_names: ['trait_added', 'trait_modified', 'a']
is 'a' in dir(inst)?: True
class traits of inst: dict_keys(['trait_added', 'trait_modified', 'a'])

As one can see, after calling hasattr a new trait is added for the missing attribute 'a', it appears in the list of trait_names as well, but calling getattr now would cause an AttributeError.

Furthermore, using a different expression in the observer changes this behavior:

...
inst.observe(observer, "trait_added")
...
trait_names: ['trait_added', 'trait_modified']
observed: TraitChangeEvent(object=<__main__.AClass object at 0x7f2068cf1450>, name='trait_added', old=<undefined>, new='a')
hasattr: False
trait_names: ['trait_added', 'trait_modified']
is 'a' in dir(inst)?: False
class traits of inst: dict_keys(['trait_added', 'trait_modified', 'a'])

Now, the name of the missing attribute isn't added to the 'trait_names', but the attribute is still created as a class trait.

To my understanding, getting a value should not have such side effects, especially if getting the now appearing attribute in trait_names causes an error. hasattr and trait_names should not contradict each other, but the dependents of the observation expression makes this even less deterministic.

Am I using traits wrong at this point, is this intended behavior or is it a bug?

mdickinson commented 1 year ago

Thanks for the report. It's not exactly intended behaviour (it's clearly not desirable), but it is behaviour that's fairly deeply baked in, and hard to change without breaking existing code (and unfortunately there's a lot of existing Traits-using code out there). Existing issues #358 and #58 are related.

Slightly simpler reproducer:

>>> class A(HasStrictTraits):
...     bar = Int()
... 
>>> a = A()
>>> "foo" in a.__class_traits__
False
>>> "foo" in a.__class_traits__
False
>>> a.foo
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: 'A' object has no attribute 'foo'
>>> "foo" in a.__class_traits__
True
felixkol commented 1 year ago

Wouldn't it be possible to split getting and setting prefix traits?

On getting, a value would be returned, when it has been set or the prefix trait has a default value. On the latter case, the prefixed trait could be created. Setting would work just as it is currently.

Then no trait would exist where getting raises an exception.

Would this break the current functionality of prefix traits?