Créer une appli Django réutilisable
L'autre jour, j'étais tranquillement assis dans mon fauteuil, sirotant mon café et dépilant une à une les stories de mon backlog avec la régularité d'un opérateur de train nippon lorsque sans grier « gare ! » mon instinct de développeur affuté par des années de labeur se mit à clignoter.
« Cette fonctionnalité, me murmura l'instinct susmentionné, ferait un candidat parfait à l'écriture d'une application dédiée. »
Le concept d'application est au cœur même de Django, mais créer une application réellement réutilisable nécessite que le code en question sorte complètement du projet pour devenir un logiciel à part entière, avec :
- son propre dépôt Git ;
- ses propres tests ;
- sa propre licence ;
- sa propre documentation ;
- sa propre procédure d'installation ;
- son propre bug tracker ;
- etc.
La doc officielle sur le sujet est plus que parcellaire, et sur tous les tutos existants que j'ai pu croiser, aucun n'aborde le workflow dans son intégralité.
Je suis certain que vous aussi, il vous arrive de rencontrer au détour d'un backlog une fonctionnalité sauvage. Comment s'en saisir et la mettre en cage tel un pimpant rossignol qui pourra ainsi eggayer vos journées en chantant dans le salon jusqu'à ce qu'il parte en dépression et ne vous coûte une fortune en antidépresseurs et honoraires de psy parce qu'un rossignol c'est pas fait pour être enfermé bordel ?!
Pourquoi une appli réutilisable ?
Pourquoi se palucher l'écriture d'une appli dédiée quand il serait beaucoup plus simple de directement inclure le code au sein du projet ? J'y vois plusieurs raisons.
La première raison, c'est que ça permet d'alléger la base de code du projet. Déporter tout un jeu de fonctionnalité vers une appli tierce permet de réduire le nombre de lignes de code au sein du projet même. Moins de code = moins de bugs.
Ensuite, vous réduisez la complexité et facilitez la maintenance. Mettre les mains dans une appli de trois fichiers, c'est fichtrement plus facile que de se plonger dans un projet Django monstrueux, et ça fait du boulot à donner aux stagiaires. Vous augmentez également les possibilités de contributions extérieures.
Créer une appli spécifique nécessite de se poser les bonnes questions et produira probablement du code de meilleure qualité. « T'as vu ma belle appli toute propre ? » vs. « J'ai fourré ça comme j'ai pu et j'ai tassé à coup de pelle pour pouvoir fermer le coffre ».
Enfin, en étant le développeur / mainteneur de plusieurs applis réutilisable, vous contribuez auprès de la communauté. Le résultat évident est que vous pourrez vous la jouer grave aux prochaines rencontres Django.
Initialiser le dépôt
Nous allons partir d'un exemple concret, qui correspond à la story suivante : « En tant qu'utilisateur, je peux télécharger une liste de fichiers sous la forme d'une seule archive zip ». Voici un bel exemple de feature qui pourrait constituer une application dédiée.
J'ai tendance à initialiser mon dépôt git directement depuis Github, pour avoir un beau .gitignore tout frais. N'oublions pas de sélectionner une licence compatible avec une redistribution massive (donc pas la GPL). Je vous laisse donc faire pareil et simplement clôner le dépôt en local.
git clone git@github.com:thibault/django-zipview.git
Ajoutons dès maintenant un fichier README.md, parce ce que le Readme driven development, c'est bien.
Django ZipView ============== A base view to zip and stream several files. Installation ------------ pip install django-zipview Usage and examples ------------------ To create a zip download view: * Extend BaseZipView * implement `get_files` * That's it The `get_files` method must return a list of Django's File objects. Example: ```python from zipview.views import BaseZipView from reviews import Review class CommentsArchiveView(BaseZipView): """Download at once all comments for a review.""" def get_files(self): document_key = self.kwargs.get('document_key') reviews = Review.objects \ .filter(document__document_key=document_key) \ .exclude(comments__isnull=True) return [review.comments.file for review in reviews if review.comments.name] ```
Notez que je suis en train d'écrire un billet en format rst qui contient du md. So meta…
Bref, nous avons un dépôt qui contient un Readme avec exemple basique, une license et un .gitignore. C'est un bon début.
Préparer la distribution
Une appli réutilisable doit bien entendu pouvoir être… réutilisée (bravo à ceux qui suivent, dans le fond) ! Et qui dit réutilisable dit… installable ! Pour ça, nous allons créer un fichier setup.py et un Manifest.in. On commence par le manifest, c'est le plus facile. En gros, on utilise ce fichier pour indiquer à l'outil de packaging quels fichiers inclure.
include LICENSE include README.md include *.txt *.ini *.cfg *.rst *md recursive-include zipview/static * recursive-include zipview/templates *
Il existe plusieurs outils permettant de packager des applis Python. Nous utiliserons setuptools parce que… parce que… parce que c'est comme ça !
import os from setuptools import setup with open(os.path.join(os.path.dirname(__file__), 'README.md')) as readme: README = readme.read() # allow setup.py to be run from any path os.chdir(os.path.normpath(os.path.join(os.path.abspath(__file__), os.pardir))) setup( name='django-zipview', version='1.0.0', packages=['zipview'], include_package_data=True, license='MIT License', description='A simple Django base view to zip and stream several files.', long_description=README, url='https://github.com/thibault/django-zipview/', author='Thibault Jouannic', author_email='the author @email', classifiers=[ 'Environment :: Web Environment', 'Framework :: Django', 'Intended Audience :: Developers', 'License :: OSI Approved :: MIT License', 'Operating System :: OS Independent', 'Programming Language :: Python', 'Programming Language :: Python :: 2', 'Programming Language :: Python :: 2.7', 'Programming Language :: Python :: 3', 'Programming Language :: Python :: 3.3', 'Topic :: Internet :: WWW/HTTP', 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', ], )
Initialiser l'appli
Nous allons maintenant créer le package qui va contenir notre application. Oui, parce qu'on ne fout pas le code de l'application directement à la racine du projet, ça fait désordre, et le désordre, on n'aime pas trop ça chez Miximum.
mkdir zipview mkdir zipview/tests touch zipview/__init__.py zipview/tests/__init__.py
Initialisons notre fichier de travail, avec le strict minimum pour commencer à écrire des tests, en éditant zipview/views.py.
# -*- coding: utf-8 -*- from __future__ import unicode_literals from django.views.generic import View class BaseZipView(View): pass
Écrire les tests
N'allez pas plus loin. Car avant d'écrire le code, il faut écrire les tests ! Comme il s'agit d'une appli Django, nous allons utiliser notre framework préféré pour lancer les tests, ce qui nécessite l'existence d'un fichier de settings. Créons donc zipview/test_settings.py.
INSTALLED_APPS = ( 'zipview', ) DATABASES = { 'default': { 'ENGINE': 'django.db.backends.sqlite3', 'NAME': ':memory:', } } MIDDLEWARE_CLASSES = ( 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', ) SECRET_KEY = "gloubiboulga secret key"
Puis écrivons nos tests dans zipview/tests/test_views.py.
# -*- coding: utf-8 -*- from __future__ import unicode_literals import os import zipfile from io import BytesIO from django.test import TestCase from django.core.files import File from django.http import HttpResponse from django.test.client import RequestFactory from zipview.views import BaseZipView class ZipView(BaseZipView): """Test ZipView basic implementation.""" _files = None def get_files(self): if self._files is None: dirname = os.path.dirname(__file__) self._files = [ File(open(os.path.join(dirname, 'test_file.txt'))), File(open(os.path.join(dirname, 'test_file.odt'))), ] return self._files class ZipViewTests(TestCase): def setUp(self): self.view = ZipView() self.request = RequestFactory() def test_response_type(self): response = self.view.get(self.request) self.assertTrue(isinstance(response, HttpResponse)) def test_response_params(self): response = self.view.get(self.request) self.assertEqual(response['Content-Type'], 'application/zip') self.assertEqual(response['Content-Disposition'], 'attachment; filename=download.zip') def test_response_content_length(self): response = self.view.get(self.request) self.assertEqual(response['Content-Length'], '19795') def test_valid_zipfile(self): response = self.view.get(self.request) content = BytesIO(response.content) self.assertTrue(zipfile.is_zipfile(content)) zip_file = zipfile.ZipFile(content) self.assertEqual(zip_file.namelist(), ['test_file.txt', 'test_file.odt'])
Faire tourner les tests avec tox
Nous pourrions certes lancer les tests en l'état, obtenir nos quelques échecs et nous en contenter avant d'entamer joyeusement l'implémentation. Malheureux ! C'est en échouant qu'on progresse, et comme on aime bien progresser, il nous faut donc échouer encore plus !
En clair, je veux faire tourner mes tests dans plusieurs environnements pour être certain que mon application sera compatible avec la majorité des projets.
J'ai décidé que mon application devait être compatible avec les deux dernières versions majeures de Python (2.7 et 3.4) et les deux dernières versions de Django (1.6 et 1.7). Cela fait donc 4 environnements de tests.
Pour nous faciliter la tâche, nous allons utiliser tox, un outil d'automatisation de test et packaging python. En gros, nous allons configurer tox, qui se chargera de construire les multiples environnements et lancer les tests à chaque fois.
Pour cela, créons un virtualenv…
mkvirtualenv django-zipview
…installons tox…
pip install tox
…et configurons la chose en créant un fichier tox.ini.
[base] # Let's configure base dependencies deps = flake8 coverage [tox] # Here is the list of our environments envlist = py27-1.6, py27-1.7, py34-1.6, py34-1.7 [testenv] # Install current package before testing usedevelop = True # Configure the actual testing command whitelist_externals = /usr/bin/make commands = make test # Let's define specific dependencies for each environment [testenv:py27-1.6] basepython = python2.7 deps = Django>=1.6,<1.7 {[base]deps} [testenv:py27-1.7] basepython = python2.7 deps = Django>=1.7,<1.8 {[base]deps} [testenv:py34-1.6] basepython = python3.4 deps = Django>=1.6,<1.7 {[base]deps} [testenv:py34-1.7] basepython = python3.4 deps = Django>=1.7,<1.8 {[base]deps}
Notez que tox se chargera d'installer les dépendances dans chaque environnement, il n'est donc pas nécessaire de le faire à la main.
Notez également que nous utilisons un Makefile pour lancer les tests. Profitons-en pour contempler dédaigneusement les pauvres hipsters qui en sont encore à décider lequel de Grunt ou Gulp est le moins pire, laissons les à leur malheur et savourons la joie simple d'utiliser de vrais outils qui ont fait leurs preuves et ne nécessitent pas 50 niveaux de parenthèses pour définir une variable de configuration.
test: flake8 zipview --ignore=E501 coverage run --branch --source=zipview `which django-admin.py` test --settings=zipview.test_settings zipview coverage report --omit=zipview/test* .PHONY: test
Bon, on est prêt à les lancer, ces tests ? C'est parti !
tox
Et c'est tout ! Il ne vous reste plus qu'à admirer Tox opérer sa magie, c'est encore plus fascinant que d'observer le linge tourner dans la machine à laver.
Configurer Travis.ci
J'ai déjà mentionné Travis.ci dans un autre article traitant de tests avancés avec Django
Travis pourrait très bien se charger de faire tourner nos tests dans de multiples environnements, mais tox s'en charge déjà, et les deux outils collaborent à merveille. Notre configuration est donc minimale. Éditons le fichier .travis.yml :
language: python install: - pip install tox coveralls script: - tox env: - TOXENV=py34-1.7 - TOXENV=py27-1.7 - TOXENV=py34-1.6 - TOXENV=py27-1.6 after_success: coveralls
Bosser sur l'appli en cours de développement
Les choses étant ce qu'elles sont, il est fréquent de vouloir utiliser dans son projet une appli en cours d'écriture. Il serait fastidieux de la re packager / installer à chaque modification. Heureusement, il existe un outil tout fait pour ce cas de figure.
Depuis le virtualenv du projet principal, il me suffit de me rendre dans le répertoire de ma lib externe, puis de tapoter joyeusement :
python setup.py develop
Setuptools va alors créer un symlink vers mon répertoire de travail dans mon virtualenv, avec pour résultat que je peux l'utiliser comme si elle était installée.
Lorsque je suis prêt à réaliser une installation plus conventionnelle, il me suffit de :
python setup.py develop --uninstall
Implémenter le bouzin
Sur cette partie là, je vous fait confiance, vous savez parfaitement comment faire. Passons donc à…
Packager et distribuer son appli
Publier une application sur Pypi est d'une redoutable facilité. Vous pensiez peut-être qu'il fallait des droits d'accès draconiens avec revue de chaque version par trois commités différents avant de pouvoir publier une lib ? Il n'en est rien !
En fait, enregister un nom d'appli se fait en une commande :
python setup.py register
Vous allez obtenir un prompt qui vous permettra de créer un compte ou de vous identifier si vous disposez déjà d'un accès.
Une fois authentifié, vous devriez obtenir une réponse qui termine par :
Registering django-zipview to https://pypi.python.org/pypi Server response (200): OK
Cool, le nom est réservé. Maintenant, il ne vous reste plus qu'à créer et uploader votre package.
python setup.py sdist upload
Et bam ! N'importe qui peut désormais installer votre lib au moyen d'un simple pip install django-zipview. N'est-ce pas génial ?
Aller plus loin
Certains aspects ont délibérément été laissés de côté, pour de multiples raisons, la première desquelles étant que j'ai la flemme de continuer à écrire, mon chocolat chaud m'attend.