8000 [3.10] gh-91330: Tests and docs for dataclass descriptor-typed fields… · python/cpython@fd34bfe · GitHub
[go: up one dir, main page]

Skip to content

Commit fd34bfe

Browse files
ambvdebonte
andauthored
[3.10] gh-91330: Tests and docs for dataclass descriptor-typed fields (GH-94424) (GH-94577)
Co-authored-by: Erik De Bonte <erikd@microsoft.com> Co-authored-by: Łukasz Langa <lukasz@langa.pl> (cherry picked from commit 5f31930)
1 parent 697e78c commit fd34bfe

File tree

3 files changed

+167
-0
lines changed

3 files changed

+167
-0
lines changed

Doc/library/dataclasses.rst

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -726,3 +726,54 @@ Mutable default values
726726
x: list = field(default_factory=list)
727727

728728
assert D().x is not D().x
729+
730+
Descriptor-typed fields
731+
-----------------------
732+
733+
Fields that are assigned :ref:`descriptor objects <descriptors>` as their
734+
default value have the following special behaviors:
735+
736+
* The value for the field passed to the dataclass's ``__init__`` method is
737+
passed to the descriptor's ``__set__`` method rather than overwriting the
738+
descriptor object.
739+
* Similarly, when getting or setting the field, the descriptor's
740+
``__get__`` or ``__set__`` method is called rather than returning or
741+
overwriting the descriptor object.
742+
* To determine whether a field contains a default value, ``dataclasses``
743+
will call the descriptor's ``__get__`` method using its class access
744+
form (i.e. ``descriptor.__get__(obj=None, type=cls)``. If the
745+
descriptor returns a value in this case, it will be used as the
746+
field's default. On the other hand, if the descriptor raises
747+
:exc:`AttributeError` i 8000 n this situation, no default value will be
748+
provided for the field.
749+
750+
::
751+
752+
class IntConversionDescriptor:
753+
def __init__(self, *, default):
754+
self._default = default
755+
756+
def __set_name__(self, owner, name):
757+
self._name = "_" + name
758+
759+
def __get__(self, obj, type):
760+
if obj is None:
761+
return self._default
762+
763+
return getattr(obj, self._name, self._default)
764+
765+
def __set__(self, obj, value):
766+
setattr(obj, self._name, int(value))
767+
768+
@dataclass
769+
class InventoryItem:
770+
quantity_on_hand: IntConversionDescriptor = IntConversionDescriptor(default=100)
771+
772+
i = InventoryItem()
773+
print(i.quantity_on_hand) # 100
774+
i.quantity_on_hand = 2.5 # calls __set__ with 2.5
775+
print(i.quantity_on_hand) # 2
776+
777+
Note that if a field is annotated with a descriptor type, but is not assigned
778+
a descriptor object as its default value, the field will act like a normal
779+
field.

Lib/test/test_dataclasses.py

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2989,6 +2989,115 @@ class C:
29892989

29902990
self.assertEqual(D.__set_name__.call_count, 1)
29912991

2992+
def test_init_calls_set(self):
2993+
class D:
2994+
pass
2995+
2996+
D.__set__ = Mock()
2997+
2998+
@dataclass
2999+
class C:
3000+
i: D = D()
3001+
3002+
# Make sure D.__set__ is called.
3003+
D.__set__.reset_mock()
3004+
c = C(5)
3005+
self.assertEqual(D.__set__.call_count, 1)
3006+
3007+
def test_getting_field_calls_get(self):
3008+
class D:
3009+
pass
3010+
3011+
D.__set__ = Mock()
3012+
D.__get__ = Mock()
3013+
3014+
@dataclass
3015+
class C:
3016+
i: D = D()
3017+
3018+
c = C(5)
3019+
3020+
# Make sure D.__get__ is called.
3021+
D.__get__.reset_mock()
3022+
value = c.i
3023+
self.assertEqual(D.__get__.call_count, 1)
3024+
3025+
def test_setting_field_calls_set(self):
3026+
class D:
3027+
pass
3028+
3029+
D.__set__ = Mock()
3030+
3031+
@dataclass
3032+
class C:
3033+
i: D = D()
3034+
3035+
c = C(5)
3036+
3037+
# Make sure D.__set__ is called.
3038+
D.__set__.reset_mock()
3039+
c.i = 10
3040+
self.assertEqual(D.__set__.call_count, 1)
3041+
3042+
def test_setting_uninitialized_descriptor_field(self):
3043+
class D:
3044+
pass
3045+
3046+
D.__set__ = Mock()
3047+
3048+
@dataclass
3049+
class C:
3050+
i: D
3051+
3052+
# D.__set__ is not called because there's no D instance to call it on
3053+
D.__set__.reset_mock()
3054+
c = C(5)
3055+
self.assertEqual(D.__set__.call_count, 0)
3056+
3057+
# D.__set__ still isn't called after setting i to an instance of D
3058+
# because descriptors don't behave like that when stored as instance vars
3059+
c.i = D()
3060+
c.i = 5
3061+
self.assertEqual(D.__set__.call_count, 0)
3062+
3063+
def test_default_value(self):
3064+
class D:
3065+
def __get__(self, instance: Any, owner: object) -> int:
3066+
if instance is None:
3067+
return 100
3068+
3069+
return instance._x
3070+
3071+
def __set__(self, instance: Any, value: int) -> None:
3072+
instance._x = value
3073+
3074+
@dataclass
3075+
class C:
3076+
i: D = D()
3077+
3078+
c = C()
3079+
self.assertEqual(c.i, 100)
3080+
3081+
c = C(5)
3082+
self.assertEqual(c.i, 5)
3083+
3084+
def test_no_default_value(self):
3085+
class D:
3086+
def __get__(self, instance: Any, owner: object) -> int:
3087+
if instance is None:
3088+
raise AttributeError()
3089+
3090+
return instance._x
3091+
3092+
def __set__(self, instance: Any, value: int) -> None:
3093+
instance._x = value
3094+
3095+
@dataclass
3096+
class C:
3097+
i: D = D()
3098+
3099+
with self.assertRaisesRegex(TypeError, 'missing 1 required positional argument'):
3100+
c = C()
29923101

29933102
class TestStringAnnotations(unittest.TestCase):
29943103
def test_classvar(self):
Lines changed: 7 additions & 0 deletions
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)
0