summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorErik De Bonte <erikd@microsoft.com>2022-07-05 11:09:41 -0700
committerGitHub <noreply@github.com>2022-07-05 20:09:41 +0200
commit5f319308a820f49fec66fc3ade50bbaa9fe2105d (patch)
tree6600ec4edc8b24484f25062bb7c237c7754ae88b
parent36fcde61ba48c4e918830691ecf4092e4e3b9b99 (diff)
downloadcpython-git-5f319308a820f49fec66fc3ade50bbaa9fe2105d.tar.gz
gh-91330: Tests and docs for dataclass descriptor-typed fields (GH-94424)
Co-authored-by: Ɓukasz Langa <lukasz@langa.pl>
-rw-r--r--Doc/library/dataclasses.rst51
-rw-r--r--Lib/test/test_dataclasses.py109
-rw-r--r--Misc/NEWS.d/next/Tests/2022-07-05-17-53-13.gh-issue-91330.Qys5IL.rst7
3 files changed, 167 insertions, 0 deletions
diff --git a/Doc/library/dataclasses.rst b/Doc/library/dataclasses.rst
index ec50696ea8..4364ac3424 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 <descriptors>` 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 98dfab481b..cfa82093df 100644
--- a/Lib/test/test_dataclasses.py
+++ b/Lib/test/test_dataclasses.py
@@ -3229,6 +3229,115 @@ class TestDescriptors(unittest.TestCase):
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 0000000000..315521102f
--- /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 #<n>: " or "- gh-issue-<n>: " or that sort of
+stuff.
+###########################################################################