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 typesstr
etint
respectivement, et retournant un objetstr
;- 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.
- Module
inspect
: https://docs.python.org/3/library/inspect.html - Annotations de fonctions : https://www.python.org/dev/peps/pep-3107
mypy
, analyseur statique de code à partir des annotations : http://mypy-lang.org/