Python avancé: arguments et réflexion

Head of a python snake, surrounded by bubbles on a blue background

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)
>>> coucou("b", "a", 0, 8, a=0, b=8, c=7)
8
0
(0, 8)

Hehehe nombre rigolo

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 transformer lst en arguments positionnels.
  • **dico va transformer dico en arguments par mots-clés.
lst = [1, 2, 3]
dico = {"end": " 4\n"}
print(*lst, **dico)
1 2 3 4
💡
Cela marche non seulement avec les listes et les dictionnaires, mais plus précisément, *i est implémenté pour tout i qui serait un itérable, et **m pour tout m qui serait un mapping.

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'
⚠️
La liste d'arguments *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)
💡
De manière générale, ces marqueurs ne doivent être utilisés que si on est concerné par la compatibilité ascendante ou descendante du code.

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 positionnels
None 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é seulement
None 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).
  const rao_e = document.querySelector("#results > #risks-and-options");
  console.assert(rao_e !== null);

Ne faites pas gaffe au nommage, c'est un prototype on vous dit!

  • 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).
🍏
Lever une erreur le plus tôt possible permet de faciliter le débug, en évitant qu'elle se propage

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 📨

Référence

4. D’autres outils de contrôle de flux
As well as the while statement just introduced, Python uses a few more that we will encounter in this chapter. L’instruction if: L’instruction if est sans doute la plus connue. Par exemple : Il peu…

Documentation python "4.9. Davantage sur la définition des fonctions"

Type hints cheat sheet - mypy 1.12.1 documentation

Cheat sheet pour le module typing de Python