reverse_lazy()

Quand django.core.urlresolvers.reverse() n'est pas applicable

Prérequis

Afin d'aller à l'essentiel, je ne prendrai pas le temps d'expliciter toutes les notions utilisées dans cet article. Aussi, si vous êtes mal à l'aise avec l'un ou l'autre des concepts, voici quelques pistes de lecture :

reverse()

Dans le framework Django, la fonction reverse() (django.core.urlresolvers.reverse pour être exact) est importante. La documentation Django à propos de reverse() l'explique assez bien. Je ne m'étendrai pas sur l'intérêt de cette fonction. Elle est tout bonnement indispensable.

Résumé du problème

La fonction reverse() effectue une recherche dans les URLconf de Django. Plus précisément, elle fait une recherche dans un objet URLResolver. Ce dernier construit un catalogue à partir des URLconf.

Donc, c'est bien logique, la fonction reverse() ne peut opérer que si le catalogue des URLconf est prêt.

Or, il y a des cas où reverse() est nécessaire alors que le catalogue d'URLconf n'est pas encore construit.

Pour faire simple, le catalogue d'URLconf est construit très tôt, mais beaucoup de modules Python sont chargés encore plus tôt.

Pour les curieux, regardez le code de django.core.handlers.base.BaseHandler.get_response() pour vous faire une idée plus précise du moment où l'URLResolver est initialisé.

reverse() dans les URLconf

Premier cas facile à appréhender : que se passe-t-il si on souhaite utiliser reverse() dans une URLconf ?

Pour lire le catalogue, on lit les URLconf. Si une URLconf utilise reverse(), alors on a besoin du catalogue. Or on est justement en train de le construire...

Par exemple si l'on veut créer une redirection "en dur", on peut utiliser django.views.generic.simple.redirect_to dans une URLconf. Cette vue requiert un paramètre obligatoire, "url". Étant donné qu'il est hors de question d'utiliser l'URL directement, on a besoin de reverse().

TODO: ajouter un exemple (code) avec l'exception générée

reverse() dans les decorateurs de vues

C'est, d'après mon expérience personnelle, le cas le plus courant.

On souhaite appliquer un décorateur à une vue. Ce décorateur prend un paramètre qui est une URL. Pas question d'indiquer l'URL réelle. Alors on a besoin de reverse().

TODO: ajouter un exemple (code), avec l'exception générée

Pour bien comprendre pourquoi on ne peut pas utiliser reverse() ici, il faut savoir que pour construire le catalogue d'URLconf, Django importe les vues qui y sont mentionnées. Cela provoque l'interprétation du code de ces modules, et l'exécution du code "global" dans ces modules. Les décorateurs sont appliqués aux vues à ce moment-là.

reverse() à d'autres endroits

Quelques autres emplacements méritent une attention particulière :

  • les fichiers __init__.py des modules listés dans INSTALLED_APPS
  • la configuration, dont le traditionnel settings.py

Dans ces emplacements, et plus généralement dans beaucoup d'endroits où l'appel à reverse() n'est pas encapsulé dans un objet ou une fonction, le problème peut apparaître.

reverse_lazy(): une solution

Une solution simple à mettre en oeuvre est d'utiliser reverse_lazy(). Cette fonction n'est pas encore disponible en standard dans Django.

Le principe : ne pas procéder à la résolution d'URL immédiatemment, comme le fait reverse(), mais le faire lorsque le résultat de la fonction est utilisé.

Lors de la construction des URLconf, si reverse() était appelé, la résolution d'URL serait tentée, mais son résultat ne serait pas utilisé. Lors de l'appel de reverse_lazy(), la résolution d'URL n'est pas tentée. La résolution n'est tentée que lorsqu'on utilise le résultat de reverse_lazy() en tant que chaîne de caractères. Par exemple lorsqu'on l'utilise dans la vue redirect_to() pour passer un header HTTP 302 redirect. Ou alors lorsqu'on l'affiche dans un template avec {% url ... %}. Mais en aucun cas lorsqu'on contruit le catalogue des URLconf.

Le ticket Django #5925 fait ce constat et propose une implémentation de reverse_lazy(). Il semblerait que le patch manque de tests unitaires pour être accepté.

La solution préconisée dans le ticket sus-mentionnée utilise l'utilitaire django.utils.functional.lazy(). Ce qui est très propre. Je ne l'ai pas encore testée.

Pour ma part, j'utilisais jusqu'à présent le code ci-dessous, directement inspiré du DjangoSnippet n°499 de guettli. Un des commentaires du ticket utilise un principe similaire.

"""
Lazy pattern applied to django.core.urlresolvers.reverse().

Codebase from http://www.djangosnippets.org/snippets/499/ by guettli
Added support of the % operator, which is required to pass a LazyString
to django.views.generic.simple.redirect_to().
"""
from django.core.urlresolvers import reverse


class LazyString(object):
    """A str-like object which value is computed when the object is actually
    used as a string.
    """
    def __init__(self, function, *args, **kwargs):
        """Constructs a "lazy" str-like object.

        The call to "function" is delayed.
        The "function" parameter receives any additional args only when the
        LazyString instance is used as a str.
        """
        self.function=function
        self.args=args
        self.kwargs=kwargs

    def __str__(self):
        """Executes self.function to convert LazyString instance to a real
        str."""
        if not hasattr(self, '_str'):
            self._str=self.function(*self.args, **self.kwargs)
        return self._str

    def __mod__(self, operand):
        """Handles string formating operator."""
        return self.__str__() % operand


def reverse_lazy(*args, **kwargs):
    """Delays URL resolution to the moment when the returned value is used
    as a string.

    Use it like Django's builtin reverse(), wherever reverse() cannot be
    called:

    * in URLconf
    * in decorators of views
    * everywhere parsed before Django's URL resolver is built. In doubt,
      everywhere outside functions and objects, including argument
      definition of functions or methods.
    """
    return LazyString(reverse, *args, **kwargs)

Solutions alternatives

Pour aller plus loin dans la réflexion, voici quelques hypothèses... Notez bien qu'à ce jour, je ne sais pas si elles se vérifient.

reverse() dans les URLconf

Si les vues ne prenaient pas en argument des URL "réelles" mais les paramètres permettant de les obtenir, le problème ne se poserait pas. En effet, la résolution des URL se ferait dans les vues, à un moment ou le catalogue d'URLconf est déjà construit.

Pour les URL internes, on l'a déjà dit, il est hors de question de ne pas utiliser reverse(). Donc pour les URL internes, on pourrait passer à la vue les paramètres qu'on passe à reverse() au lieu de passer le résultat.

Pour les URL externes, on ne dispose pas d'un outil équivalent à reverse(). De là à dire qu'on peut se permettre de passer des URL "en dur", il y a un pas que je n'ose faire. En principe, ces URL ne devraient pas apparaître en dur dans le code. Au pire, elles devraient être des variables de configuration. Dans la mesure du possible, un équivalent à reverse() pour les URL externes serait donc le bienvenu.

Donc on pourrait imaginer passer les paramètres suivants aux vues :

  • la méthode pour obtenir l'URL. Par exemple "reverse" ou "external_reverse". Dans l'absolu, ce pourrait être n'importe quel callable ou adapter.
  • les paramètres à passer à cette méthode.

reverse() dans les décorateurs de vues

On l'a évoqué, pour construire le catalogue d'URLconf, Django importe toutes les vues qui y sont mentionnées.

Si Django n'importait pas ces vues au moment de construire le catalogue d'URLconf, le problème reverse() dans les fichiers views.py, hors des vues, ne se poserait pas.

Mais, pourraient s'écrier certains, il est nécessaire d'importer ces vues pour "compiler" les URLconf. De façon à ne charger les vues qu'une seule fois. Pour des raisons de performances.

Au moins deux approches pourraient débouter cet argument :

  • construire le catalogue en deux temps. D'abord sans importer les vues. On peut alors obtenir la correspondance entre URL et URLpattern. C'est suffisant pour utiliser reverse(). Importons les vues à ce moment-là.
  • importer les vue en mode "lazy". C'est à dire importer les vues à la demande. Si une vue est demandée, l'importer et s'arranger pour que l'import ne se fasse plus. Mais ne pas importer les vues pour lesquelles le serveur ne reçoit aucune requête. En plus d'être une alternative à reverse_lazy(), cette solution pourrait faire économiser des ressources à un processus qui, durant sa vie, ne reçoit aucune requête sur certaines vues. Le gain serait d'autant plus grand que la vie d'un processus est courte, ou que le nombre de modules contenant des vues est grand.

Une autre hypothèse serait de ne pas appliquer les décorateurs dans le même module que les vues elles-mêmes. Cette approche me fait penser à d'autres concepts, peut-être plus proches du monde Zope / Plone (que je ne connais pas bien, soit dit en passant). Par exemple n'est-il pas pertinent de séparer la vue en elle-même et ses "modificateurs" ? D'autres diraient "Adapter" il me semble. En effet le décorateur @login_required ne devrait-il pas être appliqué seulement si le site est configuré ainsi ? Peut-être que pour d'autres sites la même vue pourrait être publique ? Ou bien le décorateur pourrait être une version personnalisée du @login_required ? Découpler la logique des décorateurs de celle des vues permettrait d'économiser du code (et par là-même des erreurs). Mais, on touche peut-être là à des fondamentaux de Django.