Skip to content

Commit b7d6f76

Browse files
committed
fix: further improve detection of ClassVar in dataclass' string annotations
1 parent 6f5c210 commit b7d6f76

File tree

6 files changed

+97
-33
lines changed

6 files changed

+97
-33
lines changed

Lib/dataclasses.py

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

713713

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

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

724723
# - annotation is a string type annotation
725724
# - cls is the class that this annotation was found in
726-
# - a_module is the module we want to match
727-
# - a_type is the type in that module we want to match
728-
# - is_type_predicate is a function called with (obj, a_module)
725+
# - is_type_predicate is a function called with (obj, *is_type_predicate_args)
729726
# that determines if obj is of the desired type.
727+
# - is_type_predicate_args is additional arguments forwarded to is_type_predicate
730728

731729
# Since this test does not do a local namespace lookup (and
732730
# instead only a module (global) lookup), there are some things it
@@ -756,30 +754,19 @@ def _is_type(annotation, cls, a_module, a_type, is_type_predicate):
756754
if not match:
757755
return False
758756

759-
ns = None
760757
module_name = match.group(1)
761758
type_name = match.group(2)
762759

763760
if not module_name:
764761
# No module name, assume the class's module did
765762
# "from dataclasses import InitVar".
766-
ns = sys.modules.get(cls.__module__).__dict__
763+
ns = sys.modules.get(cls.__module__)
767764
else:
768765
# Look up module_name in the class's module.
769766
cls_module = sys.modules.get(cls.__module__)
770-
if not cls_module:
771-
return False
767+
ns = cls_module.__dict__.get(module_name)
772768

773-
a_type_module = cls_module.__dict__.get(module_name)
774-
if (
775-
isinstance(a_type_module, types.ModuleType)
776-
# Handle cases when a_type is not defined in
777-
# the referenced module, e.g. 'dataclasses.ClassVar[int]'
778-
and a_type_module.__dict__.get(type_name) is a_type
779-
):
780-
ns = sys.modules.get(a_type.__module__).__dict__
781-
782-
return ns and is_type_predicate(ns.get(type_name), a_module)
769+
return is_type_predicate(getattr(ns, type_name, None), *is_type_predicate_args)
783770

784771

785772
def _get_field(cls, a_name, a_type, default_kw_only):
@@ -826,8 +813,7 @@ def _get_field(cls, a_name, a_type, default_kw_only):
826813
if typing:
827814
if (_is_classvar(a_type, typing)
828815
or (isinstance(f.type, str)
829-
and _is_type(f.type, cls, typing, typing.ClassVar,
830-
_is_classvar))):
816+
and _is_type(f.type, cls, _is_classvar, typing))):
831817
f._field_type = _FIELD_CLASSVAR
832818

833819
# If the type is InitVar, or if it's a matching string annotation,
@@ -838,8 +824,7 @@ def _get_field(cls, a_name, a_type, default_kw_only):
838824
dataclasses = sys.modules[__name__]
839825
if (_is_initvar(a_type, dataclasses)
840826
or (isinstance(f.type, str)
841-
and _is_type(f.type, cls, dataclasses, dataclasses.InitVar,
842-
_is_initvar))):
827+
and _is_type(f.type, cls, _is_initvar, dataclasses))):
843828
f._field_type = _FIELD_INITVAR
844829

845830
# Validations for individual fields. This is delayed until now,
@@ -1012,8 +997,7 @@ def _process_class(cls, init, repr, eq, order, unsafe_hash, frozen,
1012997
# See if this is a marker to change the value of kw_only.
1013998
if (_is_kw_only(type, dataclasses)
1014999
or (isinstance(type, str)
1015-
and _is_type(type, cls, dataclasses, dataclasses.KW_ONLY,
1016-
_is_kw_only))):
1000+
and _is_type(type, cls, _is_kw_only, dataclasses))):
10171001
# Switch the default to kw_only=True, and ignore this
10181002
# annotation: it's not a real field.
10191003
if KW_ONLY_seen:

Lib/test/test_dataclasses/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4095,11 +4095,14 @@ def test_classvar_module_level_import(self):
40954095
from test.test_dataclasses import dataclass_module_2_str
40964096
from test.test_dataclasses import dataclass_module_3
40974097
from test.test_dataclasses import dataclass_module_3_str
4098+
from test.test_dataclasses import dataclass_module_4
4099+
from test.test_dataclasses import dataclass_module_4_str
40984100

40994101
for m in (
41004102
dataclass_module_1, dataclass_module_1_str,
41014103
dataclass_module_2, dataclass_module_2_str,
41024104
dataclass_module_3, dataclass_module_3_str,
4105+
dataclass_module_4, dataclass_module_4_str,
41034106
):
41044107
with self.subTest(m=m):
41054108
# 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)