Licence CC BY-SA

Découper son code en modules

Factoriser le code

À force de factoriser notre code, on peut facilement se retrouver avec un script Python contenant de nombreuses fonctions. Des fonctions qui ne sont pas toujours liées les unes aux autres car agissent sur des concepts différents.

Pour aller plus loin dans la factorisation, il faudrait alors regrouper nos fonctions selon les liens qu’elles entretiennent, pour former des unités logiques. Par exemple les fonctions liées à l’affichage d’un côté et celles concernant les calculs de l’autre.

Ces unités logiques portent un nom en Python, on les appelle des modules.

Les modules

Les modules forment un espace de noms et permettent ainsi de regrouper les définitions de fonctions et variables, en les liant à une même entité.

Ils prennent la forme de fichiers Python (un nom et une extension .py) et doivent suivre une nomenclature particulière (la même que pour les noms de variables ou de fonction) : uniquement composés de lettres, de chiffres et d'underscores (_), et ne commençant pas par un chiffre.
Ainsi, un fichier foo.py correspondra à un module foo.

def addition(a, b):
    return a + b

def soustraction(a, b):
    return a - b
foo.py

Pour charger le code d’un module (le code du fichier associé) et avoir accès à ses définitions, il est nécessaire de l’importer. On utilise pour cela le mot-clé import de Python suivi du nom du module (foo dans notre exemple).

>>> import foo

Rien ne se passe. En fait, le code de notre fichier foo.py a bien été exécuté, mais comme il ne fait que définir des fonctions c’est invisible pour nous.

Avec le fichier bar.py suivant :

print('Je suis le module bar')
bar.py

On constate bien que son code est exécuté à l’import.

>>> import bar
Je suis le module bar

Mais revenons-en à notre premier module, foo. C’est bien beau de l’avoir importé, mais on aimerait pouvoir en exécuter les fonctions. Si vous avez tenté d’appeler addition ou soustraction vous avez remarqué que les fonctions n’existaient pas et obtenu une erreur NameError.

C’est parce que les fonctions existent mais dans l’espace de noms (namespace) du module foo. Il faut alors les préfixer de foo. pour y accéder : foo.addition ou foo.soustraction. L’opérateur . signifiant « accède au nom contenu dans ».

>>> foo.addition(3, 5)
8
>>> foo.soustraction(12, 42)
-30

foo en lui-même est un objet d’un nouveau type représentant le module importé.

>>> foo
<module 'foo' from '/.../foo.py'>
La fonction help

Une fonction de Python est très utile pour se documenter sur un module, il s’agit de la fonction help. On appelle la fonction depuis l’interpréteur interactif en lui donnant l’objet-module en argument (il faut donc l’avoir importé au préalable).

>>> help(foo)

Le terminal affiche alors un écran d’aide détaillant le contenu du module. Appuyez sur la touche Q pour quitter cet écran et revenir à l’interpréteur.

Help on module foo:

NAME
    foo

FUNCTIONS
    addition(a, b)
    
    soustraction(a, b)

FILE
    /.../foo.py

(END)

C’est très succinct pour le moment, nous verrons par la suite comment étayer tout cela en ajoutant de la documentation à notre module.

Notez que la fonction help n’est pas utile uniquement pour les modules, elle permet aussi de se documenter sur une fonction ou un type.

>>> help(abs)

>>> help(int)

Vous pouvez faire défiler l’écran à l’aide des flèches haut/bas ou page-up/page-down, ainsi que la touche espace pour naviguer de page en page.

La fonction s’utilise aussi avec une chaîne de caractères en argument pour rechercher de l’aide sur un sujet précis. Par exemple help('keywords') pour obtenir la liste des mots-clés de Python.

Enfin, on peut utiliser la fonction sans argument pour entrer dans une interface d’aide où chaque ligne entrée sera exécutée comme un nouvel appel à la fonction help.

>>> help()

Welcome to Python 3.9's help utility!

[...]

help>

Actuellement nos modules ne sont accessibles que si les fichiers Python sont disposés dans le même répertoire. Nous verrons dans un prochain chapitre comment permettre une arborescence plus complexe à l’aide des paquets.

Imports

Il y a différentes manières d’importer un module, et nous allons ici voir en quoi elles consistent.

Déjà, on a vu le simple import foo qui crée un nouvel objet foo représentant notre module et donc contenant les fonctions du module foo.addition et foo.soustraction.

Il est possible lors de l’import de choisir un autre nom que foo pour l’objet créé (par exemple pour opter pour un nom plus court) à l’aide du mot-clé as suivi du nom souhaité.

>>> import foo as oof
>>> foo
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
NameError: name 'foo' is not defined
>>> oof
<module 'foo' from '/tmp/foo.py'>
>>> oof.addition(1, 2)
3

Une autre syntaxe permet d’importer directement les objets que l’on veut depuis le module, sans créer d’objet pour le module, il s’agit de from ... import ....

>>> from foo import addition
>>> addition
<function addition at 0x7feb439c34c0>
>>> addition(3, 5)
8
>>> soustraction
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
NameError: name 'soustraction' is not defined
>>> foo
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
NameError: name 'foo' is not defined

Comme on le voit, cette syntaxe permet de rendre accessible la fonction addition directement, et uniquement elle.

Il est aussi possible de préciser plusieurs objets à importer en les séparant par des virgules.

>>> from foo import addition, soustraction
>>> addition(3, 5)
8
>>> soustraction(8, 2)
6

Enfin, il peut arriver que vous rencontriez des from foo import *. Cela permet d’importer tous les noms présents dans le module foo (ici addition et soustraction) et de les rendre directement accessibles comme s’ils étaient tous précisés explicitement.

C’est une syntaxe pratique pour des tests rapides dans l’interpréteur mais qui est peu recommandable généralement, parce qu’elle pollue inutilement l’espace de noms courant avec tout le contenu du module (et peut effacer des objets s’il y a un conflit entre les noms). Comme on dit, l’explicite est préférable à l’implicite.

Bibliothèque standard

Python dispose par défaut de nombreux modules déjà prêts à être utilisés. Ils sont regroupés dans ce qu’on appelle la bibliothèque standard (ou stdlib pour standard library), c’est-à-dire les modules disponibles directement après l’installation de Python.

Ces modules apportent des fonctions concernant des domaines particuliers qui ne sont pas incluses dans l’espace de noms global pour ne pas le surcharger. Ainsi on a par exemple un module math pour toutes les fonctions mathématiques usuelles (sqrt, exp, cos, sin) ainsi que les constantes (pi, e).

On importe donc le module comme on le faisait précédemment.

>>> import math

Le module a beau ne pas se trouver dans le répertoire d’exécution, Python arrive à le trouver car il se situe dans un des répertoires d’installation.

Attention d’ailleurs à la priorité des répertoires lors de la recherche d’un module : si nous avions un fichier math.py dans le répertoire d’exécution, c’est lui qui serait importé lors d’un import math plutôt que celui de la bibliothèque standard. Veillez donc toujours à ne pas utiliser de nom existant pour vos propres modules.

Comme annoncé, nous retrouvons dans ce module différentes constantes mathématiques. Il s’agit de nombres flottants, avec donc la précision qui est la leur.

>>> math.pi
3.141592653589793
>>> math.e
2.718281828459045
>>> math.inf
inf

Cette dernière représente l’infini, un nombre flottant supérieur à tout autre.

Question fonctions, il ne sera pas possible de tout énumérer, mais en voici quelques exemples.

>>> math.sqrt(2) # Racine carrée
1.4142135623730951
>>> math.floor(1.5) # Arrondi à l'inférieur
1
>>> math.ceil(1.5) # Arrondi au supérieur
2
>>> math.cos(math.pi) # Cosinus (argument en radians)
-1.0
>>> math.sin(0) # Sinus (argument en radians)
0.0
>>> math.radians(180) # Conversion degrés -> radians
3.141592653589793
>>> math.degrees(math.pi) # Conversion radians -> degrés
180.0
>>> math.exp(1) # Exponentielle
2.718281828459045
>>> math.log(math.e) # Logarithme
1.0
>>> math.gcd(12, 8) # Calcul de PGCD
4

Encore une fois, pensez à help(math) si vous voulez un aperçu complet, ou à consulter la documentation.

Je voudrais enfin attirer votre attention sur la fonction isclose. Cette fonction permet de comparer deux nombres flottants avec une certaine marge d’erreur.

Pour rappel, il y a une certaine imprécision dans le stockage des flottants, et l’opérateur == est donc déconseillé. isclose prend simplement les deux nombres en paramètres et renvoie un booléen indiquant s’ils sont « égaux » (disons très proches) ou non.

>>> 0.1 + 0.2 == 0.3
False
>>> math.isclose(0.1 + 0.2, 0.3)
True
>>> math.isclose(0.2, 0.3)
False

Modules de tests

Revenons-en à notre dernier TP. Il serait intéressant dans un premier temps de séparer les tests du reste du code. Ils n’ont en effet pas de raison particulière d’être placés là.

Dans un fichier tests.py, on va donc placer toutes les fonctions test_*. Mais ce module tests n’aura par défaut pas accès aux fonctions à tester, il va donc nous falloir les importer. Au début du module tests, on placera donc les lignes d’import suivantes.

from game import ...
from game import ...

Aussi, vous vous souvenez de notre fonction pour réunir tous les tests ? Elle n’a maintenant plus lieu d’être, étant donné que nous sommes dans un module à part nous savons que son code ne sera pas exécuté par erreur.

On peut donc placer les appels des fonctions de tests à la toute fin de notre module. Enfin pas tout à fait, on va inclure nos appels dans un bloc conditionnel if __name__ == '__main__':.

if __name__ == '__main__':
    test_...()
    test_...()

Cette ligne obscure permet de savoir si on exécute directement le module ou si on l’importe. En effet, la variable spéciale __name__ contient le nom du module. Dans le cas où le module est exécuté directement par Python (python tests.py), ce nom vaudra '__main__' (il s’agira sinon de 'tests' lors d’un import).

Par cette ligne, nous nous assurons donc que les fonctions de tests ne seront pas exécutées lors d’un import. Ce n’est pas très important pour un module de tests qui n’a pas vocation à être importé, mais ça reste un outil pratique pour qu’un script soit importable. C’est donc toujours une bonne habitude à prendre.

print('A')

if __name__ == '__main__':
    print('B')
foo.by
$ python foo.by
A
B
>>> import foo
A

Nous allons d’ailleurs aussi modifier le code de notre TP pour ajouter une telle condition if __name__ == '__main__': et y placer le code qui ne figure dans aucune fonction. Ça nous évitera d’avoir le code du jeu qui s’exécute lors d’un import game depuis les tests.

monsters = {
    'pythachu': {
        'name': 'Pythachu',
        'attacks': ['tonnerre', 'charge'],
    },
    'pythard': {
        'name': 'Pythard',
        'attacks': ['jet-de-flotte', 'charge'],
    },
    'ponytha': {
        'name': 'Ponytha',
        'attacks': ['brûlure', 'charge'],
    },
}

attacks = {
    'charge': {'damages': 20},
    'tonnerre': {'damages': 50},
    'jet-de-flotte': {'damages': 40},
    'brûlure': {'damages': 40},
}


def get_choice_input(choices, error_message):
    entry = input('> ').lower()
    while entry not in choices:
        print(error_message)
        entry = input('> ').lower()
    return choices[entry]


def get_player(player_id):
    print('Joueur', player_id, 'quel monstre choisissez-vous ?')
    monster = get_choice_input(monsters, 'Monstre invalide')
    pv = int(input('Quel est son nombre de PV ? '))
    return {'id': player_id, 'monster': monster, 'pv': pv}


def get_players():
    print('Monstres disponibles :')
    for monster in monsters.values():
        print('-', monster['name'])
    return get_player(1), get_player(2)


def apply_attack(attack, opponent):
    opponent['pv'] -= attack['damages']
    if opponent['pv'] < 0:
        opponent['pv'] = 0


def game_turn(player, opponent):
    # Si le joueur est KO, il n'attaque pas
    if player['pv'] <= 0:
        return

    print('Joueur', player['id'], 'quelle attaque utilisez-vous ?')
    for name in player['monster']['attacks']:
        print('-', name.capitalize(), -attacks[name]['damages'], 'PV')

    attack = get_choice_input(attacks, 'Attaque invalide')
    apply_attack(attack, opponent)

    print(
        player['monster']['name'],
        'attaque',
        opponent['monster']['name'],
        'qui perd',
        attack['damages'],
        'PV, il lui en reste',
        opponent['pv'],
    )


def get_winner(player1, player2):
    if player1['pv'] > player2['pv']:
        return player1
    else:
        return player2


if __name__ == '__main__':
    player1, player2 = get_players()

    print()
    print(player1['monster']['name'], 'affronte', player2['monster']['name'])
    print()

    while player1['pv'] > 0 and player2['pv'] > 0:
        game_turn(player1, player2)
        game_turn(player2, player1)

    winner = get_winner(player1, player2)
    print('Le joueur', winner['id'], 'remporte le combat avec', winner['monster']['name'])