Bonnes pratiques concernant les annotations
*******************************************

auteur:
   Larry Hastings


Résumé
^^^^^^

Ce document a pour but de regrouper les bonnes pratiques de travail
avec le dictionnaire d'annotations.  Si vous écrivez du code Python
qui examine les "__annotations__" des objets, nous vous encourageons à
suivre les recommandations décrites dans la suite.

Ce document est organisé en quatre sections : bonnes pratiques pour
accéder aux annotations d'un objet en Python 3.10 et plus récent,
bonnes pratiques pour accéder aux annotations d'un objet en Python 3.9
et antérieur, autres bonnes pratiques pour "__annotations__" à
appliquer quelle que soit la version de Python, et enfin les
curiosités concernant "__annotations__".

Notez que ce document traite spécifiquement du traitement des
"__annotations__", et non de l'*utilisation* des annotations. Si vous
cherchez des informations sur la façon d'utiliser les « indications de
type » dans votre code, veuillez consulter le module "typing".


Accès au dictionnaire des annotations d'un objet dans Python 3.10 et plus récent
================================================================================

Python 3.10 ajoute une nouvelle fonction à la bibliothèque standard :
"inspect.get_annotations()".  Dans les versions Python 3.10 et plus
récentes, l'appel à cette fonction est la meilleure pratique pour
accéder au dictionnaire d'annotations de tout objet qui prend en
charge les annotations.  Cette fonction peut également convertir pour
vous les annotations contenues dans des chaînes de caractères en
objets.

Si pour une raison quelconque, "inspect.get_annotations()" n'est pas
viable pour votre cas d'utilisation, vous pouvez accéder à l'attribut
de données "__annotations__" manuellement.  La bonne pratique pour
cela a également changé en Python 3.10 : à partir de Python 3.10, le
fonctionnement de "o.__annotations__" est *toujours* garanti sur les
fonctions, classes et modules Python.  Si vous êtes certain que
l'objet que vous examinez est l'un de ces trois objets *spécifiques*,
vous pouvez simplement utiliser "o.__annotations__" pour accéder au
dictionnaire d'annotations de l'objet.

Cependant, d'autres types d'objets appelables – par exemple, les
objets appelables créés par "functools.partial()" – peuvent ne pas
avoir d'attribut "__annotations__" défini.  Pour accéder à l'attribut
"__annotations__" d'un objet éventuellement inconnu, la meilleure
pratique, à partir de la version 3.10 de Python, est d'appeler
"getattr()" avec trois arguments, par exemple "getattr(o,
'__annotations__', None)".

Dans les versions antérieures à Python 3.10, l'accès aux
"__annotations__" d'une classe qui n'a pas d'annotation mais dont un
parent de cette classe en a, aurait renvoyé les "__annotations__" de
la classe parent. Dans les versions 3.10 et plus récentes, le résultat
d'annotations de la classe enfant est un dictionnaire vide.


Accès au dictionnaire des annotations d'un objet en Python 3.9 et antérieur
===========================================================================

En Python 3.9 et antérieur, l'accès au dictionnaire des annotations
d'un objet est beaucoup plus compliqué que dans les versions plus
récentes. Le problème est dû à un défaut de conception de ces
anciennes versions de Python, notamment en ce qui concerne les
annotations de classe.

La bonne pratique pour accéder au dictionnaire d'annotations d'autres
objets – fonctions, autres objets appelables et modules – est la même
que pour la 3.10, en supposant que vous n'appelez pas
"inspect.get_annotations()" : vous devez utiliser la forme à trois
arguments de "getattr()" pour accéder à l'attribut "__annotations__"
de l'objet.

Malheureusement, ce n'est pas la bonne pratique pour les classes.  Le
problème est que, puisque "__annotations__" est optionnel sur les
classes et que les classes peuvent hériter des attributs de leurs
classes de base, accéder à l'attribut "__annotations__" d'une classe
peut par inadvertance renvoyer le dictionnaire d'annotations d'une
*classe de base.* Par exemple :

   class Base:
       a: int = 3
       b: str = 'abc'

   class Derived(Base):
       pass

   print(Derived.__annotations__)

Ceci affiche le dictionnaire d'annotations de "Base", pas de
"Derived".

Votre code doit gérer les différentes options si l'objet que vous
examinez est une classe ("isinstance(o, type)"). Dans ce cas, la bonne
pratique repose sur un détail d'implémentation de Python 3.9 et
antérieur : si une classe a des annotations définies, elles sont
stockées dans le dictionnaire "__dict__" de la classe.  Puisque la
classe peut avoir ou non des annotations définies, la bonne pratique
est d'appeler la méthode "get" sur le dictionnaire de la classe.

Pour résumer, voici un exemple de code qui accède en toute sécurité à
l'attribut "__annotations__" d'un objet quelconque en Python 3.9 et
antérieur :

   if isinstance(o, type):
       ann = o.__dict__.get('__annotations__', None)
   else:
       ann = getattr(o, '__annotations__', None)

Après avoir exécuté ce code, "ann" pourra être soit un dictionnaire,
soit "None".  Nous vous conseillons de vérifier le type de "ann" en
utilisant "isinstance()" avant de poursuivre l'analyse.

Notez que certains objets de type exotique ou malformé peuvent ne pas
avoir d'attribut "__dict__" donc, pour plus de sécurité, vous pouvez
également utiliser "getattr()" pour accéder à "__dict__".


Conversion manuelle des annotations contenues dans une chaîne de caractères
===========================================================================

Dans les situations où certaines annotations peuvent se présenter sous
forme de chaînes de caractères brutes, et que vous souhaitez évaluer
ces chaînes pour trouver les valeurs Python qu'elles représentent, il
est vraiment préférable d'appeler "inspect.get_annotations()" pour
faire ce travail à votre place.

Si vous utilisez Python 3.9 ou antérieur ou, si pour une raison
quelconque, vous ne pouvez pas utiliser "inspect.get_annotations()",
vous devrez dupliquer son principe de fonctionnement.  Nous vous
encourageons à examiner l'implémentation de
"inspect.get_annotations()" dans la version actuelle de Python et à
suivre une approche similaire.

En bref, si vous souhaitez évaluer une annotation empaquetée en chaîne
de caractères sur un objet arbitraire "o" :

* Si "o" est un module, utilisez "o.__dict__" pour accéder aux noms
  "globals" lors de l'appel à "eval()".

* Si "o" est une classe, utilisez "sys.modules[o.__module__].__dict__"
  pour désigner les noms "globals", et "dict(vars(o))" pour désigner
  les "locals", lorsque vous appelez "eval()".

* Si "o" est un appelable encapsulé à l'aide de
  "functools.update_wrapper()", "functools.wraps()", ou
  "functools.partial()", dés-encapsulez-le itérativement en accédant à
  "o.__wrapped__" ou "o.func" selon le cas, jusqu'à ce que vous ayez
  trouvé la fonction racine.

* If "o" is a callable (but not a class), use "o.__globals__" as the
  globals when calling "eval()".

Cependant, toutes les valeurs de chaîne utilisées comme annotations ne
peuvent pas être transformées avec succès en valeurs Python par
"eval()". Les valeurs de chaîne peuvent théoriquement contenir des
chaînes valides et, en pratique, il existe des cas d'utilisation
valables pour les indications de type qui nécessitent d'annoter avec
des valeurs de chaîne qui *ne peuvent pas* être évaluées.  Par exemple
:

* Les types d'union de style **PEP 604** avec "|", avant que cette
  prise en charge ne soit ajoutée à Python 3.10.

* Les définitions qui ne sont pas nécessaires à l'exécution, importées
  uniquement lorsque "typing.TYPE_CHECKING" est vrai.

Si "eval()" tente d'évaluer de telles valeurs, elle échoue et lève une
exception.  Ainsi, lors de la conception d'une API de bibliothèque
fonctionnant avec des annotations, il est recommandé de ne tenter
d'évaluer des valeurs de type chaîne que si l'appelant le demande
explicitement.


Bonnes pratiques pour "__annotations__" dans toutes les versions de Python
==========================================================================

* Évitez d'assigner directement des objets à l'élément
  "__annotations__".  Laissez Python gérer le paramétrage de
  "__annotations__".

* Si vous assignez directement à l'élément "__annotations__" d'un
  objet, vous devez toujours le définir comme un "dict".

* Si vous accédez directement à l'élément "__annotations__" d'un
  objet, vous devez vous assurer qu'il s'agit d'un dictionnaire avant
  de tenter d'examiner son contenu.

* Évitez de modifier les dictionnaires "__annotations__".

* Évitez de supprimer l'attribut "__annotations__" d'un objet.


Les curiosités concernant "__annotations__"
===========================================

Dans toutes les versions de Python 3, les fonctions créent
paresseusement un dictionnaire d'annotations si aucune annotation
n'est définie sur cet objet.  Vous pouvez supprimer l'attribut
"__annotations__" en utilisant "del fn.__annotations__", mais si vous
accédez ensuite à "fn.__annotations__", l'objet créera un nouveau
dictionnaire vide qu'il stockera et renverra quand on lui demande ses
annotations.  Si vous supprimez les annotations d'une fonction avant
qu'elle n'ait créé paresseusement son dictionnaire d'annotations, vous
obtiendrez une exception "AttributeError" ; si vous utilisez "del
fn.__annotations__" deux fois de suite, vous êtes sûr de toujours
obtenir "AttributeError".

Tout ce qui est dit dans le paragraphe précédent s'applique également
aux objets de type classe et module en Python 3.10 et suivants.

Dans toutes les versions de Python 3, vous pouvez définir à "None"
l'élément "__annotations__" sur un objet fonction.  Cependant, si vous
accédez ensuite aux annotations de cet objet en utilisant
"fn.__annotations__", un dictionnaire vide sera créé paresseusement,
comme indiqué dans le premier paragraphe de cette section.  Ce *n'est
pas* le cas des modules et des classes, quelle que soit la version de
Python ; ces objets permettent de définir "__annotations__" à
n'importe quelle valeur Python, et conserveront la valeur définie.

Si Python convertit vos annotations en chaînes de caractères (en
utilisant "from __future__ import annotations"), et que vous spécifiez
une chaîne de caractères comme annotation, la chaîne sera elle-même
entre guillemets.  En fait, l'annotation est mise entre guillemets
*deux fois*. Par exemple :

   from __future__ import annotations
   def foo(a: "str"): pass

   print(foo.__annotations__)

Ceci renvoie : "{'a': "'str'"}".  Cela ne devrait pas vraiment être
considéré comme une « bizarrerie » ; nous le mentionnons ici
simplement parce que cela peut être surprenant.
