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

PEP 692: Update specification #3308

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Changes from all 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
200 changes: 13 additions & 187 deletions pep-0692.rst
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,17 @@ type ``int``). This indicates that the function should be called as follows::
foo(**kwargs) # OK!
foo(name="The Meaning of Life", year=1983) # OK!

For the purposes of type checking, functions annotaded with ``Unpack`` should
be considered equivalent to their explicitly expanded counterparts. Therefore,
from the type checker's point of view::

foo(**kwargs: Unpack[Movie]) -> None: ...

is equivalent to::

foo(*, name: str, year: int) -> None: ...


When ``Unpack`` is used, type checkers treat ``kwargs`` inside the
function body as a ``TypedDict``::

Expand Down Expand Up @@ -195,193 +206,8 @@ 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's ``TypedDict`` is assignable to the
source function's ``TypedDict`` and the rest of the parameters are
compatible::

class Animal(TypedDict):
name: str

class Dog(Animal):
breed: str

def accept_animal(**kwargs: Unpack[Animal]): ...
def accept_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.

.. _PEP 692 assignment dest no kwargs:

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::

class Example(TypedDict):
animal: Animal
string: str
number: NotRequired[int]

def src(**kwargs: Unpack[Example]): ...
def dest(*, 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 the ``TypedDict`` must be keyword
only::

def dest(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::

def dest(**kwargs: Unpack[Animal]): ...
def src(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
between ``TypedDict``\s is based on structural subtyping.

Source contains untyped ``**kwargs``
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

The destination callable contains ``**kwargs: Unpack[TypedDict]`` and the
source callable contains untyped ``**kwargs``::

def src(**kwargs): ...
def dest(**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 function ``TypedDict``'s fields is assignable to a variable of
type ``T``::

class Vehicle:
...

class Car(Vehicle):
...

class Motorcycle(Vehicle):
...

class Vehicles(TypedDict):
car: Car
moto: Motorcycle

def dest(**kwargs: Unpack[Vehicles]): ...
def src(**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 <PEP 692 assignment dest no kwargs>`_
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::

class Animal(TypedDict):
name: str

class Dog(Animal):
breed: str

def takes_name(name: str): ...

dog: Dog = {"name": "Daisy", "breed": "Labrador"}
animal: Animal = dog

def foo(**kwargs: Unpack[Animal]):
print(kwargs["name"].capitalize())

def bar(**kwargs: Unpack[Animal]):
takes_name(**kwargs)

def baz(animal: Animal):
takes_name(**animal)

def spam(**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 to ``foo`` will not cause any issues at
runtime. Even though ``foo`` expects ``kwargs`` of type ``Animal`` 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 to ``bar`` and ``spam`` will fail because an unexpected keyword
argument will be passed to the ``takes_name`` function.

Therefore, ``kwargs`` hinted with an unpacked ``TypedDict`` 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 the ``bar`` function above the problem could be worked
around by explicitly dereferencing desired fields and using them as arguments
to perform the function call::

def bar(**kwargs: Unpack[Animal]):
name = kwargs["name"]
takes_name(name)
Functions typed with ``Unpack`` should be expanded to their explicit signatures
and assignment correctness should be determined by already existing rules.

Using ``Unpack`` with types other than ``TypedDict``
----------------------------------------------------
Expand Down