[Python-checkins] [3.10] gh-91330: Tests and docs for dataclass descriptor-typed fields (GH-94424) (GH-94577)

ambv webhook-mailer at python.org
Tue Jul 5 14:45:08 EDT 2022


https://github.com/python/cpython/commit/fd34bfe48444fdb22ff1ae78941cf621854b351f
commit: fd34bfe48444fdb22ff1ae78941cf621854b351f
branch: 3.10
author: Łukasz Langa <lukasz at langa.pl>
committer: ambv <lukasz at langa.pl>
date: 2022-07-05T20:45:02+02:00
summary:

[3.10] gh-91330: Tests and docs for dataclass descriptor-typed fields (GH-94424) (GH-94577)

Co-authored-by: Erik De Bonte <erikd at microsoft.com>
Co-authored-by: Łukasz Langa <lukasz at langa.pl>
(cherry picked from commit 5f319308a820f49fec66fc3ade50bbaa9fe2105d)

files:
A Misc/NEWS.d/next/Tests/2022-07-05-17-53-13.gh-issue-91330.Qys5IL.rst
M Doc/library/dataclasses.rst
M Lib/test/test_dataclasses.py

diff --git a/Doc/library/dataclasses.rst b/Doc/library/dataclasses.rst
index 1fe5bda3b9415..02de64288d0d0 100644
--- a/Doc/library/dataclasses.rst
+++ b/Doc/library/dataclasses.rst
@@ -726,3 +726,54 @@ Mutable default values
          x: list = field(default_factory=list)
 
      assert D().x is not D().x
+
+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 ef5009ab11677..f4f7ed5acab48 100644
--- a/Lib/test/test_dataclasses.py
+++ b/Lib/test/test_dataclasses.py
@@ -2989,6 +2989,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 0000000000000..315521102f4ec
--- /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.
+###########################################################################



More information about the Python-checkins mailing list