Makina Blog
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ésQuestion 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 formationFormations Django
Formation Django REST Framework
À distance (FOAD) Du 9 au 13 juin 2025
Voir la formationActualités en lien
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.
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.
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.