Аннотирование args и kwargs в Python
KOD
Типизация *args и **kwargs всегда меня расстраивала, так как их нельзя было заблаговременно снабдить точными аннотациями. Например, если и позиционные, и именованные аргументы функции могут содержать лишь значения одинаковых типов, можно было поступить так:
def foo(*args: int, **kwargs: bool) -> None:
...
Применение такой конструкции указывает на то, что args — это кортеж, все элементы которого являются целыми числами, а kwargs — это словарь, ключи которого являются строками, а значения имеют логический тип.
Но нельзя было адекватно аннотировать *args и **kwargs в ситуации, когда значения, которые можно передавать в качестве позиционных и именованных аргументов, могут, в разных обстоятельствах, относиться к различным типам. В таких случаях приходилось прибегать к Any, что противоречило цели типизации аргументов функции.
Взгляните на следующий пример:
def foo(*args: tuple[int, str], **kwargs: dict[str, bool | None]) -> None:
...
Тут система проверки типов воспринимает каждый из позиционных аргументов в виде кортежа из целого числа и строки. Кроме того, она считает каждый именованный аргумент словарём, ключи которого являются строками, а значения — либо сущностями логического типа, либо объектами None.
При использовании вышеописанных аннотаций mypy не пропустит следующий код:
foo(*(1, "hello"), **{"key1": 1, "key2": False})
error: Argument 1 to "foo" has incompatible type "*tuple[int, str]";
expected "tuple[int, str]" [arg-type]
error: Argument 2 to "foo" has incompatible type "**dict[str, int]";
expected "dict[str, bool | None]" [arg-type]
А вот такое будет признано нормальным:
foo((1, "hello"), kw1={"key1": 1, "key2": False})
Программисту, вероятно, хотелось бы воспользоваться первым вариантом кода, а вот системе проверки типов нужен второй его вариант.
Для того чтобы правильно аннотировать второй пример — нужно прибегнуть к инструментам из PEP-589, PEP-646, PEP-655, и PEP-692. А именно, мы воспользуемся Unpack и TypedDict из модуля typing. Вот как это сделать:
from typing import TypedDict, Unpack # Python 3.12+
# from typing_extensions import TypedDict, Unpack # < Python 3.12
class Kw(TypedDict):
key1: int
key2: bool
def foo(*args: Unpack[tuple[int, str]], **kwargs: Unpack[Kw]) -> None:
...
args = (1, "hello")
kwargs: Kw = {"key1": 1, "key2": False}
foo(*args, **kwargs) # Ok
Тип TypedDict появился в Python 3.8. Он позволяет аннотировать словари, поддерживающие значения различных типов. Если все значения словаря имеют один и тот же тип — для его аннотирования можно просто воспользоваться конструкцией dict[str, T]. А вот тип TypedDict ориентирован на ситуации, когда все ключи словаря являются строками, а значения могут иметь различные типы.
В следующем примере показано то, как можно аннотировать словарь, содержащий значения различных типов:
from typing import TypedDict
class Movie(TypedDict):
name: str
year: int
movies: Movie = {"name": "Mad Max", "year": 2015}
С помощью оператора Unpack можно показать, что объекты являются распакованными.
Использование TypedDict с Unpack позволяет указать системе проверки типов на то, что ей не надо допускать ошибку, считая каждый позиционный и именованный аргументы, соответственно, кортежем и словарём.
Система проверки типов не возражает, когда *args и **kwargs передают в таком виде:
foo(*args, **kwargs)
Но её не устраивает, когда передаются не все именованные аргументы:
foo(*args, key1=1) # error: Missing named argument "key2" for "foo"
Для того чтобы сделать все именованные аргументы необязательными, можно отключить флаг total в определении класса, в котором используется TypedDict:
# ...
class Kw(TypedDict, total=False):
key1: int
key2: str
# ...
Или можно, воспользовавшись typing.NotRequired, указать на необязательность отдельных именованных аргументов:
# ...
class Kw(TypedDict):
key1: int
key2: NotRequired[str]
# ...