Référencer dynamiquement des fonctions dans une API
- 2021-03-23
- Publié par : Christophe DELEUZE
- Catégorie : FastAPI
Aujourd’hui, nous allons voir comment nous pouvons utiliser l’introspection pour référencer dynamiquement les fonctions d’une classe dans une API REST développée avec FastAPI.
L’intérêt de cette pratique est de vous permettre de facilement ajouter de nouvelles fonctionnalités à votre API sans avoir à les redéfinir manuellement au niveau du framework de l’API.
L'introspection
Par définition, l’introspection est un acte d’auto-examen. En programmation informatique, l’introspection est la capacité de déterminer le type ou les propriétés des objets au moment de l’exécution. Comme en Python tout est objet et que chaque objet possède ses propres attributs et méthodes, en utilisant l’introspection, nous pourrons inspecter dynamiquement leur contenu. Pour faire de l’introspection en Python, il nous faudra utiliser les fonctions du module Built-in dont je vais vous détailler 3 d’entre elles.
La fonction dir()
La fonction dir() est indispensable pour faire de l’introspection. Elle permet de renvoyer une liste triée d’attributs et de méthodes appartenant à un objet.
Utilisons-la sur un tuple :
>>> dir(())
['__add__', '__class__', '__contains__', '__delattr__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__getitem__', '__getnewargs__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__iter__', '__le__', '__len__', '__lt__', '__mul__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__rmul__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__', 'count', 'index']
La fonction dir() a retourné l’ensemble des attributs et méthodes associées au tuple. On reconnait les méthodes internes telles que __doc__ ainsi que la méthode publique count qui retourne le nombre d’éléments du tuple.
Si on utilise la fonction dir() sur une classe, on obtient aussi ses attributs et méthodes :
class Voiture:
def peinture(self):
return "grise"
ma_voiture = Voiture()
>>> dir(ma_voiture)
['__class__', '__delattr__', '__dict__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__le__', '__lt__', '__module__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__', '__weakref__', 'peinture']
En regardant attentivement le résultat de la fonction dir(), on note la présence de la méthode peinture() qui a été définie préalablement dans la classe Voiture. L’étape suivante est donc d’appeler cette méthode à l’aide de la chaine de caractère "peinture" qui nous a été fournie par la fonction dir().
Pour y arriver, nous allons utiliser la fonction getattr() qui exécutera la fonction.
La fonction getattr()
getattr() est une fonction qui permet d’obtenir une référence à une fonction sans connaître son nom avant l’exécution.
Reprenons l’exemple précédent avec la classe Voiture et récupérons la référence de la méthode peinture() :
Regardons ce que nous retourne l’interpréteur interactif de Python quand on appelle la méthode sans les parenthèses :
class Voiture:
def peinture(self):
return "grise"
ma_voiture = Voiture()
>>> ma_voiture.peinture
<bound method Voiture.peinture of <__main__.Voiture object at 0x0000019A3DF1ABE0>>
Ce résultat est une référence à la méthode peinture() de la classe Voiture. Ce n’est pas un appel à cette méthode, car un appel à celle-ci se ferait par ma_voiture.peinture(). Il s’agit donc bien de la méthode elle-même.
Maintenant, utilisons la fonction getattr() avec notre classe. Le premier argument de getattr() est l’objet dans lequel nous allons rechercher un attribut ou une méthode, et le second paramètre est le nom de cet attribut sous forme de chaine de caractères :
>>> getattr(ma_voiture, "peinture")
<bound method Voiture.peinture of <__main__.Voiture object at 0x0000019A3DF1ABE0>>
Cet exemple démontre que la fonction getattr(ma_voiture, "peinture") retourne exactement la même référence à la méthode peinture() de l’objet ma_voiture que celle qu’on obtient en appelant directement ma_voiture.peinture.
La spécificité de getattr() par rapport à l’utilisation directe d’une méthode, est que getattr() permet d’utiliser une fonction à partir de son nom écrit sous forme de chaîne de caractère. Il n’est donc pas nécessaire de connaître la fonction pour pouvoir l’utiliser puisqu’avec getattr(), le nom de la méthode est une chaîne de caractères passée comme argument de la fonction getattr(). Cela fait de getattr() une fonction prédéfinie extrêmement utile qui retourne n’importe quel attribut de n’importe quel objet.
En complément, si on souhaite exécuter la méthode peinture() avec getattr(), nous pouvons le faire de la façon suivante :
>>> getattr(ma_voiture, "peinture")()
'grise'
Enfin, si on combine la fonction dir() et getattr(), on obtient la liste complète de toutes les références de tous les attributs et méthodes d’un objet précis.
Toutefois, dans le cadre de notre article, nous ne souhaitons pas disposer de tous les attributs. On va donc procéder à un tri à l’aide de la fonction callable().
La fonction callable()
callable() est une fonction qui prend en argument un objet et qui vous retourne True si cet objet est appelable ou False s’il ne l’est pas.
>>> callable(str)
True
>>> callable(str())
False
>>> callable(ma_voiture)
False
>>> callable(Voiture)
True
>>> callable(ma_voiture.peinture)
True
Donc, si vous pouvez utiliser un objet directement, callable() vous retournera True, sinon dans tous les autres cas elle vous retournera False.
Combinons callable(), getattr() et dir() pour lister les méthodes de notre classe :
>>> dir(ma_voiture)
['__class__', '__delattr__', '__dict__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__le__', '__lt__', '__module__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__', '__weakref__', 'peinture']
>>> [func for func in dir(ma_voiture) if callable(getattr(ma_voiture, func))]
['__class__', '__delattr__', '__dir__', '__eq__', '__format__', '__ge__', '__getattribute__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__le__', '__lt__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__', 'peinture']
Par rapport à dir(), on remarque qu’il y a bien eu un tri et que seules les méthodes appelables ont été conservées.
Toutefois, les méthodes internes préfixées de __ et les méthodes privées de _ ne nous intéressant pas dans notre cas, il nous suffit de les ignorer à l’aide de .startswith("_") :
>>> [func for func in dir(ma_voiture) if callable(getattr(ma_voiture, func)) and not func.startswith("_")]
['peinture']
Référencer les fonctions dans votre API
Maintenant que nous avons réussi à lister les méthodes d’une classe, il ne nous reste plus qu’à combiner cette liste à l’API.
Pour illustrer le fonctionnel, nous utiliserons une classe Algorithms qui référence des algorithmes par pays :
class Algorithms:
def FR(self):
return "French Specific Algo"
def SP(self):
return "Spanish Specific Algo"
def IN(self):
return "Indian Specific Algo"
algorithms = Algorithms()
availables_countries = [func for func in dir(algorithms) if callable(getattr(algorithms, func)) and not func.startswith("_")]
>>> availables_countries
['FR', 'SP', 'IN']
Ici, l’introspection est utilisée pour générer la liste des fonctions disponibles dans la classe, ce qui revient à lister la liste des pays pour lesquels nous avons codé un algorithme.
['FR', 'SP', 'IN']
Il ne nous reste plus qu’à intégrer le tout dans une API, et pour cela, j’utiliserai le Framework FastAPI.
Lister les fonctions disponibles dans l'API
La première étape va être de lister les pays disponibles pour qu’un utilisateur externe puisse connaitre le nombre d’algorithmes disponibles et pour quels pays.
Pour cela rien de plus simple, on écrit une méthode HTTP GET qui retourne la liste des pays :
@app.get("/algorithms/")
async def get_algorithms_countries():
return availables_countries
Référencer les fonctions dans l'API
Une fois l’étape précédente effectuée, on ajoute une autre méthode HTTP GET qui contient l’appel générique aux fonctions de la classe Algorithms à l’aide de getattr() :
@app.get("/algorithms/{country}")
async def get_algorithm(country: str):
""" call an algorithm of a specific country"""
if country in availables_countries :
algorithm_result = getattr(algorithms, country)()
return algorithm_result
else :
raise HTTPException(status_code=404, detail=f"unexpected algorithm country, the list of availables countries is : {availables_countries}")
À noter qu’il faut absolument vérifier, préalablement à l’exécution de la fonction, que le pays choisi existe. Dans le cas où le pays n’existe pas, on retournera une erreur 404 avec un message explicite.
Ajouter des paramètres aux fonctions
Généralement, nos fonctions ont besoins d’arguments.
Commençons par modifier notre API en lui ajoutant deux arguments param1: str, param2: str :
@app.get("/algorithms/{country}")
async def get_algorithm(country: str, param1: str, param2: str):
""" call an algorithm of a specific country with 2 arguments """
if country in availables_countries :
algorithm_result = getattr(algorithms, country)(param1, param2)
return algorithm_result
else :
raise HTTPException(status_code=404, detail=f"unexpected algorithm country, the list of availables countries is : {availables_countries}")
L’envoi des paramètres à la fonction à appeler se fait toujours à l’aide de la méthode getattr(). getattr(algorithms, country) permet de sélectionner la fonction à appeler, et (param1, param2) permet de passer les paramètres à la fonction qui va être appelée.
Attention, je n’ai pas fait évoluer la méthode HTTP GET vers POST, mais dans la cadre d’une meilleure protection des données et en fonction de votre contexte, il est très fortement conseillé de le faire.
Enfin, comme du point de vue de l’API, le nombre d’arguments est fixe et que chaque paramètre est protégé en typage, du point de vue de la classe Algorithms il faudra être particulièrement vigilant à ce que toutes les signatures des fonctions publiques de la classe soient toutes identiques entrent-elles.
Une signature de fonction (ou signature de type, ou signature de méthode) définit les entrées et sorties des fonctions et des méthodes. Cette signature peut comporter : des paramètres et leurs types ; une valeur et un type de retour.
Dans notre cas, cela donne :
class Algorithms:
def FR(self, param1, param2):
return f"French Specific Algo with param1:'{param1}' and param2:'{param2}'"
def SP(self, param1, param2):
return f"Spanish Specific Algo with param1:'{param1}' and param2:'{param2}'"
def IN(self, param1, param2):
return f"Indian Specific Algo with param1:'{param1}' and param2:'{param2}'"
À vous de jouer maintenant !
Le mot de la fin
J’espère que cet article vous aura plus et que cela vous donnera quelques idées pour apporter un peu de dynamisme à votre code.
Enfin, voici l’ensemble du code que vous pouvez récupérer si vous souhaitez tester vous-même ce tutoriel.
from fastapi import FastAPI, HTTPException
app = FastAPI()
class Algorithms:
def FR(self, param1, param2):
return f"French Specific Algo with param1:'{param1}' and param2:'{param2}'"
def SP(self, param1, param2):
return f"Spanish Specific Algo with param1:'{param1}' and param2:'{param2}'"
def IN(self, param1, param2):
return f"Indian Specific Algo with param1:'{param1}' and param2:'{param2}'"
algorithms = Algorithms()
availables_countries = [func for func in dir(algorithms) if callable(getattr(algorithms, func)) and not func.startswith("_")]
@app.get("/algorithms/")
async def get_algorithms_countries():
return availables_countries
@app.get("/algorithms/{country}")
async def get_algorithm(country: str, param1: str, param2: str):
""" call an algorithm of a specific country"""
if country in availables_countries :
algorithm_result = getattr(algorithms, country)(param1, param2)
return algorithm_result
else :
raise HTTPException(status_code=404, detail=f"unexpected algorithm country, the list of availables countries is : {availables_countries}")