PEP 692 – Using TypedDict for more precise **kwargs typing
- Author:
- Franek Magiera <framagie at gmail.com>
- Sponsor:
- Jelle Zijlstra <jelle.zijlstra at gmail.com>
- Discussions-To:
- Discourse thread
- Status:
- Final
- Type:
- Standards Track
- Topic:
- Typing
- Created:
- 29-May-2022
- Python-Version:
- 3.12
- Post-History:
- 29-May-2022, 12-Jul-2022, 12-Jul-2022
- Resolution:
- Discourse message
Table of Contents
- Abstract
- Motivation
- Rationale
- Specification
- Intended Usage
- How to Teach This
- Reference Implementation
- Rejected Ideas
- Copyright
Abstract
Currently**kwargs
can be type hinted as long as all of the keyword
arguments specified by them are of the same type. However, that behaviour can
be very limiting. Therefore, in this PEP we propose a new way to enable more
precise**kwargs
typing. The new approach revolves around using
TypedDict
to type**kwargs
that comprise keyword arguments of different
types.
Motivation
Currently annotating**kwargs
with a typeT
means that thekwargs
type is in factdict[str,T]
.For example:
deffoo(**kwargs:str)->None:...
means that all keyword arguments infoo
are strings (i.e.,kwargs
is
of typedict[str,str]
). This behaviour limits the ability to type
annotate**kwargs
only to the cases where all of them are of the same type.
However, it is often the case that keyword arguments conveyed by**kwargs
have different types that are dependent on the keyword’s name. In those cases
type annotating**kwargs
is not possible. This is especially a problem for
already existing codebases where the need of refactoring the code in order to
introduce proper type annotations may be considered not worth the effort. This
in turn prevents the project from getting all of the benefits that type hinting
can provide.
Moreover,**kwargs
can be used to reduce the amount of code needed in
cases when there is a top-level function that is a part of a public API and it
calls a bunch of helper functions, all of which expect the same keyword
arguments. Unfortunately, if those helper functions were to use**kwargs
,
there is no way to properly type hint them if the keyword arguments they expect
are of different types. In addition, even if the keyword arguments are of the
same type, there is no way to check whether the function is being called with
keyword names that it actually expects.
As described in theIntended Usagesection,
using**kwargs
is not always the best tool for the job. Despite that, it is
still a widely used pattern. As a consequence, there has been a lot of
discussion around supporting more precise**kwargs
typing and it became a
feature that would be valuable for a large part of the Python community. This
is best illustrated by themypy GitHub issue 4441which
contains a lot of real world cases that could benefit from this propsal.
One more use case worth mentioning for which**kwargs
are also convenient,
is when a function should accommodate optional keyword-only arguments that
don’t have default values. A need for a pattern like that can arise when values
that are usually used as defaults to indicate no user input, such asNone
,
can be passed in by a user and should result in a valid, non-default behavior.
For example, this issuecame upin the popularhttpx
library.
Rationale
PEP 589introduced theTypedDict
type constructor that supports dictionary
types consisting of string keys and values of potentially different types. A
function’s keyword arguments represented by a formal parameter that begins with
double asterisk, such as**kwargs
,are received as a dictionary.
Additionally, such functions are often called using unpacked dictionaries to
provide keyword arguments. This makesTypedDict
a perfect candidate to be
used for more precise**kwargs
typing. In addition, withTypedDict
keyword names can be taken into account during static type analysis. However,
specifying**kwargs
type with aTypedDict
means, as mentioned earlier,
that each keyword argument specified by**kwargs
is aTypedDict
itself.
For instance:
classMovie(TypedDict):
name:str
year:int
deffoo(**kwargs:Movie)->None:...
means that each keyword argument infoo
is itself aMovie
dictionary
that has aname
key with a string type value and ayear
key with an
integer type value. Therefore, in order to support specifyingkwargs
type
as aTypedDict
without breaking current behaviour, a new construct has to
be introduced.
To support this use case, we propose reusingUnpack
which
was initially introduced inPEP 646.There are several reasons for doing so:
- Its name is quite suitable and intuitive for the
**kwargs
typing use case as our intention is to “unpack” the keywords arguments from the suppliedTypedDict
. - The current way of typing
*args
would be extended to**kwargs
and those are supposed to behave similarly. - There would be no need to introduce any new special forms.
- The use of
Unpack
for the purposes described in this PEP does not interfere with the use cases described inPEP 646.
Specification
WithUnpack
we introduce a new way of annotating**kwargs
.
Continuing the previous example:
deffoo(**kwargs:Unpack[Movie])->None:...
would mean that the**kwargs
comprise two keyword arguments specified by
Movie
(i.e. aname
keyword of typestr
and ayear
keyword of
typeint
). This indicates that the function should be called as follows:
kwargs:Movie={"name":"Life of Brian","year":1979}
foo(**kwargs)# OK!
foo(name="The Meaning of Life",year=1983)# OK!
WhenUnpack
is used, type checkers treatkwargs
inside the
function body as aTypedDict
:
deffoo(**kwargs:Unpack[Movie])->None:
assert_type(kwargs,Movie)# OK!
Using the new annotation will not have any runtime effect - it should only be taken into account by type checkers. Any mention of errors in the following sections relates to type checker errors.
Function calls with standard dictionaries
Passing a dictionary of typedict[str,object]
as a**kwargs
argument
to a function that has**kwargs
annotated withUnpack
must generate a
type checker error. On the other hand, the behaviour for functions using
standard, untyped dictionaries can depend on the type checker. For example:
deffoo(**kwargs:Unpack[Movie])->None:...
movie:dict[str,object]={"name":"Life of Brian","year":1979}
foo(**movie)# WRONG! Movie is of type dict[str, object]
typed_movie:Movie={"name":"The Meaning of Life","year":1983}
foo(**typed_movie)# OK!
another_movie={"name":"Life of Brian","year":1979}
foo(**another_movie)# Depends on the type checker.
Keyword collisions
ATypedDict
that is used to type**kwargs
could potentially contain
keys that are already defined in the function’s signature. If the duplicate
name is a standard parameter, an error should be reported by type checkers.
If the duplicate name is a positional-only parameter, no errors should be
generated. For example:
deffoo(name,**kwargs:Unpack[Movie])->None:...# WRONG! "name" will
# always bind to the
# first parameter.
deffoo(name,/,**kwargs:Unpack[Movie])->None:...# OK! "name" is a
# positional-only parameter,
# so **kwargs can contain
# a "name" keyword.
Required and non-required keys
By default all keys in aTypedDict
are required. This behaviour can be
overridden by setting the dictionary’stotal
parameter asFalse
.
Moreover,PEP 655introduced new type qualifiers -typing.Required
and
typing.NotRequired
- that enable specifying whether a particular key is
required or not:
classMovie(TypedDict):
title:str
year:NotRequired[int]
When using aTypedDict
to type**kwargs
all of the required and
non-required keys should correspond to required and non-required function
keyword parameters. Therefore, if a required key is not supported by the
caller, then an error must be reported by type checkers.
Assignment
Assignments of a function typed with**kwargs:Unpack[Movie]
and
another callable type should pass type checking only if they are compatible.
This can happen for the scenarios described below.
Source and destination contain**kwargs
Both destination and source functions have a**kwargs:Unpack[TypedDict]
parameter and the destination function’sTypedDict
is assignable to the
source function’sTypedDict
and the rest of the parameters are
compatible:
classAnimal(TypedDict):
name:str
classDog(Animal):
breed:str
defaccept_animal(**kwargs:Unpack[Animal]):...
defaccept_dog(**kwargs:Unpack[Dog]):...
accept_dog=accept_animal# OK! Expression of type Dog can be
# assigned to a variable of type Animal.
accept_animal=accept_dog# WRONG! Expression of type Animal
# cannot be assigned to a variable of type Dog.
Source contains**kwargs
and destination doesn’t
The destination callable doesn’t contain**kwargs
,the source callable
contains**kwargs:Unpack[TypedDict]
and the destination function’s keyword
arguments are assignable to the corresponding keys in source function’s
TypedDict
.Moreover, not required keys should correspond to optional
function arguments, whereas required keys should correspond to required
function arguments. Again, the rest of the parameters have to be compatible.
Continuing the previous example:
classExample(TypedDict):
animal:Animal
string:str
number:NotRequired[int]
defsrc(**kwargs:Unpack[Example]):...
defdest(*,animal:Dog,string:str,number:int=...):...
dest=src# OK!
It is worth pointing out that the destination function’s parameters that are to
be compatible with the keys and values from theTypedDict
must be keyword
only:
defdest(dog:Dog,string:str,number:int=...):...
dog:Dog={"name":"Daisy","breed":"labrador"}
dest(dog,"some string")# OK!
dest=src# Type checker error!
dest(dog,"some string")# The same call fails at
# runtime now because 'src' expects
# keyword arguments.
The reverse situation where the destination callable contains
**kwargs:Unpack[TypedDict]
and the source callable doesn’t contain
**kwargs
should be disallowed. This is because, we cannot be sure that
additional keyword arguments are not being passed in when an instance of a
subclass had been assigned to a variable with a base class type and then
unpacked in the destination callable invocation:
defdest(**kwargs:Unpack[Animal]):...
defsrc(name:str):...
dog:Dog={"name":"Daisy","breed":"Labrador"}
animal:Animal=dog
dest=src# WRONG!
dest(**animal)# Fails at runtime.
Similar situation can happen even without inheritance as compatibility
betweenTypedDict
s is based on structural subtyping.
Source contains untyped**kwargs
The destination callable contains**kwargs:Unpack[TypedDict]
and the
source callable contains untyped**kwargs
:
defsrc(**kwargs):...
defdest(**kwargs:Unpack[Movie]):...
dest=src# OK!
Source contains traditionally typed**kwargs:T
The destination callable contains**kwargs:Unpack[TypedDict]
,the source
callable contains traditionally typed**kwargs:T
and each of the
destination functionTypedDict
’s fields is assignable to a variable of
typeT
:
classVehicle:
...
classCar(Vehicle):
...
classMotorcycle(Vehicle):
...
classVehicles(TypedDict):
car:Car
moto:Motorcycle
defdest(**kwargs:Unpack[Vehicles]):...
defsrc(**kwargs:Vehicle):...
dest=src# OK!
On the other hand, if the destination callable contains either untyped or
traditionally typed**kwargs:T
and the source callable is typed using
**kwargs:Unpack[TypedDict]
then an error should be generated, because
traditionally typed**kwargs
aren’t checked for keyword names.
To summarize, function parameters should behave contravariantly and function return types should behave covariantly.
Passing kwargs inside a function to another function
A previous point mentions the problem of possibly passing additional keyword arguments by assigning a subclass instance to a variable that has a base class type. Let’s consider the following example:
classAnimal(TypedDict):
name:str
classDog(Animal):
breed:str
deftakes_name(name:str):...
dog:Dog={"name":"Daisy","breed":"Labrador"}
animal:Animal=dog
deffoo(**kwargs:Unpack[Animal]):
print(kwargs["name"].capitalize())
defbar(**kwargs:Unpack[Animal]):
takes_name(**kwargs)
defbaz(animal:Animal):
takes_name(**animal)
defspam(**kwargs:Unpack[Animal]):
baz(kwargs)
foo(**animal)# OK! foo only expects and uses keywords of 'Animal'.
bar(**animal)# WRONG! This will fail at runtime because 'breed' keyword
# will be passed to 'takes_name' as well.
spam(**animal)# WRONG! Again, 'breed' keyword will be eventually passed
# to 'takes_name'.
In the example above, the call tofoo
will not cause any issues at
runtime. Even thoughfoo
expectskwargs
of typeAnimal
it doesn’t
matter if it receives additional arguments because it only reads and uses what
it needs completely ignoring any additional values.
The calls tobar
andspam
will fail because an unexpected keyword
argument will be passed to thetakes_name
function.
Therefore,kwargs
hinted with an unpackedTypedDict
can only be passed
to another function if the function to which unpacked kwargs are being passed
to has**kwargs
in its signature as well, because then additional keywords
would not cause errors at runtime during function invocation. Otherwise, the
type checker should generate an error.
In cases similar to thebar
function above the problem could be worked
around by explicitly dereferencing desired fields and using them as arguments
to perform the function call:
defbar(**kwargs:Unpack[Animal]):
name=kwargs["name"]
takes_name(name)
UsingUnpack
with types other thanTypedDict
As described in theRationalesection,
TypedDict
is the most natural candidate for typing**kwargs
.
Therefore, in the context of typing**kwargs
,usingUnpack
with types
other thanTypedDict
should not be allowed and type checkers should
generate errors in such cases.
Changes toUnpack
Currently usingUnpack
in the context of
typing is interchangeable with using the asterisk syntax:
>>>Unpack[Movie]
*<class '__main__.Movie'>
Therefore, in order to be compatible with the new use case,Unpack
’s
repr
should be changed to simplyUnpack[T]
.
Intended Usage
The intended use cases for this proposal are described in the
Motivationsection. In summary, more precise**kwargs
typing
can bring benefits to already existing codebases that decided to use
**kwargs
initially, but now are mature enough to use a stricter contract
via type hints. Using**kwargs
can also help in reducing code duplication
and the amount of copy-pasting needed when there is a bunch of functions that
require the same set of keyword arguments. Finally,**kwargs
are useful for
cases when a function needs to facilitate optional keyword arguments that don’t
have obvious default values.
However, it has to be pointed out that in some cases there are better tools
for the job than usingTypedDict
to type**kwargs
as proposed in this
PEP. For example, when writing new code if all the keyword arguments are
required or have default values then writing everything explicitly is better
than using**kwargs
and aTypedDict
:
deffoo(name:str,year:int):...# Preferred way.
deffoo(**kwargs:Unpack[Movie]):...
Similarly, when type hinting third party libraries via stubs it is again better
to state the function signature explicitly - this is the only way to type such
a function if it has default arguments. Another issue that may arise in this
case when trying to type hint the function with aTypedDict
is that some
standard function parameters may be treated as keyword only:
deffoo(name,year):...# Function in a third party library.
deffoo(Unpack[Movie]):...# Function signature in a stub file.
foo("Life of Brian",1979)# This would be now failing type
# checking but is fine.
foo(name="Life of Brian",year=1979)# This would be the only way to call
# the function now that passes type
# checking.
Therefore, in this case it is again preferred to type hint such function explicitly as:
deffoo(name:str,year:int):...
Also, for the benefit of IDEs and documentation pages, functions that are part of the public API should prefer explicit keyword parameters whenever possible.
How to Teach This
This PEP could be linked in thetyping
module’s documentation. Moreover, a
new section on usingUnpack
could be added to the aforementioned docs.
Similar sections could be also added to the
mypy documentationand the
typing RTD documentation.
Reference Implementation
Themypy type checkeralready
supportsmore precise
**kwargs
typing usingUnpack
.
Pyright type checkeralso provides provisional support forthis feature.
Rejected Ideas
TypedDict
unions
It is possible to create unions of typed dictionaries. However, supporting
typing**kwargs
with a union of typed dicts would greatly increase the
complexity of the implementation of this PEP and there seems to be no
compelling use case to justify the support for this. Therefore, using unions of
typed dictionaries to type**kwargs
as described in the context of this PEP
can result in an error:
classBook(TypedDict):
genre:str
pages:int
TypedDictUnion=Movie|Book
deffoo(**kwargs:Unpack[TypedDictUnion])->None:...# WRONG! Unsupported use
# of a union of
# TypedDicts to type
# **kwargs
Instead, a function that expects a union ofTypedDict
s can be
overloaded:
@overload
deffoo(**kwargs:Unpack[Movie]):...
@overload
deffoo(**kwargs:Unpack[Book]):...
Changing the meaning of**kwargs
annotations
One way to achieve the purpose of this PEP would be to change the
meaning of**kwargs
annotations, so that the annotations would
apply to the entire**kwargs
dict, not to individual elements.
For consistency, we would have to make an analogous change to*args
annotations.
This idea was discussed in a meeting of the typing community, and the
consensus was that the change would not be worth the cost. There is no
clear migration path, the current meaning of*args
and**kwargs
annotations is well-established in the ecosystem, and type checkers
would have to introduce new errors for code that is currently legal.
Introducing a new syntax
In the previous versions of this PEP, using a double asterisk syntax was
proposed to support more precise**kwargs
typing. Using this syntax,
functions could be annotated as follows:
deffoo(**kwargs:**Movie):...
Which would have the same meaning as:
deffoo(**kwargs:Unpack[Movie]):...
This greatly increased the scope of the PEP, as it would require a grammar
change and adding a new dunder for theUnpack
special form. At the same
the justification for introducing a new syntax was not strong enough and
became a blocker for the whole PEP. Therefore, we decided to abandon the idea
of introducing a new syntax as a part of this PEP and may propose it again in a
separate one.
Copyright
This document is placed in the public domain or under the CC0-1.0-Universal license, whichever is more permissive.
Source:https://github.com/python/peps/blob/main/peps/pep-0692.rst
Last modified:2024-02-16 16:12:21 GMT