Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Warn if a Trait default method raises AttributeError #1762

Merged
merged 3 commits into from
Jan 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 61 additions & 6 deletions traits/ctraits.c
Original file line number Diff line number Diff line change
Expand Up @@ -1786,6 +1786,58 @@ static PyTypeObject has_traits_type = {
| Returns the default value associated with a specified trait:
+----------------------------------------------------------------------------*/

// Since traits are attributes, AttributeErrors are naturally swallowed by
// some parts of the Traits machinery (e.g., trait_get). So if computation of
// the default value raises AttributeError, we issue a warning, using the
// following helper function.
// xref: https://github.com/enthought/traits/issues/1747

void
_warn_on_attribute_error(PyObject *result)
{
if (result == NULL && PyErr_ExceptionMatches(PyExc_AttributeError)) {
// PyErr_WarnEx won't work correctly if there's an exception set,
// so save and clear the current exception.
PyObject *exc_type, *exc_value, *exc_traceback;
PyErr_Fetch(&exc_type, &exc_value, &exc_traceback);
if (PyErr_WarnEx(PyExc_UserWarning,
"default value resolution raised an AttributeError; "
"this may cause Traits to behave in unexpected ways", 0) == -1) {
// The warning was raised as an exception; retrieve that exception
// and set the AttributeError as its cause.
PyObject *warn_type, *warn_value, *warn_traceback;

// Normalize the AttributeError to ensure exc_value is valid.
PyErr_NormalizeException(&exc_type, &exc_value, &exc_traceback);
if (exc_traceback != NULL) {
PyException_SetTraceback(exc_value, exc_traceback);
}
assert(exc_value != NULL);

// Set the warning cause to the attribute error. The UserWarning
// shouldn't need to be normalized, since PyErr_WarnEx doesn't
// indulge in delayed normalization.
PyErr_Fetch(&warn_type, &warn_value, &warn_traceback);
assert(warn_value != NULL);
PyException_SetCause(warn_value, exc_value);
PyErr_Restore(warn_type, warn_value, warn_traceback);

// Reference cleanup: PyErr_Restore stole our references to
// warn_type, warn_value and warn_traceback, and
// PyException_SetCause stole our reference to exc_value; that
// leaves us to handle exc_type and exc_traceback.
Py_DECREF(exc_type);
if (exc_traceback != NULL) {
Py_DECREF(exc_traceback);
}
}
else {
// Restore the AttributeError
PyErr_Restore(exc_type, exc_value, exc_traceback);
}
}
}

static PyObject *
default_value_for(trait_object *trait, has_traits_object *obj, PyObject *name)
{
Expand Down Expand Up @@ -1820,8 +1872,10 @@ default_value_for(trait_object *trait, has_traits_object *obj, PyObject *name)
if (kw == Py_None) {
kw = NULL;
}
return PyObject_Call(
result = PyObject_Call(
PyTuple_GET_ITEM(dv, 0), PyTuple_GET_ITEM(dv, 1), kw);
_warn_on_attribute_error(result);
return result;
case CALLABLE_DEFAULT_VALUE:
tuple = PyTuple_Pack(1, (PyObject *)obj);
if (tuple == NULL) {
Expand All @@ -1834,17 +1888,18 @@ default_value_for(trait_object *trait, has_traits_object *obj, PyObject *name)
if (trait->flags & TRAIT_SETATTR_ORIGINAL_VALUE) {
if (value == NULL) {
Py_DECREF(result);
return NULL;
result = NULL;
} else {
Py_DECREF(value);
}
Py_DECREF(value);
return result;
}
else {
Py_DECREF(result);
return value;
result = value;
}
}
break;
_warn_on_attribute_error(result);
return result;
case TRAIT_SET_OBJECT_DEFAULT_VALUE:
return call_class(
TraitSetObject, trait, obj, name, trait->default_value);
Expand Down
9 changes: 6 additions & 3 deletions traits/tests/test_property_notifications.py
Original file line number Diff line number Diff line change
Expand Up @@ -407,9 +407,12 @@ def test_property_with_cache(self):
def test_property_default_initializer_with_value_in_init(self):
# With "depends_on", instantiation will fail.
# enthought/traits#709
with self.assertRaises(AttributeError):
ClassWithPropertyDependsOnInit(
info_without_default=PersonInfo(age=30))
with self.assertWarnsRegex(
UserWarning, "default value resolution raised an AttributeError"
):
with self.assertRaises(AttributeError):
ClassWithPropertyDependsOnInit(
info_without_default=PersonInfo(age=30))

# With "observe", initialization works.
instance = ClassWithPropertyObservesInit(
Expand Down
43 changes: 43 additions & 0 deletions traits/tests/test_regression.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
Either,
Enum,
Expression,
Float,
Instance,
Int,
List,
Expand Down Expand Up @@ -556,6 +557,48 @@ def test_subclass_disallow_default_value(self):
class OverrideDisallow(SubclassDefaultsSuper):
disallow_default = "a default value"

def test_warn_on_attribute_error_in_default_method(self):
# Given a misspelled reference to a trait in a _default method ...
class HasBrokenDefault(HasStrictTraits):
weight = Float(12.3)

mass = Float()

def _mass_default(self):
# Deliberately misspelled!
return self.wieght / 9.81

# When we try to get all trait values on an instance,
# Then a warning is issued ...
obj = HasBrokenDefault()
with self.assertWarnsRegex(
UserWarning, "default value resolution raised an AttributeError"
):
traits = obj.trait_get()

# ... and the returned dictionary does not include the mass.
self.assertEqual(traits, {"weight": 12.3})

def test_warn_on_attribute_error_in_factory(self):

def bad_range(start, stop):
self.this_attribute_doesnt_exist
return range(start, stop)

class HasBrokenFactory(HasStrictTraits):
ticks = Instance(range, factory=bad_range, args=(0, 10))

# When we try to get all trait values on an instance,
# Then a warning is issued ...
obj = HasBrokenFactory()
with self.assertWarnsRegex(
UserWarning, "default value resolution raised an AttributeError"
):
traits = obj.trait_get()

# ... and the returned dictionary does not include the bad trait
self.assertEqual(traits, {})


class NestedContainerClass(HasTraits):
# Used in regression test for changes to nested containers
Expand Down