Skip to content

Add __orig_bases__ to TypedDict #150

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
Apr 24, 2023
Merged
Show file tree
Hide file tree
Changes from all 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
88 changes: 71 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,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):
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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):
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