Looking for Computer Science  & Information Technology online courses ?
Check my new web site: https://www.yesik.it !

À en croire Google Trend, dans le monde du développement Web avec Python, Django se taille la part du lion. Mais des alternatives existent. C'est justement à l'une de ces alternatives que je vais m'intéresser aujourd'hui. À l'occasion d'un (tout) petit projet d'initiation à la programmation Web je vais donc vous montrer comment mettre en œuvre Pyramid.

Le projet

Application web simple.png

Schéma général de l'application web — L'application est composée de 3 pages: login, visualisation et saisie. Les données sont hébergées dans une base de données gérée par MariaDB. Un serveur Python WSGI répond aux requêtes http clientes.

Le projet qui me sert de support ici est un embryon de système de gestion de stock. Outre l'écran de connexion (login), l'application comporte deux pages: une page permet de saisir un code EAN13 (au clavier ou à l'aide d'une douchette émulant un clavier) ainsi qu'une description associée. Une autre page permet de visualiser la liste des entrées saisies.

Le SGBD-R (Système de Gestion de Bases de Données Relationnelles) adossé au système est MariaDB (ou MySQL -- puisque les deux sont compatibles).

Disponible sur GitHub:

L'ensemble des sources de cet article ainsi que les fichiers de données qui lui servent de support sont disponibles sur GitHub dans le projet s-leroux/PyramidDemo.

À votre convenance vous pouvez:

Le cœur du système est constitué par le serveur web Python WSGI (Python Web Server Gateway Interface) . En deux mots, WSGI est une spécification définie dans PEP-3333  Python Web Server Gateway Interface v1.0.1 et qui permet à des serveurs web compatibles de communiquer avec des applications écrites en Python. Grâce à cette spécification, un même serveur web peut communiquer avec des applications Python écrites avec des framework différents. De la même manière, votre application web peut être "servie" par n'importe quel serveur compatible.

Pour faciliter le développement d'applications web, il y a dans les modules standards de Python un serveur http simplifié compatible WSGI. Mais Pyramid utilise plutôt Waitress qui est une implémentation en pure-Python qui se veut utilisable en production. Mais en ce domaine, selon vos besoins d'autres solutions existent...

PEP-333 vs PEP-3333

Il existe en réalité deux documents officiels décrivant la spécification WSGI:

La raison d'être principale de la PEP-3333 est d'adapter les spécifications WSGI à Python 3. Un serveur ou une application compatible PEP-333 est aussi compatible PEP-3333. Les quelques différences portent surtout sur l'adaptation des exemples à Python 3, et sur des clarifications liées à la notion de chaîne de caractères et le support Unicode.

Installer les outils

Pyramid

Installation

Sous Debian vous aurez besoin du paquet python-virtualenv pour ce qui suit. Si ce logiciel n'est pas déjà sur votre machine utilisez le gestionnaire de paquets de votre distribution pour procéder à l'installation.

sh# apt-get install python-virtualenv

Il est recommandé d'utiliser Pyramid au travers d'un environnement Python virtuel. L'argument principal en faveur de ce choix est que, pendant le cycle de développement, vous allez être amené à installer différents modules avec easy_install (y compris notre application). Par conséquent vous devrez avoir les permissions en écriture sur les dossiers correspondants:

sh$ mkdir -p Projects/pyramid-demo
sh$ virtualenv -p /usr/bin/python3 Projects/pyramid-demo
Already using interpreter /usr/bin/python3
Using base prefix '/usr'
New python executable in Projects/pyramid-demo/bin/python3
Also creating executable in Projects/pyramid-demo/bin/python
Installing setuptools, pip...done.
sh$ cd Projects/pyramid-demo
sh$ . bin/activate
[env-pyramid] sh$ easy_install "pyramid==1.5.2"
...
Finished processing dependencies for pyramid==1.5.2

Si tout s'est bien passé, easy_install a installé Pyramid et ses dépendances dans le dossier site-package de votre environnement Python:

[env-pyramid] sh$ ls lib/python3.3/site-packages/
easy-install.pth             repoze.lru-0.6-py3.4.egg
easy_install.py              setuptools
_markerlib                   setuptools-5.5.1.dist-info
PasteDeploy-1.5.2-py3.4.egg  translationstring-1.3-py3.4.egg
pip                          venusian-1.0-py3.4.egg
pip-1.5.6.dist-info          WebOb-1.4-py3.4.egg
pkg_resources.py             zope.deprecation-4.1.2-py3.4.egg
__pycache__                  zope.interface-4.1.2-py3.4-linux-x86_64.egg
pyramid-1.5.2-py3.4.egg

Quelle version de Python ?

Au moment où j'écris ces lignes, les recommandations d'installation sur le site officiel de Pyramid indiquent que Pyramid a été testé sous Python 2.6, Python 2.7, Python 3.2 et Python 3.3. Mon système utilise Python 3.4. J'ai donc spécifiquement installé Python 3.3.6 pour ce tutoriel:

[env-pyramid] sh$ python3 --version
Python 3.3.6

MariaDB

Installation

Sous Debian vous aurez besoin du paquet mariadb-server pour ce qui suit. Si ce logiciel n'est pas déjà sur votre machine utilisez le gestionnaire de paquets de votre distribution pour procéder à l'installation.

sh# apt-get install mariadb-server

Je suppose ici que MariaDB (ou MySQL) est déjà installé sur la machine qui servira de serveur de bases de données. Je ne m'étend pas sur le processus car il est la plupart du temps trivial — et sous Debian se limite le plus souvent à installer le paquet correspondant.

Par contre, nous allons nous créer une base de données et un utilisateur spécifique pour ce tutoriel:

[mariadb] sh$ mysql -u root -p
Enter password: ************
Welcome to the MariaDB monitor.  Commands end with ; or \g.
Your MariaDB connection id is 40
Server version: 10.0.15-MariaDB-3 (Debian)

Copyright (c) 2000, 2014, Oracle, SkySQL Ab and others. 

MariaDB [(none)]> create database pyramiddemo default character set = utf8;
MariaDB [(none)]> grant all on pyramiddemo.* to sylvain@'%' identified by "********";

Cycle de développement avec Pyramid

Créer le projet

Le manuel d'introduction de Pyramid explique très clairement comment il est possible de créer une première application "à la main". Mais dans la pratique, il est nettement plus productif de partir d'un squelette de projet généré par Pyramid (scaffold). Ici, comme j'ai l'intention d'utiliser SQLAlchemy comme couche d'abstraction pour l'accès à la base de données, je vais utiliser le modèle correspondant:

[env-pyramid] sh$ pcreate -s alchemy PyramidDemo && echo ok
...
ok

A l'issue de cette commande, vous devriez voir apparaître dans le répertoire courant le dossier de notre projet:

[env-pyramid] ls -l PyramidDemo/
total 28
-rw-r--r-- 1 sylvain sylvain   28 Feb  9 10:58 CHANGES.txt
-rw-r--r-- 1 sylvain sylvain 1421 Feb  9 10:58 development.ini
-rw-r--r-- 1 sylvain sylvain  134 Feb  9 10:58 MANIFEST.in
-rw-r--r-- 1 sylvain sylvain 1227 Feb  9 10:58 production.ini
drwxr-xr-x 6 sylvain sylvain 4096 Feb  9 10:58 pyramiddemo
-rw-r--r-- 1 sylvain sylvain  239 Feb  9 10:58 README.txt
-rw-r--r-- 1 sylvain sylvain 1222 Feb  9 10:58 setup.py

Ce dossier contient entre autres:

Lancer les tests unitaires

Le script setup.py à la racine du projet permet de lancer le jeu de tests unitaires de l'application:

[env-pyramid] sh$ cd PyramidDemo
[env-pyramid] sh$ python3 setup.py test -q # `-q` pour remplacer l'affichage des tests par des points

..
----------------------------------------------------------------------
Ran 2 tests in 0.131s

OK

Remarque:

La première utilisation de setup.py peut être un peu longue selon le nombre de dépendances que le script va devoir télécharger et installer. En l’occurrence, sur mon système avec un environnement Python minimal, la première utilisation de setup.py a notamment déclenché l'installation de Waitress, de SQLAlchemy et de leurs dépendances.

Lancer l'application

Pour lancer l'application Pyramid, il faut d'abord l'installer. Comme pour les tests, cela se fait à l'aide du script setup.py. Ensuite, il faut lancer le serveur WSGI qui permettra de répondre aux requêtes http. Pour cela Pyramid fournit l'utilitaire pserve:

# à partir du répertoire PyramidDemo
[env-pyramid] sh$ python3 setup.py test
...
Finished processing dependencies for PyramidDemo==0.0

[env-pyramid] sh$ pserve development.ini
Starting server in PID 17187.
serving on http://0.0.0.0:6543

Comme vous le constatez sur les messages affichés, une fois lancé, pserve se met à l'écoute des requêtes entrantes sur le port 6543 de toutes les interfaces réseau de votre système. Cela veut dire que vous pouvez aussi bien tester à partir d'un client local, qu'à partir d'une machine distante. Vous pouvez donc vous connecter à votre application avec votre navigateur web préféré en utilisant l'URL http://localhost:6543 ou http://127.0.0.1:6543 (à partir du poste local) ou encore http://<ip-de-la-machine>:6543 à partir d'un poste distant.

Pour les besoins de la démonstration, je vais ici utiliser curl comme client http — cela me permettra aussi d'examiner en détail la réponse du serveur:

sh$ curl -D - http://localhost:6543
HTTP/1.1 500 Internal Server Error
Content-Length: 564
Content-Type: text/plain; charset=UTF-8
Date: Mon, 09 Feb 2015 11:18:15 GMT
Server: waitress

Pyramid is having a problem using your SQL database.  The problem
might be caused by one of the following things:

1.  You may need to run the "initialize_PyramidDemo_db" script
    to initialize your database tables.  Check your virtual
    environment's "bin" directory for this script and try to run it.

2.  Your database server may not be running.  Check that the
    database server referred to by the "sqlalchemy.url" setting in
    your "development.ini" file is running.

After you fix the problem, please restart the Pyramid application to
try it again.

Oups: 500 Internal Server Error ! Un échec lamentable. Que s'est-il donc passé ? Ici, le message fournit par Pyramid est clair: impossible de se connecter à la base de données. C'était tout à fait prévisible car je n'ai pour l'instant rien initialisé, ni même configuré de spécifique à ce sujet dans mon projet...

Note:

Au passage, remarquez que l'application est bien servie par Waitress.

pserve --reload

Selon votre manière de travailler vous pouvez interrompre le serveur (ctrl+C) pour le relancer après chaque modification — ou lancer pserve avec l'option --reload. Dans ce dernier cas, toute modification d'un fichier Python redémarrera automatiquement le serveur.

Modifier la configuration du projet

Pour la version de développement, la configuration du projet se trouve dans le fichier development.ini. Ici, je vais faire deux changements:

  1. tout d'abord, je vais modifier la configuration réseau pour que le serveur web ne soit à l'écoute que sur l'interface locale;
  2. ensuite, je vais configurer l'accès à la base de données MariaDB que nous avions crée au tout début de cet article.

Changer la configuration du serveur WSGI

Dans le fichier development.ini il faut trouver la section [server:main] qui se charge de la configuration du réseau. Dans sa version par défaut, cette section contient les options suivantes:

[server:main]
use = egg:waitress#main
host = 0.0.0.0
port = 6543

Pour ne se mettre qu'à l'écoute de l'interface locale, il suffit de mettre l'entrée host à la valeur 127.0.0.1 à l'aide de votre éditeur de texte préfére:

[env-pyramid] sh$ sed -i.bak '/^\[server:main]/,/^\[/{s/^host[[:space:]]*=.*/host = 127.0.0.1/}' development.ini
# vérification:
[env-pyramid] sh$ diff development.ini{,.bak}
30c30
< host = 127.0.0.1
---
> host = 0.0.0.0

Changer la configuration d'accès à la base de données

Pour modifier les paramètres de connection à la base de données, il faut altérer l'option sqlalchery.url de la section [app:main]. Par défaut, les applications utilisant SQLAlchemy sont adossées à une base SQLLite:

sqlalchemy.url = sqlite:///%(here)s/PyramidDemo.sqlite

Ici, j'ai l’intention de me connecter à MariaDB en utilisant le pilote PyMySQL. Il s'agit d'une mise en œuvre en Python du protocole MySQL qui est compatible avec Python 3. Au moment où j'écris ces lignes, PyMySQL est toujours en version beta — mais le développement est actif. Au passage, cela me permettra de vous montrer dans la section suivante comment ajouter une dépendance au projet. Mais terminons la configuration avant:

[env-pyramid] sh$ DBURL='mysql+pymysql://sylvain:********@chapignon.hoenn.pkmn/pyramiddemo'
[env-pyramid] sh$ sed -i.bak '/^\[app:main]/,/^\[/{s|^sqlalchemy.url[[:space:]]*=.*|sqlalchemy.url = '"$DBURL"'|}' development.ini

# Vérification:
[env-pyramid] sh$ diff development.ini{,.bak}
18c18
< sqlalchemy.url = mysql+pymysql://sylvain:********@chapignon.hoenn.pkmn/pyramiddemo
---
> sqlalchemy.url = sqlite:///%(here)s/PyramidDemo.sqlite

Ajouter des dépendances

Je l'ai dit dans la section précédente, je vais utiliser PyMySQL comme pilote pour accéder à la base MariaDB. C'est à dire que pour fonctionner, mon application va nécessiter ce module supplémentaire. À la fois en version de développement mais aussi en production. Il est donc inimaginable de simplement installer "à la main" le module nécessaire localement. Il est vital d'automatiser ce processus. Tout cela pour dire que nous allons ajouter cette nouvelle dépendance au script setup.py situé à la racine du projet:

requires = [
     'pyramid',
     'pyramid_chameleon',
     'pyramid_debugtoolbar',
     'pyramid_tm',
     'SQLAlchemy',
     'transaction',
     'zope.sqlalchemy',
     'waitress', 
     'PyMySQL',     # <-- dépendance supplémentaire
     ]

Une fois que c'est fait, il suffit de déployer à nouveau l'application pour que la nouvelle dépendance soit téléchargée et installée:

[env-pyramid] sh$ python3 setup.py develop
...
Processing dependencies for PyramidDemo==0.0
Searching for PyMySQL
Reading https://pypi.python.org/simple/PyMySQL/
Best match: PyMySQL 0.6.3
Downloading https://pypi.python.org/packages/source/P/PyMySQL/PyMySQL-0.6.3.tar.gz#md5=bc99090e90bfcbc13c516c857ad3b734
Processing PyMySQL-0.6.3.tar.gz
...

Initialiser la base de données

À ce stade, nous avons rassemblé toutes les briques nécessaires à notre application. La toute dernière étape avant de pouvoir (enfin) tester l'application par défaut va être d'initialiser la base de données.

Pyramid default app.png

Page par défaut d'une application Pyramid

Dans le vocabulaire de Pyramid, cela signifie créer et peupler les tables nécessaires au fonctionnement de l'application. Pour l'instant, contentons nous de l'initialisation par défaut qui va créer les tables liées au modèle de l'application de démonstration. Ici encore, un script a automatiquement été créé (dans /bin de l'environnement Python virtuel) pour cette tâche:

[env-pyramid] sh$ initialize_PyramidDemo_db development.ini

Et maintenant, on essaye !

[env-pyramid] sh$ pserve developme.ini
Starting server in PID 19212.
serving on http://127.0.0.1:6543

Pas mal: la nouvelle configuration réseau est prise en compte. Quand à une requête:

sh$ curl -D - http://localhost:6543
HTTP/1.1 200 OK
Content-Length: 3617
Content-Type: text/html; charset=UTF-8
Date: Mon, 09 Feb 2015 13:13:45 GMT
Server: waitress

<!DOCTYPE html>
<html lang="en">
...

Succès ! Bien entendu, le résultat est plus parlant à partir d'un navigateur graphique. Mais nous voilà convaincu que cela fonctionne. Bien, il est temps de commencer à modifier le projet pour en faire notre application.

Créer les modèles...

... à la main

Tout d'abord, nous allons créer la table des utilisateurs qui nous servira pour authentifier les accès à l'application. Ici, je vais créer et peupler manuellement la table dans MariaDB. Ce n'est pas nécessairement la manière recommandée de procéder, mais c'est un scénario très plausible: on pourrait facilement imaginer qu'il s'agit d'une table déjà existante gérée par une application tierce. Même si notre application n'est qu'un jouet développé pour le besoin de ce tutoriel, je vais quand même stocker les mots de passe sous forme de hash pour ne pas encourager les mauvaises pratiques.

sh$ mysql -u sylvain -p -h chapignon.hoenn.pkmn pyramiddemo
Enter password: ********

MariaDB [pyramiddemo]> CREATE TABLE USER(
                                LOGIN VARCHAR(16) PRIMARY KEY,
                                PASSWD CHAR(64) NOT NULL -- sha-256 hash
                       );
MariaDB [pyramiddemo]> INSERT INTO USER(LOGIN, PASSWD)
                                 VALUES('sylvain', SHA2('sylvain:********',256)),
                                       ('sonia', SHA2('sonia:******',256));
Query OK, 2 rows affected (0.03 sec)
Records: 2  Duplicates: 0  Warnings: 

Remarque:

Comme vous l'avez peut-être noté, j'ai ajouté un peu de sel lors du hachage du mot de passe. Cela permet de protéger le mot de passe contre l'utilisation d'une table de correspondance (reverse lookup table) ou d'une rainbow table pour récupérer le mot de passe.

Ceci dit, le sel que j'utilise ici est juste le login de l'utilisateur. C'est simple. C'est mieux que rien. Mais pour des applications plus sérieuses, il faudrait se diriger vers l'utilisation d'un sel beaucoup plus long et nettement moins facile à prédire. En pratique, l'utilisation d'un générateur aléatoire de qualité cryptographique s'impose.

... à partir de Python

Notre application Pyramid va utiliser l'ORM (Object-relational mapping) SQLAlchemy. Concrètement, cela veut dire que l'on peut définir le modèle de données objet à partir du langage de programmation (éventuellement en utilisant l'héritage ou la composition) et que le framework va prendre en charge automagiquement la conversion depuis (ou vers) le modèle relationnel de la base de données. Bref, vous écrivez du Python, pas du SQL.

Je vais utiliser cette possibilité pour définir la structure de ma table de données principale. Celle-ci contiendra pour chaque objet scanné à l'aide du lecteur de code barre son code EAN13, une description et le login de l’utilisateur ayant fait la saisie. C'est très basique mais pour ce tutoriel ce sera suffisant.

Les modifications vont se faire dans le fichier PyramidDemo/pyramiddemo/models.py. Celui-ci contient déjà un exemple appelé MyModel. Il ne nous servira pas, donc nous allons pouvoir supprimer les lignes correspondantes:

# Inutile. À supprimer
class MyModel(Base):
    __tablename__ = 'models'
    id = Column(Integer, primary_key=True)
    name = Column(Text)
    value = Column(Integer)
 
Index('my_index', MyModel.name, unique=True, mysql_length=255

Par contre, nous allons rajouter notre modèle sous la forme d'une déclaration de classe calquée sur celle proposée par défaut. Ajoutez donc les lignes suivantes à la fin du fichier models.py:

class Item(Base):
    __tablename__ = 'ITEMS'
    id = Column(Integer, primary_key=True)
    user = Column(String(16))
    ean13 = Column(CHAR(13))
    description = Column(Text)
 
Index('ITEMS_IDX', Item.ean13, unique=True)

Il faut aussi penser à ajouter quelques imports en début de fichier pour les types CHAR et String:

from sqlalchemy import (
    Column,
    Index,
    Integer,
    Text,
    String,  # <- ajouté
    CHAR,    # <- ajouté
    )

Enfin, il reste à modifier le script PyramidDemo/pyramiddemo/scripts/initializedb.py pour signaler la présence d'un nouveau modèle. Ce sera utile dans un instant pour re-générer la base de données:

from ..models import (
    DBSession,
#    MyModel, # <- Supprimé
    Item,     # <- Ajouté
    Base,
    )

Toujours dans initializedb.py, à la fin du fichier il y a du code pour peupler automatiquement la base de données. Nous allons supprimer les références au modèle MyModel et les remplacer par l'insertion de quelques valeurs dans notre table ITEMS:

# ...
 
    with transaction.manager:
#        model = MyModel(name='one', value=1) # <- Supprimé
#        DBSession.add(model)                 # <- Supprimé
 
         # ↓ Ajouter les lignes suivantes ↓
         item = Item(user='sylvain', ean13='9781933988788',
                     description='Erland and OTP in Action (book)')
         DBSession.add(item)
         item = Item(user='bob', ean13='9780321349606',
                     description='Java Concurrency in Practice (book)')
         DBSession.add(item)

Une fois toutes ces modifications effectuées, nous pouvons re-générer la base de données pour créer la nouvelle table et la peupler:

[env-pyramid] sh$ initialize_PyramidDemo_db development.ini

Remarque:

Une chose qui est un peu dommage, c'est que lors de la mise à jour avec le nouveau modèle, la (les) table(s) associée(s) aux entités supprimées ne sont pas supprimées de la base de données. C'est évidemment une bonne chose pour éviter de perdre des données. Mais cela veut aussi dire qu'il faut penser à faire le ménage après un changement de structure en cours de développement, pour ne pas se retrouver avec des tables obsolètes qui polluent la base de données. En l’occurrence, ici il faut supprimer la table models:

MariaDB [pyramiddemo]> DROP TABLE models;

Je veux voir quelque-chose ...

... mais j'ai perdu ma route

Un concept simple mais important dans le framework Pyramid est celui de route. Une route permet l'association entre une URL et une vue (une "page"). Chaque route possède un nom. Notre application contient trois pages. Qui vont ici correspondre à trois routes. Cependant, pour éviter les répétitions, Pyramid nous permet de créer des routes dynamiques à partir de motifs. Ici, je vais donc ajouter les routes suivantes au fichier PyramidDemo/pyramiddemo/__init__.py:

def main(global_config, **settings):
    ...
#   config.add_route('home', '/')                     # <- À supprimer
    config.add_route('login', '/')                    # <- À ajouter
    config.add_route('item_action', '/item/{action}') # <- À ajouter

Comme pour ce projet, la page d'accueil servira aussi de page de login, j'ai renommé la route correspondante. Ce qui facilitera ultérieurement l'évolution de l'application si je décide d'ajouter une vraie page d'accueil. La seconde route ajoutée est un peu différente, puisque vous voyez le motif {action} qui permettra de faire correspondre la route avec des URL comme /item/view ou /item/add.

La vue de connexion (login)

Enfin, nous allons pouvoir créer nos pages. Pyramid offre pour cela en standard plusieurs alternatives. Dont les moteurs de template Mako et Chameleon. Ce dernier est le moteur de template par défaut et c'est lui que je vais utiliser. Je vous livre directement le code de pyramiddemo/templates/login.pt:

<!DOCTYPE html>
<html>
 <head>
  <title>Login</title>
  <style>
body {
    background-color: #DDDDDD;
}
 
form.login {
    background-color: #EEEEEE;
    border: solid 1px #FFFFFF;
    border-radius: 8px 8px;
    padding: 4em;;
    margin: 100px;
    margin-left: auto;
    margin-right: auto;
    width: 26em;
    text-align:right;
}
  </style>
 </head>
<body>
 <form class='login' method='POST' action='${url}'>
  <p>${message}</p>
  <label>Utilisateur:</label> <input type='text' name='login' value='${login}'> <br/>
  <label>Mot de passe:</label> <input type='password' name='password'> <br/>
<input type='submit' value='envoyer' name='form.submitted'>
 </form>
</body>
</html>

Comme vous le voyez, c'est à 99% du HTML "normal", les seules exceptions étant constituées par la présence des variables ${url}, ${message} et ${login}. Nous verrons dans quelques minutes comment lier ces variables à des valeurs issues de notre propre code. Mais avant d'aller plus loin, le "gros morceau" va être constitué par la mise en oeuvre de la machinerie d'authentification et d'autorisation de Pyramid...

Authentification et autorisation

Ce sont deux concepts liés, mais clairement séparés dans Pyramid afin d'offrir un maximum de souplesse dans leur mise en œuvre:

Je l'ai dit, le mécanisme est assez souple dans Pyramid — la contrepartie est qu'il est relativement lourd à mettre en œuvre, même pour un exemple simple comme celui de ce tutoriel. Tout d'abord vous allez créer le fichier PyramidDemo/pyramiddemo/security.py:

from sqlalchemy.sql import text
from hashlib import sha256
 
from .models import (
    DBSession,
    )
 
# Check if the given login/password (hashed) pair exists in the DB
user_query = text("SELECT COUNT(*) FROM USER "
                  "WHERE LOGIN=:login "
                  "AND PASSWD=:passwd")
 
# In this sample system, all users will have
# the same group membership.
# We only have to check for user existance.
group_query = text("SELECT COUNT(*) FROM USER "
                  "WHERE LOGIN=:login")
 
def is_valid_user(username, password):
    """Check if the given user/password is a valid
    user for the system.
    """
    salted_password = username + ":" + password
    print(salted_password)
    passwd = sha256(salted_password.encode("utf-8")).hexdigest()
    return DBSession.execute(user_query,
                             dict(login=username, passwd=passwd)).scalar() == 1
 
def groupfinder(username, request):
    """Authentication policy callback.
 
    *    If the userid exists in the system, it will return a sequence of group identifiers (or an empty sequence if the user isn't a member of any groups).
    *    If the userid does not exist in the system, it will return None.
 
    See http://docs.pylonsproject.org/docs/pyramid/en/latest/tutorials/wiki/authorization.html#add-users-and-groups
    """
    print('user_group:',username,request)
    existing_user = \
        DBSession.execute(group_query, dict(login=username)).scalar()
 
    return ['editor'] if existing_user else None

Je ne vais pas détailler l'ensemble du code mais il contient deux fonctions complémentaires:

is_valid_user 
Cette fonction encapsule le hachage du mot de passe et l'accès à la base de données pour vérifier si la paire identifiant/mot-de-passe correspond bien à un utilisateur valide.
groupfinder 
Cette fonction est une callback utilisée pour obtenir les autorisations associées à l'utilisateur. Pour notre application, il s'agit de vérifier que l'utilisateur est bien existant, et le cas échéant à retourner la liste des groupes associés (codée "en dur" dans cet exemple)

La seconde étape va consister à activer les mécanismes d’authentification et d'autorisation dans la configuration de notre application. Comme je l'ai dit un peu plus haut l'utilisateur ne fournit en principe son mot de passe que sur la page de connexion. Ensuite, son identité est prouvée par un jeton transmis avec les cookies. Pour éviter les usurpations d’identité, il faut définir un secret qui permettra de signer ce jeton. Faisons les choses proprement en définissant ce secret dans le fichier de configuration (development.ini — ce qui au passage vous permettra d'avoir un autre secret en production défini dans production.ini):

[app:main]
pyramiddemo.secret = xze571

use = egg:PyramidDemo

pyramid.reload_templates = true
pyramid.debug_authorization = true
...

Ensuite, il faut modifier le code de configuration du projet pour activer l'authentification et les autorisations. Cela se fait dans PyramidDemo/pyramiddemo/__init__.py:

# Quelques imports à ajouter:
from pyramid.authentication import AuthTktAuthenticationPolicy
from pyramid.authorization import ACLAuthorizationPolicy
 
from .security import (
    groupfinder,
    )
 
# ...
 
def main(global_config, **settings):
 
    # ...
 
    authn_policy = AuthTktAuthenticationPolicy(    # <- à ajouter
            settings['pyramiddemo.secret'],        # <- à ajouter
            callback=groupfinder,                  # <- à ajouter
            hashalg='sha512')                      # <- à ajouter
    authz_policy = ACLAuthorizationPolicy()        # <- à ajouter
 
    config = Configurator(settings=settings)       # déjà présent dans le fichier
 
    config.set_authentication_policy(authn_policy) # <- à ajouter
    config.set_authorization_policy(authz_policy)  # <- à ajouter

Remarque:

Pour paraphraser le tutoriel PyTennessee 2014 Pyramid Tutorial par Chris McDonough (un des auteurs principaux de Pyramid), à cause de bizarreries (quirks) dans les règles de configuration d'un projet Pyramid, il est nécessaire de fournir à la fois une politique d'authentification et une politique d'autorisation même quand ce n'est pas requis par l'application.

Connecter tout ensemble

Presque. Nous y sommes presque. L'infrastructure de "sécurité" est en place. Le modèle pour la page web de connexion est écrit. La route pour accéder à la page est définie. Reste seulement à ajouter les quelques lignes de logique applicative en Python pour générer la vue et répondre à la saisie du formulaire. Cela se fait dans le fichier PyramidDemo/pyramiddemo/views.py. Ici, je vous donne l'intégralité du fichier car nous avons une fonction à ajouter mais aussi pas mal de ménage à faire pour supprimer la page principale par défaut générée lors de la création du projet. Rassurez-vous l'ensemble du code imports compris prend moins de 40 lignes:

from pyramid.response import Response
from pyramid.view import view_config
 
from .security import is_valid_user
from pyramid.security import (
    remember,
    )
 
from sqlalchemy.exc import DBAPIError
 
from .models import (
    DBSession,
    )
 
from pyramid.httpexceptions import (
    HTTPFound,
    )
 
 
@view_config(route_name='login', renderer='templates/login.pt')
def login_view(request):
    url = request.route_url('login')
    login = ''
    message = ''
 
    if 'form.submitted' in request.params:
        login = request.params['login']
        password = request.params['password']
        if is_valid_user(login,password):
            headers = remember(request, login)
            return HTTPFound(location = request.route_url('item_action', action='view'),
                             headers = headers)
 
        message = 'Wrong username/password'
 
 
    return dict(url=url, login=login, message=message)
Pyramid authentification.png

Authentification — La page d'accueil de l'application est la page de connexion. Tant que l'utilisateur ne saisit pas un identifiant valide et son mot de passe associé, c'est la même page qui est rechargée (avec un message pour signaler l'erreur). Une fois l'authentification réussie, l'utilisateur est redirigé sur la page /item/view ... qui n'est pas encore codée. D'où l'erreur 404 Not Found.

La partie importante est bien entendu la fonction login_view. Celle-ci est chargée de générer la page de connexion, ou, en cas d’authentification réussie de rediriger le client vers la page de visualisation (request.route_url('item_action', action='view')). La logique de l'ensemble n'est pas très complexe à comprendre. Remarquez simplement qu'en cas d'échec de l'authentification, ou si la vue est générée pour la première fois(i.e.: pas en réponse à la saisie du formulaire), la fonction renvoie un tableau associatif dont les entrées correspondent aux variables définies dans le modèle Chameleon de la vue.

Remarquez aussi que l'association entre cette fonction, la route et le modèle se fait de manière déclarative grâce à un décorateur (@view_config(route_name='login', renderer='templates/login.pt')).

Ouf! À cette étape nous avons touché à beaucoup de fichier. Si je n'ai rien oublié et aux fautes de frappes près, tout devrait être prêt pour un déploiement de la version de développement:

[env-pyramid] sh$ python3 setup.py develop
[env-pyramid] sh$ pserve development.ini --reload
Starting subprocess with file monitor
Starting server in PID 2606.
serving on http://127.0.0.1:6543

Il ne vous reste plus qu'à tester le tout. Vous constaterez qu'en cas d'authentification réussie, votre navigateur va afficher une erreur 404 Not Found. C'est normal, puisque nous redirigeons celui-ci vers une page ... que nous n'avons pas encore écrite.

Ajouter d'autres vues

La vue /item/view

Après la longue section consacrée à la page de connexion, la création de la vue qui permet de visualiser les données de la table ITEMS va vous sembler assez reposante. Tout d'abord, jetons un œil au modèle Chameleon associé PyramidDemo/pyramiddemo/templates/item_view.pt:

<!DOCTYPE html>
<html>
<head>
<title>Item View</title>
<style>
body {
    background-color: #DDDDDD;
}
 
table {
    border: solid 1px black;
    background-color: #EEEEEE;
}
 
td { padding: 0.5em; }
 
</style>
</head>
<body>
<h1>Visualisation</h1>
 
<table>
<tr tal:repeat="item items">
    <td tal:content="item.ean13"/>
    <td tal:content="item.user"/>
    <td tal:content="item.description"/>
</tr>
</table>
 
<p><a href='add'>Ajouter une entrée</a></p>
</body>
</html>

La seule chose notable ici est la présence des attributs tal:repeat et tal:content pour générer la table présentant la liste des objets enregistrés:

<tr tal:repeat="item items"> ... </tr> 
lors de la génération du document HTML, l'élément tr</tr> va être répété autant de fois qu'il y aura d'entrées dans l'itérable <tt>items. La variable item sert de variable de boucle.
<td tal:content="item.ean13"/> 
lors de la génération du document, le contenu de l'élément sera remplacé par la valeur désignée (ici, le champ ean13 de l'objet désigné par item).
Pyramid 403.png

Si vous tentez de vous connectez à la page /item/view sans vous être convenablement authentifié, les ACL bloquent l'accès à la page et le serveur renvoie une erreur 403 Forbidden. Comme je n'ai pas prévu dans l'application de bouton "déconnexion", vous pouvez quand même vérifier cela en effaçant les cookies associés au site localhost.


Pyramid view item.png

Une fois correctement authentifié, vous pouvez visualiser la page /item/view.


Ensuite, le code source de la vue (à ajouter à views.py) est relativement trivial:

from .models import (
    DBSession,
    Item,
    )
 
@view_config(route_name='item_action', match_param='action=view',
             renderer='templates/item_view.pt',
             permission='view')
def item_view(request):
    items = DBSession.query(Item).order_by(Item.ean13)
    return dict(items=items)

Le corps de la fonction est simplement constitué d'une requête utilisant le langage ORM de SQLAlchemy pour renvoyer la liste des objets. La partie réellement intéressante se trouve plutôt dans le décorateur:

route_name='item_action', match_param='action=view' 
cette vue va correspondre à la route dynamique item_action uniquement quand le paramètre action vaudra view
permission='edit' 
cette vue n'est autorisée qu'aux utilisateurs ayant la permission view.

Mais comment Pyramid peut savoir qui a le droit de voir ? Tout ce que nous avons fait jusqu'à présent, c'est authentifier l'utilisateur et déterminer les groupes auxquels il appartient. Il nous manque donc encore un élément, les ACL, qui permettent d'associer des droits en fonction d'un principal. Nous allons définir cette association dans un nouveau fichier PyramidDemo/pyramiddemo/resources.py:

from pyramid.security import Allow, Everyone
 
class Root(object):
    __acl__ = [
                (Allow, 'editor', 'edit'),
                (Allow, 'editor', 'view'),
                (Allow, 'auditor', 'view'),
              ] 
 
    def __init__(self, request):
        pass

Et il faut indiquer à Pyramid de charger cette liste d'ACL pendant la phase de configuration de l'application. Cela se fait en modifiant une ligne dans __init__.py:

def main(global_config, **settings):
    # ...
 
    config = Configurator(settings=settings,             # <- Modifier ici
                         root_factory='.resources.Root') # <- Modifier ici
    config.set_authentication_policy(authn_policy)
    config.set_authorization_policy(authz_policy)

Et voilà. Plus qu'à recharger et à essayer:

[env-pyramid] sh$ pserve development.ini --reload

La vue /item/add

Celle-ci est vraiment simple. Il suffit de créer le modèle Chameleon, puis de rajouter le code Python pour gérer cette vue et répondre au formulaire. Voici tout d'abord le contenu intégral de PyramidDemo/pyramiddemo/templates/item_add.pt. Vous le verrez, il n'y a rien de nouveau dans ce code par rapport à nos exemples précédents:

<!DOCTYPE html>
<html>
<head>
<title>Item View</title>
<style>
body {
    background-color: #DDDDDD;
}
 
form.add {
    border: solid 1px white;
    border-radius: 8px 8px;
    background-color: #EEEEEE;
    padding: 2em;
    margin-top: 15px;
    margin-left: auto;
    margin-right: auto;
    width: 26em;
    text-align:right;
}
 
label.error {
    color: red;
}
 
</style>
</head>
<body>
<h1>Ajout</h1>
 
<form class='add' method='POST' action='${url}'>
<ul><li tal:repeat="message messages" tal:content="message"/></ul>
<label class='${ean13_error}'>EAN13:</label>
<input type='text' name='ean13' value='${ean13}'> <br/>
<label class='${description_error}'>Description:</label>
<input type='text' name='description' value='${description}'> <br/>
<input type='submit' value='envoyer' name='form.submitted'>
</form>
 
<p><a href='view'>Retourner à la liste</a></p>
</body>
</html>

Et voici maintenant le code à ajouter à PyramidDemo/pyramiddemo/views.py:

def is_valid_ean13(ean13):
    return len(ean13) == 13 # XXX Fix me !!!
 
@view_config(route_name='item_action', match_param='action=add',
             renderer='templates/item_add.pt',
             permission='edit')
def item_add(request):
    url = request.route_url('item_action',action='add')
    messages = []
    ean13 = ''
    ean13_error = ''
    description_error = ''
    description = ''
    if 'form.submitted' in request.params:
        ean13 = request.params['ean13']
        description = request.params['description']
 
        # Add some validation
        if len(description) < 10:
            description_error='error'
            messages.append("La description doit faire plus de 10 caractères")
 
        if not is_valid_ean13(ean13):
            ean13_error='error'
            messages.append("EAN13 invalide")
 
        if not ean13_error and not description_error:
            item = Item(ean13=ean13, description=description,
                        user= request.authenticated_userid)
            DBSession.add(item)
 
            return HTTPFound(location = request.route_url('item_action',
                                                          action='view'))
 
 
    return dict(messages=messages,
                ean13=ean13,
                ean13_error=ean13_error,
                description_error=description_error,
                description=description,
                url=url)

Ce dernier fragment de code est un peu long, mais c'est surtout causé par l'ajout de code pour valider les entrées saisies par l'utilisateur. Ici encore, il n'y a aucune nouvelle technique d'introduite. Reste à relancer l'application, comme d'habitude:

[env-pyramid] sh$ pserve development.ini --reload

Je vous laisse tester vous même mais vous devriez maintenant être en mesure d'ajouter de nouvelles entrées à la table.

Tester

Le framework Pyramid supporte à la fois les tests unitaires, les tests d'intégration et les tests fonctionnels à l'aide de fonction qui viennent compléter le module Python standard unittest.

Vous trouverez quelques exemples dans le fichier pyramiddemo/tests.py. À titre d'exercice, vous pourriez mettre à jour ces tests pour les adapter aux modifications faites dans le code.

Ce sujet sort un peu du cadre strict de ce (déjà long) tutoriel, mais à titre d'exemple, vous verrez dans le code à télécharger sur GitHub que j'ai ajouté des tests pour une version un peu plus évoluée de la fonction is_valid_ean13.

Conclusion

Ouf! Même si l'application est relativement simple, son développement nous a quand même demandé un peu de travail. Surtout parce que j'ai pris le temps d'introduire de nombreux concepts clés en route. Par contre, une fois les premières pages réalisées, vous constaterez que l'on peut ensuite ajouter assez rapidement de nouvelles fonctionnalités à l'application sans beaucoup de difficultés. L'outil se révèle souple et est bâti sur des technologies éprouvées et interchangeables (SQLAlchemy, Chameleon, Mako...) C'est à la fois son point fort, puisque Pyramid ne vous impose finalement pas grand chose. Mais c'est peut-être aussi un écueil dans les premiers temps, puisqu'il faut souvent jongler avec les documentations de différents logiciels pour résoudre un problème.

Donnez votre avis!  Venez commenter cet article sur Google+

Ressources