-
samedi 29 octobre 2011 à 17:37:25
Le Python, l'Introspection et le Singe #2
Ce billet est la suite directe du précédent sur le même sujet. Je vous invite donc plus que fortement à aller le lire (voire le relire).
Comme je le disais dans le précédent billet, les fonctions ne sont pas immuables. Mais attention, ne me faites pas dire ce que je n'ai pas dit : on ne peut pas modifier, à proprement parler, le code d'une fonction. Mais on peut le remplacer par un autre.
Après consultation de la documentation officielle du module inspect, on apprend qu'une fonction est caractérisée par 5 attributs :
- func_code : son code sous forme de bytecode dans un code object (parfaitement immuable)
- func_defaults : les valeurs par défaut de ses arguments
- func_doc : sa docstring
- func_globals : l'espace de noms global dans lequel elle a été définie
- func_name : son nom
On va donc pouvoir modifier notre fonction _replace pour qu'elle aille changer ces 5 attributs. Celà implique que l'on n'a plus besoin de faire un parcours récurssif depuis la base du module et que l'on peut se passer du module inspect.
Voici le code avant :
def _replace(module, from_, to, processed=None, ismodule=_inspect.ismodule): if processed is None: processed = list() if module in processed: return processed.append(module) for name in dir(module): attr = getattr(module, name) if ismodule(attr): _replace(attr, from_, to, processed) elif attr is from_: setattr(module, name, to)Et le voici après :
def _replace(from_, to): for name in ('doc', 'name', 'defaults', 'code', 'globals'): attr_name = 'func_' + name setattr(from_, attr_name, getattr(to, attr_name))Ce code a malheureusement un effet de bord tellement implicite qu'on l'oublierait presque. Rappelons nous ce que nous faisons. On va remplacer les 5 attributs qui font d'une fonction une fonction. Chaque référence vers cette fonction sera donc indirectement affectée par cette modification. Avec le code précédent, on ne faisait que modifier une référence pour qu'elle pointe vers une autre fonction. Là, la fonction reste la même (dans le sens où on ne change pas d'instance : seul son état est altéré). Donc en appliquant _replace sur _webpy.net.validip, _webpy.wsgi.validip sera aussi affectée puisqu'étant la même fonction (c'est justement ce qui nous évite de devoir refaire un parcours récursif).
Le problème est donc que l'on n'a plus accès à l'ancienne fonction ! Dans certains cas, ça peut ne pas être génant. Mais pour notre exemple, c'est bloquant. Il va donc falloir créer un genre de copie de sauvegarde de l'ancienne fonction. Pour cela on va créer une fonction vide et lui assigner les 5 attributs de l'ancienne fonction. On placera ensuite une référence de cette fonction dans la fonction qu'on remplace. Ceci n'est peut-être pas très clair, mais vous allez vite comprendre avec le code :
def _replace(from_, to): def old(): pass for name in ('doc', 'name', 'defaults', 'code', 'globals'): attr_name = 'func_' + name setattr(old, attr_name, getattr(from_, attr_name)) setattr(from_, attr_name, getattr(to, attr_name)) from_.old = oldOn regagne ainsi la possibilité d'appeler la fonction « remplacée » (entre guillemets, parce qu'au final ce n'est pas l'instance de la fonction que l'on a remplacé, mais certains de ses attributs) via l'attribut old. Veuillez noter au passage que rien ne m'obligeait à appeler la fonction vide avec le même nom que celui que j'utilise pour la stocker dans la fonction à « remplacer ».
Cette fonction ne prend par contre pas en charge les méthodes. En effet, une méthode n'est pas une fonction et n'a donc pas les 5 attributs cités plus haut. Par contre, une méthode possède une fonction accessible via l'attribut im_func. Il ne nous reste plus qu'à tester la présence d'un tel attribut :
if hasattr(from_, 'im_func'): from_ = from_.im_funcVoici ce que donne le code avec ces modifications :
import imp as _imp import sys as _sys _here = _sys.path.pop(0) _fp, _pathname, _description = _imp.find_module('web') _webpy = _imp.load_module('web', _fp, _pathname, _description) _sys.path.insert(0, _here) def _replace(from_, to): def old(): pass if hasattr(from_, 'im_func'): from_ = from_.im_func for name in ('doc', 'name', 'defaults', 'code', 'globals'): attr_name = 'func_' + name setattr(old, attr_name, getattr(from_, attr_name)) setattr(from_, attr_name, getattr(to, attr_name)) from_.old = old def _validip(ip, defaultaddr='127.0.0.1', defaultport=8080): return validip.old(ip, defaultaddr, defaultport) _replace(_webpy.net.validip, _validip) del _imp, _sys, _here, _fp, _pathname, _description, _webpy, _replace, _validipVous remarquerez la modification apportée à _validip. Nous n'avons en effet plus besoin d'une référence sur l'ancienne fonction, puisque l'on est dedans. Notre fonction _replace a pris le code de notre fonction _validip et l'a donné à _webpy.net.validip. Ainsi, lorsque l'on appelle _webpy.net.validip, c'est le code de notre _validip qui est exécuté mais dans le contexte de _webpy.net.validip. Ce qui implique que l'on va pouvoir accéder à son attribut old sans devoir le passer en argument de la fonction et que la fonction s'appelle validip et non _validip. On aurait pu appeler la fonction autrement que ça ne changerait rien :
def _quux(ip, defaultaddr='127.0.0.1', defaultport=8080): return validip.old(ip, defaultaddr, defaultport) _replace(_webpy.net.validip, _quux)Si vous avez bien suivi tout ce qu'on a fait jusqu'à présent, vous vous serez rendus compte d'une chose : dans notre cas précis, on peut faire beaucoup plus simple. On a vu que ce qui caractérisait une fonction, étaient les 5 attributs cités en début de billet. Celui qui nous intéresse ici est func_defaults. Comme dit plus haut, il contient les valeurs par défaut des arguments de la fonction. Ainsi, _webpy.net.valid.func_defaults vaut « ('0.0.0.0', 8080) ». Il nous suffit alors juste de changer cette valeur.
On pourrait donc se limiter à :
import imp as _imp import sys as _sys _here = _sys.path.pop(0) _fp, _pathname, _description = _imp.find_module('web') _webpy = _imp.load_module('web', _fp, _pathname, _description) _sys.path.insert(0, _here) _webpy.net.validip.func_defaults = ('127.0.0.1', 8080) del _imp, _sys, _here, _fp, _pathname, _description, _webpyMais ceci n'est valable que parce que justement on n'a pas besoin de faire autre chose que de changer une valeur par défaut. Parfois, suivant nos besoins, le code devra vraiment être « modifié » et alors on devra nécessairement avoir recours à la fonction _replace.
Repenchons nous un peu sur le chargement du module webpy. La fonction _imp.find_module() renvoie 3 choses :
- le fichier du module ouvert en lecture ou None si le module est un package
- le chemin absolu vers le module
- un tuple de 3 éléments décrivant un type de module (référez-vous à la documentation de imp.get_suffixes() pour en savoir plus)
webpy étant un package, _fp vaudra toujours None. Mais dans le cas où on utiliserait ce code sur un module n'étant pas un package, on devrait fermer le fichier. De même, si une erreur survient durant l'import du module, une exception de type ImportError est déclenchée. Étant donné que l'on est censé remplacer un module pré-existant, autant laisser l'utilisateur se débrouiller avec l'exception si le module d'origine n'est pas disponible. Par contre, on doit quand même dans tous les cas fermer le fichier.
En faisant un peu plus attention à la documentation de imp.find_module(), on voit que l'on peut passer sa propre liste de chemins plutôt que de se reposer sur sys.path. Ça sera plus propre de faire comme ça et ça fera plaisir à ThibG. Les 4 lignes d'import de webpy deviennent donc :
_fp, _pathname, _description = _imp.find_module('web', _sys.path[1:]) try: _webpy = _imp.load_module('web', _fp, _pathname, _description) except: if _fp: _fp.close()On commence enfin à avoir quelque chose qui tient la route. Il nous reste encore à voir pour faire les modifications qui nous permettront de ne plus être obligé de mettre notre web.py dans le même répertoire que notre projet. J'ai par exemple plusieurs projets utilisant webpy. J'aimerai bien ne pas devoir mettre le fichier dans chacun de ces projets, mais plutôt à un endroit accessible à tous. L'endroit le plus approrié me semble être ~/.local/lib/python2.7/site-packages/. Il ne nous reste alors plus qu'à retirer ce chemin, ainsi que tout ceux se trouvant avant dans sys.path (simple question de logique).
import os as _os _path = _sys.path[_sys.path.index(_os.path.dirname(__file__)) + 1:] _fp, _pathname, _description = _imp.find_module('web', _path)Bien que vous ayant donné un exemple de chemin où mettre notre fichier web.py, le code reste suffisamment générique pour fonctionner avec n'importe quel emplacement. Vous remarquerez aussi que je ne traite pas le cas particulier où _sys.path.index() ne trouverait pas le chemin. Notre fichier devant nécessairement être dans sys.path pour être importé, ce problème ne peut survenir.
Nous voici donc avec une version plus que satisfaisante :
import imp as _imp import os as _os import sys as _sys _path = _sys.path[_sys.path.index(_os.path.dirname(__file__)) + 1:] _fp, _pathname, _description = _imp.find_module('web', _path) try: _webpy = _imp.load_module('web', _fp, _pathname, _description) finally: if _fp: _fp.close() _webpy.net.validip.func_defaults = ('127.0.0.1', 8080) del _imp, _os, _sys, _path, _fp, _pathname, _description, _webpy, _replaceEt quand je dis plus que satisfaisante, je ne mâche pas mes mots. Voyez par vous même cet extrait de la sortie de pylint sur notre fichier (oui, j'ai une config pour pylint qui, entre autres, me permet de ne pas être embêté par l'absence de docstring ou par les noms de variables qui ne lui plaisent pas) :
Your code has been rated at 10.00/10Que demander de plus ? Oui, bon, d'accord : un exemple plus concret de l'usage de _replace. Ça tombe bien, parce qu'il y a autre chose qui m'énerve plus dans webpy que cette histoire d'adresse ip.
_webpy.webapi.input() est censée renvoyer les arguments GET et POST. On peut même préciser via l'attribut _method, si on ne veut que GET ou que POST par exemple. Sauf que quand on y regarde de plus prêt, webpy passe par cgi.FieldStorage() qui renvoie toujours les données GET, même quand on a demandé à n'avoir que les données POST, ce qui est assez génant. Étant donné que c'est _webpy.webapi.rawinput qui fait vraiment le travail, c'est elle que je vais remplacer.
import imp as _imp import os as _os import sys as _sys import urlparse as _urlparse _path = _sys.path[_sys.path.index(_os.path.dirname(__file__)) + 1:] _fp, _pathname, _description = _imp.find_module('web', _path) try: _webpy = _imp.load_module('web', _fp, _pathname, _description) finally: if _fp: _fp.close() def _replace(from_, to): def old(): pass if hasattr(from_, 'im_func'): from_ = from_.im_func for name in ('doc', 'name', 'defaults', 'code', 'globals'): attr_name = 'func_' + name setattr(old, attr_name, getattr(from_, attr_name)) setattr(from_, attr_name, getattr(to, attr_name)) from_.old = old def _rawinput(method=None, _parse_qsl=_urlparse.parse_qsl): if method and method.lower() == 'post': inputs = storage() for key, value in _parse_qsl(data(), True): inputs[key] = value return inputs return rawinput.old(method) _webpy.net.validip.func_defaults = ('127.0.0.1', 8080) _replace(_webpy.webapi.rawinput, _rawinput) del (_imp, _os, _sys, _urlparse, _path, _fp, _pathname, _description, _webpy, _replace, _rawinput)Du coup là, pylint est beaucoup moins content :
Your code has been rated at 5.16/10Ceci s'explique par le fait qu'on utilise storage, data et rawinput qui viennent tous les trois du contexte de la fonction qu'on remplace. pylint ne peut donc pas les connaitre. Si vraiment cela vous pose un problème, vous pouvez toujours passer _webpy en argument de la fonction _rawinput :
def _rawinput(method=None, _parse_qsl=_urlparse.parse_qsl, _web=_webpy): if method and method.lower() == 'post': inputs = _web.webapi.storage() for key, value in _parse_qsl(_web.webapi.data(), True): inputs[key] = value return inputs return _web.webapi.rawinput.old(method)Notez que l'on pourrait mettre « _webpy=_webpy » au lieu de « _web=_webpy ». Mais c'est sans compter sur pylint qui nous insulte parce que l'on ose redéfinir le _webpy global. Du coup avec cette version de _rawinput, on réobtient notre 10.00/10.
Voilà qui conclut notre petite expérimentation quant au remplacement à chaud de fonctionnalités de bibliothèques que l'on utilise mais nous posent problème. Je terminerai juste en rajoutant que l'on aurait aussi pu nommer notre fichier « quux.py ». Il aurait alors fallu l'importer avant toute utilisation de webpy (peu importe d'ailleurs si on l'importe avant ou après avoir importé webpy, l'important étant de le faire avant de l'utiliser). Passer par un fichier quux.py a un avantage (malgré le désavantage d'imposer un import en plus) : on peut de ce fait faire du monkey patching pour plusieurs bibliothèques à la fois sans devoir créer un fichier pour chacun d'entre elle. On peut même distribuer le fichier comme un véritable projet dépendant des bibliothèques qu'il patche.
UPDATE: ThibG m'a fait remarquer que j'aurai pu passer par types.FunctionType pour créer la fonction accueillant l'ancien code. Même si je ne suis pas très fan (principalement parce que ça nous fait un peu dupliquer des choses et parce qu'on ne peut même pas filer la docstring au constructeur…), voici ce que ça donnerait :
import types as _types def _replace(from_, to): if hasattr(from_, 'im_func'): from_ = from_.im_func for name in ('doc', 'name', 'defaults', 'code', 'globals'): setattr(from_, attr_name, getattr(to, 'func_' + name)) from_.old = _types.FunctionType(from_.func_code, from_.func_globals, from_.func_name, from_.func_defaults) from_.old.func_doc = from_.func_doc -
mercredi 26 octobre 2011 à 02:30:40
Le Python, l'Introspection et le Singe #1
J'inaugure avec ce billet une nouvelle série ayant pour thème l'introspection et le monkey patching avec python. Parfois didactique, souvent magique, cette série de billets aura pour but de vous faire partager certaines de mes découvertes et idées folles, qui ne sont pas forcément les bienvenues dans un vrai projet, mais qui toutefois sont assez jouissives à étudier (sur le plan intellectuel).
Si d'aventure vous tombez sur une bibliothèque python dont vous avez besoin mais dont certains détails vous insupportent, inutile d'aller coder la votre ou de la forker. Non, dites plutôt bonjour au monkey patching et à l'introspection. Mais attention, faites ça proprement. On n'est pas des singes non plus.
Prenons par exemple webpy (oui, je sais que ça s'appelle « web.py », mais je l'appellerai « webpy » dans le reste de l'article pour éviter la confusion avec le fichier que l'on va créer). Le point d'entrée pour cette bibliothèque est le module web. On va donc créer le fichier web.py que l'on va mettre directement dans le répertoire du projet utilisant webpy.
Passons au remplissage de ce fichier. On va d'abord récupérer une référence sur le module courant (notre web.py à nous), importer le module sys pour pouvoir manipuler sys.path et le module imp pour pouvoir importer webpy.
import imp as _imp import sys as _sys _here = _sys.path.pop(0) _fp, _pathname, _description = _imp.find_module('web') _webpy = _imp.load_module('web', _fp, _pathname, _description) _sys.path.insert(0, _here)Comme vous pouvez le lire, on va dans un premier temps récupérer le chemin vers le module web (qui est le nom du module principal de webpy). Pour cela, on a besoin de retirer le premier répertoire de sys.path de sorte à ce qu'il ne nous renvoie pas le chemin vers notre web.py. On va ensuite lui demander de nous charger ce module en lui donnant comme nom « web » et en le stockant dans la variable webpy. Et on finit par remettre le sys.path comme il était avant.
Cet import pourrait juste s'écrire « import web as _webpy » si on n'était pas justement en train de créer le fichier web.py. Il faut donc lui faire charger le bon module sans qu'il n'aille chercher le notre. On pourra remarquer au passage que l'on doit préciser le nom du module lors de l'appel de imp.load_module(). C'est ce qui servira à donner sa valeur à son attribut __name__. Attribut dont on peut changer la valeur à n'importe quel moment (sa seule vraie utilité est quand la méthode __repr__ d'un module est utilisée).
Pour en savoir plus sur imp.find_module() et imp.load_module(), je vous invite à consulter la documentation officielle du module imp .
La documentation de imp.load_module() nous apprend par exemple que si le nom qu'on lui donne correspond à un module déjà chargé, elle va agir comme reload(). Il est donc important ici de bien comprendre que le nom donné à imp.load_module() (j'insiste sur le nom de la fonction, parce qu'il en est autrement pour imp.find_module()) doit absolument être le même que le fichier dans lequel on se trouve. Ainsi, python croira qu'on lui demande de recharger le module courant mais en utilisant un autre fichier. Ceci aura pour effet de peupler l'espace de noms web avec le contenu de webpy.
On a donc atteint la moitié de notre objectif. À savoir, créer un module qui se fasse entièrement passer pour webpy. Ainsi, on peut déjà utiliser notre web.py comme s'il sagissait de webpy.
Il ne nous reste alors plus qu'à ajouter/altérer/remplacer les fonctionnalités que l'on veut. Mais attention, il faut bien comprendre que ce que l'on va modifier ne le sera que pour l'application utilisant webpy. Les changements ne seront pas forcément effectifs pour webpy lui même.
Prenons par exemple, quelque chose que je déteste chez webpy : le fait que par défaut son mini serveur http écoute sur 0.0.0.0 alors que moi je préférerais qu'il se contente de 127.0.0.1. À chaque fois que je me remets à travailler sur un projet utilisant webpy, je suis obligé de penser à rajouter 127.0.0.1 sur la ligne de commande pour qu'il ne serve effectivement que dessus. J'aimerai bien ne plus avoir à le faire. Après un rapide coup d'œil au code de webpy, je me rends compte que tout se joue dans la fonction web.net.validip().
def _validip(ip, defaultaddr='127.0.0.1', defaultport=8080, _old=_webpy.net.validip): return _old(ip, defaultaddr, defaultport) _webpy.net.validip = _validipCeci fonctionnera parfaitement bien si dans notre projet on utilise web.net.validip(), ou bien si quelque chose de web.net l'utilise. Par contre, dans le cas qui m'intéresse, web.net.validip est importé dans web.wsgi avant que je ne puisse faire la substitution. Du coup, je n'ai modifié qu'une seule référence. Hors, il faudrait au moins modifier web.wsgi.validip, voire même toutes les références (histoire d'avoir un code cohérent). Pour faire simple, je vais juste me contenter de modifier le code donné plus haut pour l'adapter à mon réel besoin. Vous remarquerez d'ailleurs au passage que de ce fait, je n'ai plus besoin de rajouter une référence vers l'ancienne fonction vu qu'elle est toujours en place.
def _validip(ip, defaultaddr='127.0.0.1', defaultport=8080): return _webpy.net.validip(ip, defaultaddr, defaultport) _webpy.wsgi.validip = _validipSi je récapitule tout, on obtient quelque chose comme :
import imp as _imp import sys as _sys _here = _sys.path.pop(0) _fp, _pathname, _description = _imp.find_module('web') _webpy = _imp.load_module('web', _fp, _pathname, _description) _sys.path.insert(0, _here) def _validip(ip, defaultaddr='127.0.0.1', defaultport=8080): return _webpy.net.validip(ip, defaultaddr, defaultport) _webpy.wsgi.validip = _validipJe ne sais pas vous, mais moi je ne suis pas totalement satisfait avec ce code. Si MsieurHappy était là, il me dirait qu'il n'est pas assez warrior. Et, aussi exceptionnel que cela puisse être, il aurait raison !
Je n'aime vraiment pas cette histoire de fonction que l'on ne peut remplacer qu'en sachant nous même où elle est importée. Je me suis donc fendu d'une petite fonction qui va faire le travail à notre place.
def _replace(module, from_, to, processed=None, ismodule=inspect.ismodule): if processed is None: processed = list() if module in processed: return processed.append(module) for name in dir(module): attr = getattr(module, name) if ismodule(attr): _replace(attr, from_, to, processed) elif attr is from_: setattr(module, name, to)Cette fonction, que je ne détaillerai pas étant donné que c'est juste un simple parcours récursif, s'appelle avec le module de base (dans notre cas, webpy), une référence vers la fonction que l'on cherche à remplacer, ainsi qu'une référence vers la fonction par laquelle on doit remplacer. Ainsi, on peut remplacer « _webpy.wsgi.validip = _validip » par « _replace(_webpy, _webpy.wsgi.validip, _validip) ». Malheureusement, on doit réutiliser le coup du _old dans la fonction _validip (parce que sinon on n'aurait plus aucune référence dessus).
En nettoyant un peu l'espace de noms des symboles inutiles, voici ce qu'on obtient au final pour notre fichier web.py :
import imp as _imp import inspect as _inspect import sys as _sys _here = _sys.path.pop(0) _fp, _pathname, _description = _imp.find_module('web') _webpy = _imp.load_module('web', _fp, _pathname, _description) _sys.path.insert(0, _here) def _replace(module, from_, to, processed=None, ismodule=_inspect.ismodule): if processed is None: processed = list() if module in processed: return processed.append(module) for name in dir(module): attr = getattr(module, name) if ismodule(attr): _replace(attr, from_, to, processed) elif attr is from_: setattr(module, name, to) def _validip(ip, defaultaddr='127.0.0.1', defaultport=8080, _old=_webpy.net.validip): return _old(ip, defaultaddr, defaultport) _replace(_webpy, _webpy.net.validip, _validip) del _imp, _inspect, _sys, _here, _fp, _pathname, _description, _webpy, _replace, _validipÀ noter que l'on peut aussi remplacer une classe de la même manière. Bien que dans ce cas on préférera peut-être la modifier en place plutôt que d'en créer une nouvelle.
D'ailleurs, on aurait aussi très bien pu modifer la fonction en place sans devoir recourir à la fonction _replace actuelle. En effet, la version actuelle de cette fonction considère qu'une fonction est immuable. Or ce n'est pas le cas. On peut par exemple accéder à son code (pas sous forme exploitable par contre), à ses valeurs par défaut, etc. La documentation officielle de python pour le module inspect donne la liste complète des choses intéressantes.
Mais ceci fait l'objet d'un autre billet dans lequel je parle aussi de la modification de sys.path qui n'est pas optimale (on ne peut par exemple pas mettre notre fichier web.py ailleurs que là où est situé le projet l'utilisant).
Si vous n'avez pas de quoi tester notre fichier, je vous invite à vous rendre sur la page d'accueil du site de webpy qui contient un petit hello world. Testez ce fichier dans un coin (webpy affichera « http://0.0.0.0:8080/ » dans la console), puis retestez avec notre fichier dans le même répertoire (là webpy affichera « http://127.0.0.1:8080/ »).