Python Avancé : Guide Pratique
Python Avancé : Guide Pratique
PYTHON
AVANCÉE
Chez le même éditeur
Python 3
2e édition
Bob Cordeau, Laurent Pointal
304 pages
Dunod, 2025
© Dunod, 2025
11 rue Paul Bert, 92240 Malakoff
[Link]
ISBN 978-2-10-088187-1
Préface à la première édition
V
Lorsque Xavier Olive m’a contacté pour me proposer de relire ce livre que vous tenez ac-
tuellement entre les mains, ma première réaction a été « Oh non, encore un livre sur Python ! »
Un certain nombre de livres sur Python trônent déjà sur les étagères de mon bureau, même
s’il est vrai que la plupart sont en anglais. Mais lors de ce premier échange par mail, Xavier
a glissé subrepticement le fait qu’il avait pour but d’écrire le livre qu’il aurait voulu lire. Cela
a eu pour effet immédiat d’attiser ma curiosité et j’ai donc accepté de relire cet ouvrage sans
trop savoir où je mettais les pieds. Après avoir relu les quelque 350 pages qui composent ce
livre, je réalise que c’était une très bonne décision de ma part tant le livre est agréable à lire,
tant sur la forme que sur le fond.
Sur la forme d’abord, car Xavier a véritablement soigné le style du livre qui tranche avec
un certain nombre d’ouvrages que j’ai pu lire par le passé. Ayant une solide expérience quant
à la typographie et à la mise en page, je suis admiratif du soin et du souci du détail qui ont
été apportés à l’ouvrage. Trop souvent les auteurs négligent cet aspect en se disant que seul le
fond importe pour un ouvrage technique alors que la forme peut véritablement jouer un rôle
essentiel pour la compréhension de concepts parfois ardus. La relecture et les échanges avec
Xavier sur ces aspects ont été d’autant plus agréables qu’il en a une parfaite maîtrise.
Mais la prouesse du livre se trouve bien évidemment sur le fond. Ayant moi-même une as-
sez grande expérience de Python, notamment sur ses versants scientifiques, je reste admiratif
de cet ouvrage qui est à la fois bien écrit, bien structuré, bien documenté et surtout extrême-
ment pédagogique dans son approche. Lors de ma relecture du premier chapitre, je me suis
d’abord fait la réflexion qu’il allait un peu vite en besogne en présentant les bases du langage
Python, avant de me rappeler qu’il s’agissait d’un ouvrage avancé venant compléter celui de
Bob Cordeau et Laurent Pointal qui s’adresse, lui, aux débutants. Or, présenter les bases du
langage Python à des utilisateurs avancés est un vrai numéro d’équilibriste. Mais je crois que
Xavier a su justement trouver le bon équilibre en choisissant méticuleusement les aspects
peut-être moins connus du langage et en les illustrant à l’aide d’exemples pertinents (et pour
certains passionnants au niveau théorique, comme les L-systèmes).
Le livre est structuré autour de cinq grandes parties (Les bases du langage Python, L’éco-
système Python, Écrire un Python naturel et efficace, Python, couteau suisse du quotidien,
Développer un projet en Python) et de trois interludes (Calcul du rayon de la Terre, Recons-
truire une carte d’Europe, La démodulation de signaux FM) qui viennent agréablement aérer
la technicité de certains chapitres. Étant avant tout un livre pour des utilisateurs avancés, il est
V
Préface à la première édition
évident que Xavier ne pouvait éviter d’être technique sur certains des aspects les plus avancés
de Python. J’avoue ne pas être un grand fan des dernières possibilités offertes par le langage
Python car je crois que cela alourdit inutilement le langage mais Xavier a su malgré tout me
convaincre de l’utilité de la majorité d’entre elles, notamment lorsque l’on se retrouve à gérer
de gros projets collaboratifs.
Je suis donc à la fois très honoré et très heureux d’écrire aujourd’hui cette préface pour
un livre qui, je le crois, deviendra un classique. Évidemment, ce n’est que la première édition
et, au vu de la rapidité d’évolution du langage Python, je crois aussi que Xavier s’est engagé,
sans peut-être le savoir, à écrire une nouvelle édition tous les deux ou trois ans. C’est tout
le mal que je lui souhaite. D’ailleurs, à l’heure où j’écris ces lignes (février 2021), le PEP 636
concernant l’identification structurelle de motifs (Structural Pattern Matching en anglais) vient
d’être accepté, validant ainsi encore un peu plus la 10ᵉ règle de Greenspun ¹.
Nicolas P. Rougier
Docteur en informatique et chercheur
à l’Inria en neurosciences computationnelles
Février 2021
1. [Link]
VI
Z
Table des matières
Préface à la première édition V
Avant-propos IX
II L’écosystème Python 67
5 Le calcul numérique avec NumPy 69
6 Produire des graphiques avec Matplotlib 83
7 La boîte à outils scientifiques SciPy 99
8 L’environnement interactif Jupyter 107
Interlude : Reconstruire une carte d’Europe 113
VII
Table des matières
VIII
Avant-propos
]
Python est un langage généraliste et multi-plateforme, développé suivant un modèle open
source depuis le début des années 1990 : sa première version vient de fêter ses 30 ans ! C’est
un langage interprété, populaire pour sa facilité d’utilisation, pour sa polyvalence, et pour
les apports de sa communauté. Python est apprécié parmi les scientifiques et les ingénieurs
d’horizons divers.
Dans certaines spécialités, d’autres langages peuvent être plus rapides, plus sûrs ou plus
complets, mais Python se démarque par sa polyvalence, par la concision et la lisibilité de sa
syntaxe, par la facilité avec laquelle on peut écrire un prototype en quelques heures, construire
une chaîne de traitements à partir de briques logicielles écrites par d’autres, parfois dans
d’autres langages. Enfin, il reste une solution de choix pour outiller des tâches informatiques
simples de notre quotidien.
IX
Avant-propos
$ uv pip install .
X
Avant-propos
Les exemples courts démarrent par >>> pour refléter l’invite de l’interpréteur Python. Les
retours sont alors affichés à la ligne.
>>> import math
>>> [Link]
3.141592653589793
...
Tout fichier ou toute partie d’un fichier Python est imprimé dans le livre et annoté à l’aide
de balises numérotées À, Á, etc. Les fichiers complets sont disponibles sur le site https://
[Link]/python/. Tous les exemples ont été testés pour Python 3.12.
La première partie du livre sur les bases du langage ne nécessite pas de bibliothèques
particulières : n’importe quel interpréteur Python fera l’affaire.
Dans les parties suivantes, il est recommandé de cloner le dépôt du livre depuis le lien
[Link] À, d’installer l’outil pixi (☞ p. 348, § 24.6), puis de créer
un environnement dédié. Lors de la première exécution de la commande pixi Á, l’environne-
ment Python et ses dépendances seront installées.
$ git clone [Link] # À
$ pixi run python # Á
Python 3.12.5 | packaged by conda-forge | (main, Aug 8 2024, [Link]) [Clang 16.0.6 ] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>>
— le texte à chasse fixe retranscrit notamment des noms de variables, de modules et des
scripts Python.
9 Attention !
Cet encart met en valeur les erreurs et pièges courants dans lesquels le programmeur,
même averti, peut tomber.
V Bonnes pratiques
Cet encart met en valeur les bonnes pratiques, couramment admises, qui améliorent la
qualité et la lisibilité du code.
XI
Avant-propos
Cas d’application. Cet encart met en valeur un exemple appliqué au milieu d’un cha-
pitre théorique.
En quelques mots…
Cet encart conclut un chapitre en rappelant les éléments essentiels à retenir pour com-
prendre les chapitres suivants.
Remerciements
Ce projet n’aurait pas vu le jour si Bob Cordeau ne m’avait pas présenté Jean-Luc Blanc
des éditions Dunod, juste après la soumission du manuscrit de la deuxième édition de son
ouvrage Python 3. Apprendre à programmer dans l’écosystème Python. Je tiens à les remercier
tous les deux, notamment M. Blanc pour la confiance qu’il m’a accordée pour la définition de
ce nouveau projet et pour la souplesse avec laquelle il m’a accompagné.
La rédaction d’un tel ouvrage est un moment intense, un marathon dans lequel sont em-
barqués malgré eux épouse, enfants, proches et collègues. Tous ont participé d’une manière
ou d’une autre à ce travail, en faisant face à une indisponibilité qu’ils n’avaient pas choisie, en
apportant leur soutien, leur point de vue critique après relectures, leur réponse pendant les
moments de doute et leurs encouragements. Cet ouvrage leur est dédié.
Je remercie notamment Luis Basora et Thomas Dubot, fidèles soutiens et relecteurs de la
première heure, ainsi que Judicaël Bedouet pour sa gentillesse et ses métacommentaires avisés.
Junzi Sun et Enrico Spinielli ont également contribué à cet ouvrage sans vraiment le savoir, le
fruit de nos riches échanges transparaît dans de nombreux exemples.
Nicolas Rougier, auteur de nombreuses références Python de qualité, a répondu à mon
invitation, a accepté de relire l’intégralité de l’ouvrage puis m’a fait l’honneur de le préfacer.
Brice Martin, des éditions Dunod, a également été d’une grande aide pour la préparation du
manuscrit. Leurs commentaires ont contribué à améliorer la lisibilité de l’ensemble.
Il faut enfin saluer tous les étudiants qui, par leurs progrès, leurs doutes, leurs interroga-
tions et reformulations, ont contribué à améliorer les ressources pédagogiques préliminaires
sur lesquelles sont construites les fondations de ce livre.
XII
Introduction à la deuxième édition
W
L’
apprentissage d’un langage de programmation est un processus progressif. Comme
pour une langue vivante, il est tout à fait possible, et même recommandé, de pratiquer
un langage même sans en connaître toutes les subtilités. C’est par la pratique qu’on
découvre de nouvelles problématiques, des solutions pour y répondre ; puis vient la réflexion
pour généraliser ces solutions à des classes générales de problèmes. Un langage vivant est
capable de faire vivre de telles réflexions pour proposer des améliorations de celui-ci.
Ce rôle est joué en Python par les PEP, les Python Enhancement Proposals, ces discussions
levées par la communauté avec des propositions d’améliorations du langage. Certaines amé-
liorations sont controversées, mais les décisions sont toujours prises après échanges d’argu-
ments. Ces améliorations font évoluer le langage et les pratiques mois après mois. Le Python
de 1989 n’est pas le même que le Python de 2003, qui n’est pas le même que le Python de 2020.
Ces pages ont pour vocation d’explorer cet environnement changeant du langage, de son
écosystème, construit autour des structures de données efficaces de la bibliothèque NumPy
(☞ p. 69, § 5) et des technologies du web vers lesquelles la communauté se tourne progressi-
vement ( ☞ p. 107, § 8 ; ☞ p. 137, § 10), ainsi que des fondements théoriques sur lesquels il s’est
construit. Elles guident le lecteur dans le monde des bibliothèques tierces de référence conçues
pour faciliter la réalisation de tâches élémentaires : télécharger des données (☞ p. 309, § 21),
extraire des informations d’un document (☞ p. 299, § 20), les structurer, les transformer ou
les visualiser, le tout dans l’objectif d’y apporter une valeur ajoutée.
Le livre se termine sur la conception d’un projet Python (☞ p. 349, § 25). Les outils évoluent
de jour en jour, il n’a jamais été aussi facile qu’en 2020 de partager du code tout en s’assurant
qu’il pourra être exécuté sur une nouvelle plateforme, sur un nouveau système d’exploitation,
en embarquant les bibliothèques nécessaires à son bon fonctionnement.
La première version de cet ouvrage était basée sur Python 3.9, qui avait déjà vu l’introduc-
tion de plusieurs innovations majeures :
— les mots-clés pour la programmation asynchrone,
— l’opérateur de multiplication matricielle,
— les annotations de types (PEP 526),
— les chaînes de caractères formatées, ou f-strings, (PEP 498, ☞ p. 9, § 1.3)),
— le séparateur de milliers avec le souligné dans les entiers,
— les dataclasses (PEP 557 , ☞ p. 50, § 4.2),
1
Introduction à la deuxième édition
— le walrus operator := du PEP 572 au terme duquel le créateur du langage Guido van
Rossum a quitté son poste de Benevolent Dictator For Life (BDFL) ;
— les types standards génériques (PEP 585, ☞ p. 367, § 27).
Cet ouvrage doit évoluer avec l’écosystème Python. Le manuscrit de cette nouvelle édition
a été finalisé juste avant la sortie de Python 3.13. Les principaux ajouts de cette deuxième
édition incluent :
— La mise à jour vers NumPy 2.0 (☞ p. 80, § 5.7) ;
— La mise à jour vers Pandas 2.0 (☞ p. 133, § 9.6), ainsi qu’un aperçu sur la bibliothèque
Polars (☞ p. 133, § 9.7) ;
— Une présentation de la bibliothèque Xarray (☞ p. 155, § 11) ;
— Le structural pattern matching (☞ p. 166, § 12.1), un principe qui vient de la program-
mation fonctionnelle (☞ p. 165, § 12).
— Les nouveautés concernant la gestion de la concurrence (☞ p. 269, § 18).
— Un chapitre dédié à la programmation asynchrone (☞ p. 279, § 19), et l’introduction de
nouvelles bibliothèques Python adaptées à ce paradigme :
— Requests est remplacé par HTTPX (☞ p. 309, § 21.1) ;
— Flask est remplacé par FastAPI (☞ p. 312, § 21.2) ;
— la gestion des bases de données (y compris DuckDB ☞ p. 317, § 21.3.1) est présentée
avec SQLAlchemy (☞ p. 317, § 21.3.2) ;
— Dans le terminal on présente désormais les bibliothèques Rich (☞ p. 321, § 22.1) et
Textual (☞ p. 325, § 22.3).
— La possibilité d’exécuter Python directement dans un navigateur web (☞ p. 333, § 23) ;
— La gestion d’un projet Python mise à jour avec les outils Ruff (☞ p. 349, § 25) et uv
(☞ p. 345, § 24.4) ;
— Les dernières évolutions de la syntaxe de typage statique (☞ p. 367, § 27) ;
— L’intégration de Rust dans le développement Python (☞ p. 389, § 28.3) ;
— Quelques remarques sur les bonnes pratiques de code en fin d’ouvrage.
Python reste un outil, pas une fin en soi. Comme le dit A. Maslow, I suppose it is tempting,
if the only tool you have is a hammer, to treat everything as if it were a nail, « J’imagine qu’il
est tentant, si le seul outil dont vous disposez est un marteau, de tout considérer comme un
clou. »
Ces lignes visitent parfois des contrées atypiques, des manières de programmer qui peuvent
sembler déroutantes. L’objectif de cet ouvrage est d’apporter des éléments qui permettent de
clarifier des éléments de syntaxe et de conception couramment utilisés dans des grosses bases
logicielles. Attention cependant par la suite à bien distinguer « bonne pratique » d’« usage
inutilement complexe ».
Keep it simple.
2
I
Les bases du
langage Python
1
Types et arithmétique de base
L’
objectif de ce chapitre est de redécouvrir les types de base de Python : nombres
(entiers, flottants, complexes), chaînes de caractères, structures conteneurs (tuples,
listes, ensembles, dictionnaires), fonctions et exceptions. Nous nous concentrons ici
sur les particularités du langage et les singularités de chacune des structures présentées. La
documentation officielle ¹ reste la référence pour une description exhaustive des possibilités
des structures conteneurs de base.
On peut manipuler les entiers à partir de leur représentation binaire (préfixe 0b), octale
(préfixe 0o), décimale ou hexadécimale (préfixe 0x), mais la représentation par défaut est en
base 10. Des opérateurs renvoient une représentation en base 2, 8 ou 16 sous forme de chaîne
de caractères. À l’inverse, on peut lire un entier dans n’importe quelle base à partir d’une
chaîne de caractères : il suffit de la préciser en paramètre.
>>> 0b1111111 >>> bin(127) >>> int("0b1111111", 2)
127 '0b1111111' 127
>>> 0o177 >>> oct(127) >>> int("0o177", 8)
127 '0o177' 127
>>> 0x7f >>> hex(127) >>> int("0x7f", 16)
127 '0x7f' 127
Suivant le modulo (le reste de la division entière) à calculer, il peut être plus efficace de
faire appel à une opération bit à bit qui s’opère sur la représentation binaire des entiers : par
exemple, l’entier 3 s’écrit 11 en binaire, l’opération & 3, en bit à bit, filtre les deux derniers bits
1. [Link]
5
1. Types et arithmétique de base
De la même manière, les opérations de décalage de bit (bitshift) vers la gauche << ou vers la
droite >> peuvent être plus efficaces que des calculs de puissance de 2. Le passage à la puissance
implique un grand nombre de multiplications alors que le décalage de bit est une opération
unitaire du point de vue du processeur.
>>> 2 ** 8 # puissance >>> 1 << 8 # décalage de bits
256 256
Les entiers en Python ont une amplitude illimitée. C’est un atout de taille par rapport à
d’autres langages de programmation classiques (basés sur le langage C) qui s’appuient sur
les spécificités du processeur et du système d’exploitation pour représenter les entiers sur
un nombre défini d’octets, sans qu’aucun standard ne permette d’inférer quoi que ce soit. En
pratique, Python propose les opérations classiques sur les longs entiers en mobilisant autant
d’espace mémoire que nécessaire pour les représenter. ²
Il n’y a pas de standard pour la taille ou la représentation des entiers dans les langages
habituels, qui peuvent suivant les architectures occuper 32, 64 ou 128 bits. Les nombres de
l’exemple suivant ne pourraient pas être manipulés par le langage C par exemple :
>>> 2 ** 128 # le plus grand entier non signé sur 128 bits + 1
340282366920938463463374607431768211456
>>> 123 ** 24 # cet entier s'écrit sur 167 bits (pas de représentation en C)
143788010446775248848237875203163336494653562343841
>>> 123 ** 24 * 134 ** 45 # l'opération est très rapide!
75411677391330129167448442896914801155017182257509041648701768405723078474592
380051352543980177649477418579845319891270034417439450350881010089984
2. La version 2.3 de Python a introduit pour la première fois la multiplication de Karatsuba pour les grands en-
tiers. D’autres méthodes rapides basées sur la transformée de Fourier rapide (FFT, pour Fast Fourier Transform), de
l’algorithme de Schönhage-Strassen (SSA) ou celui de De, Kurur, Saha and Saptharishi (DKSS) sont accessibles pour
la multiplication des nombres décimaux (☞ p. 27, § 2.2).
6
1.2. Les flottants
V Bonnes pratiques
Ne pas faire de test d’égalité entre deux flottants : préférer un test d’inégalité sur leur
différence :
>>> 0.1 + 0.2 == 0.3 >>> abs(0.3 - (0.1 + 0.2)) < 1e-12
False True
L’artefact ci-dessus s’explique par le fait que le résultat de l’addition 0.1 + 0.2 sur les
flottants n’a pas la même représentation binaire que la valeur 0.3. Il est possible d’accéder en
Python à la représentation binaire (ou hexadécimale) des flottants via le module float : 0.1,
0.2 et 0.3 sont représentés sous forme de série hexadécimale infinie et le résultat de l’addition
est alors soumis à une erreur d’arrondi qui biaise le test d’égalité.
>>> 0.1 + 0.2 >>> [Link](0.1 + 0.2)
0.30000000000000004 '0x1.3333333333334p-2'
>>> [Link](0.1) # 0.0999999... # arrondi supérieur: finit par un 4
'0x1.999999999999ap-4' >>> [Link](0.3)
>>> [Link](0.2) # 0.1999999... '0x1.3333333333333p-2'
'0x1.999999999999ap-3' # arrondi inférieur: finit par un 3
Python permet par ailleurs d’afficher une chaîne de caractères (☞ p. 8, § 1.3) représentant
la valeur des flottants manipulés avec une précision suffisante pour voir l’effet de ces artefacts :
>>> print("{0:.32f}".format(0.1))
0.10000000000000000555111512312578 >>> print("{0:.32f}".format(0.3))
>>> print("{0:.32f}".format(0.2)) 0.29999999999999998889776975374843
0.20000000000000001110223024625157
Pour pallier ces problèmes potentiels, les modules fractions et decimal (☞ p. 27, § 2.2) en
Python permettent de manipuler des nombres décimaux avec une précision potentiellement
infinie sans subir les effets d’arrondi sur la représentation des nombres flottants. En revanche,
ces modules coupent la portabilité avec les autres langages de programmation.
Enfin l’arithmétique des nombres complexes (type complex) est accessible : la partie ima-
ginaire d’un complexe s’exprime à l’aide du suffixe j :
>>> 1j
>>> a = 3 + 4j
1j
>>> [Link], [Link]
>>> 1j * 1j
(3.0, 4.0)
(-1+0j)
>>> a * [Link]()
>>> [Link](-1)
(25+0j)
1j
7
1. Types et arithmétique de base
9 Attention !
Ne pas confondre le symbole j, qui peut représenter une variable, du suffixe j qui s’ad-
joint à la fin d’une valeur numérique entière ou flottante :
>>> j
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
NameError: name 'j' is not defined
Chaque chaîne de caractères a une longueur. On peut concaténer deux chaînes de carac-
tères (opérateur +), les répéter (opérateur *) et les indexer (opérateur []). L’indexation peut
être faite à l’aide d’un entier positif (le premier élément est indexé [0]), négatif (« en partant
de la fin ») ou à l’aide d’un intervalle (type slice ☞ p. 13, § 1.5) : la syntaxe [2:4] signifie « de
2 à 4 (exclus) » ; [-4:] signifie « les quatre derniers » ; [:] reprend l’intégralité du conteneur.
>>> "bon" + 'jour' >>> a[0]
'bonjour' 'B'
>>> a = """Bonjour >>> a[2:4]
... à tous""" 'nj'
>>> a >>> a[-4:]
'Bonjour\nà tous' 'tous'
>>> (a + ' ') * 2 >>> a[:]
'bonjour bonjour ' 'Bonjour\nà tous'
8
1.3. Les chaînes de caractères
>>> a = "bonjour"
>>> " bonjour ".strip()
>>> len(a)
'bonjour'
6
>>> "bonjour à tous".split()
>>> [Link]("bon")
['bonjour', 'à', 'tous']
True
>>> 'bonjour les amis'.title()
>>> [Link]("jour")
'Bonjour Les Amis'
True
9 Attention !
On ne peut pas éditer une variable de type str sans en créer une nouvelle. On dit que le
type str est immutable.
>>> a[2] = "b"
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: 'str' object does not support item assignment
Tout objet Python (☞ p. 211, § 15) a deux représentations sous forme de chaîne de carac-
tères : une forme courte à laquelle on accède avec l’opérateur repr() et une longue avec le
constructeur str(). Si l’une des deux représentations n’est pas définie, l’autre est appelée par
défaut. La représentation par défaut comprend le nom du type de l’objet et l’adresse de son
emplacement en mémoire.
>>> object()
<object object at 0x102c0a0d0>
Bien que ces formatages restent utiles, les f-strings facilitent grandement la lisibilité du
code écrit. Quelques facilités ont été ajoutées depuis la version 3.8 (PEP 498), notamment avec
la possibilité d’utiliser le symbole « = » qui affiche le nom d’une variable suivi de sa valeur.
>>> nom = "pi"
>>> valeur = 3.14159
>>> f"La valeur de {nom:>3s} est {valeur:.4f}"
'La valeur de pi est 3.1416'
>>> f"{nom=:>3s} {valeur=:.4f}" # depuis Python 3.8
'nom= pi valeur=3.1416'
3. Voir la section « Syntaxe de formatage de chaîne »
[Link]
4. Voir la section « Chaînes de caractères formatées littérales »
[Link]
9
1. Types et arithmétique de base
Le type bytes est celui qui ressemble le plus aux chaînes de caractères des langages de
programmation classiques : chaque caractère est représenté en mémoire par un entier entre 0
et 127 qui correspond à un caractère connu de la table ASCII. Les caractères associés à chaque
lettre et/ou chaque chiffre sont consécutifs dans cette table, ce qui permet de passer aisément
de la représentation d’un chiffre à l’entier associé.
9 Attention !
Les bytes ne fonctionnent pas avec des caractères accentués. On peut en revanche en-
coder une chaîne de caractères accentués en tableau de bytes.
>>> b"accentué"
Traceback (most recent call last):
File "<stdin>", line 1
SyntaxError: bytes can only contain ASCII literal characters.
>>> "accentué".encode('utf8')
b'accentu\xc3\xa9'
On mentionne ici l’encodage UTF-8 des caractères Unicode, adopté par défaut par Py-
thon, compatible avec l’encodage des 128 premiers caractères ASCII. Au-delà, les carac-
tères couramment utilisés dans le monde, qu’ils soient basés sur des systèmes d’écriture
alphabétiques, syllabaires ou pictographiques (emoji compris) sont encodés sur un, deux,
trois ou quatre octets. Dans l’exemple ci-dessus, le caractère accentué « é » est encodé
sur deux octets : c3 et a9.
9 Attention !
Comme les chaînes de caractères, les bytes sont immutables : le type bytearray en re-
vanche permet les modifications.
>>> b"hello"[1] = 97
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: 'bytes' object does not support item assignment
>>> a = bytearray(b"hello")
>>> a[1] = 97
>>> a
bytearray(b'hallo')
10
1.4. Les tuples
La manipulation des puissances de 10 est ici maladroite : bien que Python propose
l’opérateur ** pour la puissance, les opérations de multiplication (on en compte dix ici !)
ont bien lieu. Le schéma de Horner réduit le nombre de multiplications en réécrivant la
décomposition :
((1 × 10 + 2) × 10 + 3) × 10 + 4
result = 0
for digit in elt:
result = result * 10 + (ord(digit) - ord("0"))
return result
>>> horner("1234")
1234
On peut accéder aux éléments d’un tuple par un index positionnel. En revanche, à l’instar
des chaînes de caractères, la structure est immutable : il n’est pas possible de modifier un
élément d’un tuple sans en créer un nouveau.
>>> latlon[0]
43.6
>>> latlon[1] = 144.35
Traceback (most recent call last):
...
TypeError: 'tuple' object does not support item assignment
11
1. Types et arithmétique de base
Le tuple permet de concaténer des éléments pour donner une sémantique à chaque élément
en fonction de sa position. Cette sémantique doit être connue lorsqu’on procède à un déballage
(tuple unpacking en anglais) qui permet d’associer chaque valeur du tuple à une variable sans
passer par l’opérateur d’indexation [0], [1], etc.
Le déballage requiert autant d’éléments dans la partie gauche que dans la partie droite du
signe égal. Si tous les champs ne sont pas nécessaires à gauche, on utilise généralement la
variable muette « _ ». L’opérateur préfixe « * » permet de grouper plusieurs variables.
>>> latitude, longitude = torre_de_belem
Traceback (most recent call last):
...
ValueError: too many values to unpack (expected 2)
>>> latitude, longitude, _, _ = torre_de_belem
>>> *latlon, nom, _ = torre_de_belem
>>> nom
'Torre de Belém'
>>> latlon
[38.6916, -9.216]
Le type range est également un type séquentiel ⁵ et prend trois paramètres : une valeur de
début (incluse), une valeur de fin (exclue) et un pas (par défaut, 1). Le choix d’inclure la valeur
de début et d’exclure la valeur de fin de l’intervalle permet d’avoir un lien immédiat entre la
taille de la séquence et les bornes de l’intervalle : range(5) produira 5 éléments.
5. Avant Python 3, le mot-clé range renvoyait une liste : comme ce mot-clé sert essentiellement à démarrer des
itérations, il pouvait être coûteux en espace mémoire de produire une liste d’un million d’entiers pour une boucle à
exécuter un million de fois : range permet de produire les valeurs une par une à chaque itération.
12
1.5. Les listes
À l’instar des chaînes de caractères (☞ p. 8, § 1.3), les listes peuvent être indexées par
des entiers positifs (ou négatifs) et par des intervalles (type slice). Un slice se reconnaît
au caractère « : » placé entre des crochets, mais cette notation cause une erreur de syntaxe
(SyntaxError) si on cherche à la placer dans une variable. La syntaxe slice en revanche, qui
accepte les mêmes paramètres qu’un range, fonctionne aussi entre les crochets.
>>> a[:] >>> a[slice(None)]
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9] [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
>>> a[::2] >>> a[slice(None, None, 2)]
[0, 2, 4, 6, 8] [0, 2, 4, 6, 8]
>>> a[::-1] >>> a[slice(None, None, -1)]
[9, 8, 7, 6, 5, 4, 3, 2, 1, 0] [9, 8, 7, 6, 5, 4, 3, 2, 1, 0]
>>> a[2:7] >>> a[slice(2, 7)]
[2, 3, 4, 5, 6] [2, 3, 4, 5, 6]
Les compréhensions de liste sont une spécificité du langage Python, destinée à construire
une liste en itérant sur une expression littérale. Dans sa forme la plus simple, elle crée une liste
à partir d’une autre structure qui permet l’itération (☞ p. 195, § 14) : parmi les structures déjà
présentées, ceci s’applique aux range, list, tuple, aux chaînes de caractères, et aux bytes.
13
1. Types et arithmétique de base
Un set peut être modifié en ajoutant ou supprimant des valeurs. L’arithmétique des en-
sembles est accessible à l’aide des opérateurs usuels pour l’union |, l’intersection & et la diffé-
rence -. L’opérateur + n’est pas défini.
>>> s >>> s1 = set(range(3))
{1, 2, 3} >>> s2 = set(range(0, -3, -1))
>>> [Link](4) >>> s1, s2
>>> s ({0, 1, 2}, {0, -1, -2})
{1, 2, 3, 4} >>> s1 | s2 # union
>>> [Link](4) {0, 1, 2, -2, -1}
>>> s >>> s1 & s2 # intersection
{1, 2, 3} {0}
>>> [Link]() >>> s1 - s2 # différence
1 {1, 2}
Toutes les valeurs Python ne peuvent pas être ajoutées à un set : il faut qu’elles soient
hashables. Les ensembles sont en réalité des tables de hash : ils sont basés sur des fonctions
capables de (rapidement) transformer une valeur en un entier. Cette propriété permet aux
sets d’être très performants pour tester l’appartenance d’une valeur à un ensemble. Or cette
propriété a un prix : pour pouvoir définir une fonction de hash, la structure doit a minima ne
pas être mutable, ou modifiable.
9 Attention !
On ne peut pas créer d’ensemble de listes, ni d’ensemble d’ensembles : ces structures ne
sont pas hashables.
>>> {{1}, {2, 3}}
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: unhashable type: 'set'
14
1.7. Les dictionnaires
Le type frozenset est la version immutable, ou non modifiable, de l’ensemble. Il n’est pas
possible d’y ajouter des valeurs après sa création, mais cette structure étant hashable, on peut
l’insérer dans un ensemble :
Le crible d’Ératosthène. L’exercice, bien connu des écoles primaires, consiste à énu-
mérer les nombres premiers inférieurs à un entier donné après avoir éliminé successi-
vement les multiples de 2, puis de 3, et ainsi de suite.
L’arithmétique des ensembles est particulièrement adaptée à ce problème.
def crible_eratosthene(n: int) -> set:
"Énumère les nombres premiers inférieurs à n."
On peut utiliser l’opération .get() qui permet de définir une valeur par défaut si une clé
n’est pas présente dans le dictionnaire :
>>> altitude = [Link]("altitude", 0) # altitude = 0
15
1. Types et arithmétique de base
On peut itérer sur les clés d’un dictionnaire (méthode .keys()) ou sur ses valeurs (méthode
.values()). La méthode .items() permet d’itérer sur des paires (des tuples) de clé et valeur.
On peut aussi créer un dictionnaire par compréhension (☞ Å, ☞ p. 13, § 1.5).
>>> [Link]()
dict_keys(['latitude', 'longitude'])
>>> [Link]()
dict_values([43.6, 144.35])
>>> # nouveau dictionnaire où les clés sont en lettres capitales Å
>>> dict(([Link](), value) for (key, value) in [Link]())
{'LATITUDE': 43.6, 'LONGITUDE': 144.35}
V Bonnes pratiques
L’opérateur préfixe ** permet de décapsuler les dictionnaires. Il est couramment utilisé
pour mettre à jour un dictionnaire ou pour en concaténer deux.
>>> {**point, "pays": "Japon"}
{'latitude': 43.6, 'longitude': 144.35, 'pays': 'Japon'}
>>> # La concaténation permet aussi de mettre à jour des champs
>>> {**point, **{"pays": "France", "longitude": 1.45}}
{'latitude': 43.6, 'longitude': 1.45, 'pays': 'France'}
>>> point | {"pays": "France", "longitude": 1.45} # en Python 3.9
{'latitude': 43.6, 'longitude': 1.45, 'pays': 'France'}
6. Nous verrons plus loin que d’autres types de variables peuvent également être appelés (☞ p. 245, § 16.2).
16
1.8. Les fonctions
tous = {
"Jules": 5, "Marie": 17, "Pierre": 21,
"Julie": 34, "André": 71, "Jacques": 80
}
def nouveau_point(
x: float, y: float = 0, /, # Â
z: float = 0, *args, # À
color: str = 'red', temperature: float = 25, **kwargs # Á
) -> dict:
7. Cette notation cause une SyntaxError dans les versions antérieures.
17
1. Types et arithmétique de base
if len(args) > 0:
print(f"À Paramètres non nommés ignorés: {args}")
if len(kwargs) > 0:
print(f"Á Paramètres nommés ignorés: {kwargs}")
return {
'x': x, 'y': y, 'z': z, 'color': color, 'temperature': temperature,
}
>>> nouveau_point(1, 2, 3)
{'x': 1, 'y': 2, 'z': 3, 'color': 'red', 'temperature': 25}
>>> nouveau_point(y=2, x=1) # Â
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: nouveau_point() missing 1 required positional argument: 'x'
>>> nouveau_point(1, z=3)
{'x': 1, 'y': 0, 'z': 3, 'color': 'red', 'temperature': 25}
>>> nouveau_point(1, 2, 3, "blue")
À Paramètres non nommés ignorés: ('blue',)
{'x': 1, 'y': 2, 'z': 3, 'color': 'red', 'temperature': 25}
>>> nouveau_point(1, temperature=12, color="blue")
{'x': 1, 'y': 0, 'z': 0, 'color': 'blue', 'temperature': 12}
>>> nouveau_point(1, temperature=12, color="blue", size=5)
Á Paramètres nommés ignorés: {'size': 5}
{'x': 1, 'y': 0, 'z': 0, 'color': 'blue', 'temperature': 12}
V Bonnes pratiques
Les mots-clés *args et **kwargs sont couramment utilisés pour passer des arguments
supplémentaires à une fonction appelée dans le corps de la fonction.
def nouveau(fonction, x: float, y: float, *args, **kwargs):
"Crée un nouvel élément défini par 'fonction'."
return fonction(x, y, *args, **kwargs)
>>> nouveau(nouveau_point, 1, 2, color="blue")
{'x': 1, 'y': 2, 'z': 0, 'color': 'blue', 'temperature': 25}
Une autre manière de faire est de passer des arguments à passer à une fonction interne
sous forme de tuple et de dictionnaire, et de les décapsuler avec les opérateurs préfixe *
et **. Cette manière est particulièrement utile pour distinguer des paramètres à passer
à plusieurs fonctions internes.
def nouveau(fonction, fn_args: tuple, fn_kwargs: dict):
"Crée un nouvel élément défini par 'fonction'."
return fonction(*fn_args, **fn_kwargs)
>>> nouveau(nouveau_point, fn_args=(1, 2), fn_kwargs=dict(color="blue"))
{'x': 1, 'y': 2, 'z': 0, 'color': 'blue', 'temperature': 25}
18
1.9. Les exceptions
9 Attention !
Éviter d’utiliser des types mutables pour les arguments par défaut d’une fonction : des
effets indésirables peuvent apparaître. En effet, les arguments par défaut sont créés au
moment où la fonction est déclarée. Si un appel de fonction modifie cet argument par
défaut, il restera modifié :
def new_dict(original_data: dict = dict(), **kwargs) -> dict:
for key, value in [Link]():
original_data[key] = value
return original_data
>>> new_dict(color="red", value=1)
{'color': 'red', 'value': 1}
>>> new_dict(length=12)
{'color': 'red', 'value': 1, 'length': 12}
Il est préférable d’utiliser la valeur par défaut None et de créer la valeur mutable dans le
corps de la fonction (☞ Ã).
def update_dict(original_data = None, **kwargs) -> dict:
if original_data is None: # Ã
original_data = dict()
for key, value in [Link]():
original_data[key] = value
return original_data
>>> update_dict(color="red", value=1)
{'color': 'red', 'value': 1}
>>> update_dict(length=12)
{'length': 12}
19
1. Types et arithmétique de base
>>> int("123a")
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
ValueError: invalid literal for int() with base 10: '123a'
>>> point = {'x': 1, 'y': 2}
>>> point['z']
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
KeyError: 'z'
L’instruction raise permet de lever une exception, et les blocs try/catch permettent de les
rattraper. En cas de doute sur l’exception à rattraper, il convient d’utiliser le type Exception.
def pgcd(a: int, b: int) -> int:
"""Calcul du PGCD de deux entiers.
20
1.9. Les exceptions
V Bonnes pratiques
Rattraper une exception est un mécanisme coûteux. Il est préférable de réserver les ex-
ceptions pour des situations exceptionnelles, pour éviter un crash du programme.
L’utilisation des exceptions reste une bonne pratique :
— une construction try/except est plus rapide qu’un branchement classique (l’ins-
truction if) si l’exception n’est pas levée ;
— néanmoins, l’exception ne doit remplacer un test if si la branche de code est
conçue pour exécuter un traitement courant.
En quelques mots…
Le langage Python fournit par défaut un grand nombre de structures de base, chacune
vient avec ses atouts et ses limitations. Un mécanisme de gestion des erreurs, ou d’ex-
ceptions, garantit la bonne marche de l’exécution des programmes quand ceux-ci ne sont
pas utilisés dans le cadre pour lequel ils ont été écrits.
Le chapitre Structures de données avancées (☞ p. 49, § 4) présente des structures de
données plus complètes que nous aborderons après avoir exploré plus en profondeur la
bibliothèque standard (☞ p. 23, § 2).
Pour aller plus loin
— Modern Python Dictionaries : A confluence of a dozen great ideas
Raymond Hettinger, [Link]
21
2
La bibliothèque Python standard
U
n des attraits majeurs du langage Python repose sur la richesse de sa bibliothèque
standard ¹ et des bibliothèques publiées par sa communauté d’utilisateurs. Ce chapitre
présente quelques-unes de ces pépites, notamment : les fonctions built-ins, intégrées au
langage, qui codent de manière générique les bases de l’algorithmique ; le calcul fractionnaire
et décimal, qui permet de s’affranchir des limitations du standard IEEE 754 pour les flottants
(☞ p. 6, § 1.2) ; et la gestion de l’introspection, qui permet à tout programme de connaître, lors
de l’exécution, tout de l’environnement d’exécution (l’architecture de l’ordinateur) et du code
tel qu’il a été écrit par son auteur.
Parmi ces built-ins, certains sont remarquables par leur mode de fonctionnement qui re-
pose fortement sur le caractère dynamique du langage et les propriétés des valeurs passées
en paramètres, appelées également protocoles (☞ p. 235, § 16). Si l’opération est impossible à
exécuter, une exception (☞ p. 19, § 1.9) est levée.
1. On dit à ce titre que Python est livré « avec les piles » (batteries included).
2. Cette pratique est bien sûr fortement déconseillée, mais elle permet d’assurer la rétrocompatibilité du code si
des nouveaux built-ins sont ajoutés au langage.
23
2. La bibliothèque Python standard
La fonction sum utilise l’opérateur « + » (aussi accessible sous forme de fonction add dans le
module operator) pour sommer tous les éléments d’une séquence.
Par défaut, l’élément neutre est l’entier 0 qui doit pouvoir être ajouté avec les éléments de la
séquence. Si cette opération n’est pas définie, on peut définir un nouvel élément neutre valide
vis-à-vis des valeurs de la séquence via l’argument start :
>>> import operator
>>> [Link](1, 2)
3
>>> sum([1, 2, 3])
6
>>> sum(range(100))
4950
>>> sum([[1], [2, 3]])
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: unsupported operand type(s) for +: 'int' and 'list'
>>> sum([[1], [2, 3]], start=[])
[1, 2, 3]
La fonction sum peut se substituer à la fonction len pour les séquences qui n’ont pas de taille,
comme les expressions en compréhension (appelées générateurs, ☞ p. 196, § 14.1). Dans
l’exemple suivant, on compte le nombre de multiples de 3 inférieurs à 20 :
>>> len(x for x in range(20) if x % 3 == 0)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: object of type 'generator' has no len()
>>> sum(1 for x in range(20) if x % 3 == 0)
7
Les fonctions min et max renvoient le plus petit (ou plus grand) élément d’une séquence où
tous les éléments sont comparables les uns avec autres via l’opérateur « < » (aussi accessible
sous forme de fonction lt, pour less than, dans le module operator) . Il est possible de définir
un argument par défaut pour éviter une exception si la séquence d’entrée est vide. La valeur
None est alors souvent choisie comme argument par défaut.
>>> max([3, 7, 5, 9])
9
>>> min([])
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
ValueError: min() arg is an empty sequence
>>> min([], default=None) # renvoie None
24
2.1. Les built-ins du langage
La fonction sorted renvoie une liste à partir d’une séquence où tous les éléments sont com-
parables les uns avec les autres. L’argument key permet de préciser l’opération de comparaison
si celle-ci n’est pas définie ou si on souhaite en définir une autre :
>>> sorted([1, 7, 3, 5])
[1, 3, 5, 7]
>>> sorted([1, 1+2j, 3j])
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: '<' not supported between instances of 'complex' and 'int'
>>> sorted([1, 1+2j, 3j], key=abs)
[1, (1+2j), 3j]
Par défaut, l’opération de comparaison entre deux tuples compare les premiers éléments,
puis les deuxièmes en cas d’égalité, et ainsi de suite. Dans l’exemple ci-dessous, l’appel à la
fonction itemgetter(1) du module operator crée une fonction qui associe x[1] à x : c’est une
alternative à la notation anonyme lambda x: x[1].
>>> sorted([("Pierre", 30), ("Paul", 40)])
[('Paul', 40), ('Pierre', 30)]
>>> from operator import itemgetter
>>> sorted([("Pierre", 30), ("Paul", 40)], key=itemgetter(1))
[('Pierre', 30), ('Paul', 40)]
Les fonctions all et any renvoient True ou False après avoir évalué les éléments d’une sé-
quence : all renvoie True si tous les élements sont vrais, ou False après avoir rencontré le pre-
mier élément faux, any renvoie True après avoir rencontré le premier élément vrai, ou False si
tous les éléments sont faux. Ces fonctions s’appliquent à tout objet qui peut s’évaluer comme
un booléen (la fonction bool) :
>>> all([True, 3 > 2, 1 in range(5)])
True
>>> any([False, None, 0, 1, len("coucou") == 0])
True
>>> bool(None)
False
>>> bool(0), bool(1)
(False, True)
La fonction next permet d’accéder à l’élément suivant d’une structure itérable. Elle rem-
place le schéma for/break. Il est possible de définir un argument par défaut pour éviter une
exception si la séquence d’entrée est vide (souvent None) :
>>> for i in range(1, 20):
... if i % 3 == 0:
... break
>>> next(i for i in range(1, 20) if i % 3 == 0)
3
>>> next(i for i in range(1, 20) if i % 100 == 0)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
StopIteration
>>> next((i for i in range(1, 20) if i % 100 == 0), None) # renvoie None
25
2. La bibliothèque Python standard
9 Attention !
Les programmeurs débutants ont souvent tendance à exprimer des indices manuelle-
ment pour itérer sur plusieurs structures à la fois :
resultat = []
for i in range(len(français)):
[Link]((i, français[i], anglais[i]))
# ou alors
i = 0
for f in francais:
[Link]((f, anglais[i]))
i = i + 1
Cette façon de faire peut paraître inoffensive sur les exemples illustratifs choisis pour ce
livre. Il convient néanmoins d’entendre les arguments suivants :
— problème de lisibilité et de portée des variables : on ajoute des variables avec
des noms souvent peu explicites (i, j, etc.) dans un espace de nommage qui dé-
borde de son cadre d’application ;
— problème de gestion des erreurs : rien n’est prévu ici pour l’hypothèse où la
variable français a une longueur supérieure à celle de anglais ;
— problème d’occupation mémoire : on crée des listes potentiellement volumi-
neuses alors que l’utilisateur final pourrait n’avoir besoin que des premiers élé-
ments ;
— problème de performance : chaque appel à l’élément d’indice [i] est coûteux
en Python. Il implique en effet un grand nombre de vérifications sur la nature
de i, avec notamment les bornes de l’intervalle concerné. L’itération sur tous les
éléments est plus parcimonieuse.
26
2.2. Les fonctions mathématiques
Nous avons vu que les flottants (☞ p. 6, § 1.2) étaient soumis à des erreurs d’arrondis dus
à leur représentation dans le standard IEEE 754. Si ces erreurs d’arrondis sont critiques, les
modules fractions et decimal permettent de s’affranchir de ces contraintes.
>>> from fractions import Fraction
>>> Fraction(.1) # le flottant IEEE 754 pour 0.1 peut être approché par
Fraction(3602879701896397, 36028797018963968)
>>> Fraction(1, 10) + Fraction(2, 10)
Fraction(3, 10)
>>> Fraction(".1") + Fraction(" 1/5")
Fraction(3, 10)
>>> from decimal import Decimal
>>> Decimal(.1) # le flottant IEEE 754 pour 0.1 vaut en réalité
Decimal('0.1000000000000000055511151231257827021181583404541015625')
>>> Decimal(".1")
Decimal('0.1')
>>> Decimal(".1") + Decimal(".2") == Decimal(".3")
True
Toutes les fonctions du module random sont basées sur la fonction random qui tire un nombre
pseudo-aléatoire de manière uniforme dans l’intervalle [0, 1[ (ouvert à droite) à l’aide du gé-
nérateur Mersenne Twister, qui possède un bon compromis entre la qualité du tirage aléatoire
et la performance. Il n’est pas sûr de l’utiliser dans un contexte cryptographique.
>>> from random import random, randint, choice, sample, shuffle
>>> random() # la fonction de base, équivalent à uniform(0, 1)
0.5251274178544894
>>> randint(0, 4) # tirage d'un entier, bornes incluses
3
>>> sample(range(5), 3) # on tire trois éléments, sans remise
[4, 0, 3]
>>> list(randint(0, 4) for _ in range(3)) # avec remise
[4, 1, 1]
>>> elts = list(range(10))
>>> shuffle(elts) # mélange
>>> elts
[3, 0, 8, 2, 7, 5, 6, 4, 9, 1]
27
2. La bibliothèque Python standard
Pour manipuler le temps, on utilise plutôt le module datetime qui comprend trois sous-
modules : datetime (la gestion des timestamps, c’est-à-dire des nombres de secondes depuis
l’epoch), timedelta (la gestion des durées) et timezone (la gestion des fuseaux horaires).
>>> from datetime import datetime, timedelta, timezone
9 Attention !
Par défaut, le sous-module datetime construit des dates à partir des timestamps (chacun
représentant le nombre de secondes depuis l’epoch) mais sans référence au fuseau ho-
raire : il se rapporte de manière tacite au fuseau horaire de l’ordinateur qui exécute le
code.
Ce mode de fonctionnement peut suffire si les informations temporelles ne sont pas par-
tagées en dehors du programme. Si elles doivent être partagées dans un fichier (☞ p. 41,
§ 3.4) ou dans une base de données (☞ p. 316, § 21.3), il convient alors de partager
l’information sous forme numérique (le timestamp) ou en précisant le fuseau horaire.
Tous les exemples de cet ouvrage utilisent un fuseau horaire.
28
2.3. La gestion du temps
Deux dates ne peuvent pas être ajoutées : on peut en revanche ajouter une durée (le type
timedelta) à une date pour obtenir une autre date, ou soustraire deux dates pour mesurer
la durée entre celles-ci. Il est monnaie courante de revenir à un nombre de secondes pour
manipuler des durées.
>>> datetime(2020, 12, 25) - datetime(2020, 7, 14)
[Link](days=164)
>>> delta = timedelta(hours=1, minutes=30)
>>> delta.total_seconds()
5400.0
Le type timedelta sert également à définir des fuseaux horaires, par rapport au temps
universel (UTC) du méridien de Greenwich.
>>> heure_d_hiver = timezone(timedelta(hours=1)) # en France métropolitaine
>>> datetime(2020, 12, 25, tzinfo=heure_d_hiver).isoformat()
'2020-12-25T[Link]+01:00'
Python 3.9 intègre le PEP 615 pour le support des fuseaux horaires standard. Comme in-
diqué précédemment, par défaut, Python manipule des dates sans fuseau horaire (en anglais
tz-naive) bien que le système connaisse la plupart du temps le sien. Il est toutefois préférable
d’être explicite sur le fuseau horaire (en anglais tz-aware, « conscient du fuseau horaire »).
Le module zoneinfo donne accès aux décalages par rapport au temps universel à partir des
définitions courtes (CET pour Heure d’Europe Centrale, EST pour Heure de la côte est des
États-Unis) ou longues (Europe/London, Asia/Tokyo, Canada/Pacific) du standard IANA.
>>> f"{[Link](tz=heure_d_hiver):%Z}"
'UTC+01:00'
29
2. La bibliothèque Python standard
L’exemple suivant profite du fait que les datetime sont munis d’une relation d’ordre pour
utiliser la fonction built-in sorted, et répondre à la question récurrente des journaux télévisés
chaque 31 décembre : où a-t-on déjà célébré la nouvelle année ?
timezones = [
"Africa/Sao_Tome", "America/Los_Angeles", "America/New_York",
"Asia/Hong_Kong", "Europe/Paris", "Pacific/Noumea", "Pacific/Tahiti"
]
def saint_sylvestre(tz):
return datetime(2020, 1, 1, tzinfo=ZoneInfo(tz))
Un autre avantage de la classe ZoneInfo est sa capacité à gérer le passage à l’heure d’été :
>>> t1 = datetime(2020, 3, 29, 2, 0, tzinfo=ZoneInfo("Europe/Paris"))
>>> [Link]() # Central European Time (CET) -- heure d'hiver
'CET'
>>> t2 = datetime(2020, 3, 29, 3, 0, tzinfo=ZoneInfo("Europe/Paris"))
>>> [Link]() # Central European Summer Time (CEST) -- heure d'été
'CEST'
30
2.4. Les expressions régulières
Si on recherche plusieurs éléments dans une chaîne de caractères, la méthode group permet
de séparer plusieurs motifs au sein d’une expression régulière. Par exemple, dans l’exemple
suivant, on peut extraire l’indicatif pays, l’indicatif régional et le reste d’un numéro de télé-
phone :
>>> m = [Link](r"\+(\d+) (\d+) ([\d\s]+)", "+33 5 12 34 56 78")
>>> [Link](1), [Link](2), [Link](3)
('33', '5', '12 34 56 78')
Enfin, la fonction finditer permet de trouver toutes les sous-chaînes de caractères qui
valident un motif. On peut rechercher par exemple tous les adverbes d’un texte :
>>> texte = "Il s'est habilement déguisé, mais on l'a promptement capturé."
>>> list([Link]() for m in [Link](r"\w+ment", texte))
['habilement', 'promptement']
3. [Link]
4. Pour Cascaded Style Sheet, « feuille de style en cascade ».
31
2. La bibliothèque Python standard
>>> pgcd(12, 8)
3
>>> pgcd("4", 2.4) # conversion en entier
2
>>> pgcd(12, -8)
Traceback (most recent call last):
...
ValueError: Les deux entiers doivent être positifs
"""
a, b = int(a), int(b)
if (a < 0 or b < 0):
raise ValueError("Les deux entiers doivent être positifs")
while a != b:
if (a > b):
a = a - b
else:
b = b - a
return a
Le module doctest permet de vérifier de manière automatique que les traces présentes dans
la documentation de la fonction sont correctes. Tous les appels sont exécutés et le résultat
est comparé à la sortie de la documentation. Dans cet exemple du calcul de PGCD, c’est la
documentation qui est erronée :
>>> import doctest
>>> [Link]() # trouve toutes les fonctions dans l'espace de nommage
**********************************************************************
File "__main__", line 5, in __main__.pgcd
Failed example:
pgcd(12, 8)
Expected:
3
Got:
4
**********************************************************************
1 items had failures:
1 of 3 in __main__.pgcd
***Test Failed*** 1 failures.
TestResults(failed=1, attempted=3)
32
2.6. L’introspection
2.6. L’introspection
Une des forces du langage Python vient de sa capacité à avoir une connaissance exhaustive
de l’environnement qu’il propose, de la plateforme sur laquelle il est exécuté, des objets qu’il
manipule, et du code source qu’il exécute.
Au lancement de l’interpréteur Python, un en-tête précise des informations dont Python a
connaissance au démarrage : dans les exemples ci-dessous, on retrouve différentes versions de
Python (3.6.9, 3.8.3 ou 3.9.0b1 pour une version beta), différents compilateurs (gcc, clang)
et plateformes (Linux, darwin pour MacOS).
Les modules et objets Python sont également capables de lister les fonctions qu’ils ex-
posent, il est possible d’accéder à ces méthodes à partir de leur nom représenté sous forme de
chaîne de caractères :
>>> dir(str)
['__add__', '__class__', '__contains__', ... 'upper', 'zfill']
>>> dir(list)
['__add__', '__class__', '__contains__', ... 'reverse', 'sort']
>>> dir(3.14)
['__abs__', '__add__', '__bool__', ... 'is_integer', 'real']
>>> getattr(float, 'is_integer')
<method 'is_integer' of 'float' objects>
>>> getattr(float, 'is_integer')(3.10)
False
Les variables actuellement chargées dans l’interpréteur sont également accessibles via des
fonctions built-ins, comme globals() pour les variables globales ou locals() pour les variables
locales à une fonction.
33
2. La bibliothèque Python standard
34
2.6. L’introspection
Nous avons vu que le paramètre __code__ renvoyait un objet code. Cet objet contient le
bytecode, ou code intermédiaire, de la fonction, c’est-à-dire une représentation binaire efficace
de la fonction utilisée par l’interpréteur lors de l’exécution. Cette représentation est lisible par
tous les interpréteurs d’une version donnée de Python, quelle que soit la plateforme utilisée
(Linux, MacOS, Windows). Le module dis (pour disassembling) est capable de transformer
cette séquence binaire en séquence d’instructions lisibles :
>>> def inverse(a: float) -> float:
... return 1 / a
>>> inverse.__code__.co_code
b'd\x01|\x00\x1b\x00S\x00'
>>> import dis
>>> [Link](inverse.__code__)
2 0 LOAD_CONST 1 (1)
2 LOAD_FAST 0 (a)
4 BINARY_TRUE_DIVIDE
6 RETURN_VALUE
Le code de cette fonction est ici assez clair : en ligne 2, une constante (1) est chargée sur la
pile, suivie de la variable a, puis on exécute l’opération BINARY_TRUE_DIVIDE avant de retourner
le résultat. Cette fonctionnalité de Python est peu utilisée dans du code en production : elle
peut en revanche se révéler utile pour comprendre comment fonctionne le langage.
Les capacités d’introspection de Python peuvent également servir lorsque des exceptions
sont levées. Dans le morceau de code suivant, la fonction inverse est appelée de nombreuses
fois sur des entiers tirés au hasard au sein d’une compréhension de liste. Une exception est
levée par un des appels mais, dans ce cas, il est difficile de savoir lequel.
>>> [inverse(randint(0, 500)) for _ in range(10_000)]
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "<stdin>", line 1, in <listcomp>
File "<stdin>", line 2, in inverse
ZeroDivisionError: division by zero
L’exception levée montre un Traceback, c’est-à-dire l’ensemble des couches par lequel le
programme est passé avant de lever une exception. On lit alors que le programme a com-
mencé ligne 1, puis a démarré une compréhension de liste <listcomp> ligne 1 également, et,
lors d’un appel de la fonction inverse, ligne 2 du code de la fonction, il a levé une exception
ZeroDivisionError ⁵.
Python permet alors d’explorer plus en détail cette pile d’appel. Le module sys permet de
rappeler le dernier Traceback rencontré. Un tb_frame représente une couche d’appel. On peut
alors passer à la couche inférieure avec l’argument tb_next.
>>> tb = sys.last_traceback
>>> tb.tb_frame
<frame at 0x108907860, file '<stdin>', line 1, code <module>>
>>> tb.tb_next.tb_frame
<frame at 0x1088ed200, file '<stdin>', line 1, code <listcomp>>
>>> tb.tb_next.tb_next.tb_frame
5. Bien entendu, l’erreur n’est pas toujours aussi explicite que dans cet exemple.
35
2. La bibliothèque Python standard
Une fois arrivé à la couche d’appel problématique, on peut alors retrouver les objets qui
représentent le code de la fonction, et surtout le dictionnaire qui contient les variables telles
qu’elles ont été passées à notre fonction. On voit ici qu’avec une valeur a=0, le programme
échoue.
Il est ainsi plus facile d’isoler la cause de l’exception en connaissant les valeurs qui lèvent
une exception dans l’exécution de la fonction. Une fois le problème résolu, il est alors recom-
mandé d’ajouter un test unitaire pour ce cas particulier ( ☞ p. 32, § 2.5, ☞ p. 360, § 26.2).
En quelques mots…
Python est livré batteries included, « avec les piles ». De nombreux modules et fonctions
offrent au programmeur toute l’arithmétique et les structures de base, pour un vaste éven-
tail de possibilités. De nombreuses fonctions built-ins ne sont pas basées sur le type des
objets passés en paramètres mais sur leurs spécifications, ou protocoles, comme le proto-
cole itérable (☞ p. 195, § 14) ou comparable.
Python offre de nombreuses facilités standard au programmeur comme les timestamps
pour la gestion du temps, les expressions régulières, les tests unitaires et la documenta-
tion. Le mécanisme d’introspection intégré au langage est particulièrement avancé et
permet à Python de savoir exactement tout de la version qui l’exécute, de l’environne-
ment sur lequel il est exécuté, et de ce qu’il est capable de faire ou non.
36
3
La gestion des fichiers
L
orsqu’un programme est chargé en mémoire pour être exécuté, son environnement est
volatil. Toutes les variables sont créées par le programme et sont détruites une fois
l’exécution terminée. Les interactions avec le monde extérieur se font par un méca-
nisme d’entrées et de sorties : un programme peut lire et écrire des informations au moyen
d’interfaces. On compte parmi ces interfaces :
— la console (entrée standard, sortie standard et sortie d’erreur),
— les tubes de communication (en anglais pipe),
— les sockets de communication réseau,
— et surtout les fichiers, supports de stockage textuels ou binaires.
Tous ces mécanismes d’interaction ont une structure similaire basée sur l’itération : le
chargement de l’intégralité d’un fichier en mémoire pour le lire ou l’écrire étant souvent su-
perflu, il est courant de lire et écrire celui-ci dans ces interfaces de manière séquentielle.
37
3. La gestion des fichiers
Les attributs suivants permettent de manipuler différents attributs d’un chemin : le nom
du fichier (name), sans son extension (stem), ou uniquement l’extension (suffix).
>>> todo = (livre / "[Link]")
>>> [Link]
PosixPath('Documents/Livre Python')
>>> [Link]
'[Link]'
>>> [Link]
'todo'
>>> [Link]
'.txt'
>>> todo.with_suffix(".docx")
PosixPath('Documents/Livre Python/[Link]')
Quand les fichiers sont suffisamment petits pour être entièrement chargés sans saturer la
mémoire de l’ordinateur, les méthodes .read_text() et .write_text() sont adaptées pour lire
ou écrire l’intégralité du contenu textuel d’un fichier.
>>> contenu = "Liste des chapitres à écrire"
>>> todo.write_text(contenu) # Écriture rapide dans un fichier
28
>>> todo.read_text() # Lecture rapide dans un fichier
'Liste des chapitres à écrire'
>>> todo.is_file()
True
Ces méthodes qui manipulent des chaînes de caractères str (☞ p. 8, § 1.3) sont réservées
aux fichiers textuels. Dans le cas général (fichier textuel ou binaire) qui fait appel au type
bytes (☞ p. 10, § 1.3), les méthodes correspondantes sont .read_bytes() et .write_bytes().
Dans l’exemple suivant, on peut lire le contenu d’un fichier image PNG, dont la signature (les
8 premiers octets) est caractéristique.
>>> logo = p / "[Link]"
>>> content = logo.read_bytes()
>>> content[:8]
b'\x89PNG\r\n\x1a\n'
38
3.2. Lecture et écriture séquentielles
Nota bene Le standard définit cette même signature de 8 octets pour tous les fichiers PNG : le
premier caractère notamment est situé en dehors de l’intervalle ASCII pour s’assurer qu’aucun
fichier texte ne puisse être mépris pour un fichier PNG. Ainsi avec Python, l’ouverture d’un
fichier PNG avec la méthode .read_text() lève une exception.
>>> content = logo.read_text()
Traceback (most recent call last):
...
UnicodeDecodeError: 'utf-8' codec can't decode byte 0x89 [...]
Il est également possible de lister tous les fichiers d’une arborescence qui respectent un
motif à l’aide de la méthode .glob() : le motif est défini à l’aide d’une expression régulière
(☞ p. 30, § 2.4). On peut par exemple :
À lister dans le répertoire courant tous les fichiers de configuration (à l’extension rc) ;
Á compter le nombre de fichiers dans toute l’arborescence du dossier temporaire /tmp : le
motif **/ parcourt tous les sous-dossiers du répertoire courant ;
 accéder à des informations sur le fichier : permissions, utilisateurs, groupe, dates de
création, de modification, etc.
>>> list([Link](".*rc")) # À
[PosixPath('.npmrc'), PosixPath('.wgetrc'), PosixPath('.zshrc'),
PosixPath('.condarc'), PosixPath('.vimrc')]
>>> sum(1 for f in Path("/tmp").glob("**/*") if f.is_file()) # Á
86
>>> [Link]() # Â
os.stat_result(
st_mode=33204, st_ino=14558483, st_dev=2050, st_nlink=1,
st_uid=1001, st_gid=1001, st_size=30,
st_atime=1586815012, st_mtime=1586815012, st_ctime=1586815012
)
39
3. La gestion des fichiers
p = Path("lorem_ipsum.txt")
# On lit les lignes une par une pour compter le nombre de mots
num_words = 0
with [Link]('r') as fh: # Ã
for line in fh: # Å
num_words += len([Link]())
Pour les fichiers binaires, on utilise plutôt la méthode .read(), qui prend en argument un
nombre d’octets à lire. Dans l’exemple ci-dessous, des fichiers images PNG ont malencontreu-
sement été renommés avec l’extension .jpg : le programme fait la manipulation inverse. Ici
aucun fichier n’est chargé en mémoire au-delà des 8 premiers octets, qui nous intéressent pour
déterminer le type du fichier.
for fichier in Path(".").glob("*.jpg"):
with [Link]("rb") as fh:
header = [Link](8)
if header == b'\x89PNG\r\n\x1a\n':
[Link](fichier.with_suffix(".png"))
40
3.3. Vérification de l’intégrité des fichiers
md5sum = [Link]()
3.4. Sérialisation
La sérialisation est une opération qui permet de représenter un objet Python sous une
forme qui puisse être enregistrée dans un fichier ou partagée avec d’autres processus. La dif-
ficulté de la sérialisation vient d’un compromis entre la compatibilité et la performance :
41
3. La gestion des fichiers
Le module pickle est spécifique au langage Python. Il est utilisé par le langage pour échanger
des objets entre processus. La représentation binaire (le type bytes) d’un objet Python est alors
écrite dans un fichier. Il est possible de partager ces fichiers entre ordinateurs mais la version
de Python doit être la même : un fichier pickle écrit avec Python 3.8 ne pourra pas être ouvert
avec la version 3.7 par exemple.
# écriture dans un fichier pickle # lecture des objets
with Path("[Link]").open('wb') as fh: with Path("[Link]").open('rb') as fh:
[Link](elt1, fh) elt1 = [Link](fh)
[Link](elt2, fh) elt2 = [Link](fh)
Le module json permet de lire et écrire des fichiers au format JSON (JavaScript Object Nota-
tion), un format de données textuel qui permet de représenter de l’information structurée. Des
bibliothèques pour le format JSON existent dans la plupart des langages de programmation. Le
format JSON se représente naturellement en Python à l’aide de dictionnaires, de listes et de
valeurs génériques : chaîne de caractères, nombres (entiers, flottants) et booléens (en minus-
cule en JSON), et la valeur vide None (null en JSON). En revanche, le format n’accepte pas les
commentaires.
pays = {
'pays': [ import json
{'n': 'France', 'c': 'Paris'},
{'n': 'Espagne', 'c': 'Madrid'}, with Path("[Link]").open('w') as fh:
], [Link](pays, fh, indent=2)
'properties': {
'n': 'nom', 'c': 'capitale' with Path("[Link]").open() as fh:
} pays = [Link](fh)
}
V Bonnes pratiques
Une exception de type TypeError est levée si on souhaite sérialiser un objet Python
classique. Une pratique courante consiste alors à sérialiser manuellement l’objet dans
un dictionnaire avec tous les arguments qui permettent de le reconstruire.
exemple = {
...,
# 'modification': datetime(2020, 1, 1, tz=timedelta(hours=1)),
'modification': {'timestamp': 1577833200, 'timezone': '+01:00'}
}
42
3.5. Flux de données
Le module base64 permet la conversion de données binaires (le type bytes) en chaîne de
caractères qui utilise 64 caractères différents. L’intérêt de ce système est surtout de permettre
de transmettre des données binaires courantes au sein d’un fichier textuel, au format JSON par
exemple.
Dans l’exemple ci-dessous, l’utilisation du chemin vers un fichier PNG implique de partager
les fichiers images en même temps que le fichier JSON, sans contrôle sur le contenu des fichiers
images. Dans certains cas, il peut être préférable de partager un simple fichier JSON qui intègre
les images en utilisant une représentation textuelle. Cette technique est couramment utilisée
pour partager du contenu (image, son, police de caractères) sur des pages web.
import base64
Une socket est créée au sein d’un gestionnaire de contexte À, elle gère automatiquement
sa fermeture. Les paramètres (une explication complète dépasserait le cadre de cet ouvrage)
correspondent à l’utilisation du protocole TCP/IP. La socket est alors rattachée à une adresse
et un port Á. [Link] correspond à l’adresse locale de chaque machine : le service proposé
n’est alors accessible que depuis le même ordinateur. La socket est alors mise en attente de
connexion Â. Une fois qu’une connexion est initiée et acceptée Ã, on peut écrire n’importe
quelle séquence de bytes dans la socket : le contenu sera reçu par le client.
43
3. La gestion des fichiers
Une fois le programme précédent lancé, il est possible de s’y connecter depuis un autre
terminal à partir d’outils standards comme netcat ¹ :
$ nc localhost 12345
2020-12-31 [Link].997960+00:00
Il est également possible d’utiliser une socket en mode client, comme le font netcat ou tel-
net. Pour cela, il suffit de lire de manière séquentielle le contenu de la socket Æ. Le garde Ç
permet de quitter le programme quand la réception est terminée. L’auteur recommande au lec-
teur de consulter la page [Link] pour reproduire l’exemple
ci-dessous avec des services similaires.
with [Link](socket.AF_INET, socket.SOCK_STREAM) as s: # Å
[Link](("[Link]", 666))
while True:
data: bytes = [Link](256) # Æ
if len(data) == 0: # Ç
break
print([Link]()) # È
L’exemple précédent ne fait qu’afficher au fur et à mesure ce qui est reçu sur la socket. Le
contenu binaire reçu comprend des caractères de contrôle pour effacer l’écran b"\x1b[J" ou
pour placer le curseur en haut à gauche du terminal b"\x1b[H".
Dans l’exemple suivant, nous n’allons afficher que ce qui est reçu après le dernier caractère
de contrôle afin de laisser intacte l’apparence de notre terminal. Il pourrait y avoir plusieurs
manières de procéder : stocker chaque data reçu dans une liste, puis concaténer tous les élé-
ments reçus dans une nouvelle structure bytes ; ou alors écrire tous les data reçus dans un
fichier binaire puis relire le contenu du fichier.
Il est possible de concaténer de manière efficace et séquentielle du contenu binaire à l’aide
de flux de données dans le module io. Les structures les plus communes sont [Link] pour
le contenu binaire et [Link] pour le contenu textuel.
from io import BytesIO
1. Si la commande nc n’est pas accessible, il est possible de la remplacer par la commande telnet qu’il faut
avoir préalablement activée sous Windows 10 à l’aide de l’instruction suivante à taper dans l’invite de commande :
pkgmgr/iu:"TelnetClient"
44
3.6. Compression et archivage
clear_idx = [Link](b"\x1b[")
while clear_idx != -1:
# Effacement du contenu jusqu'au dernier caractère de contrôle
data = data[clear_idx + 3 :]
clear_idx = [Link](b"\x1b[")
f_countries = Path("[Link]")
countries = [Link](f_countries.read_text())
with ZipFile("[Link]", "r") as zf:
all_files = []
45
3. La gestion des fichiers
drapeau = {
"fichier": file_info.filename,
"taille_zip": file_info.compress_size,
"pays": countries[file_info.filename[:-4]],
}
lire_png(fh, drapeau) # définie plus loin
all_files.append(drapeau)
On crée ici une liste avec un dictionnaire par fichier PNG présent dans l’archive. Pour finir
d’illustrer ce chapitre, nous complétons le dictionnaire de métadonnées (nom du fichier, taille
du drapeau dans l’archive, nom du pays) avec d’autres informations présentes dans le fichier.
Le format PNG est un format binaire de représentation des images : nous avons parlé précé-
demment de sa signature unique. La structure d’un fichier PNG est très formalisée : on y trouve
des parties (appelés chunks), qui se décomposent toutes de la même manière : une taille (4 oc-
tets), un type (4 octets), des données (d’une longueur définie dans le champ de taille) et un code
correcteur (4 octets). Il existe différents types de chunks, mais tous les fichiers contiennent a
minima, un en-tête (type IHDR), des données compressées (type IDAT) et une marque de fin du
fichier (type IEND).
Pour l’archive qui contient les drapeaux du monde, nous allons lire dans le fichier binaire :
— la taille de l’image (hauteur × largeur) ;
— la taille des données compressées de l’image ².
>>> lire_entier(b"\x01\x00")
256
"""
return int.from_bytes(x, byteorder="big")
46
3.6. Compression et archivage
Sans surprise, les drapeaux les mieux compressés par le format PNG sont alors des dra-
peaux bicolores très simples (Lettonie, Monaco, Pologne) alors que les moins compressés sont
beaucoup plus stylisés, avec des armoiries complexes (Figure 3.1).
FIGURE 3.1 – Drapeaux les moins (première ligne) et les mieux compressés (deuxième ligne) par le format PNG
47
3. La gestion des fichiers
En revanche, les drapeaux les mieux compressés dans l’archive zip sont ceux qui suivent le
motif tricolore le plus courant (Figure 3.2). Ces fichiers PNG ont beau avoir une représentation
compressée cinq fois moins performantes que la Lettonie, ils sont très bien compressés dans
l’archive ZIP parce que leurs structures sont semblables : ils ne diffèrent les uns des autres que
par la couleur.
sorted(all_files, key=itemgetter("zip_ratio"))
En quelques mots…
Le langage Python permet une interaction facile avec le système de fichiers de l’ordina-
teur : lecture, écriture, compression, accès aux métadonnées. Il est possible d’automatiser
en Python toutes les tâches de gestion des fichiers que l’on a l’habitude de réaliser ma-
nuellement avec le gestionnaire de fichiers de notre système d’exploitation.
Les programmes les plus simples lisent des données à partir d’un fichier, exécutent
des opérations, puis retournent les résultats de manière structurée dans un autre fichier.
Les autres modèles d’interaction avec le monde extérieur (entrée et sortie standard, sortie
d’erreur, sockets de communication réseau) ont un mode de fonctionnement similaire à
celui des fichiers, avec des fonctions de lecture et d’écriture de chaînes de caractères
(mode texte) ou de bytes (mode binaire).
48
4
Structures de données avancées
P
ython offre une syntaxe flexible et une interface conviviale qui font la joie du program-
meur débutant. Pour les utilisateurs qui découvrent le langage, la liste (☞ p. 12, § 1.5) est
probablement la structure conteneur la plus populaire : flexible, intuitive, facile d’utili-
sation. Or il existe de meilleures options en fonction des besoins.
Nous avons abordé dans le chapitre 1 la question des structures mutables, hashables et de
l’intérêt de choisir la structure adaptée aux besoins du problème à étudier. Ce chapitre présente
des structures natives Python souvent ignorées voire inconnues qui contribuent à améliorer
la qualité du code afin qu’il soit plus facile à écrire, à lire et à maintenir.
>>> tour_eiffel = {
... "latitude": 48.85826, "longitude": 2.2945,
... "nom": "Tour Eiffel", "ville": "Paris"
... }
Le module collections propose une structure de données particulière. Le namedtuple ac-
cepte deux paramètres : le premier doit reprendre le nom de la variable dans laquelle on
stocke la structure ; le second est une chaîne de caractères qui concatène le nom de chacun des
champs, séparés par une espace.
49
4. Structures de données avancées
Monument = namedtuple( #
"Monument", from collections import namedtuple
"latitude longitude nom ville"
)
La même fonctionnalité est proposée dans le module typing (☞ p. 367, § 27) avec une
syntaxe plus engageante où les champs sont énumérés et annotés d’un type (PEP 526) :
class Monument(NamedTuple): #
latitude: float from typing import NamedTuple
longitude: float
nom: str
ville: str
La nouvelle structure Monument réunit alors le meilleur des deux mondes : les avantages du
tuple avec la sémantique du dictionnaire. Elle garantit notamment :
— que tous les champs sont renseignés ;
>>> tour_eiffel.latitude
48.85826
>>> tour_eiffel[2]
'Tour Eiffel'
>>> latitude, longitude, nom, ville = tour_eiffel
— que les champs ne peuvent pas être modifiés.
50
4.2. dataclass : classes de données
@dataclass #
class Monument: from dataclasses import dataclass
latitude: float
longitude: float
nom: str
ville: str
On peut alors déclarer un monument comme le namedtuple équivalent. Malgré des facilités
que nous allons explorer, cette structure n’offre plus les fonctionnalités caractéristiques du
tuple, à savoir l’indexation (☞ À) et le déballage (☞ Á).
La particularité des classes de données par rapport aux tuples (classiques ou non) est que
ces instances sont mutables : on peut y ajouter des attributs et les modifier.
>>> tour_eiffel.pays = "France"
>>> tour_eiffel.longitude = -54.5889 # oups!
Dans certains cas, il peut être souhaitable de contrôler si les champs peuvent être modifiés :
l’attribut frozen peut alors être passé au décorateur dataclass (☞ Â). Par ailleurs, la représen-
tation d’une classe de données est générée par défaut, on peut néanmoins choisir pour chaque
champ de l’ajouter dans la représentation ou non (☞ Ã). Parmi les autres arguments, on peut
notamment définir une valeur par défaut (☞ Ä).
from dataclasses import field
@dataclass(frozen=True) # Â
class Monument:
latitude: float = field(repr=False) # Ã
longitude: float = field(repr=False) # Ã
nom: str
ville: str
pays: str = field(repr=False, default="") # Ã, Ä
51
4. Structures de données avancées
9 Attention !
Il n’est pas possible de fournir un paramètre par défaut mutable (comme une liste ou un
dictionnaire) à une classe de données (☞ p. 19, § 1.8).
L’argument default_factory décrit alors comment créer une valeur par défaut :
class Monument:
latitude: float = field(repr=False)
longitude: float = field(repr=False)
nom: str
ville: str
visites = field(default_factory=list)
52
4.3. defaultdict : dictionnaires avec valeur par défaut
L’exemple précédent peut être adapté à l’aide d’un defaultdict(int) pour compter les
occurrences de chaque mot dans un fichier. La valeur par défaut est alors int() = 0 :
references = defaultdict(int)
for ligne in [Link]("\n"):
for mot in [Link]():
references[mot] += 1
V Bonnes pratiques
Il est possible de passer en paramètre de defaultdict n’importe quelle fonction qui pro-
duit un objet. list() produit une liste vide, set() un ensemble vide, dict() un diction-
naire vide, int() l’entier zéro (0). Pour des valeurs par défaut moins standard, on peut
utiliser une fonction ou une fonction anonyme :
>>> d = defaultdict(lambda: "default")
>>> d[0]
'default'
53
4. Structures de données avancées
54
4.5. deque : files et piles
38 68
30 55
26 27
36
17 31
15 16 14
9
10
5
3 0
1 2 3 4 5 6
2 3 4 5 6 7 8 9 10 11 12
Valeur du 2e plus petit dé
Somme des valeurs des deux dés
55
4. Structures de données avancées
Une pile est une structure LIFO (last in first out pour « dernier entré, premier sorti ») idéale
pour empiler et dépiler des éléments. Cette structure est utilisée par les ordinateurs pour or-
ganiser la hiérarchie d’appels de fonctions dans un programme informatique.
Dans l’exemple suivant, l’état de la pile est affiché à chaque étape de l’évaluation de
l’expression qui s’écrirait 3 + (1 + 2) × 4 :
>>> polonaise([3, 1, 2, "+", 4, "*", "+"])
deque([3]) # 3
deque([3, 1]) # 1
deque([3, 1, 2]) # 2
deque([3, 3]) # +
deque([3, 3, 4]) # 4
deque([3, 12]) # *
deque([15]) # +
56
4.6. heapq : files de priorité basées sur des tas
@dataclass(order=True)
class Client:
nom: str = field(compare=False)
priorité: int = 2 # priorité basse
Pour créer une file de priorité, on peut initialiser une liste vide classique avant d’utiliser
les opérations d’ajout heappush() et de retrait heappop().
>>> h = []
>>> heappush(h, Client("Jean"))
>>> heappush(h, Client("Anne"))
>>> heappush(h, Client("Hugo"))
>>> heappop(h)
Client(nom='Jean', priorité=2)
>>> heappush(h, Client("Jacques", 1)) # priorité forte
>>> heappop(h))
Client(nom='Jacques', priorité=1)
3. L’algorithme de Dijkstra pour la recherche du plus court chemin dans un graphe en est un exemple connu.
4. [Link]
57
4. Structures de données avancées
Pour fonctionner, chaque objet Python est représenté en mémoire sous la forme d’une
structure C qui contient a minima un en-tête, un identifiant de type, un compteur de réfé-
rences et soit une valeur pour les types simples (entiers, flottants), soit une adresse vers un
emplacement mémoire où est stockée la donnée. Pour représenter un nombre flottant double
précision (par exemple 3.14), là où le langage C occupe en général 8 octets en mémoire, un
flottant du langage Python en occupe 24 :
>>> import sys
>>> [Link](3.14) # taille en octets
24
En C, un tableau de 1 000 000 flottants double précision occupe en mémoire 8 000 000 d’oc-
tets. Une liste Python de 1 000 000 flottants contient quant à elle, en plus des champs stan-
dards évoqués ci-dessus, un tableau C qui stocke toutes les adresses vers les emplacements en
mémoire de chacun des flottants Python de la liste. On peut alors calculer l’espace mémoire
occupé, soit un peu plus de 4 fois plus d’espace mémoire que le tableau C correspondant.
>>> liste = [3.14 for i in range(1_000_000)]
>>> taille = [Link](liste) + 1_000_000 * [Link](3.14)
>>> f"{taille:_}" # avec le séparateur de milliers
32_697_456
Le module array ⁵ donne accès en Python aux données telles qu’elles sont stockées en
mémoire dans des langages plus bas niveau. Il est alors nécessaire que tous les éléments du
tableau aient le même type, défini à l’aide d’un caractère : 'i' pour les entiers signés, 'f'
pour les flottants simple précision, 'd' pour les flottants double précision, etc. Pour le même
tableau, l’espace mémoire occupé est alors d’un peu plus de 8 millions d’octets, soit l’espace
mémoire occupé par la structure Python (64 octets) en plus de l’espace occupé par le tableau C.
>>> a = [Link]('d', liste)
>>> f"{[Link](a):_}" # avec le séparateur de milliers
8_000_064
5. [Link]
58
4.7. array : tableaux de valeurs numériques
La structure de tableau de valeurs numériques array est alors un bon moyen d’optimiser
l’espace mémoire occupé par de gros volumes de données homogènes. Nous verrons plus loin
comment la bibliothèque NumPy (☞ p. 69, § 5) exploite l’encapsulation de tableaux de données
de grande taille au profit de l’espace mémoire et de la performance pour les calculs numériques.
En quelques mots…
Nous avons présenté dans ce chapitre des structures de données Python optimisées pour
un certain nombre de tâches courantes en programmation.
Les premières structures complètent les fonctionnalités du dictionnaire. En particu-
lier, les types namedtuple et dataclass permettent de s’assurer de la définition de tous les
champs d’une structure :
— le type namedtuple ajoute au tuple une sémantique proche de celle que l’on peut
avoir avec un dictionnaire ;
— le type dataclass offre plus de flexibilité, avec notamment la possibilité de définir
des champs mutables ;
— le type defaultdict crée des valeurs à la volée dans un dictionnaire pour éviter de
gérer le cas particulier d’une clé encore non existante ;
— le type Counter répond au problème de dénombrement d’une collection d’éléments.
Les structures suivantes pallient les défauts des listes Python, dont la flexibilité et
l’expressivité s’obtiennent au prix de la performance et de la robustesse :
— le type deque optimise l’accès aux données des deux côtés de la structure ; il géné-
ralise la notion de file (queue en anglais) et de pile (stack en anglais) ;
— les files de priorité basées sur des tas du module heapq (pour heap based queue en
anglais) optimisent l’accès au plus petit élément d’une collection ;
— enfin les tableaux de valeurs numériques array optimisent l’espace mémoire oc-
cupé par de gros volumes de données.
59
C
Interlude
Calcul du rayon de la Terre
C
ontrairement aux idées reçues, la rotondité de la Terre est connue depuis l’Antiquité.
Les Anciens avaient déjà observé que les mâts étaient encore visibles après que les
bateaux passaient sous l’horizon. On prête à Ératosthène (200 av. J.-C.) la première
mesure du rayon de la Terre : celui-ci constatait qu’au solstice d’été à midi, à Syène (aujour-
d’hui, Assouan) aucune ombre n’était visible au fond d’un puits. Le même jour, à Alexandrie,
les objets projetaient une ombre. Il évalue alors la différence de latitudes entre les deux villes à
un 50ᵉ de cercle (7,2°). La distance entre les deux villes étant évaluée à 5 000 stades, il estime la
circonférence de la Terre à 250 000 stades. Cette mesure correspondrait à un rayon d’environ
6 300 kilomètres.
Dans la continuité d’Ératosthène puis Hipparque, Claude Ptolémée propose un Manuel de
géographie au IIᵉ siècle, une carte de l’écoumène (le monde habité) basée sur une grille de
méridiens et de parallèles ainsi que des premières définitions de projections coniques pour
retranscrire les cartes. Au Moyen Âge, les découvertes grecques sont éclipsées par l’Église
en Europe, mais servent de modèle aux traités arabes qui rassemblent des informations de
natures géographique, économique, commerciale, historique et religieuse, à l’aide d’une re-
présentation codifiée pour les pays, villes, routes, frontières, mers, fleuves et montagnes. La
cartographie reprend ses lettres de noblesse en Europe avec la croissance du commerce mari-
time au XIIIᵉ siècle : on cartographie les côtes et les ports, les îles ; les cartes se basent désormais
sur le Nord magnétique (donné par la boussole) et plus sur le Nord géographique (donné par
l’étoile polaire).
Au XVIIᵉ siècle, Jean-Baptiste Colbert crée l’Académie des Sciences et souhaite notamment
faire des cartes de France plus exactes que celles disponibles alors. À cette époque, les mesures
de latitude sont précises et basées sur la position des étoiles dans le ciel : en revanche, les
horloges n’ont pas la précision suffisante pour des mesures convenables de longitude. C’est
à cette époque que l’abbé Picard fait une première triangulation entre Paris à Amiens. La
triangulation est une mesure des distances basée sur la loi des sinus : à l’aide d’un triangle
dont on connaît les trois angles aux sommets et la mesure d’un de ses côtés, on calcule la
longueur des deux autres côtés.
61
Interlude
𝛾 𝑎
𝑏 sin 𝛼 sin 𝛽 sin 𝛾
𝛼 𝛽 = =
𝑎 𝑏 𝑐
𝑐
Données du problème
Cassini et La Caille ont fait différentes mesures :
— la ligne droite entre Juvisy et Villejuif mesure 5 748 toises, une deuxième ligne droite
entre deux amers sur la côte du Roussillon mesure 7 928 toises et 5 pieds ;
— la triangulation est faite sur un réseau de repères géographiques : pour chaque triangle,
on a reporté les angles aux sommets de chaque repère qui forme le triangle. La figure 4.2
montre l’original issu de l’ouvrage : l’angle au sommet Villejuif (Pyramide de Villejuive
dans le livre), qui sépare l’arc Villejuif–Juvisy de l’arc Villejuif–Fontenay, mesure 87 de-
grés, 48 minutes et 50 secondes ;
— les inclinaisons des arcs du réseau par rapport au méridien de référence (Figure 4.3) ;
— les différences de latitudes entre chaque extrémité des bases (Figure 4.4) :
— 2° 11′ 50″ 17‴ entre Dunkerque et l’Observatoire ;
— 1° 45′ 7″ 20‴ entre l’Observatoire et Bourges ;
— 2° 43′ 51″ 5‴ entre Bourges et Rodez ;
— et 1° 39′ 11″ 12‴ entre Rodez et Perpignan ;
— enfin, une toise ⁷ mesure 1,949 mètre.
Une partie du maillage de la triangulation tel qu’il est présenté dans l’ouvrage de Cas-
sini est représentée Figure 4.5. Les mesures de triangulation sont données dans un fichier
[Link] et les mesures d’inclinaison dans un fichier [Link], tous deux dis-
ponibles sur la page associée au livre [Link]
6. [Link]
7. Une toise fait 6 pieds, un pied fait 12 pouces et un pouce fait 12 lignes. La carte de Cassini est à l’échelle 1 ligne
pour 100 toises, format toujours préservé aujourd’hui avec l’échelle 1:86400.
62
Calcul du rayon de la Terre
FIGURE 4.3 – Premières mesures des inclinaisons par rapport à la méridienne de Paris
63
Interlude
[Link]
[Link]
Villejuif 87 48 50
Montlhery Montmartre 10 27 13
Juvisy 30 32 9
Montmartre [Link] 0 34 41
Fontenay 61 39 1
[Link] Clermont 9 51 26
Clermont Noyers 30 18 2
Juvisy 100 41 29
Noyers Sourdon 29 25 57
Fontenay 34 18 37
Sourdon Villersbretonneux 25 35 27
Montlhery 44 59 54
Résolution du problème
Le problème peut se résoudre en trois temps :
1. tout d’abord, nous avons besoin d’une structure pour stocker les distances entre chaque
paire de nœuds du réseau ;
2. ensuite, à partir des données d’inclinaison, nous projetons la distance entre chaque
paire de nœuds sur la méridienne : une simple somme donnera la distance de la méri-
dienne entre Dunkerque et Perpignan ;
3. à partir des différences de latitude, nous pouvons procéder à la même opération qu’Éra-
tosthène et recalculer le rayon de la Terre.
Le programme utilisera les fonctions de trigonométrie élémentaires. Puisque nous manipule-
rons des noms de lieux accompagnés d’un angle, nous définirons un namedtuple plutôt qu’un
tuple pour accéder de manière naturelle et lisible à chacun des champs :
1ʳᵉ étape Un dictionnaire sera la structure la mieux adaptée pour enregistrer les distances
entre chaque paire de nœuds. À
On préremplit le dictionnaire avec les distances connues (mesurées). Á
distances = dict() # À
distances["Juvisy", "Villejuif"] = 5748 # Á
distances["[Link]", "[Link]"] = 7928 + 5 / 6
On ouvre alors le fichier [Link] pour lire chacune des lignes non vides et reconstruire
la valeur de chaque angle (en radians). Â
On stocke ces valeurs dans une liste : quand on atteint une longueur de 3, on lit la dernière
valeur de distance ⁸ pour appliquer la loi des sinus. Ã
Nous utilisons ici deux fois la technique du déballage pour accéder aux éléments d’une liste
ou d’un tuple sans les indexer explicitement. Ä
Enfin, afin de prendre en compte le fait que la distance est symétrique (nous aurons peut-être
stocké d[n2, n1] avant d’appeler d[n1, n2]), la méthode .get() du dictionnaire est préférée
à la notation entre crochets afin d’éviter une exception de type KeyError. Å
8. Le fichier étant organisé de sorte que la distance entre les deux premiers nœuds est toujours connue au moment
où on lit un triangle, il suffit de calculer les deux autres distances entre les nœuds 1 et 3 puis 2 et 3.
64
Calcul du rayon de la Terre
if len(triangle) == 3: # Ã
n1, n2, n3 = triangle # Ä
Nord
𝑗
FIGURE 4.6 – Projection des distances 𝑑𝑖,𝑗 mesurées par triangulation à partir des données d’inclinaisons 𝛼𝑖,𝑗
65
Interlude
total *= 1.949 # È
latitudes = [
[2, 11, 50, 17], # Dunkerque -- Observatoire
[1, 45, 7, 20], # Observatoire -- Bourges
[2, 43, 51, 5], # Bourges -- Rodez
[1, 39, 11, 12], # Perpignan -- Rodez
]
$ python [Link]
Rayon de la terre: 6374 km
66
II
L’écosystème
Python
5
Le calcul numérique avec NumPy
N
umPy est une extension pour le langage Python qui permet de manipuler des tableaux
(ou matrices) multi-dimensionnels. NumPy apporte la structure de données ndarray,
qui diffère de la plupart des autres structures Python par l’homogénéité des types des
valeurs qu’elle contient (à l’image du type array ☞ p. 58, § 4.7) et par la performance des
opérations proposées.
L’usage est d’importer la bibliothèque NumPy sous l’alias np :
>>> import numpy as np
69
5. Le calcul numérique avec NumPy
Python et celle d’un code NumPy qui exécute, en C, une boucle avec un grand nombre de
multiplications d’entiers. En tirant parti des types des variables enregistrées en manipulant
des structures de données C de bas niveau, NumPy est près de 24 fois plus rapide que son
équivalent Python sur cet exemple.
>>> tableau = [i for i in range(10_000_000)]
>>> np_tableau = [Link](tableau)
>>> t = [Link]()
>>> double = np_tableau * 2
>>> ([Link]() - t) // 1e-3 # en millisecondes
32.0
>>> t = [Link]()
>>> double = [x * 2 for x in tableau]
>>> ([Link]() - t) // 1e-3 # en millisecondes
757.0
70
5.1. Les bases de NumPy
71
5. Le calcul numérique avec NumPy
9 Attention !
Le test d’égalité de flottants terme à terme est toujours à proscrire (☞ p. 6, § 1.2). Les
fonctions NumPy [Link] et [Link] permettent de comparer deux tableaux avec
des valeurs de tolérance par défaut.
>>> x = [Link](0, [Link], 8)
>>> [Link](x) <= 1
array([ True, True, True, True, True, True, True, True])
>>> [Link](x) ** 2 + [Link](x) ** 2
array([1., 1., 1., 1., 1., 1., 1., 1.])
>>> [Link](x) ** 2 + [Link](x) ** 2 == 1
array([ True, True, True, True, True, True, False, True])
ATTENTION! => ^^^^^
>>> [Link](x) ** 2 + [Link](x) ** 2 - 1
array([ 0.00000000e+00, 0.00000000e+00, 0.00000000e+00, 0.00000000e+00,
0.00000000e+00, 0.00000000e+00, -1.11022302e-16, 0.00000000e+00])
>>> [Link]([Link](x) ** 2 + [Link](x) ** 2, 1)
array([ True, True, True, True, True, True, True, True])
V Bonnes pratiques
Un moyen efficace de compter le nombre d’éléments qui vérifient une condition dans
un tableau NumPy est d’écrire cette condition sous forme de vecteur de booléens. True
s’évalue à 1, False à 0 : en sommant tous les éléments du vecteur de booléens, on
obtient le nombre d’éléments qui vérifient la condition sur le tableau d’origine.
Par exemple, pour compter le nombre d’entiers pairs inférieurs à 10 :
>>> x = [Link](1, 10)
>>> x
array([1, 2, 3, 4, 5, 6, 7, 8, 9])
>>> x % 2 == 0
array([ False, True, False, True, False, True, False, True, False])
>>> [Link](x % 2 == 0)
4
72
5.2. Indexation et itération sur les tableaux NumPy
Calcul des décimales de 𝜋. Une manière (à vrai dire peu efficace) d’estimer la valeur
de 𝜋 est de tirer de manière aléatoire un grand nombre de points dans un carré de 1 cm
de côté. On compte alors parmi ces points combien sont situés à l’intérieur d’un arc de
𝜋
cercle de rayon 1 cm. L’aire du carré (en cm2 ) vaut 1, l’aire de l’arc de cercle vaut . Le
4
ratio du nombre de points compris dans l’arc de cercle par rapport au nombre total de
𝜋
points s’approche donc de .
4
𝑦
1
>>> size = 100_000_000
>>> x, y = [Link](0, 1, (2, size))
>>> 4 * [Link](x**2 + y**2 < 1) / size
3.14149
0 𝑥
0 1
Pour un tableau à plusieurs dimensions, que nous illustrerons à l’aide du tableau des trente
premiers entiers, une indexation simple [i] accède à la ligne d’indice 𝑖.
>>> x, y = [Link](a, a[:3])
>>> trente = x + 10 * y
>>> trente
array([[ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
[10, 11, 12, 13, 14, 15, 16, 17, 18, 19],
[20, 21, 22, 23, 24, 25, 26, 27, 28, 29]])
>>> trente[2]
array([20, 21, 22, 23, 24, 25, 26, 27, 28, 29])
>>> trente[2][3]
23
73
5. Le calcul numérique avec NumPy
>>> trente[2, 3] # sélection unique sur les deux dimensions, renvoie un entier
23
>>> trente[:, 2] # sélection unique sur la 2e dimension, renvoie un tableau 1D
array([ 2, 12, 22])
>>> trente[2, :] # équivalent à trente[2]
array([20, 21, 22, 23, 24, 25, 26, 27, 28, 29])
>>> trente[:, ::-1] # sélection de la 2e dimension, mais « à l'envers »
array([[ 9, 8, 7, 6, 5, 4, 3, 2, 1, 0],
[19, 18, 17, 16, 15, 14, 13, 12, 11, 10],
[29, 28, 27, 26, 25, 24, 23, 22, 21, 20]])
9 Attention !
Sur un tableau à plusieurs dimensions, les notations suivantes sont équivalentes. La no-
tation «...» complète l’index par autant de «:» que nécessaire pour atteindre le nombre
de dimensions du tableau indexé.
>>> trente[2]
array([20, 21, 22, 23, 24, 25, 26, 27, 28, 29])
>>> trente[2, :]
array([20, 21, 22, 23, 24, 25, 26, 27, 28, 29])
>>> trente[2, ...]
array([20, 21, 22, 23, 24, 25, 26, 27, 28, 29])
Tous les tableaux NumPy sont itérables : l’itération a lieu sur la première dimension du
tableau. L’instruction « for elt in trente: » renvoie dans l’ordre trente[0] (c’est-à-dire
trente[0, ...]), puis trente[1] et ainsi de suite :
>>> for elt in trente:
... print(elt)
...
[0 1 2 3 4 5 6 7 8 9]
[10 11 12 13 14 15 16 17 18 19]
[20 21 22 23 24 25 26 27 28 29]
Itération sur un tableau NumPy. Il est possible d’itérer sur tous les éléments du tableau
dans l’ordre en itérant sur l’attribut .flat du tableau NumPy. La méthode [Link]() ¹
renvoie le tableau NumPy à une dimension constitué des éléments de [Link]
>>> for elt in [Link]:
... print(elt, end=", ")
...
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, [...]
1. La fonctionnalité existe également sous forme de fonction [Link](trente).
74
5.3. Tailles et dimensions des tableaux
>>> [Link]()
array([ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16,
17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29])
Enfin, la fonctionnalité équivalente au mot-clé Python enumerate (☞ p. 26, § 2.1) est four-
nie par la fonction [Link] qui renvoie l’index complet sous forme de tuple au lieu
d’incrémenter un compteur.
>>> trente % 3 == 0
array([[ True, False, False, True, False, False, True, False, False, True],
[False, False, True, False, False, True, False, False, True, False],
[False, True, False, False, True, False, False, True, False, False]])
>>> trente[trente % 3 == 0]
array([ 0, 3, 6, 9, 12, 15, 18, 21, 24, 27])
La fonction [Link] permet quant à elle de manipuler et stocker les indices des valeurs
qui vérifient une condition donnée dans une variable :
75
5. Le calcul numérique avec NumPy
Si on souhaite répliquer un vecteur sur une autre dimension, il est nécessaire de préciser
sur quel axe augmenter la dimension Å.
>>> trente + a[:3]
Traceback (most recent call last):
...
ValueError: operands could not be broadcast together with shapes (3,10) (3,)
>>> a[:3, [Link]] # Å
array([[0],
[1],
[2]])
>>> a[:3, [Link]].repeat(10, axis=1) # Å
array([[0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
[2, 2, 2, 2, 2, 2, 2, 2, 2, 2]])
>>> trente + a[:3, [Link]]
array([[ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
[11, 12, 13, 14, 15, 16, 17, 18, 19, 20],
[22, 23, 24, 25, 26, 27, 28, 29, 30, 31]])
NumPy propage les valeurs des deux tableaux passés en paramètres suivant toutes les
dimensions possibles pour aller vers le plus disant. Il peut propager les deux arguments passés
en paramètres, comme dans l’exemple ci-dessous qui reconstruit le tableau trente introduit
en début de chapitre avec la fonction [Link].
>>> a[[Link], :] + 10 * a[:3, [Link]]
array([[ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
[10, 11, 12, 13, 14, 15, 16, 17, 18, 19],
[20, 21, 22, 23, 24, 25, 26, 27, 28, 29]])
77
5. Le calcul numérique avec NumPy
Æ La commande trente[:3, :3] = 0 écrit la valeur 0 aux indices indiqués. vue = 0 attribue la
valeur Python 0 à la variable vue. Pour indiquer explicitement que l’on souhaite modifier le contenu
de l’ensemble des valeurs du tableau vue (et, a fortiori de trente[:3, :3]), il faut faire l’assignation
sur vue[:, :] ou vue[...].
Si cet effet n’est pas désiré, il faut faire une copie explicite. Une modification de la copie n’a
aucun impact, ni sur le tableau d’origine, ni sur les vues qui s’y réfèrent.
>>> copie = [Link]()
>>> copie[...] = 1
>>> copie
array([[1, 1, 1],
[1, 1, 1],
[1, 1, 1]])
>>> trente # contenu inchangé
array([[ 0, 0, 0, 3, 4, 5, 6, 7, 8, 9],
[ 0, 0, 0, 13, 14, 15, 16, 17, 18, 19],
[ 0, 0, 0, 23, 24, 25, 26, 27, 28, 29]])
>>> vue # contenu inchangé
array([[0, 0, 0],
[0, 0, 0],
[0, 0, 0]])
Nota bene. On peut retrouver les effets du mécanisme de vue dans l’attribut .strides des
tableaux NumPy. Le tableau vue est en effet de dimension (3, 3) mais la position de l’élément
vue[1][0] est située 80 octets après vue[0][0], soit 10 entiers plus loin, en référence à la struc-
ture mémoire du tableau trente (cf. Figure 5.1). En revanche, copie[1][0] est situé 24 octets,
soit 3 entiers plus loin, conformément à la taille du tableau copie.
78
5.5. Le module d’algèbre linéaire
Notons également l’argument .base qui renvoie le tableau d’origine si le tableau est une
vue, et None sinon.
>>> [Link] >>> [Link] is trente
(80, 8) True
>>> [Link] >>> [Link] # None
(24, 8) >>> [Link] # None
79
5. Le calcul numérique avec NumPy
Cette seconde approche est efficace en mémoire mais le processeur ne peut optimiser son
exécution parce qu’elle n’est pas compilée en langage machine et que Python outille ce code
avec des opérations internes exécutées à chaque itération, indexation et opération.
Le module numexpr procède par sous-vecteurs de taille moyenne (compatible avec l’utili-
sation des caches du processeur), compile une expression sous forme de chaîne de caractères
pour optimiser à la fois la gestion du processeur et de la mémoire.
>>> import numexpr as ne
>>> a, b = [Link](0, 1, (2, 1_000_000))
>>> t = [Link]()
>>> c = 2 * a + 3 * b
>>> ([Link]() - t) // 1e-3 # en millisecondes
15.0
>>> t = [Link]()
>>> c = [Link]("2*a + 3*b")
>>> ([Link]() - t) // 1e-3 # en millisecondes
8.0
Suivant la nature des opérations à exécuter, l’évaluation d’une expression numexpr peut être
jusqu’à 20 fois plus rapide que son équivalent NumPy.
80
5.7. Le passage à Numpy 2.0
>>> type(_)
<class 'numpy.float64'>
Pour ce type d’exemple, il est recommandé de clarifier les types dans le code d’ori-
gine :
>>> np.float32(6) + np.float64(6)
np.float64(12.0)
On notera aussi que la représentation des types scalaires NumPy a été clarifiée. Au
sein d’une f-string, on retrouve toutefois des représentations plus lisibles :
>>> f"{np.float64(6)}"
'6.0'
81
6
Produire des graphiques avec
Matplotlib
M
atplotlib est une bibliothèque destinée à créer des visualisations de données sta-
tiques, interactives ou animées. Elle est très largement utilisée pour sa facilité d’uti-
lisation et pour la flexibilité avec laquelle on peut apporter un soin particulier aux dé-
tails. Elle propose d’exporter des visualisations statiques dans des formats matriciels (comme
le format PNG) ou vectoriels (comme les formats SVG ou PDF), des visualisations animées dans
des formats vidéos et peut s’intégrer dans des environnements dynamiques comme Jupyter
(☞ p. 107, § 8) ou Qt (☞ p. 329, § 22.4).
L’usage est d’importer la bibliothèque Matplotlib sous l’alias plt :
>>> import [Link] as plt
Le code qui produit les figures de ce chapitre est disponible sur la page web du livre.
83
6. Produire des graphiques avec Matplotlib
V Bonnes pratiques
Matplotlib permet d’exporter des visualisations dans des formats matriciels (une repré-
sentation de l’image pixel par pixel) comme le JPG ou le PNG, et dans des formats vec-
toriels (une représentation vectorielle des éléments qui forment l’image, pour un rendu
visuel plus soigné) comme le PDF ou le SVG.
Le meilleur choix du format dépendra du mode de diffusion choisi. Pour une page web,
on pourra afficher un rendu PNG léger et proposer un lien vers une version PDF au télé-
chargement. Les rendus au format PDF ont pour leur part toute leur place dans un article
au format PDF ; mais si la taille d’un fichier PNG de qualité convenable est très inférieure
à celle du fichier PDF équivalent, la question mérite de se poser à nouveau.
Nous nous concentrerons dans la suite de ce chapitre sur l’interface orientée objet.
FIGURE 6.1 – Une figure simple n’utilise qu’un seul système d’axes.
Une figure simple utilise un seul système d’axes (Figure 6.1) ; une figure plus complexe peut
mettre côte à côte plusieurs systèmes d’axes (Figures 6.2 et 6.3) si les informations à visualiser
sont sémantiquement liées. La manière probablement la plus générique de créer simultané-
ment une figure et un ou des systèmes d’axes est d’utiliser la fonction suivante :
fig, ax = [Link]() # un seul système d'axes
fig, (ax1, ax2) = [Link](2, 1) # déballage de tuples pour les axes
84
6.3. Les différents types de visualisations
FIGURE 6.2 – Une figure complexe peut contenir plusieurs systèmes d’axes alignés.
ax1
1.0
ax2 fig = [Link](figsize=(5, 3))
0.8 1.0
0.5
0.6 0.0 ax1 = fig.add_axes([0, 0, 1, 1])
0.0 0.5 1.0
ax2 = fig.add_axes([0.65, 0.65, 0.2, 0.2])
0.4
0.2 ax1.set_title("ax1")
ax2.set_title("ax2")
0.0
0.0 0.2 0.4 0.6 0.8 1.0
FIGURE 6.3 – Une figure complexe peut contenir plusieurs systèmes d’axes intégrés.
Pour une répartition complexe, on peut utiliser une grille (argument gridspec) et remplir
une partie des cases avec des systèmes d’axes. L’indexation est compatible avec celle de NumPy
(Figure 6.4). Pour des systèmes d’axes équilibrés différemment, mais toujours proches d’un
alignement en damiers, on peut faire appel à l’argument gridspec_kw, un dictionnaire qui est
passé en paramètre de la fonction fig.add_gridspec() appelée en interne (Figure 6.5).
85
6. Produire des graphiques avec Matplotlib
FIGURE 6.4 – Les placements les plus sophistiqués peuvent se faire sur une grille.
FIGURE 6.5 – L’argument gridspec_kw permet d’éviter de définir une grille manuellement.
est accessible sur la page web [Link] Nous nous limiterons ici aux
principaux types de visualisations, notamment ceux listés dans le tableau 6.1.
La figure 6.6 illustre les types de visualisations suivants :
— [Link](x, y) propose une visualisation de lignes reliant des points. Les coordonnées
d’abscisses et d’ordonnées sont passées sous forme de listes ou de tableaux NumPy ;
Matplotlib relie l’ensemble des points aux coordonnées passées.
— [Link](x, y) affiche un nuage de points. Une liste de caractéristiques (tailles, cou-
leurs) permet de spécifier le style de chacun des points.
— [Link](x) regroupe les échantillons par intervalles, bins en anglais, et affiche une den-
sité sous forme d’histogramme. L’argument density=True affiche en ordonnée une den-
sité plutôt qu’un nombre d’échantillons ; les arguments bins=20, range=(0, 6) forcent
le découpage en 20 intervalles entre 0 et 6.
☞ Il peut être délicat de calibrer le nombre d’intervalles pour une visualisation per-
tinente de l’information contenue dans la distribution.
— [Link](data) représente un diagramme en boîte pour chacune des distributions
dans la liste data avec des indications visuelles pour représenter médiane, quartiles et
valeurs aberrantes.
La figure 6.7 illustre différentes manières de représenter des informations matricielles :
— [Link](x, y, z) et [Link](z) proposent des représentations matricielles de
données. Une table des couleurs, colormap en anglais, associe une couleur à un sca-
laire. Parmi les nuances entre les deux fonctions, [Link] permet un affichage
en grille éventuellement irrégulière ; [Link] permet d’afficher des images à l’aide de
coordonnées RGB si la matrice z est à trois dimensions.
86
6.3. Les différents types de visualisations
Visualisation de base
[Link]() courbes simples Figure 6.6, 6.9
[Link]() nuages de points Figure 6.6, 6.9
[Link]() diagramme circulaire
[Link]() barres d’erreur
[Link]() diagramme en boîtes Figure 6.6
Visualisation par intervalles
[Link]() histogramme Figure 6.6, 6.9
ax.hist2d() histogramme 2D
[Link]() maillage hexagonal
Visualisation matricielle
[Link]() affichage de matrice sous forme d’image
[Link]() affichage de matrice sous forme d’image Figure 6.7, 6.9
[Link]() lignes de niveau Figure 6.7
[Link]() champ de gradients Figure 6.7, 6.9
[Link]() champ de barbules (vent)
Visualisation spectrale
[Link]() autocorrélation
[Link]() densité spectrale de puissance
[Link]() spectrogramme
87
6. Produire des graphiques avec Matplotlib
9 Attention !
Dans la communauté du traitement d’images, le point de coordonnées (0, 0) est situé en
haut et à gauche de l’image, avec l’axe des ordonnées qui pointe vers le bas. Par
défaut, [Link]() respecte cette convention, mais [Link]() respecte la conven-
tion mathématique avec l’axe des ordonnées qui pointe vers le haut.
[Link](z) [Link](z)
5 5
4 4
1.00
3 3
0.75
2 2
0.50
1 1
0 0 0.25
0 1 2 3 4 5 0 1 2 3 4 5
[Link](x, y, dx, dy) 0.00
5 ax.plot_surface(x, y, z)
0.25
4 1.0
0.5 0.50
3 0.0
0.5 0.75
2
1
345
0 0 1 2 3 12
0 2 4 4 5 0
FIGURE 6.7 – Les informations en trois dimensions peuvent être affichées sous forme de cartes de densité pcolormesh(),
de lignes de niveaux contour(), de champs de gradients quiver() ou de graphes en trois dimensions plot_surface().
88
6.4. Le contrôle du style
90°
120° 60°
2𝜃 − 𝜋
𝑟 = 𝑒 sin 𝜃 − 2 cos(4𝜃) + sin5 ( )
150° 30° 24
# 1re alternative
4 5
2 3 fig = [Link]()
180° 0 1 0° ax = fig.add_subplot(111, projection="polar")
# 2e alternative
210° 330° fig, ax = [Link](
subplot_kw=dict(projection="polar")
240° 300° )
270°
FIGURE 6.8 – Exemple de visualisation avec un système d’axes polaires : la courbe papillon de Temple Fay
Toutes les fonctionnalités présentées s’appliquent sur un système d’axes. Il est alors pos-
sible de les combiner sur le même système d’axes. La figure 6.9 combine à gauche histogramme,
nuage de points et courbe ; et à droite carte de densité, lignes de niveaux et leurs annotations.
5
1.0 0.600
[Link]() 1.00
-0.6
0.600
[Link]()
00
-0.600
4 0.75
0.8 [Link]() 0.0
00
0.6
0.000 0.50
0 0
3 -0.
60 0.25
0.6 0
0.600
0.6 0.00
00
2 0.25
0.4 0.6
00
0.50
-0.60
0.00
0.2 1 0 0.75
0
0.000
0.0 0
0 1 2 3 4 5 6 0 1 2 3 4 5
FIGURE 6.9 – On peut combiner plusieurs styles de visualisations sur le même système d’axes.
La méthode de contrôle de style des figures Matplotlib la plus répandue est la spécification
89
6. Produire des graphiques avec Matplotlib
0.0
ax[1].scatter(
0.5 [Link](x), [Link](x), marker=".", s=20,
1.0 color="crimson" # nom de couleur HTML
1 0 1 )
20 ax[1].set_aspect("equal")
15
ax[2].hist(
10 [Link](x), range=(-1, 1), bins=16,
linewidth=2, edgecolor="white",
5
color="#008f6b" # code hexadécimal de la couleur
0 )
1.00 0.75 0.50 0.25 0.00 0.25 0.50 0.75 1.00
Le choix des couleurs est un problème délicat. Au-delà des questions de goût, la difficulté
est souvent de choisir une palette de couleurs qui permette de distinguer différentes catégories
de données. Matplotlib propose des palettes de couleurs par défaut qui peuvent convenir ou
non, et la possibilité de configurer sa propre palette de couleur.
Il est possible de nommer une couleur à passer en paramètre, notamment :
— à l’aide des abréviations ou du nom des couleurs de base : b/blue (bleu), g/green (vert),
r/red (rouge), c/cyan, m/magenta, y/yellow (jaune), k/black (noir) et w/white (blanc) ;
— à l’aide du nom d’une couleur de la palette par défaut : tab:blue, tab:orange, tab:green,
tab:red, tab:purple, tab:brown, tab:pink, tab:gray, tab:olive, tab:cyan ;
90
6.4. Le contrôle du style
Pour les nuages de points ou pour les cartes de densité (Figures 6.7 et 6.9), Matplotlib
permet de sélectionner une carte de couleur (ou colormap) adaptée à la nature des données à
représenter. La figure 6.12 en illustre quelques exemples :
— des tables qualitatives pour des données catégorielles (p. ex. pour associer une couleur
à un pays, une langue ou une espèce) ;
— des tables séquentielles qui proposent un gradient d’une couleur vers une autre, adap-
tées aux données continues (p. ex. pour associer une couleur à une densité, un prix) ;
— des tables divergentes qui proposent deux gradients centrés sur une couleur, adaptées
pour les données continues qui ont une sémantique différente en fonction de leur signe
(p. ex. pour associer une couleur au solde d’un compte en banque) ;
— des tables spécifiques, par exemple pour représenter les altitudes (terrain) : des tons
bleus sous le niveau de la mer, des tons verts au-dessus, qui virent au marron puis au
blanc pour les montagnes.
5
0
0 1 2 3 4 5 0 1 2 3 4 5 0 1 2 3 4 5
91
6. Produire des graphiques avec Matplotlib
La figure 6.13 applique trois de ces tables de couleurs sur la carte de densité des figures 6.7
et 6.9. L’option viridis (par défaut) est souvent un bon compromis. Ici, la table divergente
RdBu est probablement mieux adaptée à un domaine centré sur la valeur 0.
Axes et graduations. Matplotlib utilise une heuristique par défaut pour positionner des gra-
duations (ticks en anglais) sur les axes des abscisses et des ordonnées. Il est possible de changer
le positionnement et le texte des graduations :
— soit de manière manuelle à l’aide des méthodes ax.set_xticks([2, 3, 5]) pour le posi-
tionnement, et ax.set_xticklabels(["deux", "trois", "cinq"]) pour le texte associé ;
— soit de manière automatique à l’aide de politiques de placement (locator) et de formatage
de texte (formatter).
5000
4500
4000
1988-01 1988-03 1988-05 1988-07 1988-09 1988-11 1989-01
5000
4500
4000
Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec Jan
5000
4500
4000
Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec Jan
[Link].set_major_locator([Link](500))
Á Les graduations sur l’axe des abscisses ne conviennent pas ici : nos données sont moyen-
nées par année et artificiellement associées à l’année 1989. On peut choisir à l’aide de
politiques de placement de dates du module [Link] de placer une graduation
à chaque début de mois. Des graduations mineures, plus petites et souvent non anno-
92
6.4. Le contrôle du style
tées sont ici ajoutées à titre illustratif à chaque début de semaine. Le module fournit
également une politique de formatage de dates pour les graduations : on choisit aussi
le formateur de date associé au nom abrégé du mois uniquement.
[Link].set_major_locator([Link]())
[Link].set_minor_locator([Link]())
[Link].set_major_formatter([Link]("%h"))
[Link].set_minor_formatter([Link]())
[Link]["right"].set_visible(False)
[Link]["top"].set_visible(False)
[Link]["bottom"].set_linewidth(1.5)
[Link]["left"].set_linewidth(1.5)
à Enfin, l’instruction [Link]() permet d’aligner un quadrillage sur les graduations (ma-
jeures et/ou mineures).
[Link](alpha=0.5, which="major")
Textes et annotations. Une visualisation peut gagner en lisibilité si on peut annoter des
parties de la figure avec des éléments graphiques ou textuels qui aident à attirer l’attention et
expliquer un phénomène. L’instruction [Link](x, y, txt, **options) permet de placer du
texte sur un système d’axes. De nombreuses options sont configurables, notamment :
— la police de caractères avec fontname= ;
— la taille du texte avec fontsize= ;
— l’alignement du texte,
— horizontal ha="left", "center", "right", ou
— vertical va="top", "center", "bottom" ;
— la possibilité de tracer une boîte autour du texte avec bbox=.
Afin d’annoter une figure à l’endroit souhaité, il est possible de préciser les coordonnées
(𝑥, 𝑦) de l’élément à ajouter dans plusieurs repères, associé à l’argument transform= :
— transform=[Link] (par défaut) référence des coordonnées dans le même repère
que les éléments visualisés ;
— transform=[Link] référence des coordonnées relatives au point (0, 0) en bas à
gauche et (1, 1) en haut à droite du système d’axes ax ;
— transform=[Link] référence des coordonnées relatives au point (0, 0) en bas
à gauche et (1, 1) en haut à droite de la figure fig.
Ces trois repères sont illustrés sur la figure 6.15. On a choisi ici l’instruction [Link]()
qui permet de spécifier à la fois le point à annoter et l’emplacement du texte associé. La pra-
tique d’ajouter une flèche pour pointer vers l’élément à annoter est courante.
93
6. Produire des graphiques avec Matplotlib
5200
5000
4800
4600
4400 < [Link](datetime(1988, 1, 1), 4400, txt)
4200 < [Link](0.1, 0.2, txt, transform=[Link])
4000< [Link](0.1, 0.2, txt, transform=[Link])
3800
Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec Jan
Nombre de naissances par jour
5200
5000
4800
4600 Thanksgiving
4400 Jour de l'indépendance
4200
4000 Jour de l'an Noël
3800
Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec Jan
[Link](
"Jour de l'indépendance",
xy=(datetime(1988, 7, 4), 4335), # coordonnées du point
xytext=(-30, 0), textcoords="offset points", # relatives au point
ha="right", color="tab:blue", # alignement, couleur
arrowprops=dict(arrowstyle="->", color="tab:blue"), # flèche
bbox=dict(boxstyle="round", fc="white", ec="tab:blue", pad=0.5)
)
94
6.5. L’affichage de données géographiques
Finalement, le jeu de données met en évidence une chute du nombre de naissances les jours
fériés pendant la période considérée. L’auteur suggère une possible corrélation entre les jours
fériés et les naissances programmées plutôt qu’un effet psychosomatique sur les naissances
naturelles.
Feuilles de style. Les personnalisations de style peuvent être fortement redondantes quand
les mêmes spécifications sont déclarées pour toutes les figures produites dans un document.
Matplotlib propose par défaut un certain nombre de feuilles de style :
>>> [Link]
['classic', ... 'fivethirtyeight', 'ggplot', ... 'seaborn', ...]
95
6. Produire des graphiques avec Matplotlib
projection=[Link]()
projection=[Link](0, 60)
projection=[Link]()
Mont Blanc
Mont Blanc
Mont Blanc
fig = [Link]()
ax1 = fig.add_subplot(131, projection=[Link]())
ax2 = fig.add_subplot(132, projection=[Link](0, 60))
ax3 = fig.add_subplot(133, projection=[Link]())
96
6.6. La génération d’animations
Le terme artist fait référence à tout élément qui forme une figure : point, graduation, texte,
ligne, polygone, etc. Toutes les fonctions qui créent un type de visualisation (comme [Link](),
[Link]() ou [Link]()) renvoient parmi leur type de retour une liste d’artistes. La fonction
animate() sera chargée de mettre à jour une partie des artistes pour faire évoluer la figure. Elle
doit renvoyer l’ensemble des artistes qui ont été modifiés par la fonction animate().
97
6. Produire des graphiques avec Matplotlib
1 1
0
1 1 /2 3 /2 2
1 1
ax[0].plot([Link](angle), [Link](angle))
ax[1].plot(angle, [Link](angle))
ax[1].xaxis.set_major_locator([Link]([Link] / 2))
Pour animer la figure 6.18, la fonction animate édite les artistes line1 et line2 en fonction
d’une valeur d’angle à déterminer à partir d’un index de frame entre 0 et 180. L’artiste Line2D
propose la méthode .set_data() pour mettre à jour les coordonnées qui la composent.
def animate(frame: int, line1, line2):
angle = i * 2 * [Link] / 180
line1.set_data([0, [Link](angle)], [0, [Link](angle)])
line2.set_data([angle, angle], [0, [Link](angle)])
return [line1, line2]
98
7
La boîte à outils scientifiques SciPy
L
a bibliothèque SciPy est une collection d’algorithmes numériques efficaces appliqués à
des domaines scientifiques aussi variés que les statistiques, l’interpolation, l’intégration,
l’optimisation, ou le traitement du signal. SciPy est construit sur les épaules des deux
géants de l’écosystème de programmation scientifique en Python, en l’occurrence les biblio-
thèques NumPy (☞ p. 69, § 5) pour les structures de données, et Matplotlib (☞ p. 83, § 6) pour
la visualisation.
La figure 7.1 illustre le mécanisme d’interpolation de SciPy quand les échantillons 𝑥𝑖 sont
définis sur un espace à une dimension. De nombreux modes d’interpolation sont disponibles
dans la documentation. Les modes les plus courants sont :
— kind="linear" (par défaut) construit une interpolation linéaire, c’est-à-dire une fonc-
tion linéaire par morceaux entre chaque échantillon ;
99
7. La boîte à outils scientifiques SciPy
Dans le code qui accompagne la figure, x_data À, un tableau NumPy à une dimension,
représente les échantillons tirés pour évaluer la fonction 𝑓 (𝑥) = 3 ⋅ sin(𝑥 2 ) alors que x_new Á
représente les points où on souhaite évaluer les fonctions interpolatrices, afin d’afficher cette
figure avec Matplotlib. Pour cette visualisation, il est important de choisir un tableau x_new
avec plusieurs points entre les échantillons de x_data.
3 X, Y = [Link](
2 [Link](0, 5, 100), [Link](0, 5, 100)
)
1
0 ax[1, 0].imshow(
method="nearest" method="cubic" griddata(
5 np.c_[x, y], z, (X, Y), method="nearest"
4 ),
extent=[0, 5, 0, 5], origin="lower",
3 )
2 ax[1, 1].imshow(
griddata(
1 np.c_[x, y], z, (X, Y), method="cubic"
0 ),
0 1 2 3 4 5 0 1 2 3 4 5 extent=[0, 5, 0, 5], origin="lower",
)
La figure 7.2 illustre une interpolation sur un domaine à deux dimensions. Les échantillons
sont tirés à l’aide de la fonction [Link] Â puis interpolés à l’aide de la fonction
griddata qui fonctionne sur des espaces à 𝑛 dimensions :
— method="nearest" interpole vers la valeur associée à l’échantillon connu le plus proche.
En deux dimensions, on reconnaît un diagramme de Voronoï ;
— method="cubic" utilise des splines d’ordre 3 ; seul l’intérieur de l’enveloppe convexe des
échantillons est interpolable.
100
7.2. Le module d’intégration
̈ = −𝑔 ⋅ 𝑧⃗
𝑥(𝑡) 𝑥(0)
̇ = 𝑣0 𝑥(0) = 𝑥0 (7.1)
600
400
200
0
0 500 1000 1500 2000 2500
200
solve_ivp(...)
400 solve_ivp(..., events=touche_le_sol)
101
7. La boîte à outils scientifiques SciPy
La méthode d’intégration par défaut est celle de Runge-Kutta d’ordre 5, plus stable que
le simple schéma d’Euler 𝑦𝑛+1 − 𝑦𝑛 = (𝑡𝑛+1 − 𝑡𝑛 ) ⋅ 𝑓 (𝑡𝑛 , 𝑦𝑛 ) appris au lycée. Les problèmes
de stabilité des intégrateurs s’illustrent bien avec le problème de deux corps soumis à leur
interaction gravitationnelle (Figure 7.4) :
𝑚1 ⋅ 𝑚 2 −1
𝐹 =𝐺⋅ avec 𝐺 = 6, 67384 ⋅ 10−11 m3 ⋅ kg ⋅ s−2 (7.2)
𝑟2
La solution analytique à ce problème est connue depuis Kepler puis Newton : les trajec-
toires des corps décrivent des ellipses. Pourtant les imprécisions qui s’accumulent dans les
schémas d’intégration font peu à peu dériver les ellipses. Dans l’exemple de la figure 7.4, le
schéma DOP853 convient pour des besoins en haute précision.
method="RK45" method="DOP853"
4 4
8 6 4 2 0 2 4 6 8 10 8 6 4 2 0 2 4 6 8 10
4 4
FIGURE 7.4 – Phénomènes d’instabilité des schémas d’intégration avec le problème à deux corps
102
7.4. Le module de statistiques
— pour les problèmes de programmation linéaire avec la fonction linprog, basées sur l’al-
gorithme du simplexe ou la méthode des points intérieurs ¹ ;
— pour les problèmes de programmation non linéaire, à base de descente de gradient avec
la fonction fmin ;
— pour les problèmes à résoudre par la méthode des moindres carrés avec la fonction
least_squares, notamment l’ajustement de courbes avec la fonction curve_fit ;
— pour les problèmes de recherche de racines d’une équation avec la fonction root.
L’interlude (☞ C p. 113) illustre en profondeur l’utilisation des méthodes d’optimisation à
base de descente de gradient.
Le module de statistiques stats propose des méthodes relatives aux distributions statis-
tiques. La figure 7.5 illustre comment des distributions de probabilité classiques parviennent
à modéliser des événements physiques. L’histogramme est tracé à partir de données ouvertes
issues des stations météorologiques réparties sur la ville de Toulouse, disponibles depuis la
page web du livre et depuis le site [Link]
1. La bibliothèque PuLP fonctionne dans le cadre plus général de la programmation linéaire mixte, et, au-delà du
programme de résolution libre fourni, se couple avec des programmes commerciaux performants.
103
7. La boîte à outils scientifiques SciPy
-ay
-ac
cmap = plt.get_cmap("RdBu")
# Création de la grille
X, Y = [Link][xmin:xmax:100j, ymin:ymax:100j]
positions = [Link]([[Link](), [Link]()])
FIGURE 7.6 – Localisation des suffixes -ac et -ay dans les toponymes. Dans cet exemple on associe alors une valeur
positive aux régions où la densité de toponymes en -ay est forte (bleu d’après la palette de couleur), et une valeur
négative (rouge) là où les toponymes se terminent en -ac.
104
7.4. Le module de statistiques
1. en haut à gauche, on représente un nuage de points avec une couleur associée à chaque
suffixe :
105
7. La boîte à outils scientifiques SciPy
Une autre méthode repose sur l’estimation par noyau, Kernel Density Estimation (KDE)
en anglais. L’estimation par noyau permet de lisser les points dans l’espace afin d’obtenir une
représentation de densité sous forme d’une fonction continue. Chaque échantillon est alors re-
présenté par une distribution (le noyau, souvent gaussien) et une bande passante (bandwidth)
qui contrôle la taille du noyau autour de chaque point.
En quelques mots…
La bibliothèque SciPy est basée sur la bibliothèque NumPy (☞ p. 69, § 5) ; elle s’est
construite à partir de contributions de nombreux laboratoires de recherche sous la forme
d’un portefeuille de modules qui fournissent des fonctions de base pour chacun de ses
domaines scientifiques.
La bibliothèque comprend d’autres modules qui ne seront pas présentés dans cet ou-
vrage. Le module linalg est très proche de son équivalent NumPy [Link] ; le module
spatial se prête aux problématiques du plan et de l’espace (enveloppes convexes, tri-
angulation de Delaunay, distances, etc.) ; le module ndimage propose une interface pour
manipuler les images mais nous avons fait le choix de nous concentrer sur la bibliothèque
OpenCV (☞ p. 299, § 20.1).
Le fonctionnement du module de traitement du signal [Link] n’est pas traité
dans ce chapitre, mais il sera abordé dans l’interlude sur la démodulation des signaux FM
(☞ C p. 287).
106
8
L’environnement interactif Jupyter
Les parties interactives de ce chapitre sont disponibles sur la page web du livre :
[Link]
L’
environnement Jupyter permet, au sein d’une application web, dans un navigateur, de
créer et de partager des documents statiques ou interactifs, qui contiennent du code
à exécuter, des équations, des visualisations et du texte. L’initiative a démarré par le
projet IPython pour un terminal Python plus interactif, avec des fonctionnalités évoluées.
Jupyter propose de travailler dans des fichiers notebooks, qui sont des fichiers divisés en
cellules, lesquelles contiennent :
— du code en Python ;
— du texte au format Markdown, avec la possibilité d’intégrer du code HTML ;
— le résultat produit par chaque cellule Python, au format HTML.
Les dernières avancées du format HTML5 permettent à Python de représenter des structures
de données sous un format convivial, éventuellement interactif, adapté au format du web. Les
images, vidéos et formats audio ont une représentation naturelle ; nous pourrons également
par la suite personnaliser les représentations de certains objets.
Les fichiers notebooks portent l’extension .ipynb pour IPython Notebook, qui était le for-
mat d’origine. En 2014, le projet Jupyter a démarré afin de décliner le concept de notebook
pour d’autres langages : Jupyter est un acronyme basé sur le nom de trois langages de pro-
grammation : Julia, Python et R. IPython, qui était le nom du projet d’origine, consacré à
Python, est devenu le projet responsable des spécificités Python, du « noyau » Jupyter. En
2018, Jupyter Lab a été publié, pour proposer un environnement plus convivial pour l’édi-
tion et l’exécution de notebooks. Les deux projets Jupyter Notebook et Jupyter Lab continuent
d’exister à l’heure où ces lignes sont écrites : le format du fichier est le même ; seul change
l’environnement.
Pour lancer l’environnement Jupyter, depuis un terminal (ou l’environnement Anaconda
Prompt sous Windows), entrer la commande :
$ jupyter lab
107
8. L’environnement interactif Jupyter
Les cellules de code sont exécutées dans un interpréteur Python qui tourne en tâche de
fond, appelé « noyau ». Les raccourcis Ctrl+Entrée et Maj+Entrée permettent d’exécuter la
cellule courante en gardant la cellule active ou en passant la sélection à la cellule suivante.
Xavier Olive
108
8.2. Matplotlib en mode intégré
Souvent on utilise ces commandes dans les notebooks partagés pour télécharger des
données (avec wget, ou avec git) ou pour installer des bibliothèques (☞ p. 344, § 24.2).
— Des commandes spéciales spécifiques à l’environnement Jupyter : la commande peut
s’appliquer à la ligne courante (préfixe %) ou à la cellule courante (préfixe %%). Les com-
mandes les plus courantes permettent de remplacer le contenu d’une cellule par celui
d’un fichier %load [Link], d’exécuter le contenu d’un fichier %run [Link], ou
de mesurer le temps d’exécution d’une cellule.
On notera alors la différence entre %time (mesure du temps d’exécution) et %timeit
(mesure intelligente, par moyenne sur plusieurs exécutions) :
%%time
estimation_pi(50)
3.1415946525910106
%%timeit
estimation_pi(50)
35.2 µs ± 1.39 µs per loop (mean ± std. dev. of 7 runs, 10000 loops each)
Il est également possible d’utiliser le préfixe %% pour exécuter des cellules dans un
autre langage de programmation. Ruby fait partie des langages activés par défaut.
%%ruby
def longest_repetition(string)
max = [Link](&:itself).map(&:last).max_by(&:size)
max ? [max[0], [Link]] : ["", 0]
end
print(longest_repetition("aaabb"))
["a", 3]
109
8. L’environnement interactif Jupyter
La fonction crée ici un slider qui au déplacement de son curseur met à jour le rendu de
la fonction en question. Le bon widget (slider, bouton à cocher, etc.) est déterminé de manière
automatique en fonction des arguments passés.
interact(plt_sinus, n=(1, 20), linestyle=["solid", "dashed", "dotted"])
La fonction interact construit une interface à base de widgets, des briques de base in-
teractives qui permettent d’interagir avec l’utilisateur. La documentation de la bibliothèque
ipywidgets présente de manière exhaustive l’ensemble des widgets accessibles, tous des élé-
ments HTML qui permettent de constituer une interface utilisateur graphique (GUI) intégrée
dans un navigateur web. L’exemple suivant en présente quelques-uns À. La page web associée
au chapitre montre ces éléments de manière interactive.
À chaque widget est associé un élément Layout qui concentre un ensemble d’éléments
de style Á : on y place généralement des contraintes sur la taille du widget et ses marges.
Enfin les widgets peuvent être concaténés et positionnés à l’aide d’éléments conteneurs : on
retiendra les plus utilisés : HBox qui concatène des éléments de manière horizontale, et VBox
qui les concatène de manière verticale.
from ipywidgets import IntSlider, Dropdown, HTML, Button, ColorPicker
from ipywidgets import Layout, HBox, VBox
1. [Link]
110
8.4. Interactivité des widgets
ligne1 = [ # À
IntSlider(value=7, min=0, max=10, step=1, layout=layout),
Dropdown(options=["français", "anglais"], value="français", layout=layout),
Button(description="Warning!", button_style="info"),
]
ligne2 = [
ColorPicker(value="#008f6b", layout=layout),
HTML('<audio controls><source src="[Link]" type="audio/ogg"></audio>'),
]
VBox([HBox(ligne1), HBox(ligne2)])
f_countries = Path("[Link]")
countries = [Link](f_countries.read_text())
display(dropdown, output)
111
8. L’environnement interactif Jupyter
[Link](affiche_drapeau, names="value") # Â
En quelques mots…
— Les notebooks proposent un format convivial pour coupler texte, code et résultats
à visualiser. Il est également relativement immédiat de programmer des interfaces
utilisateurs graphiques (GUI) simples à l’aide de widgets auxquels on associe des
fonctions de rappel, nommées callback.
— Les notebooks ne conviennent pas pour écrire, factoriser, réutiliser ni partager un
code bien construit et documenté. Ils ne remplacent pas un vrai projet Python en
bonne et due forme (☞ p. 349, § 25) ; en revanche, ils peuvent servir à l’illustrer et
le documenter. La conversion de fichiers notebooks vers des pages web classiques
peuvent faciliter l’écriture d’un site web de documentation.
9 Attention !
Bien garder en tête que les résultats apparaissent dans l’ordre dans lequel les cellules
ont été exécutées (voir les numéros en tête de cellule) et non dans l’ordre chronologique
du notebook. C’est un des principaux reproches faits à cet environnement de travail.
112
C
Interlude
Reconstruire une carte d’Europe
Ce chapitre est disponible sous forme interactive sur la page web du livre :
[Link]
L
a construction d’une carte est un défi. La position de points repérés par des coordonnées
de latitude et de longitude sur le globe terrestre se prête mal à une représentation sur
un plan à deux dimensions. L’opération qui consiste à attribuer des coordonnées 𝑥 et 𝑦
à des points définis par une latitude et une longitude s’appelle une projection.
Parmi les grandes familles de projections, on trouve les projections conformes, qui locale-
ment, c’est-à-dire autour d’une position de référence, conservent les angles, et les projections
équivalentes, qui conservent les surfaces. En pratique, beaucoup de projections usuelles sont
des compromis qui ne respectent ni les angles ni les distances.
Une projection couramment utilisée est la projection définie par Gerardus Mercator en
1569. Elle étire les latitudes de sorte que toute ligne droite tracée sur la carte représente une
route à cap constant. Si cette propriété est intéressante pour la navigation en mer, la projection
de Mercator est également décriée parce qu’elle donne une mauvaise perception de la taille
des masses terrestres : le Groenland paraît aussi grand que l’Afrique alors que sa superficie est
quatorze fois moindre.
Les projections conformes sont intéressantes à l’échelle d’un pays parce qu’elles respectent
(localement) les distances. On peut alors tirer un trait entre deux villes et obtenir le plus court
chemin entre ces deux positions. À l’échelle mondiale, le plus court chemin entre deux posi-
tions correspond au grand cercle, l’intersection entre la sphère et le plan qui passe par les deux
positions et le centre de la Terre.
Cette interlude illustre un problème de projection posé différemment : Étant donné un
nombre fixé de villes, comment les placer sur une carte de sorte à respecter les distances entre
toutes les paires de positions ? Cette question s’exprime comme un problème d’optimisation
qui peut se résoudre avec Python et le module [Link].
On donne une liste de villes d’Europe (et 𝑛 = 36 le nombre de villes) :
villes = [
113
Interlude
On fournit également sur la page web du livre une matrice de distances entre les villes.
La matrice des distances est carrée (𝑛 × 𝑛), symétrique, positive et nulle sur la diagonale. Les
distances y sont exprimées en kilomètres.
import numpy as np
Le problème d’optimisation
On considère une matrice de distances qui séparent des villes d’Europe. On cherche à
trouver leurs positions 𝑥𝑖 , 𝑦𝑖 sur une carte de sorte que les distances entre les positions soient
respectées. On cherche alors à minimiser la somme :
2 2 2
𝑓 (𝑥0 , 𝑦0 , ⋯ , 𝑦𝑛 ) = ∑ ∑ ((𝑥𝑖 − 𝑥𝑗 ) + (𝑦𝑖 − 𝑦𝑗 ) − 𝑑𝑖,𝑗2 ) (8.1)
𝑖 𝑗
On somme tous les écarts au carré entre les distances calculées à partir des positions 𝑥𝑖 , 𝑦𝑖
et les distances données. Il s’agit de minimiser les écarts entre toutes ces distances.
Avec 36 villes, nous avons un problème d’optimisation à 72 variables de décision sur des
valeurs flottantes. Les méthodes d’optimisation présentes dans le module [Link] sont
basées sur l’évaluation du gradient de la fonction 𝑓 à optimiser. La fonction qui calcule le
gradient est donnée ci-contre sous forme informatique avec numpy.
114
Reconstruire une carte d’Europe
def gradient(*args):
"""Calcul du gradient de la fonction critere.
115
Interlude
Maintenant que tout est prêt, on peut démarrer l’optimisation. En fonction de l’état initial
(aléatoire), on arrive en général à converger en une quarantaine d’itérations :
import [Link] as sopt
solution = sopt.fmin_bfgs(critere, x0, fprime=gradient, retall=True)
Optimization terminated successfully.
Current function value: 0.000000
Iterations: 47
Function evaluations: 49
Gradient evaluations: 49
On peut alors afficher l’ensemble des villes dans le plan. La version en ligne utilise les
possibilités d’animation de Matplotlib pour une version animée qui montre les positions des
villes bouger après chaque itération du problème d’optimisation.
116
Reconstruire une carte d’Europe
ax.set_xticklabels([])
ax.set_yticklabels([])
ax.set_axis_off()
117
Interlude
FIGURE – La projection de Mercator (en haut) ne conserve pas les distances mais la projection conforme
conique de Lambert (EPSG 3034, en bas) est proche de l’optimum.
Pour cet exemple, on fournit les coordonnées de latitude et longitude. Le module pyproj
permet de projeter les coordonnées selon différents systèmes de projection définis selon une
syntaxe précise, ou identifiés par un code EPSG : on utilise alors la projection de Mercator
(EPSG 3395) et la projection conforme conique de Lambert centrée sur l’Europe (EPSG 3034).
La projection de Mercator initialise les coordonnées des villes à une position qui est incom-
patible avec les distances alors que la projection de Lambert fournit des coordonnées initiales
qui sont très proches de l’optimum.
118
9
L’analyse de données avec Pandas
P
andas propose un format de structure de données tabulaires. C’est une bibliothèque qui
convient particulièrement au traitement des jeux de données présentés sous forme de
tableaux (format CSV ou Excel), ou des bases de données relationnelles (comme MySQL
ou MongoDB). Pandas (l’abréviation de panel data) offre notamment des facilités pour lire,
prétraiter, sélectionner, redimensionner, grouper, agréger et visualiser des données.
L’usage est d’importer la bibliothèque Pandas sous l’alias pd :
L’usage est généralement d’avoir en première ligne d’un fichier CSV une ligne d’en-tête
119
9. L’analyse de données avec Pandas
qui explicite le contenu de chacune des colonnes. Pandas fait ici cette hypothèse (à tort) alors
que ces informations sont absentes. Les métadonnées relatives aux colonnes sont néanmoins
décrites sur la page web du jeu de données. Nous allons n’en sélectionner que quelques-unes :
le paramètre usecols décrit l’index des colonnes à considérer ; le paramètre names les nomme.
villes = pd.read_csv(
"villes_france.csv",
nrows=5, usecols=[5, 8, 16, 19, 20, 25, 26],
names=[
"nom", "code postal", "population", "longitude", "latitude",
"altitude_min", "altitude_max",
],
)
L’affichage est maintenant plus facile à appréhender : une ligne par entrée, et une colonne
par aspect (on parle de feature) associé à chaque entrée. Ici, un petit défaut subsiste : les codes
postaux ont été interprétés comme des entiers et les zéros initiaux ont alors disparu. Il est
possible de spécifier le type associé à chaque colonne dans le paramètre dtype.
Une fois les paramètres ajustés, on peut alors lire le fichier en entier après avoir enlevé le
paramètre nrows. La structure de base de Pandas est le DataFrame, un « tableau de données »
([Link]). À l’instar de NumPy, l’attribut shape décrit le format du tableau en mémoire,
ici 36700 lignes pour 7 colonnes.
villes = pd.read_csv(
"villes_france.csv",
usecols=[5, 8, 16, 19, 20, 25, 26], names=[
"nom", "code postal", "population", "longitude", "latitude",
"altitude_min", "altitude_max",
],
dtype={"code postal": str},
)
120
9.1. Les bases de Pandas
>>> type(villes)
[Link]
>>> [Link]
(36700, 7)
Une série consiste en un tableau NumPy, accessible par l’attribut values, un dtype, un
index, et, le cas échéant, un nom name.
>>> ([Link], [Link],
... [Link], [Link])
(array([ 500, 1000, 100, ..., 8938, 36979, 6080]),
dtype('int64'),
RangeIndex(start=0, stop=36700, step=1),
'population')
On peut indexer un DataFrame à l’aide d’une liste de noms de colonnes pour n’en extraire
que certaines. Si la liste n’a qu’un seul élément, Pandas retourne un tableau ([Link]) à
une seule colonne, différent d’une colonne ([Link]).
villes[["population"]]
121
9. L’analyse de données avec Pandas
population
0 500
1 1000
2 100
3 1400
4 100
… …
36695 10195
36696 10454
36697 8938
36698 36979
36699 6080
On utilise en général l’indexation par une liste pour sélectionner un jeu de features :
villes[["nom", "population"]].head()
nom population
0 Ozan 500
1 Cormoranche-sur-Saône 1000
2 Plagne 100
3 Tossiat 1400
4 Pouillat 100
De nombreuses informations parmi celles présentées ici sont rassemblées dans le résultat
de la méthode .info(). Celle-ci est en réalité peu utilisée, mais elle rassemble toutes les infor-
mations pertinentes quant aux structures de données étudiées. La méthode .describe() offre
un autre type d’informations statistiques sur la distribution de chacune des features.
>>> [Link]()
<class '[Link]'>
RangeIndex: 36700 entries, 0 to 36699
Data columns (total 7 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 nom 36700 non-null object
1 code postal 36700 non-null object
2 population 36700 non-null int64
3 longitude 36700 non-null float64
4 latitude 36700 non-null float64
5 altitude_min 36568 non-null float64
6 altitude_max 36568 non-null float64
dtypes: float64(4), int64(1), object(2)
memory usage: 2.0+ MB
>>> [Link]()
122
9.2. Visualisation, sélection, indexation
La colonne non nommée située le plus à gauche de l’affichage ci-dessus se nomme index. Il
existe différentes manières d’indexer un [Link]. Un index numérique peut faire l’affaire :
>>> [Link]
RangeIndex(start=0, stop=36700, step=1)
>>> [Link][16123]
nom Saint-Étienne
code postal 42000-42100-42230
population 172700
longitude 4.4
latitude 45.4333
altitude_min 422
altitude_max 1117
Name: 16123, dtype: object
Il est également possible de choisir une colonne sur laquelle indexer le tableau, par exemple
le nom, ou le code postal. Si l’index est unique, un [Link] est renvoyé, sinon on récupère
un sous-tableau [Link].
>>> villes.set_index("nom").loc["Cannes"]
code postal 06400-06150
population 72900
longitude 7.01667
latitude 43.55
altitude_min 0
altitude_max 260
Name: Cannes, dtype: object
On notera ici que la plupart des opérations Pandas ont un comportement par défaut qui ne
modifie pas le [Link] mais en renvoie une copie modifiée. Ce paradigme favorise une
expression des traitements de données chaînées, c’est-à-dire où les opérations sont empilées
les unes sur les autres de manière linéaire.
>>> villes.set_index("nom").loc["Saint-Martin"]
code postal population longitude latitude altitude_min altitude_max
nom
Saint-Martin 32300 400 0.366667 43.5000 159.0 263.0
Saint-Martin 54450 100 6.752780 48.5681 241.0 301.0
Saint-Martin 65360 400 0.083333 43.1667 332.0 489.0
Saint-Martin 66220 100 2.466670 42.7833 268.0 642.0
Saint-Martin 67220 300 7.300000 48.3500 268.0 615.0
Saint-Martin 83560 200 5.884780 43.5892 343.0 582.0
Saint-Martin 97150 36979 18.091300 -63.0829 NaN NaN
123
9. L’analyse de données avec Pandas
L’argument .loc supporte un deuxième argument pour une sélection à la fois sur les lignes
et les colonnes.
>>> villes.set_index("code postal").loc["74110", ["nom", "altitude_max"]]
nom altitude_max
code postal
74110 Montriond 2340.0
74110 Morzine 2460.0
74110 Essert-Romand 1780.0
74110 La Côte-d’Arbroz 2240.0
Quand l’index est numérique, certaines opérations peuvent perturber l’ordre des index.
>>> villes.sort_values("nom")
>>> villes.sort_values("nom").index
Int64Index([26263, 21095, 22969, 23403, 20841, 21196, 8852, 9102, 16793, 9063,
...
11020, 21409, 6232, 21396, 21507, 10973, 7783, 11136, 25430, 1228],
dtype='int64', length=36700)
Dans ce cas, l’argument .iloc prend tout son sens : .loc procède à une indexation basée sur
l’index du [Link] alors que .iloc permet de compter les lignes dans l’ordre dans lequel
elles apparaissent, que les index aient été modifiés ou qu’une opération ait modifié l’ordre des
lignes :
>>> villes.set_index("nom").iloc[0]
code postal 01190
population 500
longitude 4.91667
latitude 46.3833
altitude_min 170
altitude_max 205
Name: Ozan, dtype: object
124
9.2. Visualisation, sélection, indexation
Itération sur les lignes d’un tableau. L’itération ligne par ligne est possible avec l’opéra-
teur .iterrows(). Elle renvoie des tuples index, ligne ; il est toutefois préférable de toujours
réfléchir à une manière d’obtenir le résultat voulu à l’aide d’opérations vectorielles, qui sont
beaucoup plus efficaces.
for index, ligne in villes.set_index("code_postal").iterrows():
print(index, [Link])
break
01190 Ozan
%%time
# En itérant sur les lignes
sorted(([Link], [Link]) for index, ligne in [Link]())[-5:]
[(344900, 'Nice'),
(439600, 'Toulouse'),
(474900, 'Lyon'),
(851400, 'Marseille'),
(2211000, 'Paris')]
%%time
# En écriture vectorielle
villes.sort_values("population").tail()[["population", "nom"]]
CPU times: user 11.7 ms, sys: 1.1 ms, total: 12.8 ms
Wall time: 15.3 ms
population nom
2049 344900 Nice
11718 439600 Toulouse
28152 474900 Lyon
4439 851400 Marseille
30437 2211000 Paris
Intégration avec Matplotlib. Les [Link] et les [Link] sont tous équipés du mot-clé
.plot qui donne accès à l’ensemble des méthodes Matplotlib d’affichage. On peut par exemple
afficher facilement la distribution que suit une feature particulière, ici la population des com-
munes :
fig, ax = [Link](figsize=(10, 5))
villes["population"].[Link](ax=ax, bins=20, lw=3, ec="w", fc="k")
ax.set_yscale("log") # axe logarithmique pour explorer la distribution
Cette distribution permet alors de choisir judicieusement des critères pour sélectionner
certaines lignes de notre tableau. Ici on sélectionne les communes qui ont plus de 200 000
habitants puis on les trie par ordre décroissant de population.
125
9. L’analyse de données avec Pandas
Fréquence
104
103
102
101
100
0 500_000 1_000_000 1_500_000 2_000_000
Population
nom population
30437 Paris 2_211_000
4439 Marseille 851_400
28152 Lyon 474_900
11718 Toulouse 439_600
2049 Nice 344_900
16755 Nantes 283_300
27303 Strasbourg 272_100
13338 Montpellier 253_000
12678 Bordeaux 235_900
22744 Lille 225_800
13467 Rennes 206_700
On notera ici le mot-clé .style qui donne accès à un grand nombre de fonctionnalités
Pandas pour personnaliser l’affichage d’un [Link] (ici on ajoute un séparateur de mil-
liers sur l’affichage des populations). Cette possibilité offerte par Pandas ne sera pas détaillée
dans cet ouvrage mais le lecteur pourra se référer à la documentation officielle (en anglais) :
[Link]
Les méthodes eval et query. À l’image de numexpr, Pandas met à disposition deux méthodes
particulières qui compilent des expressions et les exécutent sur le [Link] en une seule
itération.
La méthode .eval() évalue l’expression passée en paramètre :
>>> # valeur médiane des populations des communes de France
>>> [Link]("[Link]()")
400.0
>>> [Link]("altitude_max - altitude_min")
0 35.0
1 43.0
2 362.0
3 257.0
...
36696 NaN
36697 NaN
36698 NaN
36699 NaN
Length: 36700, dtype: float64
126
9.3. Enrichissement, agrégation
C’est surtout la méthode .query() qui est couramment utilisée pour sélectionner les lignes
d’un [Link] en fonction d’un critère. Ce formalisme simplifie l’écriture (plus de sou-
plesse dans la syntaxe), limite les erreurs (le nom du [Link], ici villes n’a pas besoin
d’être rappelé) et améliore la performance du code (une seule itération contre trois dans cet
exemple simple).
[Link][(villes.altitude_min > 1000) & ([Link] > 2000)]
[Link]("altitude_min > 1000 and population > 2000")
Si l’expression doit évaluer le contenu d’une variable locale, on peut la rappeler à l’aide du
symbole @ :
alt_value, pop_value = 1000, 2000
[Link]("altitude_min > @alt_value and population > @pop_value")
Supposons que l’on souhaite agréger les données qui nous sont fournies par département.
Il est possible de reconstruire le département à partir des deux premiers chiffres du code postal.
La méthode .apply() prend en paramètre une fonction, anonyme ou non, à appliquer à chacun
des éléments de la [Link].
villes.code_postal.apply(lambda code: code[:2])
Pour certains types de données, notamment les chaînes de caractères str et les données
temporelles, un attribut permet de propager les méthodes associées pour les appliquer à cha-
cun des éléments de la [Link]. Ainsi, pour obtenir le même résultat, on peut appliquer
l’opérateur [:2] à l’attribut .str :
>>> villes.code_postal.str[:2]
0 01
1 01
2 01
3 01
4 01
127
9. L’analyse de données avec Pandas
..
36695 97
36696 97
36697 97
36698 97
36699 97
Name: code_postal, Length: 36700, dtype: object
Toutes les méthodes applicables aux chaînes de caractères sont disponibles, par exemple
.[Link]() ou .[Link]("0"). Les méthodes applicables aux données temporelles seront
utilisées dans un exemple plus loin (☞ p. 241, § 16.1) : elles sont appliquées à l’attribut .dt,
comme .[Link], .dt.total_seconds() ou .dt.tz_localize().
La série étant toujours très longue, on peut agréger cette [Link] pour n’afficher que les
éléments uniques. Un tri de la série préalable permet de récupérer les éléments uniques dans
l’ordre lexicographique :
>>> villes.code_postal.str[:2].sort_values().unique()
array(['01', '02', '03', '04', '05', '06', '07', '08', '09', '10', '11',
'12', '13', '14', '15', '16', '17', '18', '19', '20', '21', '22',
'23', '24', '25', '26', '27', '28', '29', '30', '31', '32', '33',
'34', '35', '36', '37', '38', '39', '40', '41', '42', '43', '44',
'45', '46', '47', '48', '49', '50', '51', '52', '53', '54', '55',
'56', '57', '58', '59', '60', '61', '62', '63', '64', '65', '66',
'67', '68', '69', '70', '71', '72', '73', '74', '75', '76', '77',
'78', '79', '80', '81', '82', '83', '84', '85', '86', '87', '88',
'89', '90', '91', '92', '93', '94', '95', '97'], dtype=object)
Le cas particulier de la Corse nous permet d’illustrer une manière de modifier le contenu
d’un [Link] sans retourner de copie. Si cette manière de procéder manque d’élégance,
il conviendra néanmoins d’y songer quand elle clarifie la lisibilité du code :
# On utilise ici .contains qui permet l'utilisation d'expressions régulières
[Link][villes.code_postal.[Link]("^20[01]"), "departement"] = "2A"
[Link][villes.code_postal.[Link]("^20[26]"), "departement"] = "2B"
128
9.3. Enrichissement, agrégation
L’ajout de cette colonne nous permet alors de procéder à des agrégations par départe-
ment. La méthode qui permet ces opérations est .groupby(). Appelée seule, elle ne renvoie
qu’un objet de type DataFrameGroupBy sans grand intérêt. Cette structure permet néanmoins
d’appliquer des opérations d’agrégation.
>>> [Link]("departement")
<[Link] object at 0x7fda22877520>
Pour mieux appréhender l’opérateur, il est possible d’itérer dessus : Pandas renvoie alors
une valeur unique de clé (ici departement), puis le sous-tableau de villes pour lequel toutes les
valeurs de departement sont égales à la clé :
>>> for dept, df in [Link]("departement"):
... print(f"Clé: {dept}; taille: {[Link]}")
Clé: 01; taille: (424, 8)
Clé: 02; taille: (816, 8)
Clé: 03; taille: (319, 8)
Clé: 04; taille: (193, 8)
Clé: 05; taille: (182, 8)
[tronqué]
— une fonction (ou liste de fonctions) d’agrégation à appliquer à chacune des features. Les
fonctions d’agrégation les plus communes sont accessibles par une chaîne de caractères,
mais il serait également possible de passer une fonction personnalisée ;
stats = [Link]("departement").agg(
dict(
nom="count", # nombre de villes
population="sum", # population totale
129
9. L’analyse de données avec Pandas
On peut alors récupérer les départements les plus peuplés par exemple :
stats.sort_values("population", ascending=False).head(5)
[Link]("departement",).apply(
# trier chaque tableau par population et garder les deux premières lignes
lambda df: df.sort_values("population", ascending=False).head(2)
).head(12)[["nom", "population"]]
nom population
departement
01 375 Bourg-en-Bresse 40200
275 Oyonnax 23100
02 1132 Saint-Quentin 56800
478 Soissons 28500
03 1294 Montluçon 39500
1369 Vichy 25200
04 1717 Manosque 22300
1738 Digne-les-Bains 17300
05 1818 Gap 38600
1925 Briançon 11600
06 2049 Nice 344900
1999 Antibes 77000
130
9.4. Fusion de données
Pour fusionner deux tables (opération de jointure dans le langage des bases de données) à
l’aide de la méthode .merge(), il faut préciser suivant sur quelle(s) colonne(s) (quelle clé) baser
notre fusion :
— si les colonnes ont le même nom dans les deux tables, on peut utiliser l’argument on= ;
sinon, on peut raffiner à l’aide de left_on= et right_on= (pour gauche et droite) ;
— si la jointure doit se faire sur l’index, préciser left_index=True ou right_index=True ;
— la méthode de jointure par défaut est "inner", ce qui signifie que seuls les éléments
clés présents dans les deux tables sont conservés. Les autres méthodes sont "left" (on
conserve tous les éléments de la table de gauche), "right" (tous les éléments de la table
de droite), "outer" (tous les éléments présents dans une table ou l’autre).
Ici, le code du département est la clé du tableau stats. Dans le tableau de référence télé-
chargé sur [Link] c’est la colonne code_departement.
stats_avec_nom = [Link](departements, left_index=True, right_on="code_departement")
131
9. L’analyse de données avec Pandas
132
9.6. Le passage à Pandas 2.0
Si on compare les deux formats de types, on observe les valeurs NaN (du standard IEEE de
définition des flottants, également présents dans NumPy) alors que dans la nouvelle version, il
deviennent des objets vides étiquetés <NA>. Alors que NumPy ne permet pas d’avoir des types
de tableau int64 avec des valeurs vides, Arrow le permet. Ainsi, on peut comparer les dtypes
des colonnes avec le backend NumPy original "numpy_nullable" et avec le backend PyArrow,
qui autorise des valeurs nulles parmi les valeurs entières d’altitudes.
>>> [Link]
nom int64
population int64
longitude float64
latitude float64
altitude_min float64
altitude_max float64
dtype: object
>>> stats_arrow.dtypes
nom int64[pyarrow]
population int64[pyarrow]
longitude double[pyarrow]
latitude double[pyarrow]
altitude_min int64[pyarrow]
altitude_max int64[pyarrow]
dtype: object
villes_pl = pl.from_dataframe(villes.convert_dtypes(dtype_backend="pyarrow"))
villes_pl
133
9. L’analyse de données avec Pandas
— Les méthodes .query() et .eval() qui utilisent numexpr ne sont plus pertinentes en Po-
lars, puis les expressions qui utilisent [Link]("colonne") > valeur sont de type [Link]
et n’évaluent rien de manière gloutonne sur le dataframe.
— La syntaxe du groupby est aussi légèrement différente :
[Link]('departement').agg(dict(population="sum"))
# devient avec polars
villes_pl.group_by("departement").agg([Link]("population"))
134
9.7. Pandas ou Polars
Pour l’exemple de code Pandas un peu complexe qui apparaît plus haut :
[Link]("departement",).apply(
# trier chaque tableau par population et garder les deux premières lignes
lambda df: df.sort_values("population", ascending=False).head(2)
).head(12)[['nom', 'population']]
135
10
La visualisation interactive avec
Altair et ipyleaflet
Ce chapitre est disponible sous forme interactive sur la page web du livre :
[Link]
L
a place croissante que prend Jupyter dans l’écosystème Python et des technologies du
web fait la part belle à des outils de visualisation interactive. Nous présentons ici deux
de ces bibliothèques :
— la grammaire de visualisation (grammar of graphics) Altair complète la bibliothèque
Matplotlib, avec une syntaxe plus naturelle, qui traite séparément les données de la
spécification de la visualisation. Elle est basée sur les bibliothèques Javascript d3js et
Vega, couramment utilisées par les journalistes qui produisent des infographies ;
— la bibliothèque ipyleaflet propose quant à elle d’enrichir des fenêtres interactives de
visualisation de cartes, sur le modèle de Google Maps ou OpenStreetMap, avec des
données géographiques.
À l’instar de Pandas, une présentation complète d’Altair en quelques pages relève de la
gageure. Elle ne remplace pas la riche documentation de la bibliothèque accessible sur le site
[Link] Ce chapitre propose une simple introduction des possibilités de cette
bibliothèque, basée sur un jeu de données ¹ rendu célèbre par le chercheur suédois Hans Ros-
ling ². Ce fichier comprend, par année et par pays, des données de population, d’espérance de
vie et de PIB par habitant (rapportées en équivalent en dollars de 2011).
Altair est une grammaire graphique, c’est-à-dire un langage qui décrit une visualisation de
données avant de l’appliquer à un jeu de données particulier. Elle est construite autour de la bi-
bliothèque Pandas, prend en paramètre des [Link] et produit, à l’aide des bibliothèques
Javascript Vega Lite et [Link], une visualisation de données sur le web. Elle peut également
prendre en paramètre des URL vers des données ordonnées au format JSON, accessibles sur le
Net.
1. [Link]
2. [Link]
137
10. La visualisation interactive avec Altair et ipyleaflet
TABLEAU 10.1 – Aperçu du tableau data utilisé dans les exemples de ce chapitre
Le point de départ de la bibliothèque sera alors un jeu de données caractérisé par le mot-
clé anglais tidy (rangé) : cela signifie que les données brutes ont déjà été prétraitées, filtrées,
ordonnées pour produire des points qui s’approchent au plus près de la définition de la visua-
lisation. On manipulera alors :
— un [Link] qui sera intégré automatiquement à la visualisation, et où les types de
données seront inférés ;
— le chemin (URL) vers un fichier CSV ou JSON, lu directement par la bibliothèque Javas-
cript responsable du rendu.
Il convient de garder en mémoire les limitations classiques actuelles des moteurs de rendus
Javascript : à l’heure actuelle (2021), il faudra certainement se limiter à des visualisations qui
manipulent un ordre de grandeur de 100 000 points.
Le fichier fourni sur la page web précédente comprend quelques incohérences, des valeurs
manquantes (on reconstruit notamment la colonne continent), et on ne s’intéressera qu’aux
points situés entre 1950 et 2015, avec des valeurs présentes de population : le code Pandas qui
construit les données utilisées pour les visualisations de cette page (Tableau 10.1) est fourni
sur la page web du livre [Link]
L’usage est d’importer la bibliothèque Altair sous l’alias alt :
138
10.1. Encodages et marques
90
continent
Africa
80 Asia
life_expectancy
[Link](data_2015).encode( 50
x="GDP_per_capita", 40
y="life_expectancy", 30
color="continent" 20
).mark_point() 10
0
0 40,000 80,000 120,000
GDP_per_capita
Les marques les plus fréquentes ont toutes un nom explicite, qui n’appelle pas nécessaire-
ment d’explication approfondie :
mark_point() mark_circle() mark_square() mark_line() mark_area()
mark_bar() mark_tick()
Les canaux d’encodage les plus fréquents sont :
x abscisse
y ordonnée
couleur couleur de la marque
opacity transparence/opacité de la marque
shape forme de la marque
size taille de la marque
facet répétition du canal
Il est possible d’utiliser des arguments nommés pour les canaux sur le modèle x="x_data",
ou d’utiliser les constructeurs Altair associés alt.X("x_data") en paramètres nommés ou non,
qui permettent également de passer des arguments supplémentaires :
À un titre title différent pour annoter l’axe des ordonnées ;
Á une échelle scale qui ne comprend pas la valeur 0 ;
 un formatage particulier format pour compter les populations en millions. Le formatage
est défini par la bibliothèque web d3js : [Link]
Altair utilise le type de chacune des features à partir des dtype Pandas. Il est possible de
les spécifier néanmoins, et cette étape est nécessaire si les données sont passées par fichier :
— Q pour quantitative : des données numériques continues, comme une altitude, une tem-
pérature ;
— N pour nominal : des données textuelles, comme un nom de pays ;
— O pour ordinal : des données numériques entières pour des classements ;
— T pour temporal : des données temporelles.
data_france = [Link]('country == "France"') 65M
60M
[Link](data_france).encode(
alt.X("year:T", title="année"), # À
population
55M
alt.Y(
"population:Q", 50M
scale=[Link](zero=False), # Á
axis=[Link](format="~s") # Â 45M
),
40M
).mark_line() 1955 1965 1975 1985
année
1995 2005 2015
139
10. La visualisation interactive avec Altair et ipyleaflet
Un nuage de points sans encodage affiche un simple point. Bien que cette entrée ne renvoie
pas d’erreur, elle n’est pas pertinente en soi.
[Link](data).mark_point()
Pour un encodage de données nominales, une coordonnée est attribuée à chaque élément
unique de la feature. Les plages de couleurs sont également choisies en fonction, pour distin-
guer clairement une catégorie d’une autre.
Africa continent
Africa
[Link](data).encode( Asia Asia
continent
alt.Y("continent:N"), Europe Europe
North America
[Link]("continent:N") North America Oceania
South America
).mark_square() Oceania
South America
[Link](data_2015).encode(
alt.X(
"median(GDP_per_capita):Q",
title="PIB par habitant médian en 2015", axis=[Link](format="~s"),
),
alt.Y("continent:N"),
[Link]("continent:N"),
).mark_bar(size=10)
Africa continent
Africa
Asia Asia
continent
Europe Europe
North America
North America Oceania
Oceania South America
South America
D’autres opérateurs d’agrégation sont disponibles, notamment pour la somme sum, le pro-
duit product, la moyenne mean, le minimum min, le maximum max, le nombre d’éléments vides
missing, ou le nombre d’éléments distincts distinct.
140
10.2. Agrégation et composition
L’exemple suivant affiche le nombre de pays par continent. Chaque pays est représenté de
nombreuses fois dans le fichier (une fois par année) mais l’opérateur distinct comprend cette
nuance.
[Link](data).encode(
alt.X("distinct(country):N", title="Nombre de pays"),
alt.Y("continent:N"),
[Link]("continent:N"),
).mark_bar(size=10)
Africa continent
Africa
Asia
Asia
continent
Europe Europe
North America
North America Oceania
Oceania South America
South America
0 5 10 15 20 25 30 35 40 45 50 55 60
Nombre de pays
Il est possible de produire des agrégations quel que soit le canal d’encodage. Dans la visuali-
sation suivante, l’écart-type est encodé dans la couleur des barres. Comme le PIB par habitant
est annoté comme type de données quantitatif, Altair choisit une table de couleur adaptée
qui fait varier la saturation de la couleur, par opposition au type de données nominatif qui
fournit une table de couleur faisant varier la teinte.
Cet exemple est aussi l’occasion de préciser deux autres options :
À L’attribut sort permet ici de trier les catégories de l’axe Y suivant un critère qui peut
être arbitraire, croissant ou décroissant (par rapport à l’ordre alphabétique pour les va-
riables nominatives), ou suivant l’ordre associé à un autre canal d’encodage. Le signe -
dans l’exemple ci-dessous indique un ordre décroissant. Cette option permet d’ordon-
ner visuellement les barres par longueur décroissante plutôt que par ordre alphabétique
sur le nom des continents.
Á L’attribut scale fonctionne également pour le canal d’encodage de couleur : il permet
ici de calibrer les bornes inférieures et supérieures de la table de couleurs. Par défaut, ces
bornes sont assignées aux valeurs minimales et maximales trouvées dans les données.
[Link](data_2015).encode(
alt.X(
"mean(GDP_per_capita):Q",
axis=[Link](format="~s"), title="Moyenne du PIB par habitant",
),
alt.Y("continent:N", sort="-x"), # À
[Link](
"stdev(GDP_per_capita):Q", title="Écart-type",
scale=[Link](domain=(0, 30e3)) # Á
),
).mark_bar(size=10)
Oceania Écart-type
30,000
Europe
continent
Asia
North America
South America
Africa
141
10. La visualisation interactive avec Altair et ipyleaflet
L’Asie semble être le continent avec le plus d’inégalités de richesse. Un diagramme en boîte
permet de visualiser différemment les données : les éléments atypiques sortent des boîtes à
moustache et le canal d’encodage tooltip permet d’afficher le nom du pays quand on passe
la souris sur le point. Le Qatar en Asie et la Norvège en Europe par exemple sont des pays au
PIB par habitant très supérieur à celui des voisins.
[Link](data_2015).encode(
alt.X("GDP_per_capita:Q", axis=[Link](format="~s"), title="PIB par habitant",),
alt.Y("continent:N"),
[Link]("country:N"),
).mark_boxplot(size=10)
Africa
Asia
continent
Europe
North America
Oceania
South America
Le tracé d’histogrammes est vu par Altair du point de vue d’une agrégation particulière
où les échantillons sont répartis en classes (le mot-clé bin en anglais, déjà vu avec Matplotlib,
puis la méthode d’agrégation sans argument count()) : il faut donc préciser cette agrégation
pour visualiser des distributions.
Une autre fonctionnalité permise par Altair est la composition de graphes :
— l’opérateur + associe plusieurs couches (layers) sur la même visualisation ;
— les opérateurs | et & concatènent deux visualisations côte à côte (hconcat pour horizon-
tal) ou l’une au-dessus l’autre (vconcat pour vertical).
Lors de composition de graphes, il est possible de factoriser des spécifications. Dans
l’exemple suivant, le même graphe est affiché deux fois, la visualisation de droite ajoute un
canal d’encodage de couleur À. On notera également l’utilisation de la fonction .properties
Á qui permet entre autres de spécifier la taille de la fenêtre.
base = (
[Link](data_2015)
.encode(
alt.X(
"GDP_per_capita", bin=[Link](maxbins=30),
title="PIB par habitant", axis=[Link](format="$~s"),
),
alt.Y("count()", title="Nombre de pays"),
)
.mark_bar()
.properties(width=280, height=200) # Á
)
base | [Link]([Link]("continent")) # À
142
10.3. Transformation
50 50
continent
Africa
Asia
40 40
Europe
Nombre de pays
Nombre de pays
North America
Oceania
30 30
South America
20 20
10 10
0 0
$0k $40k $80k $120k $0k $40k $80k $120k
PIB par habitant PIB par habitant
Africa Africa
Asia Asia
continent
Europe Europe
North America North America
Oceania Oceania
South America South America
10.3. Transformation
Nous avons vu avec les méthodes d’agrégation que les visualisations peuvent appeler des
calculs intermédiaires sur les données d’origine. Ces calculs peuvent être faits via Pandas avant
de programmer une visualisation, ou au sein de la visualisation à l’aide de nombreuses fonc-
tions de transformation Altair spécifiques.
Les fonctions de transformation (les mots-clés suivant le modèle .transform_*) changent
la structure des données d’entrée pour y ajouter de nouvelles colonnes, ou features, filtrer ou
trier des lignes suivant un critère, ou opérer des jointures sur d’autres tables.
143
10. La visualisation interactive avec Altair et ipyleaflet
pays
400M France
Germany
Italy
Russia
300M
population
United Kingdom
200M
100M
0M
1955 1965 1975 1985 1995 2005 2015
année
L’exemple suivant met en application toutes les notions vues jusqu’ici. On cherche à af-
ficher les dix premiers pays suivant un critère donné sur le même jeu de données. Ici aucun
prétraitement Pandas n’a été réalisé. Tout est spécifié dans Altair :
— une spécification est factorisée entre les deux visualisations À. La différence réside dans
la feature attribuée au canal d’encodage x ;
— le critère est évalué sur la dernière donnée (en fonction de l’année) présente par pays :
les deux colonnes population et GDP_per_capita sont remplacées par la valeur corres-
pondant à l’année la plus récente. C’est l’agrégation argmax Á qui retrouve la dernière
donnée associée à chaque pays, la transformation calculate  sélectionne les données
de population en se basant sur les indices produits par argmax.
144
10.3. Transformation
(
[Link](alt.X("population:Q", axis=[Link](format="~s"))).transform_filter(
[Link].rank_pop <= 10 # Ä
)
| [Link](
alt.X("GDP_per_capita:Q", axis=[Link](format="$~s"), title="PIB par habitant")
).transform_filter(
[Link].rank_gdp <= 10 # Ä
)
)
pays
Pakistan Singapore
Nigeria Switzerland
Bangladesh Ireland
Japan Netherlands
0G 0.2G 0.4G 0.6G 0.8G 1G 1.2G 1.4G 1.6G $0k $40k $80k $120k
population PIB par habitant
145
10. La visualisation interactive avec Altair et ipyleaflet
10.4. Interactivité
L’interactivité la plus simple est celle qui est induite par l’encodage de canal tooltip : au
passage de la souris sur un point donné, un pop-up apparaît avec les informations spécifiées.
La documentation montre de nombreux exemples d’interactivité, basés sur les mouvements
de la souris, la sélection d’intervalles, ou d’autres.
L’exemple ci-dessous reprend le type de visualisation avec lequel Hans Rosling s’est illustré
lors de plusieurs conférences TED : un point correspond à un pays, sa taille à sa population.
Ici, on place en 𝑥 le PIB par habitant et en 𝑦 l’espérance de vie dans le pays.
On souhaite animer la visualisation par année pour pouvoir suivre la trajectoire de chacun
de ces pays dans cette espace. Cette sélection se fait au moyen d’un widget où la poignée
sélectionne l’année À. La méthode selection_single réagit en attribuant au champ year la
valeur positionnée sur le widget : la visualisation est alors mise à jour quand la valeur du
champ change.
L’encodage est basique Á ; le nom du pays s’affiche quand on passe la souris sur un point ;
le canal text servira à annoter certains points de manière permanente, directement sur la
visualisation ; l’axe des abscisses est choisi logarithmique. Au lieu de choisir la dernière année
qui contient des données, on choisit les données de l’année sélectionnée par le widget. Â
La visualisation est alors constituée de deux couches : les cercles de couleur (par conti-
nent) à la taille proportionnelle à la population du pays (en échelle logarithmique) ; et une
annotation textuelle pour certains pays dont la trajectoire reflète le cours de l’histoire (chute
de l’URSS, Khmers rouges au Cambodge, fin de l’Apartheid en Afrique du Sud, essor écono-
mique spectaculaire de la Corée du Sud).
annotate_countries = [
"South Africa", "United States", "France", "China", "Russia", "Nigeria",
"Brazil", "South Korea", "Japan", "India", "Cambodia",
]
146
10.5. Configuration
(
base.mark_circle()
.encode(
[Link]("continent:N"),
[Link]("population:Q", scale=[Link](domain=(5e6, 1e9), type="log")),
)
.add_selection(year_selector)
+ base.transform_filter(
{"field": "country", "oneOf": annotate_countries}
).mark_text(size=14, align="right", xOffset=-10, font="Ubuntu")
)
90
continent
Africa
85
Asia
80
Japan Europe
France North America
United States
South Korea Oceania
75 South America
China population
70 Brazil 10,000,000
20,000,000
65 Russia
100,000,000
Espérance de vie
India
60 200,000,000
Cambodia
55
South Africa 1,000,000,000
50
45
Nigeria
40
35
30
25
20
100 200 300 1000 2000 3000 10000 20000 30000 100000
PIB par habitant
10.5. Configuration
Il est possible de personnaliser l’affichage d’une visualisation Altair à trois niveaux :
— celui de l’encodage : on associe une valeur de couleur, de forme, à une feature ;
— celui de la marque : on spécifie localement la configuration ;
— celui de l’affichage complet, à l’aide des fonctions .configure_*().
La dernière méthode est souvent la manière privilégiée de procéder à des microajuste-
ments, par exemple sur la taille des polices, le positionnement des étiquettes.
base = (
[Link](data_france)
.encode(
alt.X("year:T", title="année"),
alt.Y("population:Q", scale=[Link](zero=False), axis=[Link](format="~s")),
)
.mark_line()
)
(
[Link](title="Population française", height=200, width=300)
.configure_axis(labelFontSize=12, titleFontSize=0, labelAngle=-30)
.configure_line(size=3, color="#008f6b")
.configure_title(anchor="start", fontSize=16, font="Fira Sans", color="#008f6b")
.configure_view(stroke=None)
)
147
10. La visualisation interactive avec Altair et ipyleaflet
Population française
M
65
M
60
M
55
population
M
50
M
45
M
40 0
6 70 80 90 00 10
19 19 19 19 20 20
année
10.6. Coordonnées géographiques
Le support pour les structures de données géographiques dans Altair est encore jeune à
l’heure où ces lignes sont écrites. Il est néanmoins possible de produire des cartes à partir de
fichiers au format standardisé pour décrire des informations géographiques, les formats les
plus courants étant GEOJSON et TOPOJSON.
Altair fournit alors :
— un marqueur spécialisé .mark_geoshape() ;
— deux canaux d’encodage pour la latitude et la longitude ;
— un type d’encodage pour les formes géométriques geojson (G) ;
— une opération de projection parmi une liste de projections simples : p. ex. mercator,
orthographic, conicConformal, etc.
La difficulté consiste alors ici à avoir accès à des fonds de carte pour créer des visualisations
de qualité. La bibliothèque fournit parmi les jeux de données officiels vega_datasets une carte
du monde de haut niveau et une carte des États-Unis à bonne résolution.
Pour un public francophone, on trouvera à l’heure de l’écriture de ces lignes :
— des données sur la France : [Link]
— des données sur la Belgique : [Link]
— des données sur la Suisse : [Link]
L’utilisation de la bibliothèque geopandas ³ (qui ne sera pas détaillée dans cet ouvrage) fa-
cilite la manipulation des fichiers GEOJSON et TOPOJSON sous forme de tableau Pandas dont les
colonnes sont les métadonnées, et une colonne particulière, généralement nommée geometry,
contient une structure qui représente la forme de l’objet en question.
import geopandas as gpd
github_url = "[Link]
regions_fr = [Link].from_file(
github_url.format(
user="gregoiredavid", repo="france-geojson",
path="[Link]",
)
)
3. [Link]
148
10.6. Coordonnées géographiques
departements_fr = [Link].from_file(
github_url.format(
user="gregoiredavid", repo="france-geojson",
path="[Link]",
)
)
belgique = [Link].from_file(
github_url.format(user="arneh61", repo="Belgium-Map", path="[Link]",)
).assign(
centroid_lon=lambda df: [Link].x,
centroid_lat=lambda df: [Link].y,
)
La structure geopandas peut être passée en argument de [Link] et il est alors possible
d’utiliser la marque géographique. Ici, on choisit le nom de la région en encodage de la couleur.
Dans l’exemple de la carte de la Belgique, on ajoute le nom de chaque province au centroïde
de la forme géométrique. Le nom de Bruxelles est enlevé (transform_filter) pour ne pas se
chevaucher avec celui du Brabant Flamand. Enfin, la coordonnée en latitude du texte est vo-
lontairement bruitée pour éviter les chevauchements (transform_calculate) ; des méthodes de
placement d’étiquettes textuelles sans chevauchement plus complexes existent mais sortent
du cadre de cet ouvrage.
base = [Link](belgique)
(
base.mark_geoshape(stroke="white").encode([Link]("NAME_1", title="Région"))
+ (
[Link](
[Link]("centroid_lon"),
[Link]("centroid_lat"),
[Link]("NAME_2"),
)
.mark_text(color="black", font="Ubuntu", fontSize=12)
.transform_filter("datum.NAME_2 != 'Bruxelles'")
.transform_calculate(centroid_lat="datum.centroid_lat + .1 * (random() - .5)")
)
)
Région
Bruxelles
Antwerpen Vlaanderen
Wallonie
Oost-Vlaanderen
West-Vlaanderen Limburg
Vlaams Brabant
Brabant Wallon
Hainaut Liège
Namur
Luxembourg
149
10. La visualisation interactive avec Altair et ipyleaflet
Contrairement à Matplotlib, la projection par défaut choisie pour les cartes est la projection
de Mercator. L’utilisation de la projection plate carrée qui associe la latitude à la coordonnée y
et la longitude à la coordonnée x est moins directe. La figure suivante compare à ce titre les trois
projections plate carrée, inadaptée pour la plupart des usages, Mercator, qui fonctionne par
défaut dans la plupart des régions du monde, et Lambert 93, définie ici manuellement, qui est la
projection standard en France. Un graticule (une grille de lignes isolatitudes et isolongitudes)
est ajouté À pour donner une meilleure perception des opérations de projection.
base = (
[Link](
[Link](step=[2, 2], extentMajor=([-5, 41], [11, 52])) # À
).mark_geoshape(fill="None", stroke="#008f6b")
+ [Link](regions_fr).mark_geoshape(
stroke="white", fill="#008f6b", strokeWidth=1.2
)
).properties(width=250, height=250)
(
[Link]("identity", reflectY=True).properties(title="Plate Carrée")
| [Link]("mercator").properties(title="Mercator (par défaut)")
| (
[Link]("conicConformal", rotate=[-3, -46.5], parallels=[49, 44])
.properties(title="Lambert 93")
)
).configure_title(font="Ubuntu", fontSize=15, anchor="start")
L’affichage de fonds de carte est rarement une fin en soi. L’intérêt est de pouvoir afficher
des informations supplémentaires. On peut ajouter une couche (layer) à notre fond de carte, et
utiliser les canaux d’encodage latitude et longitude. Nous illustrons ici cette utilisation avec
l’exemple des toponymes au suffixe -acum (☞ p. 105, § 7.4) : une colonne est ajoutée pour
catégoriser les villes par suffixe, l’encodage color se charge ensuite de la visualisation.
(
(
[Link](regions_fr)
.mark_geoshape(stroke="grey", fill="white")
.project("conicConformal", rotate=[-3, -46.5], parallels=[49, 44]) # Lambert 93
+ [Link](
[Link](
[
[Link](
150
10.6. Coordonnées géographiques
f"[Link]('.{fin}$')", engine="python"
).assign(suffixe=f"-{fin}")
for fin in ["ac", "ach", "acq", "ay", "az", "ecques"]
]
)
)
.mark_circle(opacity=0.5)
.encode(
[Link]("latitude"),
[Link]("longitude"),
[Link]("suffixe"),
[Link]("nom"),
)
)
.properties(title="Le suffixe -acum dans les toponymes en France")
.configure_legend(
labelFont="Ubuntu", titleFont="Ubuntu", labelFontSize=13, titleFontSize=14
)
.configure_title(font="Ubuntu", fontSize=16, anchor="start")
.configure_view(stroke=None)
)
L’interaction entre les données et les fonds de carte peut être plus marquée, comme dans
les cartes choroplèthes qui associent une couleur à une région géographique. On peut ici re-
prendre les statistiques de population par département français (☞ p. 132, § 9.4) pour associer
chaque mesure à un polygone affiché sur la carte.
Cette association se fait ici sur la base du numéro du département (dans la colonne de-
partement) qui est présent dans le [Link] et dans le fichier GEOJSON des départements
(dans la propriété code) à l’aide de la fonction .transform_lookup.
feature_list = ["altitude_max", "population"]
chart = (
[Link](departements_fr)
.mark_geoshape(stroke="white")
.encode([Link](["code", "nom"]))
151
10. La visualisation interactive avec Altair et ipyleaflet
altitude_max population
2,500,000
4,000
2,000,000
3,000
1,500,000
2,000
1,000,000
1,000
500,000
10.7. ipyleaflet
ipyleaflet ⁴ est une bibliothèque Python spécifiquement conçue pour l’environnement Ju-
pyter. Elle permet un portage de l’application Javascript Leaflet qui propose d’enrichir des
widgets de cartes interactives sur le modèle de Google Maps ou OpenStreetMap. La biblio-
thèque met à disposition un widget particulier Map, à l’image des autres structures ipywidgets
(☞ p. 107, § 8) qu’il est possible d’enrichir et d’équiper de fonctions callbacks (des fonctions
déclenchées automatiquement lors d’événements prédéfinis) pour l’interaction.
Une carte est alors initialisée sur des coordonnées géographiques avec un niveau de zoom.
Dans l’exemple ci-dessous, on affiche les vingt communes les plus peuplées au bout d’un Mar-
ker. Ces éléments sont par défaut interactifs : un clic dessus ouvre une fenêtre où l’on peut
insérer un nouveau widget au choix, ici un simple contenu enrichi avec le nom de la ville et
sa population.
Dans cet exemple, on ajoute également un menu déroulant Dropdown avec la liste des villes
affichées : la fonction de rappel récupère ici les coordonnées de la ville sélectionnée pour cen-
trer la carte dessus. Enfin, il est également possible d’enrichir une carte avec des informations
issues de fichiers au format GEOJSON, à l’image de ceux utilisés pour Altair. Le paramètre ho-
ver_style permet ici de configurer un comportement interactif simple où le style est mis à
jour quand la souris passe au-dessus d’un élément.
from ipyleaflet import Map, Marker, GeoData
from ipywidgets import HTML, Layout, Dropdown
4. [Link]
152
10.7. ipyleaflet
def on_click(info):
ville = top_20.set_index("nom").loc[info["new"]]
map_.center = ([Link], [Link])
map_.zoom = 8
geodata = GeoData(
geo_dataframe=departements_fr,
style={"color": "#008f6b", "opacity": 1, "fillOpacity": 0.1, "weight": 1},
hover_style={"color": "white", "fillOpacity": 0.4, "weight": 3, "zorder": 2},
)
map_.add_layer(geodata)
display(dropdown, map_)
153
10. La visualisation interactive avec Altair et ipyleaflet
En quelques mots…
— L’environnement Jupyter intégré aux navigateurs web permet d’importer les faci-
lités d’interactivité développées dans les bibliothèques Javascript modernes pour
produire des visualisations et environnements d’exploration des données tout en
restant dans l’ecosystème Python.
— Matplotlib (☞ p. 83, § 6) et Altair abordent la visualisation de données de deux
points de vue différents. Matplotlib propose un langage bas niveau et des struc-
tures de données qui permettent de configurer tous les éléments d’une présen-
tation graphique. Altair expose une grammar of graphics (grammaire de visuali-
sation) qui permet de spécifier une visualisation pour la décliner ensuite sur les
données. C’est probablement l’équivalent le plus proche des bibliothèques de vi-
sualisation avancées d’autres langages, comme ggplot2 en R.
— D’autres bibliothèques adoptent également le formalisme de la grammar of gra-
phics, certaines étant basées sur JavaScript, d’autres non. Dans ce livre, nous avons
choisi de présenter Altair, mais il existe d’autres options populaires, comme Plotly,
Bokeh (basé sur JavaScript), Seaborn (qui repose sur Matplotlib) ou ggplot (inspiré
de la bibliothèque R du même nom), qui offrent des fonctionnalités similaires. Ce-
pendant, ces deux bibliothèques ne seront pas couvertes dans cet ouvrage.
Pour aller plus loin
— The Grammar of Graphics, Leland Wilkinson, 2012
Springer, ISBN 978-1-4419-2033-1
— Fundamentals of Data Visualization, Claus O. Wilke, 2018
O’Reilly, ISBN 978-1-4920-3108-6
[Link]
154
11
L’analyse de tableaux
multidimensionnels avec Xarray
X
array est une bibliothèque largement utilisée pour l’analyse de données multidimen-
sionnelles et étiquetées. Considérée comme le pendant de Pandas pour les données
tabulaires, Xarray s’appuie sur NumPy pour étendre ses capacités à des structures de
données multi-dimensionnelles. Xarray permet de manipuler et d’analyser des données avec
des dimensions nommées, des coordonnées et des attributs, rendant le travail avec des en-
sembles de données scientifiques, climatiques ou géospatiaux plus intuitif et efficace.
L’usage est d’importer Xarray sous l’alias xr :
import xarray as xr
À l’instar du chapitre dédié à Pandas, ce chapitre propose une introduction aux fonction-
nalités de la bibliothèque Xarray, illustrées à travers un exemple concret utilisant un fichier de
prévisions de Météo France basé sur le modèle ARPEGE. Ce fichier, disponible en accès ouvert
mais uniquement pour quelques jours, peut être consulté sur la plateforme [Link] ¹. Le
fichier présenté ici est disponible sur la page web du livre.
155
11. L’analyse de tableaux multidimensionnels avec Xarray
156
11.2. La sélection de valeurs
Le type de chaque donnée (variable) est alors DataArray. Chaque DataArray contient alors
aussi des coordonnées, indices et attributs (metadata).
On peut contrôler les attributs pour cette variable particulière et constater qu’on a affaire
à une température en Kelvin.
[Link]
{
'GRIB_paramId': 130,
'GRIB_dataType': 'fc',
'GRIB_numberOfPoints': 386061,
'GRIB_typeOfLevel': 'isobaricInhPa',
...
'GRIB_units': 'K',
'long_name': 'Temperature',
'units': 'K',
'standard_name': 'air_temperature'
}
[Link](step=0).sel(isobaricInhPa=1000)
157
11. L’analyse de tableaux multidimensionnels avec Xarray
[Link](step=0).sel(
isobaricInhPa=1000, latitude=slice(53, 41), longitude=slice(-5, 10)
)
158
11.3. L’affichage graphique
159
11. L’analyse de tableaux multidimensionnels avec Xarray
import numpy as np
pente_iso = (
emiss_eau
* capa_calo
* pression_Pa
/ (ratio_vapeur * chaleur_comb * (1 - efficacite))
)
t_critique = (
160
11.4. Cas d’application : prédiction de trainées de condensation
-46.46
+ 9.43 * [Link](pente_iso - 0.053)
+ 0.72 * ([Link](pente_iso - 0.053)) ** 2
+ 273.15
)
t_critique
On peut alors construire un masque (tableau de booléens) qui vérifie les conditions d’ap-
parition de trainées de condensation et de persistance en cirrus artificiels. On calcule cet in-
dicateur uniquement à l’isobare 275 qui correspond plus ou moins à l’altitude de croisière des
avions de ligne.
date = contrails_croisiere.valid_time.values
date = [Link]("M8[ms]").astype("O")
# fg = ([Link](altitude=2800).isel(step=6).r >=100).plot(x='longitude')
# [[Link]() for ax in [Link]()]
# [Link]()
contrails_croisiere.plot(
ax=ax,
transform=PlateCarree(),
cmap=my_cmap,
vmin=0.1,
add_colorbar=False,
robust=True,
)
[Link]()
ax.set_title(f"Trainées de condensation possibles: {date:%d/%m/%y %H:%M} UTC")
161
11. L’analyse de tableaux multidimensionnels avec Xarray
FIGURE 11.1 – Localisation possible des trainées de condensation et image satellite correspondante.
Cette carte peut être comparée à une image satellite prise à la même heure. On utilise pour
ceci la bibliothèque SatPy qui est également basée sur Xarray, et qui accède aux données du
satellite NOAA 20, un satellite météorologique américain lancé en 2017 par la National Oceanic
and Atmospheric Administration (NOAA) en collaboration avec la NASA. Il fait partie du
programme JPSS, qui vise à fournir des données critiques pour les prévisions météorologiques,
la surveillance climatique et la recherche scientifique. Les données sont accessibles librement,
au format h5.
Le code n’est pas détaillé dans ces pages, mais accessible pour référence sur la page web
du livre.
162
III
Écrire un Python
naturel et
efficace
12
La programmation fonctionnelle
L’
apprentissage de l’informatique et de la programmation passe par la découverte de
différents paradigmes de programmation, autant de manières différentes d’aborder
et de décomposer des problèmes pour les traiter avec l’outil informatique.
La programmation impérative est le paradigme le plus répandu : on le retrouve en C, en
Java, et bien sûr en Python. Un programme s’y décompose comme une séquence d’instruc-
tions de base (assignation, branchement, boucle, etc.) à exécuter. On écrit alors une description
de l’algorithme à mettre en œuvre pour résoudre le problème. Ce mode de programmation,
proche des instructions exécutées par la machine, permet le contrôle de la performance.
La programmation déclarative considère un programme par la description du problème
qu’il résout. Le moteur du langage se charge ensuite de la mise en œuvre de l’algorithmique
de la résolution. C’est un paradigme de programmation sans effet de bord, c’est-à-dire sans état
interne permanent, où plusieurs exécutions du même programme renvoient le même résultat.
Par exemple, le langage LaTeX permet la composition de documents par une description des
éléments qui le constituent. Le langage CSS décrit la mise en page d’éléments sur une page
web : le navigateur utilise ensuite cette description pour assurer la mise en forme. Ce mode de
programmation, plus proche d’une description formelle, permet de s’assurer de la correction
des programmes, de prouver que le résultat qu’ils produisent est correct.
La programmation fonctionnelle est une variante de la programmation déclarative, qu’on
retrouve dans les langages Haskell, Scala ou OCaml. Ce paradigme voit la programmation
comme l’évaluation de fonctions mathématiques sans effet de bord.
Dans l’exemple suivant, la méthode .sort() appliquée à une liste suit un modèle impératif :
elle modifie son contenu (l’ordre des éléments). En revanche, la fonction sorted() suit un
modèle fonctionnel et renvoie une nouvelle liste avec les mêmes éléments triés.
>>> l = [7, 1, 4, 2] >>> l = [7, 1, 4, 2]
>>> [Link](), l >>> sorted(l), l
(None, [1, 2, 4, 7]) ([1, 2, 4, 7], [7, 1, 4, 2])
La fonction sorted() n’a pas d’effet de bord : elle ne modifie pas le contenu de la liste l.
Deux appels successifs de sorted(l) suivront exactement le même chemin d’exécution.
165
12. La programmation fonctionnelle
Il est illusoire de vouloir un programme entièrement sans effet de bord : on attend d’un
programme qu’il interagisse avec le monde extérieur. Ainsi, l’affichage dans le terminal, l’écri-
ture ou la lecture sur disque, l’échange d’information sur le réseau, sont des opérations qui
modifient toutes l’état du programme. Les langages de programmation ne sont alors pas pu-
rement fonctionnels, mais incitent à garder la majeure partie du programme pur, c’est-à-dire
sans effet de bord.
Python n’est pas un langage fonctionnel. Cependant ces recommandations de la program-
mation fonctionnelle pour :
— limiter les effets de bord ;
— limiter les états internes ;
— limiter l’usage de structures mutables ;
— programmer dans un style qui décrit une caractérisation de la solution plutôt que la
procédure pour la calculer ;
permettent un style de programmation modulaire, composable, plus facile à prouver, à débug-
ger et à tester.
Les bibliothèques Pandas (☞ p. 119, § 9) et Altair (☞ p. 137, § 10) ont une approche très
fonctionnelle en ce sens :
— les méthodes appliquées sur les [Link] ou les [Link] ne modifient pas la struc-
ture mais renvoient une nouvelle instance, ce qui permet un style de programmation
où les opérations sont chaînées ;
— le style de programmation Pandas promeut une description de la solution,
df.sort_values("altitude"), plutôt qu’une implémentation de tri rapide ou de tri fu-
sion par exemple. Altair est une grammaire graphique qui décrit une visualisation :
alt.Y("altitude"), au lieu de la construire.
La programmation fonctionnelle produit du code plus modulaire, plus concis, facile à proto-
typer et à tester. Elle s’inscrit en revanche plus difficilement dans un environnement fait d’en-
trées/sorties, d’interactions. Ce paradigme, qui peut sembler parfois réservé aux académiques,
mérite donc un chapitre dans cet ouvrage pour introduire les concepts de la programmation
fonctionnelle répandus en Python.
166
12.1. L’identification structurelle de motifs
Dans cet exemple, on effectue une comparaison entre la valeur de n et les valeurs spécifiées
après le mot-clé case. Notez que :
— Le symbole | n’est pas utilisé comme opérateur, mais plutôt comme un moyen de spé-
cifier plusieurs valeurs qui correspondent au même cas. C’est équivalent à écrire :
case 0:
case 1:
return 1
— Le symbole _ est un « joker » qui correspond à n’importe quelle valeur. Il est utilisé
pour spécifier un cas par défaut qui correspond à toutes les valeurs qui ne sont pas déjà
traitées par les cas précédents.
Le joker peut être utilisé également au sein d’une autre structure, par exemple à l’intérieur
d’une liste, dans l’implémentation suivante du tri rapide (quicksort) :
À noter que les crochets ne désignent pas spécifiquement une liste, mais peuvent corres-
pondre à tout type de séquence. Ainsi, la fonction pourrait également être utilisée pour trier
un tuple, ou toute autre séquence compatible. Les accolades quant à elles ne se limitent pas
aux dictionnaires mais à n’importe quel type de mapping :
def hemisphere_nord(coords: Mapping[str, float]) -> bool:
"""Renvoie `True` si le champ latitude est positif.
167
12. La programmation fonctionnelle
Les fonctions anonymes, définies par le mot-clé lambda, permettent de définir des fonctions
simples à la volée. Le mot-clé lambda vient du monde fonctionnel mais est trompeur : parmi
leurs limitations qui n’ont rien à voir avec la programmation fonctionnelle, on notera que leur
168
12.3. La curryfication
code est limité à une instruction, que ces fonctions n’ont pas accès aux variables locales du
bloc où elles sont définies, qu’elles ne sont pas sérialisables (☞ p. 41, § 3.4) et qu’elles n’ont
pas de nom.
Il est possible de passer une fonction en argument d’une autre fonction. Supposons que
l’on souhaite créer une liste des 𝑛 premiers éléments de la suite de Fibonacci. Le code suivant
pourra alors être utilisé pour créer la liste des 𝑛 premiers nombres premiers par exemple : il
est souhaitable alors de le factoriser en écrivant un code générique.
def n_premiers(function: "int -> int", n: int) -> list[int]:
"""Renvoie la liste des n premiers éléments pour la suite en paramètre."""
return [function(i) for i in range(n)]
Nous continuerons avec les annotations de type dans ce chapitre, afin de faciliter la com-
préhension. Toute expression valide Python peut être utilisée : l’usage recommande une no-
tation plus complexe que nous aborderons plus loin (☞ p. 367, § 27). Dans ce chapitre, nous
choisissons la notation "int -> int" ¹ pour une fonction qui prend un entier et renvoie un
entier.
Les fonctions Python permettent également de renvoyer des fonctions. Nous pourrions
avoir besoin de la fonction n_premiers_fibonacci avec pour seul argument l’entier n ; il est
possible de la générer à partir de la seule fonction fibonacci :
def premiers(function: "int -> int") -> "int -> list[int]":
"""Renvoie une fonction qui renvoie les n premiers éléments."""
return n_premiers_fun
12.3. La curryfication
La curryfication est un gros mot, en hommage à Haskell Curry qui a donné également
son nom au langage Haskell. L’exemple le plus simple pour illustrer ce concept est celui de
l’addition, que l’on peut voir comme deux formulations équivalentes :
— une fonction qui associe à deux nombres leur somme :
(float, float) -> float ;
— une fonction qui associe à un nombre une nouvelle fonction qui associe à un nombre
la somme des deux premiers nombres.
On peut alors écrire cette signature comme float -> (float -> float).
1. Cette notation est courante en programmation fonctionnelle mais n’est pas standard en Python : le code reste
néanmoins valide, comme toute chaîne de caractères utilisée en annotation.
169
12. La programmation fonctionnelle
add(1., 2.) # 3.
add_curry(1.)(2.) # 3.
Il est possible en Python de créer une fonction partielle directement à partir de la version
plus naturelle, décurryfiée, de notre fonction add :
from functools import partial
La signature de add_1 est toujours float -> float. Sur notre exemple basé sur la suite de
Fibonacci, on peut alors réécrire n_premiers_fibonacci sans fonction incluse :
n_premiers_fibonacci = partial(n_premiers, fibonacci)
n_premiers_fibonacci(8) # [1, 1, 2, 3, 5, 8, 13, 21]
map(fonction, sequence). Cette fonction applique une fonction 𝑓 à tous les éléments de la
séquence 𝑥 : elle renvoie ainsi 𝑓 (𝑥0 ) puis 𝑓 (𝑥1 ) et ainsi de suite. Son évaluation est paresseuse
(lazy en anglais), ce qui signifie qu’elle n’est évaluée sur l’élément suivant de 𝑥 que si ce
résultat est demandé. Une boucle ou un appel de list sur le résultat force l’évaluation de tous
les éléments de la séquence.
valeurs: "list[int]" = [3, 5, 8] >>> for result in map(fibonacci, valeurs):
map(fibonacci, valeurs) ... print(result)
# <map at 0x7f6391239460> 3
list(map(fibonacci, valeurs)) 8
# [3, 8, 34] 34
L’expression de listes en compréhension permet une notation plus intuitive. Plus naturel-
lement, nous verrons plus loin que la fonction map produit un générateur (☞ p. 195, § 14).
170
12.4. Les built-ins map, filter et la fonction reduce
Du point de vue des signatures, la fonction en paramètre de map peut prendre n’importe
quel type en argument et renvoyer n’importe quelle valeur. On peut néanmoins s’assurer que
les signatures sont compatibles avec le modèle suivant. A et B sont ici des annotations de type
générique : elles signifient « n’importe quel type », mais toutes les occurrences de A doivent
correspondre au même type.
map(fonction: "(A -> B)", sequence: "Sequence[A]") -> "Sequence[B]"
Dans notre exemple sur la suite de Fibonacci, A et B correspondent au type entier int. La
fonction fibonacci s’annote int -> int ; la séquence s’annote Sequence[int] (une signature
compatible avec list[int]) et le type de retour est également une séquence d’entiers.
filter(fonction, x). Cette fonction renvoie tous les éléments de la séquence 𝑥 pour lesquels
𝑓 (𝑥𝑖 ) est vrai (True). Son mode de fonctionnement paresseux est comparable à celui de la
fonction map. Sa signature se décline selon le modèle suivant :
filter(fonction: "A -> bool", sequence: "Sequence[A]") -> "Sequence[A]"
def impair(x):
return x % 2 == 1
171
12. La programmation fonctionnelle
On peut aussi réécrire l’exemple du schéma de Horner (☞ p. 11, § 1.3) avec une réduction :
def construct(x, y):
return 10 * x + y
Cette construction remplace le code suivant, qui évite d’utiliser une variable mutable cumul.
La programmation fonctionnelle proscrit en effet la manipulation de telles variables dont le
contenu change au cours de l’exécution.
cumul = 0
for elt in [1, 2, 3, 4, 5]:
cumul = 10 * cumul + elt
Dans une approche fonctionnelle, la fonction reduce peut être intéressante pour coder la
composition d’une liste de fonctions. Nous pouvons par exemple écrire une liste de fonctions
à appliquer, pour ne l’évaluer que plus tard, sur le modèle de l’évaluation paresseuse.
L’idée est donc d’écrire la fonction 𝑥 ↦ 𝑘(… ℎ(𝑔(𝑓 (𝑥)))) à partir de la liste [𝑓 , 𝑔, ℎ, … 𝑘].
add_1 = lambda x: x + 1
mul_2 = lambda x: x * 2
def compose(f: "int -> int", g: "int -> int") -> "int -> int":
def f_puis_g(x):
return g(f(x))
return f_puis_g
Pour éviter de multiplier les niveaux d’abstraction, l’écriture suivante, sans fonctions im-
briquées, est plus « conviviale » : elle utilise le troisième argument de la fonction reduce pour
définir une valeur initiale. Au lieu d’utiliser une composition de fonction 𝑔 ∘ 𝑓 , on peut se
contenter ici de l’application de fonction. Avec l’argument initial, on pourra s’assurer de
signatures compatibles avec le modèle suivant :
172
12.5. Les systèmes de Lindenmayer
reduce(fonction: "B, A -> B", sequence: "Sequence[A]", initial: "B") -> "B"
reduce(apply_function, fonctions, 3) # 20
F F+F-F-F+F F+F-F-F+F+F+F-F-F+
F-F+F-F-F+F-F+F-F-
F+F+F+F-F-F+F
173
12. La programmation fonctionnelle
Une itération de réécriture est une opération qui consiste à former une chaîne de caractères
en appliquant les règles de réécriture à chacune des lettres qui forment le mot courant :
— la fonction apply_rule applique la règle de réécriture à chacune des lettres : elle re-
cherche chaque lettre dans le dictionnaire rules, et ne modifie pas cette lettre si elle n’est
pas dans le dictionnaire. La valeur par défaut est définie grâce à la méthode .get(clé,
valeur_par_défaut) À (☞ p. 15, § 1.7) ;
— la fonction rewrite applique la fonction apply_rule à chacune des lettres de la séquence
seq, puis reforme une chaîne de caractères par l’opération de réduction join.
L’ensemble des règles de réécriture est présent dans le corps de la fonction apply_rule
À. Pour rester générique et pouvoir facilement remplacer les règles de réécriture, il y a deux
possibilités :
— la première consiste à ajouter des arguments aux fonctions apply_rule et rewrite pour
passer l’ensemble des règles en argument ; mais cette option alourdit le code avec des
arguments supplémentaires. L’appel à map serait aussi plus compliqué ;
— la seconde option consiste à générer notre fonction de réécriture à partir des règles
de réécriture, en renvoyant une fonction Á qui prend une chaîne de caractères pour
renvoyer une chaîne de caractères.
174
12.5. Les systèmes de Lindenmayer
Si une lettre n'est pas présente dans les règles de réécriture, elle
est recopiée telle quelle.
return rewrite # Á
Notons qu’il est d’ores et déjà facile de documenter et tester cette fonction, comme dans
la documentation docstring, ou sur l’exemple de la courbe de Koch :
>>> rewrite_rules(rules)("F")
'F+F-F-F+F'
>>> rewrite_rules(rules)("F+F-F-F+F")
'F+F-F-F+F+F+F-F-F+F-F+F-F-F+F-F+F-F-F+F+F+F-F-F+F'
La tortue graphique est responsable de l’affichage. Une tortue est à tout moment position-
née et orientée, mais, pour l’affichage, nous aurons besoin de tout le chemin parcouru par la
tortue, d’où :
— une liste de positions sous forme de tableau NumPy 1D ;
— une orientation sous forme de tableau NumPy 2D, pour permettre les multiplications
matricielles ;
— un dernier champ pile dont nous aurons besoin plus loin (il n’est pas utile pour la
courbe de Koch).
175
12. La programmation fonctionnelle
Une tortue avance dans la direction de son orientation. Comme nous manipulons des struc-
tures immutables, la fonction avance va renvoyer une nouvelle tortue avec la liste de positions
enrichie. La position courante est toujours la dernière valeur de la liste.
def avance(tortue: Tortue) -> Tortue:
return Tortue(
[Link] + [[Link][-1] + [Link].T],
[Link],
[Link],
)
Une tortue peut également tourner d’un angle à définir. Nous procédons par calcul matri-
ciel pour définir le nouvel angle de rotation :
radians = float
On peut alors définir les règles de mouvement de la tortue, qui à chaque lettre associe un
mouvement. Comme l’angle de rotation dépend des instances de L-systèmes, on peut définir
par curryfication la fonction partielle de rotation de 90° qui s’applique à une tortue pour ren-
voyer une tortue. On notera que toutes les valeurs du dictionnaire sont des fonctions qui ont
bien la même signature.
from functools import partial
176
12.5. Les systèmes de Lindenmayer
L’application de tous les mouvements associés à notre séquence d’action sur une tortue
initiale peut se faire par application de la fonction reduce : pour la séquence « F+F-F », on
voudrait écrire :
tortue = [Link]("F")([Link]("-")( ... [Link]("F")(Tortue())))
La fonction drawing_rules génère une fonction à partir des règles de réécriture tout en
préservant la signature qui nous intéresse. Par ailleurs, comme un caractère inconnu doit lais-
ser la tortue inchangée, nous ajoutons une fonction identité Á et l’attribuons en valeur par
défaut des règles de dessin renvoyées par .get() Â.
def drawing_rules(rules: "dict[str, Tortue -> Tortue]") -> "Tortue, str -> Tortue":
def identite(x: Tortue) -> Tortue: # Á
return x
return deplace_tortue
Il reste enfin à définir une structure de données générales pour les L-systèmes, et à écrire
la fonction qui génère la trajectoire de la tortue correspondante :
@dataclass(frozen=True)
class LSystem:
axiom: str # l'axiome de départ
rules: "dict[lettre, str]" # les règles de réécriture
order: int # combien de fois appliquer les règles
draw: "dict[str, turtle -> turtle]" # les règles de dessin
177
12. La programmation fonctionnelle
FIGURE 12.1 – Rendus graphiques pour différents L-systèmes définis sur la page web du livre
courbe_de_koch = LSystem(
axiom="F", rules=dict(F="F+F-F-F+F"), order=3,
draw={
"F": avance,
"+": partial(rotation, [Link](90)),
"-": partial(rotation, -[Link](90)),
},
)
tortue = Tortue()
tortue = reduce(
drawing_rules([Link]), # turtle, str -> turtle
actions, Tortue(), # str, turtle
)
return [Link]([Link])
178
12.5. Les systèmes de Lindenmayer
La figure 12.1 affiche le résultat de plusieurs L-systèmes décrits sur la page web associée
au chapitre. Certains utilisent une nouvelle règle de dessin associée aux lettres [ et ] : quand la
tortue rencontre le caractère ], elle retourne se positionner dans l’état où elle était au caractère
[ correspondant. C’est ici que sert la pile qui a été ajoutée à la structure Tortue, afin de stocker
les états par lesquels la tortue passe quand elle rencontre le caractère [ : la structure de type
deque (☞ p. 55, § 4.5) fournit la pile qui restitue les états en mode dernier arrivé premier sorti,
last in first out (LIFO).
Dans la fonction depile ci-dessous, on ajoute à la trace de la tortue un point aux coordon-
nées NaN, et Matplotlib interprétera ce point comme une discontinuité dans le tracé.
arbre = LSystem(
axiom="F", rules=dict(F="FF-[-F+F+F]+[+F-F-F]"), order=3,
draw={
"F": avance, "[": empile, "]": depile,
"-": partial(rotation, -[Link](25)),
"+": partial(rotation, [Link](25)),
},
)
Cet exemple illustre comment écrire ce type de programme dans un esprit « programma-
tion fonctionnelle », de manière rapide et concise pour celui qui est à l’aise avec les concepts
de map, filter et reduce, mais probablement au prix de la lisibilité pour les autres.
179
12. La programmation fonctionnelle
En quelques mots…
— La programmation fonctionnelle est un paradigme qui encourage certaines bonnes
pratiques, notamment l’utilisation de fonctions pures, qui ne modifient pas l’état
des structures passées en argument. Cette pratique préfère l’écriture de fonctions
concises, performantes, faciles à documenter et à tester.
— On trouve dans le module functools et la bibliothèque standard les fonctions map,
filter, reduce, partial ou lambda pour permettre un style de programmation
fonctionnel. Néanmoins, en Python, l’utilisation des formes en compréhension
(☞ p. 13, § 1.5) est plus naturelle que map et filter ; et la plupart des opérations de
réduction courantes sont proposées dans des fonctions built-ins (☞ p. 23, § 2.1)
comme sum(), all(), any().
— Les fonctions anonymes, définies par le mot-clé lambda et limitées aux spécifica-
tions mono-instructions, peuvent souvent être remplacées par des fonctions exis-
tantes (par exemple, les fonctions add, mul ou or_ du module operator). Si la spé-
cification d’une fonction lambda est complexe, Fredrik Lundh recommande la pro-
cédure suivante :
1. écrire un commentaire qui explique ce que fait la fonction lambda ;
2. trouver un mot qui résume ce commentaire ;
3. écrire une fonction avec ce nom et la même spécification ;
4. supprimer le commentaire.
Cette position est certes excessive, mais elle s’accorde avec le Zen de Python :
readability counts, « la lisibilité est importante ».
Voici donc un conseil plus mesuré : tant qu’une fonction équivalente n’existe pas
dans la bibliothèque standard, et que la lambda reste concise, lisible et compréhen-
sible sans commentaire supplémentaire, il n’est pas nécessaire de la supprimer.
— Les fonctions d’ordre supérieur restent un concept très couramment utilisé en Py-
thon, notamment sous la forme de décorateurs (☞ p. 181, § 13).
Pour aller plus loin
— Les bibliothèques toolz [Link] et [Link] [Link]
com/kachayev/[Link] donnent accès à de nombreux paradigmes de la programma-
tion fonctionnelle en Python.
— Purely Functional Data Structures, Chris Okasaki, 1999
Cambridge University Press, ISBN 978-0-521-66350-4
180
13
Décorateurs de fonction
et fermetures
U
n décorateur de fonction est un outil qui permet de marquer une fonction afin d’en
modifier son comportement. Cet élément de syntaxe Python est reconnaissable au
caractère arobase @ qui précède une fonction d’ordre supérieur (☞ p. 165, § 12) et qui
permet de modifier ou de remplacer la fonction qui suit (la fonction décorée).
Dans l’exemple qui suit, on peut définir une fonction d’ordre supérieur :
def decorateur(fonction: "fonction") -> "fonction":
print(f"Définition de la fonction décorée {fonction.__name__}")
return fonction
181
13. Décorateurs de fonctions et fermetures
@logger
def pause(secondes: int = 1) -> None:
[Link](secondes)
return
pause(1)
La fonction pause fait maintenant bien référence à la fonction modifiée, une variable locale
de la fonction logger.
>>> pause
<function logger.<locals>.fonction_modifiee(*args)>
L’essentiel à savoir sur les décorateurs de fonction tient en ces quelques lignes. Il est pos-
sible de coder en Python sans jamais écrire de décorateurs si on s’en tient à un style de pro-
grammation entièrement impératif, mais il est nécessaire de préciser certains détails théo-
riques, notamment à propos des fonctions fermetures (closure en anglais) et du mot-clé nonlocal
pour pouvoir tirer le meilleur de cette possibilité du langage.
def chrono_fonction(*args):
t0 = time.perf_counter()
print(f"[{elapsed:0.8f}s] {name}({arg_str})")
return result
return chrono_fonction
@chronometre
def pause(secondes: int = 1) -> None:
"""Carpe diem!"""
[Link](secondes)
return
182
13.1. Utilisations courantes des décorateurs
>>> pause(1)
[1.00305594s] pause(1)
@chronometre
def factorielle(n: int) -> int:
"""Renvoie la factorielle calculée par récursion."""
if n == 0:
return 1
return n * factorielle(n - 1)
>>> factorielle(6)
[0.00000781s] factorielle(0)
[0.01235192s] factorielle(1)
[0.01348036s] factorielle(2)
[0.01445412s] factorielle(3)
[0.01544575s] factorielle(4)
[0.01638823s] factorielle(5)
[0.01768468s] factorielle(6)
720
L’inconvénient de ces décorateurs est que les fonctions décorées ont leurs noms, annota-
tions et documentations masqués.
>>> factorielle
<function chronometre.<locals>.chrono_fonction(*args)>
>>> factorielle.__name__, factorielle.__annotations__, factorielle.__doc__
('chrono_fonction', {}, None)
def chronometre(fonction):
name = fonction.__name__
@[Link](fonction)
def chrono_fonction(*args):
t0 = time.perf_counter()
print(f"[{elapsed:0.8f}s] {name}({arg_str})")
return result
return chrono_fonction
183
13. Décorateurs de fonctions et fermetures
@chronometre
def factorielle(n: int) -> int:
"""Renvoie la factorielle calculée par récursion."""
if n == 0:
return 1
return n * factorielle(n - 1)
>>> help(factorielle)
Help on function factorielle in module __main__:
Une autre utilisation courante des décorateurs est l’enregistrement de fonctions. Dans
l’exemple suivant, on choisit d’annoter des fonctions pour les ajouter à une liste de fonctions
« autorisées » sans les modifier. Pour l’exemple de la tortue graphique du chapitre précédent,
on pourrait par exemple imaginer l’utilisation suivante :
mouvements_autorises = list()
def mouvement_tortue(fonction):
mouvements_autorises.append(fonction)
return fonction
@mouvement_tortue
def avance(tortue: "Tortue"):
pass
@mouvement_tortue
def rotation(tortue: "Tortue"):
pass
>>> mouvements_autorises
[<function avance(tortue: 'Tortue')>,
<function rotation(tortue: 'Tortue')>]
184
13.2. Portée des variables et fonctions fermetures
L’erreur est explicite. Il suffit de définir une variable globale externe. On notera qu’il est
possible d’accéder à un dictionnaire qui recense l’ensemble des variables globales, renvoyées
par la fonction globals().
>>> "externe" in globals()
False # externe n'est pas encore une variable globale
>>> externe = "externe"
>>> "externe" in globals()
True
>>> fonction()
interne
externe
185
13. Décorateurs de fonctions et fermetures
>>> fonction()
interne
Traceback (most recent call last):
...
UnboundLocalError: local variable 'externe' referenced before assignment
Le message d’erreur est différent : la variable externe est maintenant marquée comme une
variable locale dès le début de la fonction. L’attribut co_varnames liste les noms de l’ensemble
des variables dans le code de la fonction.
>>> fonction.__code__.co_varnames
('interne', 'externe')
Il est toutefois possible de marquer cette variable comme une variable globale avec le mot-
clé global :
def fonction():
global externe
interne = "interne"
print(interne)
print(externe)
externe = "externe"
>>> fonction()
interne
externe
On peut alors confirmer que la variable externe n’est plus présente dans la liste co_varnames.
>>> fonction.__code__.co_varnames
('interne',)
Il est possible de modifier le décorateur du chronomètre pour afficher une pile d’appel.
Cette fonctionnalité peut être intéressante pour les fonctions récursives notamment, afin de
comprendre ou corriger des problèmes de récursion infinie.
Afin de produire un affichage pertinent, on souhaite ici :
À indenter l’affichage de la pile d’appel par incrément à chaque sous-appel ;
Á afficher un temps d’exécution total de la fonction depuis son premier appel.
def pile_d_appel(fonction):
name: str = fonction.__name__
indentation: int = -1
t0: "timestamp ou None" = None
@[Link](fonction)
def chrono_fonction(*args):
indentation += 1 # À
t0 = time.perf_counter() if t0 is None else t0
arg_str = ", ".join(repr(arg) for arg in args)
elapsed = time.perf_counter() - t0
186
13.2. Portée des variables et fonctions fermetures
return result
return chrono_fonction
@pile_d_appel
def factorielle(n: int) -> int:
if n == 0:
return 1
return n * factorielle(n - 1)
>>> factorielle(10)
Traceback (most recent call last):
...
UnboundLocalError: local variable 'indentation' referenced before assignment
Le problème ici est que les instructions indentation += et t0 = sont des assignations dans
le corps de la fonction et sont donc marquées comme variables locales. L’instruction global ne
résoudrait pas le problème non plus : la variable name par exemple n’est pas dans les variables
globales.
>>> "name" in globals()
False
>>> "indentation" in factorielle.__code__.co_varnames
True
En réalité, ces variables sont des variables locales dans la fonction pile_d_appel, mais,
celle-ci ayant fini de s’exécuter, après la définition de la fonction factorielle, elles n’existent
plus au moment de son exécution.
>>> pile_d_appel.__code__.co_varnames
('fonction', 'indentation', 't0', 'chrono_fonction')
Pourtant, la variable name existe toujours quelque part pour pouvoir être appelée dans la
fonction chrono_fonction qui est renvoyée : cette variable n’est ni une variable locale pour la
fonction décorée, ni une variable locale pour le décorateur : on appelle cela une variable libre
(free variable), c’est-à-dire qu’elle n’est pas liée à la portée de variables locales du décorateur.
>>> factorielle.__code__.co_freevars
('fonction', 'name')
Une fonction avec des variables libres est appelée une fermeture : c’est le mot-clé en anglais,
closure, qui est utilisé pour enregistrer les valeurs de ces variables dans l’objet fonction :
>>> factorielle.__closure__
(<cell at 0x7f51717a7be0: function object at 0x7f5171801550>,
<cell at 0x7f51717a7d30: str object at 0x7f51719655f0>)
187
13. Décorateurs de fonctions et fermetures
@[Link](fonction)
def chrono_fonction(*args):
nonlocal indentation, t0
indentation += 1
t0 = time.perf_counter() if t0 is None else t0
arg_str = ", ".join(repr(arg) for arg in args)
elapsed = time.perf_counter() - t0
print(f"{' '*indentation}[{elapsed:0.8f}s] {name}({arg_str}) -> ...")
result = fonction(*args)
elapsed = time.perf_counter() - t0
print(f"{' '*indentation}[{elapsed:0.8f}s] {name}({arg_str}) -> {result}")
indentation -= 1
return result
return chrono_fonction
@pile_d_appel
def factorielle(n: int) -> int:
if n == 0:
return 1
return n * factorielle(n - 1)
>>> factorielle.__code__.co_varnames
('args', 'arg_str', 'elapsed', 'result')
>>> factorielle.__code__.co_freevars # les variables libres
('fonction', 'indentation', 'name', 't0')
>>> factorielle.__closure__
(<cell at 0x7f51718982e0: function object at 0x7f51718de5e0>,
<cell at 0x7f5171898790: int object at 0x56463f3fac80>,
<cell at 0x7f51718988e0: str object at 0x7f51719655f0>,
<cell at 0x7f5171898370: NoneType object at 0x56463f3da360>)
188
13.3. Les décorateurs dans la bibliothèque functools
>>> fibonacci(5)
[0.00002969s] fibonacci(5) -> ...
[0.00092914s] fibonacci(4) -> ...
[0.00121605s] fibonacci(3) -> ...
[0.00140441s] fibonacci(2) -> ...
[0.00175229s] fibonacci(1) -> ...
[0.00187165s] fibonacci(1) -> 1
[0.00200067s] fibonacci(0) -> ...
[0.00210426s] fibonacci(0) -> 1
[0.00220912s] fibonacci(2) -> 2
[0.00231831s] fibonacci(1) -> ...
[0.00241009s] fibonacci(1) -> 1
[0.00250786s] fibonacci(3) -> 3
[0.00262037s] fibonacci(2) -> ...
[0.00274332s] fibonacci(1) -> ...
[0.00284401s] fibonacci(1) -> 1
189
13. Décorateurs de fonctions et fermetures
La définition récursive de cette fonction est peu efficace comme le montre le tracé de la
pile d’exécution : l’appel à fibonacci(1) est fait 5 fois, l’appel à fibonacci(2) 3 fois, et ainsi
de suite. Sur des appels pour des valeurs plus grandes, cela devient rédhibitoire.
La programmation fonctionnelle propose une manière de résoudre ce problème par un
mécanisme appelé mémoïsation : il s’agit d’une mise en cache des résultats renvoyés par une
fonction appelés avec certains arguments. Cette fonctionnalité est proposée par le décorateur
@lru_cache ¹.
from functools import lru_cache
@lru_cache
@pile_d_appel
def fibonacci(n: int) -> int:
"""Renvoie la n^e valeur de la suite de Fibonacci."""
if n in [0, 1]:
return 1
return fibonacci(n - 1) + fibonacci(n - 2)
>>> fibonacci(5)
[0.00000578s] fibonacci(5) -> ...
[0.00183805s] fibonacci(4) -> ...
[0.00209511s] fibonacci(3) -> ...
[0.00234850s] fibonacci(2) -> ...
[0.00273003s] fibonacci(1) -> ...
[0.00286037s] fibonacci(1) -> 1
[0.00289418s] fibonacci(0) -> ...
[0.00291764s] fibonacci(0) -> 1
[0.00293989s] fibonacci(2) -> 2
[0.00296296s] fibonacci(3) -> 3
[0.00298481s] fibonacci(4) -> 5
1. LRU signifie en anglais Least Recently Used : le cache garde en mémoire et restitue les données les plus récem-
ment utilisées.
190
13.3. Les décorateurs dans la bibliothèque functools
On notera tout d’abord qu’il est tout à fait possible d’empiler plusieurs décorateurs sur une
fonction. Ici l’appel est équivalent à :
fibonacci = lru_cache(pile_d_appel(fibonacci))
Les appels ne sont alors faits qu’une seule fois pour tous les entiers. En effet, pour calculer
fibonacci(5), le programme doit calculer fibonacci(4) + fibonacci(3), or, pendant l’exécu-
tion de fibonacci(4), la valeur de fibonacci(3) est déjà calculée et mise en cache. Une fois
la valeur de fibonacci(4) calculée, il n’est alors pas nécessaire de calculer à nouveau fibo-
nacci(3) : on récupère sa valeur en cache.
Cette méthode de mémoïsation propose ici un déroulement de l’algorithme symétrique par
rapport à une implémentation impérative non récursive de la suite de Fibonacci qui initialise
les valeurs de fibonacci(0) et fibonacci(1) avant de calculer fibonacci(2), puis fibonacci(3),
et ainsi de suite.
def fibonacci_imperatif(n: int) -> int:
if n < 2:
return 1
f0, f1 = 1, 1
for i in range(2, n):
f0 = f0 + f1
f1 = f0
return f0
>>> fibonacci_imperatif(5)
8
%%timeit
naive_recursive_fibonacci(20)
# 4.22 ms ± 638 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
memoise_fibonacci = lru_cache(naive_recursive_fibonacci)
%%timeit
memoise_fibonacci(20)
# 110 ns ± 11.7 ns per loop (mean ± std. dev. of 7 runs, 10000000 loops each)
191
13. Décorateurs de fonctions et fermetures
9 Attention !
— Le décorateur @lru_cache peut aussi être utilisé en tant que fonction avec des ar-
guments pour contrôler la taille du cache. Par défaut, @lru_cache est équivalent à
@lru_cache() et à @lru_cache(maxsize=None) pour un cache de taille potentielle-
ment infinie. Avec une taille maximale 𝑛 fixée, le mécanisme least recently used est
activé pour ne garder en cache que les résultats des 𝑛 derniers appels de la fonc-
tion. L’autre paramètre, nommé typed (par défaut False), fait la distinction entre
des valeurs qui s’évaluent comme égales sans être identiques (comme l’entier 1
et le flottant 1.0).
— Le mécanisme de cache est basé sur un dictionnaire dont les clés sont les argu-
ments avec lesquels la fonction est appelée. Tous ces arguments doivent donc
être hashables : les listes notamment ne remplissent pas ces propriétés puisqu’il
est possible de les modifier à l’exécution.
>>> memoise_fibonacci([0, 1, 2])
Traceback (most recent call last):
...
TypeError: unhashable type: 'list'
if isinstance(valeur, list):
return list(elt + autre for elt in valeur)
if isinstance(valeur, tuple):
return tuple(elt + autre for elt in valeur)
# if ...
192
13.3. Les décorateurs dans la bibliothèque functools
@singledispatch
def ajoute(valeur, autre: float):
print("comportement par défaut")
return valeur + autre
@[Link](tuple) # À
def _(valeur, autre): # Â
print("comportement tuple")
return tuple(elt + autre for elt in valeur)
@[Link](list)
@[Link](Iterable) # Á
def _(valeur, autre):
print("comportement list ou Iterable")
return list(elt + autre for elt in valeur)
>>> ajoute(1, 3)
comportement par défaut
4
>>> ajoute((1, 2, 3), 1)
comportement tuple
(2, 3, 4)
>>> ajoute([1, 2, 3], 1)
comportement list ou Iterable
[2, 3, 4]
>>> ajoute([Link]([1, 2, 3]), 1)
comportement list ou Iterable
[2, 3, 4]
>>> ajoute(range(3), 1)
comportement list ou Iterable
[1, 2, 3]
Dans l’exemple précédent, les types [Link] et range ont été détectés comme itérables
et renvoient ainsi une liste en type de retour. La syntaxe @singledispatch permet également
de spécifier de nouveaux comportements de manière dynamique, sans avoir à modifier le code
de la fonction ajoute. Si la fonction est fournie par une bibliothèque tierce et qu’un utilisateur
souhaite ajouter un comportement spécifique, par exemple pour le type Tortue, il est ici pos-
sible pour lui de le faire sans modifier le code de la bibliothèque d’origine, mais en spécifiant
une fonction à décorer avec [Link](Tortue).
193
13. Décorateurs de fonctions et fermetures
def chronometre_fmt(fmt=DEFAULT_FMT):
def decorateur(func):
def chrono_fonction(*_args):
t0 = [Link]()
result = func(*_args)
elapsed = [Link]() - t0
name = func.__name__
args = ", ".join(repr(arg) for arg in _args)
print([Link](**locals()))
return result
return chrono_fonction
return decorateur
@chronometre_fmt()
def pause(seconds):
[Link](seconds)
for i in range(3):
pause(0.123)
addition(1, 2)
# addition(1, 2) renvoie 3
194
14
Itérateurs, générateurs et coroutines
L’
itération est un concept fondamental en algorithmique et dans les langages de pro-
grammation qui décrit la répétition d’une action. La formulation la plus simple de
l’itération pour les programmeurs est la boucle, formulée par le mot-clé for ou while.
En programmation fonctionnelle, l’itération est souvent exprimée par des appels récursifs à
des fonctions.
L’itération peut également être vue comme une abstraction, un service générique fourni
par des structures itérables. Ces structures sont alors capables de fournir des éléments un par
un, sans avoir à les charger intégralement en mémoire, ce qui est souvent impossible pour
de gros traitements de données. Toutes les structures de collection que nous avons abordées
précédemment (☞ p. 49, § 4) sont itérables. Il est alors possible :
— de les parcourir par une boucle for,
— de construire de nouvelles structures en consommant les anciennes (en passant une
structure itérable à la fonction list() par exemple),
— de les manipuler par compréhension, (☞ p. 13, § 1.5)
— de les déballer (unpacking).
Depuis Python 3, le mot-clé range ne renvoie pas de liste, mais un objet de type range.
>>> range(10)
range(0, 10)
>>> type(range(10)) # ceci n'est pas une liste
range
Il est alors possible d’itérer sur un range, que ce soit avec une boucle for ou avec des
constructeurs d’autres structures conteneurs, comme les listes.
>>> for i in range(10):
... print(i, end=" ")
0 1 2 3 4 5 6 7 8 9
>>> list(range(10))
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
195
14. Itérateurs, générateurs et coroutines
%%timeit
nouveau = []
for x in range(1000000):
[Link](2*x)
# 165 ms ± 5.63 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
Cette notation peut être parenthésée avant d’être consommée. L’objet produit est alors un
générateur. On peut itérer ou construire une nouvelle collection à partir d’un générateur mais,
une fois ce générateur utilisé, ou consommé, il n’est pas possible de le redémarrer.
>>> g = (str(i) for i in range(10))
>>> type(g)
generator
>>> list(g)
['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']
>>> list(g) # cette fois-ci, c'est vide!
[]
9 Attention !
Les deux expressions suivantes ne sont pas équivalentes : les parenthèses ont leur im-
portance dans la définition du générateur.
>>> [str(i) for i in range(10)]
['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']
>>> [(str(i) for i in range(10))]
[<generator object <genexpr> at 0x7ffe0bec2cf0>]
Au-delà des constructeurs de collection de base, d’autres fonctions natives manipulent des
générateurs et des structures itérables. La fonction sorted() (☞ p. 23, § 2.1) construit une
liste triée, la fonction max() renvoie l’élément maximal d’une structure itérable pourvu que les
éléments renvoyés un par un soient comparables :
>>> list(i * (-1) ** (i) for i in range(10))
[0, -1, 2, -3, 4, -5, 6, -7, 8, -9]
>>> sorted(i * (-1) ** (i) for i in range(10))
[-9, -7, -5, -3, -1, 0, 2, 4, 6, 8]
>>> max(i * (-1) ** (i) for i in range(10))
8
196
14.2. Le mot-clé yield
Sur des générateurs écrits par compréhension, les deux syntaxes sont alors équivalentes :
def eq2() -> "generator":
def eq1() -> "generator":
for i in range(5):
return (i for i in range(5))
yield i
Comme la suite de Fibonacci, la suite de Syracuse est un bon exemple pour illustrer le
fonctionnement des fonctions qui renvoient des générateurs. La suite de Syracuse démarre
sur un entier positif. À chaque itération, si le dernier entier est pair, on renvoie le résultat de
sa division par 2 ; sinon on le multiplie par 3 avant d’ajouter 1.
Une conjecture prédit que cette suite converge systématiquement vers 1. Le chiffre 1 étant
impair, les valeurs suivantes sont 4, puis 2, puis 1 : aussi l’usage est d’interrompre cette suite
quand la valeur 1 est atteinte.
Les résultats intéressants pour cette suite peuvent être :
— la séquence complète de valeurs qui démarrent à l’entier 𝑛,
— la longueur de cette suite : combien faut-il d’itérations pour atteindre la valeur 1 ?
— la hauteur de cette suite : quelle est la valeur maximale atteinte par la suite avant de
converger vers 1 ?
On pourrait alors imaginer une fonction qui renvoie la séquence complète, une autre fonc-
tion qui renvoie sa longueur et encore un autre qui renvoie sa hauteur. Pour factoriser cette
spécification, la définition par générateur est confortable :
197
14. Itérateurs, générateurs et coroutines
Un générateur n’a pas de longueur dans la définition de son interface. En effet, il existe
des générateurs infinis qui n’ont pas de longueur (par exemple une instruction yield dans une
boucle infinie).
Il existe une réduction (☞ p. 170, § 12.4) qui permet de trouver la longueur d’une telle
séquence : on ajoute 1 pour chaque nouvelle valeur retournée. Dans le code suivant, on peut
utiliser la variable muette _ pour insister sur le fait que la valeur récupérée dans la structure
itérable n’a pas d’importance :
def length(iterable):
"Renvoie la longueur d'une structure itérable finie."
return sum(1 for _ in iterable)
length(syracuse(58)) # 20
On peut tracer (Figure 14.1) la longueur de la suite de Syracuse pour tous les entiers (de 1
à 1000 ici) pour faire ressortir des schémas surprenants. Dans l’expression suivante, en combi-
nant application/filtrage sous forme de générateur en compréhension et réduction par l’opé-
rateur "".join(), on peut raffiner l’affichage de la suite de Syracuse qui part de la valeur 27.
>>> " -> ".join(str(i) for i in syracuse(27))
'27 -> 82 -> 41 -> 124 -> 62 -> 31 -> 94 -> 47 -> 142 -> 71 -> 214 -> 107 ->
322 -> 161 -> 484 -> 242 -> 121 -> 364 -> 182 -> 91 -> 274 -> 137 -> 412 ->
206 -> 103 -> 310 -> 155 -> 466 -> 233 -> 700 -> 350 -> 175 -> 526 -> 263 ->
790 -> 395 -> 1186 -> 593 -> 1780 -> 890 -> 445 -> 1336 -> 668 -> 334 -> 167 ->
502 -> 251 -> 754 -> 377 -> 1132 -> 566 -> 283 -> 850 -> 425 -> 1276 -> 638 ->
319 -> 958 -> 479 -> 1438 -> 719 -> 2158 -> 1079 -> 3238 -> 1619 -> 4858 ->
2429 -> 7288 -> 3644 -> 1822 -> 911 -> 2734 -> 1367 -> 4102 -> 2051 -> 6154 ->
3077 -> 9232 -> 4616 -> 2308 -> 1154 -> 577 -> 1732 -> 866 -> 433 -> 1300 ->
650 -> 325 -> 976 -> 488 -> 244 -> 122 -> 61 -> 184 -> 92 -> 46 -> 23 -> 70 ->
35 -> 106 -> 53 -> 160 -> 80 -> 40 -> 20 -> 10 -> 5 -> 16 -> 8 -> 4 -> 2 -> 1'
On trouve sa hauteur (la valeur maximale prise) à l’aide de la fonction de réduction max() :
list(max(syracuse(i)) for i in range(200))
198
14.3. Itérables et itérateurs
150 8000
6000
100
4000
50
2000
0 0
0 200 400 600 800 1000 0 20 40 60 80 100
FIGURE 14.1 – Suite de Syracuse : longueur, parcours, hauteur et hauteur de la suite en fonction de sa longueur
Si on souhaite connaître l’entier suivant pour lequel la longueur de la suite de Syracuse est
supérieure à 100, il est possible de stocker le générateur dans une variable, et d’appeler next()
une deuxième fois :
>>> g = (i for i in range(1, 50) if length(syracuse(i)) > 100)
>>> next(g), next(g)
27, 31
Il est alors possible de définir en deuxième argument une valeur par défaut (souvent None)
pour éviter les exceptions :
>>> next((i for i in range(10) if i > 10), None) # None
199
14. Itérateurs, générateurs et coroutines
La fonction next() s’applique dans un cadre plus général que celui des générateurs. Pour
autant elle ne s’applique pas à n’importe quelle structure itérable :
>>> next([1, 2, 3])
Traceback (most recent call last):
...
TypeError: 'list' object is not an iterator
La clé est dans le message d’erreur : une liste n’est pas un itérateur.
Python fait la distinction entre deux termes, itérable et itérateur : une structure itérable est
une structure à partir de laquelle il est possible, de démarrer une ou plusieurs itérations, de
produire un itérateur. Un itérateur applique l’itération. À chaque étape il se consomme, avant
de s’épuiser avec une exception StopIteration.
La fonction built-in next() ne s’applique qu’à un itérateur, c’est-à-dire une structure qui se
consomme, comme un générateur. Il est possible de créer un itérateur à partir d’une structure
itérable à l’aide de la fonction built-in iter().
>>> it = iter([1, 2, 3])
>>> next(it)
1
>>> next(it)
2
>>> next(it)
3
>>> next(it)
Traceback (most recent call last):
...
StopIteration
Enfin, un itérateur est également itérable : la fonction iter() appliquée à un itérateur ren-
voie simplement l’itérateur passé en argument.
Ce code ne fonctionne plus pour une longueur supérieure à 120, parce que la question que
nous avons posée était « quelle est la première valeur inférieure à 50 [...] ? ».
>>> next(i for i in range(1, 50) if length(syracuse(i)) > 120)
Traceback (most recent call last):
...
StopIteration
200
14.4. Le module itertools
Si nous n’avons pas de moyen de borner notre itération, il est possible d’utiliser l’itérateur
count(), un itérateur infini qui renvoie les entiers un par un :
>>> import itertools
>>> # [Link](start: int, step: int) -> Iterator[int]
>>> next(i for i in [Link](start=1) if length(syracuse(i)) > 120)
129
Chaînage, l’opérateur yield from. Un cas d’usage courant est celui du chaînage d’itérateurs.
Il est facile de concaténer deux listes à itérer à l’aide de l’opérateur +. Pour plusieurs itérateurs
i1, i2, etc., on pourrait écrire une fonction génératrice :
def chain(*iterables) -> "Iterator":
"""Combine un ensemble d'itérateurs.
La double boucle peut alourdir les notations dans le code ; aussi l’opérateur yield from
a été introduit dès Python 3.3 (PEP 380). La fonction [Link] de la librairie remplit
exactement la même spécification que le code suivant :
def chain(*iterables) -> "Iterator":
"""Combine un ensemble d'itérateurs.
Notons le parallèle à tirer entre d’une part les éléments de syntaxe yield et yield from, et,
d’autre part, les implémentations de fonctions récursives terminales. Reprenons l’exemple de
la factorielle :
def factorielle(n: int) -> int:
if n == 0:
return 1
return n * factorielle(n - 1) # À
Les langages de programmation fonctionnelle (ce n’est pas le cas de Python) sont capables
d’optimiser les appels aux fonctions récursives si les appels sont terminaux, c’est-à-dire que
la dernière instruction appelée avant le return est l’appel à la fonction récursive. Sur la ligne
À, l’appel n’est pas récursif terminal (tail-recursive en anglais) parce que le résultat de la fac-
torielle sera multiplié par 𝑛.
On peut modifier cette spécification de la manière suivante, avec une variable qui transmet
les résultats intermédiaires à l’appel suivant :
201
14. Itérateurs, générateurs et coroutines
Cette syntaxe est alors à rapprocher de la fonction suivante, à base d’itérateurs : le yield
simple renvoie le cas de base, et le yield from délègue la production des valeurs suivantes à
l’appel récursif.
def fact_iter(n: int, cumul: int = 1) -> "Iterator[int]":
if n == 0: # nécessaire pour interrompre la récursion
return
yield cumul
yield from fact_iter(n - 1, n * cumul)
Cette manière de procéder permet ici d’obtenir tous les résultats intermédiaires transmis
dans la pile pendant la récursion : le dernier élément de la liste est le résultat de la factorielle.
On peut aussi se contenter du résultat final par déballage Á.
>>> list(fact_iter(10))
[1, 10, 90, 720, 5040, 30240, 151200, 604800, 1814400, 3628800]
>>> *_, result = fact_iter(10) # Á
>>> result
3628800
Filtrage. Les fonctions suivantes permettent de filtrer des éléments d’un itérable, c’est-à-dire
de ne retourner que les valeurs qui retournent un certain critère :
phrase = "Python, un langage idéal!"
Nous avons déjà parlé de la fonction filter (☞ p. 170, § 12.4), qui ne renvoie que les
éléments évalués comme vrais par la fonction passée en paramètre.
>>> # uniquement les caractères alphabétiques
>>> "".join(filter([Link], phrase))
'Pythonunlangageidéal'
— takewhile(fun, iter) renvoie tous les éléments jusqu’au premier test qui échoue :
>>> # on arrête au premier caractère non alphabétique
>>> "".join([Link]([Link], phrase))
'Python'
— dropwhile(fun, iter) renvoie tous les éléments à partir du premier test réussi :
>>> "".join([Link]([Link], phrase))
'ython, un langage idéal!'
202
14.4. Le module itertools
— compress(iter1, iter2) agit comme un masque NumPy : il renvoie tous les éléments
de iter1 qui correspondent à un élément évaluable comme vrai dans iter2 :
>>> "".join(
... [Link](
... phrase, [0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 0, 1, 1, 1, 0, 0, 1]
... )
... )
'un ange'
Application. Nous avons déjà parlé de la fonction map, qui crée un itérateur constitué du
résultat de l’application d’une fonction à chacun des éléments d’un itérateur en entrée :
>>> sequence = [2, 3, 7, 6, 4, 5, 8, 9, 1]
>>> list(map(lambda x: x + 1, sequence))
[3, 4, 8, 7, 5, 6, 9, 10, 2]
— accumulate(iter[, fun]) renvoie une somme cumulée des éléments passés. Si une fonc-
tion est passée en paramètre, elle est appliquée à la place de la somme :
>>> list([Link](sequence))
[2, 5, 12, 18, 22, 27, 35, 44, 45]
>>> list([Link](sequence, max))
[2, 3, 7, 7, 7, 7, 8, 9, 9]
>>> # calcul de la factorielle
>>> list([Link](range(1, 10), [Link]))
[1, 2, 6, 24, 120, 720, 5040, 40320, 362880]
— starmap(fun, iter) applique fun à chacun des éléments elt de iter ; chaque elt doit
être à son tour itérable pour appeler fun(*elt).
Dans l’exemple ci-dessous, la fonction zip renvoie le premier élément de chaque col-
lection, plus le deuxième, et ainsi de suite. On calcule alors 0 + 9, 1 + 8, etc.
>>> list([Link]([Link], zip(range(10), reversed(range(10)))))
[9, 9, 9, 9, 9, 9, 9, 9, 9, 9]
203
14. Itérateurs, générateurs et coroutines
Produits.
— product(iter1, iter2, ...) génère le produit cartésien de tous les itérateurs passés
en paramètres.
>>> couleurs = ["♠", "♥", "♦", "♣"]
>>> valeurs = ["A", "R", "D", "V", "10", "9", "8", "7"]
>>> "".join(("A", "♠"))
'A♠'
>>> " ".join("".join(carte) for carte in [Link](valeurs, couleurs))
'A♠ A♥ A♦ A♣ R♠ R♥ R♦ R♣ D♠ D♥ D♦ D♣ V♠ V♥ V♦ V♣ 10♠ 10♥ 10♦ 10♣ 9♠ 9♥ 9♦ 9♣
8♠ 8♥ 8♦ 8♣ 7♠ 7♥ 7♦ 7♣'
Réarrangement. Ces deux fonctions sont moins connues que les précédentes, mais leur fonc-
tionnement est familier des utilisateurs du shell Unix ([Link]()) et de la bibliothèque
Pandas ([Link](), ☞ p. 119, § 9) :
— L’outil shell tee duplique la sortie standard d’un programme pour rediriger cette du-
plication vers l’entrée standard du programme suivant. Dans le module itertools, la
fonction tee(iter, n=2) permet de dupliquer la sortie des itérateurs pour la consommer
plusieurs fois :
204
14.5. Les coroutines
On peut ainsi produire un itérateur qui calcule la différence entre deux éléments consécutifs :
>>> sequence
[2, 3, 7, 6, 4, 5, 8, 9, 1]
>>> t1, t2 = [Link](sequence)
>>> _ = next(t1) # ignorer le résultat
>>> sub = list([Link]([Link], zip(t1, t2)))
>>> sub
[1, 4, -1, -2, 1, 3, 1, -8]
— groupby(iter, key=None) renvoie des tuples clé, iterateur avec tous les éléments qui
vérifient le critère clé. Contrairement à Pandas, groupby suppose que les éléments de
iter sont regroupés (triés par exemple Â) :
def signe(x):
return int(x / abs(x))
" ".join(
f"{key:2d} -> {list(it)}"
for key, it in [Link](sorted(sub), key=signe) # Â
)
# '-1 -> [-8, -2, -1] 1 -> [1, 1, 1, 3, 4]'
205
14. Itérateurs, générateurs et coroutines
Pour pouvoir commencer à faire consommer des données par la coroutine, il est nécessaire
de la démarrer à l’aide de la fonction next(). Comme coco est un générateur, il se termine
systématiquement par une exception StopIteration.
>>> next(coco)
>>> [Link]("Mille sabords!")
Allô, j'écoute: Mille sabords!
Traceback (most recent call last):
...
StopIteration
Il est courant de démarrer les coroutines à l’aide du décorateur suivant qui initialise (on
utilise le verbe to prime (a coroutine) en anglais) automatiquement les coroutines À :
import functools
def coroutine(fun):
@coroutine
@[Link](fun)
def allo():
def wraps(*args, **kwargs):
x = yield
gen = fun(*args, **kwargs)
print(f"Allô, j'écoute: {x}")
next(gen) # À
return gen
return wraps
>>> coco = allo()
>>> # next(coco) a été exécuté lors de l'appel à allo(), à la ligne À
>>> [Link]("Non, Madame, ce n'est pas la boucherie Sanzot!")
Allô, j'écoute: Non, Madame, ce n'est pas la boucherie Sanzot!
Traceback (most recent call last):
...
StopIteration
On peut choisir en exemple d’utilisation des coroutines une fonction qui reçoit des valeurs
pour retourner la moyenne des valeurs consommées :
@coroutine
def moyenne():
total = 0.0
average = None
for compteur in count(1): # Á
terme = yield average
total += terme
average = total / compteur
À chaque appel de .send(), une itération se fait dans la boucle infinie Á qui incrémente
un compteur, et accumule la somme des valeurs reçues pour renvoyer la moyenne des valeurs
reçues.
>>> moy = moyenne()
>>> ", ".join(f"{elt} -> {[Link](elt)}" for elt in sequence)
'2 -> 2.0, 3 -> 2.5, 7 -> 4.0, 6 -> 4.5, 4 -> 4.4, 5 -> 4.5, 8 -> 5.0,
9 -> 5.5, 1 -> 5.0'
206
14.5. Les coroutines
9 Attention !
La coroutine n’étant pas terminée, il est possible de poursuivre le calcul de la moyenne
en ajoutant des valeurs. Il est donc normal de ne pas obtenir la même sortie que précé-
demment.
>>> ", ".join(f"{elt} -> {[Link](elt):.2f}" for elt in sequence)
'2 -> 4.70, 3 -> 4.55, 7 -> 4.75, 6 -> 4.85, 4 -> 4.79, 5 -> 4.80, 8 -> 5.00,
9 -> 5.24, 1 -> 5.00'
9 Attention !
Si une exception non rattrapée à l’intérieur de la coroutine a lieu, la coroutine est alors
quittée.
>>> try:
... [Link]("grossière erreur")
... except TypeError as exc:
... print(exc)
unsupported operand type(s) for +=: 'float' and 'str'
>>> [Link](1)
Traceback (most recent call last):
...
StopIteration
Il faut plutôt rattraper l’exception dans la coroutine pour éviter d’interrompre celle-ci.
@coroutine
def moyenne():
total = 0.0
count = 0
average = None
while True:
try:
terme = yield average
total += terme
count += 1
average = total / count
except TypeError:
print("On n'a rien vu...")
207
14. Itérateurs, générateurs et coroutines
Il est également courant de prévoir une garde à pour interrompre la coroutine et renvoyer
une valeur. La valeur de retour est alors contenue dans l’exception StopIteration :
@coroutine
def moyenne():
total = 0.0
count = 0
average = None
while True:
try:
terme = yield
if terme is None: # Ã
break
total += terme
count += 1
average = total / count
except TypeError:
print("On n'a rien vu...")
return average
On peut récupérer la valeur dans une variable à l’aide des gardes d’exception :
moy = moyenne()
[Link](1)
try:
[Link](None)
except StopIteration as exc:
result = [Link]
result # 1.0
Le parallèle entre générateurs, qui produisent des données, et coroutines, qui consomment
des données, est également intéressant lors du chaînage de fonctions. Dans le premier exemple,
on consomme les données sorties de range(10), pour les faire passer successivement dans
mul_2, add_1, add_1, et ainsi de suite, avant de constituer une liste :
def add_1(it): def mul_2(it):
for elt in it: for elt in it:
yield elt + 1 yield 2 * elt
208
14.5. Les coroutines
chaine = [Link](
lambda x, f: f(x),
[mul_2, add_1, add_1, mul_2, add_1], # Â
range(10)
)
list(chaine) # [5, 9, 13, 17, 21, 25, 29, 33, 37, 41]
Dans le second exemple, la liste de résultats est au plus profond de la pile d’appel : c’est la
coroutine start_elt qui nourrira cette liste, à partir des éléments reçus de add_1, qui les reçoit
de mul_2, de add_1, et ainsi de suite. La composition des fonctions est alors faite dans le sens
inverse :
@coroutine
@coroutine
def ajoute(liste):
def add_1(output):
while True:
while True:
elt = yield
elt = yield
[Link](elt)
[Link](elt + 1)
resultat = list()
@coroutine
chaine = [Link](
def mul_2(output):
lambda x, f: f(x),
while True:
[add_1, mul_2, add_1, add_1, mul_2], # Ã
elt = yield
ajoute(resultat),
[Link](elt * 2)
)
resultat # [5, 9, 13, 17, 21, 25, 29, 33, 37, 41]
9 Attention !
Bien noter que les deux listes de fonctions  et à sont chaînées dans des sens opposés
comme sur la figure 14.2.
générateur
×2 +1 +1 ×2 +1
range(10) resultat
mul_2 add_1 add_1 mul_2 add_1
coroutine
209
14. Itérateurs, générateurs et coroutines
En quelques mots…
Python propose une syntaxe et un formalisme riches autour du protocole de l’itération.
Deux structures fonctionnent de façons opposées : les générateurs, qui produisent des
données à chaque itération, et les coroutines, qui en consomment.
Les générateurs peuvent être produits par les notations en compréhension ou avec
des fonctions qui utilisent le mot-clé yield. C’est un mécanisme extrêmement puissant
qui trouve de nombreuses applications réelles, notamment pour personnaliser des motifs
d’itération.
Quand yield est situé à droite du signe égal, la fonction devient une coroutine. Les
coroutines sont un concept des années 1960 qui ont laissé la place aux processus légers
(appelés aussi threads, ☞ p. 271, § 18.2). Néanmoins, la facilité avec laquelle elles per-
mettent d’interrompre une exécution a permis de construire les bases du module asyncio
en Python (☞ p. 279, § 19).
210
15
La programmation orientée objet
L
a programmation orientée objet est un paradigme de programmation dont les premières
idées viennent de la fin des années 1950. Les termes de classes, instances, objets, attributs,
propriétés, méthodes ou prototypes sont apparus au fur et à mesure que de nouveaux
langages de programmation expérimentaient autour de la manière de modéliser des structures
de données et les relations qui les lient.
Python est un langage qui est fondamentalement orienté objet : le langage est né au dé-
but des années 1990, à une période où la programmation orientée objet était déjà mature. Les
classes (qu’on a appelées types) et les objets, ou instances (qu’on a appelés valeurs), ont large-
ment occupé les pages des chapitres précédents. Les méthodes, ces fonctions qui s’appliquent
sur des objets à l’aide de la notation pointée, sont présentes depuis le premier chapitre.
Python est pourtant beaucoup plus flexible dans son approche de la modélisation orien-
tée objet que d’autres langages comme C++ ou Java. On peut faire carrière avec Python sans
être à l’aise avec les notions de programmation fonctionnelle présentées dans les chapitres
précédents, il est également possible d’écrire des bibliothèques Python sans maîtriser les fon-
damentaux de la programmation orientée objet. Pour autant, l’écriture de programmes Python
à la fois lisibles et efficaces passe par la maîtrise des trois grands principes de la programma-
tion orientée objet et de l’approche pragmatique avec laquelle Python les aborde. Ces principes
seront approfondis à partir de ce chapitre :
— La notion d’encapsulation : les fonctions (les méthodes) et attributs relatifs à une
structure de données (une classe) sont tous codés dans la même unité de concep-
tion. L’objet embarque toutes ses propriétés. Cette approche permet de ne pas saturer
l’espace de nommage, en regroupant ces services au sein des variables auxquelles ils
s’appliquent.
211
15. La programmation orientée objet
— La notion de factorisation : le code des objets qui ont des comportements similaires
est mis en commun. Sur des programmes simples, le code qui pourrait être copié-collé
est souvent factorisé à l’aide de fonctions pour éviter les répétitions, qui sont sources
d’erreur dans un grand projet. Les concepts d’héritage et de composition, couramment
illustrés à l’aide de diagrammes UML, portent le concept de la factorisation des services
à l’échelle des classes.
Reynolds a proposé trois règles simples pour modéliser le comportement des boids (la
contraction de l’anglais bird-oid, « qui ressemble à un oiseau ») :
— la séparation : deux boids ne peuvent pas être au même endroit au même moment ;
— la cohésion : les boids se rapprochent les uns des autres pour former un groupe ;
— l’alignement : pour rester groupés, les boids tendent à voler dans la même direction et
à la même vitesse.
Ce programme illustre le concept d’émergence, c’est-à-dire un comportement complexe
global qui émerge de règles locales simples. Nous utiliserons cet exemple dans ce chapitre
pour illustrer les concepts simples de la programmation orientée objet en Python.
212
15.2. Création d’une classe
Les attributs peuvent être ajoutés à une instance de manière dynamique et être rappelés
plus tard avec la notation pointée. Il convient ici de noter le parallèle entre un dictionnaire et
un objet avec attributs.
b = Boid()
# équivalent avec un dictionnaire
b.x, b.y, b.v_x, b.v_y = 0, 0, 1, 1
b = {"x": 0, "y": 0, "v_x": 1, "v_y": 1}
b.x, b.y
9 Attention !
Contrairement à d’autres langages de programmation, il n’existe pas en Python de no-
tion d’attributs publics, privés ou protégés. Ainsi :
— Les méthodes .get_x() et .set_x(value) ne sont pas pertinentes en Python. (Nous
verrons comment les propriétés permettent de coder des comportements plus
complexes en donnant l’illusion de manipuler des attributs ☞ p. 220, § 15.3.)
— L’usage est de marquer comme pseudo-privés des attributs en les préfixant par le
caractère « _ » : ces attributs restent accessibles mais ce préfixe doit décourager
le/les utilisateurs de les appeler directement.
Une méthode est une fonction qui est associée à une classe, rattachée à son espace de
nommage. Comparons alors les deux notations :
— la fonction module_vitesse prend en paramètre un Boid ;
— la méthode module_vitesse est rattachée à la classe Boid.
Le premier argument d’une méthode est nommé par convention self et fait référence à
l’instance courante.
def module_vitesse(b: Boid) -> float:
return [Link](b.v_x ** 2 + b.v_y ** 2)
class Boid:
def module_vitesse(self) -> float:
return [Link](self.v_x ** 2 + self.v_y ** 2)
b = Boid()
b.v_x, b.v_y = 3, 4
module_vitesse(b), b.module_vitesse() # (5.0, 5.0)
En réalité, sous le capot, les deux notations suivantes sont équivalentes si b est une instance
de Boid :
>>> b.module_vitesse(), Boid.module_vitesse(b)
(5.0, 5.0)
Nous avons déjà observé ce type de parallèle dans les premiers chapitres :
>>> "boid".title()
'Boid'
>>> [Link]("boid")
'Boid'
213
15. La programmation orientée objet
9 Attention !
Même pour de très bonnes raisons, il convient de ne pas définir de nouvelles méthodes
réservées (dunder methods).
class Boid:
def __init__(self, position: tuple, vitesse: tuple) -> None:
self.x, self.y = position
self.v_x, self.v_y = vitesse
Il est maintenant impossible de créer une instance de Boid sans donner une position et un
vecteur vitesse de départ.
>>> b = Boid()
Traceback (most recent call last):
...
TypeError: __init__() missing 2 required positional arguments: 'position'
and 'vitesse'
On ajoute également dans cette classe une méthode avance() qui fait avancer le Boid dans
la direction de son vecteur vitesse. On voit ici une première particularité des objets : les ins-
tances ont un état interne qu’il est possible de faire évoluer. C’est probablement la principale
différence avec la programmation fonctionnelle, qui décourage les effets de bord.
>>> b = Boid((0, 0), (1, 1))
>>> [Link]()
>>> b
<Boid at 0x7fae598d9d30>
La représentation par défaut des classes est peu parlante. Il est possible de la personnaliser
avec l’une des deux dunder methods suivantes :
— __repr__(self) -> str définit la représentation d’un objet qui sera renvoyée dans l’in-
terpréteur, ou dans la représentation d’une structure complexe qui intègre cet objet ;
— __str__(self) -> str définit le résultat de l’affichage avec la fonction print(). Si cette
méthode n’est pas définie, print() utilise la méthode __repr__.
214
15.2. Création d’une classe
class Boid:
# abrégé
Dans l’exemple suivant, on appelle la méthode __str__ sur le type list, laquelle fait appel
aux méthodes __repr__ pour chacun des éléments qui la constituent :
>>> print([b, b])
[Boid((1, 1), (1, 1)), Boid((1, 1), (1, 1))]
Le résultat de ces deux méthodes peut aussi être obtenu à l’aide des fonctions built-ins repr
et str :
>>> repr(b)
'Boid((1, 1), (1, 1))'
>>> str(b)
'Boid(position=(1, 1), vitesse=(1, 1))'
V Bonnes pratiques
Il est courant, dans la mesure du possible, de définir une représentation d’objet qui per-
mette de recréer une copie de la même instance en évaluant cette représentation dans
l’interpréteur Python :
>>> Boid(position=(1, 1), vitesse=(1, 1)) # ou Boid((1, 1), (1, 1))
Boid((1, 1), (1, 1))
>>> b == b, b == Boid((1, 1), (1, 1))
(True, False)
9 Attention !
Le test d’égalité est faux parce que nous n’avons pas défini les règles d’égalité entre deux
boids et les instances ne sont pas les mêmes. Le résultat de l’opérateur == est le résultat
de la méthode __eq__(self, other) :
class Boid:
# abrégé
215
15. La programmation orientée objet
Remarques :
— les méthodes __radd__, __rsub__, etc. sont des opérateurs inversés qui permettent de
définir une opération avec l’instance de la classe courante à droite de l’opérateur. Cette
fonctionnalité est particulièrement pertinente quand le terme à gauche de l’opérateur
est un objet qui ne connaît pas la classe Boid ;
— les méthodes __iadd__, __isub__ définissent les opérateurs augmentés +=, -=, etc.
216
15.2. Création d’une classe
Chaînage d’opérations. Avant d’ajouter plus de méthodes à notre classe, portons notre at-
tention sur les nuances suivantes :
class Boid:
# abrégé
— l’option  ne modifie pas l’état interne de l’instance et renvoie une nouvelle instance.
Il n’y pas de règle générale pour choisir une option plutôt qu’une autre. Il convient néan-
moins de se poser la question de quelle option choisir quand on code une méthode qui modifie
l’état d’une instance sans qu’il soit nécessaire de renvoyer quoi que ce soit :
— les options Á et  permettent de chaîner les méthodes ([Link]().avance()) ;
— l’option  permet de ne pas créer d’effet de bord (☞ p. 165, § 12), c’est l’option préférée
de Pandas (☞ p. 119, § 9) et Altair (☞ p. 137, § 10). Elle présente l’avantage d’être
source de moins d’erreurs de programmation. C’est la seule option qui affiche trois
instances différentes dans la liste.
Pour cet exemple bien particulier, l’option  est la plus pertinente, mais, d’une manière
générale, les options Á et  sont de bons choix. L’option  peut parfois sembler plus coûteuse
en espace mémoire, mais :
— lors du chaînage de code, la mémoire occupée par les objets intermédiaires est libérée
aussitôt que la dernière référence vers ceux-ci est détruite ;
217
15. La programmation orientée objet
Arguments par défaut. Pour la suite, nous allons modifier légèrement la modélisation pour
faciliter les calculs : plutôt que de manipuler séparément les coordonnées 𝑥 et 𝑦, nous manipu-
lerons un vecteur de positions x et un vecteur de vitesses dx sous la forme de tableaux NumPy.
Nous souhaitons également proposer des arguments par défaut pour les positions qui nous
permettent de créer de nouveaux boids à une position aléatoire sur un tableau.
Comme pour les fonctions classiques, les arguments par défaut sont évalués lors de la créa-
tion de la méthode. Ici, nous souhaitons appeler une fonction aléatoire qui renvoie un résultat
différent lors de la création de chaque Boid avec des arguments par défaut. Pour pouvoir éviter
cet écueil et reproduire un fonctionnement de fabrique (factory, ☞ p. 52, § 4.2), la solution la
plus courante est de mettre un argument par défaut à None et d’appeler la fonction aléatoire
dans le constructeur.
Variables de classe. Certains arguments peuvent être partagés entre toutes les classes. Nous
introduisons dans l’exemple suivant deux variables de classe : taille, qui correspond à la taille
de l’univers dans lequel évoluent les boids, et nous permet ici de tirer un Boid au hasard dans
le domaine autorisé, et un compteur count. On accède aux variables de classe non pas avec le
mot-clé self mais à partir du nom de la classe. Ici, la variable count compte le nombre de boids
existant. Le compteur est incrémenté dans le constructeur, et décrémenté dans le destructeur
de la classe (la méthode __del__).
class Boid:
taille = 300
count = 0
def __del__(self):
[Link] -= 1
218
15.2. Création d’une classe
>>> b = Boid()
>>> c = [Link]()
>>> b, c
(Boid([-195.74 -183.9 ], [-4.36 -0.71]), parmi 2,
Boid([-200.1 -184.61], [-195.74 -183.9 ]), parmi 2)
Ici nous avons bien deux instances différentes qui persistent parce que nous avons retenu
l’option  qui renvoie une nouvelle instance à l’appel de avance.
Dans l’exemple suivant, on crée un nouveau Boid dans la variable b. Le compteur est incré-
menté par la création, mais décrémenté lorsque le compteur de références qui pointent vers
le Boid de la variable b précédente redescend à zéro.
>>> b = Boid()
>>> b, c
(Boid([250.16 161.97], [1.08 4.96]), parmi 2,
Boid([-92.3 288.74], [-91.07 286.51]), parmi 2)
9 Attention !
On peut détruire une référence vers une variable avec le mot-clé del, mais celui-ci n’ap-
pelle pas systématiquement le destructeur __del__ :
>>> liste_de_boids = [b := Boid(), [Link]()]
>>> del b
>>> b = Boid()
>>> b
Boid([185.02 189.09], [-3.39 0.68]), parmi 3
Nous avons toujours trois instances ici : les deux Boid de la liste et le nouveau Boid
créé pour remplacer la variable b. L’instruction del b n’a pas détruit l’objet dans la liste
mais simplement cassé la référence de la variable b vers l’instance de Boid.
En revanche, en vidant la liste, il n’y a plus aucune référence vers les deux instances
et la méthode __del__() est alors appelée par le garbage collector.
>>> liste_de_boids.clear()
>>> b
Boid([-281.18 62.87], [-3.73 -0.35]), parmi 1
Variable de classe ou variable d’instance. Les variables de classe sont partagées entre
toutes les instances de la classe. Il est possible d’utiliser une variable de classe pour créer
une variable d’instance.
219
15. La programmation orientée objet
class A:
count = 0
def __init__(self):
[Link] += 1 # [Link] = [Link] + 1
[Link] += 1
def __repr__(self):
return f"A() {[Link]} sur {[Link]}"
>>> A(), A()
(A() 1 sur 2, A() 2 sur 2)
taille = 300
@property
def vitesse(self) -> float:
return [Link]([Link])
@[Link]
def vitesse(self, value: float) -> None:
[Link] = [Link] * value / [Link]
220
15.3. Les décorateurs de la programmation orientée objets
class Boid:
# abrégé
@classmethod
def from_scalar(cls, rayon=None, azimuth=None, vitesse=None, direction=None):
rayon = rayon if rayon is not None else [Link]([Link])
azimuth = (
[Link](azimuth) if azimuth is not None
else [Link](2 * [Link])
)
vitesse = vitesse if vitesse is not None else [Link](10)
direction = (
[Link](direction)
if direction is not None
else [Link](2 * [Link])
)
return cls( # À
rayon * [Link]([[Link](azimuth), [Link](azimuth)]),
vitesse * [Link]([[Link](direction), [Link](direction)]),
)
@staticmethod
def scene(n: int) -> "list[Boid]":
return [Boid() for _ in range(n)]
>>> Boid.from_scalar(rayon=5, vitesse=3, direction=270)
Boid([ 1.42 -4.79], [-0. -3.])
221
15. La programmation orientée objet
V Bonnes pratiques
Dans la méthode de classe À, on n’utilise pas le nom de la classe Boid à cause des possi-
bilités offertes par l’héritage (☞ p. 225, § 15.4) : en effet, si on définit une classe BoidPlus
qui hérite de Boid, cls.from_scalar permet de renvoyer une instance de BoidPlus au lieu
de Boid.
Nous avons maintenant tous les éléments pour coder le comportement d’un Boid, notam-
ment pour ajuster son vecteur vitesse en fonction de la position des autres éléments de la
population.
class Boid:
taille = 300
max_voisins = 10
@property
def vitesse(self) -> float:
return [Link]([Link])
@[Link]
def vitesse(self, value: float) -> None:
[Link] = [Link] * value / [Link]
@property
def direction(self) -> "radians":
return np.arctan2([Link][1], [Link][0])
222
15.3. Les décorateurs de la programmation orientée objets
def centripete(self):
"Une composante de force centripète."
return -self.x
223
15. La programmation orientée objet
# On avance
self.x += [Link]
return self
V Bonnes pratiques
Il est possible de définir un affichage amélioré pour les notebooks Jupyter. Si une classe
contient la méthode _repr_html_() ᵃ alors le résultat qu’elle renvoie est interprété par le
navigateur pour fournir un affichage amélioré.
Dans l’exemple ci-dessous, on propose un code HTML pour un affichage amélioré
de la structure Boid défini avec un SVG (Scalable Vector Graphics, une représentation
vectorielle d’un objet graphique) ayant subi une rotation qui dépend du vecteur vitesse
du Boid. Le schéma de base proposé dans boid_shape sous le format de chemin (Path)
Matplotlib est utilisé dans la suite du chapitre pour produire l’animation avec Matplotlib.
boid_shape = [Link](
# coordonnées du schéma ci-dessous, orienté vers la droite
vertices=[Link]([[0, 0], [-100, 100], [200, 0], [-100, -100], [0, 0]]),
codes=[Link]([1, 2, 2, 2, 79,], dtype=np.uint8,),
)
class Boid:
# abrégé
def _repr_html_(self):
# correspondance SVG/Matplotlib Path
# 1, M: moveto ; 2, L: lineto; 79: Z: close polygon
224
15.4. Héritage et composition
return f"""<h4>Boid</h4>
<svg width="40" height="40" style="float: left; margin: 0.5em">
<path fill="#b45118" stroke="#b45118" d="{points}" />
</svg>
<table style="float: left; margin-top: -2.4em">
<thead><tr><th/><td>abscisse</td><td>ordonnée</td></tr></thead>
<tbody>
<tr>
<th>position</th>
<td>{"</td><td>".join(str(f) for f in [Link](2))}</td>
</tr>
<tr>
<th>vitesse</th>
<td>{"</td><td>".join(str(f) for f in [Link](2))}</td>
</tr>
</tbody>
</table>
"""
a. La méthode _repr_html_() n’utilise qu’un seul underscore, ce n’est pas une dunder method avec un nom
réservé : l’environnement Jupyter est une bibliothèque tierce au langage.
225
15. La programmation orientée objet
V Bonnes pratiques
Avant d’écrire class B(A), on peut se poser la question de savoir si le fait d’avoir un
élément B dans une collection d’éléments A pourrait poser problème. Si la réponse est
oui, alors la relation d’héritage n’est pas appropriée.
Éviter la duplication inutile. On souhaite généraliser la simulation des boids à des coor-
données à trois dimensions tout en réutilisant toute la logique qui a déjà été codée dans Boid.
Il peut alors être tentant de définir une classe Boid3D qui hérite de Boid :
class Boid3D(Boid):
pass
Les arguments position et vitesse sont pourtant des tableaux NumPy qui ne sont pas
limités à deux dimensions. On peut donc utiliser la classe Boid sans modification. In fine, la
création d’une classe héritée sans attributs ni méthodes (re)définis peut être pertinente si on
souhaite clarifier les types manipulés, mais dans ce cas, une simple assignation suffirait :
>>> Boid3D = Boid
>>> Boid3D([Link]([1, 1, 1]), [Link]([0, 0, 1]))
Boid([1 1 1], [0 0 1])
Le lien entre deux classes peut se faire par une abstraction commune. Partant de deux
formes géométriques simples, le triangle et le quadrilatère, on souhaite généraliser l’interface
des deux classes. Le calcul de l’aire d’un quadrilatère (non dégénéré) peut se définir par la
somme des aires des deux triangles qui forment le quadrilatère.
226
15.4. Héritage et composition
Il serait malvenu de définir un quadrilatère comme une classe qui hérite des triangles :
un quadrilatère n’est pas un triangle, et des méthodes qui agissent sur des relations entre
triangles peuvent ne plus fonctionner si l’un des arguments passés est un quadrilatère alors
qu’un triangle serait attendu. Néanmoins, les deux formes géométriques dérivent d’une abs-
traction commune : on peut dire que les triangles et les quadrilatères sont tous des polygones.
Il persiste dans notre implémentation une relation de composition entre le quadrilatère et
le triangle, qui reflète la réutilisation du calcul de l’aire du triangle dans le calcul de celle du
quadrilatère.
class Polygone:
def aire(self) -> float:
raise NotImplementedError
class Triangle(Polygone):
def __init__(self, p1: complex, p2: complex, p3: complex):
self.v1 = p2 - p1
self.v2 = p3 - p1
class Quadrilatere(Polygone):
def __init__(self, p1: complex, p2: complex, p3: complex, p4: complex):
self.t1 = Triangle(p1, p2, p3)
self.t2 = Triangle(p3, p4, p1)
polygones: "List[Polygone]" = \
[Triangle(0, 4, 3j), Quadrilatere(0, 2, 2 + 2.5j, 2.5j)]
L’annotation List[Polygone] a ici du sens, mais les questions que poserait une annotation
List[Triangle] sur la même liste seraient révélatrices de problèmes de conception à venir, par
exemple le jour où on crée un maillage triangulaire de l’espace et que la relation d’héritage
autoriserait d’y utiliser des quadrilatères.
data = {
227
15. La programmation orientée objet
class Tours([Link]):
def tres_haut(self):
return [Link]("hauteur > 100")
tours = Tours.from_dict(data)
tours.tres_haut()
nom ville latitude longitude hauteur
0 Tour Eiffel Paris 48.85826 2.2945 324
Cette approche peut paraître inoffensive sur cet exemple simple mais elle est pourtant
dangereuse d’une manière générale, et ce pour plusieurs raisons :
— des problèmes liés à des choix internes de conception par les développeurs de la biblio-
thèque tierce (ici Pandas), ou de potentielles collisions avec des méthodes existantes.
Même un expert Pandas qui maîtriserait toutes les subtilités de la bibliothèque pour-
rait se retrouver en défaut après une mise à jour Pandas qui remettrait en question des
choix internes ;
— des problèmes de prolifération de classes. Lundi, notre voisin de bureau écrit une classe
Villes qui hérite de [Link] et définit tres_haut comme « ayant une latitude
supérieure à 50 degrés » ; mardi, le nouveau stagiaire écrit une classe BigBrother qui
hérite de [Link] et enregistre toutes les opérations appelées sur un tableau dans
une base de données. La combinaison d’extensions produirait alors toujours plus de
nouvelles classes ToursBigBrother, VillesBigBrother, etc. C’est cette prolifération que
le Gang of Four recommande d’éviter dans leur ouvrage.
On préférera alors une approche par composition :
class Tours:
def _repr_html_(self):
return [Link]._repr_html_()
def tres_haut(self):
return Tours([Link]("hauteur > 100"))
Héritage multiple. Tout langage de programmation comme Python qui permet l’héritage
multiple doit résoudre la question de la résolution des noms en cas de conflit, notamment si
deux classes dans la hiérarchie d’héritage contiennent le même nom de méthode.
class Langage:
def parle(self):
228
15.4. Héritage et composition
print("Ah!")
class Francais(Langage):
def parle(self):
print("Bonjour!")
class Neerlandais(Langage):
def parle(self):
print("Goedemiddag!")
Dans l’exemple ci-dessus, un Belge parle à la fois français et néerlandais. Lors de l’appel à la
méthode .parle(), Python fait le choix du français pour résoudre le conflit du nom de méthode.
Ce choix se retrouve dans l’attribut de classe __mro__ (Method Resolution Order, « ordre de
résolution des méthodes ») :
>>> Belge.__mro__
(Belge, Francais, Neerlandais, Langage, object)
Lors de l’appel à la méthode, Python recherche d’abord la fonction dans l’espace de nom-
mage de la classe Belge, puis dans celui de la classe Francais, puis Neerlandais, puis Langage.
La première méthode rencontrée dans cet ordre est celle qui est choisie pour l’exécution.
Il est possible d’enrichir l’appel d’une méthode avec l’appel à la fonction suivante dans
l’ordre des classes de l’attribut __mro__. Cet appel se fait avec la fonction super() dont la
sémantique est différente du sens classique de super dans la plupart des langages orientés
objet : super() ne remonte pas la hiérarchie des classes mais passe l’appel de la méthode à la
classe suivante dans le __mro__ . Ainsi, dans l’appel décrit ici À, l’appel à super() de la classe
Francais Á ne remonte pas à Langage mais appelle la méthode .parle() suivante dans l’ordre
du __mro__, c’est-à-dire celle de Neerlandais Â.
class Langage:
def parle(self):
print("Ah!")
class Francais(Langage):
def parle(self):
super().parle() # Á
print("Bonjour!")
class Neerlandais(Langage):
def parle(self): # Â
super().parle()
print("Goedemiddag!")
229
15. La programmation orientée objet
>>> Belge().parle() # À
Ah!
Goedemiddag
Bonjour
Les mixins. Les mixins permettent de composer des classes à l’aide de briques élémentaires.
Ces classes ne permettent pas de répondre à la question « est-ce que ma classe A est avant tout
une instance de Mixin ? ». Il est d’usage de suffixer les classes mixins pour les reconnaître.
On pourrait par exemple imaginer comment composer un affichage amélioré (la méthode
_repr_html_) pour une classe quelconque à partir de briques élémentaires :
— la classe TitleViewMixin affiche le nom de la classe ;
— la classe TableViewMixin affiche la liste des attributs de la classe (disponible via la fonc-
tion built-in vars()) sous forme de tableau à deux colonnes.
class HTMLMixin:
def _repr_html_(self):
return ""
class TitleViewMixin(HTMLMixin):
def _repr_html_(self) -> str:
# Le nom de la classe est ici porté par self
title = f"<h4>{self.__class__.__name__}</h4>"
return title + super()._repr_html_()
class TableViewMixin(HTMLMixin):
def _repr_html_(self) -> str:
ligne = lambda key, value: f"<tr><th>{key}</th><td>{value}</td></tr>"
table_view = f"""<table style="float: left;"><tbody>
{"".join(ligne(key, value) for key, value in vars(self).items())}
</tbody></table>"""
return table_view + super()._repr_html_()
Il est alors possible de réutiliser les mêmes éléments pour une classe entièrement différente.
230
15.5. Le lien avec les paradigmes précédents
@dataclass
class Boid_:
taille: ClassVar[int] = 300 # variable de classe
x: [Link] = field(
default_factory=lambda: [Link](-[Link], [Link], 2)
)
dx: float = field(default_factory=lambda: [Link](-5, 5, 2))
>>> Boid_().avance()
Boid_(x=array([-224.63, 121.07]), dx=array([1.2 , 0.81]))
231
15. La programmation orientée objet
def anim_to_html(anim):
[Link](anim._fig)
return anim.to_html5_video()
[Link]._repr_html_ = anim_to_html
Les coroutines (☞ p. 205, § 14.5) sont également un moyen de maintenir un état interne au
programme. L’exemple du chapitre précédent pourrait s’écrire plus naturellement à l’aide de
la programmation orientée objet.
from itertools import count
class Moyenne:
def __init__(self):
@coroutine
[Link] = 0.0
def Moyenne():
[Link] = 0
total = 0.0
average = None
def send(self, terme):
for compteur in count(1):
[Link] += terme
terme = yield average
[Link] += 1
total += terme
return [Link] / [Link]
average = total / compteur
Variables globales. Les attributs de classe permettent d’éviter de saturer l’espace de nom-
mage avec des variables globales qui compliquent la lecture du code. Pour créer des anima-
tions, Matplotlib attend une fonction qui est appelée à chaque itération. Pour nous, cette fonc-
tion modifie à la fois l’état des boids et les marques placées dans le système d’axes.
L’exemple ci-contre pourrait s’écrire sans utiliser de classes :
— le contenu du constructeur __init__ serait alors exécuté hors d’une fonction ;
— les attributs boids et artists seraient alors des variables globales qu’il faudrait rappeler
et modifier dans la fonction iteration, et donc préfixer dans le code du mot-clé global.
La programmation orientée objet permet de respecter cette unité de conception : tous les
attributs et méthodes qui sont relatifs à la simulation et la production de l’animation sont
regroupés dans la classe Simulation.
def rotate_marker(p: [Link], angle: "radians") -> [Link]:
cos, sin = [Link](angle), [Link](angle)
newpath = [Link] @ ([Link]([[cos, sin], [-sin, cos]]))
232
15.5. Le lien avec les paradigmes précédents
class Simulation:
def __init__(self, n: int, ax, seed: int = 2042) -> None:
[Link](seed)
[Link] = list(Boid() for _ in range(n))
[Link] = list()
[Link](ax)
ax.set_xlim((-[Link], [Link]))
ax.set_ylim((-[Link], [Link]))
ax.set_aspect(1)
[Link].set_visible(False)
[Link].set_visible(False)
233
15. La programmation orientée objet
return [Link]
[Link](
fig, [Link], frames=range(0, 200),
interval=150, blit=True, repeat=True,
) # affiche une animation dans l'environnement Jupyter
En quelques mots…
La programmation fonctionnelle et la programmation orientée objet sont deux para-
digmes a priori très différents. La programmation fonctionnelle recommande d’éviter les
états internes mutables et manipule toutes les instances comme des fonctions d’ordre su-
périeur ; la programmation orientée objet voit au contraire les variables, les fonctions et
les types comme des objets qui proposent des services et qu’il est possible de factoriser.
Quelques réflexions issues de la programmation fonctionnelle sont néanmoins bénéfiques
pour écrire des services Python de manière élégante, efficace et fiable :
— limiter les états mutables au strict nécessaire pour réduire les sources d’erreur,
faciliter les tests et la documentation du programme ;
— renvoyer self permet de proposer du chaînage d’opérations comme le font Pandas
(☞ p. 119, § 9) et Altair (☞ p. 137, § 10).
Le langage Python a une approche de l’interface beaucoup plus souple que la plupart des
langages de programmation : elle s’exprime sous la forme de protocoles (☞ p. 235, § 16).
234
16
Interfaces et protocoles
L
es interfaces sont l’un des piliers de la programmation orientée objet : il s’agit de la
manière de présenter des services à l’utilisateur. Cette réflexion qui consiste à poser en
amont de l’écriture du code la manière « idéale » pour un utilisateur potentiel d’appeler
une fonction, une méthode ou un attribut se formalise en programmation orientée objet par
l’énumération des méthodes et des services qui sont exposés à l’utilisateur.
En Python, la notion d’interface est beaucoup plus souple que dans la plupart des langages :
Python ne s’intéresse pas tant aux objets qu’à leur comportement. Si deux objets présentent
la même interface, alors on peut appeler les mêmes fonctions dessus. Le contrôle est alors
assuré par les exceptions. C’est ce que Python appelle le typage canard (duck typing) : « S’il
marche comme un canard et cancane comme un canard, alors c’est un canard ! » Par exemple,
la fonction intégrée sorted prend en paramètre une « séquence » d’éléments que l’on peut
comparer :
>>> sorted([1, 7, 4]) # sur les entiers
[1, 4, 7]
>>> sorted("hello") # une chaîne de caractères est aussi itérable
['e', 'h', 'l', 'l', 'o']
>>> sorted([1, 7, 4, 3.14]) # On peut comparer les entiers et flottants
[1, 3.14, 4, 7]
Si deux éléments de la structure ne peuvent plus être comparés, le contrôle est assuré par
les exceptions :
>>> sorted([1, 7, 3.14, 1-2j])
Traceback (most recent call last):
...
TypeError: '<' not supported between instances of 'complex' and 'float'
Le message d’erreur est clair : il n’existe pas de relation d’ordre entre les complexes et
les flottants. En revanche, puisque ces éléments peuvent être ajoutés, la fonction intégrée sum
s’applique :
>>> sum([1, 7, 3.14, 1 - 2j])
(12.14-2j)
235
16. Interfaces et protocoles
Les fonctions intégrées Python sont codées en supposant que les données d’entrée res-
pectent des propriétés, par exemple :
class Triangle(Polygone):
def __init__(self, p1: complex, p2: complex, p3: complex):
self.v1 = p2 - p1
self.v2 = p3 - p1
class Quadrilatere(Polygone):
def __init__(self, p1: complex, p2: complex, p3: complex, p4: complex):
self.t1 = Triangle(p1, p2, p3)
self.t2 = Triangle(p3, p4, p1)
>>> sorted(polygones)
236
16.1. Structures séquentielles
# abrégé
>>> sorted(polygones)
[Quadrilatere d'aire 5.00, Triangle d'aire 6.00]
Le protocole Iterable. La plupart des classes Python qui sont itérables n’héritent pas d’une
interface Iterable comme ce serait le cas dans la plupart des langages orientés objet, mais si
l’interpréteur trouve parmi les méthodes de la classe un moyen d’itérer sur une instance, alors
il reconnaît la classe comme suivant le protocole Iterable.
import itertools
from dataclasses import dataclass
237
16. Interfaces et protocoles
@dataclass
class Carte:
valeur: str
couleur: str
def __repr__(self):
return f"{[Link]}{[Link]}"
class Jeu32Cartes:
couleurs = ["♠", "♥", "♦", "♣"]
valeurs = ["A", "R", "D", "V", "10", "9", "8", "7"]
def __init__(self):
self._ensemble = list(
Carte(valeur, couleur)
for (valeur, couleur) in [Link]([Link], [Link])
)
>>> list(Jeu32Cartes())
Traceback (most recent call last):
...
TypeError: 'Jeu32Cartes' object is not iterable
>>> from collections import abc
>>> isinstance(Jeu32Cartes(), [Link])
False
Ainsi, en ajoutant une méthode __iter__(self) à notre classe, nous offrons à Python la
possibilité d’itérer dessus.
class Jeu32Cartes:
# abrégé
def __iter__(self):
yield from self._ensemble
>>> list(Jeu32Cartes())[:10]
[A♠, A♥, A♦, A♣, R♠, R♥, R♦, R♣, D♠, D♥]
>>> isinstance(Jeu32Cartes(), [Link])
True
Les protocoles Sized, Container et Collection. Une structure itérable n’est pas forcément
finie : la fonction len() n’est alors pas systématiquement disponible.
>>> len(Jeu32Cartes())
Traceback (most recent call last):
...
TypeError: object of type 'Jeu32Cartes' has no len()
238
16.1. Structures séquentielles
La fonction codée sum(1 for _ in iterable) est la manière par défaut de coder la longueur
d’une structure itérable finie. Si la longueur peut être inférée de manière plus directe, il est
préférable de procéder autrement.
class Jeu32Cartes:
# abrégé
def __len__(self):
return len(self._ensemble)
>>> len(Jeu32Cartes())
32
>>> isinstance(Jeu32Cartes(), [Link])
True
# abrégé
Notre jeu de cartes suit alors désormais le protocole Collection (qui est un alias pour
l’union des trois protocoles précédents).
>>> isinstance(Jeu32Cartes(), [Link])
True
Le protocole Sequence. Afin de mélanger notre jeu de cartes, on pourrait vouloir faire appel
à la fonction shuffle du module random :
>>> import random
>>> [Link](Jeu32Cartes())
Traceback (most recent call last):
...
TypeError: 'Jeu32Cartes' object is not subscriptable
Le message d’erreur est ici clair : le fonctionnement des opérateurs d’indexation « [ ] » n’a
pas été fourni. Il est possible de s’assurer qu’aucune méthode du protocole n’a été oubliée en
héritant de la classe abstraite de base (abstract base class, ou ABC) correspondante. Notons bien
que cet héritage est facultatif, que l’objet sera bien reconnu comme respectant le protocole en
question même si on n’hérite pas de l’ABC en question : en revanche, l’utilisation explicite de
l’ABC permet de lever une exception au moment de la création de l’objet.
239
16. Interfaces et protocoles
class Jeu32Cartes([Link]):
# abrégé
>>> Jeu32Cartes()
Traceback (most recent call last):
...
TypeError: Can't instantiate abstract class Jeu32Cartes with abstract methods
__getitem__
def __init__(self):
self._ensemble = list(
Carte(valeur, couleur)
for (valeur, couleur) in [Link]([Link], [Link])
)
def __iter__(self):
yield from self._ensemble
def __len__(self):
return len(self._ensemble)
En revanche, une nouvelle exception apparaît avec un message différent. Nous avons codé
le protocole Sequence avec les méthodes nécessaires, mais des méthodes supplémentaires (en
l’occurrence __setitem__(self, index, value)) sont aussi nécessaires pour pouvoir appliquer
la fonction shuffle().
Il existe un protocole qui permet de prendre en compte cette particularité, le protocole
Sequence étant par défaut immutable, c’est-à-dire qu’il ne garantit pas qu’on puisse modifier
la séquence en question. Le protocole MutableSequence permet quant à lui les modifications et
nécessite encore des méthodes supplémentaires indiquées par l’exception TypeError suivante.
240
16.1. Structures séquentielles
class Jeu32Cartes([Link]):
# abrégé
>>> Jeu32Cartes()
Traceback (most recent call last):
...
TypeError: Can't instantiate abstract class Jeu32Cartes with abstract methods
__delitem__, __setitem__, insert
Nous aborderons dans le chapitre suivant (☞ p. 251, § 17) le mécanisme interne aux ABC
et expliquerons comment écrire de nouvelles classes ABC.
Tour de France 2020. Les mécanismes codés dans les méthodes d’itération ne sont pas
forcément aussi directs que ceux proposés dans l’exemple précédent.
Les événements sportifs comme le Tour de France font appel à des hélicoptères pour
filmer la course au fur et à mesure que les cyclistes parcourent les routes de France.
Ces hélicoptères volent à basse altitude, et d’autres avions évoluent alors à plus haute
altitude pour relayer les signaux TV des images prises par les hélicoptères et les partager
en direct avec les chaînes de télévision. Le DataFrame Pandas suivant contient toutes les
trajectoires d’un avion qui a été recruté pour couvrir l’édition 2020 du Tour de France.
Ces données sont issues du réseau OpenSky Network [Link]
df = pd.read_csv("tour_de_france.[Link]", parse_dates=["timestamp"])
timestamp altitude callsign groundspeed icao24 latitude longitude track vertical_rate
0 2020-08-29 [Link]+00:00 23225.0 ASR182B 159.0 3924a4 43.678801 7.231097 284.211192 896.0
1 2020-08-29 [Link]+00:00 23275.0 ASR182B 165.0 3924a4 43.679825 7.228088 295.164075 960.0
2 2020-08-29 [Link]+00:00 23325.0 ASR182B 167.0 3924a4 43.681870 7.223319 299.488908 896.0
3 2020-08-29 [Link]+00:00 23400.0 ASR182B 175.0 3924a4 43.685318 7.217167 308.506475 448.0
4 2020-08-29 [Link]+00:00 23425.0 ASR182B 178.0 3924a4 43.687087 7.214748 312.273689 512.0
… … … … … … … … … …
73799 2020-09-19 [Link]+00:00 21975.0 ASR182B 218.0 3924a4 47.812180 6.750687 286.774666 -192.0
73800 2020-09-19 [Link]+00:00 22000.0 ASR182B 222.0 3924a4 47.813803 6.742788 287.045737 -128.0
73801 2020-09-19 [Link]+00:00 21975.0 ASR182B 225.0 3924a4 47.815338 6.735443 287.065269 -128.0
73802 2020-09-19 [Link]+00:00 21975.0 ASR182B 227.0 3924a4 47.816940 6.727753 287.158374 -128.0
73803 2020-09-19 [Link]+00:00 21975.0 ASR182B 227.0 3924a4 47.818451 6.720406 286.917006 -64.0
Les altitudes sont exprimées en pieds, la vitesse sol (groundspeed) est exprimée en
nœuds, la vitesse verticale vertical_rate en pieds par minute et l’angle track, en degrés,
représente le cap (l’angle de route) suivi par l’avion. La colonne icao24 est un identifiant
unique par avion, qui peut être assimilé à son immatriculation et la colonne callsign
représente une mission ou un numéro de vol.
Dans le tableau fourni, un seul avion est représenté mais on trouve plusieurs trajec-
toires dans le jeu de données.
>>> [Link](), [Link]()
(array(['3924a4'], dtype=object), array(['ASR182B'], dtype=object))
[Link]([Link](1000)).encode(
alt.X("timestamp", title=None, axis=[Link](format="%d %b"))
).mark_point()
241
16. Interfaces et protocoles
L’objectif avant de procéder à des opérations sur chacune des trajectoires est de pou-
voir les séparer. On peut alors utiliser les intervalles de temps sans avoir reçu de données
pour séparer chaque trajectoire. On peut alors tracer la distribution des longueurs d’in-
tervalles sans données de la manière suivante :
[Link](
[Link](
timestamp_diff=lambda df: [Link]().dt.total_seconds()
).query("timestamp_diff > 3600")
).encode(
alt.X(
"timestamp_diff", bin=[Link](maxbins=30),
title="Intervalle (en heures) sans données",
),
alt.Y("count()", title=None),
).mark_bar()
0
16 18 20 22 24 26 28 30 32 34 36 38 40 42 44
Intervalle (en heures) sans données
if [Link][0] == 0:
return df
else:
yield [Link]("timestamp < @[Link]()") # Á
242
16.1. Structures séquentielles
Il convient alors de fournir ces services au sein de classes qui codent le protocole
Iterator Ã. On peut alors imaginer une classe Collection et une classe Trajectoire
qui ont toutes les deux un attribut data: [Link]. Dans l’exemple ci-dessous, les
classes sont utilisées pour reconstruire une carte de France avec le parcours du Tour de
France 2020 (limité aux journées qui ont été couvertes par l’avion en question).
from [Link] import PlateCarree
class Trajectoire:
@property
def start(self) -> [Link]:
return [Link]()
@property
def stop(self) -> [Link]:
return [Link]()
@property
def duree(self) -> [Link]:
return [Link] - [Link]
class Collection:
243
16. Interfaces et protocoles
if [Link][0] == 0:
return Trajectoire([Link])
else:
yield Trajectoire([Link]("timestamp < @[Link]()"))
yield from Collection(
[Link]("timestamp >= @[Link]()")
)
def __len__(self):
return sum(1 for _ in self)
[Link].from_records(
{"start": [Link], "stop": [Link], "durée": [Link]}
for traj in Collection(df)
)
fig, ax = [Link](
figsize=(7, 7),
subplot_kw=dict(projection=Lambert93())
)
[Link]("50m")
FIGURE 16.1 – Couverture du Tour de France 2020 par les avions de relais télévisés
[Link]
244
16.2. Interfaces fonctionnelles
245
16. Interfaces et protocoles
@chronometre_fmt()
def pause(seconds):
[Link](seconds)
Ici, c’est le bloc with (un gestionnaire de contexte) qui se charge de refermer le fichier une
fois les traitements terminés :
>>> [Link](3)
Traceback (most recent call last):
...
ValueError: read of closed file
L’utilisation de ces blocs est préférable à l’appel à l’instruction explicite [Link]() ajou-
tée manuellement après le code de lecture/écriture du fichier : en effet, avec un gestionnaire
de contexte, l’instruction [Link]() est appelée même si une exception interrompt le code
executé dans le bloc.
Rappelons à ce titre la syntaxe générale des exceptions (☞ p. 19, § 1.9) :
>>> dangereux(2)
OK
On remballe
>>> dangereux(0)
NE PAS diviser par zéro !!
On remballe
Les gestionnaires de contexte sont alors une abstraction autour des blocs try/finally. Si
les méthodes spéciales sont fournies, alors l’instance peut être utilisée comme un gestionnaire
de contexte :
— la méthode __enter__(self) est exécutée à l’entrée dans le bloc ;
— la méthode __exit__(self, exc_type, exc_value, traceback) est exécutée à la sortie
du bloc. Si la sortie du bloc se fait par une exception, le type, le message et la pile d’appel
sont alors passés en paramètres. L’exception remontera alors dans la pile d’appel sauf
si la méthode renvoie True.
class Dangereux:
def __enter__(self):
pass
247
16. Interfaces et protocoles
Le bloc __enter__ est en général utilisé pour modifier un état que l’on ne souhaite pas
voir perdurer en dehors du bloc. Dans la gestion des styles Matplotlib (☞ p. 83, § 6), on
souhaite voir les paramètres des feuilles de style appliqués uniquement dans le bloc with
[Link]("ggplot"), et la feuille de style par défaut rétablie après le bloc.
Dans l’exemple suivant, on change la couleur du terminal en gras et en rouge À à l’entrée
dans le bloc. L’instruction de remise à zéro est alors dans le bloc __exit__ Á, prête à être
exécutée même après une exception. On notera ici que la variable fournie après le mot-clé as
est la valeur de retour de la fonction __enter__ Â.
class Dangereux:
def __enter__(self) -> str:
print("\033[1;31m", end="") # À
return "ROUGE!" # Â
if exc_type is ZeroDivisionError:
print("NE PAS diviser par zéro !!")
handled = True
print("\033[0m", end="") # Á
print("On remballe")
return handled
Le même protocole est également disponible en Python sous la forme d’un décorateur ap-
pliqué à une fonction génératrice. Le mot-clé yield sépare les instructions exécutées à l’entrée
du bloc de celles exécutées à la fin du bloc.
import contextlib
@[Link]
def dangereux():
print("\033[1;31m", end="")
yield "ROUGE!"
print("\033[0m", end="")
248
16.3. Gestionnaires de contexte
Pour prendre en charge une gestion des exceptions, il convient alors de garder l’instruction
yield par un bloc try/except/else/finally :
import contextlib
@[Link]
def dangereux():
print("\033[1;31m", end="")
try:
yield "ROUGE!"
except ZeroDivisionError:
print("NE PAS diviser par zéro !!")
else:
print("\033[1;34mOK")
finally:
print("\033[0m", end="")
Les gestionnaires de contexte multiples Depuis la création d’un nouveau parser pour le
langage Python, il est désormais possible de créer plusieurs gestionnaires de contextes en un
seul bloc. Il est aussi désormais possible de créer des gestionnaires de contextes simple en les
parenthésant :
with (
Path("tour_de_france.[Link]").open("rb") as fh,
Dangereux()
):
... # suite des traitements
249
16. Interfaces et protocoles
En quelques mots…
Les protocoles sont des interfaces informelles auxquelles les objets Python peuvent ré-
pondre. Ils sont la base du duck typing et permettent notamment d’étendre la syntaxe
Python à de nouvelles classes écrites par l’utilisateur ou dans des librairies tierces.
Au même titre que la surcharge d’opérateurs, ils permettent de décrire le comporte-
ment de nouvelles structures de données par rapport :
— à la plupart des éléments de syntaxe : boucles for, appels de fonction avec la no-
tation parenthésée, gestionnaires de contexte with ;
— aux fonctions intégrées au langage : p. ex. sum(), sorted() ou max() ;
— à la bibliothèque standard : p. ex. shuffle() ou sample() du module random, ou
bisect() du module bisect.
Plutôt que d’utiliser des moyens détournés pour recoder des services similaires, l’uti-
lisateur bénéficie ainsi, après avoir codé les protocoles classiques présentés dans ce cha-
pitre, de l’état de l’art de l’algorithmique avancée intégrée au langage et qui permet de
proposer à des utilisateurs débutants l’utilisation de concepts avancés et performants à
moindre coût, en leur proposant de continuer d’écrire avec une syntaxe simple et lisible.
250
17
L’ABC de la métaprogrammation
L
es langages de programmation se construisent en enchaînant les abstractions sur des
entités et des structures de plus en plus générales et en assurant leur composabilité. Les
premiers chapitres de cette partie ont montré comment la programmation fonctionnelle
manipule les fonctions comme des variables et les compose pour générer de nouvelles fonc-
tions. La programmation orientée objet ajoute un niveau d’abstraction différent autour des
types abstraits (une structure de données et l’ensemble des opérations que l’on peut y appli-
quer) en organisant les structures autour des principes d’encapsulation, d’héritage et d’inter-
face.
La métaprogrammation porte l’abstraction au niveau des programmes. Ils sont alors conçus
pour pouvoir lire, générer, analyser et transformer d’autres programmes. C’est une technique
de programmation avancée où un programme peut être vérifié, modifié ou généré au charge-
ment ou à l’exécution.
On peut accéder à la méthode intégrée dans la classe Exemple (qui est alors une simple
fonction), ou à la méthode rattachée (bound method) à une instance de la classe Exemple.
251
17. L’ABC de la métaprogrammation
Tous les attributs de la classe Exemple sont visibles dans le dictionnaire __dict__ de la
classe, auquel on accède via la fonction vars :
>>> vars(Exemple)
mappingproxy({'__module__': '__main__',
'x': 0,
'zero': <function [Link](self)>,
'__dict__': <attribute '__dict__' of 'Exemple' objects>,
'__weakref__': <attribute '__weakref__' of 'Exemple' objects>,
'__doc__': None})
Une exception est levée si on accède à un élément qui n’est pas présent dans ce dictionnaire.
On peut alors ajouter une valeur par défaut à la fonction getattr, de la même manière qu’avec
[Link]() ou next().
>>> getattr(Exemple, "null")
Traceback (most recent call last):
...
AttributeError: type object 'Exemple' has no attribute 'null'
>>> getattr(Exemple, "null", None) # renvoie None
La fonction setattr modélise quant à elle l’assignation dans une notation pointée :
>>> setattr(Exemple, "x", 1) # équivalent à Exemple.x = 1
>>> Exemple.x
1
252
17.1. Les attributs dynamiques
class Trajectoire:
@property
def start(self) -> [Link]:
return [Link]() # Á
@property
def stop(self) -> [Link]:
return [Link]() # Á
def __repr__(self):
return f"Trajectoire ({[Link]}, {[Link]})"
On pourrait aussi envisager d’accéder à des grandeurs caractéristiques de chaque série via
cet attribut. Par exemple, il est possible de coder de manière dynamique l’attribut altitude_max
pour chaque trajectoire : si altitude_max n’est pas le nom d’une colonne, on peut chercher à
appliquer [Link]().
On peut alors chercher la colonne altitude par la notation entre crochets comme dans
l’exemple précédent. Pour recherche la méthode max, la méthode getattr renvoie la bound
method qu’il convient alors d’appeler avec les parenthèses. Ã
class Trajectoire:
# abrégé
raise AttributeError(msg) # Â
253
17. L’ABC de la métaprogrammation
>>> sample.altitude_max
27025.0
>>> sample.groundspeed_mean
178.4567526555387
V Bonnes pratiques
Depuis Python 3.7 (PEP 562), il est possible d’ajouter une fonction __getattr__ dans
un module pour définir un comportement particulier à l’import d’un symbole inconnu.
Ce symbole inconnu peut, par exemple, être un nom de fonction présent dans une an-
cienne version (on ajoutera alors un warning dans la fonction __getattr__), ou le nom
d’un symbole présent dans un plugin (une extension de programme fournie par des dé-
veloppeurs tiers) découvert de manière dynamique.
Le module numbers fournit également une hiérarchie d’ABC relative aux nombres, du plus
générique au plus spécifique : Number, Complex ℂ, Real ℝ, Rational ℚ et Integral ℤ. Ainsi,
isinstance(variable, Real) renverra vrai pour les booléens (type bool), les entiers (type int),
les flottants (type float ou np.float64), etc.
Le fonctionnement de la fonction isinstance(elt, cls) est décrit dans la méthode spé-
ciale __subclasshook__, qui est une méthode de classe. La méthode isinstance ne vérifie pas
que la classe hérite de Iterator, mais renvoie le résultat de cette méthode À, qui vérifie ici
simplement la présence des méthodes spéciales __iter__ et __next__ dans la hiérarchie de la
classe C (les classes listées dans C.__mro__).
1. [Link]
254
17.2. Définir une classe abstraite ABC
# [Link]
class Iterator(Iterable):
__slots__ = ()
@abstractmethod
def __next__(self):
'Return the next item from the iterator. When exhausted, raise StopIteration'
raise StopIteration
def __iter__(self):
return self
@classmethod
def __subclasshook__(cls, C): # À
if cls is Iterator:
return _check_methods(C, '__iter__', '__next__')
return NotImplemented
Si une collection hérite explicitement de Iterator, alors le décorateur @[Link]
se chargera de renvoyer une exception si aucune méthode ne surcharge la méthode décorée
(en l’occurrence __next__). Pour coder sa propre classe abstraite, il peut être dangereux de
chercher des noms de méthodes dans l’espace de nommage de la classe : ce fonctionnement
n’est pertinent qu’avec les dunder methods, dont la notation est réservée aux définitions pro-
posées par le langage. En revanche, on utilisera le décorateur abstractmethod pour marquer
le nom des méthodes attendues, dans une classe qui hérite de ABC.
from abc import abstractmethod, ABC
class PingPong(ABC):
@abstractmethod
def ping(self, name="ping"):
return NotImplemented
def pong(self):
return [Link](name="pong")
>>> class Ping(PingPong):
... pass
>>> ping = Ping()
Traceback (most recent call last):
...
TypeError: Can't instantiate abstract class Ping with abstract methods ping
>>> class Ping(PingPong):
... def ping(self, name="ping"):
... print(name)
>>> ping = Ping()
>>> [Link]()
ping
>>> [Link]()
pong
255
17. L’ABC de la métaprogrammation
tours = {
"nom": ["Tour Eiffel", "Torre de Belém", "London Tower"],
"ville": ["Paris", "Lisboa", "London"],
"latitude": [48.85826, 38.6916, 51.508056],
"longitude": [2.2945, -9.216, -0.076111],
"hauteur": [324, 30, 27],
}
class DataFrameWrapper:
def __new__(cls, data: [Link]):
if [Link][0] > 0: # Á
return super().__new__(cls)
return None
def __repr__(self):
return f"Tableau à {[Link][0]} lignes"
256
17.4. Le protocole Descriptor
>>> w = DataFrameWrapper([Link].from_dict(tours))
>>> [Link]("hauteur > 300")
Tableau à une lignes
>>> [Link]("hauteur > 1000") # renvoie None
class Nombre:
un = Un()
>>> Nombre().un
1
import pandas as pd
class Age:
def __get__(self, obj, objtype=None):
# Renvoie la durée depuis l'attribut time
return [Link]('now') - [Link]
class Individu:
def __init__(self):
# L'attribut time est créé lors de la création de l'instance
[Link] = [Link]('now')
On peut alors se servir des descripteurs pour spécifier des comportements particuliers.
On pourrait par exemple utiliser un logger et noter tous les accès en écriture à un attribut.
257
17. L’ABC de la métaprogrammation
Comme le nom de l’attribut est maintenant associé au descripteur, on peut utiliser la méthode
__set_name__() pour stocker le contenu de la variable réelle dans un attribut donné de l’objet.
Dans l’exemple ci-dessous, on utilise l’attribut public_name pour le nom effectif de l’attri-
but dans la classe Individu, et private_name qui est le même nom préfixé par le caractère _
pour le contenu effectif de l’attribut.
import logging
class LoggedAccess:
class Individu:
nom = LoggedAccess()
age = LoggedAccess()
def __repr__(self):
return repr(vars(self))
L’intérêt de ce genre de classe est alors de pouvoir factoriser ces comportements entre les
attributs et entre les classes. On peut également imaginer de valider certains traits pour les
attributs que l’on veut donner à une classe. On propose à cet effet l’ABC Validator qui cherche
à valider la valeur attribuée à chaque attribut avec la fonction à propos.
258
17.4. Le protocole Descriptor
class Validator(ABC):
def __set_name__(self, owner, name):
self.public_name = name
self.private_name = "_" + name
@abstractmethod
def validate(self, value):
pass
class String(Validator):
def __init__(self, **kwargs):
[Link] = kwargs
— Le descripteur OneOf(option1, option2, ...) vérifie que la valeur passée est l’une des
options passées au descripteur.
class OneOf(Validator):
def __init__(self, *options):
[Link] = set(options)
— Le descripteur AgeMin(value) vérifie que la valeur de date anniversaire est bien compa-
tible avec un âge minimal.
259
17. L’ABC de la métaprogrammation
class AgeMin(Validator):
def __init__(self, value=None):
self.age_min = [Link](value)
On peut alors appliquer les validateurs à chacun de nos attributs, sur une classe simple :
class PersonneMajeure:
nom = String(istitle=True)
genre = OneOf("M", "F")
date_naissance = AgeMin("18y")
def __repr__(self):
return repr(vars(self))
Au-delà de cet exemple illustratif, on peut imaginer enrichir notre exemple précédent sur
les trajectoires (☞ p. 241, § 16.1), où la colonne timestamp était nécessaire pour pouvoir utiliser
la méthode d’itération. On peut alors coder un validateur qui vérifie la présence d’une colonne
donnée dans le [Link] :
260
17.5. La classe type
class PandasHasColumn(Validator):
def __init__(self, *columns):
[Link] = columns
class DataFrameWrapper:
# abrégé
class Collection(DataFrameWrapper):
data = PandasHasColumn("timestamp")
# abrégé
9 Attention !
— Les propriétés sont des descripteurs particuliers dont la méthode __get__ corres-
pond au corps de la méthode décorée.
— Les méthodes sont également des descripteurs particuliers, des bound methods,
dont la méthode __get__ renvoie la fonction correspondante.
>>> help(type)
class type(object)
| type(object_or_name, bases, dict)
| type(object) -> the object's type
| type(name, bases, dict) -> a new type
261
17. L’ABC de la métaprogrammation
À la lecture du résultat de la fonction help(type), la première ligne est la réunion des deux
suivantes. La première utilisation sur la deuxième ligne, type(object), est la plus répandue :
elle permet de connaître le type d’une instance.
>>> type(2)
int
>>> type(tour_de_france)
Collection
En réalité, type est une classe. C’est la classe du type object. Les deux mots-clés ont une
relation très particulière :
— object est une instance de type, ce qui signifie que toutes les classes Python sont ins-
tanciées par le constructeur de la classe type ;
— type est une sous-classe (hérite) de object. En effet, toutes les classes héritent de object.
Ce sont les seuls objets qui sont définis de manière récursive, et c’est une relation qui ne
peut pas être exprimée en Python.
>>> type(Exemple())
Exemple
>>> type(Exemple)
type
>>> type(object)
type
>>> type(type)
type
>>> type.__mro__
(type, object)
Il devient alors possible de générer des classes de manière dynamique à l’aide de fonctions.
Supposons qu’on lise un fichier qui contient des grandeurs physiques.
On souhaite lister toutes ces grandeurs, en utilisant une classe par grandeur physique en
unité du système international, et créer pour chaque unité différente (inconnue a priori) une
classe qui hérite de la classe de base, avec une méthode qui convertit la valeur en unité du
système international.
262
17.5. La classe type
distances = [
{"value": 2, "unit": "m"},
{"value": 6, "unit": "ft", "conversion": 0.3048},
{"value": 3, "unit": "km", "conversion": 1000},
{"value": 1, "unit": "nm", "conversion": 1852},
]
class Distance:
"La classe de base dont hériteront toutes les unités."
unit = "m"
# Instantiation de la classe
[Link](cls(elt["value"]))
>>> sorted(instances)
[Distance_ft(6) = 1.83m, Distance(2) = 2.00m,
Distance_nm(1) = 1852.00m, Distance_km(3) = 3000.00m]
>>> classes
{'m': Distance, 'ft': Distance_ft, 'km': Distance_km, 'nm': Distance_nm}
L’exemple est ici un petit peu artificiel dans le sens où nous comptons sur les valeurs dans
263
17. L’ABC de la métaprogrammation
le dictionnaire pour fournir les informations de conversion, mais il reste néanmoins parlant :
seules les classes nécessaires sont générées de manière parcimonieuse dès l’instant où elles
sont nécessaires.
def validate_annotations(cls):
attr_dict = dict(vars(cls))
for key, value in cls.__annotations__.items():
# value est ici le type passé dans l'annotation
attr_dict[key] = VariableVerifier(value)
return type(cls)(cls.__name__, cls.__mro__, attr_dict) # À
@validate_annotations
class Exemple:
x: int
def __init__(self, x):
self.x = x
def __repr__(self):
return f"{type(self).__name__}({self.x})"
>>> Exemple(2)
Exemple(2)
264
17.7. Les métaclasses
>>> Exemple(2.0)
Traceback (most recent call last):
...
TypeError: x doit être de type: <class 'int'>
def __repr__(self):
return f"{type(self).__name__}({self.x}, {self.y})"
Il est néanmoins possible d’écrire une classe Exemple qui propage ces modifications pour
toutes les classes dont hérite Exemple. L’idée est alors de réécrire la fonction type pour la
remplacer par le contenu de la fonction validate_annotations.
Ceci se fait en écrivant une métaclasse, une classe qui hérite de type, qui décrit comment
construire de nouvelles classes. Dans cette classe héritée, on peut surcharger les méthodes
__new__ ou __self__. Au lieu de prendre une classe (qui n’existe pas encore) en paramètre
comme dans l’exemple précédent, en utilisant un décorateur de classe (☞ p. 264, § 17.6), ces
méthodes prennent en argument les mêmes arguments que type Á :
— un nom de classe (l’argument name) ;
— la liste (un tuple) des classes dont la classe hérite (l’argument bases) ;
— et le dictionnaire qui reflète le code de la classe (attributs et méthodes, l’argument
attr_dict).
Dans notre cas, on retrouve l’argument __annotations__ dans le dictionnaire attr_dict :
on remplit alors l’argument attr_dict pour l’enrichir avec les instances du descripteur Va-
riableVerifier Â, puis on rappelle le constructeur de la classe mère Ã, de la même manière
qu’on appelait type(cls) à la ligne À dans l’exemple qui utilise le décorateur. On précise en-
suite que la classe, et les classes qui en dérivent, doivent être créées à l’aide de la métaclasse
ValidateAnnotationsMeta Ä ; par défaut, c’est la métaclasse type qui s’en charge.
class ValidateAnnotationsMeta(type):
265
17. L’ABC de la métaprogrammation
attr_dict[key] = VariableVerifier(value) # Â
return super().__new__(cls, name, bases, attr_dict) # Ã
class Exemple(metaclass=ValidateAnnotationsMeta): # Ä
x: int
def __repr__(self):
return f"{type(self).__name__}({self.x})"
class Exemple_xy(Exemple):
# Comme Exemple_xy hérite de Exemple, la classe sera créée à l'aide de la
# méthode __new__ de la métaclasse ValidateAnnotationsMeta.
# Le descripteur associé à x sera réalisé à la création de la classe Exemple;
# celui associé à y sera réalisé lors d'un autre appel à la création de Exemple_xy.
y: str
def __repr__(self):
return f"{type(self).__name__}({self.x}, {self.y})"
>>> Exemple_xy(3, 2)
Traceback (most recent call last):
...
TypeError: y doit être de type: <class 'str'>
>>> Exemple_xy(3, "2")
Exemple_xy(3, 2)
L’information sur les métaclasses n’est pas disponible dans le dictionnaire __mro__ de la
classe (ici Exemple_xy qui hérite de Exemple, qui hérite elle-même de object), mais on la re-
trouve dans le __mro__ de la métaclasse :
>>> Exemple_xy.__mro__
(Exemple_xy, Exemple, object)
>>> type(Exemple_xy).__mro__
(ValidateAnnotationsMeta, type, object)
266
17.8. La méthode __init_subclass__
On peut également proposer une classe qui ait connaissance de toutes les classes qui en
dérivent. Par exemple, pour notre classe d’unités physiques :
class Distance:
unit = "m"
unites_derivees = dict()
# abrégé
@classmethod
def __init_subclass__(cls):
super().__init_subclass__()
Distance.unites_derivees[[Link]] = cls
class Distance_ft(Distance):
unit = "ft"
class Distance_nm(Distance):
unit = "nm"
>>> Distance.unites_derivees
{'ft': Distance_ft, 'nm': Distance_nm}
267
17. L’ABC de la métaprogrammation
En quelques mots…
La métaprogrammation est la discipline qui consiste à écrire des programmes de manière
dynamique. Dans ce chapitre, nous nous sommes penchés sur différentes façons d’ins-
pecter ou de modifier le contenu de classes après leur définition mais avant leur création.
Les attributs dynamiques permettent d’étendre les attributs accessibles dans une ins-
tance en manipulant le nom de l’attribut de manière dynamique. C’est une généralisation
des propriétés, ces décorateurs qui génèrent un triplet de méthodes associées à l’accès,
l’édition et la suppression d’un attribut donné. Les comportements des propriétés sont
réutilisables au sein de classes qui répondent au protocole Descriptor, associé à une va-
riable de classe.
Enfin, nous avons mis en évidence que les classes étaient des objets Python comme les
autres : une fonction Python peut alors prendre une ou plusieurs classes en paramètres et
renvoyer une classe. Il est alors possible de décorer des classes pour adapter leur compor-
tement. En Python, le type d’une classe, et par extension le type de object, est toujours
une instance type qui hérite pourtant de object.
L’abstraction la plus poussée que nous abordons dans cet ouvrage est la notion de
métaclasse, une classe qui hérite de type. Les métaclasses permettent de personnaliser le
processus de création des classes, pour modifier ou adapter leur contenu, et pour définir
comment générer des classes à partir de leur définition.
La méthode de classe __init_subclass__(cls) suffit néanmoins dans la plupart des
cas d’application des métaclasses.
268
18
La programmation concurrente
L
a concurrence permet à un ordinateur de faire plusieurs choses en même temps (du
moins en apparence). Par exemple, le système d’exploitation de l’ordinateur se charge
d’alterner l’utilisation de ses ressources pour tous les programmes en cours d’exécution,
donnant ainsi l’illusion d’un fonctionnement simultané.
La concurrence se distingue du parallélisme, qui exécute en parallèle plusieurs instruc-
tions sur plusieurs cœurs du processeur, ou sur plusieurs machines connectées. La principale
différence est le gain de temps que l’on peut attendre de ces deux approches : la concurrence
permet de générer des chemin d’exécutions différents, la séquence d’exécution n’aura jamais
lieu dans le même ordre entre plusieurs exécutions du programme, mais le temps d’exécution
total restera le même. Le parallélisme au contraire permet de diviser par deux le temps d’exé-
cution total de deux processus exécutés en même temps plutôt qu’à la suite l’un de l’autre.
Il existe plusieurs formalismes de programmation qui prennent en charge la concurrence
mais tous ne donnent pas accès au même niveau de parallélisme. Nous abordons dans ce cha-
pitre l’exécution de différents processus depuis Python : les processus systèmes externes, les
processus légers (threads), le multiprocessing et les sous-interpréteurs.
269
18. La programmation concurrente
La fonction Popen quant à elle lance l’appel dans un processus fils, indépendant de Python.
On peut alors interroger le processus pour voir s’il a terminé avec la méthode .poll() qui
renvoie None tant que le processus tourne (et le code de retour du programme sinon).
>>> import time
>>> proc = [Link](["sleep", "1"])
>>> while [Link]() is None:
... print("Je dors.")
... [Link](0.3)
Je dors.
Je dors.
Je dors.
Je dors.
>>> [Link]()
0
Comme l’appel à Popen est non bloquant, toutes les exécutions ont lieu en parallèle. Ainsi, si
on lance 10 fois la commande sleep, le temps global d’exécution restera proche d’une seconde,
plutôt que 10 secondes en cas d’appel séquentiel. L’appel à communicate() permet de reprendre
la main sur chacun des processus (la fonction retourne quand le processus est terminé, ou après
un timeout à spécifier en paramètre).
%%time
procs = []
for _ in range(10):
[Link]([Link](["sleep", "1"]))
Pour ce genre d’opérations qui n’utilise pas les ressources du processeur, l’appel concur-
rentiel a lieu en parallèle et permet une accélération du temps global d’exécution.
V Bonnes pratiques
Il est possible de manipuler l’entrée standard stdin, la sortie standard stdout et la sortie
d’erreur stderr depuis la fonction Popen. L’argument PIPE permet de se raccorder aux
attributs correspondants, rattachés à l’instance du processus.
>>> cat_proc = [Link](
... ["cat", "-"], stdin=[Link],
... stdout=[Link], encoding="utf-8"
270
18.2. Les threads
... )
>>> cat_proc.[Link]("coucou\n")
>>> cat_proc.[Link]() # on s'assure que le contenu a bien été envoyé
>>> cat_proc.[Link]() # lecture de la sortie
'coucou\n'
>>> cat_proc.communicate() # on termine le processus
('', None)
cat_proc = [Link](
["cat", "-"], stdin=[Link], stdout=[Link], encoding="utf-8"
)
wc_proc = [Link](
["wc", "-l"], stdout=[Link], encoding="utf-8",
stdin=cat_proc.stdout, # on redirige la sortie du cat vers l'entrée du sed
)
cat_proc.[Link]("un\ndeux\ntrois\n")
cat_proc.[Link]("cat\n")
cat_proc.[Link]()
cat_proc.[Link]()
cat_proc.communicate()
wc_proc.communicate()
# (' 4\n', None)
271
18. La programmation concurrente
import math
grands_nombres_premiers = [
112272535095293, 112582705942171, 112272535095293, 115280095190773,
115797848077099, 1099726899285419,
]
class Premiers(Thread):
def __init__(self, number):
super().__init__()
[Link] = number
def run(self):
[Link] = nombre_premier([Link])
%%timeit
threads = []
for number in grands_nombres_premiers:
thread = Premiers(number)
[Link]()
[Link](thread)
Le temps total d’exécution est comparable (voire parfois supérieur) à celui d’une exécution
séquentielle. La mécanique de mise en place des threads rajoute en effet un coût initial non
négligeable.
Pourtant, les choses sont différentes dans l’exemple suivant basé sur les drapeaux étudié
plus tôt (☞ p. 45, § 3.6). On retiendra ici que la commande [Link], introduite en détail plus
loin (☞ p. 309, § 21.1), télécharge des contenus sur Internet.
272
18.3. Le Global Interpreter Lock (GIL)
import httpx
r = [Link]("[Link]
codes = [Link]()
%%time
for c in [Link]():
r = [Link](f'[Link]
# CPU times: user 10.6 s, sys: 380 ms, total: 11 s
# Wall time: 48 s
Il nous faut une minute pour télécharger l’ensemble des drapeaux sur le site. Mais en exécu-
tant ces requêtes de manière multithreadée, on observe une accélération du temps d’exécution
par un facteur proche de 20.
class Drapeau(Thread):
def __init__(self, code):
super().__init__()
[Link] = code
def run(self):
url = f"[Link]
self.r = [Link](url)
%%time
threads = []
for c in [Link]():
thread = Drapeau(c)
[Link]()
[Link](thread)
273
18. La programmation concurrente
Ces appels systèmes d’entrée et sortie étant sujets à des problèmes de latence, il est dom-
mage de paralyser du temps du processeur (CPU) pour attendre une réponse d’un serveur web
par exemple, alors qu’on pourrait l’utiliser pour d’autres opérations.
9 Attention !
Les threads sont une bonne solution pour des programmes qui font de nombreux appels
systèmes, mais restent à éviter si on souhaite paralléliser des exécutions lourdes sur le
CPU. Ils n’apportent alors aucun gain de performance tout en ayant un coût de mise en
place non négligeable.
Pour aller plus loin. Le GIL est toujours vu comme un handicap de l’interpréteur CPython
et reste le sujet de nombreuses présentations dans les conférences PyCon.
— Larry Hastings - Removing Python’s GIL : The Gilectomy (PyCon 2016)
[Link]
— Eric Snow - to GIL or not to GIL : the Future of Multi-Core (C)Python (PyCon 2019)
[Link]
274
18.5. Le multiprocessing
18.5. Le multiprocessing
Pour pallier les problèmes de GIL en Python et permettre l’utilisation de tous les cœurs
du CPU, il est possible d’utiliser une autre approche qui procède à du vrai parallélisme dans
l’exécution. Le module correspondant en Python se nomme multiprocessing, mais il reste pré-
férable de l’utiliser via le module [Link]. Le multiprocessing consiste à exécuter
des processus Python différents sur plusieurs cœurs, à y reproduire l’environnement courant
puis à y exécuter une partie des traitements en attente.
Il y a néanmoins quelques outils pratiques dans le module multiprocessing, notamment
une fonction pour détecter le nombre de cœurs CPU disponibles sur l’architecture en question.
>>> from multiprocessing import cpu_count
>>> cpu_count()
4
275
18. La programmation concurrente
# 4.6 s ± 229 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
9 Attention !
Même si le ProcessPoolExecutor n’apporte rien de plus que le ThreadPoolExecutor dans
l’exemple du téléchargement des drapeaux, il pourrait être tentant de n’utiliser plus que
le ProcessPoolExecutor.
On retiendra néanmoins que :
— tous les threads partagent le même espace mémoire sans coût supplémentaire. Il
faut prendre garde à ajouter des verrous [Link] pour accéder à certaines
variables de manière concurrente ;
— le module multiprocessing, pour s’affranchir des contraintes du GIL, lance un
nouvel exécutable Python indépendant vers lequel il transmet tous les import,
définitions de fonctions, et arguments à passer avec chaque Future puis récupère
les résultats. Le passage se fait par sérialisation via le module pickle ᵃ (☞ p. 42,
§ 3.4) et cette sérialisation a un coût non négligeable, surtout pour les structures
volumineuses. Le choix du meilleur argument max_workers ne dépendra pas seule-
ment du nombre de cœurs CPU disponibles, mais également de la mémoire RAM
disponible sur l’ordinateur qui ne permet pas forcément de contenir autant de
duplications des structures manipulées que nécessaire.
a. Les structures non sérialisables comme les fonctions anonymes lambda (☞ p. 169, § 12.2) ne peuvent
pas être passées en argument de submit.
276
18.7. Python sans le GIL
277
18. La programmation concurrente
Les tests sont réalisés sur les versions suivantes de Python : la 3.12.6, la 3.13.0rc2 (la version
finale 3.13.0 n’est pas encore disponible au moment de ces tests) et la même version 3.13.0rc2,
compilée avec les options --disable-gil --enable-optimizations
3.12.6
séquentiel
multi-threads
multi-interpréteurs
multi-processus
3.13.0rc2
séquentiel
multi-threads
multi-interpréteurs
multi-processus
3.13.0rc2+nogil
séquentiel
multi-threads
multi-interpréteurs
multi-processus
0.0 0.2 0.4 0.6 0.8 1.0 1.2 1.4 1.6 1.8 2.0 2.2 2.4 2.6 2.8
Temps d'exécution (en s) →
278
19
La programmation asynchrone
Le modèle de programmation asynchrone en Python permet de gérer un ensemble de
tâches comportant des opérations potentiellement bloquantes (comme des accès disque, des
interactions avec des périphériques ou des ressources réseau) de manière concurrente, tout en
maintenant l’exécution dans un seul et unique thread.
Le module asyncio, introduit dans la version 3.4 de Python, repose sur une boucle d’évé-
nements (event loop) qui orchestre l’exécution non bloquante des tâches dans un unique pro-
cessus. Ce modèle multitâche est similaire aux threads en ce qui concerne la concurrence des
tâches, mais sans les complications liées à la gestion des ressources partagées. De plus, démar-
rer une fonction coroutine est bien plus léger qu’initialiser un environnement multithreadé,
car cela se fait simplement via l’appel d’une fonction.
Voici quelques éléments de syntaxe essentiels :
— async permet de définir une fonction coroutine ;
— await est utilisé pour appeler une fonction coroutine de manière asynchrone.
Ainsi, toute fonction contenant le mot-clé await devient une coroutine et doit être déclarée
avec le mot-clé async.
279
19. La programmation asynchrone
9 Attention !
Une erreur fréquente consiste à oublier le mot-clé await lorsqu’on appelle une fonc-
tion asynchrone dans le corps d’une autre fonction. Cela n’exécute pas la fonction mais
crée simplement un objet coroutine, qui sera détruit lorsque son compteur de référence
retombera à zéro.
On peut aussi exécuter plusieurs fois la même fonction de manière séquentielle à l’aide
d’une boucle. L’intérêt principal de la programmation asynchrone est qu’elle permet de bas-
culer entre différentes tâches pendant qu’une opération est en attente (grâce au mot-clé await).
Ce comportement est particulièrement utile dans les environnements où des opérations blo-
quantes sont fréquentes. Pour gérer l’exécution concurrente de plusieurs coroutines, on utilise
la fonction [Link](), qui permet d’exécuter plusieurs coroutines simultanément et
d’attendre qu’elles se terminent toutes.
>>> [await compte() for _ in range(3)] # 3 secondes plus tard
un
deux
un
deux
un
deux
280
19.2. La boucle d’exécution
V Bonnes pratiques
Avant l’introduction des mots-clés async et await par le PEP 492, la syntaxe de la pre-
mière fonction était comme suit. Cette syntaxe fonctionne encore en Python 3.9, avec
néanmoins un DeprecationWarning qui encourage à l’utilisation de async def.
@[Link]
def count():
res = yield from [Link](1)
La tournure res = await fonction() est venue remplacer a = yield from fonction()
utilisée précédemment, et qui marque un lien plus fort avec les coroutines abordées plus
tôt (☞ p. 205, § 14.5).
def print_now():
print(f"{[Link]() - t0:.5f}s")
loop.call_soon(print_now) # À
loop.call_soon(print_now)
loop.run_until_complete([Link](3)) # Á
281
19. La programmation asynchrone
# 0.00011s
# 0.00014s
# fini: 3.00180s
def print_trampoline():
print(f"{[Link]()-t0:.5f}s")
loop.call_later(1, print_trampoline) # Â
loop.call_later(3, [Link]) # Ã
loop.call_soon(print_trampoline)
loop.run_forever()
# 0.00034s
# 1.00184s
# 2.00349s
282
19.4. Les gestionnaires de contexte asynchrone
tick
tick
Les deux syntaxes suivantes sont valides en Python, mais ont une sémantique, une signi-
fication différente :
1. Dans le premier cas, on a affaire à une fonction génératrice asynchrone ou un objet sur
lequel il est possible d’itérer de manière asynchrone. Chaque itération fait potentielle-
ment appel à un appel bloquant pendant lequel la boucle d’événement pourrait choisir
d’exécuter une autre tâche.
async for elt in generateur_asynchrone():
...
2. Dans le second cas, il n’y a qu’un seul appel asynchrone qui renvoie une structure sur
laquelle il est également possible d’itérer, mais de manière classique (synchrone).
for elt in await fonction_asynchrone():
...
V Bonnes pratiques
Un objet propose les méthodes spéciales __iter__ et __next__ (☞ p. 237, § 16.1) pour
configurer le processus d’itération. Pour l’itération asynchrone, on utilisera alors les
méthodes __aiter__ et __anext__.
transaction = [Link]()
await [Link]() # on démarre la transaction (connexion à la base)
try:
# envoi de la requête
await [Link]("SELECT * FROM ma_table")
except:
# si échec de la requête, on applique une opération pour revenir en arrière
await [Link]()
raise
else:
# sinon on exécute la requête
await [Link]()
283
19. La programmation asynchrone
V Bonnes pratiques
Un objet propose les méthodes spéciales __enter__ et __exit__ (☞ p. 246, § 16.3) pour
les gestionnaires de contexte. Pour l’itération asynchrone, on utilisera alors les méthodes
__enter__ et __aexit__.
À partir de la liste des codes à télécharger, on peut alors créer une liste de promesses, qu’on
exécute avec la fonction [Link] :
promises = [[Link](f"[Link] for code in codes]
async with [Link]() as client:
r = await [Link](*promises)
Ici, l’exécution dure 1.5 seconde au lieu d’une minute lorsque le téléchargement est effectué
de manière séquentielle. Néanmoins, ce code ne respecte pas les bonnes pratiques en matière
de comportement sur le web. En effet, plus de 200 requêtes sont envoyées simultanément au
serveur web. Bien que ce comportement, appelé throttling, puisse être toléré par certains sites
web, il peut être perçu comme une attaque par déni de service. Il est donc conseillé de limiter
le nombre d’appels concurrents.
Pour cela, on peut utiliser un objet Semaphore, appelé via un gestionnaire de contexte, qui
ne permet qu’un nombre fixe d’entrées simultanées dans un contexte, défini lors de la création
du sémaphore. Le code peut alors être réécrit de la manière suivante. Le téléchargement sera
plus lent, car les ressources seront limitées, mais le comportement sera plus respectueux.
284
19.5. Le téléchargement asynchrone de contenu
En quelques mots…
La programmation asynchrone, telle qu’implémentée avec le module asyncio, offre une
manière élégante de gérer la concurrence des tâches potentiellement bloquantes, comme
les accès réseau ou disque. Ce modèle permet d’exécuter plusieurs tâches de manière
concurrente, tout en restant dans un seul et unique thread, grâce à la boucle d’événe-
ments.
Il est important de souligner que, bien que les tâches asynchrones puissent s’exécuter en
parallèle, elles ne doivent pas être confondues avec le multithreading. Contrairement à la
programmation multithreadée, où plusieurs threads peuvent s’exécuter simultanément
sur plusieurs cœurs de processeur, la programmation asynchrone repose sur un modèle
non bloquant dans un seul thread. Cela évite la gestion complexe des ressources partagées
et le risque d’atteintes aux données dû à la concurrence.
Pour aller plus loin
— Python Concurrency with asyncio, Matthew Fowler, 2022
Manning, ISBN 978-1617298660
285
C
Interlude
La démodulation de signaux FM
Le code qui produit les figures de ce chapitre est disponible sur la page web du livre.
L
es premières transmissions de la voix sans fil datent du début du XXᵉ siècle : rapide-
ment l’idée de la radiodiffusion fleurit, et c’est dès 1914 que, sous l’impulsion du roi des
Belges Albert Iᵉʳ, un programme radiophonique est diffusé depuis le palais de Laeken.
Malheureusement, l’antenne est détruite peu après lors de l’invasion de la Belgique.
Au sortir de la Grande Guerre, les premières stations de radiodiffusion s’installent : PCGG
émet des programmes radiophoniques dès 1919 depuis La Haye. En France, Radio Tour Eiffel
diffuse sa première émission radiophonique le 24 décembre 1921, captée par un nombre res-
treint d’amateurs avertis capables de se construire un récepteur. Au Royaume-Uni, la BBC est
créée en 1922.
Le principe de la radiodiffusion repose sur la transmission de signaux sonores, à basse fré-
quence, superposés à des ondes électromagnétiques à haute fréquence. Les premières émis-
sions radios procèdent par modulation d’amplitude (AM) : il suffit de peu de matériel pour
pouvoir les écouter, la démodulation reposant sur l’utilisation d’un simple filtre passe-bas.
On attribue à Edwin Armstrong l’invention de la modulation de fréquence (FM) dans les
années 1930. Si l’intérêt de cette technologie paraît limité au début à cause d’une portée plus
courte et de l’utilisation de hautes fréquences, cette approche permet de gérer le compromis
entre robustesse et bande passante occupée. Elle s’impose alors dès les années 1950.
287
Interlude
Une fois positionnée sur une fréquence 𝑓 donnée, la radio logicielle va transformer un
signal radio d’amplitude 𝐴(𝑡) et de phase 𝜙(𝑡) telles que :
𝐴(𝑡) sin(2𝜋𝑓 𝑡 + 𝜙(𝑡)) = 𝐴(𝑡) sin(2𝜋𝑓 𝑡) cos 𝜙(𝑡) + 𝐴(𝑡) cos(2𝜋𝑓 𝑡) sin 𝜙(𝑡)
en échantillons 𝐴(𝑡) cos 𝜙(𝑡) + 𝑗 ⋅ 𝐴(𝑡) sin 𝜙(𝑡) exprimés sous forme de nombres complexes.
La partie réelle des échantillons est dite « en phase » (In phase) et la partie imaginaire « en
Quadrature », d’où l’appellation I/Q samples en anglais.
Dans le cadre de la démodulation FM, il s’agit alors de retrouver 𝜙(𝑡) ¹ pour le convertir en
signal audio. Un échantillon est fourni sur la page web du livre ; il se télécharge sous la forme
d’un fichier binaire, à convertir en tableau NumPy.
from pathlib import Path
import numpy as np
buffer = Path("[Link]").read_bytes()
en quadrature
288
La démodulation de signaux FM
L’essentiel du traitement du signal effectué sur ces échantillons est basé sur de l’analyse
fréquentielle. La transformée de Fourier rapide, intégrée au module SciPy, permet d’analyser
les signaux reçus dans l’espace des fréquences.
Matplotlib fournit un certain nombre d’outils pour analyser nos données, notamment :
— un périodogramme de Fourier, qui estime la densité spectrale de puissance (PSD en
anglais) et montre dans quelles fréquences se situe le plus d’information. Cette densité
est exprimée en échelle logarithmique ;
— un spectogramme, qui ajoute une dimension temporelle à cette visualisation.
Les arguments passés en paramètres (la fréquence d’échantillonnage) permettent de cali-
brer les échelles sur le graphe.
fig, ax = [Link](1, 2, figsize=(14, 4))
ax[0].xaxis.set_major_formatter([Link](format_func))
ax[1].yaxis.set_major_formatter([Link](format_func))
Densité spectrale de puissance (en dB/Hz)
55
400 kHz
65
200 kHz
75
0 kHz
85
-200 kHz
95
-400 kHz
105
-400 kHz-200 kHz 0 kHz 200 kHz 400 kHz 5 10 15 20 25
Fréquence
289
Interlude
65
75
85
95
105
-400 kHz -200 kHz 0 kHz 200 kHz 400 kHz
Fréquence
Une troisième fréquence apparaît avec un décallage de 400 kHz mais celle-ci étant partiel-
lement en dehors de la bande passante choisie (liée au paramètre freq_sr), son spectre apparaît
plus faible et le résultat de la démodulation sera par conséquent de piètre qualité.
La prochaine étape consiste à ne sélectionner que les informations relatives à la fréquence
sur laquelle on est désormais centré. La fonction [Link] combine deux étapes :
un filtre passe-bas (nous allons conserver ici une bande passante autour de 200 kHz pour éli-
miner les signaux des fréquences voisines) puis un rééchantillonnage (le terme décimation
signifie à l’origine « prendre un échantillon sur 10 »).
from [Link] import decimate
en quadrature
0.20
fm_samples = downsample(offset(samples, 200_000))
0.15
0.10
fig, ax = [Link]() 0.05
[Link]( 0.00
[Link](fm_samples[:5000]), 0.05
[Link](fm_samples[:5000]), 0.10
color="0.1", alpha=0.05/ 0.15
) 0.20
0.2 0.1 0.0 0.1 0.2
en phase
290
La démodulation de signaux FM
La modulation de fréquence se fait sur chaque échantillon en décalant la phase par rapport
à celle de l’échantillon précédent. Pour extraire 𝜙𝑖 − 𝜙𝑖−1 , on peut utiliser la formule suivante :
[Link].set_major_locator([Link](19_000))
[Link].set_major_formatter([Link](format_func))
[Link].set_major_locator([Link](20))
60
80
Dans cet interlude, nous nous contenterons d’extraire le signal mono par un simple filtre
passe-bas, conçu pour garder les 15 premiers kHz d’informations. La fonction lfilter permet
de construire un filtre à réponse impulsionnelle finie : le processus d’optimisation de Remez
permet de concevoir ici un filtre passe-bas en fonction des fréquences seuils voulues pour
la bande passante (jusqu’à 15 kHz), la bande de transition (ici 4 kHz) et la bande d’arrêt. Les
coefficients reconstruisent une approximation de la fonction sinus cardinal (le filtre passe-bas
idéal) sur un intervalle borné (la réponse impulsionnelle de notre filtre numérique) ; la fonction
freqz permet quant à elle de confirmer sa réponse fréquentielle.
291
Interlude
On peut alors comparer les densités spectrales de puissance sur les signaux avant et après
avoir appliqué le filtre passe-bas.
fig, ax = [Link]()
[Link](
extraction(fm_samples),
NFFT=2048, Fs=fm_bandwidth, label="signal d'origine",
color="0.1", linestyle="--", linewidth=0.6,
)
[Link](
lfilter(coefficients, 1.0, extraction(fm_samples)),
NFFT=2048, Fs=fm_bandwidth, label="signal filtré", color="0.1",
)
292
La démodulation de signaux FM
Une dernière étape doit venir se glisser avant de rééchantillonner notre signal audio vers
une fréquence compatible avec les logiciels de lecture (44 100 Hz est une valeur courante). En
effet, l’algorithme de démodulation de fréquence pénalise le rapport signal sur bruit des hautes
fréquences sonores.
Pour pallier ce problème, les émetteurs FM préaccentuent les hautes fréquences avant de
moduler le signal sonore sur la porteuse. Il conviendra donc de compenser cet effet avec le
mécanisme inverse de désaccentuation à la réception.
import sounddevice as sd
y = decimate(
deemphasis(
lfilter(
coefficients,
1.0,
extraction(downsample(offset(samples, 200_000))),
)
),
int(freq / 44100),
)
Les échantillons audio sont disponibles sur la page web du livre. On peut décoder deux
fréquences radio correctement : sur FIP (103.5 MHz) on diffusait Moon River par Melody Gardot
(de l’album Sunset in the blue) pendant que sur Radio Classique (103.1 MHz), on diffusait la
Gnossienne nᵒ 1 de Satie interprétée par Anne Queffélec.
293
Interlude
array: [Link]
mono_signal: Hertz = 15_000
fm_bandwidth: Hertz = 220_500
# abrégé
La lecture des données depuis l’antenne avec pyrtlsdr propose une interface native avec
un itérateur asynchrone : on compte sur la boucle d’exécution du module asyncio pour bien
ordonner les phases de lecture sur l’antenne et les phases d’écriture sur la carte son. Il est
alors possible de décoder les échantillons en simulant du temps réel. Une seconde interface
est proposée sur la page web du livre, avec une lecture asynchrone d’un gros fichier local, ou
sur un serveur distant.
294
La démodulation de signaux FM
import asyncio
sdr = RtlSdr()
sdr.sample_rate = sampling_rate
sdr.center_freq = center_frequency - offset
[Link] = gain
await [Link]()
[Link]()
295
Interlude
with [Link](
samplerate=44100,
blocksize=int(blocksize / 25),
channels=1,
dtype="int16",
callback=callback,
):
await file_streaming(
audioqueue,
file=input_path,
blocksize=blocksize,
offset=offset,
)
En quelques mots…
L’utilisation d’un dispositif de réception de radio logicielle rentre dans le cadre d’utilisa-
tion de la programmation concurrente avec le module asyncio : une boucle bloquante qui
ne ferait que recevoir les signaux ne laisserait pas la place au processeur pour les décoder
en temps réel.
Les instructions pour utiliser le script de démodulation des signaux FM à partir d’un
fichier local, distant ou d’un dispositif de réception de radio logicielle sont disponibles
sur la page web du livre : [Link]
296
IV
Python, couteau
suisse du
quotidien
20
Comment manipuler des formats
de fichiers courants ?
P
ython est reconnu comme un langage de script efficace pour accomplir de nombreuses
tâches de la vie quotidienne. Les parties précédentes se sont concentrées sur les struc-
tures propres au langage, sur les bibliothèques répandues dans le monde scientifique,
et sur des concepts avancés d’informatique. Cette partie traite quant à elle des interactions
entre Python et le reste de nos activités sur un ordinateur, à commencer par la manipulation
des fichiers les plus courants.
Lecture et écriture. Une image se lit, s’écrit, s’affiche ou se transforme en contenu binaire à
l’aide des fonctions suivantes :
# Lecture d'un fichier
img: [Link] = [Link]("[Link]", cv2.IMREAD_COLOR)
# Écriture dans un fichier
[Link]("[Link]", img_resized)
# Affichage dans une fenêtre à part
299
20. Comment manipuler des formats de fichiers courants ?
Détection de contours. Plusieurs algorithmes sont proposés pour détecter les contours, no-
tamment le filtre de Canny ².
# noir et blanc inversés par souci de lisibilité à l'impression
img_edges = 255 - [Link](img, 100, 100, True)
300
20.2. Le traitement du son et les métadonnées associées
La lecture des métadonnées attachées à une photo, qui contiennent des informations à pro-
pos de l’appareil photo, des réglages techniques (ouverture focale, etc.) ou des coordonnées
GPS, n’est pas proposée par OpenCV qui se concentre sur le traitement des images.
La bibliothèque exifread [Link] est capable d’extraire ces infor-
mations sous la forme d’un dictionnaire Python.
301
20. Comment manipuler des formats de fichiers courants ?
Gestion des métadonnées. Les fichiers audio courants (MP3, OGG, FLAC, etc.) proposent
d’embarquer des informations sur les métadonnées associées : titre, nom de l’artiste ou genre.
La bibliothèque mutagen [Link] elle-même utilisée dans des
outils comme beets [Link] permet de manipuler ces informa-
tions. La bibliothèque tinytag [Link] permet également d’accé-
der à ces métadonnées (en lecture seule uniquement) pour un grand nombre de formats : elle
mérite un coup d’œil à son code, qui est très accessible, pour comprendre la structuration de
ce type d’information dans les fichiers audio.
Ce format permet également de spécifier une grammaire pour valider les données échan-
gées (le format XSD, pour XML Schema Definition) et des feuilles de style (le format XSLT, pour
eXtensible Stylesheet Language Transformations), qui permet de produire un rendu pour les
données dans un autre format (HTML, ou PDF par exemple). Python propose une bibliothèque
intégrée pour parcourir une arborescence XML, mais la bibliothèque tierce lxml est plus robuste
et offre de meilleures performances [Link]
from [Link] import ElementTree as etree # avec la bibliothèque intégrée
from lxml import etree # **ou** avec lxml à installer: pip install lxml
302
20.3. Les formats d’échange XML et HTML
<matricule>007</matricule>
</agent>
Dans des cas d’usage plus avancés, la bibliothèque permet également d’explorer et d’ex-
ploiter le contenu d’un fichier XML en utilisant des espaces de nommage, de rechercher des
éléments avec la syntaxe XPath, de construire un nouveau document à l’aide de classes fa-
briques, mais ceci sort du cadre de cet ouvrage.
Le format HTML est un cas particulier du format XML : il est utilisé pour écrire des pages web
en hiérarchisant leur contenu. La bibliothèque beautifulsoup4 permet d’explorer des pages
HTML de manière intelligente.
pip install beautifulsoup4 # [Link]
Dans l’exemple ci-dessous, on explore la page d’accueil du journal Le Monde (Fichier >
Enregistrer sous... depuis un navigateur web). Les titres de section (International, Économie,
Sciences, etc.) sont tous étiquetés par une balise <h4>, dans une balise <div> de classe area--
section : on peut alors afficher l’ensemble des titres de section. Les méthodes find et find_all
permettent d’identifier des balises qui remplissent certaines conditions.
from pathlib import Path
page_web = Path("le_monde.html").read_text()
contenu = [Link](page_web, "lxml")
[...]
Elections américaines 2020
Planète
Les décodeurs
International
[...]
303
20. Comment manipuler des formats de fichiers courants ?
2_118 [Content_Types].xml
1_723 word/[Link]
590 _rels/.rels
2_721 word/[Link]
998 docProps/[Link]
19_572 word/media/[Link]
649 docProps/[Link]
923 word/media/[Link]
2_033 word/_rels/[Link]
18_061 word/[Link]
290 word/_rels/[Link]
3_038 word/[Link]
182_261 word/[Link]
32_686 word/[Link]
1_717 word/[Link]
23_155 word/theme/[Link]
2_389 word/[Link]
525 word/[Link]
2_803 word/[Link]
Les documents Word. Pour une simple extraction du contenu d’un fichier Word (texte et
images) ou pour générer un tel fichier à partir d’un contenu exprimé dans un autre langage à
balises (Markdown, ReST, etc.), l’outil pandoc [Link] conviendra mieux qu’une
bibliothèque Python. Pour manipuler ou composer de tels fichiers de manière programmatique
dans l’écosystème Python, la bibliothèque python-docx [Link]
fait référence.
Les tableurs Excel. La bibliothèque Pandas (☞ p. 119, § 9) permet de lire et écrire des docu-
ments au format Excel. Les dépendances optionnelles xlrd (pour les fichiers .xls) et openpyxl
(pour les fichiers .xlsx) doivent néanmoins être installées :
304
20.5. Manipuler un fichier PDF
L’extraction de texte depuis des fichiers PDF peut se faire en lisant les objets du fichier dans
l’ordre. Il n’y a cependant aucune garantie que le texte soit extrait dans l’ordre naturel de la
lecture. La bibliothèque pdfminer [Link] permet d’itérer sur
les pages d’un fichier PDF, puis sur les éléments du fichier.
pip install [Link]
Pour l’exemple ci-dessous, on utilise un PDF du plan du métro parisien (depuis le site de la
RATP), également mis à disposition sur la page web du livre :
from pdfminer.high_level import extract_pages
305
20. Comment manipuler des formats de fichiers courants ?
À parcourir le fichier de manière récursive (les objets LTFigure peuvent contenir à leur
tour des objets PDF) ;
Á extraire tous les éléments de texte dans texte_extrait ;
 extraire la liste des polices utilisées (la police de caractères est attachée à un caractère :
on construit alors un ensemble des polices utilisées)
Les éléments de texte sont souvent découpés d’une manière qui ne préserve pas la séman-
tique de l’ensemble : noms de station ou phrases coupés, ou encore ligatures typographiques
3. [Link]
306
20.5. Manipuler un fichier PDF
(p. ex. le « fi » dans tarification). On peut également analyser les couleurs utilisées à pour
dessiner les lignes de métro, encodées ici au format CMYK (pour Cyan, Magenta, Yellow/jaune,
blacK/noir).
>>> curve_colors # (sortie abrégée et commentée)
{(0, 0, 0, 0),
..., (0, 0.19, 1, 0), # Bouton d'or (ligne 1)
..., (0, 0.53, 0.78, 0), # Orange (ligne 5)
..., (0.26, 0.85, 0, 0), # Parme (ligne 4)
..., (0.44, 0, 0.12, 0), # Pervenche (lignes 3bis et 13)
(0.46, 0.33, 1, 0), # Olive (ligne 3)
..., (1, 0.54, 0, 0), # Azur (ligne 2)
(1, 0.84, 0, 0)} # Bleu RATP
V Bonnes pratiques
Un cas d’usage courant est l’extraction de tables depuis un fichier PDF. Il n’y a pas
de méthode directe pour cela mais la bibliothèque Camelot ᵃ, construite au-dessus de
pdfminer, réussit le tour de force d’extraire des tables de la plupart des fichiers PDF et
de les proposer au format Pandas (☞ p. 119, § 9). Il peut rester un peu de travail pour
nettoyer le contenu des tableaux Pandas résultants.
conda install -c conda-forge camelot-py
a. [Link]
En quelques mots…
De nombreuses bibliothèques sont développées par la communauté pour lire, écrire et
analyser un grand nombre de formats de fichiers. Les fonctionnalités proposées par une
bibliothèque ou une autre dépendent du profil d’utilisateur (p. ex. académique, data scien-
tist, journaliste). Ce chapitre a tenté de proposer un aperçu du paysage des bibliothèques
qui permettent de lire ou écrire la plupart des fichiers courants.
On trouve sur le web des awesome lists qui recensent un grand nombre de biblio-
thèques utiles pour ce genre de tâches courantes, notamment :
[Link]
307
21
Comment interroger et construire
des services web ?
L
a plupart des services web suivent le modèle REST (Representational State Transfer) : ils
proposent d’exécuter des requêtes via des méthodes HTML (GET, POST, DELETE, etc.) pour
lire, créer, mettre à jour ou supprimer des données. Les requêtes sont effectuées sur l’URI
d’une ressource et produisent une réponse dont le corps est formaté dans un format standard,
le plus souvent HTML, XML ou JSON.
Nous verrons dans ce chapitre comment accéder à ces services en Python avec la biblio-
thèque httpx, comment construire ce genre de services avec la bibliothèque fastapi, puis com-
ment accéder à des bases de données relationnelles avec DuckDB et SQLAlchemy ou suivant
un modèle NoSQL, comme MongoDB.
import httpx
# Un service web qui renvoie les morceaux récemment passés sur la radio FIP
response = [Link]("[Link]
Chaque requête renvoie un code de retour : le code 200 signifie que tout est correct.
D’autres codes sont associés à différentes situations : la ressource n’existe pas (404), la res-
309
21. Comment interroger et construire des services web ?
source existe mais a été déplacée (301), trop de requêtes viennent du même client (429). L’objet
renvoyé contient également des en-têtes à propos des données renvoyées. Si l’en-tête confirme
que les données sont au format JSON, une méthode permet la conversion automatique en dic-
tionnaire Python.
>>> response.status_code
200
>>> [Link]['content-type']
'application/json; charset=utf-8'
>>> [Link][:70] # abrégé pour le livre
b'{"steps":{"062725df-fd81-42a7-8feb-b6d6efefe8b3_7":{"uuid":"bd35e192-2'
>>> [Link]()
{'steps': {'062725df-fd81-42a7-8feb-b6d6efefe8b3_7':
{'uuid': 'bd35e192-20ec-443a-bc54-bd8607757b89',
'stepId': '062725df-fd81-42a7-8feb-b6d6efefe8b3_7',
'title': 'Beautiful stranger',
...
'authors': 'Madonna',
...
9 Attention !
Bien qu’il soit possible de prévoir des comportements particuliers pour chaque code
d’erreur, il conviendra néanmoins d’utiliser la méthode .raise_for_status() dans le
cas général afin de lever une exception explicite en cas d’erreur lors de la requête.
>>> response.raise_for_status()
Traceback (most recent call last):
...
[Link]: Client error '404 Not Found'
Les contenus binaires peuvent être lus, puis manipulés ou écrits dans un fichier :
from io import BytesIO
from pathlib import Path
import cv2
import [Link] as plt
from ipywidgets import Image
310
21.1. Émettre des requêtes et accéder à des ressources web
Cette méthode pour effectuer des requêtes est simple et rapide, mais elle n’est pas opti-
male en termes de performance lorsqu’il faut exécuter plusieurs requêtes. Il est préférable de
créer un Client avant d’envoyer les requêtes. Sans cela, un nouveau client est créé à chaque
requête, ce qui peut entraîner une baisse de performance. Utiliser un client unique permet de
réutiliser les connexions sous-jacentes, améliorant ainsi l’efficacité et la rapidité des appels.
L’option follow_redirects=True permet de suivre les redirections proposées par les serveurs
si un contenu a été déplacé par exemple. Cette option est souvent activée par défaut dans les
bibliothèques web, mais elle ne l’est pas sous httpx.
client = [Link](follow_redirects=True)
response = [Link](url)
311
21. Comment interroger et construire des services web ?
app = FastAPI()
@[Link]("/time")
async def now():
now = [Link]("now", unit="s", tz="utc")
return {"time": f"{now}"}
On peut alors appeler le service par un appel de la librairie httpx ou avec un outil classique
en ligne de commande comme curl :
312
21.2. Construire un service web
>>> [Link]("[Link]
{'time': '2020-12-31 [Link].472322+00:00'}
$ curl -X GET [Link]
{'time': '2020-12-31 [Link].472322+00:00'}
Il est aussi possible de passer des arguments en les ajoutant à la fonction :
from fastapi import FastAPI
app = FastAPI()
@[Link]("/time")
async def now(tz: None | str = None):
now = [Link]("now", unit="s", tz="utc")
if tz:
now = now.tz_convert(tz)
return {"time": f"{now}"}
$ curl -X GET [Link]
{'time': '2020-12-31 [Link].472322+02:00'}
C’est alors ici le type dans la signature qui détermine le type de l’argument récupéré. Celui
sert également à créer la documentation qui va être disponible sur le lien [Link]
7812/docs.
Si un argument invalide est passé, par exemple avec une requête du type [Link]
7812/time?tz=12, une exception va être levée dans le code Python, du côté du serveur, à la
ligne du tz_convert, avec une [Link] : cette exception va
renvoyer un code d’erreur 500 (Erreur Interne), qu’on peut rattraper du côté du client avec
r.raise_for_status(). En fonction de l’exception levée, on peut vouloir donner des détails à
l’utilisateur : pour le moment, il ne verra que Internal Server Error et n’aura aucune idée de
la raison de l’erreur.
Il est possible de définir une manière de renvoyer des détails sur ces exceptions à l’utili-
sateur à l’aide d’un gestionnaire d’exception qui fonctionne également à base de décorateur.
On décide ici le type de base Exception, mais il serait sans doute préférable de raffiner cette
gestion d’erreur en fonction du type d’exception rencontrée.
@app.exception_handler(Exception)
async def basic_exception(request: Request, exc: Exception):
return JSONResponse(
status_code=500, content={"error": f"{exc.__class__.__name__}: {exc}"}
)
313
21. Comment interroger et construire des services web ?
À dans le cas d’un appel GET, on affiche les résultats de la radio par défaut ("FIP") ; dans
le cas d’un appel POST qui embarque l’option sélectionnée dans le menu déroulant, on
affiche les résultats pour la radio demandée ;
Á on envoie une requête sur l’API ouverte correspondante ;
 le fichier [Link] indique la structure générale donnée à la page. La classe Templa-
teResponse se charge de la compléter avec les arguments passés en paramètres : le nom
de la radio courante, la liste des morceaux, et la liste des radios supportées.
api_points = {
"FIP": "[Link]
"FIP Rock": "[Link]
"FIP Jazz": "[Link]
"FIP Groove": "[Link]
"FIP Monde": "[Link]
# etc.
}
app = FastAPI()
[Link]("/static", StaticFiles(directory="static"), name="static")
templates = Jinja2Templates(directory="templates")
@app.exception_handler(Exception)
async def basic_exception(request: Request, exc: Exception):
return JSONResponse(
status_code=500, content={"error": f"{exc.__class__.__name__}: {exc}"}
)
@[Link]("/", response_class=HTMLResponse)
async def get_index(request: Request):
await list_radio(request, "FIP") # À
@[Link]("/", response_class=HTMLResponse)
async def post_index(request: Request, radio: str = Form("FIP")):
await list_radio(request, radio) # À
314
21.2. Construire un service web
return [Link]( # Â
"[Link]",
{
"request": request,
"radio": radio,
"results": results,
"api_points": api_points.keys(),
},
)
La partie d’affichage est gérée par un modèle HTML, où des marqueurs (placeholders) entre
accolades indiquent du code à exécuter. La partie qui nous intéresse se déroule comme suit :
à on suit le modèle général de page HTML (commun à tout le site), situé dans le fichier
[Link] (défini par ailleurs) ;
Ä on remplace {{radio}} par le contenu de la variable passée à render_template en  ;
Å la spécification du menu déroulant est décrite dans le fichier [Link] ;
Æ les instructions encadrées par des {% %} permettent d’écrire des instructions particu-
lières, notamment des boucles, ici pour afficher une image pour les trois dernières en-
trées, ou pour écrire une ligne de tableau pour toutes les entrées dans la liste result ;
315
21. Comment interroger et construire des services web ?
FIGURE 21.1 – Aperçu du site produit par notre application Flask minimale
Ç il est possible d’utiliser des fonctions Python personnalisées si elles sont enregistrées
comme telles È. Ici la fonction readtime convertit un timestamp Unix en une heure (ici
celle du fuseau horaire du système sur lequel tourne le serveur).
[Link]["readtime"] = readtime # È
316
21.3. Accéder à une base de données
21.3.1. DuckDB
DuckDB est un système de gestion de bases de données relationnelles en colonnes, conçu
pour l’analyse de données. Il a été pensé pour être utilisé sans avoir besoin de configurer un
serveur de base de données séparé, sur un simple ordinateur personnel. Il permet notamment
d’exécuter des requêtes SQL sur des données en mémoire RAM, sur disque (au format CSV,
JSON, Arrow/Parquet par exemple), ou aussi sur des serveurs distants. DuckDB est souvent
décrit comme un « SQLite pour les workloads analytiques » en raison de sa légèreté et de sa
facilité d’intégration, tout en offrant des capacités avancées d’analyse de données.
Il existe des API pour DuckDB dans de nombreux langages, y compris le langage Python.
Il est possible d’exécuter n’importe quel code DuckDB, mais surtout, il est possible d’interagir
avec des structures Python, Pandas ou Polars.
Les ressources en ligne contiennent un fichier [Link] qui propose une réécriture d’une
partie des examples Pandas (☞ p. 119, § 9) avec DuckDB.
317
21. Comment interroger et construire des services web ?
Base = declarative_base()
class VilleModel(Base):
__tablename__ = "villes"
nom = Column(String)
code_postal = Column(String, primary_key=True)
population = Column(Integer)
latitude = Column(Double)
longitude = Column(Double)
altitude_min = Column(Double)
altitude_max = Column(Double)
Nous créons ensuite un moteur connecté à notre base de données DuckDB en mémoire À
et chargeons un DataFrame Pandas dans la base avec la commande register Á.
from sqlalchemy import Column, Integer, Double, String, create_engine, select, text
engine = create_engine("duckdb:///:memory:") # À
conn = [Link]()
[Link](text("register(:name, :df)"), {"name": "villes", "df": villes}) # Á
Grâce à SQLAlchemy, il est possible de construire des requêtes SQL de manière program-
matique et de bénéficier d’une vérification statique (avec des outils d’analyse comme MyPy
☞ p. 367, § 27) et dynamique (à l’exécution, au moyen d’exceptions) du code. Cela évite d’écrire
des requêtes sous forme de texte, un mode de fonctionnement qui peut poser des risques de
sécurité, notamment à cause des injections SQL.
[Link](
select(VilleModel)
.with_only_columns([Link], VilleModel.altitude_min, [Link])
.where(VilleModel.altitude_min > 1000)
.where([Link] > 2000)
).fetchall()
318
21.3. Accéder à une base de données
21.3.3. MongoDB
Les bases de données comme MongoDB fonctionnent de manière différente, sans faire
appel au langage SQL. Il est possible d’envoyer des requêtes sous la forme de dictionnaires
Python qui décrivent le format des données attendues. La bibliothèque pymongo offre toutes
les interfaces nécessaires à la manipulation de données MongoDB.
L’exemple ci-dessous montre comment accéder à toutes les entrées de la table test_table
de la base test_db, qui ont une profession égale à "agent secret" et un nom qui commence
par bo (avec ou sans majuscule).
import pymongo
df = [Link].from_records(
connector.test_db.test_table.find(
{'profession': 'agent secret'},
{'nom': {'$regex': '^[Bb]o'}} # expression régulière
)
)
En quelques mots…
Les applications web (frontend et backend) constituent un des principaux domaines d’ap-
plication de Python, qui mériteraient un ouvrage à part entière. Le cas exemple présenté
ici n’est qu’un petit aperçu des possibilités les plus basiques de la bibliothèque FastAPI.
La bibliothèque httpx présentée dans ce chapitre pour l’accès aux données sur le web
devient d’autant plus puissante quand elle est couplée aux bibliothèques présentées dans
les chapitres précédents, notamment Pandas (☞ p. 119, § 9), OpenCV (☞ p. 299, § 20.1),
lxml ou BeautifulSoup (☞ p. 302, § 20.3).
319
22
Comment écrire un outil graphique
ou en ligne de commande ?
L’
interface en ligne de commande (Command Line Interface, CLI) offre un accès à des
programmes par des commandes textuelles, par opposition aux interfaces graphiques
(Graphical User Interface, GUI) orientées vers les interactions basées sur les mouve-
ments de la souris.
Les outils CLI conviennent en général aux utilisateurs à l’aise sur l’outil informatique :
ils présentent des interfaces légères, directes et puissantes. Ces interfaces font le lien entre le
monde des bibliothèques Python et celui du système d’exploitation, des outils de planification
(comme crontab), et des outils shells classiques basés sur le principe « une fonctionnalité, un
outil » et sur le chaînage d’outils (comme grep, sort, uniq ou wc).
— Si vous écrivez une bibliothèque Python et que certaines fonctionnalités pourraient être
appelées par un utilisateur qui ne connaît pas le langage, il faudra sans doute considérer
l’idée de proposer un outil CLI qui donne accès à ces fonctionnalités.
— À l’inverse, si l’objectif de votre projet est de fournir un outil en ligne de commande,
il est préférable de penser son architecture pour un utilisateur Python dans un pre-
mier temps, avec des cas d’utilisation en Python. Une fois l’interface stabilisée, on peut
proposer des points d’entrée par un outil CLI.
Ce chapitre présente dans un premier temps la bibliothèque Rich qui propose d’afficher
du texte enrichi dans le terminal, puis la bibliothèque Click pour la gestion des arguments et
paramètres passés à un outil CLI. Ensuite, on propose d’écrire un outil CLI interactif simple,
en plein écran, avec la bibliothèque textual, elle-même basée sur Rich et adaptée à la program-
mation asynchrone. Enfin la même application est portée dans une interface graphique simple
dans l’environnement graphique Qt.
321
22. Comment écrire un outil graphique ou en ligne de commande ?
progression, et même des graphiques ou du texte formaté en Markdown. Elle sert principale-
ment à rendre les sorties textuelles dans les terminaux plus faciles à lire et visuellement plus
attrayantes.
Bien que Rich ne soit pas toujours directement utilisée par les développeurs, elle est sou-
vent employée comme une dépendance par d’autres bibliothèques telles que tqdm (qui gère les
barres de progression ☞ p. 311, § 21.1), structlog (pour la gestion de logs, ☞ p. 359, § 26.1)
ou textual (pour créer des interfaces utilisateur en ligne de commande, ☞ p. 325, § 22.3)
Rich permet de formater le texte à l’aide de balises :
from rich import print
print("[bold red]Texte en gras et rouge[/bold red]")
Il propose également de faciliter l’affichage sous forme de tableau :
from [Link] import Table
from [Link] import Console
console = Console()
[Link](table)
Plus généralement, Rich permet de rendre l’affichage dans le terminal plus attrayant éga-
lement pour les retours de variables Python. On peut initialiser cet affichage avec les lignes
suivantes (qu’on peut également placer dans le fichier $HOME/.pythonstartup pour un charge-
ment automatique) :
322
22.2. La gestion des arguments avec Click
La philosophie derrière Click est de définir un point d’entrée sous forme de fonction avec
autant d’arguments que nécessaire, tous spécifiés par des décorateurs (☞ p. 181, § 13). Un
exemple est proposé sur la page web du livre [Link] avec un outil
qui affiche les mêmes informations que la page web (☞ p. 312, § 21.2) construite à partir du
service web de la radio FIP.
La fonction va afficher dans le terminal un ou plusieurs titres de morceaux en fonction des
options suivantes (à enrichir à l’envi) :
— le nom de la radio ;
— l’affichage du morceau à venir dans la playlist ;
— l’affichage du morceau précédent dans la playlist ;
— l’affichage de l’ensemble des morceaux communiqués.
import click
@[Link]()
def main(radio: str, next_: bool, previous: bool, all_: bool):
response = [Link](api_points[radio])
response.raise_for_status()
# gestion de l'affichage
if __name__ == "__main__":
main()
L’appel à la fonction main() dans le point d’entrée peut se faire alors sans argument grâce
au décorateur @[Link]().
323
22. Comment écrire un outil graphique ou en ligne de commande ?
Au lancement du programme avec l’option --help, un message d’aide est construit à partir
des arguments passés dans les décorateurs. Sinon, les autres arguments sont interprétés et
passés à la fonction Python :
$ python fip_click.py --help
Usage: fip_click.py [OPTIONS] [RADIO]
Options:
-a, --all Afficher tous les morceaux
--next
--previous
--help Show this message and exit.
$ python fip_click.py
[*] 14:39 -> 14:43 Twins par Tord Gustavsen (The ground)
324
22.3. Créer des applications pour le terminal avec Textual
V Bonnes pratiques
D’autres options permettent de définir des incompatibilités entre arguments, des para-
mètres par défaut, des types pour convertir l’argument passé en chaîne de caractères
sous forme d’objet Python. La documentation en ligne présente de nombreux cas avan-
cés, notamment la vérification des arguments passés.
class FipTextual(App):
BINDINGS = [
("q,escape", "quit", "Quitter"),
]
325
22. Comment écrire un outil graphique ou en ligne de commande ?
if __name__ == "__main__":
main()
L’attribut BINDINGS permet de définir les actions associées à des touches du clavier. Dans cet
exemple, on lie les touches q et Échap à l’action quit, qui correspond à la méthode asynchrone
action_quit() (définie par défaut). De plus, le texte « Quitter » s’affiche dans la barre des
tâches pour rappeler ces raccourcis à l’utilisateur.
Pour récupérer les morceaux diffusés, on souhaite appeler une API de manière asynchrone
en utilisant la bibliothèque httpx (☞ p. 309, § 21.1). Chaque morceau sera ensuite représenté
par un bouton interactif dans l’interface.
Ainsi, dans la méthode de construction de la fenêtre compose(), on ajoute un élément défi-
lant VerticalScroll. C’est dans cet espace que l’on insérera dynamiquement un bouton pour
chaque chanson récupérée depuis l’API.
# class FipTextual(App): # (suite)
def compose(self) -> ComposeResult:
yield Header()
yield Footer()
yield VerticalScroll(id="entries")
On rajouter également une action retrieve (dans les BINDINGS, liée à la touche R pour « Ra-
fraîchir », avant de définir une méthode action_retrieve(). Puisque la méthode contiendra
un appel asynchrone, on définit cette méthode async. L’idée ici est de compléter la liste au
fur et à mesure que des nouveaux morceaux apparaissent : on souhaite alors n’ajouter que les
nouveaux éléments : on vérifie alors (de manière assez naïve) que le titre n’est pas déjà présent
dans la liste avant de l’ajouter.
On ajoute également une action retrieve dans les BINDINGS, associée à la touche R (pour
Rafraîchir). Cette action est liée à une méthode asynchrone action_retrieve() ; elle devra
effectuer un appel réseau asynchrone pour récupérer les données à jour. L’objectif est alors
de compléter dynamiquement la liste des morceaux au fur et à mesure que de nouveaux titres
apparaissent. Pour éviter les doublons, on implémente un mécanisme naïf qui vérifie si le titre
d’un morceau est déjà présent dans la liste avant de l’ajouter.
# class FipTextual(App): # (suite)
BINDINGS = [
("q,escape", "quit", "Quitter"),
("r", "retrieve", "Rafraîchir"),
]
CSS_PATH = "[Link]"
326
22.3. Créer des applications pour le terminal avec Textual
327
22. Comment écrire un outil graphique ou en ligne de commande ?
Textual utilise des feuilles de style au format .css ou .tcss (Textual CSS), un format très
proche du CSS standard utilisé pour le web. Le fichier complet est disponible sur le site du pro-
jet, mais voici quelques éléments-clés. Par exemple, on peut définir un style différent lorsque
la souris passe sur un bouton (Entry:hover) ou lorsque le bouton a le focus (Entry:focus). On
peut également choisir des couleurs de texte différentes en fonction du type d’information et
une couleur de fond spéciale si un lien YouTube est présent.
Entry {
/* sur la page du livre */
}
Entry:hover {
border: solid #4c78ae;
}
Entry:focus {
border: tall #f58518;
text-style: bold;
}
#titre {
dock: left;
color: #f58518;
max-width: 50%
}
#album {
color: steelblue;
}
#annee, #label {
dock: right;
text-align: right;
max-width: 25;
}
.youtube {
background: red 30% !important;
}
Le rendu de l’application (Figure xx) peut varier selon l’émulateur de terminal utilisé. Par
exemple, l’application par défaut sur MacOS est connue pour ses capacités limitées de rendu
des couleurs. Il est donc recommandé d’utiliser un autre émulateur, comme iTerm2 ou Ghostty,
pour un rendu optimal.
328
22.4. Les environnements graphiques avec Qt
class MainScreen([Link]):
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
def main():
app = [Link]([Link])
main = MainScreen()
[Link]()
return app.exec_()
La deuxième étape consiste à positionner des widgets dans la fenêtre principale. On dis-
tingue les widgets, des éléments graphiques auquel on attache un contenu et un comportement,
des structures layout qui décrivent comment positionner les éléments. Les interfaces peuvent
être décrites à la main en Python, mais des logiciels comme Qt Designer peuvent aider à gé-
nérer le code qui correspond au design voulu.
329
22. Comment écrire un outil graphique ou en ligne de commande ?
FIGURE 22.1 – Aperçu d’une application Qt pour interagir avec la playlist FIP
Pour parvenir au design de la figure 22.1, on peut démarrer de la manière suivante, par
énumération des éléments à afficher :
class MainScreen([Link]):
def set_widgets(self):
# Découpage en blocs de gauche à droite
mainLayout = [Link]()
self.menu_radios = [Link]()
[Link](self.menu_radios)
for radio_name in api_points.keys(): # la liste des radios disponibles
self.menu_radios.addItem(radio_name)
[Link] = [Link]("Rafraîchir")
[Link]([Link])
[Link] = [Link]()
[Link] = [Link]()
[Link]([Link])
# Partie à droite
self.music_widget = [Link]()
self.music_view = [Link](self.music_widget) # À
[Link](self.music_widget)
330
22.4. Les environnements graphiques avec Qt
Pour ce qui concerne le contenu dynamique, qui peut être modifié au cours de l’exécution,
il conviendra d’ajouter des méthodes à notre classe, en vue de les initialiser au lancement du
programme, et de les programmer à nouveau pour s’exécuter quand on interagit avec un ou
plusieurs widgets. Ici, nous construisons deux méthodes : une qui accède au JSON produit par
l’API de Radio France, et une qui télécharge l’image de la couverture de l’album en vue de
l’afficher dans l’application.
def get_content(self, *args, **kwargs):
# Lecture du contenu du menu déroulant pour le nom de la radio
url = self.menu_radios.currentText()
response = [Link](api_points[url])
response.raise_for_status()
# Construction du [Link]
[Link] = [Link].from_records(
list([Link]()["steps"].values())
)
self.music_model = PandasTableModel([Link]) # Á
self.music_view.setModel(self.music_model)
self.get_image()
La partie qui concerne l’affichage sous forme de tableau reflète le motif d’architecture
modèle, vue, contrôleur :
À la vue QTableView est associée à un widget (self.music_widget) : elle exprime la pré-
sentation des données dans l’interface graphique ;
Á le modèle PandasTableModel s’exprime autour d’un [Link] (☞ p. 119, § 9) : il
contient les données à afficher, dans un formalisme compatible avec la vue ;
— le contrôleur (le widget !) met à jour le modèle et déclenche une mise à jour de la vue.
La partie contrôleur va s’exprimer sous forme de fonctions callbacks :
def set_callbacks(self):
# un clic sur le bouton Rafraîchir met à jour le contenu de la playlist
[Link](self.get_content)
# la sélection d'une nouvelle radio met aussi à jour le contenu
self.menu_radios.[Link](self.get_content)
331
22. Comment écrire un outil graphique ou en ligne de commande ?
En quelques mots…
La création d’une application graphique ou en ligne de commande est la dernière étape
du processus de création logicielle. Une application de qualité est souvent une simple
interface entre une bibliothèque, qui propose des fonctionnalités, et le monde extérieur
au langage de programmation.
Pour une application réussie, le plus grand soin doit être apporté en amont, au niveau
de l’interface de la bibliothèque, la partie du code qui répond à un besoin fort. Une fois
cette interface pensée, une fois les réponses apportées à la question « comment appeler
ces fonctionnalités depuis le langage de programmation ? » ᵃ, il est alors possible de :
— procéder à l’écriture des fonctionnalités de la bibliothèque (la partie souvent ap-
pelée backend en anglais), pour répondre aux besoins de l’interface. La priorité
devient alors l’efficacité, la robustesse et la performance ;
— concevoir des interfaces utilisateurs plus haut niveau, la partie visible depuis le
monde extérieur au langage (le frontend en anglais). La priorité est alors la clarté
de l’interface et de la documentation.
Les trois types d’interfaces présentés dans ce chapitre ne sont pas toujours tous les
trois nécessaires, mais la question doit néanmoins se poser :
— les outils en ligne de commande présentent généralement une interface efficace,
simple, à penser en termes d’interface avec les autres outils en ligne de commande
(les mots-clés stdin, stdout, stderr, pipe, etc.) ;
— les outils en environnement terminal plein écran présentent généralement l’avan-
tage d’être légers au lancement. L’interface curses a beau être très bas niveau, elle
permet de parvenir rapidement à des affichages fort convenables ;
— les outils graphiques sont les plus complets, mais aussi les plus coûteux à dévelop-
per. Distribuer une application graphique là où il n’est pas possible de faire d’hy-
pothèses sur la présence d’un environnement Python installé (Windows) est une
tâche ardue. L’environnement PyInstaller [Link] tente de
répondre à ce besoin.
332
23
Comment exécuter du code Python
dans un navigateur web ?
PyScript est une technologie qui permet d’exécuter du code Python directement dans le
navigateur web via WebAssembly WASM. WebAssembly se décline au format binaire WASM et
au format texte WAT ; il propose un jeu d’instructions compris par les navigateurs web, per-
mettant d’exécuter du code de manière efficace et proche des performances natives. Il permet
aux langages tels que C, C++, Rust, et maintenant Python d’être exécutés dans le navigateur,
complétant ainsi les capacités de JavaScript.
PyScript repose sur Pyodide, une distribution de Python compilée pour la plateforme
WASM, permettant ainsi d’exécuter du code Python directement dans le navigateur. L’un des
principaux avantages est que, comme pour JavaScript, le code Python est exécuté côté client,
sans avoir besoin d’un serveur pour traiter les requêtes Python. Pyodide inclut une large sé-
lection de bibliothèques Python populaires comme NumPy, Pandas, et Matplotlib, dont les dé-
pendances C ont été spécialement recompilées pour WASM, permettant de tirer parti de toute
la puissance de l’écosystème Python directement dans un environnement web.
Ces possibilités sont particulièrement pertinentes pour publier une documentation de bi-
bliothèques Python, permettant aux utilisateurs de la tester directement dans le navigateur
sans avoir à installer quoi que ce soit. Ainsi, le site web de la bibliothèque NumPy, acces-
sible à l’adresse [Link] dans sa version actuelle, propose un terminal sur sa page
d’accueil qui permet d’exécuter des instructions NumPy simples.
333
23. Comment exécuter du code Python dans un navigateur web ?
Pour observer les résultats, on peut lancer un mini-serveur HTTP avec Python :
python3 -m [Link]
<!DOCTYPE html>
<head>
<title>Un exemple PyScript</title>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
</head>
<body>
</body>
Le code Python est alors inscrit dans des balises <script type="py">.
<div id="output"></div>
<script type="py">
from pyscript import display
display("Hello world!", target="output")
</script>
334
23.2. L’affichage d’un message simple
<script type="py">
import js
[Link]("Hello world!")
</script>
— le type <py-editor> propose une cellule à exécuter, avec un affichage HTML pour la
sortie, à la manière des environnements Jupyter.
<script type="py-editor">
print(0.1 + 0.2)
</script>
Les importations des bibliothèques standards de Python se font de manière classique avec
PyScript. Cependant, certains comportements diffèrent par rapport à une exécution dans un
terminal Python classique. En effet, l’environnement d’exécution est le navigateur web, ce
qui implique certaines particularités : l’accès direct au système de fichiers est interdit, et le
navigateur fournit des fonctionnalités natives pour télécharger des ressources en ligne.
Ainsi, lorsque l’on liste le contenu du répertoire courant avec PyScript, on n’affiche pas le
contenu du répertoire du projet, mais un répertoire virtuel contenant uniquement les fichiers
nécessaires à PyScript, y compris la bibliothèque pyscript. Ce fonctionnement particulier per-
met d’importer pyscript sans avoir besoin de l’installer localement.
<script type="py">
from pathlib import Path
print(list(Path(".").glob("**"))[:5]) # les 5 premiers éléments uniquement
</script>
[PosixPath('pyscript'), PosixPath('pyscript/__init__.py'),
PosixPath('pyscript/[Link]'), PosixPath('pyscript/event_handling.py'),
PosixPath('pyscript/[Link]')]
335
23. Comment exécuter du code Python dans un navigateur web ?
De la même manière, l’accès à une ressource web pourra se faire avec la fonction open_url
du paquet [Link]. C’est le navigateur qui va alors télécharger la ressource et la rendre
disponible pour le reste du programme.
Les autres bibliothèques Python doivent être déclarées dans le fichier [Link] avant
de pouvoir être importées dans un script PyScript. Pour qu’une bibliothèque soit accessible
dans l’interpréteur Pyodide (la version WebAssembly de Python utilisée par PyScript), elle
doit répondre à l’une des conditions suivantes :
— être une bibliothèque Python pure, c’est-à-dire sans dépendances compilées depuis un
autre langage comme C, C++ ou Rust ;
— avoir été spécifiquement compilée en WebAssembly (WASM) pour être compatible avec
Pyodide.
En outre, toutes les dépendances de la bibliothèque doivent également être disponibles
sous Pyodide.
Actuellement, la plupart des bibliothèques Python les plus populaires, telles que NumPy
et Pandas, sont disponibles pour Pyodide. La liste complète des bibliothèques compatibles
est mise à jour et accessible sur la page suivante : [Link]
[Link]
Dans l’exemple suivant, nous utilisons la fonction open_url pour télécharger un fichier
contenant les données des aéroports du monde entier. Nous employons ensuite Pandas pour
pré-traiter ces données en filtrant les aéroports situés en France (ceux dont l’identifiant com-
mence par LF) et en ajoutant des colonnes pour les coordonnées latitude et longitude. Enfin,
nous utilisons Altair pour visualiser ces coordonnées.
Dans le fichier HTML, nous définissons les balises où les résultats seront affichés, nous
référençons le fichier [Link] pour spécifier les bibliothèques nécessaires, et le code
Python est déplacé dans un fichier séparé nommé [Link].
<div id="altair-output"></div>
<div id="airport-codes"></div>
import pandas as pd
import altair as alt
336
23.3. L’interactivité dans le navigateur
url = "[Link]
df = (
pd.read_csv(open_url(url))
.eval("""
latitude = [Link](",").str[0].astype("float")
longitude = [Link](",").str[1].astype("float")
""")
.query("[Link]('LF') and longitude > -5 and longitude < 15")
)
chart = (
[Link](df)
.mark_point()
.encode(latitude="latitude", longitude="longitude")
.project("conicConformal", rotate=[-3, -46.5], parallels=[49, 44])
)
337
23. Comment exécuter du code Python dans un navigateur web ?
def demarrer(event):
[Link](event) # observer l'événement dans la console développeur
— soit à l’aide d’un décorateur dans le code Python (l’attribut py-click est alors superflu) :
@when("click", "#bouton_demarrer")
def demarrer(event):
[Link](event)
Illustrons maintenant un projet un peu plus complexe. Dans le chapitre 12, nous avons
expliqué comment développer des L-systèmes. Ici, nous allons créer une page web avec un
menu déroulant permettant de sélectionner un L-système, qui sera ensuite tracé à l’aide d’une
tortue graphique. Le code responsable du mouvement de la tortue, qui génère le dessin dans
un élément SVG, est déjà fourni et sera exécuté par PyScript.
Pour que les imports fonctionnent, on déclare les fichiers dans le fichier [Link] :
[[fetch]]
from = "/"
files = ["[Link]", "[Link]"]
Le squelette de la page HTML contient le menu déroulant ainsi qu’un champ où définir
l’ordre de la fractale.
338
23.3. L’interactivité dans le navigateur
Le code Python des L-systèmes du chapitre 12 est modifié dans le champ draw. Les instruc-
tions de dessin sont légèrement différentes puisque la tortue graphique présentée ici propose
déjà des fonctions forward, left et right :
import tortle
from js import document
sierpinsky = LSystem(
axiom="F+G+G",
rules=dict(F="F+G-F-G+F", G="GG"),
order=5,
draw={
"F": lambda tortue: [Link](10),
"G": lambda tortue: [Link](10),
"+": lambda tortue: [Link](120), # en degrés
"-": lambda tortue: [Link](120),
},
)
# Chaque identifiant du menu déroulant "select" est relié à une instance de L-système
FRACTALES = {
"koch": courbe_de_koch,
"sierpinsky": sierpinsky,
"hilbert": hilbert,
"crystal": crystal,
"snow_flake": snow_flake,
}
def tracer(event):
# on récupère la valeur de l'ordre dans le champ correspondant
ordre = int([Link]("#ordre").value)
[Link]().restart() # réinitialisation
ma_tortue = [Link]() # création de la tortue
ma_tortue.speed(10) # réglage de sa vitesse
lsystem(FRACTALES[[Link]], ma_tortue, ordre) # tracé graphique
[Link](target=[Link]("#dessin")) # rendu du SVG dans le DOM
339
23. Comment exécuter du code Python dans un navigateur web ?
FIGURE 23.1 – Capture d’écran du rendu de la fractale de Sierpinsky. Le rendu graphique dynamique est disponible
sur la page web du livre.
En quelques mots…
Le projet PyScript est encore très jeune à l’heure où ces pages sont écrites. Ce chapitre
propose un simple aperçu de ces possibilités qui vont aller grandissant au fur et à mesure
que de nouvelles bibliothèques seront compilées pour Pyodide.
La documentation en ligne est mise à jour très régulièrement et de nombreux autres
exemples sont régulièrement rendus disponibles en ligne.
340
V
Développer un
projet en Python
24
Manipuler des environnements
Python
U
n des défis principaux dans le développement logiciel, quel que soit le langage utilisé,
est celui de la gestion des erreurs et des évolutions. Quand un utilisateur rapporte
qu’une fonctionnalité est défaillante, il est important de pouvoir reproduire cette er-
reur. Il est important de pouvoir se placer dans le même environnement pour reproduire une
exécution.
Plus largement, le développeur va chercher à définir des contraintes sur les versions des
dépendances sur lesquelles il repose pour son projet. Cela inclut le type de système d’exploi-
tation utilisé, les versions des dépendances systèmes (avec notamment la libc sous Linux), la
version Python utilisée (p. ex. 3.12.2), les versions des bibliothèques utilisées.
Ce chapitre présente les différents utilitaires offerts par l’environnement Python pour gé-
rer des environnements de travail Python faciles à reproduire sur différentes plateformes.
343
24. Manipuler des environnements Python
9 Attention !
Chaque module n’est importé qu’une seule fois par session. Si le contenu du module a
changé et qu’on souhaite utiliser la nouvelle version, il faut redémarrer l’interpréteur.
9 Attention !
L’outil pip n’installe que des bibliothèques Python, qui peuvent être écrites en Python
(et exécutables sur n’importe quelle architecture) ou compilées (pour une architecture
particulière). Les bibliothèques externes à l’écosystème Python (par exemple des biblio-
thèques scientifiques C ou Fortran) doivent être installées séparément.
344
24.4. L’outil uv
# puis
$ pip install numpy
$ python
Python 3.12.5 (main, Aug 6 2024, [Link]) [Clang 15.0.0 (clang-1500.3.9.4)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> import numpy as np
>>> np.__file__ # on peut vérifier où la bibliothèque a été installée
'[...]/mon_environnement/lib/python3.12/site-packages/numpy/__init__.py'
24.4. L’outil uv
uv est un outil qui se définit comme une suite logicielle, alternative ultra-performante
(codée non pas en Python mais en Rust) à une série d’outils Python utilisés couramment pour
la gestion des environnements et des projets Python. uv se définit comme un outil capable de
remplir le rôle équivalent de cargo en Rust ou npm en Javascript.
C’est un outil encore jeune, en cours d’amélioration permanente. Il conviendra alors de se
référer à la documentation en ligne ([Link] pour suivre l’évolution des
fonctionnalités à l’heure de la lecture de cet ouvrage.
Les principales fonctionnalités de uv comprennent :
— la gestion des versions de Python avec uv python
$ uv python install 3.10 3.11 3.12
Si la version de Python est déjà installée sur le système, il l’utilise directement, sinon il
en télécharge une version. La liste des versions de Python déjà installée est disponible avec la
commande uv python list.
— la gestion des scripts Python avec uv run : il est possible de lancer des scripts ou
l’interpréteur Python via la commande uv run. Une version de Python par défaut est
choisie, mais il est possible d’en spécifier une autre qui sera téléchargée si besoin.
$ uv run python # utilise la version par défaut
Python 3.12.5 (main, Aug 6 2024, [Link]) [Clang 15.0.0 (clang-1500.3.9.4)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>>
$ uv run --python 3.13 python # télécharge la version 3.13 si non disponilbe
Python 3.13.0rc2 (main, Sep 9 2024, [Link]) [Clang 18.1.8 ] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>>
On peut également passer des scripts Python en argument, voire même en utilisant l’entrée
standard :
$ uv run salut_toi.py
Salut xo!
$ echo 'print("Salut '$USER'!")' | uv run -
Salut xo!
Si une dépendance est nécessaire, on peut utiliser l’option --with
$ echo 'import numpy; print(numpy.__file__)' | uv run -
$ echo 'import numpy; print(numpy.__file__)' | uv run --with numpy -
345
24. Manipuler des environnements Python
— la gestion des environnements virtuels avec uv venv et uv pip : uv venv est une
solution de remplacement de virtualenv, et uv pip une solution de remplacement de
pip. Les deux outils sont beaucoup plus rapides que leur contrepartie historique, grâce
à une série d’optimisations astucieuses.
$ uv venv # par défaut, avec l'argument .venv
Creating virtualenv at: .venv
Activate with: source .venv/bin/activate
$ source .venv/bin/activate
$ uv pip install numpy
Resolved 1 package in 58ms
Installed 1 package in 29ms
+ numpy==2.1.0
La création de l’environnement est beaucoup plus rapide qu’avec virtualenv. Une fois
l’environnement chargé, on peut installé des dépendances avec uv pip.
— la gestion des outils Python avec uv tool et uvx : un certain nombre d’outils codés
en Python nécessite d’avoir un interpréteur disponible, avec une liste de dépendances
installées. Historiquement, l’outil pipx permet de créer des environnements virtuels
dédiés à chaque outil pour installer l’outil dedans.
L’outil uv tool propose la même fonctionnalité (en plus efficace et rapide). L’outil
uvx ¹ permet de tester un outil dans un environnement éphémère, sans avoir à l’installer.
Par exemple, si on souhaite tester l’outil posting ([Link]
posting), un client HTTP en ligne de commande avec interface textuelle, on peut utiliser
les commandes suivantes.
$ uvx posting # crée un environnement éphémère à chaque utilisation
$ uv tool install posting # installer l'outil une fois pour toutes
$ posting # puis exécuter l'outil
346
24.6. Environnements de développement
Pour installer une bibliothèque ou un outil, utiliser dans un terminal ² la commande conda :
# Installation de l'environnement Jupyter Lab (Python)
$ conda install jupyterlab
# Installation de l'outil git (hors Python)
$ conda install git
Certains outils sont disponibles dans d’autres canaux, c’est-à-dire qu’ils ne sont pas pré-
parés par l’équipe Anaconda mais dans le cadre d’autres initiatives. Le canal tiers conda-forge
est à ce titre très complet [Link] :
$ conda install -c conda-forge numpy
En dernier recours, si l’outil voulu n’est pas disponible à l’installation avec conda (c’est de
moins en moins le cas), il est toujours possible d’utiliser l’outil pip.
2. Sous Windows, ne pas ouvrir l’invite de commande classique, mais plutôt choisir l’outil « Anaconda Prompt »
depuis le menu Démarrer.
347
24. Manipuler des environnements Python
Enfin, si l’objectif n’est pas de fournir un environnement de développement pour une bi-
bliothèque, mais simplement de figer des versions pour une étude donnée, p. ex., sous forme de
fichier Jupyter notebook, ou sous la forme d’un projet qui n’a pas vocation à être empaqueté,
l’outil Pixi [Link] (2023), également codé en Rust, est une option pertinente. Cet
outil est entièrement basé sur Anaconda, et se base sur un fichier de définition [Link] et
un fichier verrou [Link]. Par exemple, si on souhaite fournir un fichier au format Jupyter
notebook, on pourra, dans le même dossier, appliquer les commandes suivantes :
$ pixi init
Initialized project in /home/xo/project/
$ pixi add jupyterlab ipyleaflet # voire plus de dépendances si nécessaire
✔ Added jupyterlab
✔ Added ipyleaflet
$ pixi run jupyter lab # on lance alors le serveur Jupyter dans un environnement dédié
$ pixi run python # pour lancer le python de l'environnement pixi
Python 3.12.5 | packaged by conda-forge | (main, Aug 8 2024, [Link]) [Clang 16.0.6 ]
Type "help", "copyright", "credits" or "license" for more information.
>>>
On partagera alors, en même temps que le fichier notebook, les deux fichiers [Link]
et [Link]. L’environnement, basé sur Anaconda, est créé dans un sous-dossier .pixi/ : il
suffit de supprimer ce dossier pour effacer toute trace de cet environnement.
En quelques mots…
Il est conseillé d’utiliser un environnement virtuel chaque fois que vous codez en Python
ou utilisez un outil basé sur Python. Cela permet d’isoler les bibliothèques et dépendances
utilisées dans chaque projet, évitant ainsi les conflits entre versions de bibliothèques.
De plus, il existe de plus en plus d’outils qui simplifient la création et la gestion de ces
environnements virtuels.
Les éditeurs de code comme Visual Studio Code offrent des fonctionnalités qui faci-
litent la gestion des environnements virtuels. Ils sont capables de les détecter automati-
quement et permettent d’exécuter des scripts Python directement dans l’environnement
virtuel sélectionné, garantissant ainsi que le bon contexte de travail est utilisé.
348
25
Publier une bibliothèque Python
Q
uels que soient la motivation et le public visé par un code informatique, l’objectif est
généralement de reproduire les mêmes comportements et résultats sur des environ-
nements de travail variés, pour des utilisateurs aux habitudes différentes.
Après avoir factorisé du code puis rendu les fonctionnalités aussi génériques que possible,
il conviendra de donner suffisamment d’informations aux systèmes de packages afin que qui-
conque puisse recréer un environnement dans lequel exécuter les outils destinés à être parta-
gés, en respectant notamment les particularités des systèmes d’exploitation et les dépendances
logicielles nécessaires à la bonne marche du programme.
Ce chapitre présente alors dans l’ordre :
1. comment préparer et partager un paquet Python à installer sur des environnements de
travail différents ;
2. comment isoler des informations spécifiques (répertoires vers des données privées,
mots de passe) et les déplacer dans des fichiers de configuration ;
3. comment mettre en place des conventions pour partager le code source ;
4. comment publier un paquet sur les plateformes PyPI et conda-forge.
349
25. Publier une bibliothèque Python
Dans la plupart des projets open-source, on trouvera une arborescence qui comprend a
minima les fichiers suivants :
— le fichier [Link] est le fichier pivot défini par le PEP 517 qui contient la des-
cription des métadonnées du projet ;
— le fichier README ou [Link] ou [Link] contient une description du projet, des
exemples simples d’utilisation, des liens vers la documentation, la license d’utilisation.
Il apparaît en général sur la page GitHub ou sur la page PyPI d’un projet ;
— le ficher LICENSE ou [Link] ou [Link] contient le texte de la license d’utilisa-
tion du programme.
9 Attention !
Il est très maladroit de faire l’impasse sur la définition de la licence d’utilisation, notam-
ment si le code est publié sur un dépôt public. Par défaut, l’absence de licence interdit
toute réutilisation du code. Les outils en ligne comme GitHub accompagnent le déve-
loppeur dans le choix de la licence d’utilisation.
On trouve encore parfois des fichiers qui sont devenus quasiment obsolètes aujourd’hui
(mais qui fonctionnent toujours) :
— le fichier [Link] est le fichier historique, présent la plupart des bibliothèques les plus
anciennes. Il décrit le processus de construction d’un paquet mais peut être quasiment
considéré comme obsolète aujourd’hui ;
— le fichier [Link] fonctionne avec [Link] (souvent obsolète aussi) contient les spé-
cifications du paquet, des métadonnées, et le chemin vers des fichiers à intégrer.
Dans le doute, l’installation d’un projet qui contient un fichier [Link] ou [Link]
dans un environnement courant fonctionnera avec la commande :
$ pip install .
350
25.2. Le packaging avec l’outil uv
Pour un exécutable destiné à être lancé sans faire référence à un interpréteur Python,
on peut définir un point d’entrée. Si un point d’entrée est défini, l’outil construit des scripts
exécutables et les place dans un dossier compatible avec la variable d’environnement PATH. Si
on utilise des environnements virtuels, l’exécutable pour ce point d’entrée sera dans le dossier
où sont situés également les exécutables tels que python et jupyter.
[[Link]]
fip_textual = 'fip_textual:main'
$ uv run fip_textual
Une fois le développement terminé, on pourra alors installer le paquet :
— dans un environnement dédié :
$ pip install . # ou uv pip install .
— dans l’espace global :
$ uv tool install .
On peut également préparer une archive à partager sur une autre machine. Les archives
des paquets Python peuvent être :
— des archives sources au format .[Link] ;
— des archives construites pour lesquelles on préfère souvent le format .whl (pour wheel).
Suivant les cas, l’archive wheel sera universelle, dans le cas d’un code intégralement en
Python, mais elle peut également être spécifique à une architecture, si elle contient du code
compilé (☞ p. 381, § 28).
351
25. Publier une bibliothèque Python
V Bonnes pratiques
S’il y a un risque :
— de conflit de nom entre un point d’entrée et un outil déjà installé,
— de conflit d’installation ou d’exécution entre plusieurs environnements virtuels ᵃ,
il est alors possible de lancer l’application avec l’option -m de Python, qui utilise le fichier
__main__.py parfois présent dans les projets :
# pour s'assurer que le paquet est bien installé pour la version courante
$ python -m pip install httpx
# pour s'assurer que Jupyter est lancé avec la bonne version de Python
$ python -m jupyter lab
a. En théorie, cela ne devrait pas se produire ; mais on n’est jamais assez préparé face à la pratique !
[github]
user = xoolive
password = azerty123
352
25.4. Publier du code source
V Bonnes pratiques
Si le fichier de configuration attendu n’existe pas, il peut être apprécié de générer à
l’emplacement attendu un fichier de configuration documenté avec des valeurs vides.
config_template = Path("config_template.cfg").read_text()
config_file = Path(appdirs.user_config_dir("fip_textual")) / "[Link]"
if not config_file.exists():
if not config_file.parent.is_dir():
msg = f"Le chemin {config_file.parent} devrait être un dossier"
raise RuntimeError(msg)
config_file.write_text(config_template)
[Link](config_file)
353
25. Publier une bibliothèque Python
354
25.5. Publier des paquets Python
L’outil conda gère également les dépendances, y compris celles qui sortent de l’écosys-
tème Python. Il est possible de publier un paquet conda sur la plateforme conda-forge, mais le
processus sort du cadre de cet ouvrage. Mentionnons néanmoins deux conditions nécessaires
à la publication sur conda-forge : toutes les dépendances doivent être accessibles sur conda-
forge et l’outil doit être accessible sur pip. Une fois le paquet publié, la mise à jour à partir des
nouvelles versions publiées sur pip est quasi automatique.
En quelques mots…
On considère comme bonne pratique d’automatiser tout le processus de publication d’un
paquet. Une fois le code source référencé sur une plateforme comme GitHub, il est pos-
sible de mettre en place des actions à faire exécuter en fonction de différents événements.
À titre personnel, j’ai l’habitude :
— de lancer une vérification du style (avec les outils ruff) ainsi qu’une analyse sta-
tique (☞ p. 367, § 27) au moment du git commit. Ce processus est automatisable
avec des outils comme pre-commit ;
— d’exécuter les tests unitaires (☞ p. 360, § 26.2) sur GitHub Actions après chaque
commande git push, et à chaque demande de pull request. Il est possible de lancer
des tests sur différentes plateformes et versions de Python ;
— de programmer une mise à jour de la documentation (☞ p. 364, § 26.3) après
chaque git push. Suivant la maturité du projet et de la documentation, il peut être
pertinent de programmer une mise à jour à chaque incrément de version ;
— d’automatiser la construction et la publication des paquets sur PyPI à chaque in-
crément de version.
355
26
Mettre en place
un environnement de tests
B
eware of bugs in the above code ; I have only proved it correct, not tried it, « Attention aux
erreurs dans le code ci-dessus ; je n’ai fait que prouver qu’il était correct, je ne l’ai pas
testé » est un extrait de la correspondance de Donald Knuth qui rappelle que, même
avec toutes les précautions du monde prises lors de l’écriture de code informatique, le test
reste un outil incontournable pour vérifier son bon fonctionnement.
Le test unitaire est le moyen le plus direct de vérifier qu’un programme se comporte
comme il a été spécifié. Même un code prouvé de manière formelle, vérifié par analyse sta-
tique (☞ p. 367, § 27), mérite d’être testé de manière systématique pour s’assurer que toutes
les branches fonctionnent comme elles ont été spécifiées.
Il existe plusieurs types de tests : nous allons nous concentrer dans ce chapitre sur les tests
unitaires, dont l’objet est de vérifier le fonctionnement de petites unités de code. Les scénarios
plus complexes sont le sujet d’autres types de tests (les tests d’intégration par exemple).
Ce chapitre est consacré à trois pratiques logicielles qui tournent autour du test unitaire :
la journalisation existe en dehors des environnements de tests, mais elle est d’une grande aide
pour identifier les causes d’un comportement défectueux. La deuxième partie est consacrée
à la bibliothèque Pytest, qui intègre un environnement de tests à un projet Python : elle est
basée sur des fonctions particulières et des assertions à vérifier.
La bibliothèque Pytest s’intègre bien au module doctest (☞ p. 32, § 2.5) utilisé pour inté-
grer des cas d’utilisation dans une documentation, sous une forme compatible avec des tests
unitaires. La dernière section donnera alors des pistes pour publier une documentation asso-
ciée à un projet Python.
357
26. Mettre en place un environnement de tests
Les messages print() sont souvent mal positionnés par le programmeur débutant, qui peut
parfois confondre la fonction print et l’instruction return. Les messages print() peuvent être
d’une grande aide pour identifier un problème dans un code si l’utilisation d’un debugger n’est
pas d’actualité, mais l’étape qui suit la résolution des problèmes est souvent la suppression des
messages d’erreurs, parce que leur affichage obère la performance du programme.
On utilise la journalisation, via le module logging, dans tous les cas où un message print
aurait du sens. On dispose de plusieurs niveaux de criticité d’un message, et d’un niveau seuil
à partir duquel on affiche les messages, dans le terminal, ou dans un fichier de journalisation
(on utilise souvent l’extension .log).
Les cinq niveaux de criticité du module logging sont :
— DEBUG, pour un diagnostic très poussé et détaillé ;
— INFO, pour suivre la trace d’exécution du programme ;
— WARNING, pour signaler des cas pour lesquels le programme peut s’exécuter, mais sous
des conditions dégradées (espace disque faible, régression linéaire à partir de deux
points, utilisation de données potentiellement incohérentes, etc.) ;
— ERROR, pour une action qui ne peut pas s’exécuter ;
— CRITICAL, pour les problèmes les plus graves.
def titres_du_monde():
[Link]("Connexion au site du Monde")
content = [Link]("[Link]
try:
content.raise_for_status()
except Exception:
[Link]("Erreur de connexion")
if "<a>" not in [Link]:
msg = "Le contenu du site ne semble pas contenir de lien hypertexte"
[Link](msg)
return extraire_titres([Link])
Lors de l’exécution du code, le seuil par défaut est WARNING : tous les messages au moins
aussi critiques sont affichés :
>>> titres_du_monde()
WARNING:root:Le contenu du site ne semble pas contenir de lien hypertexte
358
26.1. La journalisation avec le module logging
Il est également possible de rediriger systématiquement les messages d’erreur vers un fi-
chier de journalisation, en spécifiant le format d’affichage des messages avec des balises. On
précise alors souvent :
— le nom du programme name ;
— le niveau de journalisation levelname ;
— l’horodatage asctime ;
— le message en lui-même message.
[Link](
filename='[Link]', filemode='w', format='%(name)s - %(levelname)s - %(message)s'
)
V Bonnes pratiques
Lors de la création d’un outil en ligne de commande (☞ p. 321, § 22), on utilise souvent
l’option -v/--verbose pour afficher des messages de journalisation. Une pratique cou-
rante est de déplacer le seuil en fonction du nombre de -v passés en paramètres : -v pour
le niveau INFO, -vv pour le niveau DEBUG, etc.
L’outil click (☞ p. 323, § 22.2) propose cette option avec le paramètre count=True :
@[Link]("-v", "--verbose", count=True, help="Niveau de verbosité")
Cela rend les logs plus faciles à analyser et à interpréter, surtout lorsqu’ils sont envoyés à
des systèmes de gestion de logs ou des bases de données. En combinant une grande flexibilité
avec une simplicité d’utilisation, structlog permet d’enrichir les logs avec des informations
1. [Link]
359
26. Mettre en place un environnement de tests
>>> [Link](
... processors=[
... [Link].add_log_level,
... [Link](
... { [Link] }
... ),
... [Link](fmt="%H:%M:%S %Z"),
... [Link](),
... ]
... )
>>> log = structlog.get_logger()
>>> [Link]("Un message JSON")
{"event": "Un message JSON", "level": "info", "lineno": 1, "timestamp": "[Link] UTC"}
fip_online/tests/test_coreutils.py .. [100%]
============================== 2 passed in 0.61s ===============================
360
26.2. Les tests unitaires avec Pytest
L’exécutable parcourt l’arborescence, ouvre le fichier Python qui contient les tests et af-
fiche un caractère . si le test est réussi, et un F si le test échoue. Par exemple, avec une erreur
sur le cas limite dans la fonction wrap :
fip_online/tests/test_coreutils.py .F [100%]
fip_online/tests/test_coreutils.py:13: AssertionError
=========================== short test summary info ============================
FAILED fip_online/tests/test_coreutils.py::test_wrap - AssertionError: assert...
========================= 1 failed, 1 passed in 2.07s ==========================
Par souci d’efficacité, lors d’une nouvelle exécution des tests unitaires, Pytest commencera
par exécuter les fonctions qui ont causé une erreur avant celles qui fonctionnaient déjà.
9 Attention !
Dans sa version d’origine (☞ p. 316, § 21.2), la fonction readtime prenait en compte le
fuseau horaire du système sur lequel la fonction est lancée.
Dans un contexte de test unitaire, où aucune hypothèse ne peut être faite sur le
fuseau horaire de la machine qui va exécuter les tests, il est préférable d’ajouter un
argument par défaut et de le spécifier au moment du test unitaire.
Intégration avec les tests doctest. Si le code des fonctions contient déjà des tests unitaires
dans la documentation intégrée (☞ p. 32, § 2.5), il est possible de passer à pytest l’option
--doctest-modules.
$ pytest --doctest-modules
[...]
fip_online/core/[Link] . [ 33%]
fip_online/tests/test_coreutils.py .. [100%]
361
26. Mettre en place un environnement de tests
Gestion des exceptions. Il est possible de tester le bon fonctionnement d’exceptions à l’aide
du gestionnaire de contexte [Link]. Celui-ci signale une erreur à Pytest si l’exception
donnée n’est pas levée.
def test_division_par_zero():
with [Link](ZeroDivisionError):
_ = 1 / 0
with [Link](ZeroDivisionError):
> _ = 1 / 1
E Failed: DID NOT RAISE <class 'ZeroDivisionError'>
V Bonnes pratiques
Dans les doctests, on utilise une syntaxe elliptique (voir le mot-clé ELLIPSIS dans le fi-
chier de configuration ci-dessus) qui est utilisée dans tous les exemples le long de cet
ouvrage : les trois points ... sont valides vis-à-vis de n’importe quelle chaîne de carac-
tères passée en entrée.
>>> 1 / 0
Traceback (most recent call last):
...
ZeroDivisionError: division by zero
On peut également utiliser ces ellipses dans des contextes où la sortie produite est
trop longue à écrire ou impossible à prédire (représentations par défaut) :
>>> dict((i, str(i)) for i in range(100))
{0: '0', 1: '1', 2: '2', ... 99: '99'}
>>> class C:
... pass
>>> C()
<__main__.C object at 0x...>
Tests paramétrés. Une bonne couverture de tests passe souvent par la génération de cas
exemples pour lesquels on souhaite vérifier des invariants. On peut alors paramétrer des tests
en décrivant comment générer des arguments. Dans l’exemple ci-dessous, les deux arguments
x et sign sont tirés parmi les éléments fournis par l’itérable zip suivant (☞ p. 195, § 14) :
@[Link]("x, sign", zip(range(10), [Link]([1, -1])))
def test_parametres(x, sign):
assert (-1) ** x == sign
362
26.2. Les tests unitaires avec Pytest
def pytest_configure(config):
# la variable [Link] est positionnée par pytest
settings.cache_dir = Path([Link]) / "tests" / "cache"
[Link](f"Dossier de cache: {settings.cache_dir}")
Fixtures. Les fonctions fixtures permettent de partager des configurations particulières entre
fonctions de test. Il est courant de définir ces fonctions dans le fichier [Link], ce qui
permet également d’alléger les exemples d’utilisation dans la documentation doctest.
Le paramètre scope précise que la fonction ne sera exécutée qu’une seule fois (et mise en
cache pour les autres appels) par function, class, module, package ou session.
@[Link](scope="package")
def session():
return [Link]()
def test_getdata(session):
result = [Link]("[Link]
result.raise_for_status()
assert result.status_code == 200
Les objets mocks sont des « bouchons » intelligents utilisés pour simuler des situations diffi-
ciles à reproduire, notamment des accès à des bases de données, à des services web qui peuvent
renvoyer une valeur dont on ne peut pas prédire le contenu. Les objets mocks sont alors conçus
pour remplacer par monkey-patch (☞ p. 231, § 15.5) le comportement de fonctions au com-
portement critique : ceci permet d’écrire des tests fiables et reproductibles pour s’assurer du
bon fonctionnement général du logiciel, même dans ses parties critiques.
Le cas des services web est l’un des plus courants : pour notre application fip_online, pour
tester un contenu qui dépend de ce qui est renvoyé par l’API de Radio France, on va remplacer
les appels web par des appels à des valeurs fixées pour les tests. Dans l’exemple suivant, l’objet
MockResponse va remplacer le retour de la fonction [Link]
On crée alors un objet Mock pour faire du monkey-patching (☞ p. 231, § 15.5) sur la fonction
[Link] et remplacer les appels réseau par des données qui permettent de reproduire les
tests.
363
26. Mettre en place un environnement de tests
class MockResponse:
@staticmethod
def raise_for_status():
pass
@staticmethod
def json():
# On a stocké dans le fichier JSON la réponse à un appel à l'API
txt = Path("fip_sample.json").read_text()
return [Link](txt)
@[Link]
def mock_response(monkeypatch):
"""[Link]() renverra le dictionnaire ci-dessus."""
def mock_get(*args, **kwargs):
return MockResponse()
[Link](requests, "get", mock_get)
def test_api(mock_response):
result = [Link](api_points["FIP"])
result.raise_for_status()
json = [Link]()
assert "steps" in [Link]()
assert len(json["steps"]) > 4 # ou d'autres tests mieux choisis
The project name will occur in several places in the built documentation.
> Project name: fip_online
> Author name(s): Xavier Olive
> Project release []: 0.1
[...]
$ ls
build/ [Link] Makefile source/
$ ls source
[Link] [Link] _static/ _templates/
Parmi les fichiers générés, le dossier courant contient un Makefile, et le dossier source
contient un fichier de configuration [Link] avec les informations demandées et un démarrage
de page d’accueil, au format reSt(ructuredText).
364
26.3. Publier une documentation avec sphinx
Il est donc possible de démarrer la rédaction de la documentation dans cette page. On peut
alors écrire du code, afficher des images (à placer dans le dossier _static) et faire des liens
avec d’autres pages dans le même document.
L’intérêt de l’infrastructure sphinx vient de son système de plugins qui permet de générer
du contenu pour la documentation de manière automatique. Le plus célèbre est le module
autodoc qui génère des pages de documentation à partir des docstrings des fonctions, classes
et modules.
On peut générer les pages web avec la commande :
$ make html
En quelques mots…
La mise en place d’un framework complet de suivi des versions, des tests unitaires et
de la documentation est un travail qui peut paraître fastidieux, mais qui reste une étape
nécessaire avant de publier un projet de qualité. La documentation reste la vitrine du
projet, le point de chute d’utilisateurs potentiels de la bibliothèque qui décideront de
poursuivre l’aventure ou non en fonction du contenu de ces pages.
Les tests unitaires sont également gages du sérieux de votre travail, leur mise en place
sur des environnements virtualisés ou des conteneurs (cachés derrière les plateformes
en ligne GitHub Actions ou Travis) permet de clarifier la procédure d’installation pour
la documentation, et de s’assurer de son bon fonctionnement sur un vaste éventail de
configurations.
Les outils d’assistance à la gestion de projets évoluent très vite. Il est recommandé
de surveiller les outils mis en place pour accompagner le développement de nos biblio-
thèques préférées.
365
27
Annotations et typage statique
P
ython est un langage fondamentalement dynamique. Toutes les variables manipulées
par un programme peuvent, sur le papier, être passées en argument de n’importe quelle
fonction ou méthode. Le contrôle est alors assuré pendant l’exécution par les excep-
tions : c’est le style de programmation EAFP (Easier to Ask for Forgiveness than Permission).
Dès les premières lignes de cet ouvrage, nous avons fait le choix de tirer profit du PEP 526
sur les annotations à des fins de documentation, pour clarifier les variables manipulées dans
les exemples de code. C’est le premier cas d’utilisation des annotations : documenter le code,
fournir des indications supplémentaires pour assister à la fois le rédacteur du code et l’utilisa-
teur final. On notera notamment les différentes possibilités fournies pour annoter une même
variable :
angle: float = 3.14 radians = float
angle: "radians" = 3.14 angle: radians = 3.14
L’annotation float est « vraie ». Au fond, elle n’apporte pas grand-chose de plus que ce qui
est déjà lisible dans le code : une nouvelle variable qui prend la valeur 3.14 est probablement de
type flottant. Une annotation peut contenir n’importe quel élément de syntaxe Python valide,
notamment une chaîne de caractères. Annoter la variable angle avec la chaîne de caractères
"radians" est plus utile au développeur que l’annotation float, et se substitue alors à un com-
mentaire du type « valeur de l’angle en radians ». Écrire radians = float permet de combiner
les deux approches : le « vrai » (angle est de type radians, donc float) et la sémantique (on
manipule une valeur d’angle en radians).
1. Dans les langages compilés, c’est une étape qui a lieu en général avant la compilation.
367
27. Annotations et typage statique
À ce jour, les outils les plus répandus sont Mypy [Link] (Dropbox)
et Pyright [Link] (Microsoft). Ces deux outils sont facilement
intégrables dans les éditeurs de code classiques (VS Code, Vim, Sublime Text, Emacs, etc.)
L’idée est de pouvoir signaler au développeur les incohérences au moment où il écrit le
code. L’objectif est de permettre au développeur, à l’image d’un correcteur orthographique,
de corriger ces petites erreurs sans avoir à lancer le programme, lequel pourrait ne pas passer
par les lignes problématiques.
radians = float
angle = 3.14
# plus loin...
angle = "radians" # <<= l'éditeur devrait soulever une incohérence!
L’outil Mypy, en ligne de commande, permet de détecter cette incohérence. Les éditeurs
de texte comme VS Code vont lancer Mypy (ou Pyright) en tâche de fond, et surligner les
incohérences détectées au fur et à mesure (Figure 27.1).
$ mypy typing_01.py
typing_01.py:6: error: Incompatible types in assignment (expression has type "str",
variable has type "float")
Found 1 error in 1 file (checked 1 source file)
[1] 915758 exit 1 mypy typing_01.py
FIGURE 27.1 – L’éditeur VS Code intègre les résultats du programme Mypy dans son interface.
V Bonnes pratiques
Une troisième utilisation des annotations de type est de permettre aux éditeurs de texte
de fournir des informations pour la complétion automatique de code (Figure 27.2).
Sur la capture d’écran, l’éditeur comprend que la variable m est de type Math : il propose
alors dans la liste de complétion l’ensemble des méthodes associées au type Math. Comme
la méthode [Link]() renvoie un flottant, alors la complétion propose l’ensemble des
méthodes associées au type float.
FIGURE 27.2 – L’éditeur VS Code utilise les annotations de type pour proposer une complétion intelligente.
def function():
return 1 + "" # pas d'erreur détectée
V Bonnes pratiques
L’annotation des arguments avec valeurs par défaut se fait sur le modèle suivant :
def distance(x1: float, y1: float, x2: float = 0, y2: float = 0) -> float:
...
V Bonnes pratiques
Les arguments de fonction args et kwargs (☞ p. 17, § 1.8) peuvent être annotés. Dans
l’exemple ci-dessous, l’instruction reveal_type est comprise par Mypy pour aider le dé-
veloppeur ponctuellement mais devra être enlevée avant d’exécuter le code :
def fonction(*args: int, **kwargs: float):
reveal_type(args[0])
reveal_type(kwargs["facteur"])
369
27. Annotations et typage statique
Le type Any est un type qui est compatible avec n’importe quel type. Il est possible d’assigner
une valeur de n’importe quel type à une variable de type Any, et d’appeler n’importe quelle
méthode dessus.
from typing import Any
a: Any = None
a = 1
a = "hello"
a = [Link]()
Le type Optional permet de dire que la variable peut valoir None. Cette annotation permet de
rattraper la plupart des erreurs de programmation détectables par analyse statique :
class Maybe:
def maybe_none(self, x: int) -> int:
if x > 0:
return x
Ici, Mypy relève une erreur de typage sur la méthode Maybe.maybe_none() : il est facile
d’oublier que l’instruction return est située dans un bloc if. Le cas else (omis ici) sous-entend
donc que la méthode ne renvoie rien (return None).
typing_05.py:5: error: Missing return statement
Le type Optional[int] permet alors de préciser que la méthode renvoie soit None, soit un
entier. Cette fois, c’est la ligne dans la fonction située plus loin qui cause une erreur. Il est
probable que, dans tous les cas déjà rencontrés, maybe_none() a toujours renvoyé un entier.
Cependant, l’utilisation des annotations par Mypy rappelle qu’il convient de traiter le cas où
la valeur renvoyée est None.
from typing import Optional
class Maybe:
def maybe_none(self, x: int) -> Optional[int]:
if x > 0:
return x
return None
370
27.4. Les types paramétrés
Le type Union fait référence à une variable qui peut avoir plusieurs formats différents, p. ex.
un entier ou un flottant, une liste ou une chaîne de caractères. On énumère alors toutes les
possibilités de type que peut prendre la variable.
from datetime import datetime
from numbers import Number
from typing import Union
import pandas as pd
>>> make_timestamp("2020-12-25")
Timestamp('2020-12-25 [Link]')
>>> make_timestamp(1608854400)
Timestamp('2020-12-25 [Link]')
"""
if isinstance(value, str) or isinstance(value, datetime):
return [Link](value)
if isinstance(value, Number):
return [Link](value, unit="s")
return value
l: list[int] = [1, 3, 5, 3]
s: set[str] = {"un", "trois", "cinq"}
d: dict[int, str] = {1: "un", 3: "trois", 5: "cinq"}
Pour les tuples, on peut préciser un type pour tous les éléments d’un tuple de longueur
inconnue, ou un type par valeur du tuple.
t1: tuple[int, ...] = (1, 3, 5) # uniquement des entiers, longueur inconnue
t2: tuple[int, int, str] = (1, 3, "cinq") # longueur fixe
371
27. Annotations et typage statique
V Bonnes pratiques
Le type Optional[T] est équivalent à Union[None, T]. Si un type Union plus complexe
est optionnel, les deux notations Union[None, int, str] et Optional[Union[int, str]]
sont équivalentes.
Bien qu’il n’y ait pas de vrais arguments pour préférer une des annotations à l’autre
dans tous les cas, la première peut permettre de limiter le nombre de crochets pour
améliorer la lisibilité.
9 Attention !
Si le type Union offre de la souplesse, il peut à l’inverse devenir contraignant pour son
ambiguïté, son manque de précision sur la valeur annotée.
personne: dict[str, Union[str, int]] = {'prenom': 'Jean', 'age': 18}
majorite: bool = personne['age'] >= 18
typing_06.py:4: error: Unsupported operand types for >= ("str" and "int")
typing_06.py:4: note: Left operand is of type "Union[str, int]"
Dans ce cas particulier, on pourra préférer une autre structure de données, comme
le Namedtuple ou la dataclass, ou encore utiliser le type TypedDict :
from typing import TypedDict
class Personne(TypedDict):
prenom: str
age: int
Les types ABC permettent d’être le plus générique possible sur les types des variables d’en-
trée d’une fonction. La philosophie pour parvenir à typer rapidement et efficacement un pro-
gramme consiste à être :
— le plus générique possible sur les paramètres d’entrée,
— le plus spécifique possible sur les paramètres de sortie.
Si une variable de sortie est définie de manière générale, il est plus difficile de connaître à
l’avance les services qu’elle offre. Inversement, si une variable d’entrée est définie de manière
spécifique, il devient difficile de passer une variable qui propose pourtant les mêmes services.
372
27.4. Les types paramétrés
n = nonzero({0, 1, 3, 5})
# error: Argument 1 to "nonzero" has incompatible type "set[int]";
# expected "lIst[int]"
Cette première option qui ne manipule que des listes est probablement trop restrictive : il
est possible de passer des ensembles en entrée de la fonction sans perte de généralité, pourtant
l’analyse statique échoue.
def nonzero(seq: Iterable[int]) -> Iterable[int]:
return list(elt for elt in seq if elt == 0)
n = nonzero({0, 1, 3, 5}) # ok
[Link]("sept")
# error: "Iterable[int]" has no attribute "append"
Cette deuxième option qui ne manipule que des structures génériques Iterable est éga-
lement trop restrictive : l’analyse statique échoue sur l’appel à .append() qui fonctionne bien
puisque le type de retour est une liste.
n = nonzero({0, 1, 3, 5})
[Link]("sept")
# error: Argument 1 to "append" of "list" has incompatible type "str";
# expected "int"
[Link](7)
# Success: no issues found in 1 source file
La troisième option est la meilleure : il suffit pour la variable seq de pouvoir itérer dessus ;
mais la fonction renvoie bien une liste.
Tous les ABC sont ainsi disponibles : Iterable[T], Iterator[T], Sequence[T], Hashable,
Mapping[K, V], etc. Dans la plupart des cas, une fonction génératrice (avec le mot-clé yield)
pourra être typée avec Iterator[YieldType]. Pour une coroutine, on pourra utiliser le type
Generator[YieldType, SendType, ReturnType].
def stringify(seq: Iterable[int]) -> Iterator[str]: # ou Generator[str, None, None]
for elt in seq:
yield str(elt)
Enfin, les fonctions d’ordre supérieur sont annotées avec l’ABC Callable. C’est la manière
correcte de typer des fonctions, à préférer à la notation fléchée, courante dans les langages
fonctionnels de la famille ML, que nous avons utilisée dans le chapitre 12 mais qui n’est pas
comprise par Mypy : les types des paramètres sont passés sous forme de liste, et le deuxième
argument est le type de retour.
Enfin, on utilise ici les fonctions du module operator pour faire appel aux fonctions asso-
ciées aux opérateurs infixes +, * et -.
import operator
373
27. Annotations et typage statique
def sort_results(
a: int, b: int,
# au chapitre 12, on aurait écrit Iterable[int * int -> int]
fonctions: Iterable[Callable[[int, int], int]]
) -> list[int]:
return sorted(f(a, b) for f in fonctions)
Le type TypeGuard (PEP 647) permet de spécifier un type plus précisément dans des branches
de code particulières. Supposons qu’on utilise un service web qui renvoie des objets JSON qui
peuvent être d’un type Temperature ou d’un type Pression.
On peut définir les deux types, et définir le type JSON comme un type union :
class Temperature(TypedDict):
type: Literal["temperature"]
valeur: int
class Pression(TypedDict):
type: Literal["pression"]
valeur: float
T = TypeVar("T")
374
27.6. Les types génériques
def sort_results(
a: T, b: T,
fonctions: Iterable[Callable[[T, T], int]]
) -> list[int]:
return sorted(f(a, b) for f in fonctions)
Il est possible de contraindre des types variables, c’est-à-dire d’énumérer les types qui
peuvent convenir à la variable annotée T. À la différence d’un type Union, le type contraint
fixe le type une fois pour toutes :
T = TypeVar("T", int, str)
Depuis la version 3.12, il est possible de simplifier la notation sans avoir à importer TypeVar
grâce à la syntaxe de paramètres de type (PEP 695).
def sort_results[T](
a: T, b: T, fonctions: Iterable[Callable[[T, T], int]]
) -> list[int]:
return sorted(f(a, b) for f in fonctions)
Le PEP 695 introduit également le mot-clé type pour définir des alias de types (et simplifier
les notations). On peut ainsi créer un type Point de la sorte :
375
27. Annotations et typage statique
@prefixe(">>> ")
def resultat_1() -> str:
return "un" # renvoie ">>> un" à cause du décorateur
@prefixe(2)
def resultat_2() -> int:
return 2 # renvoie 4 à cause du décorateur
@prefixe(">>> ")
def resultat_3() -> int:
return 3 # ">>> " + 3 n'est pas une opération valide
# error: Argument 1 to "__call__" of "prefixe" has incompatible type
# "Callable[[], int]"; expected "Callable[..., str]"
reveal_type(prefixe(">>>"))
# note: Revealed type is 'typing_14.prefixe[[Link]*]'
reveal_type(prefixe(2))
# note: Revealed type is 'typing_14.prefixe[[Link]*]'
@prefixe(2.4)
def resultat_4() -> float: # float n'est pas dans les arguments de T
return 4.1
# error: Value of type variable "T" of "prefixe" cannot be "float"
Plutôt que d’utiliser un type variable qui nous contraint à ne manipuler qu’un type int ou
str, il est possible d’être un peu plus général pour accepter, entre autres, le type float pour
resultat_4. D’après le code du décorateur, plus précisément de la fonction newfun, tout type
valide vis-à-vis de l’addition pourrait convenir ici.
On peut alors réécrire l’exemple à l’aide du type paramétré Protocol, une simple classe
qui ne contient que des définitions de méthodes annotées : le code n’importe pas, on peut se
contenter des points de suspension.
376
27.6. Les types génériques
T = TypeVar("T", bound="Addable") # À
class Addable(Protocol[T]):
def __add__(self: T, other: T) -> T: # Á
...
class prefixe(Generic[T]):
def __init__(self, elt: T) -> None:
[Link]: T = elt
@prefixe(">>> ")
def resultat_3() -> int: # Â
return 3
# error: Argument 1 to "__call__" of "prefixe" has incompatible type
# "Callable[[], int]"; expected "Callable[..., str]"
@prefixe(2.4)
def resultat_4() -> float: # Ã
return 4.1
class Exemple:
def __add__(self, other: "Exemple") -> "Exemple":
return Exemple()
@prefixe(Exemple()) # Ä
def resultat_5() -> Exemple:
return Exemple()
À On utilise un type variable borné (bounded en anglais) : T est alors n’importe quel sous-
type de Addable, n’importe quel type qui propose l’addition.
Á Addable définit l’opérateur addition : les deux arguments, self et other, et le type de
retour sont les mêmes.
 Mypy détecte que le type de retour de resultat_3 n’est pas compatible avec le paramètre
passé à prefixe.
à On peut manipuler des flottants qui sont valides vis-à-vis du calcul de l’addition.
Ä La classe Exemple propose aussi la méthode spéciale __add__(self, other) dans ses
services.
377
27. Annotations et typage statique
P = ParamSpec("P")
R = TypeVar("R")
return fonction_async
class Polygone:
378
27.8. Variance : covariance et contravariance
Le constructeur de type d’une fonction (ou d’une méthode) peut alors être :
— covariant par rapport au type de retour ;
— contravariant dans les types des paramètres d’entrée.
V Bonnes pratiques
Lors du typage d’une fonction, on gagne en utilisabilité en choisissant des types :
— les plus génériques possibles pour les arguments (dans le sens de la contrava-
riance, du plus spécifique au plus générique) ;
— les plus spécifiques possibles pour le type de retour (dans le sens de la covariance).
Un type list[T] pour le paramètre d’entrée serait correct mais interdirait de facto
l’usage d’ensembles ou de fonctions génératrices qui seraient pourtant acceptés par la
fonction : il est préférable de typer Iterable[T].
Un type Iterable[T] pour le paramètre de sortie serait correct mais interdirait alors
d’appliquer une méthode applicable aux listes sur le résultat de la fonction : il est pré-
férable d’afficher l’ensemble des fonctionnalités accessibles sur le type de retour avec le
type list[T].
379
27. Annotations et typage statique
Il est possible de construire des types covariants ou contravariants à l’aide des arguments
covariant et contravariant du constructeur TypeVar. Les occasions de manipuler ces argu-
ments en pratique sont plutôt rares. Le lecteur est invité à se référer au PEP 484 le cas échéant.
En quelques mots…
Les annotations de type permettent de détecter un grand nombre d’erreurs, souvent fa-
ciles à résoudre, avant d’exécuter le code. Ces annotations sont facultatives, mais il y a
toutefois un effet de seuil dans un grand projet à partir duquel on ressent les bénéfices
des annotations, et les maladresses ou erreurs commencent à être efficacement repérées.
Plus les types d’entrée sont génériques, inclusifs et plus les types de sortie sont précis,
prescriptifs, plus grande sera la plus-value apportée par l’analyse statique de code.
Un code mal annoté, ou annoté partiellement, reste exécutable, au même titre qu’un
code où les types sont erronés. Une annotation difficile à appréhender peut être enlevée,
ou remplacée par Any, dans l’attente de trouver une solution plus tard, à court, moyen ou
très long terme, voire jamais. Une ligne de code peut aussi être marquée comme à ignorer
par l’analyseur statique avec le commentaire ## type: ignore
Enfin, les annotations permettent de réduire le volume de commentaires et de docu-
mentation pour en améliorer la lisibilité. Les informations des types sont alors placées
au plus près des variables, là où l’œil recherche l’information. On ajoute souvent l’exécu-
tion d’un analyseur statique, comme Mypy, dans les outils de vérification de code, avant
l’exécution des tests automatiques, pour surveiller la viabilité du code d’un projet et la
qualité des modifications proposées par les développeurs tiers.
Est-ce que tout le monde devrait annoter son code ?
Non. Les utilisateurs du langage Python ont tous un profil différent, et tous ne sont pas
sensibles à la logique des types.
Un programmeur débutant aura probablement déjà beaucoup à faire avec d’autres as-
pects du langage. Les annotations de type n’apporteront probablement guère plus qu’une
complexité inutile. Un data analyst qui code quelques lignes de Pandas et Matplotlib
dans un notebook ne verra aussi aucune plus-value à annoter son code : l’objectif de
sa démarche étant d’arriver rapidement à des résultats ou à un prototype qui valide sa
faisabilité.
En revanche, un code partagé, destiné à être réutilisé dans d’autres projets, par soi ou
par d’autres utilisateurs, qui passent souvent peu de temps dans la documentation, gagne
beaucoup à être annoté. Ces annotations pourront, au même titre que la documentation,
être exploitées par les éditeurs de code, pour proposer de la complétion de code ou pour
surligner des erreurs et mauvaises utilisations de la bibliothèque.
Pour aller plus loin
— Le site suivant propose de nombreux exercices de typage :
[Link]
380
28
Optimiser du code Python
L
e langage Python brille par son expressivité mais le contrôle de la performance se fait
plutôt au niveau de langages plus bas niveau comme le C et le C++. De nombreuses
bibliothèques comme NumPy (☞ p. 69, § 5) et Pandas (☞ p. 119, § 9) sont bâties sur
des grands codes écrits en C, à l’aide d’un outil particulier nommé Cython. Rust est un autre
langage de programmation qui inspire de nouvelles pratiques dans l’écosystème Python, avec
des outils performants (Ruff pour le contrôle du style ☞ p. 349, § 25, ou uv pour remplacer
pip ☞ p. 345, § 24.4). Du code écrit en Rust est facilement portable vers d’autres langages
comme Python (avec la bibliothèque maturin ☞ p. 389, § 28.3) ou Webassembler abordé dans
le chapitre sur Python pour le web (☞ p. 333, § 23).
Cython est à la fois un langage hybride, entre le C et le Python, et un compilateur, capable
de générer des bibliothèques Python en C, à compiler pour la version courante de Python.
Cython répond à deux principaux cas d’utilisation :
— optimiser un code Python grâce à des annotations statiques, qui permettent de se
rapprocher le plus possible des instructions machines ;
— écrire une API Python vers une bibliothèque écrite en C.
Des outils plus récents, comme le compilateur Numba, permettent de répondre au premier
cas d’utilisation de manière très directe, mais Cython reste l’outil de choix pour le deuxième.
Python dans sa version 3.13 propose une ébauche de compilateur JIT (just-in-time) déjà
présente dans d’autres langages interprétés. L’idée derrière cette évolution (disponible si Py-
thon est compilé avec l’option --enable-experimental-jit) est de repérer des boucles pour y
remplacer des séquences d’instructions bytecode par des motifs d’instructions machine.
En effet, pour exécuter des instructions bytecode, l’interpréteur parcourt une boucle pour
associer chaque instruction bytecode à une instruction machine. Cette recherche peut être
optimisée si une séquence revient régulièrement en associant une séquence d’instruction ma-
chine à une séquence d’instruction bytecode. Les résultats préliminaires de cette optimisation
montrent une amélioration de la performance entre 2 et 9% en moyenne, 47% dans le meilleur
des cas. Les chiffres ne sont pas très éloquents à ce stade, mais ce compilateur JIT n’est consi-
déré que comme un point de départ à des optimisations plus conséquentes attendues dans les
futures versions de Python.
381
28. Optimiser du code Python
return next_grid
La figure 28.1 illustre plusieurs itérations de cet automate. Notre base de comparaison
s’effectuera alors sur la fonction update(grid) :
%timeit update(grid)
11.9 ms ± 1.39 ms per loop (mean ± std. dev. of 7 runs, 100 loops each)
382
28.1. Optimiser un code avec Numba et Cython
Le gros défaut de performance de cette fonction vient des nombreux appels à l’opérateur
d’indexation : contrairement au langage C, Python prend énormément de précautions avant
d’accéder à un élément et d’effectuer des opérations dessus. Numba est un compilateur JIT
(just in time) : à la première exécution d’une fonction décorée, Numba analyse le code source,
génère un code C efficace correspondant et remplace la fonction en question par le résultat
de la compilation correspondante. Son utilisation est extrêmement simple pour des résultats
souvent extraordinaires : ici, la même fonction est exécutée 165 fois plus rapidement.
import numba
@[Link](nopython=True)
def update_numba(grid: [Link]) -> [Link]:
n, m = [Link]
next_grid = [Link]((n, m), dtype=np.int8)
# abrégé...
%timeit update_numba(grid)
71.9 µs ± 919 ns per loop (mean ± std. dev. of 7 runs, 10000 loops each)
Si Numba ne parvient pas à trouver une optimisation en C, il revient sur la fonction Py-
thon d’origine : l’option nopython=True permet de lever une exception dans ce cas-là. En cas
d’erreur à trouver dans le code, il est facile de commenter le décorateur pour revenir dans le
monde Python et faire du pas à pas avec un debugger. L’optimisation avec Cython est plus
complexe, mais elle permet aussi un contrôle plus fin de la performance. Numba a générale-
ment de très bonnes performances dès le premier essai, mais il est difficile de trouver des axes
d’amélioration après coup.
Cython est un langage de programmation qui enrichit la syntaxe Python : ceci signifie tout
d’abord que tout code Python est syntaxiquement valide en Cython. On peut alors utiliser le
compilateur Cython sur un code Python pour des gains marginaux de performance. Ici, nous
nous contenterons de l’extension Cython des notebooks Jupyter (☞ p. 107, § 8). L’extension
doit être chargée avec la commande %load_ext Cython : toute cellule préfixée par la com-
mande %%cython sera alors compilée, et les fonctions seront intégrées à l’espace de nommage
du noyau Jupyter. Un code Python compilé par Cython présente en général une amélioration
de performance de l’ordre de 30 %. C’est aussi le cas pour notre exemple :
%timeit update_cython(grid)
8.88 ms ± 578 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
FIGURE 28.2 – Rendu graphique des optimisations update_cython1 (à gauche) et update_cython2 (à droite)
Cython propose une option -a pour indiquer les points du code qui méritent une opti-
misation. Sur la figure 28.2, plus une ligne est jaune, plus elle coûte cher en Python à cause
383
28. Optimiser du code Python
des précautions prises par le langage ; plus elle est blanche, plus elle est proche d’un code C
optimisé. Le code C généré est lisible en cliquant sur une ligne jaune : on y trouve parfois des
pistes d’optimisation.
Les optimisations se font alors au moyen de déclarations de variables associées à un type.
Par exemple, dans la fonction update_cython, on peut commencer par annoter les variables
entières n, m, row et live_neighbors à l’aide de l’instruction Cython cdef suivie du type de
la variable. Ces instructions permettent d’optimiser (« enlever du jaune ») certaines lignes
correctement typées pour la machine (à droite sur la figure 28.2).
%%cython -a
import numpy as np
def update_cython2(grid):
cdef int n, m, row, col, live_neighbors
n, m = [Link]
next_grid = [Link]((n, m), dtype=np.int8)
# abrégé...
9 Attention !
Les déclarations de variables typées Cython sont de nature très différente des annota-
tions de type Python (☞ p. 367, § 27). Cython utilise les déclarations de variables typées
pour écrire du code C au plus proche des instructions machines, alors que les annota-
tions Python sont ignorées par le langage à l’exécution.
Les « lignes jaunes » restantes (Figure 28.3) après ces optimisations sont légitimes : la
création du tableau NumPy pour la grille de l’itération suivante ne peut pas être faite plus
rapidement.
Le fruit de nos efforts est enfin récompensé :
%timeit update_cython3(grid)
8.13 µs ± 241 ns per loop (mean ± std. dev. of 7 runs, 100000 loops each)
Cette optimisation est alors 10 fois plus rapide que l’optimisation Numba mais l’effort à
fournir pour y parvenir est plus grand. De plus, l’écriture de la somme sans [Link]() dans la
fonction décorée par Numba permet d’atteindre le même niveau d’optimisation. Néanmoins,
ce sont les outils d’analyse de Cython qui ont permis de trouver cette dernière faiblesse du
code.
384
28.2. Écrire une API Python pour une bibliothèque C
385
28. Optimiser du code Python
slot = face->glyph;
for (n = 0; n < num_chars; n++) {
/* load glyph image into the slot (erase previous one) */
FT_Load_Char(face, text[n], FT_LOAD_RENDER);
/* now, draw to our target surface (convert position) */
draw_bitmap(&slot->bitmap, /* etc. */);
}
FT_Done_Face(face);
FT_Done_FreeType(library);
return 0;
}
386
28.2. Écrire une API Python pour une bibliothèque C
Á Les variables sont déclarées par l’instruction cdef : dans les fichiers d’en-tête, la variable
FT_LOAD_RENDER est un entier, qu’on définit comme tel.
 L’instruction C typedef devient ctypedef en Cython. Tous les types nommés peuvent
alors être déclarés et toutes les variations de type entier (char, uint8, long, etc.) peuvent
être déclarées sous la forme générique int.
à Les structures de données C struct deviennent ctypedef struct en Cython : il n’est
pas nécessaire de rappeler tous les champs : on peut se contenter de ceux qu’on utilise
dans le fichier Cython courant.
Les définitions de fonction peuvent alors être ajoutées, en utilisant les types définis ci-
dessus :
FT_Error FT_Init_FreeType(FT_Library*)
FT_Error FT_New_Face(FT_Library, char* filepath, int, FT_Face*)
FT_Error FT_Done_Face(FT_Face face)
FT_Error FT_Set_Char_Size(FT_Face, int w, int h, int hres, int vres)
FT_Error FT_Load_Char(FT_Face, int char_code, int load_flags)
Puis le contenu du fichier Cython définit l’interface voulue en Python. Le contenu des
fonctions est similaire au travail fait dans la section précédente. On notera simplement une
nuance : le mot-clé cdef class en Cython Ä ne définit pas formellement une classe Python
mais un type étendu (extension type), qui se présente côté Python comme une classe, mais qui
a accès à des fonctions C.
387
28. Optimiser du code Python
for i in range([Link]):
for j in range([Link]):
char_view[j, i] = [Link][j*[Link] + i]
return result
Toutes les subtilités des types étendus débordent du cadre de cet ouvrage, mais on retiendra
simplement que :
Å les attributs du type étendu qui sont des variables C sont définis au niveau des variables
de classe, avec le mot-clé cdef : ils ne seront pas accessibles en Python ;
Æ la partie C de la construction et de la destruction du type étendu a lieu dans les méthodes
spéciales __cinit__ et __dealloc__ ;
Ç pour une utilisation générale, la conversion entre les chaînes de caractères str Python
et C mérite un détour par la documentation. Le sens Python vers C (ici) est plus facile
à maîtriser que C vers Python.
Le code complet est une fois de plus disponible sur la page du livre.
Pour compiler le projet et le tester, il conviendra de suivre le modèle du module historique
setuptools qui permet de définir des extensions ; la fonction cythonize se charge de procéder
à la compilation du code et à l’édition de la librairie dynamique du module.
Pour installer les bonnes dépendances, on définira tout d’abord dans le fichier [Link]
la section suivante :
[build-system]
requires = ["setuptools", "wheel", "Cython"]
setup(
name="freetype", version="0.1",
ext_modules=cythonize(
Extension(
"freetype", ["[Link]"],
include_dirs=[...], # les chemins vers les fichiers d'en-tête .h
library_dirs=[...], # les chemins vers les librairies dynamiques
libraries=["freetype"],
)
),
)
389
28. Optimiser du code Python
Une fois le projet initialisé, une arborescence par défaut est créée, avec un [Link] qui
décrit les dépendances Rust, un [Link] pour la bibliothèque Python, et également un
fichier [Link] dans .github/workflows pour l’intégration continue dans GitHub Actions. Le
fichier src/[Link] contient déjà un morceau de code qui, une fois compilé, permet d’appeler
une fonction simple qui concatène deux chaînes de caractères.
use pyo3::prelude::*;
390
28.3. Écrire un binding Rust avec maturin
struct SerializedInfo(whatlang::Info);
#[pyfunction]
fn detect(input: &str) -> PyResult<String> {
match whatlang::detect(input) {
Some(res) => {
// On sérialise si la fonction renvoie un résultat
let serialized_info = SerializedInfo(res);
Ok(serde_json::to_string(&serialized_info).unwrap())
}
// On renvoie une exception sinon
None => Err(PyErr::new::<PyRuntimeError, _>("Language not detected")),
}
}
#[pymodule]
fn pywhatlang(_py: Python, m: &PyModule) -> PyResult<()> {
m.add_function(wrap_pyfunction!(detect, m)?)?;
Ok(())
}
En relançant la commande maturin develop, on arrive facilement à un premier résultat.
391
28. Optimiser du code Python
On peut constater que les chaînes de caractères UTF-8 sont bien passées correctement
entre les langages ; la bibliothèque fonctionne très bien avec des alphabets non latins.
>>> from pywhatlang import detect
>>> detect("J'apprends à programmer en Python et en Rust.")
'{"lang":"fra","script":"Latin","confidence":0.4401946958305268}'
>>> detect("I am learning to program in Python and Rust.")
'{"lang":"eng","script":"Latin","confidence":0.9944095797574993}'
>>> detect("Jag lär mig att programmera i Python och Rust")
'{"lang":"swe","script":"Latin","confidence":1.0}'
Pour aller plus loin dans le binding, on pourra écrire une fonction dans __init__.py pour
charger le JSON :
>>> import json
>>> [Link](_)
{'lang': 'swe', 'script': 'Latin', 'confidence': 1.0}
Enfin, si whatlang ne parvenait pas à détecter de langue, nous avons capté le cas où la
fonction Rust renvoyait None pour créer une exception Python de type RuntimeError que l’on
peut tester facilement :
>>> detect("")
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
RuntimeError: Language not detected
Une fois le travail terminé, on peut construire et/ou publier le fichier wheel. L’option
--release permet de s’assurer d’avoir le code Rust compilé avec toutes les optimisations at-
tendues.
$ maturin build --release
2. [Link]
392
Pour aller plus loin
X
Notre visite du langage Python touche à sa fin, mais le chemin de l’apprentissage est loin
d’être terminé. Python est un langage en constante évolution, qui offre une multitude d’op-
portunités pour améliorer sa pratique, renforcer ses compétences et suivre les avancées du
langage. Dans cet ouvrage, nous avons couvert de nombreux concepts, allant des bases aux
aspects plus avancés, pour vous aider à mieux comprendre les mécanismes de Python et à
écrire un code plus efficace, lisible et maintenable.
Cependant, la maîtrise d’un langage de programmation ne se limite pas à la simple applica-
tion de ses concepts. Cela nécessite une pratique régulière, une veille technologique constante
et l’adoption de bonnes pratiques du développement. Pour poursuivre votre progression, voici
quelques conseils à garder à l’esprit.
393
Pour aller plus loin
394
Pour aller plus loin
gage (en général en anglais). Parmi les plus éloquents ces dernières années, on pourra
citer Raymond Hettinger (core developper), Łukasz Langa (responsable de la publica-
tion des versions 3.8 et 3.9), Pablo Galindo Salgado (pour les versions 3.10 et 3.11),
Charlie Marsh (auteur des bibliothèques uv ☞ p. 345, § 24.4 et Ruff ☞ p. 349, § 25) ou
Arjan Egges (qui propose une chaîne YouTube ArjanCodes avec de nombreux contenus
Python pertinents)
395
Y
Index
ABC (abstract base classes), 239, 254, 372 callable, 16, 245
-acum (suffixe), 105, 150 callback, 111
agrégation, 127, 140 Canny (filtre de), 300
algèbre linéaire, 79 cartes, 95, 113, 116, 148, 244
alpha, 90, 139 Cartopy, 95
Altair, 137 Cassini, 62
animations, 97, 232 chaîne de caractères, 8, 10
annotations, 16, 34, 50, 169, 183, 264, 368 chronomètre, 182, 194, 245
argparse, 323 classes, 212
arguments par défaut, 19, 52 abstraites, 239, 254
array, 58 métaclasses, 265
ASCII, 10 click, 323
asyncio, 279, 293 closure, 185
attributs, 213 communes de France, 105, 119, 150
dynamiques, 251 complex, 7
authentification, 312 composition, 225
compréhension
bases de données, 119, 316 d’ensemble, 14
beautifulsoup, 303 de dictionnaire, 16
binary heap, 57 de liste, 13, 170, 196
binding, 385 compression, 45
bins, 86, 142 conda, 346, 355
bitshift, 6 configuration, 352
boids, 212 contextmanager, 246, 283
bravo, 384 Conway (John), 382
broadcasting, 76 coroutines, 205, 232, 281
bytearray, 10 couleurs, 31, 90, 141
bytecode, 35 Counter, 54
bytes, 10, 43 CSS (format), 31, 165, 328
CSV (format), 119, 132, 138
C (langage), 69, 385 Curry (Haskell), 169
397
Index
398
Index
399
Index
400