From 2bf31ccab3d17f3f35b42dca97f99576dfe2fc7d Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Tue, 17 Sep 2019 21:22:00 +0300 Subject: [PATCH] bpo-38191: Accept arbitrary keyword names in NamedTuple() and TypedDict(). (GH-16222) This includes such names as "cls", "self", "typename", "_typename", "fields" and "_fields". Passing positional arguments by keyword is deprecated. --- Lib/test/test_typing.py | 60 +++++++++++++++ Lib/typing.py | 77 +++++++++++++++++-- .../2019-09-17-12-28-27.bpo-38191.1TU0HV.rst | 4 + 3 files changed, 133 insertions(+), 8 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2019-09-17-12-28-27.bpo-38191.1TU0HV.rst diff --git a/Lib/test/test_typing.py b/Lib/test/test_typing.py index 59b1b4e6d54..5914f314db0 100644 --- a/Lib/test/test_typing.py +++ b/Lib/test/test_typing.py @@ -3563,6 +3563,36 @@ class XMethBad2(NamedTuple): with self.assertRaises(TypeError): NamedTuple('Name', x=1, y='a') + def test_namedtuple_special_keyword_names(self): + 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)]) + self.assertEqual(a.cls, str) + self.assertEqual(a.self, 42) + self.assertEqual(a.typename, 'foo') + self.assertEqual(a.fields, [('bar', tuple)]) + + def test_namedtuple_errors(self): + with self.assertRaises(TypeError): + NamedTuple.__new__() + with self.assertRaises(TypeError): + NamedTuple() + with self.assertRaises(TypeError): + NamedTuple('Emp', [('name', str)], None) + with self.assertRaises(ValueError): + NamedTuple('Emp', [('_name', str)]) + + with self.assertWarns(DeprecationWarning): + Emp = NamedTuple(typename='Emp', name=str, id=int) + self.assertEqual(Emp.__name__, 'Emp') + self.assertEqual(Emp._fields, ('name', 'id')) + + with self.assertWarns(DeprecationWarning): + Emp = NamedTuple('Emp', fields=[('name', str), ('id', int)]) + self.assertEqual(Emp.__name__, 'Emp') + self.assertEqual(Emp._fields, ('name', 'id')) + def test_pickle(self): global Emp # pickle wants to reference the class by name Emp = NamedTuple('Emp', [('name', str), ('id', int)]) @@ -3604,6 +3634,36 @@ class TypedDictTests(BaseTestCase): self.assertEqual(Emp.__annotations__, {'name': str, 'id': int}) self.assertEqual(Emp.__total__, True) + def test_typeddict_special_keyword_names(self): + TD = TypedDict("TD", cls=type, self=object, typename=str, _typename=int, fields=list, _fields=dict) + self.assertEqual(TD.__name__, 'TD') + self.assertEqual(TD.__annotations__, {'cls': type, 'self': object, 'typename': str, '_typename': int, 'fields': list, '_fields': dict}) + a = TD(cls=str, self=42, typename='foo', _typename=53, fields=[('bar', tuple)], _fields={'baz', set}) + self.assertEqual(a['cls'], str) + self.assertEqual(a['self'], 42) + self.assertEqual(a['typename'], 'foo') + self.assertEqual(a['_typename'], 53) + self.assertEqual(a['fields'], [('bar', tuple)]) + self.assertEqual(a['_fields'], {'baz', set}) + + def test_typeddict_create_errors(self): + with self.assertRaises(TypeError): + TypedDict.__new__() + with self.assertRaises(TypeError): + TypedDict() + with self.assertRaises(TypeError): + TypedDict('Emp', [('name', str)], None) + + with self.assertWarns(DeprecationWarning): + Emp = TypedDict(_typename='Emp', name=str, id=int) + self.assertEqual(Emp.__name__, 'Emp') + self.assertEqual(Emp.__annotations__, {'name': str, 'id': int}) + + with self.assertWarns(DeprecationWarning): + Emp = TypedDict('Emp', _fields={'name': str, 'id': int}) + self.assertEqual(Emp.__name__, 'Emp') + self.assertEqual(Emp.__annotations__, {'name': str, 'id': int}) + def test_typeddict_errors(self): Emp = TypedDict('Emp', {'name': str, 'id': int}) self.assertEqual(TypedDict.__module__, 'typing') diff --git a/Lib/typing.py b/Lib/typing.py index 32011332127..43486a7c8ba 100644 --- a/Lib/typing.py +++ b/Lib/typing.py @@ -1653,35 +1653,96 @@ class NamedTuple(metaclass=NamedTupleMeta): """ _root = True - def __new__(self, typename, fields=None, **kwargs): + def __new__(*args, **kwargs): + if not args: + raise TypeError('NamedTuple.__new__(): not enough arguments') + cls, *args = args # allow the "cls" keyword be passed + if args: + typename, *args = args # allow the "typename" keyword be passed + elif 'typename' in kwargs: + typename = kwargs.pop('typename') + import warnings + warnings.warn("Passing 'typename' as keyword argument is deprecated", + DeprecationWarning, stacklevel=2) + else: + raise TypeError("NamedTuple.__new__() missing 1 required positional " + "argument: 'typename'") + if args: + try: + fields, = args # allow the "fields" keyword be passed + except ValueError: + raise TypeError(f'NamedTuple.__new__() takes from 2 to 3 ' + f'positional arguments but {len(args) + 2} ' + f'were given') from None + elif 'fields' in kwargs and len(kwargs) == 1: + fields = kwargs.pop('fields') + import warnings + warnings.warn("Passing 'fields' as keyword argument is deprecated", + DeprecationWarning, stacklevel=2) + else: + fields = None + if fields is None: fields = kwargs.items() elif kwargs: raise TypeError("Either list of fields or keywords" " can be provided to NamedTuple, not both") return _make_nmtuple(typename, fields) + __new__.__text_signature__ = '($cls, typename, fields=None, /, **kwargs)' -def _dict_new(cls, *args, **kwargs): +def _dict_new(*args, **kwargs): + if not args: + raise TypeError('TypedDict.__new__(): not enough arguments') + cls, *args = args # allow the "cls" keyword be passed return dict(*args, **kwargs) +_dict_new.__text_signature__ = '($cls, _typename, _fields=None, /, **kwargs)' -def _typeddict_new(cls, _typename, _fields=None, **kwargs): - total = kwargs.pop('total', True) - if _fields is None: - _fields = kwargs +def _typeddict_new(*args, total=True, **kwargs): + if not args: + raise TypeError('TypedDict.__new__(): not enough arguments') + cls, *args = args # allow the "cls" keyword be passed + if args: + typename, *args = args # allow the "_typename" keyword be passed + elif '_typename' in kwargs: + typename = kwargs.pop('_typename') + import warnings + warnings.warn("Passing '_typename' as keyword argument is deprecated", + DeprecationWarning, stacklevel=2) + else: + raise TypeError("TypedDict.__new__() missing 1 required positional " + "argument: '_typename'") + if args: + try: + fields, = args # allow the "_fields" keyword be passed + except ValueError: + raise TypeError(f'TypedDict.__new__() takes from 2 to 3 ' + f'positional arguments but {len(args) + 2} ' + f'were given') from None + elif '_fields' in kwargs and len(kwargs) == 1: + fields = kwargs.pop('_fields') + import warnings + warnings.warn("Passing '_fields' as keyword argument is deprecated", + DeprecationWarning, stacklevel=2) + else: + fields = None + + if fields is None: + fields = kwargs elif kwargs: raise TypeError("TypedDict takes either a dict or keyword arguments," " but not both") - ns = {'__annotations__': dict(_fields), '__total__': total} + ns = {'__annotations__': dict(fields), '__total__': total} try: # Setting correct module is necessary to make typed dict classes pickleable. ns['__module__'] = sys._getframe(1).f_globals.get('__name__', '__main__') except (AttributeError, ValueError): pass - return _TypedDictMeta(_typename, (), ns) + return _TypedDictMeta(typename, (), ns) +_typeddict_new.__text_signature__ = '($cls, _typename, _fields=None, /, *, total=True, **kwargs)' def _check_fails(cls, other): diff --git a/Misc/NEWS.d/next/Library/2019-09-17-12-28-27.bpo-38191.1TU0HV.rst b/Misc/NEWS.d/next/Library/2019-09-17-12-28-27.bpo-38191.1TU0HV.rst new file mode 100644 index 00000000000..1a6de60e46c --- /dev/null +++ b/Misc/NEWS.d/next/Library/2019-09-17-12-28-27.bpo-38191.1TU0HV.rst @@ -0,0 +1,4 @@ +Constructors of :class:`~typing.NamedTuple` and :class:`~typing.TypedDict` +types now accept arbitrary keyword argument names, including "cls", "self", +"typename", "_typename", "fields" and "_fields". Passing positional +arguments by keyword is deprecated.