traits icon indicating copy to clipboard operation
traits copied to clipboard

Troubles with properties and defaults initializations

Open notmatthancock opened this issue 4 years ago • 5 comments

In the following example, an AttributeError is raised complaining that the foo attribute is None even though the foo attribute is supplied at instantiation.

My fuzzy understanding is that the presence of the Property attempts to connect some traits listener that attempts to access foo before foo itself is attached to the Baz instance.

This is possible to workaround by removing the Property and setting the bad_prop via an on_trait_change. Still, the behavior here is unexpected.

Example

from traits.api import *


class Foo(HasTraits):
    x = Int


class Bar(HasTraits):
    y = Int


class Baz(HasTraits):
    foo = Instance(Foo)
    bar = Instance(Bar)
    bad_prop = Property(depends_on='bar.y')

    def _bar_default(self):
        return Bar(y=self.foo.x)

    def _get_bad_prop(self):
        return self.bar.y + 1


if __name__ == '__main__':
    Baz(foo=Foo(x=1))

Traceback

  File ".../traits/has_traits.py", line 3566, in _init_trait_listeners
    getattr(self, "_init_trait_%s_listener" % data[0])(name, *data)
  File ".../traits/has_traits.py", line 3619, in _init_trait_property_listener
    self.on_trait_change(notify, pattern, target=self)
  File ".../traits/has_traits.py", line 2825, in on_trait_change
    listener.register(self)
  File ".../traits/traits_listener.py", line 464, in register
    value = getattr(self, type)(new, name, False)
  File ".../traits/traits_listener.py", line 738, in _register_simple
    return next.register(getattr(object, name))
  File "nothing.py", line 18, in _bar_default
    return Bar(y=self.foo.x)
AttributeError: 'NoneType' object has no attribute 'x'

notmatthancock avatar Jan 07 '20 20:01 notmatthancock

Agreed that this looks like a bug, but it may not be trivial to fix. If it helps, reviewing and fixing the Traits initialization system is part of the ETS roadmap.

If this is a situation where a Baz instance is always expected to have a non-None foo trait value, and that foo trait value isn't ever modified for a given Baz instance, then one pattern is to assign that trait in the __init__ method, to ensure that foo gets assigned first:

    def __init__(self, foo, **traits):
        self.foo = foo
        super().__init__(**traits)

mdickinson avatar Jan 08 '20 08:01 mdickinson

Removing the "bug" label: if this is a bug, it's a bug at design level. The Traits initialization sequence (as encoded in CHasTraits.__init__) is essentially in three stages:

  1. hook up listeners
  2. assign any traits passed as keywords
  3. hook up post-init listeners

In this case, we're depending on something that happens in step 2 while hooking up the listeners as part of step 1.

mdickinson avatar Jan 08 '20 11:01 mdickinson

Thank you @notmatthancock and @mdickinson for looking into this issue. This looks like a duplicate of https://github.com/enthought/traits/issues/481, could you confirm please?

The investigation made in #481 suggests that this is due to an extended trait ("bar.y" as opposed to just "bar") being used. The registration of the extended listener for "y" causes the parent trait to be evaluated in stage 1 (see the three stages Mark listed above).

on_trait_change avoids the evaluation of default in stage 1 by setting defer to True, which causes other problem (see previous attempt to change this flag for fixing #275).

kitchoi avatar Apr 20 '20 09:04 kitchoi

This looks like a duplicate of #481, could you confirm please?

Yep; looks like we're talking about the same underlying issue.

notmatthancock avatar Apr 20 '20 14:04 notmatthancock

For future reference, this issue does not apply to Property(observe=...) (since Traits 6.2).

from traits.api import *


class Foo(HasTraits):
    x = Int


class Bar(HasTraits):
    y = Int


class Baz(HasTraits):
    foo = Instance(Foo)
    bar = Instance(Bar)
    bad_prop = Property(observe='bar.y')

    def _bar_default(self):
        return Bar(y=self.foo.x)

    def _get_bad_prop(self):
        return self.bar.y + 1


if __name__ == '__main__':
    Baz(foo=Foo(x=1))

runs without errors.

kitchoi avatar Feb 05 '21 15:02 kitchoi