From e0043ad25a007a9b4cfc597d745a2dc9fde88f75 Mon Sep 17 00:00:00 2001 From: mementum Date: Sat, 22 Jul 2023 18:00:23 +0200 Subject: [PATCH 1/2] Support Annotated typing with a field as metainformation to also allow default values be assigned to the class attribute with the actual defined type --- Doc/library/dataclasses.rst | 43 +++++++++++++++++++++++++++++------- Lib/dataclasses.py | 44 ++++++++++++++++++++++++++++++++++--- 2 files changed, 76 insertions(+), 11 deletions(-) diff --git a/Doc/library/dataclasses.rst b/Doc/library/dataclasses.rst index 535a60ccca8d07..d8948a370bd122 100644 --- a/Doc/library/dataclasses.rst +++ b/Doc/library/dataclasses.rst @@ -231,6 +231,18 @@ Module contents additional information, you can replace the default field value with a call to the provided :func:`field` function. For example:: + @dataclass + class C: + mylist: Annotated[list[int], field(default_factory=list)] + + c = C() + c.mylist += [1, 2, 3] + + .. versionchanged:: 3.12 + + Prior to this, `field` had to be specified as the default value of the + class attribute, as in:: + @dataclass class C: mylist: list[int] = field(default_factory=list) @@ -292,7 +304,24 @@ Module contents .. versionadded:: 3.10 - If the default value of a field is specified by a call to + When using ``Annotated`` to provide a extra information for the field, + a ``default`` value can be specified as usual. For example:: + + @dataclass + class C: + x: int + y: Annotated[int, field(repr=False)] + z: Annotated[int, field(repr=False)] = 10 + t: int = 20 + + .. versionchanged:: 3.12 + + The class attribute ``C.z`` will be ``10``, the class attribute + ``C.t`` will be ``20``, and the class attributes ``C.x`` and + ``C.y`` will not be set. + + Ussing the previous way of assigning a :func:`field` to the attribute, + if the default value of a field is specified by a call to :func:`field()`, then the class attribute for this field will be replaced by the specified ``default`` value. If no ``default`` is provided, then the class attribute will be deleted. The intent is @@ -308,9 +337,7 @@ Module contents z: int = field(repr=False, default=10) t: int = 20 - The class attribute ``C.z`` will be ``10``, the class attribute - ``C.t`` will be ``20``, and the class attributes ``C.x`` and - ``C.y`` will not be set. + The result being the same as in the ``Annotated`` version above .. class:: Field @@ -526,7 +553,7 @@ Post-init processing class C: a: float b: float - c: float = field(init=False) + c: Annotated[float, field(init=False)] def __post_init__(self): self.c = self.a + self.b @@ -663,7 +690,7 @@ fields, and ``Base.x`` and ``D.z`` are regular fields:: @dataclass class D(Base): z: int = 10 - t: int = field(kw_only=True, default=0) + t: Annotated[int, field(kw_only=True] = 0 The generated :meth:`~object.__init__` method for ``D`` will look like:: @@ -684,7 +711,7 @@ If a :func:`field` specifies a ``default_factory``, it is called with zero arguments when a default value for the field is needed. For example, to create a new instance of a list, use:: - mylist: list = field(default_factory=list) + mylist: Annotated[list, field(default_factory=list)] If a field is excluded from :meth:`~object.__init__` (using ``init=False``) and the field also specifies ``default_factory``, then the default @@ -748,7 +775,7 @@ mutable types as default values for fields:: @dataclass class D: - x: list = field(default_factory=list) + x: Annotated[list, field(default_factory=list)] assert D().x is not D().x diff --git a/Lib/dataclasses.py b/Lib/dataclasses.py index e766a7b554afe1..7ff74be1d5a604 100644 --- a/Lib/dataclasses.py +++ b/Lib/dataclasses.py @@ -694,6 +694,13 @@ def _is_initvar(a_type, dataclasses): return (a_type is dataclasses.InitVar or type(a_type) is dataclasses.InitVar) + +def _is_annotated(a_type, typing): + # This test uses a typing internal class, but it's the best way to + # test if this is Annotated. + return a_type is typing.Annotated or type(a_type) is typing._AnnotatedAlias + + def _is_kw_only(a_type, dataclasses): return a_type is dataclasses.KW_ONLY @@ -763,16 +770,47 @@ def _get_field(cls, a_name, a_type, default_kw_only): # default_kw_only is the value of kw_only to use if there isn't a field() # that defines it. + typing = sys.modules.get('typing') # detect typing early for Annotated + # If the default value isn't derived from Field, then it's only a # normal default value. Convert it to a Field(). default = getattr(cls, a_name, MISSING) + + # If the Field is in an Annotated typehint, it will be in ann_a_type. It will be + # uaed as a flag to skip the ClassVar check later + ann_a_type = None + if isinstance(default, Field): f = default else: if isinstance(default, types.MemberDescriptorType): # This is a field in __slots__, so it has no default value. default = MISSING - f = field(default=default) + + # Look early for Annotated, because it may host a `Field` definition, + # to be then used instead of creating a blank Field + f = None + if typing: + if _is_annotated(a_type, typing): + ann_a_type, *ann_args = typing.get_args(a_type) + elif isinstance(a_type, str) and \ + _is_type(a_type, cls, typing, typing.Annotated, _is_annotated): + + # eval annotated string + br0, br1 = a_type.index("["), a_type.rindex("]") + eval_str = a_type[br0:br1].lstrip("[ ").rstrip(" ]") + ann_a_type, *ann_args = eval(eval_str) + + if ann_a_type is not None: # annotated detected - look for Field + for ann_arg in ann_args: + if isinstance(ann_arg, Field): + f = ann_arg # reuse the field + f.default = default # record fetched default + a_type = ann_a_type # record real type + break # only 1st Field considered + + if f is None: # search for Annotated was not successful + f = field(default=default) # Only at this point do we know the name and the type. Set them. f.name = a_name @@ -797,8 +835,8 @@ def _get_field(cls, a_name, a_type, default_kw_only): # annotation to be a ClassVar. So, only look for ClassVar if # typing has been imported by any module (not necessarily cls's # module). - typing = sys.modules.get('typing') - if typing: + # Do only do the check if the value was not annotated with a Field + if ann_a_type is None and typing: if (_is_classvar(a_type, typing) or (isinstance(f.type, str) and _is_type(f.type, cls, typing, typing.ClassVar, From d53a5819b8c672ab3f912a7da60d767fae256e06 Mon Sep 17 00:00:00 2001 From: mementum Date: Mon, 24 Jul 2023 01:45:41 +0200 Subject: [PATCH 2/2] Add exception specified in the draft PEP --- Lib/dataclasses.py | 89 ++++++++++++++++++++++++++++------------------ 1 file changed, 55 insertions(+), 34 deletions(-) diff --git a/Lib/dataclasses.py b/Lib/dataclasses.py index 7ff74be1d5a604..087920b659dd7e 100644 --- a/Lib/dataclasses.py +++ b/Lib/dataclasses.py @@ -776,41 +776,62 @@ def _get_field(cls, a_name, a_type, default_kw_only): # normal default value. Convert it to a Field(). default = getattr(cls, a_name, MISSING) - # If the Field is in an Annotated typehint, it will be in ann_a_type. It will be - # uaed as a flag to skip the ClassVar check later + if isinstance(default, types.MemberDescriptorType): + # This is a field in __slots__, so it has no default value. + default = MISSING + + # Look early for Annotated, because it may host a `Field` definition, + # to be then used instead of creating a blank Field + f = None + # If the Field is in an Annotated typehint, it will be in ann_a_type. It + # will be uaed as a flag to skip the lassVar check later ann_a_type = None - if isinstance(default, Field): - f = default - else: - if isinstance(default, types.MemberDescriptorType): - # This is a field in __slots__, so it has no default value. - default = MISSING - - # Look early for Annotated, because it may host a `Field` definition, - # to be then used instead of creating a blank Field - f = None - if typing: - if _is_annotated(a_type, typing): - ann_a_type, *ann_args = typing.get_args(a_type) - elif isinstance(a_type, str) and \ - _is_type(a_type, cls, typing, typing.Annotated, _is_annotated): - - # eval annotated string - br0, br1 = a_type.index("["), a_type.rindex("]") - eval_str = a_type[br0:br1].lstrip("[ ").rstrip(" ]") - ann_a_type, *ann_args = eval(eval_str) - - if ann_a_type is not None: # annotated detected - look for Field - for ann_arg in ann_args: - if isinstance(ann_arg, Field): - f = ann_arg # reuse the field - f.default = default # record fetched default - a_type = ann_a_type # record real type - break # only 1st Field considered - - if f is None: # search for Annotated was not successful - f = field(default=default) + if typing: + if _is_annotated(a_type, typing): + ann_a_type, *ann_args = typing.get_args(a_type) + elif (isinstance(a_type, str) + and _is_type(a_type, cls, typing, typing.Annotated, + _is_annotated)): + + # eval annotated string + br0, br1 = a_type.index("["), a_type.rindex("]") + eval_str = a_type[br0:br1].lstrip("[ ").rstrip(" ]") + # Annotated args "guaranteed" to be at least a 2-tuple. Unpacking + # can be done safely after eval. If the user has manually added an + # annotation in string format which does not match the minimum + # "Annotated" requirements an exception will be raised, which + # should be the expected behavior. + ann_a_type, *ann_args = eval(eval_str) + + if ann_a_type is not None: # annotated detected - look for Field + for ann_arg in ann_args: + if isinstance(ann_arg, Field): + f = ann_arg # reuse the field + defmissing = default is MISSING + if f.default is not MISSING and not defmissing: + msg = ("If 'field' has a default value inside " + "'Annotated', the field cannot also be " + "assigned a default value") + raise ValueError(msg) + + if f.default_factory is not MISSING and not defmissing: + msg = ("If 'field' has a default_factory inside" + "'Annotated', the field cannot also be " + "assigned a default value") + raise ValueError(msg) + + if isinstance(default, Field): + msg = ("If 'field' is specified in 'Annotated' it " + "cannot be also assigned as default value") + raise ValueError(msg) + + f.default = default # record fetched default + a_type = ann_a_type # record real type + break # only 1st Field considered + + if f is None: # search for Annotated was not successful + f = default if isinstance(default, Field) else field(default=default) # Only at this point do we know the name and the type. Set them. f.name = a_name @@ -835,7 +856,7 @@ def _get_field(cls, a_name, a_type, default_kw_only): # annotation to be a ClassVar. So, only look for ClassVar if # typing has been imported by any module (not necessarily cls's # module). - # Do only do the check if the value was not annotated with a Field + # Do only do the check if the value was not "Annotated" with a Field if ann_a_type is None and typing: if (_is_classvar(a_type, typing) or (isinstance(f.type, str)