Skip to content

[3.11] gh-91330: Tests and docs for dataclass descriptor-typed fields (GH-94424) #94576

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jul 5, 2022
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions Doc/library/dataclasses.rst
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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):
Original file line number Diff line number Diff line change
@@ -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.
###########################################################################