You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
In #94424, the behavior of dataclasses for a descriptor that was assigned as a field default was defined and documented.
While I might be wrong here, a number of things lead me to believe it was an "accidental feature" being documented:
It only works when the descriptor is assigned through the "Pythonic syntax" (my_field: T = descriptor), not my_field: T = field(default=descriptor).
The descriptor's __get__ is called twice with instance=None, but not called for instance attribute gets.
Inconsistently with that, __set__ is called for each instance attribute assignment.
We can probably address those with some amount of effort, but first I'd like to make sure this behavior is intentional and desired.
What is the merit of this, vs. a property or a descriptor assigned as a ClassVar?
Given that the __get__ is called only during class initialization with instance=None, then wouldn't the following be equivalent?
-my_field: T = descriptor+my_field: T = field(default=descriptor.__get__())
(Clearly the first line is shorter, but more vague - descriptors are not usually used for this purpose.)
Motivation: python/mypy#14869 brought up mypy's dataclasses plugin not properly understanding the finer semantics of assigning a descriptor, which led me to look at what exactly are the semantics.
Thanks for the report! Detailed response below. In summary, I'm leaving this issue open because I think there is one potential code change follow-up (to make the behavior more consistent in the case of = field(default=ADescriptor())) and one potential docs follow-up (to clarify the difference in behavior between data and non-data descriptors.)
a number of things lead me to believe it was an "accidental feature" being documented
This is true, in the sense that the interaction with descriptors was not initially considered in the dataclasses design. But the choice to clarify and document the behaviors was made carefully and with a lot of discussion (much of it on the typing-sig mailing list IIRC.)
It only works when the descriptor is assigned through the "Pythonic syntax" (my_field: T = descriptor), not my_field: T = field(default=descriptor).
This is a good point; these two should probably be equivalent. In the latter case, the descriptor is (already today) assigned to the class and functions as a descriptor at runtime, but the default value in dataclass' internal Field object remains set to the descriptor object, instead of the value returned from its __get__ when fetched on the class. This means that on creation of a new instance, the descriptor's __set__ method is called with its own instance as the value to set. This definitely seems like broken behavior, which means I think we can fix it as a bug and shouldn't have to worry about breaking code relying on the current behavior.
The descriptor's get is called twice with instance=None
This doesn't seem like a problem; it just means that internally dataclasses is fetching the default value off the class more than once. A descriptor should be fine with having its __get__ called any number of times.
but not called for instance attribute gets
This is not true. Maybe you tested the __get__ behavior with a non-data descriptor (one that defines only __get__); these are overridden by a value in the instance attributes dict, so they would display the behavior you saw. (Even in this case you can still cause the descriptor __get__ to be called, if you first del the attribute off the instance.) But I agree that using non-data descriptors for dataclass fields doesn't have much if any use. Perhaps this should be called out more clearly in the documentation.
If you use a data descriptor (one defining __set__ and/or __delete__, as in the example added to the docs in #94424), then it takes precedence over the instance attribute dictionary, and you will see its __get__ method called on every instance attribute access.
All of this is normal Python descriptor behavior that is not particular to dataclasses.
Inconsistently with that, set is called for each instance attribute assignment.
This is true, and useful, but not inconsistent, because __get__ is also called on each attribute access.
What is the merit of this, vs. a property or a descriptor assigned as a ClassVar?
The field will be treated as a dataclass field, and thus e.g. included in the default generated __init__ etc methods.
Given that the get is called only during class initialization with instance=None, then wouldn't the following be equivalent?
No, because the premise (that __get__ is called only during class initialization) is false.
Agree that being called twice or any number of times is not a problem :)
Also, you are right that it does get called for instances if it's a data descriptor. It took me a while to understand why, but it checks out.
Also, I was wrong about my_field: T = field(default=descriptor) ("non Pythonic assignment") not working. It merely doesn't trigger the class __get__s, but if it's a data descriptor then the per-instance __get__s do get called.
Sorry for the confusion, and thanks for taking the time to explain point by point.
Also, I was wrong about my_field: T = field(default=descriptor) ("non Pythonic assignment") not working. It merely doesn't trigger the class __get__s, but if it's a data descriptor then the per-instance __get__s do get called.
Yes, but the problem is that we don't ever do the class __get__ and set that as the default value in the Field, and thus on instantation without a value for that field, the descriptor __set__ ends up getting called with the descriptor itself as value, which is weird and will likely break. E.g. using the example descriptor from the docs:
>>> class IntConversionDescriptor:
... def __init__(self, *, default):
... self._default = default
... def __set_name__(self, owner, name):
... self._name = "_" + name
... def __get__(self, obj, type):
... if obj is None:
... return self._default
... return getattr(obj, self._name, self._default)
... def __set__(self, obj, value):
... setattr(obj, self._name, int(value))
...
>>> @dataclass
... class B:
... x: int = field(default=IntConversionDescriptor(default=24))
...
>>> b = B()
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "<string>", line 3, in __init__
File "<stdin>", line 11, in __set__
TypeError: int() argument must be a string, a bytes-like object or a real number, not 'IntConversionDescriptor'
>>> B.__dataclass_fields__['x'].default
<__main__.IntConversionDescriptor object at 0x7faefd797c60>
See the confusing error message due to the IntConversionDescriptor being passed to __set__ as the value for the field. The reasion is the contents of the default attribute of the Field; contrast with this version:
>>> @dataclass
... class A:
... x: int = IntConversionDescriptor(default=23)
...
>>> A.__dataclass_fields__['x'].default
23
Although the descriptor is attached to the class and used in both cases, I think the behavior of B in the above examples is just buggy and not useful to anyone; it would be better if it behaved like A. (The use case is that there may be a reason to supply a different argument to field, e..g init or repr or compare. And in general the two forms of supplying a default to a dataclass field should behave the same.)
Just ran into this today, the documentation on: https://docs.python.org/3/library/dataclasses.html#descriptor-typed-fields only mentions the case when using the descriptor directly instead of via dataclasses.field(default=my_descriptor()). I'd consider the behavior of calling __set__ on the descriptor with the descriptor's instance a bug.
Following is a demonstration based off the example on the above documentation page:
dc_type: InventoryItemFieldDefault, init_val: 9
9
2
dc_type: InventoryItemDirectDefault, init_val: 9
9
2
dc_type: InventoryItemDirectDefault, init_val: None
100
2
dc_type: InventoryItemFieldDefaultInitFalse, init_val: None
100
2
Traceback (most recent call last):
File "/redacted/scratch/bug_report.py", line 52, in <module>
test(InventoryItemFieldDefault, None)
File "/redacted/scratch/bug_report.py", line 38, in test
i = dc_type()
^^^^^^^^^
File "<string>", line 3, in __init__
File "/redacted/scratch/bug_report.py", line 19, in __set__
setattr(obj, self._name, int(value))
^^^^^^^^^^
TypeError: int() argument must be a string, a bytes-like object or a real number, not 'IntConversionDescriptor'
Activity
carljm commentedon Mar 13, 2023
Thanks for the report! Detailed response below. In summary, I'm leaving this issue open because I think there is one potential code change follow-up (to make the behavior more consistent in the case of
= field(default=ADescriptor())) and one potential docs follow-up (to clarify the difference in behavior between data and non-data descriptors.)This is true, in the sense that the interaction with descriptors was not initially considered in the dataclasses design. But the choice to clarify and document the behaviors was made carefully and with a lot of discussion (much of it on the typing-sig mailing list IIRC.)
This is a good point; these two should probably be equivalent. In the latter case, the descriptor is (already today) assigned to the class and functions as a descriptor at runtime, but the default value in dataclass' internal
Fieldobject remains set to the descriptor object, instead of the value returned from its__get__when fetched on the class. This means that on creation of a new instance, the descriptor's__set__method is called with its own instance as the value to set. This definitely seems like broken behavior, which means I think we can fix it as a bug and shouldn't have to worry about breaking code relying on the current behavior.This doesn't seem like a problem; it just means that internally dataclasses is fetching the default value off the class more than once. A descriptor should be fine with having its
__get__called any number of times.This is not true. Maybe you tested the
__get__behavior with a non-data descriptor (one that defines only__get__); these are overridden by a value in the instance attributes dict, so they would display the behavior you saw. (Even in this case you can still cause the descriptor__get__to be called, if you firstdelthe attribute off the instance.) But I agree that using non-data descriptors for dataclass fields doesn't have much if any use. Perhaps this should be called out more clearly in the documentation.If you use a data descriptor (one defining
__set__and/or__delete__, as in the example added to the docs in #94424), then it takes precedence over the instance attribute dictionary, and you will see its__get__method called on every instance attribute access.All of this is normal Python descriptor behavior that is not particular to dataclasses.
This is true, and useful, but not inconsistent, because
__get__is also called on each attribute access.The field will be treated as a dataclass field, and thus e.g. included in the default generated
__init__etc methods.No, because the premise (that
__get__is called only during class initialization) is false.ikonst commentedon Mar 13, 2023
Agree that being called twice or any number of times is not a problem :)
Also, you are right that it does get called for instances if it's a data descriptor. It took me a while to understand why, but it checks out.
Also, I was wrong about
my_field: T = field(default=descriptor)("non Pythonic assignment") not working. It merely doesn't trigger the class__get__s, but if it's a data descriptor then the per-instance__get__s do get called.Sorry for the confusion, and thanks for taking the time to explain point by point.
carljm commentedon Mar 13, 2023
Yes, but the problem is that we don't ever do the class
__get__and set that as the default value in theField, and thus on instantation without a value for that field, the descriptor__set__ends up getting called with the descriptor itself as value, which is weird and will likely break. E.g. using the example descriptor from the docs:See the confusing error message due to the
IntConversionDescriptorbeing passed to__set__as the value for the field. The reasion is the contents of thedefaultattribute of theField; contrast with this version:Although the descriptor is attached to the class and used in both cases, I think the behavior of
Bin the above examples is just buggy and not useful to anyone; it would be better if it behaved likeA. (The use case is that there may be a reason to supply a different argument tofield, e..ginitorreprorcompare. And in general the two forms of supplying a default to a dataclass field should behave the same.)daveware-nv commentedon Sep 2, 2024
Just ran into this today, the documentation on: https://docs.python.org/3/library/dataclasses.html#descriptor-typed-fields only mentions the case when using the descriptor directly instead of via
dataclasses.field(default=my_descriptor()). I'd consider the behavior of calling__set__on the descriptor with the descriptor's instance a bug.Following is a demonstration based off the example on the above documentation page:
expected output:
actual output: