PEP 728: Improve specification (#4166)

- Specify that extra_items and closed are also supported with the
  functional syntax.
- Rewrite the rules for `closed=True` and inheritance. I attempted
  to make `closed=True` exactly equivalent to `extra_items=Never` in
  terms of inheritance. The semantics as specified in the previous
  version of the PEP felt harder to understand and less consistent.
- Fix some incorrect comments regarding expected type checker errors.
- Clarify section on assignability with Mapping
- Add section on runtime behavior. I tried to make the intended runtime
  behavior simple to implement and understand. This makes the runtime
  simpler but may make life more complicated for tools consuming the
  metadata.
- Fix typos and trailing whitespace
This commit is contained in:
Jelle Zijlstra 2024-12-19 16:13:09 -08:00 committed by GitHub
parent d98e3fa418
commit 484c43d0a7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
1 changed files with 78 additions and 51 deletions

View File

@ -234,38 +234,46 @@ Here, ``'year'`` in ``a`` is an extra key defined on ``Movie`` whose value type
is ``int``. ``'other_extra_key'`` in ``b`` is another extra key whose value type is ``int``. ``'other_extra_key'`` in ``b`` is another extra key whose value type
must be assignable to the value of ``extra_items`` defined on ``MovieBase``. must be assignable to the value of ``extra_items`` defined on ``MovieBase``.
``extra_items`` is also supported with the functional syntax::
Movie = TypedDict("Movie", {"name": str}, extra_items=int | None)
The ``closed`` Class Parameter The ``closed`` Class Parameter
------------------------------ ------------------------------
When ``closed=True`` is set, no extra items are allowed. This is a shorthand for When ``closed=True`` is set, no extra items are allowed. This is equivalent to
``extra_items=Never``, because there can't be a value type that is assignable to ``extra_items=Never``, because there can't be a value type that is assignable to
:class:`~typing.Never`. :class:`~typing.Never`. It is a runtime error to use the ``closed`` and
``extra_items`` parameters in the same TypedDict definition.
Similar to ``total``, only a literal ``True`` or ``False`` is supported as the Similar to ``total``, only a literal ``True`` or ``False`` is supported as the
value of the ``closed`` argument; ``closed`` is ``False`` by default, which value of the ``closed`` argument. Type checkers should reject any non-literal value.
preserves the previous TypedDict behavior.
The value of ``closed`` is not inherited through subclassing, but the Passing ``closed=False`` explicitly requests the default TypedDict behavior,
implicitly set ``extra_items=Never`` is. It should be an error to use the where arbitrary other keys may be present and subclasses may add arbitrary items.
default ``closed=False`` when subclassing a closed TypedDict type:: It is a type checker error to pass ``closed=False`` if a superclass has
``closed=True`` or sets ``extra_items``.
If ``closed`` is not provided, the behavior is inherited from the superclass.
If the superclass is TypedDict itself or the superclass does not have ``closed=True``
or the ``extra_items`` parameter, the previous TypedDict behavior is preserved:
arbitrary extra items are allowed. If the superclass has ``closed=True``, the
child class is also closed.
class BaseMovie(TypedDict, closed=True): class BaseMovie(TypedDict, closed=True):
name: str name: str
class MovieA(BaseMovie): # Not OK. An explicit 'closed=True' is required class MovieA(BaseMovie): # OK, still closed
pass pass
class MovieB(BaseMovie, closed=True): # OK class MovieB(BaseMovie, closed=True): # OK, but redundant
pass pass
Setting both ``closed`` and ``extra_items`` when defining a TypedDict type class MovieC(BaseMovie, closed=False): # Type checker error
should always be a runtime error:: pass
class Person(TypedDict, closed=False, extra_items=bool): # Not OK. 'closed' and 'extra_items' are incompatible As a consequence of ``closed=True`` being equivalent to ``extra_items=Never``,
name: str the same rules that apply to ``extra_items=Never`` also apply to
As a consequence of ``closed=True`` being equivalent to ``extra_items=Never``.
The same rules that apply to ``extra_items=Never`` should also apply to
``closed=True``. It is possible to use ``closed=True`` when subclassing if the ``closed=True``. It is possible to use ``closed=True`` when subclassing if the
``extra_items`` argument is a read-only type:: ``extra_items`` argument is a read-only type::
@ -275,7 +283,7 @@ The same rules that apply to ``extra_items=Never`` should also apply to
class MovieClosed(Movie, closed=True): # OK class MovieClosed(Movie, closed=True): # OK
pass pass
class MovieNever(Movie, extra_items=Never): # Not OK. 'closed=True' is preferred class MovieNever(Movie, extra_items=Never): # OK, but 'closed=True' is preferred
pass pass
This will be further discussed in This will be further discussed in
@ -286,6 +294,10 @@ is assumed to allow non-required extra items of value type ``ReadOnly[object]``
during inheritance or assignability checks. This preserves the existing behavior during inheritance or assignability checks. This preserves the existing behavior
of TypedDict. of TypedDict.
``closed`` is also supported with the functional syntax::
Movie = TypedDict("Movie", {"name": str}, closed=True)
Interaction with Totality Interaction with Totality
------------------------- -------------------------
@ -378,20 +390,17 @@ added in a subclass, all of the following conditions should apply:
- The item's value type is :term:`typing:consistent` with ``T`` - The item's value type is :term:`typing:consistent` with ``T``
- If ``extra_items`` is not overriden, the subclass inherits it as-is. - If ``extra_items`` is not overridden, the subclass inherits it as-is.
For example:: For example::
class MovieBase(TypedDict, extra_items=int | None): class MovieBase(TypedDict, extra_items=int | None):
name: str name: str
class AdaptedMovie(MovieBase): # Not OK. 'bool' is not assignable to 'int | None' class MovieRequiredYear(MovieBase): # Not OK. Required key 'year' is not known to 'MovieBase'
adapted_from_novel: bool
class MovieRequiredYear(MovieBase): # Not OK. Required key 'year' is not known to 'Parent'
year: int | None year: int | None
class MovieNotRequiredYear(MovieBase): # Not OK. 'int | None' is not assignable to 'int' class MovieNotRequiredYear(MovieBase): # Not OK. 'int | None' is not consistent with 'int'
year: NotRequired[int] year: NotRequired[int]
class MovieWithYear(MovieBase): # OK class MovieWithYear(MovieBase): # OK
@ -578,17 +587,13 @@ arguments of this type when constructed by calling the class object::
Interaction with Mapping[KT, VT] Interaction with Mapping[KT, VT]
-------------------------------- --------------------------------
A TypedDict type can be assignable to ``Mapping[KT, VT]`` types other than A TypedDict type is :term:`typing:assignable` to a type of the form ``Mapping[str, VT]``
``Mapping[str, object]`` as long as all value types of the items on the when all value types of the items in the TypedDict
TypedDict type is :term:`typing:assignable` to ``VT``. This is an extension of this are assignable to ``VT``. For the purpose of this rule, a
TypedDict that does not have ``extra_items=`` or ``closed=`` set is considered
to have an item with a value of type ``object``. This extends the current
assignability rule from the `typing spec assignability rule from the `typing spec
<https://typing.readthedocs.io/en/latest/spec/typeddict.html#assignability>`__: <https://typing.readthedocs.io/en/latest/spec/typeddict.html#assignability>`__.
* A TypedDict with all ``int`` values is not :term:`typing:assignable` to
``Mapping[str, int]``, since there may be additional non-``int`` values
not visible through the type, due to :term:`typing:structural`
assignability. These can be accessed using the ``values()`` and
``items()`` methods in ``Mapping``,
For example:: For example::
@ -598,6 +603,10 @@ For example::
extra_str: MovieExtraStr = {"name": "Blade Runner", "summary": ""} extra_str: MovieExtraStr = {"name": "Blade Runner", "summary": ""}
str_mapping: Mapping[str, str] = extra_str # OK str_mapping: Mapping[str, str] = extra_str # OK
class MovieExtraInt(TypedDict, extra_items=int):
name: str
extra_int: MovieExtraInt = {"name": "Blade Runner", "year": 1982}
int_mapping: Mapping[str, int] = extra_int # Not OK. 'int | str' is not assignable with 'int' int_mapping: Mapping[str, int] = extra_int # Not OK. 'int | str' is not assignable with 'int'
int_str_mapping: Mapping[str, int | str] = extra_int # OK int_str_mapping: Mapping[str, int | str] = extra_int # OK
@ -611,7 +620,7 @@ and ``items()`` on such TypedDict types::
Interaction with dict[KT, VT] Interaction with dict[KT, VT]
----------------------------- -----------------------------
Note that because the presence of ``extra_items`` on a closed TypedDict type Because the presence of ``extra_items`` on a closed TypedDict type
prohibits additional required keys in its :term:`typing:structural` prohibits additional required keys in its :term:`typing:structural`
:term:`typing:subtypes <subtype>`, we can determine if the TypedDict type and :term:`typing:subtypes <subtype>`, we can determine if the TypedDict type and
its structural subtypes will ever have any required key during static analysis. its structural subtypes will ever have any required key during static analysis.
@ -656,6 +665,24 @@ because such dict can be a subtype of dict::
not_a_regular_dict: CustomDict = {"num": 1} not_a_regular_dict: CustomDict = {"num": 1}
int_dict: IntDict = not_a_regular_dict # Not OK int_dict: IntDict = not_a_regular_dict # Not OK
Runtime behavior
----------------
At runtime, it is an error to pass both the ``closed`` and ``extra_items``
arguments in the same TypedDict definition, whether using the class syntax or
the functional syntax. For simplicity, the runtime does not check other invalid
combinations involving inheritance.
For introspection, the ``closed`` and ``extra_items`` arguments are mapped to
two new attributes on the resulting TypedDict object: ``__closed__`` and
``__extra_items__``. These attributes reflect exactly what was passed to the
TypedDict constructor, without considering superclasses.
If ``closed`` is not passed, the value of ``__closed__`` is None. If ``extra_items``
is not passed, the value of ``__extra_items__`` is the new sentinel object
``typing.NoExtraItems``. (It cannot be ``None``, because ``extra_items=None`` is a
valid definition that indicates all extra items must be ``None``.)
How to Teach This How to Teach This
================= =================
@ -673,7 +700,7 @@ Because ``extra_items`` is an opt-in feature, no existing codebase will break
due to this change. due to this change.
Note that ``closed`` and ``extra_items`` as keyword arguments do not collide Note that ``closed`` and ``extra_items`` as keyword arguments do not collide
with othere keys when using something like with other keys when using something like
``TD = TypedDict("TD", foo=str, bar=int)``, because this syntax has already ``TD = TypedDict("TD", foo=str, bar=int)``, because this syntax has already
been removed in Python 3.13. been removed in Python 3.13.