Skip to content

Commit 552fc9a

Browse files
authoredJul 5, 2022
gh-91330: Tests and docs for dataclass descriptor-typed fields (GH-94424) (GH-94576)
Co-authored-by: Erik De Bonte <erikd@microsoft.com> Co-authored-by: Łukasz Langa <lukasz@langa.pl> (cherry picked from commit 5f31930)
1 parent d49c99f commit 552fc9a

File tree

3 files changed

+167
-0
lines changed

3 files changed

+167
-0
lines changed
 

Diff for: ‎Doc/library/dataclasses.rst

+51
Original file line numberDiff line numberDiff line change
@@ -749,3 +749,54 @@ mutable types as default values for fields::
749749
``dict``, or ``set``, unhashable objects are now not allowed as
750750
default values. Unhashability is used to approximate
751751
mutability.
752+
753+
Descriptor-typed fields
754+
-----------------------
755+
756+
Fields that are assigned :ref:`descriptor objects <descriptors>` as their
757+
default value have the following special behaviors:
758+
759+
* The value for the field passed to the dataclass's ``__init__`` method is
760+
passed to the descriptor's ``__set__`` method rather than overwriting the
761+
descriptor object.
762+
* Similarly, when getting or setting the field, the descriptor's
763+
``__get__`` or ``__set__`` method is called rather than returning or
764+
overwriting the descriptor object.
765+
* To determine whether a field contains a default value, ``dataclasses``
766+
will call the descriptor's ``__get__`` method using its class access
767+
form (i.e. ``descriptor.__get__(obj=None, type=cls)``. If the
768+
descriptor returns a value in this case, it will be used as the
769+
field's default. On the other hand, if the descriptor raises
770+
:exc:`AttributeError` in this situation, no default value will be
771+
provided for the field.
772+
773+
::
774+
775+
class IntConversionDescriptor:
776+
def __init__(self, *, default):
777+
self._default = default
778+
779+
def __set_name__(self, owner, name):
780+
self._name = "_" + name
781+
782+
def __get__(self, obj, type):
783+
if obj is None:
784+
return self._default
785+
786+
return getattr(obj, self._name, self._default)
787+
788+
def __set__(self, obj, value):
789+
setattr(obj, self._name, int(value))
790+
791+
@dataclass
792+
class InventoryItem:
793+
quantity_on_hand: IntConversionDescriptor = IntConversionDescriptor(default=100)
794+
795+
i = InventoryItem()
796+
print(i.quantity_on_hand) # 100
797+
i.quantity_on_hand = 2.5 # calls __set__ with 2.5
798+
print(i.quantity_on_hand) # 2
799+
800+
Note that if a field is annotated with a descriptor type, but is not assigned
801+
a descriptor object as its default value, the field will act like a normal
802+
field.

Diff for: ‎Lib/test/test_dataclasses.py

+109
Original file line numberDiff line numberDiff line change
@@ -3229,6 +3229,115 @@ class C:
32293229

32303230
self.assertEqual(D.__set_name__.call_count, 1)
32313231

3232+
def test_init_calls_set(self):
3233+
class D:
3234+
pass
3235+
3236+
D.__set__ = Mock()
3237+
3238+
@dataclass
3239+
class C:
3240+
i: D = D()
3241+
3242+
# Make sure D.__set__ is called.
3243+
D.__set__.reset_mock()
3244+
c = C(5)
3245+
self.assertEqual(D.__set__.call_count, 1)
3246+
3247+
def test_getting_field_calls_get(self):
3248+
class D:
3249+
pass
3250+
3251+
D.__set__ = Mock()
3252+
D.__get__ = Mock()
3253+
3254+
@dataclass
3255+
class C:
3256+
i: D = D()
3257+
3258+
c = C(5)
3259+
3260+
# Make sure D.__get__ is called.
3261+
D.__get__.reset_mock()
3262+
value = c.i
3263+
self.assertEqual(D.__get__.call_count, 1)
3264+
3265+
def test_setting_field_calls_set(self):
3266+
class D:
3267+
pass
3268+
3269+
D.__set__ = Mock()
3270+
3271+
@dataclass
3272+
class C:
3273+
i: D = D()
3274+
3275+
c = C(5)
3276+
3277+
# Make sure D.__set__ is called.
3278+
D.__set__.reset_mock()
3279+
c.i = 10
3280+
self.assertEqual(D.__set__.call_count, 1)
3281+
3282+
def test_setting_uninitialized_descriptor_field(self):
3283+
class D:
3284+
pass
3285+
3286+
D.__set__ = Mock()
3287+
3288+
@dataclass
3289+
class C:
3290+
i: D
3291+
3292+
# D.__set__ is not called because there's no D instance to call it on
3293+
D.__set__.reset_mock()
3294+
c = C(5)
3295+
self.assertEqual(D.__set__.call_count, 0)
3296+
3297+
# D.__set__ still isn't called after setting i to an instance of D
3298+
# because descriptors don't behave like that when stored as instance vars
3299+
c.i = D()
3300+
c.i = 5
3301+
self.assertEqual(D.__set__.call_count, 0)
3302+
3303+
def test_default_value(self):
3304+
class D:
3305+
def __get__(self, instance: Any, owner: object) -> int:
3306+
if instance is None:
3307+
return 100
3308+
3309+
return instance._x
3310+
3311+
def __set__(self, instance: Any, value: int) -> None:
3312+
instance._x = value
3313+
3314+
@dataclass
3315+
class C:
3316+
i: D = D()
3317+
3318+
c = C()
3319+
self.assertEqual(c.i, 100)
3320+
3321+
c = C(5)
3322+
self.assertEqual(c.i, 5)
3323+
3324+
def test_no_default_value(self):
3325+
class D:
3326+
def __get__(self, instance: Any, owner: object) -> int:
3327+
if instance is None:
3328+
raise AttributeError()
3329+
3330+
return instance._x
3331+
3332+
def __set__(self, instance: Any, value: int) -> None:
3333+
instance._x = value
3334+
3335+
@dataclass
3336+
class C:
3337+
i: D = D()
3338+
3339+
with self.assertRaisesRegex(TypeError, 'missing 1 required positional argument'):
3340+
c = C()
32323341

32333342
class TestStringAnnotations(unittest.TestCase):
32343343
def test_classvar(self):
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
Added more tests for :mod:`dataclasses` to cover behavior with data
2+
descriptor-based fields.
3+
4+
# Write your Misc/NEWS entry below. It should be a simple ReST paragraph. #
5+
Don't start with "- Issue #<n>: " or "- gh-issue-<n>: " or that sort of
6+
stuff.
7+
###########################################################################

0 commit comments

Comments
 (0)
Please sign in to comment.