77. Python et la persistance

77.1. Les fichiers

77.1.1. L'objet fichier

La gestion des fichiers en Python est similaire de celle du C.

Pour lire ou écrire un fichier nous allons commencer par utiliser la fonction file. Cette fonction va retourner un objet fichier qui va nous permettre d’interagir avec lui.

Synopsis : file(fichier, [mode,[ bufsize]])

  • fichier : indique le fichier considéré.

  • mode : indique le mode d'ouverture du fichier.

  • bufsize : indique la taille du buffer.

Voici les principales méthodes de l’objet file :

  • read([size]) : Lit le nombre d’octets précisés. Si rien n’est précisé retourne tout le fichier.

  • readline([size]) : Lit une ligne entière (jusqu'au premier caractère de fin de ligne). Si une taille est précisée la ligne ne pourra dépasser ce nombre d’octets.

  • write(chaine) : Ecrit la chaine dans le fichier.

  • close() : Ferme le fichier.

Exemple de script :

[adminbdd ~]$ cat fichier.py
# -*- coding: utf-8 -*-

fichier = file("monFichier", "w+")
fichier.write("Texte ecrit grace à Python\n")
fichier.close()

fichier = file("monFichier")
contenu = fichier.read()
fichier.close()

print contenu
[adminbdd ~]$ python fichier.py && cat monFichier
Texte ecrit grace à Python

Texte ecrit grace à Python

77.1.2. Pickle

Le module Pickle est intéressant pour les développements qui font appel à l’utilisation d’objets. Il permet de sérialiser un objet, c’est à dire qu’il transforme un objet en un flux d’octets. Il sera ensuite possible de l’enregistrer dans un fichier ou de le transmettre au travers d’un réseau.

Selon le support la sérialisation peut prendre plusieurs formes. Une forme binaire comme par exemple dans le cas de CORBA ou un format qui peut-être lu comme celui de SOAP qui s’appuie sur du XML. Pickle nous permet d’utiliser les deux formes et par défaut il utilise celle qui est lisible.

Voici un exemple :

#!/usr/bin/python
# -*- coding: utf-8 -*-

import pickle

class Personne:
	def __init__(self, nom, prenom):
		self.nom = nom
		self.prenom = prenom
		
	def presentation(self):
		print "%s %s"%(self.prenom, self.nom)
		
moi = Personne("floury","sylvain")
moi.presentation()

fichier = file("personne.sav", "w")
pickle.dump(moi,fichier)
fichier.close()

fichier = file("personne.sav", "r")

unAutreMoi = pickle.load(fichier)
unAutreMoi.presentation()

Exécution du script :

[adminbdd ~]$ ./personne.py
sylvain floury
sylvain floury
[adminbdd ~]$ cat personne.sav
(i__main__
Personne
p0
(dp1
S'nom'
p2
S'floury'
p3
sS'prenom'
p4
S'sylvain'
p5
sb.

Nous utilisons dans ce script la sérialisation de base. Après son exécution vous devriez disposer d’un fichier personne.sav dans votre répertoire (attention de disposer sur ce répertoire des droits nécessaires).

Le troisième paramètre nous permet de choisir le protocole de sérialisation à utiliser. Il existe trois protocoles :

  • version 0 en ASCII,

  • version 1 binaire, versions de Python < 2.3,

  • version 2 binaire, à partir de la version 2.3 de Python.

Le module Pickle dispose aussi des classes Pickler et Unpickler dont des classes utilisateur peuvent hériter.

77.2. Bases de données

Python dispose de plusieurs modules pour se connecter à différentes bases de données. On peut citer MySQL, PostgreSQL ou Oracle.

Il existe une spécification d’API que doivent respecter les modules qui permettent de se connecter à une base de données. Cette spécification est décrite dans une PEP (Python Enhancement Proposal 1) qui est à Python ce que les RFC sont à l’Internet. Ceci permet pour les modules qui la respectent de garantir la portabilité de code.

77.2.1. MySQL

77.2.1.1. Création de base

Nous allons créer une base et un utilisateur pour s’y connecter.

[adminbdd ~]$ mysql -u root -p
Enter password:
Welcome to the MySQL monitor.  Commands end with ; or \g.
Your MySQL connection id is 11
Server version: 5.0.77 Source distribution

Type 'help;' or '\h' for help. Type '\c' to clear the buffer.

mysql> create database eof;
Query OK, 1 row affected (0.00 sec)

Nous allons ensuite créer un utilisateur auquel nous donnerons tous les droits sur la base eof. Nous indiquons également le mot de passe pour cet utilisateur.

mysql> grant all privileges on eof.* to 'cvanvincq'@'%' identified by 'azerty';
Query OK, 0 rows affected (0.00 sec)

mysql> grant all privileges on eof.* to 'cvanvincq'@'localhost' identified by 'azerty';
Query OK, 0 rows affected (0.00 sec)

mysql> select host, user, password from mysql.user;
+---------------------------------------+-----------+------------------+
| host                                  | user      | password         |
+---------------------------------------+-----------+------------------+
| localhost                             | root      | 67457e226a1a15bd |
| %                                     | cvanvincq | 1097f85d22aac928 |
| localhost                             | cvanvincq | 1097f85d22aac928 |
+---------------------------------------+-----------+------------------+
3 rows in set (0.00 sec)

mysql> quit
Bye

Le '%' indique qu'on autorise un hôte à se connecter depuis n'importe quel hôte excepté en localhost. En ajoutant % et localhost, on peut se connecter de n'importe où.

Vérification si la création du nouveau compte est effective :

[adminbdd ~]$ mysql -u cvanvincq -p
Enter password:
Welcome to the MySQL monitor.  Commands end with ; or \g.
Your MySQL connection id is 35657
Server version: 5.0.77 Source distribution

Type 'help;' or '\h' for help. Type '\c' to clear the buffer.

mysql> show databases;
+--------------------+
| Database           |
+--------------------+
| information_schema |
| eof                |
| test               |
+--------------------+
3 rows in set (0.01 sec)

Cette commande vous montre les bases auxquelles vous avez accès.

Pour contrôler les droits de l’utilisateur vous pouvez également utiliser la commande suivante :

mysql> show grants for cvanvincq@localhost;
+-----------------------------------------------------------------------------------------+
| Grants for cvanvincq@localhost                                                          |
+-----------------------------------------------------------------------------------------+
| GRANT USAGE ON *.* TO 'cvanvincq'@'localhost' IDENTIFIED BY PASSWORD '1097f85d22aac928' |
| GRANT ALL PRIVILEGES ON `eof`.* TO 'cvanvincq'@'localhost'                              |
+-----------------------------------------------------------------------------------------+
2 rows in set (0.00 sec)

77.2.1.2. Module mysql-python

La base de données créée il faut s'assurer que le module spécifique à MySQL est disponible pour Python.

Pour vérifier ce point il vous suffit tout simplement d’essayer d’importer le module s’il n’est pas présent vous obtiendrez une exception :

[adminbdd ~]$ python
Python 2.4.3 (#1, Feb 22 2012, 16:05:45)
[GCC 4.1.2 20080704 (Red Hat 4.1.2-52)] on linux2
Type "help", "copyright", "credits" or "license" for more information.
>>> import MySQLdb
Traceback (most recent call last):
  File "<stdin>", line 1, in ?
ImportError: No module named MySQLdb
>>>

Le module n'est pas installé. Il suffit donc de l'installer :

[adminbdd ~]$ wget ftp://rpmfind.net/linux/centos/5.8/os/x86_64/CentOS/MySQL-python-1.2.3-0.1.c1.el5.x86_64.rpm
--2012-06-18 14:35:15--  ftp://rpmfind.net/linux/centos/5.8/os/x86_64/CentOS/MySQL-python-1.2.3-0.1.c1.el5.x86_64.rpm
           => `MySQL-python-1.2.3-0.1.c1.el5.x86_64.rpm'
Résolution de rpmfind.net... 195.220.108.108
Connexion vers rpmfind.net|195.220.108.108|:21...connecté.
Ouverture de session en anonymous...Session établie!
==> SYST ... complété.    ==> PWD ... complété.
==> TYPE I ... complété.  ==> CWD /linux/centos/5.8/os/x86_64/CentOS ... complété.
==> SIZE MySQL-python-1.2.3-0.1.c1.el5.x86_64.rpm ... 96863
==> PASV ... complété.    ==> RETR MySQL-python-1.2.3-0.1.c1.el5.x86_64.rpm ... complété.
Longueur: 96863 (95K)

100%[======================================================================================================================>] 96 863      --.-K/s   in 0,1s

2012-06-18 14:35:16 (993 KB/s) - « MySQL-python-1.2.3-0.1.c1.el5.x86_64.rpm » sauvegardé [96863]
[adminbdd ~]$ rpm -ivh MySQL-python-1.2.3-0.1.c1.el5.x86_64.rpm
Préparation...              ########################################### [100%]
   1:MySQL-python           ########################################### [100%]
[adminbdd ~]$ python
Python 2.4.3 (#1, Feb 22 2012, 16:05:45)
[GCC 4.1.2 20080704 (Red Hat 4.1.2-52)] on linux2
Type "help", "copyright", "credits" or "license" for more information.
>>> import MySQLdb
>>>

Tout est là. Effectuons maintenant un premier test :

>>> connexion = MySQLdb.connect("localhost", "cvanvincq", "azerty")
>>> curseur = connexion.cursor()
>>> curseur.execute("show databases")
3L
>>> print curseur.fetchall()
(('information_schema',), ('eof',), ('test',))

Nous avons bien accès à la base eof, nous allons créer notre première base. Pour cela il nous faut sélectionner la base de travail.

>>> connexion.select_db("eof")
>>> curseur.execute("create table test (id int, texte varchar(50))")
0L

Notez qu’il est possible de donner le nom de la base de données lors de l’instanciation de la connexion.

>>> connexion = MySQLdb.connect(host="localhost", user="cvanvincq", passwd="azerty", db="eof")

77.2.1.3. Base de l'application

Pour mettre en application l’utilisation nous allons imaginer une formation découpée en modules. Chaque module dispose d’un identifiant unique, d’une description, d’un volume horaire et d’un formateur.

Commençons par créer la table :

[adminbdd ~]$ mysql -u cvanvincq -pazerty
Welcome to the MySQL monitor.  Commands end with ; or \g.
Your MySQL connection id is 35933
Server version: 5.0.77 Source distribution

Type 'help;' or '\h' for help. Type '\c' to clear the buffer.

mysql> connect eof;
Reading table information for completion of table and column names
You can turn off this feature to get a quicker startup with -A

Connection id:    35934
Current database: eof

mysql> create table modules (nom varchar(50) primary key, description text, volumeHoraire int, responsable varchar(50));
Query OK, 0 rows affected (0.00 sec)

mysql> desc modules;
+---------------+-------------+------+-----+---------+-------+
| Field         | Type        | Null | Key | Default | Extra |
+---------------+-------------+------+-----+---------+-------+
| nom           | varchar(50) | NO   | PRI | NULL    |       |
| description   | text        | YES  |     | NULL    |       |
| volumeHoraire | int(11)     | YES  |     | NULL    |       |
| responsable   | varchar(50) | YES  |     | NULL    |       |
+---------------+-------------+------+-----+---------+-------+
4 rows in set (0.01 sec)

77.2.2. Module cmd

La table créée nous allons utiliser le module cmd qui est une sorte de mini framework pour applications en ligne de commande.

Son principe est relativement simple, un préfixe est associé aux méthodes pour déterminer s’il s’agit de commandes ou d’aides.

77.2.2.1. Les commandes

Les méthodes qui doivent servir de commandes sont préfixées par "do_" et prennent deux paramètres self et l’information transmise avec la commande.

77.2.2.2. L'aide

De la même façon une aide en ligne peut-être fournie pour les commandes. Les méthodes doivent être préfixées par "help_" et prendre self comme unique paramètre.

77.2.2.3. Mise en application

Pour gérer les modules de notre formation nous devons pouvoir en ajouter un, en supprimer un et lister l’ensemble de ceux disponibles.

Considérons le code suivant :

#!/usr/bin/python
# -*- coding: utf-8 -*-

import cmd, sys, MySQLdb

class GestionModules(cmd.Cmd):
	def __init__(self):
		cmd.Cmd.__init__(self)
		self.prompt = "action à réaliser > "
		self.connexion = MySQLdb.connect("localhost", "cvanvincq", "azerty", "eof")
		self.curseur = self.connexion.cursor()
		
	def do_ajout(self, nom):
		if nom =="":
			nom = raw_input("nom du module : ")
		description = raw_input("description : ")
		volumeHoraire = raw_input("volume horaire : ")
		responsable = raw_input("responsable : ")
		self.curseur.execute("INSERT INTO modules (nom, description, volumeHoraire, responsable) VALUES ('%s', '%s','%s','%s')" % (nom, description, volumeHoraire, responsable))

	def help_ajout(self):
		print """Permet d’ajouter un module. Le nom du module peut-etre passé directement en paramètre."""

	def do_liste(self, nom):
		if nom == "":
			nom = "%"
		else:
			nom = "%"+nom+"%"
		self.curseur.execute("SELECT * FROM modules WHERE nom LIKE '%s'" % (nom))
		print self.curseur.fetchall()
	
	def help_liste(self):
		print """Permet d’obtenir la liste complete des modules ou ceux qui contiennent en partie le nom indiqué."""

	def do_supprime(self, nom):
		self.curseur.execute("DELETE FROM modules WHERE nom = '%s'" % (nom))
		
	def help_supprime(self):
		print """Supprime le module dont le nom est passé en paramètre."""
		
	def do_quitte(self, param):
		sys.exit()
		
	def help_quitte(self):
		print """Quitte l’application."""
		
if __name__ == "__main__":
	gestionnaire = GestionModules()
	gestionnaire.cmdloop()

Pour utiliser le module cmd nous faisons simplement hériter notre classe de la classe Cmd.

Exécutez le script vous remarquerez que l’invite est personnalisée avec le texte fourni dans le constructeur.

Utilisez la commande help pour afficher les informations sur les commandes disponibles dans l'application.

[adminbdd ~]$ ./modules.py
action à réaliser > help

Documented commands (type help <topic>):
========================================
ajout  liste  quitte  supprime

Undocumented commands:
======================
help

Utilisez l’aide sur la commande ajout, vous remarquerez que la complétion est fournie par la classe Cmd (utilisez la touche tabulation).

Le champ nom est notre clé primaire et doit donc être unique. Si vous créez deux modules avec le même nom vous obtenez une exception.

Traceback (most recent call last):
  File "./modules.py", line 49, in ?
    gestionnaire.cmdloop()
  File "/usr/lib64/python2.4/cmd.py", line 142, in cmdloop
    stop = self.onecmd(line)
  File "/usr/lib64/python2.4/cmd.py", line 219, in onecmd
    return func(arg)
  File "./modules.py", line 19, in do_ajout
    self.curseur.execute("INSERT INTO modules (nom, description, volumeHoraire, responsable) VALUES ('%s', '%s','%s','%s')" % (nom, description, volumeHoraire, responsable))
  File "/usr/lib64/python2.4/site-packages/MySQLdb/cursors.py", line 173, in execute
    self.errorhandler(self, exc, value)
  File "/usr/lib64/python2.4/site-packages/MySQLdb/connections.py", line 36, in defaulterrorhandler
    raise errorclass, errorvalue
_mysql_exceptions.IntegrityError: (1062, "Duplicate entry 'si' for key 1")

Faisons un test d'insertion :

action à réaliser > ajout
nom du module : si
description : Description SI
volume horaire : 140
responsable : Jean Dupond
action à réaliser > liste
(('si', 'Description SI', 140L, 'Jean Dupond'),)

Pour améliorer le fonctionnement de notre application nous allons ajouter une gestion d’exception. Nous allons modifier la méthode ajout :

def do_ajout(self, nom):
	if nom =="":
		nom = raw_input("nom du module : ")
	description = raw_input("description : ")
	volumeHoraire = raw_input("volume horaire : ")
	responsable = raw_input("responsable : ")

	try:
		self.curseur.execute("INSERT INTO modules (nom, description, volumeHoraire,	responsable) VALUES ('%s', '%s', '%s', '%s')" % (nom, description, volumeHoraire, responsable))
	except MySQLdb.Error, e:
		print """L’ajout de ce module n’a pu etre éffectué pour les raisons suivantes : %s""" % e.args[1]

Ce qui nous donne en exception :

action à réaliser > ajout si
description : Description SI
volume horaire : 140
responsable : Jean Dupont
L’ajout de ce module n’a pu etre éffectué pour les raisons suivantes : Duplicate entry 'si' for key 1

Ajoutons un nouveau module.

Si vous utilisez la commande liste vous constatez que le résultat retourné est un tuple des différents enregistrements.

Ceci est une caractéristique de la méthode fetchall qui comme son nom le laisse supposer renvoie tous les enregistrements en même temps.

action à réaliser > liste
(('si', 'Description SI', 140L, 'Jean Dupond'), ('sr', 'Module SR', 140L, 'Paul Durand'))

Le curseur par défaut dispose de deux autres méthodes de récupération :

  • fetchone qui retourne un seul enregistrement à la fois,

  • fetchmany qui retourne un nombre fini d’enregistrements.

Modifiez la méthode liste pour qu’elle liste les différents modules avec la méthode fetchone et créez une nouvelle méthode listeFixe qui limite l’affichage à un nombre donné d’enregistrements.

Pour connaitre le prototype de ces deux fonctions vous pouvez soit vous baser sur la PEP qui spécifie l’API d’accès aux bases de données, soit vous reporter à la documentation du module.

def do_liste(self, nom):
	if nom == "":
		nom = "%"
	else:
		nom = "%"+nom+"%"
	self.curseur.execute("SELECT * FROM modules WHERE nom LIKE '%s'" % (nom))
	for i in range(self.curseur.rowcount):
		print self.curseur.fetchone()
		
def do_listeFixe(self, nombre):
	if nombre == "":
		# Valeur maximale plafonnée.
		nombre = 1000
	self.curseur.execute("SELECT * FROM modules")
	for i in range(self.curseur.rowcount):
		if i >= int(nombre):
			break
		print self.curseur.fetchone()

Si vous saisissez une description ou un nom de module avec une apostrophe vous n’obtiendrez pas le résultat attendu.

Pour cela le module MySQLdb met à disposition une méthode qui permet d’échapper vos chaînes (escape_string).

Modifiez la commande ajout avec le code suivant :

def do_ajout(self, nom):
	if nom =="":
		nom = raw_input("nom du module : ")
	description = raw_input("description : ")
	volumeHoraire = raw_input("volume horaire : ")
	responsable = raw_input("responsable : ")
	try:
		self.curseur.execute("INSERT INTO modules (nom, description, volumeHoraire, responsable) VALUES ('%s', '%s', '%s', '%s')" % (MySQLdb.escape_string(nom), MySQLdb.escape_string(description), volumeHoraire, responsable))
	except MySQLdb.Error, e:
		print """ L’ajout de ce module n'a pu etre éffectué pour les raisons suivantes : %s""" % e.args[1]

Il nous manque une méthode de modification, cette méthode pourrait permettre de modifier la description de plusieurs enregistrements à la fois.

Pour cela nous allons utiliser un dictionnaire dont la clé sera le nom du module et la valeur associée à cette clé la nouvelle valeur de la description.

Ajoutez la commande suivante :

def do_modifieDescription(self, commande):
	dict_modules = eval(commande)
	modules=[]
	for cle in dict_modules:
		modules.append((dict_modules[cle],cle))
	self.curseur.executemany("UPDATE modules SET description=%s WHERE nom = %s", (modules))