Skip to content

Commit 0c3c5ca

Browse files
committed
fix: further improve detection of ClassVar in dataclass' string annotations
1 parent a18ff35 commit 0c3c5ca

6 files changed

Lines changed: 97 additions & 33 deletions

File tree

Lib/dataclasses.py

Lines changed: 15 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -762,22 +762,20 @@ def _is_kw_only(a_type, dataclasses):
762762
return a_type is dataclasses.KW_ONLY
763763

764764

765-
def _is_type(annotation, cls, a_module, a_type, is_type_predicate):
766-
# Given a type annotation string, does it refer to a_type in
767-
# a_module? For example, when checking that annotation denotes a
768-
# ClassVar, then a_module is typing, and a_type is
769-
# typing.ClassVar.
765+
def _is_type(annotation, cls, is_type_predicate, *is_type_predicate_args):
766+
# Loosely parse a string annotation and pass the result to is_type_predicate,
767+
# along with any additional arguments it might require.
770768

771-
# It's possible to look up a_module given a_type, but it involves
772-
# looking in sys.modules (again!), and seems like a waste since
773-
# the caller already knows a_module.
769+
# We can't perform a full type hint evaluation at the point where @dataclass
770+
# was invoked because class's module is not fully initialized yet. So we resort
771+
# to parsing string annotation using regexp, and extracting a type before
772+
# the first square bracket.
774773

775774
# - annotation is a string type annotation
776775
# - cls is the class that this annotation was found in
777-
# - a_module is the module we want to match
778-
# - a_type is the type in that module we want to match
779-
# - is_type_predicate is a function called with (obj, a_module)
776+
# - is_type_predicate is a function called with (obj, *is_type_predicate_args)
780777
# that determines if obj is of the desired type.
778+
# - is_type_predicate_args is additional arguments forwarded to is_type_predicate
781779

782780
# Since this test does not do a local namespace lookup (and
783781
# instead only a module (global) lookup), there are some things it
@@ -814,30 +812,19 @@ def _is_type(annotation, cls, a_module, a_type, is_type_predicate):
814812
if not match:
815813
return False
816814

817-
ns = None
818815
module_name = match.group(1)
819816
type_name = match.group(2)
820817

821818
if not module_name:
822819
# No module name, assume the class's module did
823820
# "from dataclasses import InitVar".
824-
ns = sys.modules.get(cls.__module__).__dict__
821+
ns = sys.modules.get(cls.__module__)
825822
else:
826823
# Look up module_name in the class's module.
827824
cls_module = sys.modules.get(cls.__module__)
828-
if not cls_module:
829-
return False
825+
ns = cls_module.__dict__.get(module_name)
830826

831-
a_type_module = cls_module.__dict__.get(module_name)
832-
if (
833-
isinstance(a_type_module, types.ModuleType)
834-
# Handle cases when a_type is not defined in
835-
# the referenced module, e.g. 'dataclasses.ClassVar[int]'
836-
and a_type_module.__dict__.get(type_name) is a_type
837-
):
838-
ns = sys.modules.get(a_type.__module__).__dict__
839-
840-
return ns and is_type_predicate(ns.get(type_name), a_module)
827+
return is_type_predicate(getattr(ns, type_name, None), *is_type_predicate_args)
841828

842829

843830
def _get_field(cls, a_name, a_type, default_kw_only):
@@ -884,8 +871,7 @@ def _get_field(cls, a_name, a_type, default_kw_only):
884871
if typing:
885872
if (_is_classvar(a_type, typing)
886873
or (isinstance(f.type, str)
887-
and _is_type(f.type, cls, typing, typing.ClassVar,
888-
_is_classvar))):
874+
and _is_type(f.type, cls, _is_classvar, typing))):
889875
f._field_type = _FIELD_CLASSVAR
890876

891877
# If the type is InitVar, or if it's a matching string annotation,
@@ -896,8 +882,7 @@ def _get_field(cls, a_name, a_type, default_kw_only):
896882
dataclasses = sys.modules[__name__]
897883
if (_is_initvar(a_type, dataclasses)
898884
or (isinstance(f.type, str)
899-
and _is_type(f.type, cls, dataclasses, dataclasses.InitVar,
900-
_is_initvar))):
885+
and _is_type(f.type, cls, _is_initvar, dataclasses))):
901886
f._field_type = _FIELD_INITVAR
902887

903888
# Validations for individual fields. This is delayed until now,
@@ -1070,8 +1055,7 @@ def _process_class(cls, init, repr, eq, order, unsafe_hash, frozen,
10701055
# See if this is a marker to change the value of kw_only.
10711056
if (_is_kw_only(type, dataclasses)
10721057
or (isinstance(type, str)
1073-
and _is_type(type, cls, dataclasses, dataclasses.KW_ONLY,
1074-
_is_kw_only))):
1058+
and _is_type(type, cls, _is_kw_only, dataclasses))):
10751059
# Switch the default to kw_only=True, and ignore this
10761060
# annotation: it's not a real field.
10771061
if KW_ONLY_seen:

Lib/test/test_dataclasses/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4331,11 +4331,14 @@ def test_classvar_module_level_import(self):
43314331
from test.test_dataclasses import dataclass_module_2_str
43324332
from test.test_dataclasses import dataclass_module_3
43334333
from test.test_dataclasses import dataclass_module_3_str
4334+
from test.test_dataclasses import dataclass_module_4
4335+
from test.test_dataclasses import dataclass_module_4_str
43344336

43354337
for m in (
43364338
dataclass_module_1, dataclass_module_1_str,
43374339
dataclass_module_2, dataclass_module_2_str,
43384340
dataclass_module_3, dataclass_module_3_str,
4341+
dataclass_module_4, dataclass_module_4_str,
43394342
):
43404343
with self.subTest(m=m):
43414344
# There's a difference in how the ClassVars are

Lib/test/test_dataclasses/dataclass_module_3_str.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from __future__ import annotations
22
USING_STRINGS = True
33

4-
# dataclass_module_3.py and dataclass_module_2_str.py are identical
4+
# dataclass_module_3.py and dataclass_module_3_str.py are identical
55
# except only the latter uses string annotations.
66

77
from dataclasses import dataclass
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
#from __future__ import annotations
2+
USING_STRINGS = False
3+
4+
# dataclass_module_4.py and dataclass_module_4_str.py are identical
5+
# except only the latter uses string annotations.
6+
7+
from dataclasses import dataclass
8+
import dataclasses
9+
import typing
10+
11+
class TypingProxy:
12+
ClassVar = typing.ClassVar
13+
InitVar = dataclasses.InitVar
14+
15+
T_CV2 = TypingProxy.ClassVar[int]
16+
T_CV3 = TypingProxy.ClassVar
17+
18+
T_IV2 = TypingProxy.InitVar[int]
19+
T_IV3 = TypingProxy.InitVar
20+
21+
@dataclass
22+
class CV:
23+
T_CV4 = TypingProxy.ClassVar
24+
cv0: TypingProxy.ClassVar[int] = 20
25+
cv1: TypingProxy.ClassVar = 30
26+
cv2: T_CV2
27+
cv3: T_CV3
28+
not_cv4: T_CV4 # When using string annotations, this field is not recognized as a ClassVar.
29+
30+
@dataclass
31+
class IV:
32+
T_IV4 = TypingProxy.InitVar
33+
iv0: TypingProxy.InitVar[int]
34+
iv1: TypingProxy.InitVar
35+
iv2: T_IV2
36+
iv3: T_IV3
37+
not_iv4: T_IV4 # When using string annotations, this field is not recognized as an InitVar.
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
from __future__ import annotations
2+
USING_STRINGS = True
3+
4+
# dataclass_module_4.py and dataclass_module_4_str.py are identical
5+
# except only the latter uses string annotations.
6+
7+
from dataclasses import dataclass
8+
import dataclasses
9+
import typing
10+
11+
class TypingProxy:
12+
ClassVar = typing.ClassVar
13+
InitVar = dataclasses.InitVar
14+
15+
T_CV2 = TypingProxy.ClassVar[int]
16+
T_CV3 = TypingProxy.ClassVar
17+
18+
T_IV2 = TypingProxy.InitVar[int]
19+
T_IV3 = TypingProxy.InitVar
20+
21+
@dataclass
22+
class CV:
23+
T_CV4 = TypingProxy.ClassVar
24+
cv0: TypingProxy.ClassVar[int] = 20
25+
cv1: TypingProxy.ClassVar = 30
26+
cv2: T_CV2
27+
cv3: T_CV3
28+
not_cv4: T_CV4 # When using string annotations, this field is not recognized as a ClassVar.
29+
30+
@dataclass
31+
class IV:
32+
T_IV4 = TypingProxy.InitVar
33+
iv0: TypingProxy.InitVar[int]
34+
iv1: TypingProxy.InitVar
35+
iv2: T_IV2
36+
iv3: T_IV3
37+
not_iv4: T_IV4 # When using string annotations, this field is not recognized as an InitVar.
Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,4 @@
1-
Fix bug where ``ClassVar`` string annotation in :func:`@dataclass <dataclasses.dataclass>` caused incorrect __init__ generation
1+
Fix bug where :func:`@dataclass <dataclasses.dataclass>`
2+
wouldn't detect ``ClassVar`` fields
3+
if ``ClassVar`` was re-exported from a module
4+
other than :mod:`typing`.

0 commit comments

Comments
 (0)