From c3645106ba7da7a0e27c548779f34f97dec654a4 Mon Sep 17 00:00:00 2001 From: Viicos <65306057+Viicos@users.noreply.github.com> Date: Thu, 24 Oct 2024 23:04:21 +0200 Subject: [PATCH 1/6] Inlined typed dictionaries PEP --- peps/conf.py | 1 + peps/pep-9995.rst | 218 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 219 insertions(+) create mode 100644 peps/pep-9995.rst diff --git a/peps/conf.py b/peps/conf.py index f566a42ee53..60c0404fe69 100644 --- a/peps/conf.py +++ b/peps/conf.py @@ -70,6 +70,7 @@ "typing": ("https://typing.readthedocs.io/en/latest/", None), "trio": ("https://trio.readthedocs.io/en/latest/", None), "devguide": ("https://devguide.python.org/", None), + "mypy": ("https://mypy.readthedocs.io/en/latest/", None), "py3.11": ("https://docs.python.org/3.11/", None), "py3.12": ("https://docs.python.org/3.12/", None), "py3.13": ("https://docs.python.org/3.13/", None), diff --git a/peps/pep-9995.rst b/peps/pep-9995.rst new file mode 100644 index 00000000000..d9f7b9f2d61 --- /dev/null +++ b/peps/pep-9995.rst @@ -0,0 +1,218 @@ +PEP: 9995 +Title: Inlined typed dictionaries +Author: Victorien Plot +Discussions-To: +Status: Draft +Type: Standards Track +Topic: Typing +Created: 23-Oct-2024 +Post-History: +Python-Version: 3.14 +Resolution: + +.. highlight:: python + + +Abstract +======== + +:pep:`589` defines a `class-based `_ and a +:ref:`functional syntax ` to create typed +dictionaries. In both scenarios, it requires defining a class or assigning to +a value. In some situations, this can add unnecessary boilerplate, especially +if the typed dictionary is only used once. + +This PEP proposes the addition of a new inlined syntax, by subscripting the +:class:`~typing.TypedDict` type:: + + from typing import TypedDict + + def get_movie() -> TypedDict[{'name': str, 'year': int}]: + return { + 'name': 'Blade Runner', + 'year': 1982, + } + +Motivation +========== + +Python dictionaries are an essential data structure of the language. Many +times, it is used to return or accept structured data in functions. However, +it can get tedious to define :class:`~typing.TypedDict` classes: + +* A typed dictionary requires a name, which might not be relevant. +* Nested dictionaries requires more than one class definition. + +Taking a simple function returning some nested structured data as an example:: + + from typing import TypedDict + + class ProductionCompany(TypedDict): + name: str + location: str + + class Movie(TypedDict): + name: str + year: int + production: ProductionCompany + + + def get_movie() -> Movie: + return { + 'name': 'Blade Runner', + 'year': 1982, + 'production': { + 'name': 'Warner Bros.', + 'location': 'California', + } + } + + +Rationale +========= + +The new inlined syntax can be used to resolve these problems:: + + def get_movie() -> TypedDict[{'name': str, year: int, 'production': {'name': str, 'location': str}}]: + ... + +It is recommended to *only* make use of inlined typed dictionaries when the +structured data isn't too large, as this can quickly get hard to read. + +While less useful (as the functional or even the class-based syntax can be +used), inlined typed dictionaries can be defined as a type alias:: + + type InlinedDict = TypedDict[{'name': str}] + +Specification +============= + +The :class:`~typing.TypedDict` class is made subscriptable, and accepts a +single type argument which must be a :class:`dict`, following the same +semantics as the :ref:`functional syntax ` +(the dictionary keys are strings representing the field names, and values are +valid :ref:`annotation expressions `). + +Inlined typed dictionaries can be referred as being *anonymous*, meaning they +don't have a name. For this reason, their :attr:`~type.__name__` attribute +will be set to an empty string. + +It is not possible to specify any class arguments such as ``total``. + +Runtime behavior +---------------- + +Although :class:`~typing.TypedDict` is commonly referred as a class, it is +implemented as a function at runtime. To be made subscriptable, it will be +changed to be a class. + +Creating an inlined typed dictionary results in a new class, so both syntaxes +return the same type:: + + from typing import TypedDict + + T1 = TypedDict('T1', {'a': int}) + T2 = TypedDict[{'a': int}] + + +Backwards Compatibility +======================= + +Apart from the :class:`~typing.TypedDict` internal implementation change, this +PEP does not bring any backwards incompatible changes. + + +Security Implications +===================== + +There are no known security consequences arising from this PEP. + + +How to Teach This +================= + +The new inlined syntax will be documented both in the :mod:`typing` module +documentation and the :ref:`typing specification `. + +As mentioned in the `Rationale`_, it should be mentioned that inlined typed +dictionaries should be used for small structured data to not hurt readability. + + +Reference Implementation +======================== + +Mypy supports a similar syntax as an :option:`experimental feature `:: + + def test_values() -> {"int": int, "str": str}: + return {"int": 42, "str": "test"} + +Pyright has added support in version `1.1.297`_ (using :class:`dict`), but was later +removed in version `1.1.366`_. + +.. _1.1.297: https://github.com/microsoft/pyright/releases/tag/1.1.297 +.. _1.1.366: https://github.com/microsoft/pyright/releases/tag/1.1.366 + +Runtime implementation +---------------------- + +A draft implementation is available `here `_. + + +Rejected Ideas +============== + +Using the functional syntax in annotations +------------------------------------------ + +The alternative functional syntax could be used as an annotation directly:: + + def get_movie() -> TypedDict('Movie', {'title': str}): ... + +However, call expressions are currently unsupported in such a context for +various reasons (expensive to process, evaluating them is not standardized). + +This would also require a name which is sometimes not relevant. + +Using ``dict`` with a single type argument +------------------------------------------ + +We could reuse :class:`dict` with a single type argument to express the same +concept:: + + def get_movie() -> dict[{'title': str}]: ... + +While this would avoid having to import :class:`~typing.TypedDict` from +:mod:`typing`, this solution has several downsides: + +* For type checkers, :class:`dict` is a regular class with two type variables. + Allowing :class:`dict` to be parametrized with a single type argument would + require special casing from type checkers, as there is no way to express + parametrization overloads. On ther other hand, :class:`~typing.TypedDict` is + already a :term:`special form `. + +* If fufure work extends what inlined typed dictionaries can do, we don't have + to worry about impact of sharing the symbol with :class:`dict`. + + +Open Issues +=========== + +Subclassing an inlined typed dictionary +--------------------------------------- + +Should we allow the following?:: + + from typing import TypedDict + + InlinedTD = TypedDict[{'a': int}] + + + class SubTD(InlinedTD): + pass + + +Copyright +========= + +This document is placed in the public domain or under the +CC0-1.0-Universal license, whichever is more permissive. From ac5786be37f18283eba3cbab6ba094ec9f011546 Mon Sep 17 00:00:00 2001 From: Viicos <65306057+Viicos@users.noreply.github.com> Date: Sun, 27 Oct 2024 21:51:01 +0100 Subject: [PATCH 2/6] Starting incorporating feedback --- peps/pep-9995.rst | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/peps/pep-9995.rst b/peps/pep-9995.rst index d9f7b9f2d61..57acc0e2ec2 100644 --- a/peps/pep-9995.rst +++ b/peps/pep-9995.rst @@ -16,11 +16,11 @@ Resolution: Abstract ======== -:pep:`589` defines a `class-based `_ and a -:ref:`functional syntax ` to create typed -dictionaries. In both scenarios, it requires defining a class or assigning to -a value. In some situations, this can add unnecessary boilerplate, especially -if the typed dictionary is only used once. +:pep:`589` defines a :ref:`class-based ` +and a :ref:`functional syntax ` to create +typed dictionaries. In both scenarios, it requires defining a class or +assigning to a value. In some situations, this can add unnecessary boilerplate, +especially if the typed dictionary is only used once. This PEP proposes the addition of a new inlined syntax, by subscripting the :class:`~typing.TypedDict` type:: @@ -114,6 +114,16 @@ return the same type:: T1 = TypedDict('T1', {'a': int}) T2 = TypedDict[{'a': int}] +Typing specification changes +---------------------------- + +The inlined typed dictionary syntax adds a new valid location for +:term:`type expressions `. As such, the specification +on :ref:`valid locations ` will need to be updated, most +likely by adding a new item to the list: + + * The definitions of the fields in the inlined typed dictionary syntax + Backwards Compatibility ======================= @@ -190,7 +200,7 @@ While this would avoid having to import :class:`~typing.TypedDict` from parametrization overloads. On ther other hand, :class:`~typing.TypedDict` is already a :term:`special form `. -* If fufure work extends what inlined typed dictionaries can do, we don't have +* If future work extends what inlined typed dictionaries can do, we don't have to worry about impact of sharing the symbol with :class:`dict`. From c058d0edc409866b44a9fd9494d59fcf17f5d98b Mon Sep 17 00:00:00 2001 From: Viicos <65306057+Viicos@users.noreply.github.com> Date: Tue, 29 Oct 2024 22:09:37 +0100 Subject: [PATCH 3/6] Applied remaining feedback Some GH discussions are left opened for now, waiting for an answer. --- peps/pep-9995.rst | 106 +++++++++++++++++++++++++++++++++++++++------- 1 file changed, 90 insertions(+), 16 deletions(-) diff --git a/peps/pep-9995.rst b/peps/pep-9995.rst index 57acc0e2ec2..be31fac932a 100644 --- a/peps/pep-9995.rst +++ b/peps/pep-9995.rst @@ -5,7 +5,7 @@ Discussions-To: Status: Draft Type: Standards Track Topic: Typing -Created: 23-Oct-2024 +Created: 25-Oct-2024 Post-History: Python-Version: 3.14 Resolution: @@ -73,16 +73,17 @@ Rationale The new inlined syntax can be used to resolve these problems:: - def get_movie() -> TypedDict[{'name': str, year: int, 'production': {'name': str, 'location': str}}]: + def get_movie() -> TypedDict[{'name': str, 'year': int, 'production': TypedDict[{'name': str, 'location': str}]}]: ... It is recommended to *only* make use of inlined typed dictionaries when the -structured data isn't too large, as this can quickly get hard to read. +structured data isn't too large, as this can quickly become hard to read. While less useful (as the functional or even the class-based syntax can be -used), inlined typed dictionaries can be defined as a type alias:: +used), inlined typed dictionaries can be assigned to a variable, as an alias:: + + InlinedTD = TypedDict[{'name': str}] - type InlinedDict = TypedDict[{'name': str}] Specification ============= @@ -97,7 +98,43 @@ Inlined typed dictionaries can be referred as being *anonymous*, meaning they don't have a name. For this reason, their :attr:`~type.__name__` attribute will be set to an empty string. -It is not possible to specify any class arguments such as ``total``. +It is possible to define a nested inlined dictionary:: + + Movie = TypedDict[{'name': str, 'production': TypedDict[{'location': str}]}] + + # Note that the following is invalid as per the updated `type_expression` production: + Movie = TypedDict[{'name': str, 'production': {'location': str}}] + +Although it is not possible to specify any class arguments such as ``total``, +Any :external+typing:term:`type qualifier` can be used for individual fields:: + + Movie = TypedDict[{'name': NotRequired[str], 'year': ReadOnly[int]}] + +Inlined typed dictionaries are implicitly *total*, meaning all keys must be +present. Using the :data:`~typing.Required` type qualifier is thus redundant. + +Type variables are allowed in inlined typed dictionaries, provided that they +are bound to some outer scope:: + + class C[T]: + inlined_td: TypedDict[{'name': T}] # OK, `T` is scoped to the class `C`. + + reveal_type(C[int]().inlined_td['name']) # Revealed type is 'int' + + + def fn[T](arg: T) -> TypedDict[{'name': T}]: ... # OK: `T` is scoped to the function `fn`. + + reveal_type(fn('a')['name']) # Revealed type is 'str' + + + type InlinedTD[T] = TypedDict[{'name': T}] # OK + + + T = TypeVar('T') + + InlinedTD = TypedDict[{'name': T}] # Not OK, `T` refers to a type variable that is not bound to any scope. + +**TODO** closed Runtime behavior ---------------- @@ -107,7 +144,7 @@ implemented as a function at runtime. To be made subscriptable, it will be changed to be a class. Creating an inlined typed dictionary results in a new class, so both syntaxes -return the same type:: +return the same type (apart from the different :attr:`~type.__name__`):: from typing import TypedDict @@ -117,12 +154,15 @@ return the same type:: Typing specification changes ---------------------------- -The inlined typed dictionary syntax adds a new valid location for -:term:`type expressions `. As such, the specification -on :ref:`valid locations ` will need to be updated, most -likely by adding a new item to the list: +The inlined typed dictionary adds a new kind of +:term:`type expressions `. As such, the +:external+typing:token:`~expression-grammar:type_expression` production will +need to be updated to include the inlined syntax: - * The definitions of the fields in the inlined typed dictionary syntax +.. productionlist:: inlined-typed-dictionaries-grammar + new-type_expression: `~expression-grammar:type_expression` + : | '[' '{' (string: ':' `~expression-grammar:annotation_expression` ',')* '}' ']' + (where string is any string literal) Backwards Compatibility @@ -156,11 +196,9 @@ Mypy supports a similar syntax as an :option:`experimental feature {"int": int, "str": str}: return {"int": 42, "str": "test"} -Pyright has added support in version `1.1.297`_ (using :class:`dict`), but was later -removed in version `1.1.366`_. +Pyright added support for the new syntax in version `1.1.387`_. -.. _1.1.297: https://github.com/microsoft/pyright/releases/tag/1.1.297 -.. _1.1.366: https://github.com/microsoft/pyright/releases/tag/1.1.366 +.. _1.1.387: https://github.com/microsoft/pyright/releases/tag/1.1.387 Runtime implementation ---------------------- @@ -203,6 +241,24 @@ While this would avoid having to import :class:`~typing.TypedDict` from * If future work extends what inlined typed dictionaries can do, we don't have to worry about impact of sharing the symbol with :class:`dict`. +Using a simple dictionary +------------------------- + +Instead of subscripting the :class:`~typing.TypedDict` class, a plain +dictionary could be used as an annotation:: + + def get_movie() -> {'title': str}: ... + +However, :pep:`584` added union operators on dictionaries and :pep:`604` +introduced :ref:`union types `. Both features make use of +the :ref:`bitwise or (|) ` operator, making the following use +cases incompatible, especially for runtime introspection:: + + # Dictionaries are merged: + def fn() -> {'a': int} | {'b': str}: ... + + # Raises a type error at runtime: + def fn() -> {'a': int} | int: ... Open Issues =========== @@ -220,6 +276,24 @@ Should we allow the following?:: class SubTD(InlinedTD): pass +Using ``typing.Dict`` with a single argument +-------------------------------------------- + +While using :class:`dict` isn't ideal, we could make use of +:class:`typing.Dict` with a single argument:: + + def get_movie() -> Dict[{'title': str}]: ... + +It is less verbose, doesn't have the baggage of :class:`dict`, +and is defined as some kind of special form (an alias to the built-in +``dict``). + +However, it is currently marked as deprecated (although not scheduled for +removal), so it might be confusing to undeprecate it. + +This would also set a precedent on typing constructs being parametrizable +with a different number of type arguments. + Copyright ========= From 76dc942dd37f1bc93d57a83a52c9ae3bee4d7df1 Mon Sep 17 00:00:00 2001 From: Viicos <65306057+Viicos@users.noreply.github.com> Date: Tue, 29 Oct 2024 22:18:51 +0100 Subject: [PATCH 4/6] Rename PEP, fix headers --- peps/{pep-9995.rst => pep-0764.rst} | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) rename peps/{pep-9995.rst => pep-0764.rst} (99%) diff --git a/peps/pep-9995.rst b/peps/pep-0764.rst similarity index 99% rename from peps/pep-9995.rst rename to peps/pep-0764.rst index be31fac932a..324745b438e 100644 --- a/peps/pep-9995.rst +++ b/peps/pep-0764.rst @@ -1,14 +1,12 @@ -PEP: 9995 +PEP: 764 Title: Inlined typed dictionaries Author: Victorien Plot -Discussions-To: +Sponsor: Eric Traut Status: Draft Type: Standards Track Topic: Typing Created: 25-Oct-2024 -Post-History: Python-Version: 3.14 -Resolution: .. highlight:: python From 2e0c379b42ed1136ce7185b9057d99c7e69c44a6 Mon Sep 17 00:00:00 2001 From: Viicos <65306057+Viicos@users.noreply.github.com> Date: Tue, 29 Oct 2024 22:44:12 +0100 Subject: [PATCH 5/6] Misc. fixes --- peps/pep-0764.rst | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/peps/pep-0764.rst b/peps/pep-0764.rst index 324745b438e..4026384f89e 100644 --- a/peps/pep-0764.rst +++ b/peps/pep-0764.rst @@ -100,11 +100,11 @@ It is possible to define a nested inlined dictionary:: Movie = TypedDict[{'name': str, 'production': TypedDict[{'location': str}]}] - # Note that the following is invalid as per the updated `type_expression` production: + # Note that the following is invalid as per the updated `type_expression` grammar: Movie = TypedDict[{'name': str, 'production': {'location': str}}] Although it is not possible to specify any class arguments such as ``total``, -Any :external+typing:term:`type qualifier` can be used for individual fields:: +any :external+typing:term:`type qualifier` can be used for individual fields:: Movie = TypedDict[{'name': NotRequired[str], 'year': ReadOnly[int]}] @@ -134,6 +134,19 @@ are bound to some outer scope:: **TODO** closed +Typing specification changes +---------------------------- + +The inlined typed dictionary adds a new kind of +:external+typing:term:`type expression`. As such, the +:external+typing:token:`~expression-grammar:type_expression` production will +be updated to include the inlined syntax: + +.. productionlist:: inlined-typed-dictionaries-grammar + new-type_expression: `~expression-grammar:type_expression` + : | '[' '{' (string: ':' `~expression-grammar:annotation_expression` ',')* '}' ']' + : (where string is any string literal) + Runtime behavior ---------------- @@ -149,19 +162,6 @@ return the same type (apart from the different :attr:`~type.__name__`):: T1 = TypedDict('T1', {'a': int}) T2 = TypedDict[{'a': int}] -Typing specification changes ----------------------------- - -The inlined typed dictionary adds a new kind of -:term:`type expressions `. As such, the -:external+typing:token:`~expression-grammar:type_expression` production will -need to be updated to include the inlined syntax: - -.. productionlist:: inlined-typed-dictionaries-grammar - new-type_expression: `~expression-grammar:type_expression` - : | '[' '{' (string: ':' `~expression-grammar:annotation_expression` ',')* '}' ']' - (where string is any string literal) - Backwards Compatibility ======================= From 2857d69d3451617e3a4a5de2ace401ff3e80513a Mon Sep 17 00:00:00 2001 From: Viicos <65306057+Viicos@users.noreply.github.com> Date: Fri, 1 Nov 2024 00:57:55 +0100 Subject: [PATCH 6/6] Sydney's feedback --- peps/pep-0764.rst | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/peps/pep-0764.rst b/peps/pep-0764.rst index 4026384f89e..d74b1e65c80 100644 --- a/peps/pep-0764.rst +++ b/peps/pep-0764.rst @@ -39,7 +39,7 @@ times, it is used to return or accept structured data in functions. However, it can get tedious to define :class:`~typing.TypedDict` classes: * A typed dictionary requires a name, which might not be relevant. -* Nested dictionaries requires more than one class definition. +* Nested dictionaries require more than one class definition. Taking a simple function returning some nested structured data as an example:: @@ -82,6 +82,9 @@ used), inlined typed dictionaries can be assigned to a variable, as an alias:: InlinedTD = TypedDict[{'name': str}] + def get_movie() -> InlinedTD: + ... + Specification ============= @@ -92,7 +95,7 @@ semantics as the :ref:`functional syntax ` (the dictionary keys are strings representing the field names, and values are valid :ref:`annotation expressions `). -Inlined typed dictionaries can be referred as being *anonymous*, meaning they +Inlined typed dictionaries can be referred to as *anonymous*, meaning they don't have a name. For this reason, their :attr:`~type.__name__` attribute will be set to an empty string. @@ -125,7 +128,7 @@ are bound to some outer scope:: reveal_type(fn('a')['name']) # Revealed type is 'str' - type InlinedTD[T] = TypedDict[{'name': T}] # OK + type InlinedTD[T] = TypedDict[{'name': T}] # OK, `T` is scoped to the type alias. T = TypeVar('T') @@ -154,8 +157,8 @@ Although :class:`~typing.TypedDict` is commonly referred as a class, it is implemented as a function at runtime. To be made subscriptable, it will be changed to be a class. -Creating an inlined typed dictionary results in a new class, so both syntaxes -return the same type (apart from the different :attr:`~type.__name__`):: +Creating an inlined typed dictionary results in a new class, so ``T1`` and +``T2`` are the same type (apart from the different :attr:`~type.__name__`):: from typing import TypedDict