Annotations et signatures

Intéressons-nous maintenant à ce qui entoure les fonctions. Une fonction n’est pas seulement un nom ou un bloc de code à exécuter. Elle possède aussi des informations complémentaires comme des noms de paramètres, des annotations, ou une docstring.

Ce chapitre s’intéresse justement à ces informations : comment les définir, mais surtout comment y accéder et ce que l’on peut en faire.

Annotations

Commençons par les annotations. Il se peut que vous ne les ayez jamais rencontrées, il s’agit d’une fonctionnalité relativement nouvelle du langage.

Les annotations sont des informations de types que l’on peut ajouter sur les paramètres et le retour d’une fonction.

Prenons une fonction d’addition entre deux nombres.

1
2
def addition(a, b):
    return a + b

Nous avons destiné cette fonction à des calculs numériques, mais nous pourrions aussi l’appeler avec des chaînes de caractères. Les annotations vont nous permettre de préciser le type des paramètres attendus, et le type de la valeur de retour.

1
2
def addition(a: int, b: int) -> int:
    return a + b

Leur définition est assez simple : pour annoter un paramètre, on le fait suivre d’un : et on ajoute le type attendu. Pour annoter le retour de la fonction, on ajoute -> puis le type derrière la liste des paramètres.

Attention cependant, les annotations ne sont là qu’à titre indicatif. Rien n’empêche de continuer à appeler notre fonction avec des chaînes de caractères.

À ce titre, on notera aussi que donner des types comme annotations n’est qu’une convention. Annoter des paramètres avec des chaînes de caractères ne provoquera pas d’erreur par exemple.

Utilité des annotations

Si elles ne sont là qu’à titre indicatif, les annotations sont surtout utiles dans un but de documentation. Elles sont le meilleur moyen en Python de documenter les types des paramètres et de retour d’une fonction.

Elles apparaissent d’ailleurs dans l’aide de la fonction fournie par help.

1
2
3
4
>>> help(addition)
Help on function addition in module __main__:

addition(a:int, b:int) -> int

Les annotations ne sont pas censées avoir d’autre utilité lors de l’exécution d’un programme Python. Elles ne sont pas destinées à vérifier au runtime le type des paramètres par exemple. La définitition d’annotations ne doit normalement rien changer sur le déroulement du programme.

Toutefois, les annotations ont l’utilité que l’on veut bien leur donner. Il existe des outils d’analyse statique tels que mypy qui peuvent en tirer partie. Ces outils n’exécutent pas le code, mais se contentent de vérifier que les types utilisés n’entrent pas en conflit avec les annotations.

Des types plus complexes (module typing)

Nous avons défini une fonction addition opérant sur deux nombres, mais l’avons annotée comme ne pouvant recevoir que des nombres entiers (int).

En effet, les annotations utilisées jusqu’ici étaient plutôt simples. Mais elles peuvent accueillir des expressions plus complexes.

Le module typing nous présente une collection de classes pour composer des types. Ce module a été introduit dans Python 3.5, et n’est donc pas disponible dans les versions précédentes du langage.

Dans notre fonction addition, nous voudrions en fait que les int, float et complex soient admis. Nous pouvons pour cela utiliser le type Union du module typing. Il nous permet de définir un ensemble de types valides pour nos paramètres, et s’utilise comme suit.

1
2
3
4
Number = Union[int, float, complex]

def addition(a: Number, b: Number) -> Number:
    return a + b

Nous définissons premièrement un type Number comme l’ensemble des types int, float et complex via la syntaxe Union[...]. Puis nous utilisons notre nouveau type Number au sein de nos annotations.

Outre Union, le module typing présente d’autres types génériques pour avoir des annotations plus précises. Nous pourrions avoir, par exemple :

  • List[str] – Une liste de chaînes de caractères ;
  • Sequence[str] – Une séquence (liste/tuple/etc.) de chaînes de caractères ;
  • Callable[[str, int], str] – Un callable prenant deux paramètres de types str et int respectivement, et retournant un objet str ;
  • Et bien d’autres à découvrir dans la documentation du module.

Attention encore, le module typing ne doit servir que dans le cadre des annotations. Les types fournis par ce module ne doivent pas être utilisées au sein d’expressions avec isinstance ou issubclass.

Dans le cas précis de notre fonction addition, nous aurions aussi pu utiliser le type Number du module numbers. Nous y reviendrons plus tard dans ce cours, mais il s’agit d’un type qui regroupe et hiérarchise tous les types numériques.

Annotations de variables

Les annotations sont ici abodées sous l’angle des fonctions et de leurs paramètres. Mais il est à noter que depuis Python 3.6, il est aussi possible d’annoter les variables et attributs.

La syntaxe est la même que pour les paramètres de fonction. Encore une fois, les annotations sont là à titre indicatif, et pour les analyseurs statiques. Elles sont toutefois stockées dans le dictionnaire __annotations__ du module ou de la classe qui les contient.

1
2
max_value : int = 10 # Définition d'une variable max_value annotée comme int
min_value : int      # Annotation seule, la variable n'est pas définie dans ce cas

Un petit coup d’œil à la variable __annotations__ et aux variables annotées.

1
2
3
4
5
6
7
8
>>> __annotations__
{'max_value': <class 'int'>, 'min_value': <class 'int'>}
>>> max_value
10
>>> min_value
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
NameError: name 'min_value' is not defined

Inspecteur Gadget

Nous savons maintenant renseigner des informations complémentaires sur nos fonctions. En plus des annotations vues précédemment, vous avez probablement déjà rencontré les docstrings.

Les docstrings sont des chaînes de caractères, à placer en tête d’une fonction, d’une classe ou d’un module. Elles servent à décrire l’usage de ces objets, et sont accessibles dans l’aide fournie par help, au même titre que les annotations.

1
2
3
def addition(a:int, b:int) -> int:
    "Return the sum of the two numbers `a` and `b`"
    return a + b
1
2
3
4
5
>>> help(addition)
Help on function addition in module __main__:

addition(a:int, b:int) -> int
    Return the sum of the two numbers `a` and `b`

Elles deviennent aussi accessibles par l’attribut spécial __doc__ de l’objet.

1
2
>>> addition.__doc__
'Return the sum of the two numbers `a` and `b`'

Module inspect

inspect est un module de la bibliothèque standard qui permet d’extraire des informations complémentaires sur les objets Python. Il est notamment dédié aux modules, classes et fonctions.

Il comporte en effet des fonctions pour vérifier le type d’un objet : ismodule, isclass, isfunction, etc.

1
2
3
4
5
6
7
8
9
>>> import inspect
>>> inspect.ismodule(inspect)
True
>>> inspect.isclass(int)
True
>>> inspect.isfunction(addition)
True
>>> inspect.isbuiltin(len)
True

D’autres fonctions vont s’intéresser plus particulièrement aux documentations (getdoc) et à la gestion du code source (getsource, getsourcefile, etc.).

Imaginons un fichier operations.py contenant le code suivant :

1
2
3
4
5
6
7
8
9
"Mathematical operations"

def addition(a:int, b:int) -> int:
    """
    Return the sum of the two numbers `a` and `b`

    ex: addition(3, 5) -> 8
    """
    return a + b

Depuis la fonction addition importée du module, nous pourrons grâce à inspect récupérer toutes les informations nécessaires au débogage.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
>>> import inspect
>>> from operations import addition
>>> inspect.getdoc(addition)
'Return the sum of the two numbers `a` and `b`\n\nex: addition(3, 5) -> 8'
>>> inspect.getsource(addition)
'def addition(a:int, b:int) -> int:\n    [...]\n    return a + b\n'
>>> inspect.getsourcefile(addition)
'/home/entwanne/operations.py'
>>> inspect.getmodule(addition)
<module 'operations' from '/home/entwanne/operations.py'>
>>> inspect.getsourcelines(addition)
(['def addition(a:int, b:int) -> int:\n', ..., '    return a + b\n'], 3)

L’intérêt de getdoc par rapport à l’attribut __doc__ étant que la documentation est nettoyée (suppression des espaces en début de ligne) par la fonction cleandoc.

1
2
3
4
>>> addition.__doc__
'\n    Return the sum of the two numbers `a` and `b`\n\n    ex: addition(3, 5) -> 8\n    '
>>> inspect.cleandoc(addition.__doc__)
'Return the sum of the two numbers `a` and `b`\n\nex: addition(3, 5) -> 8'

Signatures

Le module inspect ne nous a pas encore révélé toutes ses surprises. Il contient aussi une méthode signature, retournant la signature d’une fonction.

La signature est l’ensemble des paramètres (avec leurs noms, positions, valeurs par défaut et annotations), ainsi que l’annotation de retour d’une fonction. C’est-à-dire toutes les informations décrites à droite du nom de fonction lors d’une définition.

1
2
3
>>> sig = inspect.signature(addition)
>>> print(sig)
(a:int, b:int) -> int

Les objets retournés par signature sont de type Signature. Ces objets comportent notamment un dictionnaire ordonné des paramètres de la fonction (attribut parameters), et l’annotation de retour (return_annotation).

Les paramètres sont un encore un nouveau type d’objets, Parameter. Les objets Parameter possèdent un nom (name), une valeur par défaut (default), une annotation (annotation), et un type de positionnement (kind).

1
2
3
4
5
6
7
>>> sig.return_annotation
<class 'int'>
>>> for param in sig.parameters.values():
...     print(param.name, param.default, param.annotation, param.kind)
...
a <class 'inspect._empty'> <class 'int'> POSITIONAL_OR_KEYWORD
b <class 'inspect._empty'> <class 'int'> POSITIONAL_OR_KEYWORD

On retrouve bien les noms et annotations de nos paramètres. inspect._empty indique que nos paramètres ne prennent pas de valeurs par défaut.

Le type de positionnement correspond à la différence entre arguments positionnels et arguments nommés, ils sont au nombre de 5 :

  • POSITIONAL_ONLY – Le paramètre ne peut recevoir qu’un argument positionnel ;
  • POSITIONAL_OR_KEYWORD – Le paramètre peut indifféremment recevoir un argument positionnel ou nommé ;
  • VAR_POSITIONAL – Correspond au paramètre spécial *args ;
  • KEYWORD_ONLY – Le paramètre ne peut recevoir qu’un argument nommé ;
  • VAR_KEYWORD – Correspond au paramètre spécial **kwargs.

Il n’existe aucune syntaxe en Python pour définir des paramètres positional-only, ils existent cependant dans certaines builtins (range par exemple).

Les positonal-or-keyword sont les plus courants. Ils sont en fait tous les paramètres définis à gauche d’*args.

Les keyword-only sont ceux définis à sa droite.

Pour prendre une signature de fonction plus complète :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
>>> def function(a, b:int, c=None, d:int=0, *args, g, h:int, i='foo', j:float=5.0, **kwargs):
...     pass
...
>>> for param in inspect.signature(function).parameters.values():
...     print(param.name, param.default, param.annotation, param.kind)
...
a <class 'inspect._empty'> <class 'inspect._empty'> POSITIONAL_OR_KEYWORD
b <class 'inspect._empty'> <class 'int'> POSITIONAL_OR_KEYWORD
c None <class 'inspect._empty'> POSITIONAL_OR_KEYWORD
d 0 <class 'int'> POSITIONAL_OR_KEYWORD
args <class 'inspect._empty'> <class 'inspect._empty'> VAR_POSITIONAL
g <class 'inspect._empty'> <class 'inspect._empty'> KEYWORD_ONLY
h <class 'inspect._empty'> <class 'int'> KEYWORD_ONLY
i foo <class 'inspect._empty'> KEYWORD_ONLY
j 5.0 <class 'float'> KEYWORD_ONLY
kwargs <class 'inspect._empty'> <class 'inspect._empty'> VAR_KEYWORD

Rappelons qu’il est possible d’avoir des paramètres keyword-only sans pour autant définir *args. Il faut pour cela avoir un simple * dans la liste des paramètres, juste à gauche des keyword-only.

1
2
3
4
5
6
7
8
9
>>> def function(a, *, b, c):
...     pass
...
>>> for param in inspect.signature(function).parameters.values():
...     print(param.name, param.kind)
...
a POSITIONAL_OR_KEYWORD
b KEYWORD_ONLY
c KEYWORD_ONLY

Arguments préparés

Nous venons de voir quelles informations nous pouvions tirer des signatures. Mais celles-ci ne servent pas qu’à la documentation.

Les objets Signature sont aussi pourvus d’une méthode bind. Cette méthode reçoit les mêmes arguments que la fonction cible et retourne un objet de type BoundArguments, qui fait la correspondance entre les paramètres et les arguments, après vérification que ces derniers respectent la signature.

L’objet BoundArguments, avec ses attributs args et kwargs, pourra ensuite être utilisé pour appeler la fonction cible.

1
2
3
4
5
6
7
8
>>> sig = inspect.signature(addition)
>>> bound = sig.bind(3, b=5)
>>> bound.args
(3, 5)
>>> bound.kwargs
{}
>>> addition(*bound.args, **bound.kwargs)
8

Comme on peut le voir, cela permet de résoudre les paramètres pour n’avoir dans kwargs que les keyword-only, et les autres (ceux qui peuvent être positionnels) dans args. Cet objet BoundArguments sera donc identique quelle que soit la manière dont seront passés les arguments.

1
2
>>> sig.bind(3, 5) == sig.bind(b=5, a=3)
True

Nous pouvons ainsi avoir une représentation unique des arguments de l’appel, pouvant servir pour une mise en cache des résultats par exemple.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
cache_addition = {}
sig_addition = inspect.signature(addition)

def addition_with_cache(*args, **kwargs):
    bound = sig_addition.bind(*args, **kwargs)
    # addition ne reçoit aucun paramètre keyword-only
    # nous pouvons donc nous contenter de bound.args
    if bound.args in cache_addition:
        print('Retrieving from cache')
        return cache_addition[bound.args]
    print('Computing result')
    result = cache_addition[bound.args] = addition(*bound.args)
    return result
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
>>> addition_with_cache(3, 5)
Computing result
8
>>> addition_with_cache(3, b=5)
Retrieving from cache
8
>>> addition_with_cache(b=5, a=3)
Retrieving from cache
8
>>> addition_with_cache(5, 3) # Le résultat de (a=5, b=3) n'est pas en cache
Computing result
8

Depuis Python 3.5, une autre fonctionnalité des BoundArguments est de pouvoir appliquer les valeurs par défaut aux paramètres. Ils ont pour cela une méthode apply_defaults.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
>>> def addition_with_default(a, b=3):
...     return a + b
...
>>> sig = inspect.signature(addition_with_default)
>>> bound = sig.bind(5)
>>> bound.args
(5,)
>>> bound.apply_defaults()
>>> bound.args
(5, 3)

Méthode replace

Je voudrais enfin vous parler des méthodes replace des objets Signature et Parameter. Ces méthodes permettent de retourner une copie de l’objet en en modifiant un ou plusieurs attributs.

Nous pourrons sur un paramètre modifier les valeurs name, kind, default et annotation.

1
2
3
4
5
6
7
8
>>> param = sig.parameters['b']
>>> print(param)
b=3
>>> new_param = param.replace(name='c', default=4, annotation=int)
>>> print(new_param)
c:int=4
>>> print(param)
b=3

La méthode replace des signatures est similaire, et permet de modifier parameters et return_annotation.

1
2
3
4
5
6
>>> print(sig)
(a, b=3)
>>> new_params = [sig.parameters['a'], new_param]
>>> new_sig = sig.replace(parameters=new_params)
>>> print(new_sig)
(a, c:int=4)

TP : Vérification de signature

Afin de nous exercer un peu sur les signatures, nous allons créer une fonction vérifiant la validité d’un ensemble d’arguments. Cette fonction, signature_check, recevra en paramètres la signature à tester, et tous les arguments positionnels et nommés de l’appel.

Prototype

On pourrait à première vue la prototyper ainsi.

1
2
def signature_check(signature, *args, **kwargs):
    ...

Mais ce serait oublier que signature peut aussi posséder un paramètre nommé signature, qui ne pourrait alors être testé. Nous allons donc plutôt récupérer la signature à tester depuis *args.

1
2
3
def signature_check(*args, **kwargs):
    signature, *args = args # Soit signature = args[0] et args = list(args[1:])
    ...

Passons maintenant au contenu de notre fonction. Celle-ci va devoir itérer sur les paramètres, pour leur faire correspondre les arguments. On lèvera des TypeError en cas de non-correspondance ou de manque/surplus d’arguments.

Le plus simple est de procéder en consommant la liste args et le dictionnaire kwargs. Les arguments seront supprimés chaque fois qu’une correspondance sera faite avec un paramètre.

S’il reste des arguments après itération de tous les paramètres, c’est qu’il y a surplus. On lèvera donc une erreur pour l’indiquer.

On obtient alors un premier squelette.

1
2
3
4
5
6
7
8
def signature_check(*args, **kwargs):
    sig, *args = args
    for param in sig.parameters.values():
        ...
    if args:
        raise TypeError('too many positional arguments')
    if kwargs:
        raise TypeError('too many keyword arguments')

Paramètres

Attachons-nous maintenant à remplacer ces points de suspension par le traitement des paramètres. Pour rappel, on retrouve 5 sortes de paramètres, qui seront traitées différemment.

VAR_POSITIONAL

Si un paramètre VAR_POSITIONAL est présent, il aura pour effet de consommer tous les arguments restant, c’est-à-dire de vider args.

1
2
if param.kind == param.VAR_POSITIONAL:
    args.clear()

VAR_KEYWORD

De même ici, mais en vidant kwargs, puisque tous les arguments nommés sont consommés.

1
2
elif param.kind == param.VAR_KEYWORD:
    kwargs.clear()

POSITIONAL_ONLY

Passons aux choses sérieuses avec les paramètres POSITIONAL_ONLY. Les arguments correspondant à ce type de paramètres se trouveront nécessairement dans args.

Deux cas sont cependant à distinguer :

  • Si la liste args n’est pas vide, nous en consommons le premier élément ;
  • Si elle est vide, il faut alors vérifier que le paramètre possède une valeur par défaut.

En effet, si aucun argument n’est reçu, le paramètre prend sa valeur par défaut. Mais s’il ne possède aucune valeur par défaut, il faut alors lever une erreur : le paramètre est manquant.

1
2
3
4
5
elif param.kind == param.POSITIONAL_ONLY:
    if args:
        del args[0]
    elif param.default == param.empty: # Ni argument ni valeur par défaut
        raise TypeError("Missing '{}' positional argument".format(param.name))

KEYWORD_ONLY

On retrouve ici un code semblable au positional-only, mais en s’intéressant à kwargs plutôt qu’à args. Le même cas d’erreur est à traiter si le paramètre n’est associé à aucune valeur.

1
2
3
4
5
elif param.kind == param.KEYWORD_ONLY:
    if param.name in kwargs:
        del kwargs[param.name]
    elif param.default == param.empty:
        raise TypeError("Missing '{}' keyword argument".format(param.name))

POSITIONAL_OR_KEYWORD

Le dernier cas, le plus complexe, est celui des paramètres pouvant recevoir arguments positionnels ou nommés. Nous devrons alors tester la présence d’un argument dans chacun des deux conteneurs.

Nous serons confrontés à un nouveau cas d’erreur : si un paramètre est associé à la fois à un argument positionnel et à un nommé.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
else: # POSITIONAL_OR_KEYWORD
    positional = keyword = False
    if args: # Prend valeur dans args
        del args[0]
        positional = True
    if param.name in kwargs: # Prend valeur dans kwargs
        del kwargs[param.name]
        keyword = True
    if positional and keyword:
        raise TypeError("Multiple arguments for parameter '{}'".format(param.name))
    if not positional and not keyword and param.default == param.empty:
        raise TypeError("Missing argument for '{}' parameter".format(param.name))

Résultat

En mettant bout à bout nos morceaux de code, nous obtenons notre fonction signature_check pour tester si des arguments correspondent à une signature. La fonction ne retourne rien, mais lève une erreur en cas de non-correspondance.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
def signature_check(*args, **kwargs):
    sig, *args = args
    for param in sig.parameters.values():
        if param.kind == param.VAR_POSITIONAL: # Consomme tous les positionnels
            args.clear()
        elif param.kind == param.VAR_KEYWORD: # Consomme tous les nommés
            kwargs.clear()
        elif param.kind == param.POSITIONAL_ONLY: # Prend valeur dans args
            if args:
                del args[0]
            elif param.default == param.empty: # Ni argument ni valeur par défaut
                raise TypeError("Missing '{}' positional argument".format(param.name))
        elif param.kind == param.KEYWORD_ONLY: # Prend valeur dans kwargs
            if param.name in kwargs:
                del kwargs[param.name]
            elif param.default == param.empty:
                raise TypeError("Missing '{}' keyword argument".format(param.name))
        else: # POSITIONAL_OR_KEYWORD
            positional = keyword = False
            if args: # Prend valeur dans args
                del args[0]
                positional = True
            if param.name in kwargs: # Prend valeur dans kwargs
                del kwargs[param.name]
                keyword = True
            if positional and keyword:
                raise TypeError("Multiple arguments for parameter '{}'".format(param.name))
            if not positional and not keyword and param.default == param.empty:
                raise TypeError("Missing argument for '{}' parameter".format(param.name))
    if args:
        raise TypeError('too many positional arguments')
    if kwargs:
        raise TypeError('too many keyword arguments')

Et pour voir signature_check en action :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
>>> sig = inspect.signature(lambda a, b: None)
>>> signature_check(sig, 3, 5)
>>> signature_check(sig, 3, b=5)
>>> signature_check(sig, 3)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "signature_check.py", line 37, in signature_check
    raise TypeError("Missing argument for '{}' parameter".format(param.name))
TypeError: Missing argument for 'b' parameter
>>> signature_check(sig, 3, 5, 7)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "signature_check.py", line 39, in signature_check
    raise TypeError('too many positional arguments')
TypeError: too many positional arguments
>>> signature_check(sig, 3, 5, b=5)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "signature_check.py", line 35, in signature_check
    raise TypeError("Multiple arguments for parameter '{}'".format(param.name))
TypeError: Multiple arguments for parameter 'b'
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
>>> sig = inspect.signature(lambda a, *, b: None)
>>> signature_check(sig, 3, 5)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "signature_check.py", line 25, in signature_check
    raise TypeError("Missing '{}' keyword argument".format(param.name))
TypeError: Missing 'b' keyword argument
>>> signature_check(sig, 3, b=5)
>>> signature_check(sig, 3, b=5, c=7)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "signature_check.py", line 41, in signature_check
    raise TypeError('too many keyword arguments')
TypeError: too many keyword arguments
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
>>> sig = inspect.signature(lambda *args, file=sys.stdin: None)
>>> signature_check(sig, 1, 2, 3, 4)
>>> signature_check(sig)
>>> signature_check(sig, 1, 2, 3, 4, file=None)
>>> signature_check(sig, 1, 2, 3, 4, file=None, foo=0)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "signature_check.py", line 41, in signature_check
    raise TypeError('too many keyword arguments')
TypeError: too many keyword arguments

Retrouvons les quelques ressources complémentaires sur ces sujets.