Autour du codeDevelopper toujours mieux
Posté le

Les annotations

Python est un langage à typage dynamique vous pensez alors que faire du type checking est impossible , c'est raté

Les annotations permettent d'indiquer les types de paramètres attendus et retourner par une méthode. Elles ont été introduites en python 3 et sont décrites dans la PEP 3107. Pour annoter qu'une fonction prend en paramètre un entier et retourne une chaîne on fait comme ceci:

def fr_to_int(num:str) -> int:
    return ('zero', 'un', 'deux').index(num.lower())

print(fr_to_int("Deux"))

Mais ça casse mon duck typing !

Pas de panique. smile

Premièrement si vous passez des arguments avec le mauvais type, il ne se passe rien. De plus, vous pouvez spécifier des expressions dans vos annotations. Dans notre exemple, n'importe quel objet avec une méthode lower ne fera pas cracher le programme on pourrait alors annoter notre fonction comme ceci:

def fr_to_int(num: lambda num: hasattr(num, 'lower')) -> int:
    return ('zero', 'un', 'deux').index(num.lower())

Ou comme cela:

def fr_to_int(num: 'object with lower method') -> int:
    return ('zero', 'un', 'deux').index(num.lower())

Bref vous pouvez mettre ce que vous souhaitez, mais pour unifier un peu les choses, il y a la PEP 484 qui définie le module typing.

Les annotations d'une fonction peuvent être récupérées à chaud via l'attribut __annotations__.

fr_to_int.__annotations__
#{'return': <class int="">, 'num': <function lambda=""> at 0x7f26b7074950>}

Checker le code pendant son execution peut remonter une erreur au plus tôt et fournir plus d'information que si un mauvais objet fait planté le dernière appele de 20 fonctions empilées. L'inconvénient, c'est que ça ralentit le code. Une solution est de faire de l'analyse statique de code.

Le module ast

Le module ast ("Abstract Syntax Trees") permet de créer un arbre syntaxique à partir d'une source. L'idée est donc de parcourir l'arbre est de remonter les définitions de fonction, les affectations de variable et les appels de fonctions pour voir si les types sont bien passés. Pour ce faire, on va utiliser la classe NodeVisitor. Il suffit d'en hériter et de redéfinir les méthodes préfixées par visit_ en les suffixant par le type de nœud que l'on veut traiter. La liste des nœuds est détaillée sur greentreesnakes. Par exemple, pour récupérer les définitions de fonction on utilise la **méthode visit_FunctionDef. Il est important d'appeler la méthode generic_visit à la fin sinon, les nœuds fils ne seront pas parcourus et de ce fait, le code à l'intérieur des fonctions ne serait pas analysé.

Voici un exemple d'utilisation:

import ast, sys

class MyVisitor(ast.NodeVisitor):

    def visit_FunctionDef(self, node):
        print(node.name)
        self.generic_visit(node)


source = '''
def foo(a0=12):

    def bar1():
        a1 = 33

    def bar2():
        a1 = 42
'''

node = ast.parse(source)

Avant de vous montrer comment récupérer toutes les infos d'une fonction à partir de son nœud, on va regarder comment récupérer les affectations. Pour ce faire, on utilise la méthode visit_Assign. Le nœud transmit à visite_Assigne contient un attribut value contenant ce qui sera affecté à ma variable et un attribut target contenant une liste des noms de variables qui seront affectés.

Pourquoi une liste de variables ? Tout simplement pour les cas d'affectation multiple comme:

a = b = 1

Deux problèmes se posent avec les affectations :

1 Comment stocker les variables et leur type ?

Il ne suffit pas de stoker bêtement les variables dans un dico, en effet des variables peuvent avoir des noms identiques, mais se trouver dans des fonctions différentes, de plus les variables définies en dehors d'une fonction doivent être accessible dans la fonction, mais pas l'inverse. Pour résoudre ce problème on va créer une classe Scope qui encapsulera les variables dans des dicts qui seront empilés les un sur les autres au fur et à mesure que l'on descend dans des scopes.

Voici la classe Scope

class Scope(object):
    def __init__(self):
        self._pile = [{}]

    def add(self, var_name, var_type):
        self._pile[-1][var_name] = var_type

    def push(self):
        self._pile.append({})

    def pop(self):
        return self._pile.pop()

    def get(self, var_name):
        for scope in reversed(self._pile):
            try:
                return scope[var_name]
            except KeyError:
                continue
        raise NameError("name '{}' is not defined".format(var_name))

2 Comment récupérer le type de ma variable ?

Pour ça on va parser l'attribut value du nœud passé. Si ce nœud contient une constante numérique 5, 3.69, 4L, Il est du type ast.Num et on récupére la valeur dans n. S'il contient une constante litérale "toto", il est du type ast.Str et le contenu sera dans l'attribut s. Si c'est une variable, on va essayer de la récupérer dans le scope. Comme je n'ai pas prévu tous les cas, je fais en sorte que le code me laisse un beau message d'erreur en cas de problème.

Voici notre visiteur modifié, il empile un nouveau scope avant de visiter les nœuds fils et les dépiles après.

class MyVisitor(ast.NodeVisitor):

    def __init__(self, *args, **kws):
        super().__init__(*args, **kws)
        self.variables = Scope()

    def _get_type(self, node):
        if isinstance(node, ast.Num):
            var_type = type(node.n)

        elif isinstance(node, ast.Str):
            var_type = type(node.s)

        elif isinstance(node, ast.Name):
            try:
                var_type = self.variables.get(node.id)
            except KeyError as err:
                raise NameError("name '{}' is not defined".format(node.value.id))
        else:
            raise NotImplementedError("Houps, je l'ai pas vu venir {}".format(node))

        return var_type

    def visit_FunctionDef(self, node):
        print(node.name)
        self.variables.push()
        self.generic_visit(node)
        self.variables.pop()

    def visit_Assign(self, node):
        var_type = self._get_type(node.value)
        for target in node.targets:
            self.variables.add(target.id, var_type)
        self.generic_visit(node)

On a globalement une structure qui tient la route libre à vous de corriger ces défauts pas la suite. La prochaine étape est de récupérer les définitions et les appels de fonction.

Le nœud fonction contient un nœud args.args qui contient la liste de tous les arguments de la fonction. On va parcourir cette liste pour stoker la position de l'argument, son nom et son type. Je ne traiterai pas les arguments de type *args ou **kws qui se traitent respectivement avec les attributs args.varargannotation et args.kwargannotation. Si l'on n'arrive pas à récupérer le type d'un argument, on le mettra à None. Les définitions de fonction seron ajouté au scope comme n'importe quelle variable. Ensuite, on a plus qu'a vérifier que les types soient bien passés lors des appels de fonctions. Le nœud passé en paramètre à la méthode visit_Call contient dans args tout les paramètre passé à la fonction.

Voici le code final.

class MyVisitor(ast.NodeVisitor):

    def __init__(self, *args, **kws):
        super().__init__(*args, **kws)
        self.variables = Scope()

    def _get_type(self, node):
        if isinstance(node, ast.Num):
            var_type = type(node.n)
        elif isinstance(node, ast.Str):
            var_type = type(node.s)
        elif isinstance(node, ast.Name):
            try:
                var_type = self.variables.get(node.id)
            except KeyError as err:
                raise NameError("name '{}' is not defined".format(node.value.id))
        elif isinstance(node, ast.Call):
            var_type = self.variables.get(node.func.id)['returns']
        else:
            raise NotImplementedError("Houps, je l'ai pas vu venir {}".format(node))
        return var_type


    def visit_FunctionDef(self, node):
        arguments = {}
        for pos, arg in enumerate(node.args.args):
            if arg.annotation is None:
                arguments[arg.arg] = arguments[pos] = None
            else:
                arguments[arg.arg] = arguments[pos] = eval(arg.annotation.id)

        #node.args.varargannotation
        #node.args.kwargannotation

        if node.returns is None:
            return_type = None
        else:
            return_type = eval(node.returns.id)

        func_info = {
            'args' : arguments,
            'returns': return_type
        }

        self.variables.add(node.name, func_info)
        self.variables.push()
        self.generic_visit(node)
        self.variables.pop()

    def visit_Assign(self, node):
        var_type = self._get_type(node.value)
        for target in node.targets:
            self.variables.add(target.id, var_type)
        self.generic_visit(node)

    def visit_Call(self, node):
        func = self.variables.get(node.func.id)
        for pos, arg in enumerate(node.args):
            var_type = self._get_type(arg)
            if var_type != func['args'][pos]:
                print("Error line {line}:"
                      " {func} argument {pos} must be"
                      " {expected.__name__}, got {given}"
                      .format(line=node.lineno,
                              func=node.func.id,
                              pos=pos + 1,
                              given=var_type,
                              expected=func['args'][pos]))
        self.generic_visit(node)


source = """
def bar1(a: int, b: str, c:int=42) -> int:
    pass

toto = 78
tata = "salut"

tutu = bar1(78, tata, 25)
bar1(toto, 45, tutu)
"""

node = ast.parse(source)
MyVisitor().visit(node)

C'est un début et ils restent pas mal de choses à faire qui ne seront pas montré ici par exemple au lancement de python il y a déjà plein de noms définis comme True, False... Il y a également toutes les fonctions standards dont les paramètres d'entrée et de retour ne sont pas connus. Étendre le contrôle des fonctions aux méthodes. Bref pas mal de taf.

J'espère que ça vous a plu et que vous n'êtes pas trop frustré de na pas sortir avec une solution clé en main pour faire du typechecking. En attendant le prochain article, A vos IDE et bon code laughing