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

Backport NamedTuple and TypedDict deprecations from Python 3.13 #240

Merged
merged 7 commits into from
Jun 16, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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)
Comment on lines 5285 to 5286
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tbh this test method feels pretty redundant now; I'd just delete the whole method :p

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It still tests that we have some docstring :)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd hope we'd catch it in manual review if somebody proposed deleting the entire docstring in a PR 😄

When I added this test method, my intent was just to test that the monkeypatching in the backport was working correctly. But I guess we can keep it; it doesn't do any harm :)

The PR LGTM!


@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
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I took this opportunity to copy-paste the docstring from CPython.

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.
Copy link
Member

@AlexWaygood AlexWaygood Jun 16, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we're adding a docstring the normal way here, we no longer need to monkey-patch it onto the function afterwards:

NamedTuple.__doc__ = typing.NamedTuple.__doc__

If you get rid of the docstring monkey-patching, you'll need to ditch this test, as it'll start to fail:

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

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh I missed that. I think it's better if we present the same docstring across all versions, so I'll remove the monkey-patching.


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