Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
89 changes: 72 additions & 17 deletions src/test_typing_extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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')
Expand Down Expand Up @@ -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')
Expand All @@ -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)])
Expand Down Expand Up @@ -2829,6 +2822,50 @@ 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):
Expand Down Expand Up @@ -3595,22 +3632,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(
Expand Down Expand Up @@ -3656,7 +3694,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
Expand Down Expand Up @@ -3796,7 +3833,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")
Expand Down Expand Up @@ -3927,6 +3966,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):
Expand Down
31 changes: 24 additions & 7 deletions src/typing_extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -749,14 +749,16 @@ 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"
# keyword with old-style TypedDict(). See https://bugs.python.org/issue42059
# 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
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand All @@ -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(
Copy link
Member

@AlexWaygood AlexWaygood Apr 23, 2023

Choose a reason for hiding this comment

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

I think it's important that we copy CPython's behaviour here, and CPython emits a deprecation warning here on 3.11+. This PR means we reimplement TypedDict on 3.11 now, so the change is needed, I think. @JelleZijlstra do you think we should only emit the deprecation warning if the user is running typing_extensions on 3.11+? (Referencing #150 (comment))

Copy link
Member

Choose a reason for hiding this comment

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

That's a hard question and gets into what we would do if we ever want to break backwards compatibility for typing-extensions. My first instinct is to say we should always warn in typing-extensions, regardless of the version, but then what would we do when CPython removes support for kwargs-based TypedDicts? I'd be hesitant to remove the runtime behavior in typing-extensions and break backwards compatibility.

Copy link
Member

Choose a reason for hiding this comment

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

I agree it's a hard question.

Elsewhere in the typing_extensions implementation of TypedDict, we actually already have two places where CPython long ago removed support for a certain feature, but typing_extensions has in effect had "eternal deprecation warnings":

import warnings
warnings.warn("Passing '_typename' as keyword argument is deprecated",
DeprecationWarning, stacklevel=2)

import warnings
warnings.warn("Passing '_fields' as keyword argument is deprecated",
DeprecationWarning, stacklevel=2)

Copy link
Member

Choose a reason for hiding this comment

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

I think eternal deprecation warnings might be the right answer, actually. (Or eternal until we ever make typing-extensions 5 in the distant uncertain future.)

Copy link
Member

@AlexWaygood AlexWaygood Apr 23, 2023

Choose a reason for hiding this comment

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

So maybe we do want the change in #150 (comment)? ;)

Copy link
Member

Choose a reason for hiding this comment

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

Yes, let's do that. I commented there because the change looked unrelated to this PR.

"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:
Expand Down Expand Up @@ -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__', {})
Expand Down Expand Up @@ -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=()):
Expand Down Expand Up @@ -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', (), {})
Expand Down