From 9e181104784d02845eb25713b2432c2bb65a05c4 Mon Sep 17 00:00:00 2001 From: AlexWaygood Date: Fri, 20 May 2022 00:17:46 +0100 Subject: [PATCH 01/20] Backport generic `NamedTuple`s --- src/test_typing_extensions.py | 241 +++++++++++++++++++++++++++++++++- src/typing_extensions.py | 79 +++++++++++ 2 files changed, 318 insertions(+), 2 deletions(-) diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index 7f14f3f9..03153af7 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -5,6 +5,7 @@ import collections from collections import defaultdict import collections.abc +import copy from functools import lru_cache import inspect import pickle @@ -17,7 +18,7 @@ from typing import TypeVar, Optional, Union, Any, AnyStr from typing import T, KT, VT # Not in __all__. from typing import Tuple, List, Dict, Iterable, Iterator, Callable -from typing import Generic, NamedTuple +from typing import Generic from typing import no_type_check import typing_extensions from typing_extensions import NoReturn, ClassVar, Final, IntVar, Literal, Type, NewType, TypedDict, Self @@ -27,6 +28,7 @@ from typing_extensions import TypeVarTuple, Unpack, dataclass_transform, reveal_type, Never, assert_never, LiteralString from typing_extensions import assert_type, get_type_hints, get_origin, get_args from typing_extensions import clear_overloads, get_overloads, overload +from typing_extensions import NamedTuple # Flags used to mark tests that only apply after a specific # version of the typing module. @@ -2874,7 +2876,7 @@ def test_typing_extensions_defers_when_possible(self): if sys.version_info < (3, 10): exclude |= {'get_args', 'get_origin'} if sys.version_info < (3, 11): - exclude.add('final') + exclude |= {'final', 'NamedTuple'} for item in typing_extensions.__all__: if item not in exclude and hasattr(typing, item): self.assertIs( @@ -2892,6 +2894,241 @@ def test_typing_extensions_compiles_with_opt(self): self.fail('Module does not compile with optimize=2 (-OO flag).') +class CoolEmployee(NamedTuple): + name: str + cool: int + + +class CoolEmployeeWithDefault(NamedTuple): + name: str + cool: int = 0 + + +class XMeth(NamedTuple): + x: int + def double(self): + return 2 * self.x + + +class XRepr(NamedTuple): + x: int + y: int = 1 + def __str__(self): + return f'{self.x} -> {self.y}' + def __add__(self, other): + return 0 + + +class NamedTupleTests(BaseTestCase): + class NestedEmployee(NamedTuple): + name: str + cool: int + + def test_basics(self): + Emp = NamedTuple('Emp', [('name', str), ('id', int)]) + self.assertIsSubclass(Emp, tuple) + joe = Emp('Joe', 42) + jim = Emp(name='Jim', id=1) + self.assertIsInstance(joe, Emp) + self.assertIsInstance(joe, tuple) + self.assertEqual(joe.name, 'Joe') + self.assertEqual(joe.id, 42) + self.assertEqual(jim.name, 'Jim') + self.assertEqual(jim.id, 1) + self.assertEqual(Emp.__name__, 'Emp') + self.assertEqual(Emp._fields, ('name', 'id')) + self.assertEqual(Emp.__annotations__, + collections.OrderedDict([('name', str), ('id', int)])) + + def test_annotation_usage(self): + tim = CoolEmployee('Tim', 9000) + self.assertIsInstance(tim, CoolEmployee) + self.assertIsInstance(tim, tuple) + self.assertEqual(tim.name, 'Tim') + self.assertEqual(tim.cool, 9000) + self.assertEqual(CoolEmployee.__name__, 'CoolEmployee') + self.assertEqual(CoolEmployee._fields, ('name', 'cool')) + self.assertEqual(CoolEmployee.__annotations__, + collections.OrderedDict(name=str, cool=int)) + + def test_annotation_usage_with_default(self): + jelle = CoolEmployeeWithDefault('Jelle') + self.assertIsInstance(jelle, CoolEmployeeWithDefault) + self.assertIsInstance(jelle, tuple) + self.assertEqual(jelle.name, 'Jelle') + self.assertEqual(jelle.cool, 0) + cooler_employee = CoolEmployeeWithDefault('Sjoerd', 1) + self.assertEqual(cooler_employee.cool, 1) + + self.assertEqual(CoolEmployeeWithDefault.__name__, 'CoolEmployeeWithDefault') + self.assertEqual(CoolEmployeeWithDefault._fields, ('name', 'cool')) + self.assertEqual(CoolEmployeeWithDefault.__annotations__, + dict(name=str, cool=int)) + self.assertEqual(CoolEmployeeWithDefault._field_defaults, dict(cool=0)) + + with self.assertRaises(TypeError): + class NonDefaultAfterDefault(NamedTuple): + x: int = 3 + y: int + + def test_annotation_usage_with_methods(self): + self.assertEqual(XMeth(1).double(), 2) + self.assertEqual(XMeth(42).x, XMeth(42)[0]) + self.assertEqual(str(XRepr(42)), '42 -> 1') + self.assertEqual(XRepr(1, 2) + XRepr(3), 0) + + with self.assertRaises(AttributeError): + class XMethBad(NamedTuple): + x: int + def _fields(self): + return 'no chance for this' + + with self.assertRaises(AttributeError): + class XMethBad2(NamedTuple): + x: int + def _source(self): + return 'no chance for this as well' + + def test_multiple_inheritance(self): + class A: + pass + with self.assertRaises(TypeError): + class X(NamedTuple, A): + x: int + with self.assertRaises(TypeError): + class X(NamedTuple, tuple): + x: int + with self.assertRaises(TypeError): + class X(NamedTuple, NamedTuple): + x: int + class A(NamedTuple): + x: int + with self.assertRaises(TypeError): + class X(NamedTuple, A): + y: str + + def test_generic(self): + class X(NamedTuple, Generic[T]): + x: T + self.assertEqual(X.__bases__, (tuple, Generic)) + self.assertEqual(X.__orig_bases__, (NamedTuple, Generic[T])) + self.assertEqual(X.__mro__, (X, tuple, Generic, object)) + + class Y(Generic[T], NamedTuple): + x: T + self.assertEqual(Y.__bases__, (Generic, tuple)) + self.assertEqual(Y.__orig_bases__, (Generic[T], NamedTuple)) + self.assertEqual(Y.__mro__, (Y, Generic, tuple, object)) + + for G in X, Y: + with self.subTest(type=G): + self.assertEqual(G.__parameters__, (T,)) + A = G[int] + self.assertIs(A.__origin__, G) + self.assertEqual(A.__args__, (int,)) + self.assertEqual(A.__parameters__, ()) + + a = A(3) + self.assertIs(type(a), G) + self.assertEqual(a.x, 3) + + with self.assertRaises(TypeError): + G[int, str] + + if sys.version_info >= (3, 9): + def test_non_generic_subscript(self): + # For backward compatibility, subscription works + # on arbitrary NamedTuple types. + class Group(NamedTuple): + key: T + group: list[T] + A = Group[int] + self.assertEqual(A.__origin__, Group) + self.assertEqual(A.__parameters__, ()) + self.assertEqual(A.__args__, (int,)) + a = A(1, [2]) + self.assertIs(type(a), Group) + self.assertEqual(a, (1, [2])) + + def test_namedtuple_keyword_usage(self): + 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.assertRaises(TypeError): + NamedTuple('Name', [('x', int)], y=str) + + 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_empty_namedtuple(self): + NT = NamedTuple('NT') + + class CNT(NamedTuple): + pass # empty body + + for struct in [NT, CNT]: + with self.subTest(struct=struct): + self.assertEqual(struct._fields, ()) + self.assertEqual(struct._field_defaults, {}) + self.assertEqual(struct.__annotations__, {}) + self.assertIsInstance(struct(), struct) + + 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.assertRaises(TypeError): + NamedTuple(typename='Emp', name=str, id=int) + + def test_copy_and_pickle(self): + global Emp # pickle wants to reference the class by name + Emp = NamedTuple('Emp', [('name', str), ('cool', int)]) + for cls in Emp, CoolEmployee, self.NestedEmployee: + with self.subTest(cls=cls): + jane = cls('jane', 37) + for proto in range(pickle.HIGHEST_PROTOCOL + 1): + z = pickle.dumps(jane, proto) + jane2 = pickle.loads(z) + self.assertEqual(jane2, jane) + self.assertIsInstance(jane2, cls) + + jane2 = copy.copy(jane) + self.assertEqual(jane2, jane) + self.assertIsInstance(jane2, cls) + + jane2 = copy.deepcopy(jane) + self.assertEqual(jane2, jane) + self.assertIsInstance(jane2, cls) + + def test_compatibility(self): + self.assertEqual(NamedTuple.__doc__, typing.NamedTuple.__doc__) + + if sys.version_info >= (3, 9): + self.assertEqual(set(dir(NamedTuple)), set(dir(typing.NamedTuple))) + self.assertIs(type(NamedTuple), type(typing.NamedTuple)) + else: + # _field_types was removed in 3.9 + self.assertEqual( + self.NestedEmployee.__annotations__, + self.NestedEmployee._field_types + ) + if __name__ == '__main__': main() diff --git a/src/typing_extensions.py b/src/typing_extensions.py index dc038819..39b444ec 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -37,6 +37,7 @@ 'Counter', 'Deque', 'DefaultDict', + 'NamedTuple', 'OrderedDict', 'TypedDict', @@ -1958,3 +1959,81 @@ def decorator(cls_or_fn): if not hasattr(typing, "TypeVarTuple"): typing._collect_type_vars = _collect_type_vars typing._check_generic = _check_generic + + +if sys.version_info >= (3, 11): + NamedTuple = typing.NamedTuple +else: + def _caller(depth=1, default='__main__'): + try: + return sys._getframe(depth + 1).f_globals.get('__name__', default) + except (AttributeError, ValueError): # For platforms without _getframe() + return None + + def _make_nmtuple(name, types, module, defaults = ()): + fields = [n for n, t in types] + types = {n: typing._type_check(t, f"field {n} annotation must be a type") + for n, t in types} + nm_tpl = collections.namedtuple(name, fields, + defaults=defaults, module=module) + nm_tpl.__annotations__ = nm_tpl.__new__.__annotations__ = types + if sys.version_info < (3, 9): + nm_tpl._field_types = nm_tpl.__annotations__ + return nm_tpl + + _prohibited_namedtuple_fields = typing._prohibited + _special_namedtuple_fields = typing._special + + class _NamedTupleMeta(type): + def __new__(cls, typename, bases, ns): + assert _NamedTuple in bases + for base in bases: + if base is not _NamedTuple and base is not typing.Generic: + raise TypeError( + 'can only inherit from a NamedTuple type and Generic') + bases = tuple(tuple if base is _NamedTuple else base for base in bases) + types = ns.get('__annotations__', {}) + default_names = [] + for field_name in types: + if field_name in ns: + default_names.append(field_name) + elif default_names: + raise TypeError(f"Non-default namedtuple field {field_name} " + f"cannot follow default field" + f"{'s' if len(default_names) > 1 else ''} " + f"{', '.join(default_names)}") + nm_tpl = _make_nmtuple( + typename, types.items(), + defaults=[ns[n] for n in default_names], + module=ns['__module__'] + ) + nm_tpl.__bases__ = bases + if typing.Generic in bases: + 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: + raise AttributeError("Cannot overwrite NamedTuple attribute " + key) + elif key not in _special_namedtuple_fields and key not in nm_tpl._fields: + setattr(nm_tpl, key, ns[key]) + if typing.Generic in bases: + nm_tpl.__init_subclass__() + return nm_tpl + + def NamedTuple(__typename, __fields=None, **kwargs): + 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, module=_caller()) + + NamedTuple.__doc__ = typing.NamedTuple.__doc__ + _NamedTuple = type.__new__(_NamedTupleMeta, 'NamedTuple', (), {}) + + def _namedtuple_mro_entries(bases): + assert NamedTuple in bases + return (_NamedTuple,) + + NamedTuple.__mro_entries__ = _namedtuple_mro_entries From 394b0159313773b6decaddaa1f1fb9beaa5debba Mon Sep 17 00:00:00 2001 From: AlexWaygood Date: Fri, 20 May 2022 16:56:30 +0100 Subject: [PATCH 02/20] Improve --- src/test_typing_extensions.py | 1 + src/typing_extensions.py | 30 +++++++++++++++++++++++------- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index 03153af7..6656c0d4 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -3121,6 +3121,7 @@ def test_compatibility(self): if sys.version_info >= (3, 9): self.assertEqual(set(dir(NamedTuple)), set(dir(typing.NamedTuple))) + self.assertEqual(inspect.signature(NamedTuple), inspect.signature(typing.NamedTuple)) self.assertIs(type(NamedTuple), type(typing.NamedTuple)) else: # _field_types was removed in 3.9 diff --git a/src/typing_extensions.py b/src/typing_extensions.py index 39b444ec..bc2095bc 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -1977,6 +1977,8 @@ def _make_nmtuple(name, types, module, defaults = ()): nm_tpl = collections.namedtuple(name, fields, defaults=defaults, module=module) nm_tpl.__annotations__ = nm_tpl.__new__.__annotations__ = types + # The `_field_types` attribute was removed in 3.9; + # in earlier versions, it is the same as the `__annotations__` attribute if sys.version_info < (3, 9): nm_tpl._field_types = nm_tpl.__annotations__ return nm_tpl @@ -2021,13 +2023,27 @@ 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() - elif kwargs: - raise TypeError("Either list of fields or keywords" - " can be provided to NamedTuple, not both") - return _make_nmtuple(__typename, __fields, module=_caller()) + _bad_args_error_message = ( + "Either list of fields or keywords can be provided to NamedTuple, not both" + ) + + # Match the signature of typing.NamedTuple on 3.9+ exactly where possible, + # working around the fact that syntax for positional-only arguments + # is only available on 3.8+ + if sys.version_info >= (3, 8): + def NamedTuple(typename, fields=None, /, **kwargs): + if fields is None: + fields = kwargs.items() + elif kwargs: + raise TypeError(_bad_args_error_message) + return _make_nmtuple(typename, fields, module=_caller()) + else: + def NamedTuple(__typename, __fields=None, **kwargs): + if __fields is None: + __fields = kwargs.items() + elif kwargs: + raise TypeError(_bad_args_error_message) + return _make_nmtuple(__typename, __fields, module=_caller()) NamedTuple.__doc__ = typing.NamedTuple.__doc__ _NamedTuple = type.__new__(_NamedTupleMeta, 'NamedTuple', (), {}) From b678b110a86177e8b8b7d5b2bcbe6d0f4b071bb8 Mon Sep 17 00:00:00 2001 From: AlexWaygood Date: Fri, 20 May 2022 17:23:05 +0100 Subject: [PATCH 03/20] Improve more --- src/test_typing_extensions.py | 55 +++++++++++++++++++---------------- src/typing_extensions.py | 29 +++++------------- 2 files changed, 38 insertions(+), 46 deletions(-) diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index 6656c0d4..2b9838bf 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -3035,20 +3035,20 @@ class Y(Generic[T], NamedTuple): with self.assertRaises(TypeError): G[int, str] - if sys.version_info >= (3, 9): - def test_non_generic_subscript(self): - # For backward compatibility, subscription works - # on arbitrary NamedTuple types. - class Group(NamedTuple): - key: T - group: list[T] - A = Group[int] - self.assertEqual(A.__origin__, Group) - self.assertEqual(A.__parameters__, ()) - self.assertEqual(A.__args__, (int,)) - a = A(1, [2]) - self.assertIs(type(a), Group) - self.assertEqual(a, (1, [2])) + @skipIf(sys.version_info < (3, 9), "tuple.__class_getitem__ was added in 3.9") + def test_non_generic_subscript(self): + # For backward compatibility, subscription works + # on arbitrary NamedTuple types. + class Group(NamedTuple): + key: T + group: list[T] + A = Group[int] + self.assertEqual(A.__origin__, Group) + self.assertEqual(A.__parameters__, ()) + self.assertEqual(A.__args__, (int,)) + a = A(1, [2]) + self.assertIs(type(a), Group) + self.assertEqual(a, (1, [2])) def test_namedtuple_keyword_usage(self): LocalEmployee = NamedTuple("LocalEmployee", name=str, age=int) @@ -3116,19 +3116,24 @@ def test_copy_and_pickle(self): self.assertEqual(jane2, jane) self.assertIsInstance(jane2, cls) - def test_compatibility(self): + def test_docstring(self): self.assertEqual(NamedTuple.__doc__, typing.NamedTuple.__doc__) - if sys.version_info >= (3, 9): - self.assertEqual(set(dir(NamedTuple)), set(dir(typing.NamedTuple))) - self.assertEqual(inspect.signature(NamedTuple), inspect.signature(typing.NamedTuple)) - self.assertIs(type(NamedTuple), type(typing.NamedTuple)) - else: - # _field_types was removed in 3.9 - self.assertEqual( - self.NestedEmployee.__annotations__, - self.NestedEmployee._field_types - ) + @skipIf(sys.version_info < (3, 9), "NamedTuple was a class on 3.8 and lower") + def test_same_as_typing_NamedTuple(self): + self.assertEqual( + set(dir(NamedTuple)), + set(dir(typing.NamedTuple)) | {"__text_signature__"} + ) + self.assertEqual(inspect.signature(NamedTuple), inspect.signature(typing.NamedTuple)) + self.assertIs(type(NamedTuple), type(typing.NamedTuple)) + + @skipIf(sys.version_info >= (3, 9), "_field_types attribute was removed on 3.9") + def test__field_types(self): + self.assertEqual( + self.NestedEmployee.__annotations__, + self.NestedEmployee._field_types + ) if __name__ == '__main__': diff --git a/src/typing_extensions.py b/src/typing_extensions.py index bc2095bc..ee321b9c 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -2023,29 +2023,16 @@ def __new__(cls, typename, bases, ns): nm_tpl.__init_subclass__() return nm_tpl - _bad_args_error_message = ( - "Either list of fields or keywords can be provided to NamedTuple, not both" - ) - - # Match the signature of typing.NamedTuple on 3.9+ exactly where possible, - # working around the fact that syntax for positional-only arguments - # is only available on 3.8+ - if sys.version_info >= (3, 8): - def NamedTuple(typename, fields=None, /, **kwargs): - if fields is None: - fields = kwargs.items() - elif kwargs: - raise TypeError(_bad_args_error_message) - return _make_nmtuple(typename, fields, module=_caller()) - else: - def NamedTuple(__typename, __fields=None, **kwargs): - if __fields is None: - __fields = kwargs.items() - elif kwargs: - raise TypeError(_bad_args_error_message) - return _make_nmtuple(__typename, __fields, module=_caller()) + def NamedTuple(__typename, __fields=None, **kwargs): + 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, module=_caller()) NamedTuple.__doc__ = typing.NamedTuple.__doc__ + NamedTuple.__text_signature__ = '(typename, fields=None, /, **kwargs)' _NamedTuple = type.__new__(_NamedTupleMeta, 'NamedTuple', (), {}) def _namedtuple_mro_entries(bases): From 5960b9e3a0d4add7e685e624bfcb4fd635ff401d Mon Sep 17 00:00:00 2001 From: AlexWaygood Date: Fri, 20 May 2022 17:43:30 +0100 Subject: [PATCH 04/20] Docs --- CHANGELOG.md | 6 ++++++ README.md | 5 +++++ 2 files changed, 11 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index aa66e55c..b6721cd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +# Unreleased + +- Add `typing_extensions.NamedTuple`, allowing for generic `NamedTuple`s on + Python <3.11 (backport from python/cpython#92027, by Serhiy Storchaka). Patch + by Alex Waygood (@AlexWaygood). + # Release 4.2.0 (April 17, 2022) - Re-export `typing.Unpack` and `typing.TypeVarTuple` on Python 3.11. diff --git a/README.md b/README.md index 55a23185..0261a0db 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,7 @@ This module currently contains the following: - `Counter` - `DefaultDict` - `Deque` + - `NamedTuple` - `NewType` - `NoReturn` - `overload` @@ -121,6 +122,10 @@ Certain objects were changed after they were added to `typing`, and introspectable at runtime. In order to access overloads with `typing_extensions.get_overloads()`, you must use `@typing_extensions.overload`. +- `NamedTuple` was changed in Python 3.11 to allow for multiple inheritance + with `typing.Generic` and, therefore, generic `NamedTuple`s. The + implementation of `NamedTuple` was also changed in 3.9 so that `NamedTuple` + became a function rather than a class. There are a few types whose interface was modified between different versions of typing. For example, `typing.Sequence` was modified to From 608473b3d754e52876bd6dd4864ed78cbeba10cc Mon Sep 17 00:00:00 2001 From: AlexWaygood Date: Fri, 20 May 2022 17:46:39 +0100 Subject: [PATCH 05/20] Improve docs --- README.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 0261a0db..b249ef02 100644 --- a/README.md +++ b/README.md @@ -123,9 +123,8 @@ Certain objects were changed after they were added to `typing`, and `typing_extensions.get_overloads()`, you must use `@typing_extensions.overload`. - `NamedTuple` was changed in Python 3.11 to allow for multiple inheritance - with `typing.Generic` and, therefore, generic `NamedTuple`s. The - implementation of `NamedTuple` was also changed in 3.9 so that `NamedTuple` - became a function rather than a class. + with `typing.Generic` The implementation of `NamedTuple` was also changed in + 3.9 so that `NamedTuple` became a function rather than a class. There are a few types whose interface was modified between different versions of typing. For example, `typing.Sequence` was modified to From e6196da39fdee4bb2d5c8f408877b70aa7b12cc6 Mon Sep 17 00:00:00 2001 From: AlexWaygood Date: Fri, 20 May 2022 17:51:00 +0100 Subject: [PATCH 06/20] Lint --- src/typing_extensions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/typing_extensions.py b/src/typing_extensions.py index ee321b9c..5ccdc1f5 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -1970,7 +1970,7 @@ def _caller(depth=1, default='__main__'): except (AttributeError, ValueError): # For platforms without _getframe() return None - def _make_nmtuple(name, types, module, defaults = ()): + def _make_nmtuple(name, types, module, defaults=()): fields = [n for n, t in types] types = {n: typing._type_check(t, f"field {n} annotation must be a type") for n, t in types} From b6ffa931a71173fb39ab9c6bbe50c16057f71464 Mon Sep 17 00:00:00 2001 From: AlexWaygood Date: Fri, 20 May 2022 17:54:29 +0100 Subject: [PATCH 07/20] More lint --- src/test_typing_extensions.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index 2b9838bf..1d05c4f1 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -2906,6 +2906,7 @@ class CoolEmployeeWithDefault(NamedTuple): class XMeth(NamedTuple): x: int + def double(self): return 2 * self.x @@ -2913,8 +2914,10 @@ def double(self): class XRepr(NamedTuple): x: int y: int = 1 + def __str__(self): return f'{self.x} -> {self.y}' + def __add__(self, other): return 0 From 0b5c6ab10bc5e513e99298cdbb8bd485bef07b34 Mon Sep 17 00:00:00 2001 From: AlexWaygood Date: Fri, 20 May 2022 19:48:02 +0100 Subject: [PATCH 08/20] Fix test --- src/test_typing_extensions.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index 1d05c4f1..b5f57f89 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -2967,13 +2967,22 @@ def test_annotation_usage_with_default(self): self.assertEqual(CoolEmployeeWithDefault._fields, ('name', 'cool')) self.assertEqual(CoolEmployeeWithDefault.__annotations__, dict(name=str, cool=int)) - self.assertEqual(CoolEmployeeWithDefault._field_defaults, dict(cool=0)) with self.assertRaises(TypeError): class NonDefaultAfterDefault(NamedTuple): x: int = 3 y: int + @skipUnless( + ( + sys.version_info >= (3, 8) + or hasattr(CoolEmployeeWithDefault, '_field_defaults') + ), + '"_field_defaults" attribute was added in a micro version of 3.7' + ) + def test_field_defaults(self): + self.assertEqual(CoolEmployeeWithDefault._field_defaults, dict(cool=0)) + def test_annotation_usage_with_methods(self): self.assertEqual(XMeth(1).double(), 2) self.assertEqual(XMeth(42).x, XMeth(42)[0]) @@ -3083,9 +3092,10 @@ class CNT(NamedTuple): for struct in [NT, CNT]: with self.subTest(struct=struct): self.assertEqual(struct._fields, ()) - self.assertEqual(struct._field_defaults, {}) self.assertEqual(struct.__annotations__, {}) self.assertIsInstance(struct(), struct) + if hasattr(struct, "_field_defaults"): + self.assertEqual(struct._field_defaults, {}) def test_namedtuple_errors(self): with self.assertRaises(TypeError): @@ -3137,6 +3147,7 @@ def test__field_types(self): self.NestedEmployee.__annotations__, self.NestedEmployee._field_types ) + self.assertIsInstance(inspect.signature(NamedTuple), inspect.Signature) if __name__ == '__main__': From ff02a627511ddf3abc636f09730890da8d01c3be Mon Sep 17 00:00:00 2001 From: AlexWaygood Date: Fri, 20 May 2022 20:24:56 +0100 Subject: [PATCH 09/20] Signature issues --- src/test_typing_extensions.py | 5 +++-- src/typing_extensions.py | 4 +++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index b5f57f89..9cbcfda5 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -3133,7 +3133,7 @@ def test_docstring(self): self.assertEqual(NamedTuple.__doc__, typing.NamedTuple.__doc__) @skipIf(sys.version_info < (3, 9), "NamedTuple was a class on 3.8 and lower") - def test_same_as_typing_NamedTuple(self): + def test_same_as_typing_NamedTuple_39_plus(self): self.assertEqual( set(dir(NamedTuple)), set(dir(typing.NamedTuple)) | {"__text_signature__"} @@ -3142,12 +3142,13 @@ def test_same_as_typing_NamedTuple(self): self.assertIs(type(NamedTuple), type(typing.NamedTuple)) @skipIf(sys.version_info >= (3, 9), "_field_types attribute was removed on 3.9") - def test__field_types(self): + def test_same_as_typing_NamedTuple_38_minus(self): self.assertEqual( self.NestedEmployee.__annotations__, self.NestedEmployee._field_types ) self.assertIsInstance(inspect.signature(NamedTuple), inspect.Signature) + self.assertFalse(hasattr(NamedTuple, "__text_signature__")) if __name__ == '__main__': diff --git a/src/typing_extensions.py b/src/typing_extensions.py index 5ccdc1f5..34b71b5a 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -2032,9 +2032,11 @@ def NamedTuple(__typename, __fields=None, **kwargs): return _make_nmtuple(__typename, __fields, module=_caller()) NamedTuple.__doc__ = typing.NamedTuple.__doc__ - NamedTuple.__text_signature__ = '(typename, fields=None, /, **kwargs)' _NamedTuple = type.__new__(_NamedTupleMeta, 'NamedTuple', (), {}) + if sys.version_info >= (3, 9): + NamedTuple.__text_signature__ = '(typename, fields=None, /, **kwargs)' + def _namedtuple_mro_entries(bases): assert NamedTuple in bases return (_NamedTuple,) From 4f886928accc64e163bd09321e23f6438b64d3ab Mon Sep 17 00:00:00 2001 From: AlexWaygood Date: Fri, 20 May 2022 20:47:34 +0100 Subject: [PATCH 10/20] More test fixes --- src/test_typing_extensions.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index 9cbcfda5..24fc1f30 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -3112,7 +3112,12 @@ def test_namedtuple_errors(self): def test_copy_and_pickle(self): global Emp # pickle wants to reference the class by name Emp = NamedTuple('Emp', [('name', str), ('cool', int)]) - for cls in Emp, CoolEmployee, self.NestedEmployee: + pickleable_classes = [Emp, CoolEmployee] + # support for pickling nested classes + # appears to have been added in 3.7.6 + if sys.version_info >= (3, 7, 6): + pickleable_classes.append(self.NestedEmployee) + for cls in pickleable_classes: with self.subTest(cls=cls): jane = cls('jane', 37) for proto in range(pickle.HIGHEST_PROTOCOL + 1): @@ -3141,7 +3146,7 @@ def test_same_as_typing_NamedTuple_39_plus(self): self.assertEqual(inspect.signature(NamedTuple), inspect.signature(typing.NamedTuple)) self.assertIs(type(NamedTuple), type(typing.NamedTuple)) - @skipIf(sys.version_info >= (3, 9), "_field_types attribute was removed on 3.9") + @skipIf(sys.version_info >= (3, 9), "tests are only relevant to <=3.8") def test_same_as_typing_NamedTuple_38_minus(self): self.assertEqual( self.NestedEmployee.__annotations__, From 873e946fb1f09b90f497bdeedaee9bf9dc9e7cab Mon Sep 17 00:00:00 2001 From: Alex Waygood Date: Fri, 20 May 2022 20:52:20 +0100 Subject: [PATCH 11/20] Add comment --- src/test_typing_extensions.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index 24fc1f30..0fd0e16a 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -3094,6 +3094,8 @@ class CNT(NamedTuple): self.assertEqual(struct._fields, ()) self.assertEqual(struct.__annotations__, {}) self.assertIsInstance(struct(), struct) + # Attribute was added in a micro version of 3.7 + # and is tested more fully elsewhere if hasattr(struct, "_field_defaults"): self.assertEqual(struct._field_defaults, {}) From 3e23113a59129993ca2f34271242f9b6e260ef0e Mon Sep 17 00:00:00 2001 From: AlexWaygood Date: Sat, 21 May 2022 14:50:47 +0100 Subject: [PATCH 12/20] Address review --- src/typing_extensions.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/typing_extensions.py b/src/typing_extensions.py index 34b71b5a..5777af93 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -1964,23 +1964,23 @@ def decorator(cls_or_fn): if sys.version_info >= (3, 11): NamedTuple = typing.NamedTuple else: - def _caller(depth=1, default='__main__'): + def _caller(): try: - return sys._getframe(depth + 1).f_globals.get('__name__', default) + return sys._getframe(2).f_globals.get('__name__', '__main__') except (AttributeError, ValueError): # For platforms without _getframe() return None def _make_nmtuple(name, types, module, defaults=()): fields = [n for n, t in types] - types = {n: typing._type_check(t, f"field {n} annotation must be a type") - for n, t in types} + annotations = {n: typing._type_check(t, f"field {n} annotation must be a type") + for n, t in types} nm_tpl = collections.namedtuple(name, fields, defaults=defaults, module=module) - nm_tpl.__annotations__ = nm_tpl.__new__.__annotations__ = types + nm_tpl.__annotations__ = nm_tpl.__new__.__annotations__ = annotations # The `_field_types` attribute was removed in 3.9; # in earlier versions, it is the same as the `__annotations__` attribute if sys.version_info < (3, 9): - nm_tpl._field_types = nm_tpl.__annotations__ + nm_tpl._field_types = annotations return nm_tpl _prohibited_namedtuple_fields = typing._prohibited From 0b70bcfc966603652d38402c1d992a72e3c5b30c Mon Sep 17 00:00:00 2001 From: Alex Waygood Date: Sat, 21 May 2022 15:37:37 +0100 Subject: [PATCH 13/20] Update README.md Co-authored-by: Jelle Zijlstra --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b249ef02..acd19623 100644 --- a/README.md +++ b/README.md @@ -123,7 +123,7 @@ Certain objects were changed after they were added to `typing`, and `typing_extensions.get_overloads()`, you must use `@typing_extensions.overload`. - `NamedTuple` was changed in Python 3.11 to allow for multiple inheritance - with `typing.Generic` The implementation of `NamedTuple` was also changed in + with `typing.Generic`. The implementation of `NamedTuple` was also changed in 3.9 so that `NamedTuple` became a function rather than a class. There are a few types whose interface was modified between different From 4d06a74b4d0dfce06cc341192e383b8d79172c42 Mon Sep 17 00:00:00 2001 From: AlexWaygood Date: Sat, 21 May 2022 16:22:31 +0100 Subject: [PATCH 14/20] Delete mention of implementation details in the README --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index acd19623..79112d1c 100644 --- a/README.md +++ b/README.md @@ -123,8 +123,7 @@ Certain objects were changed after they were added to `typing`, and `typing_extensions.get_overloads()`, you must use `@typing_extensions.overload`. - `NamedTuple` was changed in Python 3.11 to allow for multiple inheritance - with `typing.Generic`. The implementation of `NamedTuple` was also changed in - 3.9 so that `NamedTuple` became a function rather than a class. + with `typing.Generic`. There are a few types whose interface was modified between different versions of typing. For example, `typing.Sequence` was modified to From 197f794c5d726d5a63e7375040bbbfeb55175718 Mon Sep 17 00:00:00 2001 From: AlexWaygood Date: Sat, 21 May 2022 16:27:43 +0100 Subject: [PATCH 15/20] Backport the ability to pickle nested `NamedTuple`s --- src/test_typing_extensions.py | 7 +------ src/typing_extensions.py | 2 +- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index 0fd0e16a..b365e19d 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -3114,12 +3114,7 @@ def test_namedtuple_errors(self): def test_copy_and_pickle(self): global Emp # pickle wants to reference the class by name Emp = NamedTuple('Emp', [('name', str), ('cool', int)]) - pickleable_classes = [Emp, CoolEmployee] - # support for pickling nested classes - # appears to have been added in 3.7.6 - if sys.version_info >= (3, 7, 6): - pickleable_classes.append(self.NestedEmployee) - for cls in pickleable_classes: + for cls in Emp, CoolEmployee, self.NestedEmployee: with self.subTest(cls=cls): jane = cls('jane', 37) for proto in range(pickle.HIGHEST_PROTOCOL + 1): diff --git a/src/typing_extensions.py b/src/typing_extensions.py index 5777af93..911e10c4 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -1984,7 +1984,7 @@ def _make_nmtuple(name, types, module, defaults=()): return nm_tpl _prohibited_namedtuple_fields = typing._prohibited - _special_namedtuple_fields = typing._special + _special_namedtuple_fields = frozenset({'__module__', '__name__', '__annotations__'}) class _NamedTupleMeta(type): def __new__(cls, typename, bases, ns): From 70646b4d0246452edd825e32750814e9f6074e83 Mon Sep 17 00:00:00 2001 From: AlexWaygood Date: Sat, 21 May 2022 16:29:59 +0100 Subject: [PATCH 16/20] Add comment --- src/typing_extensions.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/typing_extensions.py b/src/typing_extensions.py index 911e10c4..a4134f74 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -1961,6 +1961,9 @@ def decorator(cls_or_fn): typing._check_generic = _check_generic +# Backport NamedTuple as it exists in 3.11 +# 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. if sys.version_info >= (3, 11): NamedTuple = typing.NamedTuple else: From e0f371391f3a229c48ddc7dab2da7b22d3ac9f92 Mon Sep 17 00:00:00 2001 From: AlexWaygood Date: Sat, 21 May 2022 16:37:39 +0100 Subject: [PATCH 17/20] Improve signature on 3.8, add comments, improve tests --- src/test_typing_extensions.py | 12 +++++++++--- src/typing_extensions.py | 5 ++++- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index b365e19d..1d1fb388 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -3134,13 +3134,21 @@ def test_copy_and_pickle(self): def test_docstring(self): self.assertEqual(NamedTuple.__doc__, typing.NamedTuple.__doc__) + @skipIf(sys.version_info < (3, 8), "NamedTuple had a bad signature on <=3.7") + def test_signature_is_same_as_typing_NamedTuple(self): + self.assertEqual(inspect.signature(NamedTuple), inspect.signature(typing.NamedTuple)) + + @skipIf(sys.version_info >= (3, 8), "tests are only relveant to <=3.7") + def test_signature_on_37(self): + self.assertIsInstance(inspect.signature(NamedTuple), inspect.Signature) + self.assertFalse(hasattr(NamedTuple, "__text_signature__")) + @skipIf(sys.version_info < (3, 9), "NamedTuple was a class on 3.8 and lower") def test_same_as_typing_NamedTuple_39_plus(self): self.assertEqual( set(dir(NamedTuple)), set(dir(typing.NamedTuple)) | {"__text_signature__"} ) - self.assertEqual(inspect.signature(NamedTuple), inspect.signature(typing.NamedTuple)) self.assertIs(type(NamedTuple), type(typing.NamedTuple)) @skipIf(sys.version_info >= (3, 9), "tests are only relevant to <=3.8") @@ -3149,8 +3157,6 @@ def test_same_as_typing_NamedTuple_38_minus(self): self.NestedEmployee.__annotations__, self.NestedEmployee._field_types ) - self.assertIsInstance(inspect.signature(NamedTuple), inspect.Signature) - self.assertFalse(hasattr(NamedTuple, "__text_signature__")) if __name__ == '__main__': diff --git a/src/typing_extensions.py b/src/typing_extensions.py index a4134f74..db083686 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -2037,7 +2037,10 @@ def NamedTuple(__typename, __fields=None, **kwargs): NamedTuple.__doc__ = typing.NamedTuple.__doc__ _NamedTuple = type.__new__(_NamedTupleMeta, 'NamedTuple', (), {}) - if sys.version_info >= (3, 9): + # On 3.8+, alter the signature so that it matches typing.NamedTuple. + # The signature of typing.NamedTuple on >=3.8 is invalid syntax in Python 3.7, + # so just leave the signature as it is on 3.7. + if sys.version_info >= (3, 8): NamedTuple.__text_signature__ = '(typename, fields=None, /, **kwargs)' def _namedtuple_mro_entries(bases): From a805f205e3664fdced8c58067ef97c29b626d150 Mon Sep 17 00:00:00 2001 From: Alex Waygood Date: Sat, 21 May 2022 16:40:49 +0100 Subject: [PATCH 18/20] Update src/typing_extensions.py --- src/typing_extensions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/typing_extensions.py b/src/typing_extensions.py index db083686..3b9a39cf 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -1961,7 +1961,7 @@ def decorator(cls_or_fn): typing._check_generic = _check_generic -# Backport NamedTuple as it exists in 3.11 +# Backport typing.NamedTuple as it exists in Python 3.11. # 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. if sys.version_info >= (3, 11): From 2d3665816b66b2ad6ad7a75aa63d156c7f298072 Mon Sep 17 00:00:00 2001 From: Alex Waygood Date: Sat, 21 May 2022 16:42:10 +0100 Subject: [PATCH 19/20] I can spell Co-authored-by: Jelle Zijlstra --- src/test_typing_extensions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index 1d1fb388..acd7b57f 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -3138,7 +3138,7 @@ def test_docstring(self): def test_signature_is_same_as_typing_NamedTuple(self): self.assertEqual(inspect.signature(NamedTuple), inspect.signature(typing.NamedTuple)) - @skipIf(sys.version_info >= (3, 8), "tests are only relveant to <=3.7") + @skipIf(sys.version_info >= (3, 8), "tests are only relevant to <=3.7") def test_signature_on_37(self): self.assertIsInstance(inspect.signature(NamedTuple), inspect.Signature) self.assertFalse(hasattr(NamedTuple, "__text_signature__")) From cc6d709a52e7c809e106b3241af42777fbd036c3 Mon Sep 17 00:00:00 2001 From: AlexWaygood Date: Tue, 24 May 2022 16:04:51 +0100 Subject: [PATCH 20/20] Enhance tests --- src/test_typing_extensions.py | 70 ++++++++++++++++++++++++++--------- 1 file changed, 53 insertions(+), 17 deletions(-) diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index acd7b57f..407a4860 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -33,6 +33,7 @@ # Flags used to mark tests that only apply after a specific # version of the typing module. TYPING_3_8_0 = sys.version_info[:3] >= (3, 8, 0) +TYPING_3_9_0 = sys.version_info[:3] >= (3, 9, 0) TYPING_3_10_0 = sys.version_info[:3] >= (3, 10, 0) # 3.11 makes runtime type checks (_type_check) more lenient. @@ -2922,6 +2923,7 @@ def __add__(self, other): return 0 +@skipIf(TYPING_3_11_0, "These invariants should all be tested upstream on 3.11+") class NamedTupleTests(BaseTestCase): class NestedEmployee(NamedTuple): name: str @@ -2968,14 +2970,17 @@ def test_annotation_usage_with_default(self): self.assertEqual(CoolEmployeeWithDefault.__annotations__, dict(name=str, cool=int)) - with self.assertRaises(TypeError): + with self.assertRaisesRegex( + TypeError, + 'Non-default namedtuple field y cannot follow default field x' + ): class NonDefaultAfterDefault(NamedTuple): x: int = 3 y: int @skipUnless( ( - sys.version_info >= (3, 8) + TYPING_3_8_0 or hasattr(CoolEmployeeWithDefault, '_field_defaults') ), '"_field_defaults" attribute was added in a micro version of 3.7' @@ -2989,13 +2994,15 @@ def test_annotation_usage_with_methods(self): self.assertEqual(str(XRepr(42)), '42 -> 1') self.assertEqual(XRepr(1, 2) + XRepr(3), 0) - with self.assertRaises(AttributeError): + bad_overwrite_error_message = 'Cannot overwrite NamedTuple attribute' + + with self.assertRaisesRegex(AttributeError, bad_overwrite_error_message): class XMethBad(NamedTuple): x: int def _fields(self): return 'no chance for this' - with self.assertRaises(AttributeError): + with self.assertRaisesRegex(AttributeError, bad_overwrite_error_message): class XMethBad2(NamedTuple): x: int def _source(self): @@ -3004,18 +3011,30 @@ def _source(self): def test_multiple_inheritance(self): class A: pass - with self.assertRaises(TypeError): + with self.assertRaisesRegex( + TypeError, + 'can only inherit from a NamedTuple type and Generic' + ): class X(NamedTuple, A): x: int - with self.assertRaises(TypeError): + + with self.assertRaisesRegex( + TypeError, + 'can only inherit from a NamedTuple type and Generic' + ): class X(NamedTuple, tuple): x: int - with self.assertRaises(TypeError): + + with self.assertRaisesRegex(TypeError, 'duplicate base class'): class X(NamedTuple, NamedTuple): x: int + class A(NamedTuple): x: int - with self.assertRaises(TypeError): + with self.assertRaisesRegex( + TypeError, + 'can only inherit from a NamedTuple type and Generic' + ): class X(NamedTuple, A): y: str @@ -3044,11 +3063,11 @@ class Y(Generic[T], NamedTuple): self.assertIs(type(a), G) self.assertEqual(a.x, 3) - with self.assertRaises(TypeError): + with self.assertRaisesRegex(TypeError, 'Too many parameters'): G[int, str] - @skipIf(sys.version_info < (3, 9), "tuple.__class_getitem__ was added in 3.9") - def test_non_generic_subscript(self): + @skipUnless(TYPING_3_9_0, "tuple.__class_getitem__ was added in 3.9") + def test_non_generic_subscript_py39_plus(self): # For backward compatibility, subscription works # on arbitrary NamedTuple types. class Group(NamedTuple): @@ -3062,6 +3081,19 @@ class Group(NamedTuple): self.assertIs(type(a), Group) self.assertEqual(a, (1, [2])) + @skipIf(TYPING_3_9_0, "Test isn't relevant to 3.9+") + def test_non_generic_subscript_error_message_py38_minus(self): + class Group(NamedTuple): + key: T + group: List[T] + + with self.assertRaisesRegex(TypeError, 'not subscriptable'): + Group[int] + + for attr in ('__args__', '__origin__', '__parameters__'): + with self.subTest(attr=attr): + self.assertFalse(hasattr(Group, attr)) + def test_namedtuple_keyword_usage(self): LocalEmployee = NamedTuple("LocalEmployee", name=str, age=int) nick = LocalEmployee('Nick', 25) @@ -3070,7 +3102,10 @@ def test_namedtuple_keyword_usage(self): self.assertEqual(LocalEmployee.__name__, 'LocalEmployee') self.assertEqual(LocalEmployee._fields, ('name', 'age')) self.assertEqual(LocalEmployee.__annotations__, dict(name=str, age=int)) - with self.assertRaises(TypeError): + with self.assertRaisesRegex( + TypeError, + 'Either list of fields or keywords can be provided to NamedTuple, not both' + ): NamedTuple('Name', [('x', int)], y=str) def test_namedtuple_special_keyword_names(self): @@ -3106,7 +3141,7 @@ def test_namedtuple_errors(self): NamedTuple() with self.assertRaises(TypeError): NamedTuple('Emp', [('name', str)], None) - with self.assertRaises(ValueError): + with self.assertRaisesRegex(ValueError, 'cannot start with an underscore'): NamedTuple('Emp', [('_name', str)]) with self.assertRaises(TypeError): NamedTuple(typename='Emp', name=str, id=int) @@ -3133,17 +3168,18 @@ def test_copy_and_pickle(self): def test_docstring(self): self.assertEqual(NamedTuple.__doc__, typing.NamedTuple.__doc__) + self.assertIsInstance(NamedTuple.__doc__, str) - @skipIf(sys.version_info < (3, 8), "NamedTuple had a bad signature on <=3.7") + @skipUnless(TYPING_3_8_0, "NamedTuple had a bad signature on <=3.7") def test_signature_is_same_as_typing_NamedTuple(self): self.assertEqual(inspect.signature(NamedTuple), inspect.signature(typing.NamedTuple)) - @skipIf(sys.version_info >= (3, 8), "tests are only relevant to <=3.7") + @skipIf(TYPING_3_8_0, "tests are only relevant to <=3.7") def test_signature_on_37(self): self.assertIsInstance(inspect.signature(NamedTuple), inspect.Signature) self.assertFalse(hasattr(NamedTuple, "__text_signature__")) - @skipIf(sys.version_info < (3, 9), "NamedTuple was a class on 3.8 and lower") + @skipUnless(TYPING_3_9_0, "NamedTuple was a class on 3.8 and lower") def test_same_as_typing_NamedTuple_39_plus(self): self.assertEqual( set(dir(NamedTuple)), @@ -3151,7 +3187,7 @@ def test_same_as_typing_NamedTuple_39_plus(self): ) self.assertIs(type(NamedTuple), type(typing.NamedTuple)) - @skipIf(sys.version_info >= (3, 9), "tests are only relevant to <=3.8") + @skipIf(TYPING_3_9_0, "tests are only relevant to <=3.8") def test_same_as_typing_NamedTuple_38_minus(self): self.assertEqual( self.NestedEmployee.__annotations__,