Blog d’un « connard amer »™

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 = _validip

Ceci 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 = _validip

Si 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 = _validip

Je 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/ »).

Commentaire(s)

  • Par ThibG
    (jeudi 27 octobre 2011 à 01:19:29)

    Mais quel bourrin !
    N'empêche que parcourir le module pour remplacer les références, c'est rigolo et bien trouvé.
    Ah, aussi, __name__ peut-être utilisé par autre chose qu'un repr, genre pour le test __name__ == '__main__', ou pour afficher où on se situe (par exemple, en utilisant le message logging, ça peut être pas mal d'utiliser __name__ pour savoir exactement qui affiche le message).

Ajouter un commentaire


Je leur diffuse la bonne parole :

This blog and all its content is under the GNU GPLv3.

Running Djlog 0.42.