Patrice Ferlet
Patrice Ferlet
Créateur de ce blog.
Publié le janv. 24, 2021 Temps de lecture: 14 min

Me suivre sur Mastodon

Python, Jouez Avec Les Annotations de Classes

thumbnail for this post

Et si on se servait des annotations pour faire autre chose que du simple “type checking” dans les IDE ? Par exemple, si on rendait la sérialisation JSON ou la création d’un système de modèle pour BDD plus élégant ?

Les annotations, on s’en sert de plus en plus en Python, ça aide la compréhension du code, et les IDE adorent ça pour vous proposer de l’autocompletion efficacement. Mais je trouve qu’on ne se sert pas assez des annotations pour “tout le reste” - et notamment pour rendre la configuration de classe bien plus pratique. Je vais d’abord vous expliquer rapidement l’état des lieux, puis je vais vous montrer comment on peut utiliser les annotations pour “configurer une classe”. Cela peut rendre vos projets bien plus jolis à lire, à modifier, et surtout à contrôler.

Ce que vous faisiez avant…

Imaginons que nous devions gérer un ensemble d’objets très hétérogène, c’est souvent le cas qu’on veut travailler avec des objets à stocker en base de données. Par exemple,

  • un “User” a un nom, un prénom, un email, une date de naissance… Il faut contrôler que les types sont bons, mais aussi ne pas exporter les propriétés inutiles.

  • Mais vous aurez aussi un autre type d’objet. Par exemple un “Projet” a un nom, une description, mais surtout un responsable qui est de type “User” (ou autre) , etc…

Du coup vous allez composer les objets avec d’autres objets. C’est normal, c’est logique, mais ça pose un souci. Parce que quand vous voulez exporter ça en JSON:

TypeError: Object of type Project is not JSON serializable

Arrrrrggggg ouais…(suivi de pleurs, Dust in the wind à fond, etc…)

Après quelques minutes à trouver pourquoi il vous insulte de la sorte, alors que vous êtes gentil hein, on vous propose dans des documentations de faire des méthodes de sérialisation, et de boucler les propriétés pour ne sortir que ce dont vous avez besoin. Et puis vous ajoutez des propriétés, et vous recommencez à devoir manipuler les méthodes et ainsi de suite. En gros, chaque classe dérive de “JsonMachin” et vous devez surcharger la méthode “to_json()” pour dire quelles propriétés vous voulez exporter.

Ça marche hein, on est d’accord, mais c’est fastidieux à la longue.

Le souci est que lorsqu’un objet est composé d’autres objets, cela devient le berzingue pour sortir un truc propre. Vos classes commencent à devoir gérer des propriétés publiques que vous voulez, ou non, exporter, transformer, et ça commence à vous énerver. Et je vous comprends, moi ça m’énerve aussi.

Regardons ce que font les autres…

SQLAlchemy propose une idée très plaisante pour sérialiser les données à mettre en base, c’est d’utiliser des variables statiques. Par exemple dans la classe “User” :

class User(Model):
    name = StringValue(32)
    lastname = StringValue(32)
    # ...

C’est pas idiot car cela rend identifiables les propriétés avec des types qu’on connait bien. Il suffit de vérifier si les champs sont des dérivés de la classe “Column” (avec la fonction isinstance(obj, type)).

Cette méthode est super intéressante mais elle a des désagréments pour notre cas:

  • la classe a un objet statique instancié, ça a un côut en mémoire (pas grand chose mais il est là)
  • pour bien être certain que la propriété de l’objet est un champs à traiter, il faut absolument avoir un type qui hérite d’une classe identifiable comme telle, par exemple “Field” ou “Column” - au final on va quand même le faire, mais pour l’heure imaginons que nous voulions nous en passer
  • et donc, il faut penser à tout… tous les types !
  • et c’est pas ce qu’on veut, on veut sérialiser en JSON des trucs simples à taper bon sang. Des chaines de caractères, des nombres, et potentiellement des objet “sérialisables” de temps en temps
  • et quid des objets déjà tout prêts qu’on veut rendre sérialisables après coup ? Par exemple dans projet déjà bien conséquent

Je ne suis pas en train de dire que SQLAlchemy fait mal les choses, non non. Je dis juste que dans des cas “simples”, et dans un cas “custom”, on a pas forcément l’envie de se prendre la tête définir des types spécifiques. Là pour la sérialisation JSON, c’est bien trop compliqué !

C’est quoi la solution pour un cas plus simple ?

Les annotations !

Dans les faits vous avez peut-être déjà vu des annotations dans des déclarations de fonctions.

def truc(vara: int, varb: str):
    pass

Dans l’exemple du dessus, les annotations sont les “: quelquechose”. Contrairement à ce que beaucoup pense, ce n’est pas du typage, car Python se fout complètement de l’annotation. Par contre les IDE, ils s’en servent pas mal pour vous proposer l’autocompletion adéquate. Ça sert aussi à ne pas se tromper dans les arguments, et de ne pas avoir à les nommer pour faire comprendre quel type est attendu. Mais fonctionnellement, c’est pas utile à Python.

Une annotation, c’est un genre d’attribut mais il ne fait rien… mais alors rien ! À part “taguer” des propriétés

Par contre, chose que beaucoup en savent pas (moi je sais, nanaère), c’est que les annotations peuvent être retrouvées au sein du code. C’est donc super intéressant car vous pouvez jouer avec pour vérifier vous même les types. Et vous savez quoi ? Ça marche aussi avec des attributs de classe !

L’idée c’est que l’annotation c’est un genre de “tag”, qui ne peut contenir qu’une chose: un type (ou un tuple de types). Et vraiment rien d’autre… Il sert de “description” d’un attribut. Il n’assigne pas de valeur.

Vous sentez venir le coup ?

Une annotation, comme quand on le fait pour les arguments d’une fonction, ça s’écrit “nom: type”. Attention, pas de signe “égal”, c’est bien “deux points”.

class User(JSONizer):
    firstname: str
    lastname: str
    email: str

Sérieusement, vous trouvez pas ça joli ? C’est net, c’est efficace, c’est carrément compréhensible.

Mon objet hérite de JSONizer, une classe que je vais développer. Cette classe va pouvoir traiter les annotations et faire ce que je veux: Nous aurons accès à self.__annotations__ qui est un dictionnaire (dict). On peut donc le parcourir, avoir la valeur et les clefs - ça va pas être bien compliqué.

Ici je vais m’en servir pour:

  • vérifier que ce que m’envoi l’utilisateur dans la construction de l’objet existe dans les annotations (par exemple hein, on est pas obligé)
  • vérifier que le type déclaré correspond au type de la valeur que je reçois en constriction (encore une fois, pas obligé, c’est pour vous montrer)

C’est un exemple hein, on peut s’en servir pour autre chose… vous verrez après…

Pour faire ceci, ma classe mère JSONizer contient:

  • un constructeur, pas obligatoire mais intéressant, il va enregistrer les propriétés envoyées en argument (mais on pourrait très bien, en plus, utiliser __setattribute__ , en complément ou pas)

  • une méthode save() qui me retourne le JSON (mais elle pourrait très bien enregistrer en base de données),

  • et une méthode todict() qui va me permettre de faire un peu d’introspection et sortir un dictionnaire ne contenant que les attributs qui correspondent à une annotation. Bien entendu ça pourrait très bien être fait dans la méthode save() mais j’aime bien factoriser les trucs.

Voilà le code:

class JSONizer:
    def __init__(self, **kwargs):
        # kwargs => on prend toutes les valeurs à la bourrin
        # évidemment vous pourrez améliorer la classe hein
        # Dans votre cas, ce sera peut-être pas utile

        # Pour chaque argument nommé reçu, on va faire quelques
        # tests et les injecter dans la classe en tant que **proriété**
        for name, val in kwargs.items():

            # Par exemple l'argument doit être dans les annotations
            # On peut s'en passer, là c'est pour vous
            # montrer comment on peut "forcer la déclaration", si pas
            # d'annotation => je refuse l'attribut
            if name not in self.__annotations__:
                raise Exception(f"Undeclared attribute {name}")

            # et/ou  on vérifie que le type est bon
            if isinstance(val, self.__annotations__[name]):
                wanted = self.__annotations__[name]
                raise Exception(f"Bad type for {name}, want {wanted}")

            # tout est bon, on assigne l'attribut à l'objet
            setattr(self, name, val)

    def todict(self):
        """ Transforme en dictionnaire """

        # C'est là que c'est intéressant, je peux par exemple ne prendre
        # dans mon dictionnaire, que les valeurs qui ont une annotations
        data = {k: getattr(self, k) for k in self.__annotations__}
        for k, v in data.items():
            # et si l'attribut a "todict", on l'appelle
            if hasattr(v, "todict"):
                data[k] = v.todict()

        return data

    def save(self):
        """ là c'est un exemple hein """
        return json.dumps(self.todict(), indent=4)

Vous pouvez tenter de faire ceci:

u = User(name="Sarah", lastname="Connor", email="terminator@skynet")

# et là ça passe
print(u.save())

# affiche:
# {
#     "name": "Sarah",
#     "lastname": "Connor",
#     "email": "terminator@skynet"
#
# }

Maintenant faisons notre classe “Project” qui contient un “User”:

class Project(JSONizer)
    name: str
    owner: User # là

Comme l’attribut “owner” est de type “User” et que “User” hérite de “JSONizer”, on trouvera la méthode “todict()” . Et comme la fonction de cet objet enfant va aussi vérifier ses propres enfants, tous les objets vont être transformés.

Parfois le développement parait magique…

Et par conséquent:

u = User(name="Patrice", lastname="Ferlet", email="me@foo")
print("L'utilisateur:", u.save())

p = Project(name="Website", owner=u)
print("Le projet:", p.save())

Ce qui nous produit:

L'utilisateur: {
    "name": "Patrice",
    "lastname": "Ferlet",
    "email": "me@foo"
}
Le projet: {
    "name": "Website",
    "owner": {
        "name": "Patrice",
        "lastname": "Ferlet",
        "email": "me@foo"
    }
}

On voit bien que l’objet “User” est dans l’objet “Project”.

Bien entendu vous faites comme vous le voulez, par exemple au lieu de coller l’objet vous pourriez prendre l’ID qui vient de la base de donnée de cet objet, ou faire une transformation qui vous amuse. Rien n’est interdit (si, mais bon vous me comprenez).

Changeons donc la méthode “todict()” pour illustrer mon propos, par exemple je ne vais garder que le champs “name"de l’utilisateur dans le Projet :

    def todict(self):
        """ Transforme en dictionnaire """
        data = {k: getattr(self, k) for k in self.__annotations__}
        for k, v in data.items():
            if hasattr(v, "name"):
                data[k] = v.name

        return data

Et on aura donc en sortie:

L'utilisateur: {
    "name": "John",
    "lastname": "Ferlet",
    "email": "me@foo"
}
Le projet: {
    "name": "Website",
    "owner": "John"
}

On a toujours le risque d’écraser une propriété parente, je ne le nie pas. Mais…

  • ma classe “JSONizer” ne va pas garder beaucoup de propriétés, elle est faite pour “transporter” des données, le risque est minime en soit
  • je vais de toutes manière préfixer mes propriétés par un double underscore pour les rendre privées

On va aller un peu plus loin

Bon maintenant vous vous dites que c’est cool, mais vous aimeriez mettre d’autres annotations. Mieux encore, vous aimeriez décider quelle propriété mettre dans l’export, et ignorer les autres.

Et bien c’est pas compliqué, on va commencer par fair un type (une classe) qui va nous permettre d’identifier que l’attribut annoté est une propriété à exporter en JSON.

class JSONProp:
    pass

Reste à changer l’implémentation de la classe JSONizer, on va maintenant accepter toutes les annotations de tous les types, on ne contrôle pas le contenu. Par contre, la méthode todict() va filtrer les propriétés annotées en tant “JSONProp” - vous allez voir la souplesse du bousin.

Dans vos développements, bien entendu, le constructeur qui prend en paramètre les valeurs à assigner peut être supprimé. Je l’ai mis ici pour réduire la taille des exemples et ne pas assigner les valeurs ligne par ligne.

class JSONizer:
    def __init__(self, **kwargs):
        # cette fois j'accepte tout
        for name, val in kwargs.items():
            # on accepte tout, pas de contrôle de type
            setattr(self, name, val)

    def todict(self):
        annotations = self.__annotations__

        # et là je filtre
        data = {
            key: getattr(self, key)
            for key, kind in annotations.items()
            if kind is JSONProp
        }
        for k, v in data.items():
            if hasattr(v, "todict"):
                data[k] = v.todict()

        return data

    def save(self):
        return json.dumps(self.todict(), indent=4)

Et je vais maintenant faire en sorte que seules les propriétés “name” et “lastname” de la classe “User” soient dans mon export JSON :

class User(JSONizer):
    name: JSONProp
    lastname: JSONProp
    email: str # Cette propriétés sera ignorée
               # On pourrait d'ailleur la virer tiens...

Maintenant, u.save() ne me retournera pas l’email. C’est une annotation “autre” dont je me fous royalement.

Heu OK, mais pour avoir plusieurs types dans une annotation ?

Et ouais, ce qui serait cool maintenant, c’est que l’annotation puisse contenir une liste de types, comme ça je peux dire que le champs “name” est une chaîne et en plus que c’est une propriété à exporter:

class User(JSONizer):
    name: (str, JSONProp)
    lastname: JSONProp # là on se fout du type
    email: str # et ça c'est pas exporté

Et bien il suffit de changer la méthode todict et remplacer le filtrage par :

data = {
    key: getattr(self, key) # on prend l'attribut
    for key, kind in annotations.items() # dans les annotations
    if kind is JSONProp or (isinstance(kind, tuple) and JSONProp in kind)
    # ↑ si le type est un JSONProp, ou que c'est un tuple et que JSONProp s'y trouve
}

Voilà, pas plus compliqué…

Et là, un truc intéressant, c’est que vous pouvez faire du type checking. Vous pouvez donc faire hériter vos classes de JSONizer qui gère les JSONProp + hériter d’une autre classe qui va vérifier les types que vous injectez. Ça peut aller très très loin !

Ça t’as vraiment servi ?

Oui. J’ai un projet qui utilise une base de données que j’adore vraiment, RethinkDB. Cette base orientée document ne prend en charge que des types “natifs” aux langages que vous utilisez (chaines, entiers, flottants, dates…) - cela réduit fortement les contrôles à effectuer. Du coup j’ai créé mon propre système de modèle.

J’avais commencé à utilisé la méthode de SQLAlchemy, mais quand j’ai repensé mon implémentation, et que j’ai utilisé les annotations, tout est devenu plus élégant. Dans mon cas en tout cas.

Un objet injectable en base (donc qui hérite de “Model”) ressemble à ça:

class User(Model):
    username: (str, Unique, NonNull)
    email: (str, Unique, NonNull)
    password: (str, NonNull)
    verified: bool

    def login(self, username, password):
        """ Needed method to log in user """
        found = self.filter({
            "username": username,
            "password": SHA(password),
        })
        if len(found) > 0:
            # return the found user
            return User(**found)

        # not found user
        return False

J’ai créé quelques classes dérivées du genre “Unique” qui demande un contrôle de l’existence en base de tel ou tel attribut d’un objet, et la classe de base ajoute des champs qui me plaisent comme “createdAt, updatedAt, deletedAt” dans sa méthode “save()”. Méthode qui en plus sait faire un insert ou update en fonction de la valeur de l’ID de l’objet… Bref un truc fun et très facile à maintenir.

Il aurait été possible, bien entendu, d’utiliser des propriétés statiques comme je vous le dis plus haut. Mais les annotations me permettent de réduire le code de contrôle des champs et de ne pas avoir à tout typer. Bref c’est très adapté à ma façon de coder. Je préfère taper (str, NonNull, Unique) que StringValue(null=False, unique=True) par exemple, je trouve les annotations plus “jolies”.

Ce qui est cool

Ce qui est cool, c’est que la classe “JSONizer” et la classe “JSONProp” sont juste deux types à utiliser sans se prendre la tête. Tous vos objets déjà existants vont pouvoir utiliser les annotations pour définir ce que vous voulez sortir en JSON, sans ajouter de méthode. Vous avez juste à annoter vos objets pour définir ce qui doit être exporté, et comment. Je vous rappelle que la méthode “save()” peut être nommée comme vous le souhaitez, et que rien ne vous interdit de transformer les valeurs de l’objet.

Alors attention, ce que je vous mets dans l’article c’est un exemple, à vous de faire un truc plus propre, plus adapté, avec les contrôles qui vous chantent.

T’es sûr de toi ?

Les annotations servent déjà au “type checking” dans certains IDE (y compris vim quand on sait faire 😜), il est donc assez naturel de les utiliser pour faire tu type checking par introspection sur les annotations.

Évidemment, la méthode qu’utilise SQLAlchemy est très puissante. Les propriétés statiques permettent de ne pas avoir un type mais un objet qui contient de la configuration de champs (par exemple, donner une longueur maximale de chaîne, dire si un champs est unique, etc…) - En soit, les annotations sont surtout un outils pratique, peu coûteux en mémoire et facile à appréhender.

J’aime beaucoup le fait de pouvoir, par exemple, générer du JSON en définissant quelles propriétés sortir, sans avoir l’erreur XXX is not JSON Serializable. Et comme je vous le disais, c’est aussi intéressant pour faire un système custom de modèle de base de données.

Par contre, je ne dis pas qu’il faille les utiliser pour tout et n’importe quoi. Les annotations sont utiles pour du type checking et, à la limite, du filtrage, elle ne peuvent contenir que des “types” ou “tuple de types”, pas des instances. Sinon, passez par des propriétés statiques !

PS: quand tout sera bien testé, je pense vous proposer en open source mon package “RDBModel” pour Python 😇

Voilà !

comments powered by Disqus