diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a3f62b0..a95f31ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,12 @@ Patch by Alex Waygood. - Speedup `isinstance(3, typing_extensions.SupportsIndex)` by >10x on Python <3.12. Patch by Alex Waygood. +- Add `__orig_bases__` to non-generic TypedDicts, call-based TypedDicts, and + call-based NamedTuples. Other TypedDicts and NamedTuples already had the attribute. + Patch by Adrian Garcia Badaracco. +- Constructing a call-based `TypedDict` using keyword arguments for the fields + now causes a `DeprecationWarning` to be emitted. This matches the behaviour + of `typing.TypedDict` on 3.11 and 3.12. # Release 4.5.0 (February 14, 2023) diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index 9c89cfc1..aa14c293 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -63,13 +63,6 @@ def assertNotIsSubclass(self, cls, class_or_tuple, msg=None): message += f' : {msg}' raise self.failureException(message) - @contextlib.contextmanager - def assertWarnsIf(self, condition: bool, expected_warning: Type[Warning]): - with contextlib.ExitStack() as stack: - if condition: - stack.enter_context(self.assertWarns(expected_warning)) - yield - class Employee: pass @@ -2260,7 +2253,7 @@ def test_basics_iterable_syntax(self): self.assertEqual(Emp.__total__, True) def test_basics_keywords_syntax(self): - with self.assertWarnsIf(sys.version_info >= (3, 11), DeprecationWarning): + with self.assertWarns(DeprecationWarning): Emp = TypedDict('Emp', name=str, id=int) self.assertIsSubclass(Emp, dict) self.assertIsSubclass(Emp, typing.MutableMapping) @@ -2276,7 +2269,7 @@ def test_basics_keywords_syntax(self): self.assertEqual(Emp.__total__, True) def test_typeddict_special_keyword_names(self): - with self.assertWarnsIf(sys.version_info >= (3, 11), DeprecationWarning): + with self.assertWarns(DeprecationWarning): TD = TypedDict("TD", cls=type, self=object, typename=str, _typename=int, fields=list, _fields=dict) self.assertEqual(TD.__name__, 'TD') @@ -2312,7 +2305,7 @@ def test_typeddict_create_errors(self): def test_typeddict_errors(self): Emp = TypedDict('Emp', {'name': str, 'id': int}) - if hasattr(typing, "Required"): + if sys.version_info >= (3, 12): self.assertEqual(TypedDict.__module__, 'typing') else: self.assertEqual(TypedDict.__module__, 'typing_extensions') @@ -2325,7 +2318,7 @@ def test_typeddict_errors(self): issubclass(dict, Emp) if not TYPING_3_11_0: - with self.assertRaises(TypeError): + with self.assertRaises(TypeError), self.assertWarns(DeprecationWarning): TypedDict('Hi', x=1) with self.assertRaises(TypeError): TypedDict('Hi', [('x', int), ('y', 1)]) @@ -2829,6 +2822,49 @@ def test_get_type_hints_typeddict(self): 'year': NotRequired[Annotated[int, 2000]], } + def test_orig_bases(self): + T = TypeVar('T') + + class Parent(TypedDict): + pass + + class Child(Parent): + pass + + class OtherChild(Parent): + pass + + class MixedChild(Child, OtherChild, Parent): + pass + + class GenericParent(TypedDict, Generic[T]): + pass + + class GenericChild(GenericParent[int]): + pass + + class OtherGenericChild(GenericParent[str]): + pass + + class MixedGenericChild(GenericChild, OtherGenericChild, GenericParent[float]): + pass + + class MultipleGenericBases(GenericParent[int], GenericParent[float]): + pass + + CallTypedDict = TypedDict('CallTypedDict', {}) + + self.assertEqual(Parent.__orig_bases__, (TypedDict,)) + self.assertEqual(Child.__orig_bases__, (Parent,)) + self.assertEqual(OtherChild.__orig_bases__, (Parent,)) + self.assertEqual(MixedChild.__orig_bases__, (Child, OtherChild, Parent,)) + self.assertEqual(GenericParent.__orig_bases__, (TypedDict, Generic[T])) + self.assertEqual(GenericChild.__orig_bases__, (GenericParent[int],)) + self.assertEqual(OtherGenericChild.__orig_bases__, (GenericParent[str],)) + self.assertEqual(MixedGenericChild.__orig_bases__, (GenericChild, OtherGenericChild, GenericParent[float])) + self.assertEqual(MultipleGenericBases.__orig_bases__, (GenericParent[int], GenericParent[float])) + self.assertEqual(CallTypedDict.__orig_bases__, (TypedDict,)) + class TypeAliasTests(BaseTestCase): def test_canonical_usage_with_variable_annotation(self): @@ -3595,22 +3631,23 @@ def test_typing_extensions_defers_when_possible(self): 'overload', 'ParamSpec', 'Text', - 'TypedDict', 'TypeVar', 'TypeVarTuple', 'TYPE_CHECKING', 'Final', 'get_type_hints', - 'is_typeddict', } if sys.version_info < (3, 10): exclude |= {'get_args', 'get_origin'} if sys.version_info < (3, 10, 1): exclude |= {"Literal"} if sys.version_info < (3, 11): - exclude |= {'final', 'NamedTuple', 'Any'} + exclude |= {'final', 'Any'} if sys.version_info < (3, 12): - exclude |= {'Protocol', 'runtime_checkable', 'SupportsIndex'} + exclude |= { + 'Protocol', 'runtime_checkable', 'SupportsIndex', 'TypedDict', + 'is_typeddict', 'NamedTuple', + } for item in typing_extensions.__all__: if item not in exclude and hasattr(typing, item): self.assertIs( @@ -3656,7 +3693,6 @@ 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 @@ -3796,7 +3832,9 @@ class Y(Generic[T], NamedTuple): self.assertIs(type(a), G) self.assertEqual(a.x, 3) - with self.assertRaisesRegex(TypeError, 'Too many parameters'): + things = "arguments" if sys.version_info >= (3, 11) else "parameters" + + with self.assertRaisesRegex(TypeError, f'Too many {things}'): G[int, str] @skipUnless(TYPING_3_9_0, "tuple.__class_getitem__ was added in 3.9") @@ -3927,6 +3965,22 @@ def test_same_as_typing_NamedTuple_38_minus(self): self.NestedEmployee._field_types ) + def test_orig_bases(self): + T = TypeVar('T') + + class SimpleNamedTuple(NamedTuple): + pass + + class GenericNamedTuple(NamedTuple, Generic[T]): + pass + + self.assertEqual(SimpleNamedTuple.__orig_bases__, (NamedTuple,)) + self.assertEqual(GenericNamedTuple.__orig_bases__, (NamedTuple, Generic[T])) + + CallNamedTuple = NamedTuple('CallNamedTuple', []) + + self.assertEqual(CallNamedTuple.__orig_bases__, (NamedTuple,)) + class TypeVarLikeDefaultsTests(BaseTestCase): def test_typevar(self): diff --git a/src/typing_extensions.py b/src/typing_extensions.py index b6b6bd49..411ccd42 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -749,7 +749,7 @@ def __index__(self) -> int: pass -if hasattr(typing, "Required"): +if sys.version_info >= (3, 12): # 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" @@ -757,6 +757,8 @@ def __index__(self) -> int: # The standard library TypedDict below Python 3.11 does not store runtime # information about optional and required keys when using Required or NotRequired. # 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. TypedDict = typing.TypedDict _TypedDictMeta = typing._TypedDictMeta is_typeddict = typing.is_typeddict @@ -786,7 +788,6 @@ def _typeddict_new(*args, total=True, **kwargs): typename, args = args[0], args[1:] # 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: @@ -801,7 +802,6 @@ def _typeddict_new(*args, total=True, **kwargs): 'were given') 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: @@ -813,6 +813,15 @@ def _typeddict_new(*args, total=True, **kwargs): raise TypeError("TypedDict takes either a dict or keyword arguments," " but not both") + if kwargs: + warnings.warn( + "The kwargs-based syntax for TypedDict definitions is deprecated, " + "may be removed in a future version, and may not be " + "understood by third-party type checkers.", + DeprecationWarning, + stacklevel=2, + ) + ns = {'__annotations__': dict(fields)} module = _caller() if module is not None: @@ -844,9 +853,14 @@ def __new__(cls, name, bases, ns, total=True): # Instead, monkey-patch __bases__ onto the class after it's been created. tp_dict = super().__new__(cls, name, (dict,), ns) - if any(issubclass(base, typing.Generic) for base in bases): + is_generic = any(issubclass(base, typing.Generic) for base in bases) + + if is_generic: tp_dict.__bases__ = (typing.Generic, dict) _maybe_adjust_parameters(tp_dict) + else: + # generic TypedDicts get __orig_bases__ from Generic + tp_dict.__orig_bases__ = bases or (TypedDict,) annotations = {} own_annotations = ns.get('__annotations__', {}) @@ -2313,10 +2327,11 @@ def wrapper(*args, **kwargs): typing._check_generic = _check_generic -# Backport typing.NamedTuple as it exists in Python 3.11. +# Backport typing.NamedTuple as it exists in Python 3.12. # 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): +# On 3.12, we added __orig_bases__ to call-based NamedTuples +if sys.version_info >= (3, 12): NamedTuple = typing.NamedTuple else: def _make_nmtuple(name, types, module, defaults=()): @@ -2378,7 +2393,9 @@ def NamedTuple(__typename, __fields=None, **kwargs): elif kwargs: raise TypeError("Either list of fields or keywords" " can be provided to NamedTuple, not both") - return _make_nmtuple(__typename, __fields, module=_caller()) + nt = _make_nmtuple(__typename, __fields, module=_caller()) + nt.__orig_bases__ = (NamedTuple,) + return nt NamedTuple.__doc__ = typing.NamedTuple.__doc__ _NamedTuple = type.__new__(_NamedTupleMeta, 'NamedTuple', (), {})