Skip to content

Commit dd00995

Browse files
authored
Accept arbitrary keyword names in NamedTuple() and TypedDict() (#686)
Fixes #677 This backports python/cpython#16222 (Python 3 only). The Python 2 backport is more tricky so I skip it for now.
1 parent c943372 commit dd00995

File tree

4 files changed

+140
-9
lines changed

4 files changed

+140
-9
lines changed

src/test_typing.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2513,6 +2513,38 @@ def test_namedtuple_keyword_usage(self):
25132513
with self.assertRaises(TypeError):
25142514
NamedTuple('Name', x=1, y='a')
25152515

2516+
@skipUnless(PY36, 'Python 3.6 required')
2517+
def test_namedtuple_special_keyword_names(self):
2518+
NT = NamedTuple("NT", cls=type, self=object, typename=str, fields=list)
2519+
self.assertEqual(NT.__name__, 'NT')
2520+
self.assertEqual(NT._fields, ('cls', 'self', 'typename', 'fields'))
2521+
a = NT(cls=str, self=42, typename='foo', fields=[('bar', tuple)])
2522+
self.assertEqual(a.cls, str)
2523+
self.assertEqual(a.self, 42)
2524+
self.assertEqual(a.typename, 'foo')
2525+
self.assertEqual(a.fields, [('bar', tuple)])
2526+
2527+
@skipUnless(PY36, 'Python 3.6 required')
2528+
def test_namedtuple_errors(self):
2529+
with self.assertRaises(TypeError):
2530+
NamedTuple.__new__()
2531+
with self.assertRaises(TypeError):
2532+
NamedTuple()
2533+
with self.assertRaises(TypeError):
2534+
NamedTuple('Emp', [('name', str)], None)
2535+
with self.assertRaises(ValueError):
2536+
NamedTuple('Emp', [('_name', str)])
2537+
2538+
with self.assertWarns(DeprecationWarning):
2539+
Emp = NamedTuple(typename='Emp', name=str, id=int)
2540+
self.assertEqual(Emp.__name__, 'Emp')
2541+
self.assertEqual(Emp._fields, ('name', 'id'))
2542+
2543+
with self.assertWarns(DeprecationWarning):
2544+
Emp = NamedTuple('Emp', fields=[('name', str), ('id', int)])
2545+
self.assertEqual(Emp.__name__, 'Emp')
2546+
self.assertEqual(Emp._fields, ('name', 'id'))
2547+
25162548
def test_pickle(self):
25172549
global Emp # pickle wants to reference the class by name
25182550
Emp = NamedTuple('Emp', [('name', str), ('id', int)])

src/typing.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2204,17 +2204,47 @@ class Employee(NamedTuple):
22042204
"""
22052205
_root = True
22062206

2207-
def __new__(self, typename, fields=None, **kwargs):
2207+
def __new__(*args, **kwargs):
22082208
if kwargs and not _PY36:
22092209
raise TypeError("Keyword syntax for NamedTuple is only supported"
22102210
" in Python 3.6+")
2211+
if not args:
2212+
raise TypeError('NamedTuple.__new__(): not enough arguments')
2213+
_, args = args[0], args[1:] # allow the "cls" keyword be passed
2214+
if args:
2215+
typename, args = args[0], args[1:] # allow the "typename" keyword be passed
2216+
elif 'typename' in kwargs:
2217+
typename = kwargs.pop('typename')
2218+
import warnings
2219+
warnings.warn("Passing 'typename' as keyword argument is deprecated",
2220+
DeprecationWarning, stacklevel=2)
2221+
else:
2222+
raise TypeError("NamedTuple.__new__() missing 1 required positional "
2223+
"argument: 'typename'")
2224+
if args:
2225+
try:
2226+
fields, = args # allow the "fields" keyword be passed
2227+
except ValueError:
2228+
raise TypeError('NamedTuple.__new__() takes from 2 to 3 '
2229+
'positional arguments but {} '
2230+
'were given'.format(len(args) + 2))
2231+
elif 'fields' in kwargs and len(kwargs) == 1:
2232+
fields = kwargs.pop('fields')
2233+
import warnings
2234+
warnings.warn("Passing 'fields' as keyword argument is deprecated",
2235+
DeprecationWarning, stacklevel=2)
2236+
else:
2237+
fields = None
2238+
22112239
if fields is None:
22122240
fields = kwargs.items()
22132241
elif kwargs:
22142242
raise TypeError("Either list of fields or keywords"
22152243
" can be provided to NamedTuple, not both")
22162244
return _make_nmtuple(typename, fields)
22172245

2246+
__new__.__text_signature__ = '($cls, typename, fields=None, /, **kwargs)'
2247+
22182248

22192249
def NewType(name, tp):
22202250
"""NewType creates simple unique types with almost zero

typing_extensions/src_py3/test_typing_extensions.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import pickle
77
import subprocess
88
import types
9-
from unittest import TestCase, main, skipUnless
9+
from unittest import TestCase, main, skipUnless, skipIf
1010
from typing import TypeVar, Optional
1111
from typing import T, KT, VT # Not in __all__.
1212
from typing import Tuple, List, Dict, Iterator
@@ -1445,6 +1445,40 @@ def test_basics_keywords_syntax(self):
14451445
self.assertEqual(Emp.__annotations__, {'name': str, 'id': int})
14461446
self.assertEqual(Emp.__total__, True)
14471447

1448+
def test_typeddict_special_keyword_names(self):
1449+
TD = TypedDict("TD", cls=type, self=object, typename=str, _typename=int,
1450+
fields=list, _fields=dict)
1451+
self.assertEqual(TD.__name__, 'TD')
1452+
self.assertEqual(TD.__annotations__, {'cls': type, 'self': object, 'typename': str,
1453+
'_typename': int, 'fields': list, '_fields': dict})
1454+
a = TD(cls=str, self=42, typename='foo', _typename=53,
1455+
fields=[('bar', tuple)], _fields={'baz', set})
1456+
self.assertEqual(a['cls'], str)
1457+
self.assertEqual(a['self'], 42)
1458+
self.assertEqual(a['typename'], 'foo')
1459+
self.assertEqual(a['_typename'], 53)
1460+
self.assertEqual(a['fields'], [('bar', tuple)])
1461+
self.assertEqual(a['_fields'], {'baz', set})
1462+
1463+
@skipIf(hasattr(typing, 'TypedDict'), "Should be tested by upstream")
1464+
def test_typeddict_create_errors(self):
1465+
with self.assertRaises(TypeError):
1466+
TypedDict.__new__()
1467+
with self.assertRaises(TypeError):
1468+
TypedDict()
1469+
with self.assertRaises(TypeError):
1470+
TypedDict('Emp', [('name', str)], None)
1471+
1472+
with self.assertWarns(DeprecationWarning):
1473+
Emp = TypedDict(_typename='Emp', name=str, id=int)
1474+
self.assertEqual(Emp.__name__, 'Emp')
1475+
self.assertEqual(Emp.__annotations__, {'name': str, 'id': int})
1476+
1477+
with self.assertWarns(DeprecationWarning):
1478+
Emp = TypedDict('Emp', _fields={'name': str, 'id': int})
1479+
self.assertEqual(Emp.__name__, 'Emp')
1480+
self.assertEqual(Emp.__annotations__, {'name': str, 'id': int})
1481+
14481482
def test_typeddict_errors(self):
14491483
Emp = TypedDict('Emp', {'name': str, 'id': int})
14501484
if hasattr(typing, 'TypedDict'):

typing_extensions/src_py3/typing_extensions.py

Lines changed: 42 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1583,25 +1583,60 @@ def _check_fails(cls, other):
15831583
pass
15841584
return False
15851585

1586-
def _dict_new(cls, *args, **kwargs):
1586+
def _dict_new(*args, **kwargs):
1587+
if not args:
1588+
raise TypeError('TypedDict.__new__(): not enough arguments')
1589+
_, args = args[0], args[1:] # allow the "cls" keyword be passed
15871590
return dict(*args, **kwargs)
15881591

1589-
def _typeddict_new(cls, _typename, _fields=None, **kwargs):
1590-
total = kwargs.pop('total', True)
1591-
if _fields is None:
1592-
_fields = kwargs
1592+
_dict_new.__text_signature__ = '($cls, _typename, _fields=None, /, **kwargs)'
1593+
1594+
def _typeddict_new(*args, total=True, **kwargs):
1595+
if not args:
1596+
raise TypeError('TypedDict.__new__(): not enough arguments')
1597+
_, args = args[0], args[1:] # allow the "cls" keyword be passed
1598+
if args:
1599+
typename, args = args[0], args[1:] # allow the "_typename" keyword be passed
1600+
elif '_typename' in kwargs:
1601+
typename = kwargs.pop('_typename')
1602+
import warnings
1603+
warnings.warn("Passing '_typename' as keyword argument is deprecated",
1604+
DeprecationWarning, stacklevel=2)
1605+
else:
1606+
raise TypeError("TypedDict.__new__() missing 1 required positional "
1607+
"argument: '_typename'")
1608+
if args:
1609+
try:
1610+
fields, = args # allow the "_fields" keyword be passed
1611+
except ValueError:
1612+
raise TypeError('TypedDict.__new__() takes from 2 to 3 '
1613+
'positional arguments but {} '
1614+
'were given'.format(len(args) + 2))
1615+
elif '_fields' in kwargs and len(kwargs) == 1:
1616+
fields = kwargs.pop('_fields')
1617+
import warnings
1618+
warnings.warn("Passing '_fields' as keyword argument is deprecated",
1619+
DeprecationWarning, stacklevel=2)
1620+
else:
1621+
fields = None
1622+
1623+
if fields is None:
1624+
fields = kwargs
15931625
elif kwargs:
15941626
raise TypeError("TypedDict takes either a dict or keyword arguments,"
15951627
" but not both")
15961628

1597-
ns = {'__annotations__': dict(_fields), '__total__': total}
1629+
ns = {'__annotations__': dict(fields), '__total__': total}
15981630
try:
15991631
# Setting correct module is necessary to make typed dict classes pickleable.
16001632
ns['__module__'] = sys._getframe(1).f_globals.get('__name__', '__main__')
16011633
except (AttributeError, ValueError):
16021634
pass
16031635

1604-
return _TypedDictMeta(_typename, (), ns)
1636+
return _TypedDictMeta(typename, (), ns)
1637+
1638+
_typeddict_new.__text_signature__ = ('($cls, _typename, _fields=None,'
1639+
' /, *, total=True, **kwargs)')
16051640

16061641
class _TypedDictMeta(type):
16071642
def __new__(cls, name, bases, ns, total=True):

0 commit comments

Comments
 (0)