Un framework Web python dans une bouteille

Introduction à Bottle

Bottle est un framework Web très simple à mettre en oeuvre et qui permet d'écrire des applications Web rapides avec peu de lignes de code. Il suffit de lire l'exemple fourni sur la page du projet pour s'en rendre compte.

Introduction de Bottle

Pour illustrer son fonctionnement, nous allons créé un outil simple de diffusion d'un diaporama.

Contexte de notre application

On aimerait pouvoir diffuser un briefing (suite de diapositives) à un ensemble de personnes via leur navigateur Web. L'idée est de réaliser un programme qui irait lire les images dans un répertoire, prendre la dernière uploadée dedans et l'afficher dans une page HTML.

On aura donc un serveur disposant :

  • d'une application/serveur Web qui pointe sur un répertoire donné et affiche la dernière image disponible
  • d'un accès FTP sur ce même répertoire sur lequel on va placer des images de notre briefing

On aura N clients qui iront sur l'URL fournie par l'application et ils disposeront d'un bouton pour interroger régulièrement le serveur sur l'image en cours à afficher.

L'administrateur déposera les fichiers au fur et à mesure sur le répertoire accessible via FTP et l'image se mettra à jour chez les N clients à quelques secondes près.

Contraintes techniques

Les clients auront une interface très simple :

  • une zone pour afficher l'image
  • un bouton leur permettant de lancer la requête AJAX qui va interroger le serveur toutes les X secondes
  • un autre bouton pour arrêter les requêtes

Pour faire fonctionner tout ça, on se limitera à l'utilisation de :

  • Bottle pour l'application/serveur Web
  • jQuery pour le code javascript

L'environnement de développement

Pour développer dans un environnement propre de tout package inutile, on va utiliser virtualenv

$ mkdir briefing
$ cd briefing
$ pip install -U virtualenv
$ virtualenv env --no-site-packages
$ source env/bin/activate
(env)$ pip install bottle

Le script de base

Dans un premier temps, nous allons réaliser notre script de base, le fichier serveur_bottle.py

# -*- coding: utf-8 -*-

import bottle

bottle.debug(True)

@bottle.route('/')
def test():
    return "test"

def main():
    bottle.run(host='localhost', port=8080)

if __name__== '__main__' :
    main()

On lance le script :

(env)$ python serveur_bottle.py
Bottle server starting up (using WSGIRefServer())...
Listening on http://localhost:8080/
Use Ctrl-C to quit.

Un petit tour sur http://localhost:8080 et on peut voir que l'on affiche "test".

Notre test

Toute l'application va fonctionner sur un principe simple : une route(URL) <=> une fonction qui retourne un élément (page, image, code javascript, json, etc ...).

Je ne m'étendrai pas là-dessus, mais on a "décoré" notre fonction test par la fonction bottle.route de façon à ce qu'elle renvoie un resultat pour l'url de base (/).

Interface de l'outil

On va créer le minimum syndical pour construire notre interface :

.
|-- css
|   `-- style.css              => La feuille de style CSS
|-- env
|-- images
|   `-- default.jpg             => l'image affichée par défaut
|-- interface.html              => la page HTML contenant l'interface
`-- serveur_bottle.py

On commence par le fichier interface.html

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html>
    <head>
        <meta http-equiv="Content-type" content="text/html; charset=utf-8">
        <link rel="stylesheet" href="/css/style.css" type="text/css" media="screen" charset="utf-8" />
        <title>Outil de Briefing</title>
    </head>

    <body>
        <h1>Mon outil de Briefing</h1>

        <!-- Contiendra notre image -->
        <div class="img-container">
            <p><img id="current-diapo" src="/images/default.jpg" title="briefing current img" /></p>
        </div><!-- img-container -->

        <!-- Contiendra nos boutons d'actions -->
        <div class="actions">
            <ul>
                <li><a href="" title="start briefing"> Start Briefing</a></li>
                <li><a href="" title="start briefing"> Stop Briefing</a></li>
            </ul>
        </div><!-- actions -->

    </body>
</html>

Puis, on continue avec le fichier styles.css

.img-container {
    width: 640px;
    height: 480px;
    padding: 0px;
    margin: 0px;
    border: solid 1px black;
}

p {
    margin: 0px;
    padding: 0px;
}

Interaction avec Bottle

On va modifier notre script de base pour utiliser notre interface html

# -*- coding: utf-8 -*-

import os, time
import bottle

@bottle.route('/')
def index():
    bottle.send_file(root=os.path.abspath(os.path.dirname(__file__)),
                     filename='interface.html')

def main():
    bottle.run(host='localhost', port=8080)

if __name__== '__main__' :
    main()

On lance le serveur et on retourne sur l'adresse http://localhost:8080

Interface mais il y a un soucis

On a un problème : ni le CSS, ni l'image ne sont pris en compte. Rendez-vous sur les liens suivants pour bien s'en rendre compte :

Le soucis est que Bottle ne sait pas comment gérer ces deux URLs. Il faut lui indiquer comment traiter /css/ et /images/.

Ajout de répertoires statiques

On va indiquer à Bottle comment retourner les images et les css en ajoutant respectivement une fonction render_image et une fonction render_css que l'on va "router" de la manière suivante :

@bottle.route('/css/:filename')
def render_css(filename):
    bottle.send_file(filename, root=os.path.join(os.path.abspath(os.path.dirname(__file__)), 'css'))

@bottle.route('/images/:filename')
def render_image(filename):
    bottle.send_file(filename, root=os.path.join(os.path.abspath(os.path.dirname(__file__)), 'images'))

Si on va sur http://localhost:8080/images/default.jpg, la fonction va nous retourner le fichier default.jpg du répertoire /images/ qui est contenu dans le répertoire courant.

Si on relance le serveur, on obtient bien notre interface avec notre image et notre CSS.

Interface complète...moche mais complète

On pourra faire la même chose avec un répertoire /js/ qui contiendra nos fichiers javascript. Mais nous avons autant définir une route plus générique.

@bottle.route('/:dirname/:filename')
def render_files(dirname, filename):
    bottle.send_file(filename, root=os.path.join(os.path.abspath(os.path.dirname(__file__)), dirname))

Utilisation d'un template

Admettons maintenant qu'on veut passer des paramètres à notre interface comme :

  • le titre de l'outil
  • et/ou l'image par défaut
  • et/ou encore une zone accessible qu'en mode d'administrateur.

On utilisait bottle.send_file pour retourner notre fichier html. Bottle peut utiliser ce fichier interface.html comme un template via la fonction bottle.template.

On modifie donc notre fonction index

def index():
    template_file = os.path.join(os.path.abspath(os.path.dirname(__file__)), 'interface.html')
    return bottle.template(template_file,
                           title="Briefing Tools",
                           default_image="/images/default.jpg",
                           admin=True)

ainsi que le template interface.html pour utiliser ces nouveaux paramètres

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html>
    <head>
        <meta http-equiv="Content-type" content="text/html; charset=utf-8">
        <link rel="stylesheet" href="/css/style.css" type="text/css" media="screen" charset="utf-8" />
        <title>{{title}}</title>
    </head>

    <body>
        <h1>{{title}}</h1>

        <!-- Contiendra notre image -->
        <div class="img-container">
            <p><img id="current-diapo" src="{{default_image}}" title="briefing current img" /></p>
        </div><!-- img-container -->

        % if admin == True:
        <!-- Contiendra nos boutons d'actions -->
        <div class="actions">
            <ul>
                <li><a href="" title="start briefing"> Start Briefing</a></li>
                <li><a href="" title="start briefing"> Stop Briefing</a></li>
            </ul>
        </div><!-- actions -->
        % end
    </body>
</html>

Scanner les répertoires

Ajoutons maintenant un répertoire sur lequel pointera notre FTP. Appelons le /ftp/.

Quand un client appuira sur "Start Briefing", cela lancera une requête AJAX toutes les trois secondes afin de demander au serveur, la dernière image uploadée disponible dans le répertoire /ftp/. Il suffira de cliquer sur "Stop Briefing" pour interrompre ces requêtes automatiques.

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html>
    <head>
        <meta http-equiv="Content-type" content="text/html; charset=utf-8">
        <link rel="stylesheet" href="/css/style.css" type="text/css" media="screen" charset="utf-8" />
        <title>{{title}}</title>

        <script type="text/javascript" src="/js/jquery-1.5.2.min.js"></script>
        <script type="text/javascript">
            function refresh() {
                $.post('/getdata' , {
                        'action': 'refresh',
                    },
                    function success(reponse){
                        if (timeout_flag != null) {
                            if (reponse.status == 'OK') {
                                $('#current-diapo')[0].src = '/ftp/' + reponse.last_file
                            } else {
                                $('#current-diapo')[0].src = '{{default_image}}'
                            }
                            timeout_flag = setTimeout(function(){ refresh(); }, 3000)
                        }
                    },
                    "json"
                );
            }

            function start() {
                timeout_flag = setTimeout(function(){ refresh(); }, 100)
            }

            function stop() {
                clearTimeout(timeout_flag)
            }
        </script>

    </head>

    <body>
        <h1>{{title}}</h1>

        <!-- Contiendra notre image -->
        <div class="img-container">
            <p><img id="current-diapo" src="{{default_image}}" title="briefing current img" /></p>
        </div><!-- img-container -->

        % if admin == True:
        <!-- Contiendra nos boutons d'actions -->
        <div class="actions">
            <ul>
                <li><a href="#" onclick="start(); return false;" title="start briefing"> Start Briefing</a></li>
                <li><a href="#" onclick="stop(); return false;"> Stop Briefing</a></li>
            </ul>
        </div><!-- actions -->
        % end

    </body>
</html>

Coté serveur, on ajoute :

  • une route pour obtenir le nom de la dernière image uploadée dans le répertoire.
  • une liste qui se met à jour en fonction des fichiers présents.
  • une vérification de fichiers très sommaire qui ajoute à la liste la dernière image trouvée que si sa date de dernière modification n'évolue pas pendant 0.5 secondes. Ceci permettra de faire un test rapide pour vérifier que l'image est complétement uploadée sur le FTP (ce n'est pas la bonne manière, mais pour l'exemple, ça marche).
@bottle.route('/getdata', method='post')
def get_data():
    global files_list

    if bottle.request.POST['action']=='refresh':
        for root, dirs, files in os.walk(os.path.join(os.path.abspath(os.path.dirname(__file__)), 'ftp')):
            for filename in files:
                if filename not in files_list:
                    mtime_before_sleep = os.path.getmtime(os.path.join(root, filename))
                    time.sleep(0.5)
                    mtime_after_sleep = os.path.getmtime(os.path.join(root, filename))

                    if mtime_after_sleep == mtime_before_sleep:
                        files_list.append(filename)

    for filename in files_list:
        if not os.path.exists(os.path.join(os.path.abspath(os.path.dirname(__file__)), 'ftp', filename)):
            files_list.remove(filename)

    if len(files_list) > 0:
        return {"status": "OK",
                "last_file": files_list[-1]}
    else:
        return {"status": "NO_IMAGE"}


def main():
    global files_list
    files_list = []
    bottle.run(host='localhost', port=8080)

A noter que notre fonction retourne un dictionnaire qui sera transformé automatiquement au format json par bottle.

Utilisation de l'outil

On peut lancer la démonstration.

1- On ouvre notre répertoire FTP : une copie d'un fichier image vers ce répertoire permettra de simuler un transfert vers un compte FTP (en plus rapide).

2- On ouvre notre outil sur http://localhost:8080 et on clique sur "Start Briefing".

3- On copie les images une par une dans le répertoire.

L'application va alors rafraichir l'image au fur et à mesure comme présenté sur l'animation ci-dessous.

Animation de l'utilisation de l'application

Conclusions

Bottle est très simple à utiliser et à mettre oeuvre. Il utilise un système de routage via des décorateurs (les @ au dessus de chaque fonction) et un système de template. Pour de petits projets, cela peut-être un moyen rapide d'obtenir quelque chose de fonctionnel avec la puissance du langage Python derrière.

Cet exemple codé avec les pieds, n'est qu'un essai à titre démonstratif. Vous pouvez le télécharger en cliquant ici.

L'exemple est inspiré de l'outil loform_briefing_tools que j'ai développé pour remplir ce besoin simple. Il est disponible à l'adresse suivante : https://bitbucket.org/kyoku57/loform_briefing_tools/src

Il dispose d'une interface d'admin facilement accessible afin de pouvoir choisir l'image à diffuser à l'ensemble des clients connectés à l'application, de classer les images par taille, par nom et par date de dernière modification. Chaque client peut interrompre le briefing pour revenir à une image précédente. Et enfin, le tout s'utilise sans base de données.

Conclusions sur Bottle

Commentaires