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
24 changes: 21 additions & 3 deletions Lib/dataclasses.py
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,8 @@ class _MISSING_TYPE:
# A sentinel object to indicate that following fields are keyword-only by
# default. Use a class to give it a better repr.
class _KW_ONLY_TYPE:
pass
def __call__(self, *args, **kwds):
GBeauregard marked this conversation as resolved.
Show resolved Hide resolved
raise TypeError(f"Cannot instantiate {self!r}")
KW_ONLY = _KW_ONLY_TYPE()

# Since most per-field metadata will be unused, create an empty
Expand Down Expand Up @@ -220,7 +221,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+)')

class InitVar:
__slots__ = ('type', )
Expand All @@ -239,6 +241,9 @@ def __repr__(self):
def __class_getitem__(cls, type):
return InitVar(type)

def __call__(self, *args, **kwds):
raise TypeError(f"Cannot instantiate {self!r}")
GBeauregard marked this conversation as resolved.
Show resolved Hide resolved

# Instances of Field are only ever created from within this module,
# and only from the field() function, although Field instances are
# exposed externally as (conceptually) read-only objects.
Expand Down Expand Up @@ -737,6 +742,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 @@ -760,7 +772,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 Expand Up @@ -940,7 +952,13 @@ def _process_class(cls, init, repr, eq, order, unsafe_hash, frozen,
# Get a reference to this module for the _is_kw_only() test.
KW_ONLY_seen = False
dataclasses = sys.modules[__name__]
typing = sys.modules.get('typing')
for name, type in cls_annotations.items():
if typing:
while isinstance(type, typing._AnnotatedAlias):
type = type.__origin__
if isinstance(type, typing.ForwardRef):
type = type.__forward_arg__
# See if this is a marker to change the value of kw_only.
if (_is_kw_only(type, dataclasses)
or (isinstance(type, str)
Expand Down
66 changes: 49 additions & 17 deletions Lib/test/test_dataclasses.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
import types
import unittest
from unittest.mock import Mock
from typing import ClassVar, Any, List, Union, Tuple, Dict, Generic, TypeVar, Optional, Protocol
from typing import Annotated, ClassVar, Any, List, Union, Tuple, Dict, Generic, TypeVar, Optional, Protocol
from typing import get_type_hints
from collections import deque, OrderedDict, namedtuple
from functools import total_ordering
Expand Down Expand Up @@ -1022,19 +1022,25 @@ 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
GBeauregard marked this conversation as resolved.
Show resolved Hide resolved
b: Annotated[ClassVar[int], 'meta'] = 6000
c: Annotated['ClassVar', "meta"] = 7000

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__), 9) # And 7 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)
C.z += 1
self.assertEqual(c.z, 1001)
c = C(20)
Expand All @@ -1043,6 +1049,9 @@ 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)

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 @@ -1138,13 +1147,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 @@ -3025,16 +3035,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 @@ -3077,17 +3092,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 Expand Up @@ -3808,6 +3828,18 @@ class A:
with self.assertRaisesRegex(TypeError, msg):
A(3, 4, 5)

def test_KW_ONLY_annotated(self):
@dataclass
class A:
a: int
_: Annotated[KW_ONLY, 'meta']
GBeauregard marked this conversation as resolved.
Show resolved Hide resolved
b: int
c: int
A(3, c=5, b=4)
msg = "takes 2 positional arguments but 4 were given"
with self.assertRaisesRegex(TypeError, msg):
A(3, 4, 5)

def test_KW_ONLY_twice(self):
msg = "'Y' is KW_ONLY, but KW_ONLY has already been specified"

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Allow :data:`typing.Annotated` to wrap :data:`typing.ClassVar`, :data:`dataclasses.InitVar`, and :data:`dataclasses.KW_ONLY` in dataclasses. Patch by Gregory Beauregard.