Créer une appli Django réutilisable

Développeur, développeur, dis moi qui est la plus belle

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 ?!

Au menu aujourd'hui

Ce tutoriel couvrira les points suivants :

  • comment structurer un projet d'application Django redistribuable ?
  • comment utiliser dans son projet une appli en cours de développement sans la repackager / distribuer / installer toutes les 5 minutes ?
  • comment tester son appli pour de multiples configuration de Python / Django ?
  • comment distribuer son appli sur Pypi ?

Pourquoi une appli réutilisable ?

On a qu'à filer ça au stagiaire

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.