Les Crash-Devs d'un Passionné

Type hinting sur *args et **kwargs en python, avec l'aide de Mypy

/Catégorie/python

Temps de lecture : 8 minutes

Le type hinting de *args et **kwargs en Python pose des problèmes que nous allons voir en détail.

Mettre en place du typage en python est possible depuis la version 3.5 et cela est une bonne pratique pour donner des instructions au développeur.

Il est important de savoir que ce typage n'est pas inclus dans le runtime mais bien là pour assister le développeur à implémenter correctement ses instructions, grâce notamment à MyPy

Voici un exemple de typage simple

clipboard
Copier le code
1
2
def add(a:int, b:int) -> int:
   return a + b

Rappel sur les paramètres positionnels et nommés

Dans la signature d'une fonction on peut trouver :

  • Des paramètres positionnels, ce sont les premiers et, lors de l'appel, on ne précise pas le nom du paramètre. Ils sont stockés dans un tuple et notés par convention
    *args
  • Des paramètres nommés, ce sont les derniers et lors de l'appel on précise forcément le nom du paramètre. Ils sont stockés dans un dictionnaire et notés par convention
    **kwargs

J'en parle plus longuement sur un article dédié à l'usage de ces paramètres args et kwargs

Comment utiliser ces paramètres

Il suffit de les placer comme suit, vous remarquez qu'en fonction des implémentations, la fonction

add()
s'adapte parfaitement au nombre de paramètres qu'on lui injecte, et ce sans changer la signature de la fonction.

On appelle cela la variadicité

clipboard
Copier le code
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
def add(*args, **kwargs):
     print(args)
     print(kwargs)
   
>>> add(1, 2)
(1, 2)
{}
>>> add(1, b=2)
(1,)
{'b': 2}
>>> add(a=1, b=2)
()
{'a': 1, 'b': 2}

Appliquer le type hinting sur *args et **kwargs

En suivant ce principe si on veut mettre du typage sur *args et **kwargs on ferait

clipboard
Copier le code
1
2
3
4
5
# Mauvais typage sur args et kwargs
def add(*args:tuple[int], **kwargs:dict[str, int]) -> int:
     print(args)
     print(kwargs)
     ...

L'analyseur de type (comme MyPy) retournera une erreur en indiquant

Argument 1 to "add" has incompatible type "int"; expected "tuple[int]"

La PEP 484 permet de simplifier le typage pour faire le binding entre

int
et
tuple[int]
pour les paramètres positionnels
Ce qui donne

clipboard
Copier le code
1
2
def add(*args: int, **kwargs: int) -> int:
     ...

On peut ainsi typer de manière plus lisible comme

clipboard
Copier le code
1
2
def add(*args: int | float, **kwargs: int | float) -> float:
     ...

Mais cette notation avec le pipe est limitée. Le typage

**kwargs: int
impose que tous les paramètres nommés soient du même type. Dans la vraie vie, ce n'est presque jamais le cas : une fonction reçoit souvent des options de types variés (un timeout entier, un booléen retry…).

Typer précisément **kwargs avec TypedDict et Unpack (PEP 692)

Depuis Python 3.12, la PEP 692 permet de décrire précisément chaque clé de **kwargs grâce à un TypedDict combiné à Unpack.

clipboard
Copier le code
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
from typing import NotRequired, TypedDict, Unpack


class Options(TypedDict):
    timeout: int
    retry: NotRequired[bool]


def fetch(url: str, **kwargs: Unpack[Options]) -> None:
    ...


fetch("https://rigaudie.fr", timeout=10)   # OK
fetch("https://rigaudie.fr", timeout=10, retry=True)   # OK
fetch("https://rigaudie.fr", timeout="10")   # erreur MyPy
fetch("https://rigaudie.fr", timeout=10, debug=True)   # erreur MyPy

NotRequired
permet de rendre une clé optionnelle. Pour les versions antérieures à Python 3.12,
Unpack
est disponible dans le package
typing_extensions
.

Typer *args avec *tuple et TypeVarTuple (PEP 646)

On rencontre le même problème pour les paramètres positionnels :

*args: int
force tous les arguments à être des entiers. Si l'on attend une séquence de types précise, la PEP 646 (Python 3.11) autorise la syntaxe
*tuple[...]
:

clipboard
Copier le code
1
2
3
4
5
def add(*args: *tuple[int, float]) -> float:
   ...

add(1, 2.3)    # OK
add(2.3, 1)    # erreur MyPy : l'ordre des types compte

Pour aller plus loin,

TypeVarTuple
permet de conserver les types des arguments reçus, quels qu'ils soient. C'est un typage générique variadique :

clipboard
Copier le code
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
from typing import TypeVarTuple, reveal_type

Ts = TypeVarTuple("Ts")


def to_tuple(*args: *Ts) -> tuple[*Ts]:
    return args


reveal_type(to_tuple(1, "a", 2.0))  # tuple[int, str, float]

Depuis Python 3.12, la syntaxe se simplifie, plus besoin d'importer

typing
. On va se reposer sur les génériques, pour en savoir plus consultez Variant, contravariant et invariant

clipboard
Copier le code
1
2
def to_tuple[*Ts](*args: *Ts) -> tuple[*Ts]:
    return args

*args: Any ou *args: object : lequel choisir ?

Quand une fonction accepte vraiment n'importe quoi, on est tenté d'écrire

*args: Any
.

Attention :

Any
désactive la vérification de type.
MyPy
acceptera alors n'importe quelle opération sur ces valeurs, même erronée.

clipboard
Copier le code
1
2
3
4
5
from typing import Any

def do_something(*args: Any) -> None:
    for arg in args:
        print(arg.upper())    # aucune erreur MyPy... mais AttributeError si arg est un int

Préférez

object
, qui accepte tout en entrée mais vous oblige à vérifier le type avant de l'utiliser.

clipboard
Copier le code
1
2
3
4
5
6
def do_something(*args: object) -> None:
    for arg in args:
        if isinstance(arg, str):
            print(arg.upper())    # OK : le type est vérifié (grâce au if du dessus)
        else:
            print(arg)

Sans le

isinstance
,
MyPy
refuserait d'ailleurs
do_something
:

error: "object" has no attribute "upper"

Réservez

Any
aux cas où le typage précis est impossible, par exemple pour du code legacy ou une librairie non typée.

  • Any
    => accepte tous les types et ne fait pas de vérification (mypy OK)
  • object
    => accepte tous les types mais fait des vérifications avant de l'utiliser (mypy KO sans les vérifications, ex:
    isinstance
    )

Est-ce que le typage impacte les performances d'une application python ?

Bien sûr la réponse est non, puisque ce typage n'est pas interprété au runtime. Il sert aux IDE avec le couplage de Mypy (ou autre) et dans la CI pour fournir des indications sur l'usage des méthodes / fonctions

L’interpréteur ne vérifie jamais les types quand on appelle une fonction.

add(1, 2, 3)
s’exécute exactement à la même vitesse, qu’elle soit annotée ou non.

Il y a cependant une nuance. Jusqu’à Python 3.13, les annotations sont évaluées au moment où la fonction est définie, c’est-à-dire à l’import du module. Python calcule chaque expression d’annotation et stocke le résultat dans

__annotations__
. On peut le vérifier facilement :

clipboard
Copier le code
1
2
3
4
5
6
7
class Panier:
    def __init__(self) -> None:
        self.articles: list[str] = []

    def ajouter(self, *articles: str) -> Panier: # NameError: name 'Panier' is not defined
        self.articles.extend(articles)
        return self

Ce coût est négligeable dans la très grande majorité des cas, mais il existe. Un module avec des centaines de fonctions annotées par des types complexes (génériques imbriqués,

TypedDict
…) les évalue tous à l’import.

On pouvait déjà l’éviter avec

from __future__ import annotations
(PEP 563), qui stocke les annotations sous forme de chaînes de caractères au lieu de les évaluer.

Sinon dans l'exemple ci-dessus on mettrait

-> "Panier"

Depuis Python 3.14, les annotations sont paresseuses (PEP 649). Elles ne sont plus évaluées à la définition de la fonction, mais seulement lorsqu’on accède à

__annotations__
:

clipboard
Copier le code
1
2
>>> add.__annotations__
{'args': int | float, 'kwargs': int | float, 'return': <class 'float'>}

Le coût à l’import disparaît donc, et on gagne au passage la possibilité de référencer une classe qui n’est pas encore définie, sans la mettre entre guillemets.

Une petite nuance : certaines librairies comme

pydantic
utilisent ce dunder pour lire les informations de typage. À ce moment-là le typage a un coût, en même temps c'est le but de faire une vérification des données selon le type déclaré donc c'est largement acceptable