In Python, what is the rationale for which object.__setattr__
and type.__setattr__
raise an AttributeError
during attribute update if the type has an attribute which is a data descriptor without a __set__
method? Likewise, what is the rationale for which object.__delattr__
and type.__delattr__
raise an AttributeError
during attribute deletion if the type has an attribute which is a data descriptor without a __delete__
method?
I am asking this because I have noticed that object.__getattribute__
and type.__getattribute__
do not raise an AttributeError
during attribute lookup if the type has an attribute which is a data descriptor without a __get__
method.
Here is a simple program illustrating the differences between attribute lookup by object.__getattribute__
on the one hand (AttributeError
is not raised), and attribute update by object.__setattr__
and attribute deletion by object.__delattr__
on the other hand (AttributeError
is raised):
class DataDescriptor1: # missing __get__
def __set__(self, instance, value): pass
def __delete__(self, instance): pass
class DataDescriptor2: # missing __set__
def __get__(self, instance, owner=None): pass
def __delete__(self, instance): pass
class DataDescriptor3: # missing __delete__
def __get__(self, instance, owner=None): pass
def __set__(self, instance, value): pass
class A:
x = DataDescriptor1()
y = DataDescriptor2()
z = DataDescriptor3()
a = A()
vars(a).update({'x': 'foo', 'y': 'bar', 'z': 'baz'})
a.x
# actual: returns 'foo'
# expected: returns 'foo'
a.y = 'qux'
# actual: raises AttributeError: __set__
# expected: vars(a)['y'] == 'qux'
del a.z
# actual: raises AttributeError: __delete__
# expected: 'z' not in vars(a)
Here is another simple program illustrating the differences between attribute lookup by type.__getattribute__
on the one hand (AttributeError
is not raised), and attribute update by type.__setattr__
and attribute deletion by type.__delattr__
on the other hand (AttributeError
is raised):
class DataDescriptor1: # missing __get__
def __set__(self, instance, value): pass
def __delete__(self, instance): pass
class DataDescriptor2: # missing __set__
def __get__(self, instance, owner=None): pass
def __delete__(self, instance): pass
class DataDescriptor3: # missing __delete__
def __get__(self, instance, owner=None): pass
def __set__(self, instance, value): pass
class M(type):
x = DataDescriptor1()
y = DataDescriptor2()
z = DataDescriptor3()
class A(metaclass=M):
x = 'foo'
y = 'bar'
z = 'baz'
A.x
# actual: returns 'foo'
# expected: returns 'foo'
A.y = 'qux'
# actual: raises AttributeError: __set__
# expected: vars(A)['y'] == 'qux'
del A.z
# actual: raises AttributeError: __delete__
# expected: 'z' not in vars(A)
I would expect the instance dictionary to be mutated instead of getting an AttributeError
for attribute update and attribute deletion. Attribute lookup returns a value from the instance dictionary, so I am wondering why attribute update and attribute deletion do not use the instance dictionary as well (like they would do if the type did not have an attribute which is a data descriptor).
I think it's just a consequence of the C-level design that no one really thought or cared much about.
At C level, __set__
and __delete__
correspond to the same C-level slot, tp_descr_set
, and deletion is specified by passing a null value to set. (This is similar to the design used for __setattr__
and __delattr__
, which also correspond to a single slot that also gets passed NULL
for deletion.)
If you implement either __set__
or __delete__
, the C-level slot gets set to a wrapper function that looks for __set__
or __delete__
and calls it:
static int
slot_tp_descr_set(PyObject *self, PyObject *target, PyObject *value)
{
PyObject* stack[3];
PyObject *res;
_Py_IDENTIFIER(__delete__);
_Py_IDENTIFIER(__set__);
stack[0] = self;
stack[1] = target;
if (value == NULL) {
res = vectorcall_method(&PyId___delete__, stack, 2);
}
else {
stack[2] = value;
res = vectorcall_method(&PyId___set__, stack, 3);
}
if (res == NULL)
return -1;
Py_DECREF(res);
return 0;
}
The slot has no way to say "oops, didn't find the method, go back to normal handling", and it doesn't try. It also doesn't try to emulate the normal handling - that would be error-prone, since "normal handling" is type-dependent, and it can't know what to emulate for all types. If the slot wrapper doesn't find the method, it just raises an exception.
This effect wouldn't happen if __set__
and __delete__
had gotten two slots, but someone would have had to care while they were designing the API, and I doubt anyone did.