Skip to content

Commit bc9ce4f

Browse files
Backport NamedTuple and TypedDict deprecations from Python 3.13 (#240)
1 parent 38bb6e8 commit bc9ce4f

File tree

4 files changed

+224
-24
lines changed

4 files changed

+224
-24
lines changed

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,15 @@
3030
- Allow classes to inherit from both `typing.Protocol` and `typing_extensions.Protocol`
3131
simultaneously. Since v4.6.0, this caused `TypeError` to be raised due to a
3232
metaclass conflict. Patch by Alex Waygood.
33+
- Backport several deprecations from CPython relating to unusual ways to
34+
create `TypedDict`s and `NamedTuple`s. CPython PRs #105609 and #105780
35+
by Alex Waygood; `typing_extensions` backport by Jelle Zijlstra.
36+
- Creating a `NamedTuple` using the functional syntax with keyword arguments
37+
(`NT = NamedTuple("NT", a=int)`) is now deprecated.
38+
- Creating a `NamedTuple` with zero fields using the syntax `NT = NamedTuple("NT")`
39+
or `NT = NamedTuple("NT", None)` is now deprecated.
40+
- Creating a `TypedDict` with zero fields using the syntax `TD = TypedDict("TD")`
41+
or `TD = TypedDict("TD", None)` is now deprecated.
3342

3443
# Release 4.6.3 (June 1, 2023)
3544

doc/index.rst

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,22 @@ Special typing primitives
216216

217217
Support for the ``__orig_bases__`` attribute was added.
218218

219+
.. versionchanged:: 4.7.0
220+
221+
The undocumented keyword argument syntax for creating NamedTuple classes
222+
(``NT = NamedTuple("NT", x=int)``) is deprecated, and will be disallowed
223+
in Python 3.15. Use the class-based syntax or the functional syntax instead.
224+
225+
.. versionchanged:: 4.7.0
226+
227+
When using the functional syntax to create a NamedTuple class, failing to
228+
pass a value to the 'fields' parameter (``NT = NamedTuple("NT")``) is
229+
deprecated. Passing ``None`` to the 'fields' parameter
230+
(``NT = NamedTuple("NT", None)``) is also deprecated. Both will be
231+
disallowed in Python 3.15. To create a NamedTuple class with zero fields,
232+
use ``class NT(NamedTuple): pass`` or ``NT = NamedTuple("NT", [])``.
233+
234+
219235
.. data:: Never
220236

221237
See :py:data:`typing.Never`. In ``typing`` since 3.11.
@@ -355,6 +371,15 @@ Special typing primitives
355371
This brings ``typing_extensions.TypedDict`` closer to the implementation
356372
of :py:mod:`typing.TypedDict` on Python 3.9 and higher.
357373

374+
.. versionchanged:: 4.7.0
375+
376+
When using the functional syntax to create a TypedDict class, failing to
377+
pass a value to the 'fields' parameter (``TD = TypedDict("TD")``) is
378+
deprecated. Passing ``None`` to the 'fields' parameter
379+
(``TD = TypedDict("TD", None)``) is also deprecated. Both will be
380+
disallowed in Python 3.15. To create a TypedDict class with 0 fields,
381+
use ``class TD(TypedDict): pass`` or ``TD = TypedDict("TD", {})``.
382+
358383
.. class:: TypeVar(name, *constraints, bound=None, covariant=False,
359384
contravariant=False, infer_variance=False, default=...)
360385

src/test_typing_extensions.py

Lines changed: 94 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3265,7 +3265,7 @@ def test_typeddict_create_errors(self):
32653265

32663266
def test_typeddict_errors(self):
32673267
Emp = TypedDict('Emp', {'name': str, 'id': int})
3268-
if sys.version_info >= (3, 12):
3268+
if sys.version_info >= (3, 13):
32693269
self.assertEqual(TypedDict.__module__, 'typing')
32703270
else:
32713271
self.assertEqual(TypedDict.__module__, 'typing_extensions')
@@ -3754,6 +3754,45 @@ class MultipleGenericBases(GenericParent[int], GenericParent[float]):
37543754
self.assertEqual(MultipleGenericBases.__orig_bases__, (GenericParent[int], GenericParent[float]))
37553755
self.assertEqual(CallTypedDict.__orig_bases__, (TypedDict,))
37563756

3757+
def test_zero_fields_typeddicts(self):
3758+
T1 = TypedDict("T1", {})
3759+
class T2(TypedDict): pass
3760+
try:
3761+
ns = {"TypedDict": TypedDict}
3762+
exec("class T3[tvar](TypedDict): pass", ns)
3763+
T3 = ns["T3"]
3764+
except SyntaxError:
3765+
class T3(TypedDict): pass
3766+
S = TypeVar("S")
3767+
class T4(TypedDict, Generic[S]): pass
3768+
3769+
expected_warning = re.escape(
3770+
"Failing to pass a value for the 'fields' parameter is deprecated "
3771+
"and will be disallowed in Python 3.15. "
3772+
"To create a TypedDict class with 0 fields "
3773+
"using the functional syntax, "
3774+
"pass an empty dictionary, e.g. `T5 = TypedDict('T5', {})`."
3775+
)
3776+
with self.assertWarnsRegex(DeprecationWarning, fr"^{expected_warning}$"):
3777+
T5 = TypedDict('T5')
3778+
3779+
expected_warning = re.escape(
3780+
"Passing `None` as the 'fields' parameter is deprecated "
3781+
"and will be disallowed in Python 3.15. "
3782+
"To create a TypedDict class with 0 fields "
3783+
"using the functional syntax, "
3784+
"pass an empty dictionary, e.g. `T6 = TypedDict('T6', {})`."
3785+
)
3786+
with self.assertWarnsRegex(DeprecationWarning, fr"^{expected_warning}$"):
3787+
T6 = TypedDict('T6', None)
3788+
3789+
for klass in T1, T2, T3, T4, T5, T6:
3790+
with self.subTest(klass=klass.__name__):
3791+
self.assertEqual(klass.__annotations__, {})
3792+
self.assertEqual(klass.__required_keys__, set())
3793+
self.assertEqual(klass.__optional_keys__, set())
3794+
self.assertIsInstance(klass(), dict)
3795+
37573796

37583797
class AnnotatedTests(BaseTestCase):
37593798

@@ -4903,8 +4942,10 @@ def test_typing_extensions_defers_when_possible(self):
49034942
exclude |= {
49044943
'Protocol', 'SupportsAbs', 'SupportsBytes',
49054944
'SupportsComplex', 'SupportsFloat', 'SupportsIndex', 'SupportsInt',
4906-
'SupportsRound', 'TypedDict', 'is_typeddict', 'NamedTuple', 'Unpack',
4945+
'SupportsRound', 'Unpack',
49074946
}
4947+
if sys.version_info < (3, 13):
4948+
exclude |= {'NamedTuple', 'TypedDict', 'is_typeddict'}
49084949
for item in typing_extensions.__all__:
49094950
if item not in exclude and hasattr(typing, item):
49104951
self.assertIs(
@@ -5124,21 +5165,47 @@ class Group(NamedTuple):
51245165
self.assertFalse(hasattr(Group, attr))
51255166

51265167
def test_namedtuple_keyword_usage(self):
5127-
LocalEmployee = NamedTuple("LocalEmployee", name=str, age=int)
5168+
with self.assertWarnsRegex(
5169+
DeprecationWarning,
5170+
"Creating NamedTuple classes using keyword arguments is deprecated"
5171+
):
5172+
LocalEmployee = NamedTuple("LocalEmployee", name=str, age=int)
5173+
51285174
nick = LocalEmployee('Nick', 25)
51295175
self.assertIsInstance(nick, tuple)
51305176
self.assertEqual(nick.name, 'Nick')
51315177
self.assertEqual(LocalEmployee.__name__, 'LocalEmployee')
51325178
self.assertEqual(LocalEmployee._fields, ('name', 'age'))
51335179
self.assertEqual(LocalEmployee.__annotations__, dict(name=str, age=int))
5180+
51345181
with self.assertRaisesRegex(
51355182
TypeError,
5136-
'Either list of fields or keywords can be provided to NamedTuple, not both'
5183+
"Either list of fields or keywords can be provided to NamedTuple, not both"
51375184
):
51385185
NamedTuple('Name', [('x', int)], y=str)
51395186

5187+
with self.assertRaisesRegex(
5188+
TypeError,
5189+
"Either list of fields or keywords can be provided to NamedTuple, not both"
5190+
):
5191+
NamedTuple('Name', [], y=str)
5192+
5193+
with self.assertRaisesRegex(
5194+
TypeError,
5195+
(
5196+
r"Cannot pass `None` as the 'fields' parameter "
5197+
r"and also specify fields using keyword arguments"
5198+
)
5199+
):
5200+
NamedTuple('Name', None, x=int)
5201+
51405202
def test_namedtuple_special_keyword_names(self):
5141-
NT = NamedTuple("NT", cls=type, self=object, typename=str, fields=list)
5203+
with self.assertWarnsRegex(
5204+
DeprecationWarning,
5205+
"Creating NamedTuple classes using keyword arguments is deprecated"
5206+
):
5207+
NT = NamedTuple("NT", cls=type, self=object, typename=str, fields=list)
5208+
51425209
self.assertEqual(NT.__name__, 'NT')
51435210
self.assertEqual(NT._fields, ('cls', 'self', 'typename', 'fields'))
51445211
a = NT(cls=str, self=42, typename='foo', fields=[('bar', tuple)])
@@ -5148,12 +5215,32 @@ def test_namedtuple_special_keyword_names(self):
51485215
self.assertEqual(a.fields, [('bar', tuple)])
51495216

51505217
def test_empty_namedtuple(self):
5151-
NT = NamedTuple('NT')
5218+
expected_warning = re.escape(
5219+
"Failing to pass a value for the 'fields' parameter is deprecated "
5220+
"and will be disallowed in Python 3.15. "
5221+
"To create a NamedTuple class with 0 fields "
5222+
"using the functional syntax, "
5223+
"pass an empty list, e.g. `NT1 = NamedTuple('NT1', [])`."
5224+
)
5225+
with self.assertWarnsRegex(DeprecationWarning, fr"^{expected_warning}$"):
5226+
NT1 = NamedTuple('NT1')
5227+
5228+
expected_warning = re.escape(
5229+
"Passing `None` as the 'fields' parameter is deprecated "
5230+
"and will be disallowed in Python 3.15. "
5231+
"To create a NamedTuple class with 0 fields "
5232+
"using the functional syntax, "
5233+
"pass an empty list, e.g. `NT2 = NamedTuple('NT2', [])`."
5234+
)
5235+
with self.assertWarnsRegex(DeprecationWarning, fr"^{expected_warning}$"):
5236+
NT2 = NamedTuple('NT2', None)
5237+
5238+
NT3 = NamedTuple('NT2', [])
51525239

51535240
class CNT(NamedTuple):
51545241
pass # empty body
51555242

5156-
for struct in [NT, CNT]:
5243+
for struct in NT1, NT2, NT3, CNT:
51575244
with self.subTest(struct=struct):
51585245
self.assertEqual(struct._fields, ())
51595246
self.assertEqual(struct.__annotations__, {})
@@ -5196,7 +5283,6 @@ def test_copy_and_pickle(self):
51965283
self.assertIsInstance(jane2, cls)
51975284

51985285
def test_docstring(self):
5199-
self.assertEqual(NamedTuple.__doc__, typing.NamedTuple.__doc__)
52005286
self.assertIsInstance(NamedTuple.__doc__, str)
52015287

52025288
@skipUnless(TYPING_3_8_0, "NamedTuple had a bad signature on <=3.7")

src/typing_extensions.py

Lines changed: 96 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -972,7 +972,7 @@ def __round__(self, ndigits: int = 0) -> T_co:
972972
pass
973973

974974

975-
if sys.version_info >= (3, 12):
975+
if sys.version_info >= (3, 13):
976976
# The standard library TypedDict in Python 3.8 does not store runtime information
977977
# about which (if any) keys are optional. See https://bugs.python.org/issue38834
978978
# The standard library TypedDict in Python 3.9.0/1 does not honour the "total"
@@ -982,6 +982,7 @@ def __round__(self, ndigits: int = 0) -> T_co:
982982
# Generic TypedDicts are also impossible using typing.TypedDict on Python <3.11.
983983
# Aaaand on 3.12 we add __orig_bases__ to TypedDict
984984
# to enable better runtime introspection.
985+
# On 3.13 we deprecate some odd ways of creating TypedDicts.
985986
TypedDict = typing.TypedDict
986987
_TypedDictMeta = typing._TypedDictMeta
987988
is_typeddict = typing.is_typeddict
@@ -1077,13 +1078,14 @@ def __subclasscheck__(cls, other):
10771078

10781079
__instancecheck__ = __subclasscheck__
10791080

1080-
def TypedDict(__typename, __fields=None, *, total=True, **kwargs):
1081+
def TypedDict(__typename, __fields=_marker, *, total=True, **kwargs):
10811082
"""A simple typed namespace. At runtime it is equivalent to a plain dict.
10821083
1083-
TypedDict creates a dictionary type that expects all of its
1084+
TypedDict creates a dictionary type such that a type checker will expect all
10841085
instances to have a certain set of keys, where each key is
10851086
associated with a value of a consistent type. This expectation
1086-
is not checked at runtime but is only enforced by type checkers.
1087+
is not checked at runtime.
1088+
10871089
Usage::
10881090
10891091
class Point2D(TypedDict):
@@ -1103,19 +1105,39 @@ class Point2D(TypedDict):
11031105
Point2D = TypedDict('Point2D', {'x': int, 'y': int, 'label': str})
11041106
11051107
By default, all keys must be present in a TypedDict. It is possible
1106-
to override this by specifying totality.
1107-
Usage::
1108+
to override this by specifying totality::
11081109
1109-
class point2D(TypedDict, total=False):
1110+
class Point2D(TypedDict, total=False):
11101111
x: int
11111112
y: int
11121113
1113-
This means that a point2D TypedDict can have any of the keys omitted. A type
1114+
This means that a Point2D TypedDict can have any of the keys omitted. A type
11141115
checker is only expected to support a literal False or True as the value of
11151116
the total argument. True is the default, and makes all items defined in the
11161117
class body be required.
1118+
1119+
The Required and NotRequired special forms can also be used to mark
1120+
individual keys as being required or not required::
1121+
1122+
class Point2D(TypedDict):
1123+
x: int # the "x" key must always be present (Required is the default)
1124+
y: NotRequired[int] # the "y" key can be omitted
1125+
1126+
See PEP 655 for more details on Required and NotRequired.
11171127
"""
1118-
if __fields is None:
1128+
if __fields is _marker or __fields is None:
1129+
if __fields is _marker:
1130+
deprecated_thing = "Failing to pass a value for the 'fields' parameter"
1131+
else:
1132+
deprecated_thing = "Passing `None` as the 'fields' parameter"
1133+
1134+
example = f"`{__typename} = TypedDict({__typename!r}, {{}})`"
1135+
deprecation_msg = (
1136+
f"{deprecated_thing} is deprecated and will be disallowed in "
1137+
"Python 3.15. To create a TypedDict class with 0 fields "
1138+
"using the functional syntax, pass an empty dictionary, e.g. "
1139+
) + example + "."
1140+
warnings.warn(deprecation_msg, DeprecationWarning, stacklevel=2)
11191141
__fields = kwargs
11201142
elif kwargs:
11211143
raise TypeError("TypedDict takes either a dict or keyword arguments,"
@@ -2570,7 +2592,8 @@ def wrapper(*args, **kwargs):
25702592
# In 3.11, the ability to define generic `NamedTuple`s was supported.
25712593
# This was explicitly disallowed in 3.9-3.10, and only half-worked in <=3.8.
25722594
# On 3.12, we added __orig_bases__ to call-based NamedTuples
2573-
if sys.version_info >= (3, 12):
2595+
# On 3.13, we deprecated kwargs-based NamedTuples
2596+
if sys.version_info >= (3, 13):
25742597
NamedTuple = typing.NamedTuple
25752598
else:
25762599
def _make_nmtuple(name, types, module, defaults=()):
@@ -2614,8 +2637,11 @@ def __new__(cls, typename, bases, ns):
26142637
)
26152638
nm_tpl.__bases__ = bases
26162639
if typing.Generic in bases:
2617-
class_getitem = typing.Generic.__class_getitem__.__func__
2618-
nm_tpl.__class_getitem__ = classmethod(class_getitem)
2640+
if hasattr(typing, '_generic_class_getitem'): # 3.12+
2641+
nm_tpl.__class_getitem__ = classmethod(typing._generic_class_getitem)
2642+
else:
2643+
class_getitem = typing.Generic.__class_getitem__.__func__
2644+
nm_tpl.__class_getitem__ = classmethod(class_getitem)
26192645
# update from user namespace without overriding special namedtuple attributes
26202646
for key in ns:
26212647
if key in _prohibited_namedtuple_fields:
@@ -2626,17 +2652,71 @@ def __new__(cls, typename, bases, ns):
26262652
nm_tpl.__init_subclass__()
26272653
return nm_tpl
26282654

2629-
def NamedTuple(__typename, __fields=None, **kwargs):
2630-
if __fields is None:
2631-
__fields = kwargs.items()
2655+
def NamedTuple(__typename, __fields=_marker, **kwargs):
2656+
"""Typed version of namedtuple.
2657+
2658+
Usage::
2659+
2660+
class Employee(NamedTuple):
2661+
name: str
2662+
id: int
2663+
2664+
This is equivalent to::
2665+
2666+
Employee = collections.namedtuple('Employee', ['name', 'id'])
2667+
2668+
The resulting class has an extra __annotations__ attribute, giving a
2669+
dict that maps field names to types. (The field names are also in
2670+
the _fields attribute, which is part of the namedtuple API.)
2671+
An alternative equivalent functional syntax is also accepted::
2672+
2673+
Employee = NamedTuple('Employee', [('name', str), ('id', int)])
2674+
"""
2675+
if __fields is _marker:
2676+
if kwargs:
2677+
deprecated_thing = "Creating NamedTuple classes using keyword arguments"
2678+
deprecation_msg = (
2679+
"{name} is deprecated and will be disallowed in Python {remove}. "
2680+
"Use the class-based or functional syntax instead."
2681+
)
2682+
else:
2683+
deprecated_thing = "Failing to pass a value for the 'fields' parameter"
2684+
example = f"`{__typename} = NamedTuple({__typename!r}, [])`"
2685+
deprecation_msg = (
2686+
"{name} is deprecated and will be disallowed in Python {remove}. "
2687+
"To create a NamedTuple class with 0 fields "
2688+
"using the functional syntax, "
2689+
"pass an empty list, e.g. "
2690+
) + example + "."
2691+
elif __fields is None:
2692+
if kwargs:
2693+
raise TypeError(
2694+
"Cannot pass `None` as the 'fields' parameter "
2695+
"and also specify fields using keyword arguments"
2696+
)
2697+
else:
2698+
deprecated_thing = "Passing `None` as the 'fields' parameter"
2699+
example = f"`{__typename} = NamedTuple({__typename!r}, [])`"
2700+
deprecation_msg = (
2701+
"{name} is deprecated and will be disallowed in Python {remove}. "
2702+
"To create a NamedTuple class with 0 fields "
2703+
"using the functional syntax, "
2704+
"pass an empty list, e.g. "
2705+
) + example + "."
26322706
elif kwargs:
26332707
raise TypeError("Either list of fields or keywords"
26342708
" can be provided to NamedTuple, not both")
2709+
if __fields is _marker or __fields is None:
2710+
warnings.warn(
2711+
deprecation_msg.format(name=deprecated_thing, remove="3.15"),
2712+
DeprecationWarning,
2713+
stacklevel=2,
2714+
)
2715+
__fields = kwargs.items()
26352716
nt = _make_nmtuple(__typename, __fields, module=_caller())
26362717
nt.__orig_bases__ = (NamedTuple,)
26372718
return nt
26382719

2639-
NamedTuple.__doc__ = typing.NamedTuple.__doc__
26402720
_NamedTuple = type.__new__(_NamedTupleMeta, 'NamedTuple', (), {})
26412721

26422722
# On 3.8+, alter the signature so that it matches typing.NamedTuple.

0 commit comments

Comments
 (0)