Skip to content

Commit

Permalink
pythongh-91330: Tests and docs for dataclass descriptor-typed fields (p…
Browse files Browse the repository at this point in the history
…ythonGH-94424)

Co-authored-by: Łukasz Langa <lukasz@langa.pl>
  • Loading branch information
debonte and ambv committed Jul 5, 2022
1 parent 36fcde6 commit 5f31930
Show file tree
Hide file tree
Showing 3 changed files with 167 additions and 0 deletions.
51 changes: 51 additions & 0 deletions Doc/library/dataclasses.rst
Expand Up @@ -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.
109 changes: 109 additions & 0 deletions Lib/test/test_dataclasses.py
Expand Up @@ -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):
Expand Down
@@ -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.
###########################################################################

0 comments on commit 5f31930

Please sign in to comment.