Python avancé: arguments et réflexion
Personnellement j'use et abuse des opérateurs un peu magiques que Python propose pour manipuler les arguments; que ce soit pour ma librairie de scripting shell epycs ou bien en programmation par contrats avec python-dbc.
Le langage ne s'arrête pas là puisqu'il propose également un ensemble de librairies permettant la réflexion, c'est à dire l'inspection et la manipulation du code depuis du code Python lui-même (oui c'est récursif, ou meta comdizlédjeunz)
Liste *args
Premier opérateur, le plus simple, *args
permet de déclarer une liste d'arguments positionnels, petite mise en situation:
def coucou(*args):
for e in args:
print(e)
>>> coucou(1, 2, 3)
1
2
3
On récupère un objet args
, qui est un tuple contenant l'ensemble des arguments par position (= non nommés) qu'on a fourni.
On peut le mélanger avec des paramètres positionnels normaux, mais il faut spécifier ceux-ci avant args
def coucou(before, after, *args):
for e in args:
print(before, e, after)
>>> coucou(">", "<", 1,2,3)
> 1 <
> 2 <
> 3 <
Dictionnaire **kwargs
Ensuite vient le dictionnaire **kwargs
permet de récupérer de manière similaire l'ensemble des arguments par mot clé.
def coucou(**kwargs):
for k, v in kwargs.items():
print(k, v)
On récupère kwargs
, qui est un dictionnaire.
>>> coucou(a=1, b=2, c=3)
a 1
b 2
c 3
On peut le mélanger avec des paramètres normaux, et avec args
, dans ce cas **kwargs
se placera à la fin.
def coucou(k1, k2, *args, **kwargs):
print(kwargs[k1])
print(kwargs[k2])
print(args)
Appeler avec *args
et **kwargs
Cette logique existe dans le "sens inverse": on peut utiliser les astérisques pour exploser un itérable en arguments positionnels ou par mots clés:
def f(a, b):
print(a)
print(b)
l = [0, 1]
d = {
'b' : 3,
'a' : 2,
}
>>> f(*l)
0
1
>>> f(**d)
2
3
Transformer une list
ou un dict
en arguments
De manière symétrique, si vous essayez d'appeler une fonction avec l'ensemble des arguments placés dans une liste ou un dictionnaire, vous pouvez utiliser *lst
ou **dico
pour transformer ces objets en arguments:
*lst
va transformerlst
en arguments positionnels.**dico
va transformerdico
en arguments par mots-clés.
lst = [1, 2, 3]
dico = {"end": " 4\n"}
print(*lst, **dico)
1 2 3 4
Forcer le positionnel /
ou les mot clef *
Depuis Python 3.8
Si une fonction a un pseudo-argument nommé /
alors tous les arguments qui viennent avant doivent être seulement par position .
Si elle a *
alors tous les arguments venant après doivent être donnés seulement par mot-clé.
def pedantic(a, b, /, tata, *, toto):
print(a, b, tata, toto)
Quelques usages corrects:
>>> pedantic(1, 2, 3, toto=4)
1 2 3 4
>>> pedantic(1, 2, tata=3, toto=4)
1 2 3 4
>>> pedantic(1, 2, toto=4, tata=3)
1 2 3 4
Quelques usages incorrects:
>>> pedantic(1, 2, 3, 4)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: pedantic() takes 3 positional arguments but 4 were given
>>> pedantic(1, b=2, tata=3, toto=4)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: pedantic() got some positional-only arguments passed as keyword arguments: 'b'
*args
et les marqueurs /
, *
ne coexistent que dans certaines configurations. D'autant que je sache c'est une limitation dans la syntaxe du langage.Les fonctions
f(*a, /, b)
et f(*a, *, b)
sont illégales, par exemple.Forcer l'usage de la notation positionnelle est utile pour les fonctions commutatives. On peut changer l'ordre de leurs arguments sans changer le résultat. Pour une telle opération, préciser le nom des arguments n'a pas de sens.
def min_of_3(a, b, c, /):
return min((a,b,c))
>>> min_of_3(1, 2, 3)
1
Forcer l'usage de la notation seulement par mot clef peut être utile pour les arguments qui sont de pures options, c'est à dire qu'ils ont une valeur par défaut et peuvent être présent dans n'importe quel ordre dans les version futures ou passées du code.
def print_with_format(s, *, color="#000", bold=False)
def t1000_search_for_future_dangers(
*,
john_connor=True,
sarah_connor=True,
jean_louis_connor=False)
Réflexion programmatique: inspect
Le module inspect
permet de récupérer de nombreuses informations sur une fonction, il permet notamment de récupérer les arguments d'une fonction donnée.
import inspect
def u():
pass
def f(a, b, /, u, v, *, w, **kw):
pass
def g(a=1, *b, w=2):
pass
def h(a: int) -> int:
return a
if __name__ == "__main__":
fns = [t for t in locals().items() if callable(t[1])]
for (name, fn) in fns:
print(name, inspect.getfullargspec(fn))
u FullArgSpec(args=[], varargs=None, varkw=None, defaults=None, kwonlyargs=[], kwonlydefaults=None, annotations={})
f FullArgSpec(args=['a', 'b', 'u', 'v'], varargs=None, varkw='kw', defaults=None, kwonlyargs=['w'], kwonlydefaults=None, annotations={})
g FullArgSpec(args=['a'], varargs='b', varkw=None, defaults=(1,), kwonlyargs=['w'], kwonlydefaults={'w': 2}, annotations={})
h FullArgSpec(args=['a'], varargs=None, varkw=None, defaults=None, kwonlyargs=[], kwonlydefaults=None, annotations={'return': <class 'int'>, 'a': <class 'int'>})
Analysons les attributs disponibles dans un objet FullArgSpec
Attribut | Annotation de type | Description |
---|---|---|
args | list[str] |
Tous les arguments, sauf *arg et **kwargs |
varargs | str | None |
Nom de l'arg *arg , None si aucun |
varkw | str | None |
Nom de l'arg **kwarg , None si aucun |
defaults | tuple[str] | None |
Valeurs par défaut des positionnelsNone si aucune |
kwonlyargs | list[str] |
Arguments passés par mot-clé seulement |
kwonlydefaults | dict[str, Any] | None |
Valeurs par défaut des args mot-clé seulementNone si aucune |
annotations | dict[str, str] |
Annotations de type des arguments et du return |
Mise en pratique
Voici un petit élément de code que j'utilise pour pouvoir trouver, depuis un appel de fonction, l'ensemble des arguments fournis, et leur valeur.
import inspect
def get_kw_from_call(f, a, kw):
argspec = inspect.getfullargspec(f)
kw2 = kw.copy()
# kwargs
for i, nm in enumerate(argspec.args):
# positionals
if len(a) > i:
v = a[i]
kw2[nm] = v
for i, nm in enumerate(a for a in argspec.args if a not in kw2):
# defaults
kw2[nm] = argspec.defaults[i]
return kw2
def trace_call(f):
def print_kw_and_call_(*a, **kw):
kw_to_str = ", ".join(f"{n}={v!r}" for n, v in get_kw_from_call(f, a, kw).items())
print(f"{f.__name__}({kw_to_str})")
f(*a, **kw)
return print_kw_and_call_
Un exemple d'utilisation. J'utilise un décorateur, mais appeler la fonction trace_call
directement marcherait également.
@trace_call
def toto(a, b, c=1, d=2):
print("toto!", a, b, c, d)
>>> toto(10, 20, d=30)
toto(d=30, a=10, b=20, c=1)
toto! 10 20 1 30
Conclusion
Au travers de cet article, j'ai condensé mes connaissances sur la manipulation avancée des arguments en Python. J'espère que cela vous aura donné envie de jouer avec.
Quelques usages pour lesquels ils sont fort pratiques:
- Faire une trace automatique
- Fournir uniquement les paramètres que demande la fonction
- Générer une interface REST automatiquement depuis un module
Et vous, est-ce que ce post vous donne des idées ? Avez vous des cas d'usage favoris pour les args
ou **kwargs
?
Q&A
J'ai reçu quelques questions depuis la dernière fois.
Les contrats sont-ils toujours utiles ?
Absolument pas, il y a des contextes où les contrats ne sont pas utiles.
Par exemple il n'y a aucun sens a contractualiser l'usage de la fonction reponse_a_la_question_ultime = (lambda: 42)
Dans quels contextes les contrats sont-ils utiles ?
Trois cas qui sont des gains de temps garantis me viennent en tête:
- En prototypage: les contrats vérifieront les invariants un peu "bancals". Par exemple je les ai récemment utilisés sur du JavaScript, pour valider que j'avais bien écrit mes sélecteurs (car je suis une bille en JS).
- En utilisation d'une API externe, particulièrement si elle est lente ou coûteuse: les contrats permettent de garantir que la requête est bien formée et qu'elle correspond à ce qu'on voulait demander avant d'avoir à faire l'appel.
- En intégration avec un autre programme: Les contrats permettront une gestion "interne" des erreurs, sans les propager dehors, ce qui évite de simplifie la traçabilité des erreurs (typiquement: on a un fichier JSON mal formé en sortie de notre programme, et çà fait planter un autre programme).
Je m'efforce de répondre à toutes les questions et remarques, alors n'hésitez pas à me contacter par le site ou bien par linkedin 📨