diff --git a/Doc/library/dataclasses.rst b/Doc/library/dataclasses.rst index ec50696ea89d40..4364ac342471eb 100644 --- a/Doc/library/dataclasses.rst +++ b/Doc/library/dataclasses.rst @@ -749,3 +749,54 @@ mutable types as default values for fields:: ``dict``, or ``set``, unhashable objects are now not allowed as default values. Unhashability is used to approximate mutability. + +Descriptor-typed fields +----------------------- + +Fields that are assigned :ref:`descriptor objects ` as their +default value have the following special behaviors: + +* The value for the field passed to the dataclass's ``__init__`` method is + passed to the descriptor's ``__set__`` method rather than overwriting the + descriptor object. +* Similarly, when getting or setting the field, the descriptor's + ``__get__`` or ``__set__`` method is called rather than returning or + overwriting the descriptor object. +* To determine whether a field contains a default value, ``dataclasses`` + will call the descriptor's ``__get__`` method using its class access + form (i.e. ``descriptor.__get__(obj=None, type=cls)``. If the + descriptor returns a value in this case, it will be used as the + field's default. On the other hand, if the descriptor raises + :exc:`AttributeError` in this situation, no default value will be + provided for the field. + +:: + + 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 InventoryItem: + quantity_on_hand: IntConversionDescriptor = IntConversionDescriptor(default=100) + + i = InventoryItem() + print(i.quantity_on_hand) # 100 + i.quantity_on_hand = 2.5 # calls __set__ with 2.5 + print(i.quantity_on_hand) # 2 + +Note that if a field is annotated with a descriptor type, but is not assigned +a descriptor object as its default value, the field will act like a normal +field. diff --git a/Lib/test/test_dataclasses.py b/Lib/test/test_dataclasses.py index 822e7305bd8a12..569f97902a3900 100644 --- a/Lib/test/test_dataclasses.py +++ b/Lib/test/test_dataclasses.py @@ -3229,6 +3229,115 @@ class C: self.assertEqual(D.__set_name__.call_count, 1) + def test_init_calls_set(self): + class D: + pass + + D.__set__ = Mock() + + @dataclass + class C: + i: D = D() + + # Make sure D.__set__ is called. + D.__set__.reset_mock() + c = C(5) + self.assertEqual(D.__set__.call_count, 1) + + def test_getting_field_calls_get(self): + class D: + pass + + D.__set__ = Mock() + D.__get__ = Mock() + + @dataclass + class C: + i: D = D() + + c = C(5) + + # Make sure D.__get__ is called. + D.__get__.reset_mock() + value = c.i + self.assertEqual(D.__get__.call_count, 1) + + def test_setting_field_calls_set(self): + class D: + pass + + D.__set__ = Mock() + + @dataclass + class C: + i: D = D() + + c = C(5) + + # Make sure D.__set__ is called. + D.__set__.reset_mock() + c.i = 10 + self.assertEqual(D.__set__.call_count, 1) + + def test_setting_uninitialized_descriptor_field(self): + class D: + pass + + D.__set__ = Mock() + + @dataclass + class C: + i: D + + # D.__set__ is not called because there's no D instance to call it on + D.__set__.reset_mock() + c = C(5) + self.assertEqual(D.__set__.call_count, 0) + + # D.__set__ still isn't called after setting i to an instance of D + # because descriptors don't behave like that when stored as instance vars + c.i = D() + c.i = 5 + self.assertEqual(D.__set__.call_count, 0) + + def test_default_value(self): + class D: + def __get__(self, instance: Any, owner: object) -> int: + if instance is None: + return 100 + + return instance._x + + def __set__(self, instance: Any, value: int) -> None: + instance._x = value + + @dataclass + class C: + i: D = D() + + c = C() + self.assertEqual(c.i, 100) + + c = C(5) + self.assertEqual(c.i, 5) + + def test_no_default_value(self): + class D: + def __get__(self, instance: Any, owner: object) -> int: + if instance is None: + raise AttributeError() + + return instance._x + + def __set__(self, instance: Any, value: int) -> None: + instance._x = value + + @dataclass + class C: + i: D = D() + + with self.assertRaisesRegex(TypeError, 'missing 1 required positional argument'): + c = C() class TestStringAnnotations(unittest.TestCase): def test_classvar(self): diff --git a/Misc/NEWS.d/next/Tests/2022-07-05-17-53-13.gh-issue-91330.Qys5IL.rst b/Misc/NEWS.d/next/Tests/2022-07-05-17-53-13.gh-issue-91330.Qys5IL.rst new file mode 100644 index 00000000000000..315521102f4ec3 --- /dev/null +++ b/Misc/NEWS.d/next/Tests/2022-07-05-17-53-13.gh-issue-91330.Qys5IL.rst @@ -0,0 +1,7 @@ +Added more tests for :mod:`dataclasses` to cover behavior with data +descriptor-based fields. + +# Write your Misc/NEWS entry below. It should be a simple ReST paragraph. # +Don't start with "- Issue #: " or "- gh-issue-: " or that sort of +stuff. +###########################################################################