Skip to content

Commit

Permalink
Add support for PEP 728 (#329)
Browse files Browse the repository at this point in the history
Signed-off-by: Zixuan James Li <[email protected]>
  • Loading branch information
PIG208 committed Feb 18, 2024
1 parent 9f040ab commit b7bf949
Show file tree
Hide file tree
Showing 4 changed files with 222 additions and 3 deletions.
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
154 changes: 154 additions & 0 deletions src/test_typing_extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@
# 3.12 changes the representation of Unpack[] (PEP 692)
TYPING_3_12_0 = sys.version_info[:3] >= (3, 12, 0)

# 3.13 drops support for the keyword argument syntax of TypedDict
TYPING_3_13_0 = sys.version_info[:3] >= (3, 13, 0)

# https://github.com/python/cpython/pull/27017 was backported into some 3.9 and 3.10
# versions, but not all
HAS_FORWARD_MODULE = "module" in inspect.signature(typing._type_check).parameters
Expand Down Expand Up @@ -3820,6 +3823,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 @@ -4178,6 +4199,139 @@ class AllTheThings(TypedDict):
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"
):
TypedDict("A", {"__extra_items__": Required[int]}, closed=True)

with self.assertRaisesRegex(
TypeError,
"Special key __extra_items__ does not support 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__)

@skipIf(
TYPING_3_13_0,
"The keyword argument alternative to define a "
"TypedDict type using the functional syntax is no longer supported"
)
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
32 changes: 29 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,26 @@ 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__', ()))
base_extra_items_type = base_dict.get('__extra_items__', None)
if base_extra_items_type 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:
raise TypeError(
"Special key __extra_items__ does not support "
"Required"
)
if NotRequired in qualifiers:
raise TypeError(
"Special key __extra_items__ does not support "
"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 +977,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 +992,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 +1052,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 +1076,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

0 comments on commit b7bf949

Please sign in to comment.