11. Survol de la bibliothèque standard – Deuxième partie

Cette deuxième partie aborde des modules plus à destination des programmeurs professionnels. Ces modules sont rarement nécessaires dans de petits scripts.

11.1. Formatage de l’affichage

Le module reprlib est une variante de la fonction repr(), spécialisé dans l’affichage concis de conteneurs volumineux ou fortement imbriqués :

>>> import reprlib
>>> reprlib.repr(set('supercalifragilisticexpialidocious'))
"{'a', 'c', 'd', 'e', 'f', 'g', ...}"

Le module pprint propose un contrôle plus fin de l’affichage des objets, aussi bien natifs que ceux définis par l’utilisateur, de manière à être lisible par l’interpréteur. Lorsque le résultat fait plus d’une ligne, il est séparé sur plusieurs lignes et est indenté pour rendre la structure plus visible :

>>> import pprint
>>> t = [[[['black', 'cyan'], 'white', ['green', 'red']], [['magenta',
...     'yellow'], 'blue']]]
...
>>> pprint.pprint(t, width=30)
[[[['black', 'cyan'],
   'white',
   ['green', 'red']],
  [['magenta', 'yellow'],
   'blue']]]

Le module textwrap formate des paragraphes de texte pour tenir sur un écran d’une largeur donnée :

>>> import textwrap
>>> doc = """The wrap() method is just like fill() except that it returns
... a list of strings instead of one big string with newlines to separate
... the wrapped lines."""
...
>>> print(textwrap.fill(doc, width=40))
The wrap() method is just like fill()
except that it returns a list of strings
instead of one big string with newlines
to separate the wrapped lines.

Le module locale utilise une base de données des formats spécifiques à chaque région pour les dates, nombres, etc. L’attribut grouping de la fonction de formatage permet de formater directement des nombres avec un séparateur :

>>> import locale
>>> locale.setlocale(locale.LC_ALL, 'English_United States.1252')
'English_United States.1252'
>>> conv = locale.localeconv()          # get a mapping of conventions
>>> x = 1234567.8
>>> locale.format("%d", x, grouping=True)
'1,234,567'
>>> locale.format_string("%s%.*f", (conv['currency_symbol'],
...                      conv['frac_digits'], x), grouping=True)
'$1,234,567.80'

11.2. Gabarits (Templates)

Le module string contient une classe polyvalente : Template. Elle permet d’écrire des gabarits (templates en anglais) avec une syntaxe simple, dans le but d’être utilisable par des non-développeurs. Ainsi, vos utilisateurs peuvent personnaliser leur application sans la modifier.

Le format utilise des marqueurs formés d’un $ suivi d’un identifiant Python valide (caractères alphanumériques et tirets-bas). Entourer le marqueur d’accolades permet de lui coller d’autres caractères alphanumériques sans intercaler une espace. Écrire $$ produit un simple $ :

>>> from string import Template
>>> t = Template('${village}folk send $$10 to $cause.')
>>> t.substitute(village='Nottingham', cause='the ditch fund')
'Nottinghamfolk send $10 to the ditch fund.'

La méthode substitute() lève une exception KeyError lorsqu’un marqueur n’a pas été fourni, ni dans un dictionnaire, ni sous forme d’un paramètre nommé. Dans certains cas, lorsque la donnée à appliquer peut n’être fournie que partiellement par l’utilisateur, la méthode safe_substitute() est plus appropriée car elle laisse tels quels les marqueurs manquants :

>>> t = Template('Return the $item to $owner.')
>>> d = dict(item='unladen swallow')
>>> t.substitute(d)
Traceback (most recent call last):
  ...
KeyError: 'owner'
>>> t.safe_substitute(d)
'Return the unladen swallow to $owner.'

Les classes filles de Template peuvent définir leur propre délimiteur. Typiquement, un script de renommage de photos par lots peut choisir le symbole pourcent comme marqueur pour les champs tels que la date actuelle, le numéro de l’image ou son format :

>>> import time, os.path
>>> photofiles = ['img_1074.jpg', 'img_1076.jpg', 'img_1077.jpg']
>>> class BatchRename(Template):
...     delimiter = '%'
>>> fmt = input('Enter rename style (%d-date %n-seqnum %f-format):  ')
Enter rename style (%d-date %n-seqnum %f-format):  Ashley_%n%f

>>> t = BatchRename(fmt)
>>> date = time.strftime('%d%b%y')
>>> for i, filename in enumerate(photofiles):
...     base, ext = os.path.splitext(filename)
...     newname = t.substitute(d=date, n=i, f=ext)
...     print('{0} --> {1}'.format(filename, newname))

img_1074.jpg --> Ashley_0.jpg
img_1076.jpg --> Ashley_1.jpg
img_1077.jpg --> Ashley_2.jpg

Une autre utilisation des gabarits consiste à séparer la logique métier des détails spécifiques à chaque format de sortie. Il est ainsi possible de générer des gabarits spécifiques pour les fichiers XML, texte, HTML …

11.3. Traitement des données binaires

Le module struct expose les fonctions pack() et unpack() permettant de travailler avec des données binaires. L’exemple suivant montre comment parcourir une entête de fichier ZIP sans recourir au module zipfile. Les marqueurs "H" et "I" représentent des nombres entiers non signés, stockés respectivement sur deux et quatre octets. Le "<" indique qu’ils ont une taille standard et utilisent la convention petit-boutiste :

import struct

with open('myfile.zip', 'rb') as f:
    data = f.read()

start = 0
for i in range(3):                      # show the first 3 file headers
    start += 14
    fields = struct.unpack('<IIIHH', data[start:start+16])
    crc32, comp_size, uncomp_size, filenamesize, extra_size = fields

    start += 16
    filename = data[start:start+filenamesize]
    start += filenamesize
    extra = data[start:start+extra_size]
    print(filename, hex(crc32), comp_size, uncomp_size)

    start += extra_size + comp_size     # skip to the next header

11.4. Fils d’exécution

Des tâches indépendantes peuvent être exécutées de manière non séquentielle en utilisant des fils d’exécution (threading en anglais). Les fils d’exécution peuvent être utilisés pour améliorer la réactivité d’une application qui interagit avec l’utilisateur pendant que d’autres traitements sont exécutés en arrière-plan. Une autre utilisation typique est de séparer sur deux fils d’exécution distincts les entrées / sorties et le calcul.

Le code suivant donne un exemple d’utilisation du module threading exécutant des tâches en arrière-plan pendant que le programme principal continue de s’exécuter :

import threading, zipfile

class AsyncZip(threading.Thread):
    def __init__(self, infile, outfile):
        threading.Thread.__init__(self)
        self.infile = infile
        self.outfile = outfile

    def run(self):
        f = zipfile.ZipFile(self.outfile, 'w', zipfile.ZIP_DEFLATED)
        f.write(self.infile)
        f.close()
        print('Finished background zip of:', self.infile)

background = AsyncZip('mydata.txt', 'myarchive.zip')
background.start()
print('The main program continues to run in foreground.')

background.join()    # Wait for the background task to finish
print('Main program waited until background was done.')

Le principal défi des applications avec plusieurs fils d’exécution consiste à coordonner ces fils qui partagent des données ou des ressources. Pour ce faire, le module threading expose quelques outils dédiés à la synchronisation comme les verrous (locks en anglais), les événements (events en anglais), les variables conditionnelles (condition variables en anglais) et les sémaphores (semaphore en anglais).

Bien que ces outils soient puissants, de petites erreurs de conception peuvent engendrer des problèmes difficiles à reproduire. Donc, l’approche classique pour coordonner des tâches est de restreindre l’accès d’une ressource à un seul fil d’exécution et d’utiliser le module queue pour alimenter ce fil d’exécution en requêtes venant d’autres fils d’exécution. Les applications utilisant des Queue pour leurs communication et coordination entre fils d’exécution sont plus simples à concevoir, plus lisibles et plus fiables.

11.5. Journalisation

Le module logging est un système de journalisation complet. Dans son utilisation la plus élémentaire, les messages sont simplement envoyés dans un fichier ou sur sys.stderr :

import logging
logging.debug('Debugging information')
logging.info('Informational message')
logging.warning('Warning:config file %s not found', 'server.conf')
logging.error('Error occurred')
logging.critical('Critical error -- shutting down')

Cela produit l’affichage suivant :

WARNING:root:Warning:config file server.conf not found
ERROR:root:Error occurred
CRITICAL:root:Critical error -- shutting down

Par défaut, les messages d’information et de débogage sont ignorés, les autres sont envoyés vers la sortie standard. Il est aussi possible d’envoyer les messages par courriel, datagrammes, sur des sockets ou vers un serveur HTTP. Des nouveaux filtres permettent d’utiliser des sorties différentes en fonction de la priorité du message : DEBUG, INFO, WARNING, ERROR et CRITICAL.

La configuration de la journalisation peut être effectuée directement dans le code Python ou peut être chargée depuis un fichier de configuration, permettant de personnaliser la journalisation sans modifier l’application.

11.6. Références faibles

Python gère lui-même la mémoire (par comptage des références pour la plupart des objets et en utilisant un ramasse-miettes (garbage collector en anglais) pour éliminer les cycles). La mémoire est libérée rapidement lorsque sa dernière référence est supprimée.

Cette approche fonctionne bien pour la majorité des applications mais, parfois, il est nécessaire de surveiller un objet seulement durant son utilisation par quelque chose d’autre. Malheureusement, le simple fait de le suivre crée une référence qui rend l’objet permanent. Le module weakref expose des outils pour suivre les objets sans pour autant créer une référence. Lorsqu’un objet n’est pas utilisé, il est automatiquement supprimé du tableau des références faibles et une fonction de rappel (callback en anglais) est appelée. Un exemple typique est le cache d’objets coûteux à créer :

>>> import weakref, gc
>>> class A:
...     def __init__(self, value):
...         self.value = value
...     def __repr__(self):
...         return str(self.value)
...
>>> a = A(10)                   # create a reference
>>> d = weakref.WeakValueDictionary()
>>> d['primary'] = a            # does not create a reference
>>> d['primary']                # fetch the object if it is still alive
10
>>> del a                       # remove the one reference
>>> gc.collect()                # run garbage collection right away
0
>>> d['primary']                # entry was automatically removed
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
    d['primary']                # entry was automatically removed
  File "C:/python37/lib/weakref.py", line 46, in __getitem__
    o = self.data[key]()
KeyError: 'primary'

11.7. Outils pour les listes

Beaucoup de structures de données peuvent être représentées avec les listes natives. Cependant, d’autres besoins peuvent émerger pour des structures ayant des caractéristiques différentes, typiquement en termes de performance.

Le module array fournit un objet array() ne permettant de stocker que des listes homogènes mais d’une manière plus compacte. L’exemple suivant montre une liste de nombres stockés chacun sur deux octets non signés (marqueur "H") plutôt que d’utiliser 16 octets comme l’aurait fait une liste classique :

>>> from array import array
>>> a = array('H', [4000, 10, 700, 22222])
>>> sum(a)
26932
>>> a[1:3]
array('H', [10, 700])

Le module collections fournit la classe deque(). Elle ressemble à une liste mais est plus rapide pour l’insertion ou l’extraction des éléments par la gauche et plus lente pour accéder aux éléments du milieu. Ces objets sont particulièrement adaptés pour construire des queues ou des algorithmes de parcours d’arbres en largeur (ou BFS, pour Breadth First Search en anglais) :

>>> from collections import deque
>>> d = deque(["task1", "task2", "task3"])
>>> d.append("task4")
>>> print("Handling", d.popleft())
Handling task1
unsearched = deque([starting_node])
def breadth_first_search(unsearched):
    node = unsearched.popleft()
    for m in gen_moves(node):
        if is_goal(m):
            return m
        unsearched.append(m)

En plus de fournir des implémentations de listes alternatives, la bibliothèque fournit des outils tels que bisect, un module contenant des fonctions de manipulation de listes triées :

>>> import bisect
>>> scores = [(100, 'perl'), (200, 'tcl'), (400, 'lua'), (500, 'python')]
>>> bisect.insort(scores, (300, 'ruby'))
>>> scores
[(100, 'perl'), (200, 'tcl'), (300, 'ruby'), (400, 'lua'), (500, 'python')]

Le module heapq permet d’implémenter des tas (heap en anglais) à partir de simples listes. La valeur la plus faible est toujours à la première position (indice 0). C’est utile dans les cas où l’application accède souvent à l’élément le plus petit mais sans vouloir classer entièrement la liste :

>>> from heapq import heapify, heappop, heappush
>>> data = [1, 3, 5, 7, 9, 2, 4, 6, 8, 0]
>>> heapify(data)                      # rearrange the list into heap order
>>> heappush(data, -5)                 # add a new entry
>>> [heappop(data) for i in range(3)]  # fetch the three smallest entries
[-5, 0, 1]

11.8. Arithmétique décimale à virgule flottante

Le module decimal expose la classe Decimal : elle est spécialisée dans le calcul de nombres décimaux représentés en virgule flottante. Par rapport à la classe native float, elle est particulièrement utile pour :

  • les applications traitant de finance et autres utilisations nécessitant une représentation décimale exacte,
  • le contrôle de la précision,
  • le contrôle sur les arrondis pour répondre à des obligations légales ou réglementaires,
  • suivre les décimales significatives, ou
  • les applications pour lesquelles l’utilisateur attend des résultats identiques aux calculs faits à la main.

Par exemple, calculer 5 % de taxe sur une facture de 70 centimes donne un résultat différent en nombre à virgule flottante binaire et décimale. La différence devient significative lorsqu’on arrondit le résultat au centime près :

>>> from decimal import *
>>> round(Decimal('0.70') * Decimal('1.05'), 2)
Decimal('0.74')
>>> round(.70 * 1.05, 2)
0.73

Le résultat d’un calcul donné par Decimal conserve les zéros non-significatifs. La classe conserve automatiquement quatre décimales significatives pour des opérandes à deux décimales significatives. La classe Decimal imite les mathématiques telles qu’elles pourraient être effectuées à la main, évitant les problèmes typiques de l’arithmétique binaire à virgule flottante qui n’est pas capable de représenter exactement certaines quantités décimales.

La représentation exacte de la classe Decimal lui permet de faire des calculs de modulo ou des tests d’égalité qui ne seraient pas possibles avec une représentation à virgule flottante binaire :

>>> Decimal('1.00') % Decimal('.10')
Decimal('0.00')
>>> 1.00 % 0.10
0.09999999999999995

>>> sum([Decimal('0.1')]*10) == Decimal('1.0')
True
>>> sum([0.1]*10) == 1.0
False

Le module decimal permet de faire des calculs avec autant de précision que nécessaire :

>>> getcontext().prec = 36
>>> Decimal(1) / Decimal(7)
Decimal('0.142857142857142857142857142857142857')