Blog d’un « connard amer »™

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

On 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_func

Voici 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, _validip

Vous 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, _webpy

Mais 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, _replace

Et 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/10

Que 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/10

Ceci 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

Commentaire(s)

Ajouter un commentaire


Je leur diffuse la bonne parole :

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

Running Djlog 0.42.