-
Notifications
You must be signed in to change notification settings - Fork 45
/
04_python_practice.qmd
279 lines (212 loc) · 12.8 KB
/
04_python_practice.qmd
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
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
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
---
title: "Bonne pratique de Python"
date: 2020-07-22T12:00:00Z
draft: false
weight: 40
slug: bonnespratiques
type: book
description: |
Les normes communautaires du monde de
l'_open-source_ ont permis une
harmonisation de la structure des projets
`Python` et des scripts. Ce chapitre
évoque quelques-unes de ces conventions.
Pour aller plus loin, un cours en 3e année
d'ENSAE est disponible [sur un autre site](https://ensae-reproductibilite.github.io/website/)
image: https://ensae-reproductibilite.github.io/website/cards/quality/snake.png
categories:
- Tutoriel
- Rappels
---
Une référence utile à lire est le
[*Hitchhiker’s Guide to Python*](https://docs.python-guide.org/#writing-great-python-code)
## Structure d'un projet en python
La structure basique d'un projet développé en `Python` est la suivante, qu'on peut retrouver dans
[ce dépôt](https://github.com/navdeep-G/samplemod):
~~~python
README.md
LICENSE
setup.py
requirements.txt
monmodule/__init__.py
monmodule/core.py
monmodule/helpers.py
docs/conf.py
docs/index.rst
tests/context.py
tests/test_basic.py
tests/test_advanced.py
~~~
Quelques explications et parallèles avec les packages `R`^[1] :
* Le code Python est stocké dans un module nommé `monmodule`. C'est le coeur du code dans le projet. Contrairement
à `R`, il est possible d'avoir une arborescence avec plusieurs modules dans un seul package. Un bon exemple
de package dont le fonctionnement adopte une arborescence à plusieurs niveaux est `scikit`
* Le fichier `setup.py` sert à construire le package `monmodule` pour en faire un code utilisable. Il n'est pas
obligatoire quand le projet n'a pas vocation à être sur `PyPi` mais il est assez facile à créer en suivant ce
[*template*](https://packaging.python.org/tutorials/packaging-projects/#creating-setup-py). C'est l'équivalent
du fichier `Description` dans un package `R`
([exemple](https://github.com/Rdatatable/data.table/blob/master/DESCRIPTION))
* Le fichier `requirements.txt` permet de contrôler les dépendances du projet. Il s'agit des
dépendances nécessaires pour faire tourner les fonctions (par exemple `numpy`), les tester et
construire automatiquement la documentation (par exemple `sphinx`). Dans un package `R`, le fichier qui contrôle
l'environnement est le `NAMESPACE`.
* Le dossier `docs` stocke la documentation du package. Le mieux est de le générer à partir de
[sphinx](https://docs.readthedocs.io/en/stable/intro/getting-started-with-sphinx.html) et non de l'éditer
manuellement. (cf. [plus tard](#docfonctions)).
Les éléments qui s'en rapprochent dans un package `R` sont les vignettes.
* Les tests génériques des fonctions. Ce n'est pas obligatoire mais c'est recommandé : ça évite de découvrir deux jours
avant un rendu de projet que la fonction ne produit pas le résultat espéré.
* Le `README.md` permet de créer une présentation du package qui s'affiche automatiquement sur
github/gitlab et le fichier `LICENSE` vise à protéger la propriété intellectuelle. Un certain nombre de licences
standards existent et peuvent être utilisées comme *template* grâce au site <https://choosealicense.com/>
^[1:] La structure nécessaire des projets nécessaire pour pouvoir construire un package `R` est plus contrainte.
Les packages `devtools`, `usethis` et `testthat` ont grandement facilité l'élaboration d'un package `R`. A cet égard,
il est recommandé de lire l'incontournable [livre d'Hadley Wickham](http://r-pkgs.had.co.nz/)
## Style de programmation et de documentation
> The code is read much more often than it is written.
>
> Guido Van Rossum [créateur de Python]
`Python` est un langage très lisible. Avec un peu d'effort sur le nom des objets, sur la gestion
des dépendances et sur la structure du programme, on peut
très bien comprendre un script sans avoir besoin de l'exécuter. La communauté Python a abouti à un certain
nombre de normes, dites PEP (Python Enhancement Proposal), qui constituent un standard
dans l'écosystème Python. Les deux normes les plus connues sont
la norme PEP8 (code) et la norme PEP257 (documentation).
La plupart de ces recommandations ne sont pas propres à `Python`, on les retrouve aussi dans `R`
(cf. [ici](https://www.book.utilitr.org/02_bonnes_pratiques/02-structure-code)).
On retrouve de nombreux conseils dans [cet ouvrage](https://docs.python-guide.org/writing/style/) qu'il est
recommandé de suivre. La suite se concentrera sur des éléments complémentaires.
### Import des modules
Les éléments suivants concernent plutôt les scripts finaux, qui appellent de multiples fonctions, que des
scripts qui définissent des fonctions.
Un module est un ensemble de fonctions stockées dans un fichier `.py`. Lorsqu'on écrit dans un script
~~~python
import modu
~~~
`Python` commence par chercher le fichier `modu.py` dans le dossier de travail. Il n'est donc pas une bonne
idée d'appeler un fichier du nom d'un module standard de python, par exemple `math.py` ou `os.py`. Si le fichier
`modu.py` n'est pas trouvé dans le dossier de travail, `Python` va chercher dans le chemin et s'il ne le trouve pas
retournera une erreur.
Une fois que `modu.py` est trouvé, il sera exécuté dans un environnement isolé (relié de manière cohérente
aux dépendances renseignées) et le résultat rendu disponible à l'interpréteur `Python` pour un usage
dans la session via le *namespace* (espace où Python associe les noms donnés aux objets).
En premier lieu, ne **jamais** utiliser la syntaxe suivante :
~~~python
# A NE PAS UTILISER
from modu import *
x = sqrt(4) # Is sqrt part of modu? A builtin? Defined above?
~~~
L'utilisation de la syntaxe `import *` créé une ambiguité sur les fonctions disponibles dans l'environnement. Le code
est ainsi moins clair, moins compartimenté et ainsi moins robuste. La syntaxe à privilégier est la suivante :
~~~python
import modu
x = modu.sqrt(4) # Is sqrt part of modu? A builtin? Defined above?
~~~
## Structuration du code
Il est commun de trouver sur internet des codes très longs, généralement dans un fichier `__init__.py`
(méthode pour passer d'un module à un package, qui est un ensemble plus structuré de fonctions).
Contrairement à la légende, avoir des scripts longs est peu désirable et est même mauvais ;
cela rend le code difficilement à s'approprier et à faire évoluer. Mieux vaut avoir des scripts relativement courts
(sans l'être à l'excès...) qui font éventuellement appels à des fonctions définies dans d'autres scripts.
Pour la même raison, la multiplication de conditions logiques `if`...`else if`...`else` est généralement très mauvais
signe (on parle de [code spaghetti](https://fr.wikipedia.org/wiki/Programmation_spaghetti)) ; mieux vaut
utiliser des méthodes génériques dans ce type de circonstances.
## Écrire des fonctions
Les fonctions sont un objet central en `Python`.
La fonction idéale est une fonction qui agit de manière compartimentée :
elle prend un certain nombre d'*inputs* et est reliée au monde extérieur uniquement par les dépendances,
elle effectue des opérations sans interaction avec le monde extérieur et retourne un résultat.
Cette définition assez consensuelle masque un certain nombre d'enjeux :
* Une bonne gestion des dépendances nécessite d'avoir appliqué les recommandations évoquées précédemment
* Isoler du monde extérieur nécessite de ne pas faire appel à un objet extérieur à l'environnement de la fonction.
Autrement dit, aucun objet hors de la portée (*scope*) de la fonction ne doit être altéré ou utilisé.
Par exemple, le script suivant est mauvais au sens où il utilise un objet `y` hors du *scope* de la fonction `add`
```python
def add(x):
return x + y
```
Il faudrait revoir la fonction pour y ajouter un élément `y`:
```python
def add(x, y):
return x + y
```
`Pycharm` offre des outils de diagnostics très pratiques pour détecter et corriger ce type d'erreur.
### ⚠️ aux arguments optionnels
La fonction la plus lisible (mais la plus contraignante) est celle
qui utilise exclusivement des arguments positionnels avec des noms explicites.
Dans le cadre d'une utilisation avancée des fonctions (par exemple un gros modèle de microsimulation), il est
difficile d'anticiper tous les objets qui seront nécessaires à l'utilisateur. Dans ce cas, on retrouve généralement
dans la définition d'une fonction le mot-clé `**kwargs` (équivalent du `...` en `R`) qui capture les
arguments supplémentaires et les stocke sous forme de dictionnaire. Il s'agit d'une technique avancée de
programmation qui est à utiliser avec parcimonie.
## Documenter les fonctions {.docfonctions}
La documentation d'une fonction s'appelle le `docstring`. Elle prend la forme suivante :
~~~python
def square_and_rooter(x):
"""Return the square root of self times self."""
...
~~~
Avec `PyCharm`, lorsqu'on utilise trois guillemets sous la définition d'une fonction, un *template* minimal à
completer est automatiquement généré. Les normes à suivre pour que la *docstrings* soit reconnue par le package
[sphinx](https://docs.python-guide.org/writing/documentation/) sont présentées dans la PEP257. Néanmoins,
elles ont été enrichies par le style de *docstrings* `NumPy` qui est plus riche et permet ainsi des documentations
plus explicites
([voir ici](https://docs.python-guide.org/writing/documentation/#writing-docstrings) et
[ici](https://sphinxcontrib-napoleon.readthedocs.io/en/latest/example_numpy.html)).
Suivre ces canons formels permet une lecture simplifiée du code source de la documentation. Mais cela a surtout
l'avantage, lors de la génération d'un package, de permettre une mise en forme automatique des fichiers
`help` d'une fonction à partir de la *docstrings*. L'outil canonique pour ce type de construction automatique est
[sphinx](https://pypi.org/project/Sphinx/) (dont l'équivalent `R` est `Roxygen`)
## Les tests {.tests}
Tester ses fonctions peut apparaître formaliste mais c'est, en fait, souvent d'un grand secours car cela permet de
détecter et corriger des bugs précoces (ou au moins d'être conscient de leur existence).
Au-delà de la correction de *bug*, cela permet de vérifier que
la fonction produit bien un résultat espéré dans une expérience contrôlée.
En fait, il existe deux types de tests:
* tests unitaires : on teste seulement une fonctionalité ou propriété
* tests d'intégration : on teste l'intégration de la fonction dans un ensemble plus large de fonctionalités
Ici, on va plutôt se focaliser sur la notion de test unitaire, la notion de
test d'intégration nécessitant d'avoir une chaîne plus complète de fonctions (mais il ne faut
pas la négliger).
On peut partir du principe suivant :
> toute fonctionnalité non testée comporte un bug
Le fichier `tests/context.py` sert à définir le contexte dans lequel le test de la fonction s'exécute, de manière
isolée. On peut adopter le modèle suivant, en changeant `import monmodule` par le nom de module adéquat
~~~python
import os
import sys
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
import monmodule
~~~
Chaque fichier du dossier de test
(par exemple `test_basic.py` et `test_advanced.py`) incorpore ensuite la ligne suivante,
en début de script
~~~python
from .context import sample
~~~
Pour automatiser les tests, on peut utiliser le package `unittest`
([doc ici](https://docs.python.org/3/library/unittest.html)). L'idée est que dans un cadre contrôlé
(on connaît l'*input* et en tant que concepteur de la fonction on connaît l'*output* ou, *a minima*
les propriétés de l'*output*) on peut tester la sortie d'une fonction.
La structure canonique de test est la suivante^[2]
```python
import unittest
def fun(x):
return x + 1
class MyTest(unittest.TestCase):
def test(self):
self.assertEqual(fun(3), 4)
```
^[2:] Le code équivalent avec `R` serait `testthat::expect_equal(fun(3),4)`
**Parler de codecov**
## Partager
Ce point est ici évoqué en dernier mais, en fait, il est essentiel et mérite d'être une réflexion prioritaire.
Tout travail n'a pas vocation à être public
ou à dépasser le cadre d'une équipe. Cependant, les mêmes exigences qui s'appliquent lorsqu'un code est public méritent
de s'appliquer avec un projet personnel. Avant de partager un code avec d'autres, on le partage avec le *"futur moi"*.
Reprendre un code écrit il y a plusieurs semaines est coûteux et mérite d'anticiper en adoptant des bonnes pratiques qui
rendront quasi-indolore la ré-appropriation du code.
L'intégration d'un projet avec `git` fiabilise grandement le processus d'écriture du code mais aussi, grâce aux
outils d'intégration continue, la production de contenu (par exemple des visualisations html ou des rapports
finaux écrits avec markdown). Il est recommandé d'immédiatement connecter un projet à `git`, même avec un
dépôt qui aura vocation à être personnel. Les instructions d'utilisation de `git` sont détaillées [ici](/course/git).