Skip to content
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

Support __extra__ for PEP 728. #329

Merged
merged 3 commits into from
Feb 18, 2024
Merged
Show file tree
Hide file tree
Changes from 2 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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# Unreleased

- Add support for PEP 728, supporting the `closed` keyword argument and the
special `__extra_items__` key for TypedDict. Patch by Zixuan James Li.
- Add support for PEP 742, adding `typing_extensions.TypeIs`. Patch
by Jelle Zijlstra.
- Drop runtime error when a read-only `TypedDict` item overrides a mutable
Expand Down
37 changes: 37 additions & 0 deletions doc/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,38 @@ Special typing primitives
are mutable if they do not carry the :data:`ReadOnly` qualifier.

.. versionadded:: 4.9.0

The experimental ``closed`` keyword argument and the special key
``__extra_items__`` proposed in :pep:`728` are supported.

When ``closed`` is unspecified or ``closed=False`` is given,
``__extra_items__`` behaves like a regular key. Otherwise, this becomes a
special key that does not show up in ``__readonly_keys__``,
``__mutable_keys__``, ``__required_keys__``, ``__optional_keys``, or
``__annotations__``.

For runtime introspection, two attributes can be looked at:

.. attribute:: __closed__

A boolean flag indicating whether the current ``TypedDict`` is
considered closed. This is not inherited by the ``TypedDict``'s
subclasses.

.. versionadded:: 4.10.0

.. attribute:: __extra_items__

The type annotation of the extra items allowed on the ``TypedDict``.
This attribute defaults to ``None`` on a TypedDict that has itself and
all its bases non-closed. This default is different from ``type(None)``
that represents ``__extra_items__: None`` defined on a closed
``TypedDict``.

If ``__extra_items__`` is not defined or inherited on a closed
``TypedDict``, this defaults to ``Never``.

.. versionadded:: 4.10.0

.. versionchanged:: 4.3.0

Expand Down Expand Up @@ -427,6 +459,11 @@ Special typing primitives

Support for the :data:`ReadOnly` qualifier was added.

.. versionchanged:: 4.10.0

The keyword argument ``closed`` and the special key ``__extra_items__``
when ``closed=True`` is given were supported.

.. class:: TypeVar(name, *constraints, bound=None, covariant=False,
contravariant=False, infer_variance=False, default=...)

Expand Down
146 changes: 146 additions & 0 deletions src/test_typing_extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -3820,6 +3820,24 @@ class ChildWithInlineAndOptional(Untotal, Inline):
{'inline': bool, 'untotal': str, 'child': bool},
)

class Closed(TypedDict, closed=True):
__extra_items__: None

class Unclosed(TypedDict, closed=False):
...

class ChildUnclosed(Closed, Unclosed):
...

self.assertFalse(ChildUnclosed.__closed__)
self.assertEqual(ChildUnclosed.__extra_items__, type(None))

class ChildClosed(Unclosed, Closed):
...

self.assertFalse(ChildClosed.__closed__)
self.assertEqual(ChildClosed.__extra_items__, type(None))

wrong_bases = [
(One, Regular),
(Regular, One),
Expand Down Expand Up @@ -4177,6 +4195,134 @@ class AllTheThings(TypedDict):
self.assertEqual(AllTheThings.__optional_keys__, frozenset({'c', 'd'}))
self.assertEqual(AllTheThings.__readonly_keys__, frozenset({'a', 'b', 'c'}))
self.assertEqual(AllTheThings.__mutable_keys__, frozenset({'d'}))

def test_extra_keys_non_readonly(self):
class Base(TypedDict, closed=True):
__extra_items__: str

class Child(Base):
a: NotRequired[int]

self.assertEqual(Child.__required_keys__, frozenset({}))
self.assertEqual(Child.__optional_keys__, frozenset({'a'}))
self.assertEqual(Child.__readonly_keys__, frozenset({}))
self.assertEqual(Child.__mutable_keys__, frozenset({'a'}))

def test_extra_keys_readonly(self):
class Base(TypedDict, closed=True):
__extra_items__: ReadOnly[str]

class Child(Base):
a: NotRequired[str]

self.assertEqual(Child.__required_keys__, frozenset({}))
self.assertEqual(Child.__optional_keys__, frozenset({'a'}))
self.assertEqual(Child.__readonly_keys__, frozenset({}))
self.assertEqual(Child.__mutable_keys__, frozenset({'a'}))

def test_extra_key_required(self):
with self.assertRaisesRegex(
TypeError,
"Special key __extra_items__ does not support Required and NotRequired"
):
TypedDict("A", {"__extra_items__": Required[int]}, closed=True)

with self.assertRaisesRegex(
TypeError,
"Special key __extra_items__ does not support Required and NotRequired"
):
TypedDict("A", {"__extra_items__": NotRequired[int]}, closed=True)

def test_regular_extra_items(self):
class ExtraReadOnly(TypedDict):
__extra_items__: ReadOnly[str]

self.assertEqual(ExtraReadOnly.__required_keys__, frozenset({'__extra_items__'}))
self.assertEqual(ExtraReadOnly.__optional_keys__, frozenset({}))
self.assertEqual(ExtraReadOnly.__readonly_keys__, frozenset({'__extra_items__'}))
self.assertEqual(ExtraReadOnly.__mutable_keys__, frozenset({}))
self.assertEqual(ExtraReadOnly.__extra_items__, None)
self.assertFalse(ExtraReadOnly.__closed__)

class ExtraRequired(TypedDict):
__extra_items__: Required[str]

self.assertEqual(ExtraRequired.__required_keys__, frozenset({'__extra_items__'}))
self.assertEqual(ExtraRequired.__optional_keys__, frozenset({}))
self.assertEqual(ExtraRequired.__readonly_keys__, frozenset({}))
self.assertEqual(ExtraRequired.__mutable_keys__, frozenset({'__extra_items__'}))
self.assertEqual(ExtraRequired.__extra_items__, None)
self.assertFalse(ExtraRequired.__closed__)

class ExtraNotRequired(TypedDict):
__extra_items__: NotRequired[str]

self.assertEqual(ExtraNotRequired.__required_keys__, frozenset({}))
self.assertEqual(ExtraNotRequired.__optional_keys__, frozenset({'__extra_items__'}))
self.assertEqual(ExtraNotRequired.__readonly_keys__, frozenset({}))
self.assertEqual(ExtraNotRequired.__mutable_keys__, frozenset({'__extra_items__'}))
self.assertEqual(ExtraNotRequired.__extra_items__, None)
self.assertFalse(ExtraNotRequired.__closed__)

def test_closed_inheritance(self):
class Base(TypedDict, closed=True):
__extra_items__: ReadOnly[Union[str, None]]

self.assertEqual(Base.__required_keys__, frozenset({}))
self.assertEqual(Base.__optional_keys__, frozenset({}))
self.assertEqual(Base.__readonly_keys__, frozenset({}))
self.assertEqual(Base.__mutable_keys__, frozenset({}))
self.assertEqual(Base.__annotations__, {})
self.assertEqual(Base.__extra_items__, ReadOnly[Union[str, None]])
self.assertTrue(Base.__closed__)

class Child(Base):
a: int
__extra_items__: int

self.assertEqual(Child.__required_keys__, frozenset({'a', "__extra_items__"}))
self.assertEqual(Child.__optional_keys__, frozenset({}))
self.assertEqual(Child.__readonly_keys__, frozenset({}))
self.assertEqual(Child.__mutable_keys__, frozenset({'a', "__extra_items__"}))
self.assertEqual(Child.__annotations__, {"__extra_items__": int, "a": int})
self.assertEqual(Child.__extra_items__, ReadOnly[Union[str, None]])
self.assertFalse(Child.__closed__)

class GrandChild(Child, closed=True):
__extra_items__: str

self.assertEqual(GrandChild.__required_keys__, frozenset({'a', "__extra_items__"}))
self.assertEqual(GrandChild.__optional_keys__, frozenset({}))
self.assertEqual(GrandChild.__readonly_keys__, frozenset({}))
self.assertEqual(GrandChild.__mutable_keys__, frozenset({'a', "__extra_items__"}))
self.assertEqual(GrandChild.__annotations__, {"__extra_items__": int, "a": int})
self.assertEqual(GrandChild.__extra_items__, str)
self.assertTrue(GrandChild.__closed__)

def test_implicit_extra_items(self):
class Base(TypedDict):
a: int

self.assertEqual(Base.__extra_items__, None)
self.assertFalse(Base.__closed__)

class ChildA(Base, closed=True):
...

self.assertEqual(ChildA.__extra_items__, Never)
self.assertTrue(ChildA.__closed__)

class ChildB(Base, closed=True):
__extra_items__: None

self.assertEqual(ChildB.__extra_items__, type(None))
self.assertTrue(ChildB.__closed__)

def test_backwards_compatibility(self):
with self.assertWarns(DeprecationWarning):
TD = TypedDict("TD", closed=int)
self.assertFalse(TD.__closed__)
self.assertEqual(TD.__annotations__, {"closed": int})


class AnnotatedTests(BaseTestCase):
Expand Down
26 changes: 23 additions & 3 deletions src/typing_extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -875,7 +875,7 @@ def _get_typeddict_qualifiers(annotation_type):
break

class _TypedDictMeta(type):
def __new__(cls, name, bases, ns, *, total=True):
def __new__(cls, name, bases, ns, *, total=True, closed=False):
"""Create new typed dict class object.

This method is called when TypedDict is subclassed,
Expand Down Expand Up @@ -920,6 +920,7 @@ def __new__(cls, name, bases, ns, *, total=True):
optional_keys = set()
readonly_keys = set()
mutable_keys = set()
extra_items_type = None

for base in bases:
base_dict = base.__dict__
Expand All @@ -929,6 +930,20 @@ def __new__(cls, name, bases, ns, *, total=True):
optional_keys.update(base_dict.get('__optional_keys__', ()))
readonly_keys.update(base_dict.get('__readonly_keys__', ()))
mutable_keys.update(base_dict.get('__mutable_keys__', ()))
if (base_extra_items_type := base_dict.get('__extra_items__', None)) is not None:
extra_items_type = base_extra_items_type

if closed and extra_items_type is None:
extra_items_type = Never
if closed and "__extra_items__" in own_annotations:
annotation_type = own_annotations.pop("__extra_items__")
qualifiers = set(_get_typeddict_qualifiers(annotation_type))
if Required in qualifiers or NotRequired in qualifiers:
raise TypeError(
"Special key __extra_items__ does not support "
"Required and NotRequired"
)
extra_items_type = annotation_type

annotations.update(own_annotations)
for annotation_key, annotation_type in own_annotations.items():
Expand Down Expand Up @@ -956,6 +971,8 @@ def __new__(cls, name, bases, ns, *, total=True):
tp_dict.__mutable_keys__ = frozenset(mutable_keys)
if not hasattr(tp_dict, '__total__'):
tp_dict.__total__ = total
tp_dict.__closed__ = closed
tp_dict.__extra_items__ = extra_items_type
return tp_dict

__call__ = dict # static method
Expand All @@ -969,7 +986,7 @@ def __subclasscheck__(cls, other):
_TypedDict = type.__new__(_TypedDictMeta, 'TypedDict', (), {})

@_ensure_subclassable(lambda bases: (_TypedDict,))
def TypedDict(typename, fields=_marker, /, *, total=True, **kwargs):
def TypedDict(typename, fields=_marker, /, *, total=True, closed=False, **kwargs):
"""A simple typed namespace. At runtime it is equivalent to a plain dict.

TypedDict creates a dictionary type such that a type checker will expect all
Expand Down Expand Up @@ -1029,6 +1046,9 @@ class Point2D(TypedDict):
"using the functional syntax, pass an empty dictionary, e.g. "
) + example + "."
warnings.warn(deprecation_msg, DeprecationWarning, stacklevel=2)
if closed is not False and closed is not True:
kwargs["closed"] = closed
closed = False
fields = kwargs
elif kwargs:
raise TypeError("TypedDict takes either a dict or keyword arguments,"
Expand All @@ -1050,7 +1070,7 @@ class Point2D(TypedDict):
# Setting correct module is necessary to make typed dict classes pickleable.
ns['__module__'] = module

td = _TypedDictMeta(typename, (), ns, total=total)
td = _TypedDictMeta(typename, (), ns, total=total, closed=closed)
td.__orig_bases__ = (TypedDict,)
return td

Expand Down
Loading