Skip to content
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

gh-90669: Let typing.Annotated wrap dataclasses annotations #30997

Open
wants to merge 12 commits into
base: main
Choose a base branch
from
12 changes: 10 additions & 2 deletions Lib/dataclasses.py
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,8 @@ def __repr__(self):
# String regex that string annotations for ClassVar or InitVar must match.
# Allows "identifier.identifier[" or "identifier[".
# https://bugs.python.org/issue33453 for details.
_MODULE_IDENTIFIER_RE = re.compile(r'^(?:\s*(\w+)\s*\.)?\s*(\w+)')
_MODULE_IDENTIFIER_RE = re.compile(
r'^(?:(?:\w+\s*\.\s*)?Annotated\s*\[\s*)*(?:(\w+)\s*\.)?\s*(\w+)')

# Atomic immutable types which don't require any recursive handling and for which deepcopy
# returns the same object. We can provide a fast-path for these types in asdict and astuple.
Expand Down Expand Up @@ -774,6 +775,13 @@ def _get_field(cls, a_name, a_type, default_kw_only):
default = MISSING
f = field(default=default)

typing = sys.modules.get('typing')
if typing:
while isinstance(a_type, typing._AnnotatedAlias):
a_type = a_type.__origin__
if isinstance(a_type, typing.ForwardRef):
a_type = a_type.__forward_arg__

# Only at this point do we know the name and the type. Set them.
f.name = a_name
f.type = a_type
Expand All @@ -797,7 +805,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).
typing = sys.modules.get('typing')

if typing:
if (_is_classvar(a_type, typing)
or (isinstance(f.type, str)
Expand Down
60 changes: 43 additions & 17 deletions Lib/test/test_dataclasses.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
import traceback
import unittest
from unittest.mock import Mock
from typing import ClassVar, Any, List, Union, Tuple, Dict, Generic, TypeVar, Optional, Protocol, DefaultDict
from typing import Annotated, ClassVar, Any, List, Union, Tuple, Dict, Generic, TypeVar, Optional, Protocol, DefaultDict
from typing import get_type_hints
from collections import deque, OrderedDict, namedtuple, defaultdict
from functools import total_ordering
Expand Down Expand Up @@ -1153,19 +1153,29 @@ def test_class_var(self):
class C:
x: int
y: int = 10
z: ClassVar[int] = 1000
w: ClassVar[int] = 2000
t: ClassVar[int] = 3000
s: ClassVar = 4000
z: ClassVar[int] = 1000
w: ClassVar[int] = 2000
t: ClassVar[int] = 3000
s: ClassVar = 4000
a: Annotated[ClassVar, 'meta'] = 5000
b: Annotated[ClassVar[int], 'meta'] = 6000
c: Annotated['ClassVar', 'meta'] = 7000
d: Annotated[Annotated[ClassVar, 'meta'], 'meta'] = 8000
e: Annotated['Annotated[ClassVar, "meta"]', 'meta'] = 9000

c = C(5)
self.assertEqual(repr(c), 'TestCase.test_class_var.<locals>.C(x=5, y=10)')
self.assertEqual(len(fields(C)), 2) # We have 2 fields.
self.assertEqual(len(C.__annotations__), 6) # And 4 ClassVars.
self.assertEqual(len(C.__annotations__), 11) # And 9 ClassVars.
self.assertEqual(c.z, 1000)
self.assertEqual(c.w, 2000)
self.assertEqual(c.t, 3000)
self.assertEqual(c.s, 4000)
self.assertEqual(c.a, 5000)
self.assertEqual(c.b, 6000)
self.assertEqual(c.c, 7000)
self.assertEqual(c.d, 8000)
self.assertEqual(c.e, 9000)
C.z += 1
self.assertEqual(c.z, 1001)
c = C(20)
Expand All @@ -1174,6 +1184,11 @@ class C:
self.assertEqual(c.w, 2000)
self.assertEqual(c.t, 3000)
self.assertEqual(c.s, 4000)
self.assertEqual(c.a, 5000)
self.assertEqual(c.b, 6000)
self.assertEqual(c.c, 7000)
self.assertEqual(c.d, 8000)
self.assertEqual(c.e, 9000)

def test_class_var_no_default(self):
# If a ClassVar has no default value, it should not be set on the class.
Expand Down Expand Up @@ -1269,13 +1284,14 @@ def test_init_var(self):
class C:
x: int = None
init_param: InitVar[int] = None
annotated_init_param: Annotated[InitVar[int], 'meta'] = None

def __post_init__(self, init_param):
def __post_init__(self, init_param, annotated_init_param):
if self.x is None:
self.x = init_param*2
self.x = init_param*2 + annotated_init_param

c = C(init_param=10)
self.assertEqual(c.x, 20)
c = C(init_param=10, annotated_init_param=5)
self.assertEqual(c.x, 25)

def test_init_var_preserve_type(self):
self.assertEqual(InitVar[int].type, int)
Expand Down Expand Up @@ -3590,16 +3606,21 @@ def test_classvar(self):
# typing import *" have been run in this file.
for typestr in ('ClassVar[int]',
'ClassVar [int]',
' ClassVar [int]',
'ClassVar',
' ClassVar ',
'typing.ClassVar[int]',
'typing.ClassVar[str]',
' typing.ClassVar[str]',
'typing .ClassVar[str]',
'typing. ClassVar[str]',
'typing.ClassVar [str]',
'typing.ClassVar [ str]',
'Annotated[ClassVar[int], (3, 5)]',
'Annotated[Annotated[ClassVar[int], (3, 5)], (3, 6)]',
'Annotated[typing.ClassVar[int], (3, 5)]',
'Annotated [ClassVar[int], (3, 5)]',
'Annotated[ ClassVar[int], (3, 5)]',
'typing.Annotated[ClassVar[int], (3, 5)]',
'typing .Annotated[ClassVar[int], (3, 5)]',
'typing. Annotated[ClassVar[int], (3, 5)]',

# Not syntactically valid, but these will
# be treated as ClassVars.
Expand Down Expand Up @@ -3642,17 +3663,22 @@ def test_initvar(self):
# These tests assume that both "import dataclasses" and "from
# dataclasses import *" have been run in this file.
for typestr in ('InitVar[int]',
'InitVar [int]'
' InitVar [int]',
'InitVar [int]',
'InitVar',
' InitVar ',
'dataclasses.InitVar[int]',
'dataclasses.InitVar[str]',
' dataclasses.InitVar[str]',
'dataclasses .InitVar[str]',
'dataclasses. InitVar[str]',
'dataclasses.InitVar [str]',
'dataclasses.InitVar [ str]',
'Annotated[InitVar[int], (3, 5)]',
'Annotated[Annotated[InitVar[int], (3, 5)], (3, 6)]',
'Annotated[dataclasses.InitVar[int], (3, 5)]',
'Annotated [InitVar[int], (3, 5)]',
'Annotated[ InitVar[int], (3, 5)]',
'typing.Annotated[InitVar[int], (3, 5)]',
'typing .Annotated[InitVar[int], (3, 5)]',
'typing. Annotated[InitVar[int], (3, 5)]',

# Not syntactically valid, but these will
# be treated as InitVars.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Allow :data:`typing.Annotated` to wrap :data:`typing.ClassVar` and :data:`dataclasses.InitVar` in dataclasses. Patch by Gregory Beauregard.