The Python Oracle

Type annotations for *args and **kwargs

Become part of the top 3% of the developers by applying to Toptal https://topt.al/25cXVn

--

Music by Eric Matyas
https://www.soundimage.org
Track title: Magic Ocean Looping

--

Chapters
00:00 Question
01:26 Accepted answer (Score 434)
02:46 Answer 2 (Score 53)
04:29 Answer 3 (Score 49)
06:50 Answer 4 (Score 28)
07:23 Thank you

--

Full question
https://stackoverflow.com/questions/3703...

Accepted answer links:
[Arbitrary argument lists and default argument values]: https://www.python.org/dev/peps/pep-0484...

Answer 2 links:
[@overload]: https://docs.python.org/3/library/typing...

Answer 3 links:
[added]: https://github.com/python/mypy/pull/1347...
[Mypy 0.981]: https://mypy-lang.blogspot.com/2022/09/m...
[GitHub issue]: https://github.com/python/mypy/issues/44...

--

Content licensed under CC BY-SA
https://meta.stackexchange.com/help/lice...

--

Tags
#python #typehinting #pythontyping

#avk47



ACCEPTED ANSWER

Score 530


For variable positional arguments (*args) and variable keyword arguments (**kw) you only need to specify the expected value for one such argument.

From the Arbitrary argument lists and default argument values section of the Type Hints PEP:

Arbitrary argument lists can as well be type annotated, so that the definition:

def foo(*args: str, **kwds: int): ...

is acceptable and it means that, e.g., all of the following represent function calls with valid types of arguments:

foo('a', 'b', 'c')
foo(x=1, y=2)
foo('', z=0)

So you'd want to specify your method like this:

def foo(*args: int):

However, if your function can only accept either one or two integer values, you should not use *args at all, use one explicit positional argument and a second keyword argument:

def foo(first: int, second: Optional[int] = None):

Now your function is actually limited to one or two arguments, and both must be integers if specified. *args always means 0 or more, and can't be limited by type hints to a more specific range.




ANSWER 2

Score 117


2022 Update

The mypy team added support for Unpack, this is available since Mypy 0.981 or higher.

Attention! Although this feature is complete, Unpack[...] is still considered experimental, so you will need to use --enable-incomplete-features to enable it.

You can use this feature as follows:

from typing import TypedDict
from typing_extensions import Unpack


class RequestParams(TypedDict):
    url: str
    allow_redirects: bool


def request(**kwargs: Unpack[RequestParams]) -> None:
    ...

If you call the request function with the arguments defined in the TypedDict, you won't get any errors:

# OK
request(url="https://example.com", allow_redirects=True)

If you forget to pass an argument, mypy will let you know now 😊

# error: Missing named argument "allow_redirects" for "request"  [call-arg]
request(url="https://example.com")

You can also make the fields non-required by adding total=False to the TypedDict:

class RequestParams(TypedDict, total=False):
    url: str
    allow_redirects: bool

# OK
request(url="https://example.com")

Alternatively, you can use the Required and NotRequired annotations to control whether a keyword argument is required or not:

from typing import TypedDict
from typing_extensions import Unpack, NotRequired


class RequestParams(TypedDict):
    url: str
    allow_redirects: NotRequired[bool]

def request(**kwargs: Unpack[RequestParams]) -> None:
    ...

# OK
request(url="https://example.com", allow_redirects=True)

Old answer below:

While you can annotate variadic arguments with a type, I don't find it very useful because it assumes that all arguments are of the same type.

The proper type annotation of *args and **kwargs that allows specifying each variadic argument separately is not supported by mypy yet. There is a proposal for adding an Expand helper on mypy_extensions module, it would work like this:

class Options(TypedDict):
    timeout: int
    alternative: str
    on_error: Callable[[int], None]
    on_timeout: Callable[[], None]
    ...

def fun(x: int, *, **options: Expand[Options]) -> None:
    ...

The GitHub issue was opened on January 2018 but it's still not closed. Note that while the issue is about **kwargs, the Expand syntax will likely be used for *args as well.




ANSWER 3

Score 55


The easiest way to do this -- without changing your function signature -- is using @overload

First, some background. You cannot annotate the type of *args as a whole, only the type of the items in args. So you can't say that *args is Tuple[int, int] you can only say that the type of each item within *args is int. That means that you can't put a limit on the length of *args or use a different type for each item.

To solve this you can consider changing the signature of your function to give it named arguments, each with their own type annotation, but if want (or need) to keep your function using *args, you can get mypy to work using @overload:

from typing import overload

@overload
def foo(arg1: int, arg2: int) -> int:
    ...

@overload
def foo(arg: int) -> int:
    ...

def foo(*args):
    try:
        i, j = args
        return i + j
    except ValueError:
        assert len(args) == 1
        i = args[0]
        return i

print(foo(1))
print(foo(1, 2))

Note that you do not add @overload or type annotations to the actual implementation, which must come last.

You can also use this to vary the returned result in a way that makes explicit which argument types correspond with which return type. e.g.:

from typing import Tuple, overload

@overload
def foo(arg1: int, arg2: int) -> Tuple[int, int]:
    ...

@overload
def foo(arg: int) -> int:
    ...

def foo(*args):
    try:
        i, j = args
        return j, i
    except ValueError:
        assert len(args) == 1
        i = args[0]
        return i

print(foo(1))
print(foo(1, 2))



ANSWER 4

Score 28


As a short addition to the previous answer, if you're trying to use mypy on Python 2 files and need to use comments to add types instead of annotations, you need to prefix the types for args and kwargs with * and ** respectively:

def foo(param, *args, **kwargs):
    # type: (bool, *str, **int) -> None
    pass

This is treated by mypy as being the same as the below, Python 3.5 version of foo:

def foo(param: bool, *args: str, **kwargs: int) -> None:
    pass