Makina Blog

Le blog Makina-corpus

Générer les urls Django à partir de la structure des dossiers


Dans cet article, nous vous proposons un exemple de génération automatique des urlpatterns à partir de la structure des fichiers et dossiers contenant les vues.

Dans les projets qui deviennent imposants en termes de nombre de vues Django, il est important que la structure des fichiers et dossiers soit propre afin de pouvoir naviguer dans le code intuitivement. En partant de ce postulat, nous pouvons imaginer que celle-ci reflète la structure des urls d'une application.

Exemple

Partons d'un exemple simple mais concret, le tutorial Django officiel, dont voici la structure des urls :

from django.urls import path

from . import views

app_name = 'polls'
urlpatterns = [
    path('', views.index, name='index'),
    path('<int:question_id>/', views.detail, name='detail'),
    path('<int:question_id>/results/', views.results, name='results'),
    path('<int:question_id>/vote/', views.vote, name='vote'),
]

Nous pouvons concevoir une structure de fichier qui reflète cette arborescence d'urls :

views
├── __init__.py
├── index.py
└── questions
    ├── __init__.py
    ├── detail.py
    ├── results.py
    └── vote.py

Il devient intéressant d'avoir cette arborescence synchronisée lorsque la quinzaine de vues est dépassée, avec de nombreux modèles ayant chacun son namespace (pour le CRUD par exemple).

Une proposition d'implémentation

L'idée est donc de parcourir le package views/de l'application afin de découvrir les vues automatiquement et de retranscrire la structure des fichiers dans les urls, et ce en pouvant configurer les paramètres d'url et en activant ou non les namespaces.

Quelques règles pour contraindre

Voici quelques règles que nous pouvons nous imposer afin de coller le mieux au besoin :

  • Les vues sont toutes des class-based views, pour plus de facilité/homogénéité
  • Elles doivent être référencées dans l'attribut __all__ de chaque module
  • Par défaut, un package est retranscrit en namespace, le nom du package étant le nom du namespace
  • Par défaut, un module :
    • S'il contient une seule vue, il est retranscrit en chemin d'url, le nom du chemin et le prefixe d'url correspondant au nom du module
    • S'il contient plusieurs vues, il est alors considéré comme un namespace (de la même manière qu'un package) et chaque vue est un chemin d'url. Chaque vue doit déclarer un attribut urlpatterns qui définit son nom de chemin et son préfixe d'url. 

Si nous suivons ces règles à la lettre à partir de l'arborescence ci dessus, nous avons cette configuration d'url :

urlpatterns = [
    path('index/', IndexView.as_view(), name='index'),
    path('questions/<int:question_id>/', include(([
        path('detail/', DetailView.as_view(), name='detail'),
        path('results/', ResultsView.as_view(), name='results'),
        path('vote/', VoteView.as_view(), name='vote'),
    ], 'questions')),
]

Quelques adaptations sont donc à réaliser pour personnaliser la manière de générer les chemins d'url.

Un premier algorithme pour générer les urls

Exemple du chemin index

Nous allons donc commencer par polls:index, la vue qui liste toutes les questions, et nous sommes déjà confrontés à une personnalisation du préfixe puisque celui-ci doit être '' et non 'index/'. Déclarons donc au niveau de la vue un dictionnaire urlpatterns qui prend en clé le nom du chemin et en valeur le chemin :

# polls/views/index.py
__all__ = ['IndexView']

class IndexView(generic.ListView):
    urlpatterns = {'index': ''}
    template_name = 'polls/index.html'
    context_object_name = 'latest_question_list'

    def get_queryset(self):
        """
        Return the last five published questions (not including those set to be
        published in the future).
        """
        return Question.objects.filter(
            pub_date__lte=timezone.now()
        ).order_by('-pub_date')[:5]

Exemple d'un namespace simple

Si nous partons du principe que chaque vue a son fichier, nous pouvons adopter cette structure pour l'affichage des questions :

└── questions
    ├── __init__.py
    ├── detail.py
    ├── results.py
    └── vote.py

Mais les vues de détail, résultat et vote ont pour paramètre <int:question_id>, or ce paramètre étant composé de caractères spéciaux, il ne peut faire partie du nom du package. Nous allons donc personnaliser le nom du namespace et son chemin d'url dans le __init__.py comme ceci :

# polls/views/questions/__init__.py
__namespace__ = {'questions': 'questions/<int:question_id>'}

Implémentation du parcours de l'arborescence

Pour implémenter cette fonctionnalité, partons sur une classe qui va nous permettre d'encapsuler toute la logique. Puis, comme nous parcourons une arborescence de fichiers, nous allons utiliser des méthodes récursives.

N'hésitez pas à lire les commentaires pour l'explication de l'algorithme.

import importlib
import inspect
from pathlib import Path
from typing import List

from django.conf import settings
from django.urls import include, path
from django.views import View


class FileRouter:
    def __call__(self, filepath: str):
        # On utilise la méthode magique __call__ pour pouvoir appeler notre routeur directement
        # router = FileRouter()
        # urlpatterns = router('yourapp/views')
        # settings.BASE_DIR est souvent déclaré dans les settings d'un projet
        return self.patterns_from_tree(Path(settings.BASE_DIR) / filepath)

    @staticmethod
    def _patterns_from_views(module, stem: str) -> List:
        subpatterns = []
        # On parcourt toutes les classes référencées dans le __all__ du module
        for view_name in getattr(module, '__all__', []):
            # En ne sélectionnant que celles qui sont des vues
            view_class = getattr(module, view_name)
            if not inspect.isclass(view_class) or not issubclass(view_class, View):
                continue

            # Si la classe ne déclare pas d'urlpatterns, on prend le nom du module
            view_urlpatterns = getattr(view_class, 'urlpatterns', stem)
            if isinstance(view_urlpatterns, str):
                view_urlpatterns = {view_urlpatterns: view_urlpatterns}

            # Puis on boucle sur les urlpatterns de la classe pour générer les chemins
            # (une CBV peut donc répondre à plusieurs chemins !)
            for name, url in view_urlpatterns.items():
                subpatterns.append(
                    path(url, view_class.as_view(), name=name),
                )
        return subpatterns

    def patterns_from_tree(self, start_dir: Path) -> List:
        # Ceci est une méthode récursive, elle nous permettra d'aller en profondeur dans l'arborescence
        patterns = []

        # On boucle sur les entrées du répertoire
        for entry in start_dir.iterdir():
            entry_name = entry.stem.replace('_', '-')

            if entry.is_file() and entry.stem != '__init__':
                # Le fichier est donc un module, on l'importe
                module = importlib.import_module(
                    '.'.join(entry.relative_to(settings.BASE_DIR).parts).replace(".py", "")
                )
                # On récupère son __all__ pour avoir ses vues, si elles sont multiples et que le module se déclare
                # comme étant un namespace (comportement par défaut)
                views = getattr(module, '__all__', [])
                if len(views) > 1 and getattr(module, '__namespace__', True):
                    # Alors on va récupérer les urlpatterns de ses vues
                    subpatterns = self._patterns_from_views(module, entry_name)
                    # Et les ajouter sous un ou plusieurs namespaces, spécifié par __namespace__ du module
                    # par défaut, ce sera tout simplement le nom du module
                    namespaces = getattr(module, '__namespace__', entry_name)
                    if isinstance(namespaces, str):
                        namespaces = {namespaces: namespaces}
                    for namespace, url in namespaces.items():
                        patterns.append(
                            path(url + "/", include((subpatterns.copy(), namespace))),
                        )
                else:
                    # Sinon, le module contient une seule vue, donc un seul chemin sans namespace
                    subpatterns = self._patterns_from_views(module, entry_name)
                    patterns += subpatterns

            elif entry.is_dir() and entry.name != "__pycache__":
                # Dans ce cas, le dossier est un package, on l'importe et on déclenche l'analyse récursive 
                # pour avoir les patterns qu'il contient
                package = importlib.import_module('.'.join(entry.relative_to(settings.BASE_DIR).parts))

                subpatterns = self.patterns_from_tree(entry)
                if subpatterns:
                    # On regarde ensuite s'il déclare un attribut __namespace__ dans son __init__
                    namespaces = getattr(package, '__namespace__', entry_name)
                    if namespaces is False:
                        # Si __namespace__ = False, pas de namespace à créer donc on ajoute simplement 
                        # les chemins récupérés auparavant
                        patterns += subpatterns
                        continue
                    elif isinstance(namespaces, str):
                        # Si __namespace__ est une chaine, il est considéré comme le nom du namespace et le préfixe de chemin
                        namespaces = {namespaces: namespaces}
                    # On ajoute ensuite pour chaque namespace (car un package poeut en déclarer plusieurs)
                    # les chemins récupérés auparavant
                    for namespace, url in namespaces.items():
                        url = url + "/" if url else ''
                        patterns.append(
                            path(url, include((subpatterns.copy(), namespace))),
                        )
        return patterns

Ce code à l'air complexe, mais il implémente toutes les conditions établies précédemment.

Une aide au debug

Bon, en effet, il est vraiment complexe ! Nous allons donc ajouter un peu de débug pour voir quels urlpatterns il génére. Il suffit de parcourir la liste des urlpatterns et d'afficher leurs informations, tout cela récursivement.

    def debug(self, patterns, depth=0):
        msg: str = ''
        line_prefix = '    '  # une identation afin d'avoir un code clair
        for p in patterns:
            msg += line_prefix * depth
            if hasattr(p, 'namespace'):
                # Le pattern est un namespace, on l'affiche en tant qu'include() en affichant récursivement ses "enfants"
                msg += f"path('{p.pattern.regex.pattern}', include(([\n"
                msg += self.debug(p.url_patterns, depth + 1)
                msg += line_prefix * depth
                msg += f"], '{p.namespace}')),\n"
            else:
                # Le pattern est un chemin normal, on a affiche le nom de sa vue
                msg += "path('{url}', {view_name}.as_view(), name='{name}'),\n".format(
                    url=p.pattern.regex.pattern,
                    view_name=p.callback.view_class.__name__,
                    name=p.name,
                )
        return msg

Ainsi, lorsque que nous déclenche le debug, notre configuration actuelle :

file_router = FileRouter()
urlpatterns = file_router('polls/views')
app_name = 'polls'

if settings.DEBUG:
    print(file_router.debug(urlpatterns))

Nous obtenons :

path('', IndexView.as_view(), name='index'),
path('questions/<int:question_id>/', include(([
    path('', DetailView.as_view(), name='detail'),
    path('results/', ResultsView.as_view(), name='results'),
    path('vote/', VoteView.as_view(), name='vote'),
], 'questions')),

Dans un environnement en production, nous préférons l'utilisation du logging :

if settings.DEBUG:
    import logging

    logger = logging.getLogger(__name__)
    logger.debug("\n" + file_router.debug(urlpatterns))

Avec, par exemple pour activer l'affichage dans la console, dans vos settings :

LOGGING['loggers']['myproject.urls'] = {
    'handlers': ['console'],
    'level': 'DEBUG',
}

La problématique de duplication de namespaces

Grâce à (ou à cause ?) notre possiblité de personnaliser les namespaces de packages, Il peut arriver d'avoir des dossiers qui bien que différement placés dans l'arborescence, sont "jumeaux" en terme de nommage. Voici un exemple avec un nouveau package :

views
├── __init__.py
├── index.py
├── exports
│   ├── __init__.py
│   └── questions.py
└── questions
    ├── __init__.py
    ├── detail.py
    ├── results.py
    └── vote.py

Et un nouveau fichier de vue exports/questions.py:

__namespace__ = {'questions': 'questions/<int:question_id>'}
__all__ = [
    "ExportView",
]


class ExportView(View):
    urlpattern = 'export'
    ...

Si dans cette arborescence, le fichier exports/__init__.py est configuré avec __namespace__ = False, alors nous avons un conflit de namespace sur "questions:" entre exports/questions.py et le package questions/. Ceci se traduit par le fait que le premier découvert est le seul connu, car c'est le premier dans la liste des patterns, avec notre debug :

path('', IndexView.as_view(), name='index'),
path('questions/<int:question_id>/', include(([
    path('export', ExportView.as_view(), name='export'),
], 'questions')),
path('questions/<int:question_id>/', include(([
    path('', DetailView.as_view(), name='detail'),
    path('results/', ResultsView.as_view(), name='results'),
    path('vote/', VoteView.as_view(), name='vote'),
], 'questions')),

Un warning urls.W005 est aussi remonté par les checks de Django. Il faut donc résoudre ce problème en fusionnant les namespace, c'est à dire ajouter les patterns de l'un à la liste de l'autre. Pour ce faire, Nous pouvons passer par une méthode dédiée (vous y trouvez que les changements requis) :

class FileRouter:
    def __call__(self, filepath: str):
        patterns = self.patterns_from_tree(Path(settings.BASE_DIR) / filepath)
        self._dedupe_namespaces(patterns)
        return patterns

    def _dedupe_namespaces(self, patterns, parents=(), namespaces=None):
        # Encore une méthode récursive, car les conflits de namespace peuvent intervenir à tout niveau
        # On reférence les namespaces dans un dictionnaire ou la clé est le nom complet du namespace 
        # (avec ses enfants) la valeur la liste des chemins de celui-ci (cela permettra de la modifier 
        # en place)
        namespaces = {} if namespaces is None else namespaces

        # On stocke les chemins qui sont en conflit pour les retirer en fin d'analyse
        to_remove = []

        # On parcours notre liste de namespaces
        for pattern in patterns:
            namespace = getattr(pattern, 'namespace', None)
            current = parents
            if namespace is not None:
                # On construit notre arborescence de namespaces pour la récursivité
                current += (namespace,)
                self._dedupe_namespaces(pattern.url_patterns, current, namespaces)


                namespace_name = ':'.join(current)
                if namespace_name not in namespaces:
                    # Le namespace n'est pas encore utilisé, on l'ajoute à notre dictionnaire
                    namespaces[namespace_name] = pattern.url_patterns
                else:
                    # Le namespace est déjà utilisé, on ajoute  les chemins à celui déja référencé,
                    # et on le marque pour suppression
                    namespaces[namespace_name].extend(pattern.url_patterns)
                    to_remove.append(pattern)

        # On retire les entrées en doublons qui ont été fusionnées
        for item in to_remove:
            patterns.remove(item)

Conclusion et autres problématiques pouvant survenir

Voilà, nous avons donc une mécanique certes un peu complexe, mais qui comparée à d'autres solutions comme django-file-router ne casse pas les imports python en ayant des placeholders (ex: <id>) dans les noms de dossier.

Vous pouvez retrouver cette classe dans le code du projet pour lequel il a été produit : voir le code complet file_router.py. Il aura peut-être évolué d'ici là, car certaines fonctionnalités peuvent être ajoutées par la suite :

  • Question des regex
    Dans le cas où nous voulons utiliser des regex avec des arguments optionnels ou des regex complexes ne pouvant être gérées avec les convertisseurs de chemin personnalisés

  • Question des chemins multiples par pattern
    Dans le cas où nous voulons qu'une vue réponde par plusieurs chemins mais avec le même nom.

  • Gestion des vues basées sur des fonctions
    Dans le cas où nous voulons gérer à la fois des vues basées sur des classes et des vues basées sur des fonctions, ceci pourrait être résolu avec un peu d'introspection python.

Formations associées

Formations Django

Formation Django avancé

À distance (FOAD) Du 9 au 13 décembre 2024

Voir la formation

Formations Django

Formation Django REST Framework

À distance (FOAD) Du 9 au 13 juin 2025

Voir la formation

Formations Django

Formation Django intégration

À distance (FOAD) 22 janvier 2025

Voir la formation

Actualités en lien

Image
Agrégateur Geotrek
08/06/2023

Le projet Agrégateur : fusionner des bases de données Geotrek

Le partage et la diffusion des données font partie des problématiques historiques au cœur du projet Geotrek.

Voir l'article
Image
Django Python Keycloak
18/11/2021

Administrer des comptes Keycloak depuis une application Python/Django

Dans cet article, nous allons créer une application Python/Django qui agira en tant que maître sur Keycloak afin de pouvoir ajouter facilement des comportements personnalisés à Keycloak.

Voir l'article
Image
Django_Rest_Framework_routeurs3
06/01/2016

Django Rest Framework : fonctionnement des routeurs (partie 3)

Chose promise, chose due : après avoir mis en avant les bienfaits des Viewset, on va plonger dans le côté obscur de la force de DRF : les routeurs.

Voir l'article

Inscription à la newsletter

Nous vous avons convaincus