Skip to content

Commit

Permalink
Backport NamedTuple and TypedDict deprecations from Python 3.13 (#240)
Browse files Browse the repository at this point in the history
  • Loading branch information
JelleZijlstra committed Jun 16, 2023
1 parent 38bb6e8 commit bc9ce4f
Show file tree
Hide file tree
Showing 4 changed files with 224 additions and 24 deletions.
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,15 @@
- Allow classes to inherit from both `typing.Protocol` and `typing_extensions.Protocol`
simultaneously. Since v4.6.0, this caused `TypeError` to be raised due to a
metaclass conflict. Patch by Alex Waygood.
- Backport several deprecations from CPython relating to unusual ways to
create `TypedDict`s and `NamedTuple`s. CPython PRs #105609 and #105780
by Alex Waygood; `typing_extensions` backport by Jelle Zijlstra.
- Creating a `NamedTuple` using the functional syntax with keyword arguments
(`NT = NamedTuple("NT", a=int)`) is now deprecated.
- Creating a `NamedTuple` with zero fields using the syntax `NT = NamedTuple("NT")`
or `NT = NamedTuple("NT", None)` is now deprecated.
- Creating a `TypedDict` with zero fields using the syntax `TD = TypedDict("TD")`
or `TD = TypedDict("TD", None)` is now deprecated.

# Release 4.6.3 (June 1, 2023)

Expand Down
25 changes: 25 additions & 0 deletions doc/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,22 @@ Special typing primitives

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

.. versionchanged:: 4.7.0

The undocumented keyword argument syntax for creating NamedTuple classes
(``NT = NamedTuple("NT", x=int)``) is deprecated, and will be disallowed
in Python 3.15. Use the class-based syntax or the functional syntax instead.

.. versionchanged:: 4.7.0

When using the functional syntax to create a NamedTuple class, failing to
pass a value to the 'fields' parameter (``NT = NamedTuple("NT")``) is
deprecated. Passing ``None`` to the 'fields' parameter
(``NT = NamedTuple("NT", None)``) is also deprecated. Both will be
disallowed in Python 3.15. To create a NamedTuple class with zero fields,
use ``class NT(NamedTuple): pass`` or ``NT = NamedTuple("NT", [])``.


.. data:: Never

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

.. versionchanged:: 4.7.0

When using the functional syntax to create a TypedDict class, failing to
pass a value to the 'fields' parameter (``TD = TypedDict("TD")``) is
deprecated. Passing ``None`` to the 'fields' parameter
(``TD = TypedDict("TD", None)``) is also deprecated. Both will be
disallowed in Python 3.15. To create a TypedDict class with 0 fields,
use ``class TD(TypedDict): pass`` or ``TD = TypedDict("TD", {})``.

.. class:: TypeVar(name, *constraints, bound=None, covariant=False,
contravariant=False, infer_variance=False, default=...)

Expand Down
102 changes: 94 additions & 8 deletions src/test_typing_extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -3265,7 +3265,7 @@ def test_typeddict_create_errors(self):

def test_typeddict_errors(self):
Emp = TypedDict('Emp', {'name': str, 'id': int})
if sys.version_info >= (3, 12):
if sys.version_info >= (3, 13):
self.assertEqual(TypedDict.__module__, 'typing')
else:
self.assertEqual(TypedDict.__module__, 'typing_extensions')
Expand Down Expand Up @@ -3754,6 +3754,45 @@ class MultipleGenericBases(GenericParent[int], GenericParent[float]):
self.assertEqual(MultipleGenericBases.__orig_bases__, (GenericParent[int], GenericParent[float]))
self.assertEqual(CallTypedDict.__orig_bases__, (TypedDict,))

def test_zero_fields_typeddicts(self):
T1 = TypedDict("T1", {})
class T2(TypedDict): pass
try:
ns = {"TypedDict": TypedDict}
exec("class T3[tvar](TypedDict): pass", ns)
T3 = ns["T3"]
except SyntaxError:
class T3(TypedDict): pass
S = TypeVar("S")
class T4(TypedDict, Generic[S]): pass

expected_warning = re.escape(
"Failing to pass a value for the 'fields' parameter is deprecated "
"and will be disallowed in Python 3.15. "
"To create a TypedDict class with 0 fields "
"using the functional syntax, "
"pass an empty dictionary, e.g. `T5 = TypedDict('T5', {})`."
)
with self.assertWarnsRegex(DeprecationWarning, fr"^{expected_warning}$"):
T5 = TypedDict('T5')

expected_warning = re.escape(
"Passing `None` as the 'fields' parameter is deprecated "
"and will be disallowed in Python 3.15. "
"To create a TypedDict class with 0 fields "
"using the functional syntax, "
"pass an empty dictionary, e.g. `T6 = TypedDict('T6', {})`."
)
with self.assertWarnsRegex(DeprecationWarning, fr"^{expected_warning}$"):
T6 = TypedDict('T6', None)

for klass in T1, T2, T3, T4, T5, T6:
with self.subTest(klass=klass.__name__):
self.assertEqual(klass.__annotations__, {})
self.assertEqual(klass.__required_keys__, set())
self.assertEqual(klass.__optional_keys__, set())
self.assertIsInstance(klass(), dict)


class AnnotatedTests(BaseTestCase):

Expand Down Expand Up @@ -4903,8 +4942,10 @@ def test_typing_extensions_defers_when_possible(self):
exclude |= {
'Protocol', 'SupportsAbs', 'SupportsBytes',
'SupportsComplex', 'SupportsFloat', 'SupportsIndex', 'SupportsInt',
'SupportsRound', 'TypedDict', 'is_typeddict', 'NamedTuple', 'Unpack',
'SupportsRound', 'Unpack',
}
if sys.version_info < (3, 13):
exclude |= {'NamedTuple', 'TypedDict', 'is_typeddict'}
for item in typing_extensions.__all__:
if item not in exclude and hasattr(typing, item):
self.assertIs(
Expand Down Expand Up @@ -5124,21 +5165,47 @@ class Group(NamedTuple):
self.assertFalse(hasattr(Group, attr))

def test_namedtuple_keyword_usage(self):
LocalEmployee = NamedTuple("LocalEmployee", name=str, age=int)
with self.assertWarnsRegex(
DeprecationWarning,
"Creating NamedTuple classes using keyword arguments is deprecated"
):
LocalEmployee = NamedTuple("LocalEmployee", name=str, age=int)

nick = LocalEmployee('Nick', 25)
self.assertIsInstance(nick, tuple)
self.assertEqual(nick.name, 'Nick')
self.assertEqual(LocalEmployee.__name__, 'LocalEmployee')
self.assertEqual(LocalEmployee._fields, ('name', 'age'))
self.assertEqual(LocalEmployee.__annotations__, dict(name=str, age=int))

with self.assertRaisesRegex(
TypeError,
'Either list of fields or keywords can be provided to NamedTuple, not both'
"Either list of fields or keywords can be provided to NamedTuple, not both"
):
NamedTuple('Name', [('x', int)], y=str)

with self.assertRaisesRegex(
TypeError,
"Either list of fields or keywords can be provided to NamedTuple, not both"
):
NamedTuple('Name', [], y=str)

with self.assertRaisesRegex(
TypeError,
(
r"Cannot pass `None` as the 'fields' parameter "
r"and also specify fields using keyword arguments"
)
):
NamedTuple('Name', None, x=int)

def test_namedtuple_special_keyword_names(self):
NT = NamedTuple("NT", cls=type, self=object, typename=str, fields=list)
with self.assertWarnsRegex(
DeprecationWarning,
"Creating NamedTuple classes using keyword arguments is deprecated"
):
NT = NamedTuple("NT", cls=type, self=object, typename=str, fields=list)

self.assertEqual(NT.__name__, 'NT')
self.assertEqual(NT._fields, ('cls', 'self', 'typename', 'fields'))
a = NT(cls=str, self=42, typename='foo', fields=[('bar', tuple)])
Expand All @@ -5148,12 +5215,32 @@ def test_namedtuple_special_keyword_names(self):
self.assertEqual(a.fields, [('bar', tuple)])

def test_empty_namedtuple(self):
NT = NamedTuple('NT')
expected_warning = re.escape(
"Failing to pass a value for the 'fields' parameter is deprecated "
"and will be disallowed in Python 3.15. "
"To create a NamedTuple class with 0 fields "
"using the functional syntax, "
"pass an empty list, e.g. `NT1 = NamedTuple('NT1', [])`."
)
with self.assertWarnsRegex(DeprecationWarning, fr"^{expected_warning}$"):
NT1 = NamedTuple('NT1')

expected_warning = re.escape(
"Passing `None` as the 'fields' parameter is deprecated "
"and will be disallowed in Python 3.15. "
"To create a NamedTuple class with 0 fields "
"using the functional syntax, "
"pass an empty list, e.g. `NT2 = NamedTuple('NT2', [])`."
)
with self.assertWarnsRegex(DeprecationWarning, fr"^{expected_warning}$"):
NT2 = NamedTuple('NT2', None)

NT3 = NamedTuple('NT2', [])

class CNT(NamedTuple):
pass # empty body

for struct in [NT, CNT]:
for struct in NT1, NT2, NT3, CNT:
with self.subTest(struct=struct):
self.assertEqual(struct._fields, ())
self.assertEqual(struct.__annotations__, {})
Expand Down Expand Up @@ -5196,7 +5283,6 @@ def test_copy_and_pickle(self):
self.assertIsInstance(jane2, cls)

def test_docstring(self):
self.assertEqual(NamedTuple.__doc__, typing.NamedTuple.__doc__)
self.assertIsInstance(NamedTuple.__doc__, str)

@skipUnless(TYPING_3_8_0, "NamedTuple had a bad signature on <=3.7")
Expand Down
112 changes: 96 additions & 16 deletions src/typing_extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -972,7 +972,7 @@ def __round__(self, ndigits: int = 0) -> T_co:
pass


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

__instancecheck__ = __subclasscheck__

def TypedDict(__typename, __fields=None, *, total=True, **kwargs):
def TypedDict(__typename, __fields=_marker, *, total=True, **kwargs):
"""A simple typed namespace. At runtime it is equivalent to a plain dict.
TypedDict creates a dictionary type that expects all of its
TypedDict creates a dictionary type such that a type checker will expect all
instances to have a certain set of keys, where each key is
associated with a value of a consistent type. This expectation
is not checked at runtime but is only enforced by type checkers.
is not checked at runtime.
Usage::
class Point2D(TypedDict):
Expand All @@ -1103,19 +1105,39 @@ class Point2D(TypedDict):
Point2D = TypedDict('Point2D', {'x': int, 'y': int, 'label': str})
By default, all keys must be present in a TypedDict. It is possible
to override this by specifying totality.
Usage::
to override this by specifying totality::
class point2D(TypedDict, total=False):
class Point2D(TypedDict, total=False):
x: int
y: int
This means that a point2D TypedDict can have any of the keys omitted. A type
This means that a Point2D TypedDict can have any of the keys omitted. A type
checker is only expected to support a literal False or True as the value of
the total argument. True is the default, and makes all items defined in the
class body be required.
The Required and NotRequired special forms can also be used to mark
individual keys as being required or not required::
class Point2D(TypedDict):
x: int # the "x" key must always be present (Required is the default)
y: NotRequired[int] # the "y" key can be omitted
See PEP 655 for more details on Required and NotRequired.
"""
if __fields is None:
if __fields is _marker or __fields is None:
if __fields is _marker:
deprecated_thing = "Failing to pass a value for the 'fields' parameter"
else:
deprecated_thing = "Passing `None` as the 'fields' parameter"

example = f"`{__typename} = TypedDict({__typename!r}, {{}})`"
deprecation_msg = (
f"{deprecated_thing} is deprecated and will be disallowed in "
"Python 3.15. To create a TypedDict class with 0 fields "
"using the functional syntax, pass an empty dictionary, e.g. "
) + example + "."
warnings.warn(deprecation_msg, DeprecationWarning, stacklevel=2)
__fields = kwargs
elif kwargs:
raise TypeError("TypedDict takes either a dict or keyword arguments,"
Expand Down Expand Up @@ -2570,7 +2592,8 @@ def wrapper(*args, **kwargs):
# In 3.11, the ability to define generic `NamedTuple`s was supported.
# This was explicitly disallowed in 3.9-3.10, and only half-worked in <=3.8.
# On 3.12, we added __orig_bases__ to call-based NamedTuples
if sys.version_info >= (3, 12):
# On 3.13, we deprecated kwargs-based NamedTuples
if sys.version_info >= (3, 13):
NamedTuple = typing.NamedTuple
else:
def _make_nmtuple(name, types, module, defaults=()):
Expand Down Expand Up @@ -2614,8 +2637,11 @@ def __new__(cls, typename, bases, ns):
)
nm_tpl.__bases__ = bases
if typing.Generic in bases:
class_getitem = typing.Generic.__class_getitem__.__func__
nm_tpl.__class_getitem__ = classmethod(class_getitem)
if hasattr(typing, '_generic_class_getitem'): # 3.12+
nm_tpl.__class_getitem__ = classmethod(typing._generic_class_getitem)
else:
class_getitem = typing.Generic.__class_getitem__.__func__
nm_tpl.__class_getitem__ = classmethod(class_getitem)
# update from user namespace without overriding special namedtuple attributes
for key in ns:
if key in _prohibited_namedtuple_fields:
Expand All @@ -2626,17 +2652,71 @@ def __new__(cls, typename, bases, ns):
nm_tpl.__init_subclass__()
return nm_tpl

def NamedTuple(__typename, __fields=None, **kwargs):
if __fields is None:
__fields = kwargs.items()
def NamedTuple(__typename, __fields=_marker, **kwargs):
"""Typed version of namedtuple.
Usage::
class Employee(NamedTuple):
name: str
id: int
This is equivalent to::
Employee = collections.namedtuple('Employee', ['name', 'id'])
The resulting class has an extra __annotations__ attribute, giving a
dict that maps field names to types. (The field names are also in
the _fields attribute, which is part of the namedtuple API.)
An alternative equivalent functional syntax is also accepted::
Employee = NamedTuple('Employee', [('name', str), ('id', int)])
"""
if __fields is _marker:
if kwargs:
deprecated_thing = "Creating NamedTuple classes using keyword arguments"
deprecation_msg = (
"{name} is deprecated and will be disallowed in Python {remove}. "
"Use the class-based or functional syntax instead."
)
else:
deprecated_thing = "Failing to pass a value for the 'fields' parameter"
example = f"`{__typename} = NamedTuple({__typename!r}, [])`"
deprecation_msg = (
"{name} is deprecated and will be disallowed in Python {remove}. "
"To create a NamedTuple class with 0 fields "
"using the functional syntax, "
"pass an empty list, e.g. "
) + example + "."
elif __fields is None:
if kwargs:
raise TypeError(
"Cannot pass `None` as the 'fields' parameter "
"and also specify fields using keyword arguments"
)
else:
deprecated_thing = "Passing `None` as the 'fields' parameter"
example = f"`{__typename} = NamedTuple({__typename!r}, [])`"
deprecation_msg = (
"{name} is deprecated and will be disallowed in Python {remove}. "
"To create a NamedTuple class with 0 fields "
"using the functional syntax, "
"pass an empty list, e.g. "
) + example + "."
elif kwargs:
raise TypeError("Either list of fields or keywords"
" can be provided to NamedTuple, not both")
if __fields is _marker or __fields is None:
warnings.warn(
deprecation_msg.format(name=deprecated_thing, remove="3.15"),
DeprecationWarning,
stacklevel=2,
)
__fields = kwargs.items()
nt = _make_nmtuple(__typename, __fields, module=_caller())
nt.__orig_bases__ = (NamedTuple,)
return nt

NamedTuple.__doc__ = typing.NamedTuple.__doc__
_NamedTuple = type.__new__(_NamedTupleMeta, 'NamedTuple', (), {})

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

0 comments on commit bc9ce4f

Please sign in to comment.