Licence CC BY-SA

Débogage

Les bugs sont monnaie courante en programmation. Une erreur d’inattention est vite arrivée, et hop, un bug se glisse dans le programme.

Ils peuvent prendre de multiples formes : parfois ils feront planter purement et simplement l’application, d’autres mèneront à des traitements incohérents voire à des failles de sécurité, d’autres encore pourront être invisibles.
Mais quand un bug est repéré, il est encore loin d’être identifié : il faut trouver quelle fonction n’a pas eu un traitement correct et sur quelles données le problème survient.
Il s’agit alors de tenter de reproduire l’erreur dans différentes conditions pour l’identifier, pour enfin être en mesure de la corriger. C’est ce que l’on appelle le débogage !

Je ne peux que vous conseiller d’être attentif et de bien tester vos codes pour les éviter au maximum, malheureusement ce n’est pas toujours suffisant.
Aussi, pour ne pas vous retrouver désemparé quand un bug survient (qu’il soit décelé lorsque l’application tourne ou lors de tests), voici un petit guide pour apprendre à trouver l’origine du bug et la corriger.

Programme de référence

Nous prendrons pour exemple au long de ce chapitre le programme suivant de combat entre monstres et qui présente plusieurs bugs :

import json


def input_choice(prompt, choices):
    value = None
    prompt += '(' + '/'.join(choices) + ') '
    while value not in choices:
        print('Valeur invalide')
        value = input(prompt)
    return value


def input_int(prompt):
    while True:
        try:
            return int(input(prompt))
        except ValueError:
            print('Nombre invalide')


with open('data.json') as f:
    data = json.load(f)
    attacks = data['attacks']
    monsters = data['monsters']


def input_player():
    name = input_choice('Monstre: ', monsters)
    monster = monsters[name]
    pv = input_int('PV du monstre: ')
    return {'monster': monster, 'pv': pv}


def input_attack(player):
    monster = player['monster']
    name = input_choice(f"Attaque de {monster['name']}: ", monster['attacks'])
    return attacks[name]


def apply_attack(player1, player2, attack):
    print(player1['monster']['name'], 'utilise', attack['name'], ':',
          player2['monster']['name'], 'perd', attack['damage'], 'PV')
    player1['pv'] -= attack['damage']


if __name__ == '__main__':
    player1 = input_player()
    player2 = input_player()
    print(player1['monster']['name'], 'vs', player2['monster']['name'])

    while player1['pv'] and player2['pv'] > 0:
        print(player1['monster']['name'], player1['pv'], 'PV')
        print(player2['monster']['name'], player2['pv'], 'PV')

        attack = input_attack(player1)
        apply_attack(player1, player2, attack)

        if player2['pv'] > 0:
            attack = input_attack(player2)
            apply_attack(player1, player2, attack)

    if player1['pv'] > 0:
        print(player1['monster']['name'], 'gagne')
    else:
        print(player2['monster']['name'], 'gagne')
battle.py

Il s’accompagne du fichier de données ci-dessous.

{
    "attacks": {
        "charge": {"name": "Charge", "damage": 20},
        "tonnerre": {"name": "Tonnerre", "damage": 50},
        "jet-de-flotte": {"name": "Jet de flotte", "damages": 50},
        "jet-de-flamme": {"name": "Jet de flamme", "damage": 60}
    },
    "monsters": {
        "pythachu": {
            "name": "Pythachu",
            "attacks": ["charge", "tonnerre", "eclair"]
        },
        "pythard": {
            "name": "Pythard",
            "attacks": ["charge", "jet-de-flotte"]
        },
        "ponytha": {
            "name": "Ponytha",
            "attacks": ["charge", "jet-de-flamme"]
        }
    }
}
data.json

Ces deux fichiers sont à retrouver sur le Gist suivant : https://gist.github.com/entwanne/630e73d59696b0bab2899b0db1ea201b.

Vous pouvez dores et déjà tenter d’exécuter le programme, et constater que celui-ci a un comportement incohérent voire lève une exception.

% python battle.py
Valeur invalide
Monstre: (pythachu/pythard/ponytha) pythachu
PV du monstre: 10
Valeur invalide
Monstre: (pythachu/pythard/ponytha) pythard
PV du monstre: 10
Pythachu vs Pythard
Pythachu 10 PV
Pythard 10 PV
Valeur invalide
Attaque de Pythachu: (charge/tonnerre/eclair) tonnerre
Pythachu utilise Tonnerre : Pythard perd 50 PV
Valeur invalide
Attaque de Pythard: (charge/jet-de-flotte) charge
Pythachu utilise Charge : Pythard perd 20 PV
Pythachu -60 PV
Pythard 10 PV
Valeur invalide
Attaque de Pythachu: (charge/tonnerre/eclair) eclair
Traceback (most recent call last):
  File "battle.py", line 55, in <module>
    attack = input_attack(player1)
  File "battle.py", line 37, in input_attack
    return attacks[name]
KeyError: 'eclair'

Introspection

Informations sur la valeur

Avant d’en venir proprement au débogage de notre programme, faisons un tour des outils qui sont à notre disposition pour l’examiner. Il s’agit de fonctions proposées par Python pour inspecter différentes valeurs du programme.
On parle d’outils d’introspection car ils permettent au programme de s’examiner lui-même.

La première information, toute bête, c’est la valeur en elle-même, ou plutôt sa représentation. C’est ce que l’on obtient quand on tape juste le nom de la variable dans l’interpréteur interactif par exemple.

>>> value = 'toto'
>>> value
'toto'

Cette représentation est fournie par la fonction repr, qui renvoie donc une chaîne de caractères représentant la valeur. Souvent, cette représentation va être la manière dont il est possible de définir cette valeur en Python, c’est pour ça que des guillemets apparaissent autour des chaînes de caractères.
Elle peut tout à fait être appelée depuis un programme pour afficher (avec print) l’état d’une variable.

>>> print(repr(value))
'toto'
>>> print(repr(10))
10
>>> print(repr([1, 2, 'abc']))
[1, 2, 'abc']

Grâce à cette simple information, on identifie déjà à quoi correspond notre valeur.

Mais une autre information pertinente que l’on connaît aussi sur notre valeur, c’est son type, renvoyé par la fonction type.
Cela nous permet, pour peu que l’on connaisse le type, de s’avoir quelles opérations et méthodes sont applicables à notre objet.

>>> type(value)
<class 'str'>
>>> type([])
<class 'list'>

Et si on ne connaît pas ce type, on peut toujours se documenter dessus. Soit en consultant la documentation en ligne, soit à l’aide de la fonction help que j’ai présentée plus tôt.

On sait que cette fonction peut prendre un type en argument, il est donc tout à fait possible de lui donner directement le retour de la fonction type.

>>> help(type(value))
Help on class str in module builtins:

class str(object)
 |  str(object='') -> str
 |  str(bytes_or_buffer[, encoding[, errors]]) -> str
 |  [...]
>>> help(type([]))
Help on class list in module builtins:

class list(object)
 |  list(iterable=(), /)
 |  [...]

Mais plus simple encore : on peut directement donner à help la valeur sur laquelle on a besoin d’aide, la fonction s’occupera de renvoyer la documentation du type correspondant.

>>> help([])
Help on list object:

class list(object)
 |  list(iterable=(), /)
 | [...]

Attention, cela fonctionne pour toutes les valeurs sauf les chaînes de caractères.

En effet, la fonction help interprète les chaînes comme un sujet d’aide en particulier : help('NUMBERS') affichera de l’aide sur les nombres en Python et pas sur le type str.

>>> help('NUMBERS')
Numeric literals
****************

There are three types of numeric literals: integers, floating point
numbers, and imaginary numbers.
[...]
>>> help('toto')
No Python documentation found for 'toto'.
Use help() to get the interactive help utility.
Use help(str) for help on the str class.

Par ailleurs, il est possible de connaître tous les sujets sur lesquels help est capable de fournir de l’aide avec l’appel help('topics').

Contenu de la valeur

On a maintenant des informations globales sur notre valeur et l’on sait comment la manipuler, mais il peut-être utile de l’examiner encore plus loin pour savoir ce qu’elle contient. C’est l’objectif de la fonction dir qui va permettre de lister des méthodes et attributs d’un objet.

Un appel à dir permet donc de savoir de façon plus concise que help ce que contient un objet, en ne nous renvoyant que les noms des méthodes/attributs.

>>> dir('toto')
['__add__', '__class__', '__contains__', ..., 'strip', 'swapcase', 'title', 'translate', 'upper', 'zfill']

Les méthodes de type __xxx__ sont des méthodes spéciales et ne nous intéressent pas ici, elles sont abordées dans le cours sur la programmation orientée objet en Python.

Mais nous voyons ensuite les autres méthodes de l’objet telles que nous les connaissons déjà.

>>> 'toto'.title()
'Toto'
>>> 'toto'.upper()
'TOTO'

Pour les objets plus complexes (qui possèdent des attributs), la fonction vars permet de récupérer le dictionnaire de ces attributs. Par exemple on peut obtenir ainsi tout le contenu d’un module.

>>> vars(math)
{'__name__': 'math', ..., 'pi': 3.141592653589793, 'e': 2.718281828459045, 'tau': 6.283185307179586, 'inf': inf, 'nan': nan}
>>> vars(math)['pi']
3.141592653589793

À part les modules, on manipule asssez peu d’objets avec des attributs dans les built-ins ou la bibliothèque standard, mais ils sont assez courants dans les bibliothèques tierces. On a tout de même la fonction open qui nous renvoie un tel objet par exemple.

>>> vars(open('hello.txt'))
{'mode': 'r'}

Un appel à vars sur un objet sans dictionnaire d’attributs lèvera une exception TypeError.

>>> vars('toto')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: vars() argument must have __dict__ attribute

Vous avez peut-être déjà rencontré la notation obj.__dict__ pour accéder au dictionnaire d’attributs d’un objet, sachez qu’elle est équivalente à vars(obj).

Notez enfin que vars peut s’utiliser sans argument, elle renverra alors le dictionnaire des variables définies dans l’espace de nom courant, ce qui peut aussi être utile au débogage.

>>> vars()
{'__name__': '__main__', '__doc__': None, '__package__': None, ..., 'value': 'toto'}

Déboguer « à la main »

Maintenant que nous sommes en mesure de nous dépatouiller pour inspecter les valeurs, il est temps de comprendre comment elles évoluent au cours du programme pour mener jusqu’au bug.

Suivre et comprendre les exceptions

Une manière courante de procéder au débogage, bien qu’elle ne soit pas des plus efficaces, est de placer des appels à print à plusieurs endroits du programme afin d’afficher des informations de debug.

Par exemple si l’on reprend le programme présenté en introduction, on remarque que le premier problème que l’on rencontre est que le programme affiche sans cesse « Valeur invalide » avant même que l’on ait entré quelque chose.
Pour comprendre ce qui se passe, on peut donc ajouter un print pour afficher ce que l’on connaît, la valeur de value et celle de choices.

def input_choice(prompt, choices):
    value = None
    prompt += '(' + '/'.join(choices) + ') '
    while value not in choices:
        print('debug', repr(value), repr(choices))
        print('Valeur invalide')
        value = input(prompt)
    return value
battle.py
% python battle.py
debug None {'pythachu': {'name': 'Pythachu', 'attacks': ['charge', 'tonnerre', 'eclair']}, 'pythard': {'name': 'Pythard', 'attacks': ['charge', 'jet-de-flotte']}, 'ponytha': {'name': 'Ponytha', 'attacks': ['charge', 'jet-de-flamme']}}
Valeur invalide
Monstre: (pythachu/pythard/ponytha)

On peut déjà s’interroger sur le fait que choices soit un dictionnaire mais c’est normal : on lui passe directement l’objet monsters de notre JSON, et comme le in sur un dictionnaire fait une recherche sur les clés ça ne pose pas de problème.

Non le problème vient de ce None, la valeur initiale de notre variable, qui n’est effectivement pas dans les choix. On peut donc conditionner l’affichage du message d’erreur au fait que value ne soit pas None pour résoudre le premier problème.

    while value not in choices:
        if value is not None:
            print('Valeur invalide')
        value = input(prompt)
battle.py

Une autre erreur que l’on remarque, c’est le plantage à la fin après avoir sélectionné l’attaque « eclair ». C’est une erreur qui se produit systématiquement dans le cas où l’on choisit cette attaque, et qui n’arrive pas autrement.
On peut donc facilement la reproduire pour l’analyser.

Là encore, on peut afficher quelques informations au moment où l’on manipule cette information pour comprendre ce qu’il se passe.
La trace de l’erreur nous dit déjà que celle-ci se produit dans la fonction input_attack, c’est donc à cette fonction que nous allons nous intéresser en premier.

De la même manière que précdemment, on peut vérifier les différentes valeurs que l’on manipule pour s’assurer qu’elles correspondent à ce que l’on attend, notammant player, monster, name et attacks.

Pour nous aider à afficher nos valeurs, on peut s’appuyer sur le module pprint.
Ce module fournit une fonction pprint (pour pretty print, soit affichage joli) qui donne un rendu plus aéré que print.

def input_attack(player):
    from pprint import pprint
    print('player')
    pprint(player)
    monster = player['monster']
    print('monster')
    pprint(monster)
    name = input_choice(f"Attaque de {monster['name']}: ", monster['attacks'])
    print('name', repr(name))
    print('attacks')
    pprint(attacks)
    return attacks[name]
battle.py

On obtient alors le résultat suivant en relançant le programme.

player
{'monster': {'attacks': ['charge', 'tonnerre', 'eclair'], 'name': 'Pythachu'},
 'pv': 10}
monster
{'attacks': ['charge', 'tonnerre', 'eclair'], 'name': 'Pythachu'}
Attaque de Pythachu: (charge/tonnerre/eclair) eclair
name 'eclair'
attacks
{'charge': {'damage': 20, 'name': 'Charge'},
 'jet-de-flamme': {'damage': 60, 'name': 'Jet de flamme'},
 'jet-de-flotte': {'damages': 50, 'name': 'Jet de flotte'},
 'tonnerre': {'damage': 50, 'name': 'Tonnerre'}}
Traceback (most recent call last):
  File "battle.py", line 61, in <module>
    attack1 = input_attack(player1)
  File "battle.py", line 50, in input_attack
    return attacks[name]
KeyError: 'eclair'

Et là on constate que l’attaque eclair proposée pour le monstre n’existe pas dans le dictionnaire des attaques car elle n’est pas encore implémentée : on a donc simplement renseigné une mauvaise valeur dans notre JSON.
Il nous suffit de corriger ce dernier en supprimant eclair pour résoudre l’erreur, on peut alors retirer tous les print de debug de notre programme.

	"pythachu": {
	    "name": "Pythachu",
	    "attacks": ["charge", "tonnerre"]
	},
data.json
S’appuyer sur des tests unitaires

Mais on le voit, utiliser print pour déboguer peut être assez fastidieux. Heureusement un autre outil peut nous venir en aide : un ensemble de tests unitaires.

Je vous en parlais d’ailleurs plus tôt, les tests unitaires nous permettent de déceler des bugs dans nos fonctions en vérifiant que le retour correspond à ce qui est attendu.
C’est pourquoi je ne peux que vous reconseiller de découper vos programmes en fonctions afin de plus facilement pouvoir les déboguer. L’idéal serait aussi de disposer les fonctions en différents modules pour pouvoir tester unitairement chacun des modules.

Mais revenons-en à notre code. Il possède peu de fonctions que nous pouvons tester en l’état car beaucoup reposent sur des entrées utilisateurs que nous ne savons pas simuler1.
Il n’y a en fait que la fonction apply_attack qui est déterministe : elle doit toujours faire la même chose quand on lui renseigne les mêmes arguments.

Pour la tester, il faut alors que l’on donne à la fonction des données dans le format qu’elle attend (deux joueurs et une attaque) puis que l’on vérifie son retour. Ici la fonction ne renvoie rien mais elle peut altérer ses paramètres, c’est donc sur ceux-ci que nous ferons nos assertions afin de vérifier que les points de vie sont bien mis à jour (en l’occurence que les dégâts sont retirés du second monstre).

Les arguments n’ont pas besoin d’être exhaustifs mais simplement de contenir les informations qui seront utilisées par la fonction. Ici les joueurs n’ont besoin de n’avoir par exemple qu’un nombre de points de vie et un nom de monstre, et l’attaque seulement un nom et un nombre de dégâts.

from battle import apply_attack


def test_apply_attack():
    p1 = {
        'monster': {'name': 'Pythachu'},
        'pv': 50,
    }
    p2 = {
        'monster': {'name': 'Ponytha'},
        'pv': 100,
    }
    attack = {'name': 'électrocution', 'damage': 30}

    apply_attack(p1, p2, attack)
    assert p2['pv'] == 70


if __name__ == '__main__':
    test_apply_attack()
test_battle.py

Et là… c’est le drame !

% python test_battle.py
Pythachu utilise électrocution : Ponytha perd 30 PV
Traceback (most recent call last):
  File "test_battle.py", line 20, in <module>
    test_apply_attack()
  File "test_battle.py", line 16, in test_apply_attack
    assert p2['pv'] == 70
AssertionError

Notre assertion échoue parce que les PV du second joueur ne valent pas 70 comme attendu.
Sans plus d’outils à notre disposition pour le moment, on peut associer à nos tests un print comme précédemment afin d’obtenir plus d’informations.

    apply_attack(p1, p2, attack)
    print('résultat', p2['pv'])
    assert p2['pv'] == 70
test_battle.py

À l’exécution du test on comprend mieux le problème : les PV du deuxième joueur n’ont pas bougé.

% python test_battle.py
résultat 100
Traceback (most recent call last):
  File "test_battle.py", line 21, in <module>
    test_apply_attack()
  File "test_battle.py", line 17, in test_apply_attack
    assert p2['pv'] == 70
AssertionError

On peut alors se demander où sont retirés les PV, et logiquement ajouter une assertion sur le premier joueur.

    apply_attack(p1, p2, attack)
    print('résultat', p1['pv'], p2['pv'])
    assert p1['pv'] == 50
    assert p2['pv'] == 70
test_battle.py
% python test_battle.py
Pythachu utilise électrocution : Ponytha perd 30 PV
résultat 20 100
Traceback (most recent call last):
  File "test_battle.py", line 22, in <module>
    test_apply_attack()
  File "test_battle.py", line 17, in test_apply_attack
    assert p1['pv'] == 50
AssertionError

Cette fois-ci c’est clair : les dégâts sont appliqués au premier joueur plutôt qu’au deuxième. Et à regarder notre fonction apply_attack, c’est vrai que les noms des paramètres player1 et player2 prêtent à confusion.

Nous leur préférerons alors respectivement les noms plus descriptifs de attacker (attaquant) et target (cible).

def apply_attack(attacker, target, attack):
    print(attacker['monster']['name'], 'utilise', attack['name'], ':',
          target['monster']['name'], 'perd', attack['damage'], 'PV')
    target['pv'] -= attack['damage']
battle.py

On peut alors exécuter à nouveau nos tests et constater que tout se passe bien.

% python test_battle.py
Pythachu utilise électrocution : Ponytha perd 30 PV
résultat 50 70

  1. Nous apprendrons à le faire par la suite à l’aide de mocks intégré aux frameworks de tests, mais ce n’est pas l’objet de ce chapitre.

Utilisation d'un débogueur (Pdb)

Vous avez peut-être remarqué un autre bug dans notre programme : le jeu ne s’arrête pas quand le premier joueur est censé être KO.

% python battle.py
Monstre: (pythachu/pythard/ponytha) pythachu
PV du monstre: 100
Monstre: (pythachu/pythard/ponytha) ponytha
PV du monstre: 120
Pythachu vs Ponytha
Pythachu 100 PV
Ponytha 120 PV
Attaque de Pythachu: (charge/tonnerre) charge
Pythachu utilise Charge : Ponytha perd 20 PV
Attaque de Ponytha: (charge/jet-de-flamme) jet-de-flamme
Ponytha utilise Jet de flamme : Pythachu perd 60 PV
Pythachu 40 PV
Ponytha 100 PV
Attaque de Pythachu: (charge/tonnerre) charge
Pythachu utilise Charge : Ponytha perd 20 PV
Attaque de Ponytha: (charge/jet-de-flamme) jet-de-flamme
Ponytha utilise Jet de flamme : Pythachu perd 60 PV
Pythachu -20 PV
Ponytha 80 PV
Attaque de Pythachu: (charge/tonnerre) charge
Pythachu utilise Charge : Ponytha perd 20 PV
Attaque de Ponytha: (charge/jet-de-flamme) jet-de-flamme
Ponytha utilise Jet de flamme : Pythachu perd 60 PV
Pythachu -80 PV
Ponytha 60 PV
[...]

Pour déceler son origine, nous allons cette fois-ci faire appel à un débogueur, j’ai nommé Pdb (pour Python Debugger).

Lancer un programme pas-à-pas avec Pdb

Pour commencer, on va lancer l’exécution de notre programme pas-à-pas à l’aide de Pdb, en l’exécutant via python -m pdb battle.py.

% python -m pdb battle.py
> /.../battle.py(1)<module>()
-> import json
(Pdb) 

Pdb nous indique quel fichier est exécuté (battle.py) et quelle ligne (import json). Puis on se retrouve face à un prompt qui attend nos ordres pour continuer l’exécution. Ce prompt comprend plusieurs commandes que nous allons voir ici.

Premièrement nous pouvons lui demander un peu de contexte. Cela se fait avec la commande list (ou simplement l) qui va afficher les lignes autour de nous.

(Pdb) l
  1  ->	import json
  2  	
  3  	
  4  	def input_choice(prompt, choices):
  5  	    value = None
  6  	    prompt += '(' + '/'.join(choices) + ') '
  7  	    while value not in choices:
  8  	        if value is not None:
  9  	            print('Valeur invalide')
 10  	        value = input(prompt)
 11  	    return value

On peut continuer l’exécution jusqu’à l’instruction suivante à l’aide de la commande next (ou n).

(Pdb) n
> /.../battle.py(4)<module>()
-> def input_choice(prompt, choices):
(Pdb) n
> /.../battle.py(14)<module>()
-> def input_int(prompt):
(Pdb) n
> /../battle.py(22)<module>()
-> with open('data.json') as f:

Mais on se rend vite compte que c’est un peu long à avancer. On pourrait plutôt se rendre directement là où ça nous intéresse : au début de la boucle de jeu.
Pour cela il existe la commande until (ou unt) qui prend en argument un numéro de ligne, le programme continuera alors son exécution jusqu’à cette ligne.

Mais comment connaître le numéro de ligne que l’on souhaite atteindre ? Si vous avez le fichier ouvert en parallèle, vous pouvez voir que le while se trouve ligne 52. Sinon, un appel à la commande longlist (ou ll) permet d’afficher toutes les lignes du fichier.

(Pdb) ll
[...]
 47  	if __name__ == '__main__':
 48  	    player1 = input_player()
 49  	    player2 = input_player()
 50  	    print(player1['monster']['name'], 'vs', player2['monster']['name'])
 51  	
 52  	    while player1['pv'] and player2['pv'] > 0:
 53  	        print(player1['monster']['name'], player1['pv'], 'PV')
 54  	        print(player2['monster']['name'], player2['pv'], 'PV')
[...]

On va maintenant pouvoir entrer la commande until 52 pour avancer jusqu’à notre boucle. Là notre programme reprend son exécution normale sur les lignes intermédiaires, et nous demande alors d’entrer les informations sur les joueurs.

(Pdb) until 52
Monstre: (pythachu/pythard/ponytha) pythachu
PV du monstre: 100
Monstre: (pythachu/pythard/ponytha) ponytha
PV du monstre: 120
Pythachu vs Ponytha
> /.../battle.py(52)<module>()
-> while player1['pv'] and player2['pv'] > 0:
(Pdb)

Nous entrons maintenant dans la boucle et nous pouvons reprendre l’exécution pas-à-pas à l’aide de next.

Petite astuce : il est possible d’utiliser les flèches haut/bas de votre clavier pour naviguer dans l’historique des commandes entrées à Pdb.

> /.../battle.py(52)<module>()
-> while player1['pv'] and player2['pv'] > 0:
(Pdb) next
> /.../battle.py(53)<module>()
-> print(player1['monster']['name'], player1['pv'], 'PV')
(Pdb) next
Pythachu 100 PV
> /.../battle.py(54)<module>()
-> print(player2['monster']['name'], player2['pv'], 'PV')
(Pdb) next
Ponytha 120 PV
> /.../battle.py(56)<module>()
-> attack = input_attack(player1)
(Pdb) next
Attaque de Pythachu: (charge/tonnerre) charge
> /.../battle.py(57)<module>()
-> apply_attack(player1, player2, attack)
(Pdb) next
Pythachu utilise Charge : Ponytha perd 20 PV
> /.../battle.py(59)<module>()
-> if player2['pv'] > 0:
(Pdb) next
> /.../battle.py(60)<module>()
-> attack = input_attack(player2)
(Pdb) next
Attaque de Ponytha: (charge/jet-de-flamme) jet-de-flamme
> /.../battle.py(61)<module>()
-> apply_attack(player2, player1, attack)
(Pdb) next
Ponytha utilise Jet de flamme : Pythachu perd 60 PV
> /.../battle.py(52)<module>()
-> while player1['pv'] and player2['pv'] > 0:
(Pdb)

On a fait un tour de boucle et on ne voit rien d’anormal pour le moment. On peut afficher à l’aide de la commande pp (pretty-print) les valeurs de certaines variables pour vérifier que tout va bien.

(Pdb) pp player1
{'monster': {'attacks': ['charge', 'tonnerre'], 'name': 'Pythachu'}, 'pv': 40}
(Pdb) pp player2
{'monster': {'attacks': ['charge', 'jet-de-flamme'], 'name': 'Ponytha'},
 'pv': 100}

On pourrait alors recommencer les mêmes opérations pour effectuer un tour de boucle supplémentaire, mais comme nous l’avons vu c’est un peu long.
Nous allons donc utiliser une autre fonctionnalité offerte par Pdb : les points d’arrêts (ou breakpoints). Il s’agit de points dans le programme qui provoqueront sa mise en pause chaque fois qu’ils seront atteints.

C’est notre boucle qui nous intéresse, et nous posons donc un point d’arrêt sur la ligne 52 à l’aide de la commande break 52.

(Pdb) break 52
Breakpoint 1 at /.../battle.py:52

Pdb nous informe bien qu’un breakpoint a été posé. La commande break utilisée sans argument nous permet de lister tous les breakpoints.

(Pdb) break
Num Type         Disp Enb   Where
1   breakpoint   keep yes   at /.../battle.py:52

Nous pouvons maintenant demander à Pdb de continuer normalement l’exécution du programme à l’aide de la commande continue (abrégée en cont ou c). Le programme reprendra alors son cours normal jusqu’à la prochaine interruption (notre point d’arrêt).

(Pdb) c
Pythachu 40 PV
Ponytha 100 PV
Attaque de Pythachu: (charge/tonnerre) charge
Pythachu utilise Charge : Ponytha perd 20 PV
Attaque de Ponytha: (charge/jet-de-flamme) jet-de-flamme
Ponytha utilise Jet de flamme : Pythachu perd 60 PV
> /.../battle.py(52)<module>()
-> while player1['pv'] and player2['pv'] > 0:
(Pdb)

Nous sommes bien revenus à la condition de notre boucle, et cette fois Pythachu est censé être KO, donc nous devrions en sortir. On va s’en assurer à l’aide d’un simple next : nous verrons tout de suite où nous emmène la prochaine instruction.

(Pdb) next
> /.../battle.py(53)<module>()
-> print(player1['monster']['name'], player1['pv'], 'PV')

Et là c’est le drame. La boucle ne s’est pas arrêtée comme ça aurait dû être le cas. On peut jeter un œil à la valeur de player1 pour essayer de comprendre.

(Pdb) pp player1
{'monster': {'attacks': ['charge', 'tonnerre'], 'name': 'Pythachu'}, 'pv': -20}

Les PV sont négatifs, la condition de boucle aurait alors dû être fausse. pp n’accepte pas seulement une variable en argument mais n’importe quelle expression. On peut alors exécuter pp sur la condition de notre boucle pour voir ce qui cloche.

(Pdb) pp player1['pv'] and player2['pv'] > 0
True

Bien que les points de vie du joueur 1 soient négatifs, cette condition est tout de même considérée comme vraie. La source de notre bug se trouve donc ici.

Et effectivement, si nous analysons notre condition de plus près, nous pouvons voir qu’elle est équivalente à (player1['pv']) and (player2['pv'] > 0).
On ne teste donc jamais si les points de vie du premier joueur sont positifs, mais seulement s’ils ne sont pas nuls.

Il ne nous reste plus qu’à corriger notre programme dans l’éditeur de texte pour utiliser la condition player1['pv'] > 0 and player2['pv'] > 0 et à recommencer le débogage une fois notre fichier enregistré à l’aide de la commande restart.

Le programme repart alors de zéro depuis la première ligne, on peut entrer la commande continue pour continuer l’exécution jusqu’au point d’arrêt.
On refait deux tours d’attaque comme précédemment pour revenir sur la condition de notre boucle qui doit maintenant être fausse.

Ponytha utilise Jet de flamme : Pythachu perd 60 PV
> /.../battle.py(52)<module>()
-> while player1['pv'] > 0 and player2['pv'] > 0:
(Pdb) pp player1
{'monster': {'attacks': ['charge', 'tonnerre'], 'name': 'Pythachu'}, 'pv': -20}
(Pdb) pp player1['pv'] > 0 and player2['pv'] > 0
False

On peut alors exécuter next et vérifier que l’on sort bien de la boucle.

(Pdb) next
> /.../battle.py(63)<module>()
-> if player1['pv'] > 0:

Le bug en question est donc corrigé !

Invoquer Pdb depuis le programme

Mais ce mode d’utilisation de Pdb n’est pas le plus intuitif. Généralement on a déjà constaté le bug en dehors du débogueur et on sait donc à peu près à quel endroit il va se produire. On pourrait alors directement poser notre point d’arrêt dans le programme pour invoquer Pdb.

Cela est rendu possible à l’aide de la fonction breakpoint de Python, appelable depuis n’importe où dans le programme. On lancera alors notre jeu normalement, et la fonction aura pour effet de le mettre en pause et de nous amener sur une console Pdb.

Avant Python 3.7, la fonction breakpoint n’existait pas. Il fallait alors écrire import pdb; pdb.set_trace() dans le code pour placer un point d’arrêt.

Vous avez peut-être pu constater un autre bug dans le jeu en utilisant le monstre Pythard, celui-ci se produit quand on essaie d’utiliser l’attaque jet-de-flotte.

% python battle.py
Monstre: (pythachu/pythard/ponytha) pythard
PV du monstre: 100
Monstre: (pythachu/pythard/ponytha) pythachu
PV du monstre: 100
Pythard vs Pythachu
Pythard 100 PV
Pythachu 100 PV
Attaque de Pythard: (charge/jet-de-flotte) jet-de-flotte
Traceback (most recent call last):
  File "/.../battle.py", line 57, in <module>
    apply_attack(player1, player2, attack)
  File "/.../battle.py", line 43, in apply_attack
    target['monster']['name'], 'perd', attack['damage'], 'PV')
KeyError: 'damage'

On constate donc que le bug survient dans la fonction apply_attack et l’on va pouvoir placer un point d’arrêt directement dans cette fonction.

def apply_attack(attacker, target, attack):
    breakpoint()
    print(attacker['monster']['name'], 'utilise', attack['name'], ':',
          target['monster']['name'], 'perd', attack['damage'], 'PV')
    target['pv'] -= attack['damage']

Il nous suffit ensuite de relancer normalement notre programme. Et après avoir saisi les informations de jeu, on se retrouve interrompu par notre breakpoint.

% python battle.py
Monstre: (pythachu/pythard/ponytha) pythard
PV du monstre: 100
Monstre: (pythachu/pythard/ponytha) pythachu
PV du monstre: 100
Pythard vs Pythachu
Pythard 100 PV
Pythachu 100 PV
Attaque de Pythard: (charge/jet-de-flotte) jet-de-flotte
> /.../battle.py(43)apply_attack()
-> print(attacker['monster']['name'], 'utilise', attack['name'], ':',
(Pdb)

Nous pouvons alors reprendre notre attirail de commandes et essayer de comprendre le problème en inspectant les différentes valeurs.

(Pdb) pp attacker
{'monster': {'attacks': ['charge', 'jet-de-flotte'], 'name': 'Pythard'},
 'pv': 100}
(Pdb) pp attack
{'damages': 50, 'name': 'Jet de flotte'}
(Pdb) pp target
{'monster': {'attacks': ['charge', 'tonnerre'], 'name': 'Pythachu'}, 'pv': 100}

Rien qui ne saute forcément aux yeux pour l’instant, donc on continue l’exécution avec next.

(Pdb) next
> /.../battle.py(44)apply_attack()
-> target['monster']['name'], 'perd', attack['damage'], 'PV')
(Pdb) next
KeyError: 'damage'
> /.../battle.py(44)apply_attack()
-> target['monster']['name'], 'perd', attack['damage'], 'PV')

Là on voit bien l’erreur KeyError qui se produit et la ligne fautive est pointée. On se rend alors compte qu’on a utilisé dans notre JSON la clé 'damages' plutôt que 'damage' pour l’attaque jet-de-flotte. Encore une fois l’erreur venait donc de nos données.
On peut directement quitter le programme pour aller corriger notre fichier data.json.

Par acquit de conscience, on le relance ensuite dans les mêmes conditions pour vérifier que tout se passe bien.

Attaque de Pythard: (charge/jet-de-flotte) jet-de-flotte
> /.../battle.py(43)apply_attack()
-> print(attacker['monster']['name'], 'utilise', attack['name'], ':',
(Pdb) 

On est à nouveau interrompu par notre breakpoint et on avance alors pas-à-pas pour nous assurer du bon fonctionnement.

(Pdb) next
> /.../battle.py(44)apply_attack()
-> target['monster']['name'], 'perd', attack['damage'], 'PV')
(Pdb) next
> /.../battle.py(43)apply_attack()
-> print(attacker['monster']['name'], 'utilise', attack['name'], ':',
(Pdb) continue
Pythard utilise Jet de flotte : Pythachu perd 50 PV
Attaque de Pythachu: (charge/tonnerre)

Cette fois-ci c’est bon, le problème semble bien résolu. Mais le point d’arrêt reste toujours présent et continuera de nous interrompre. Il nous suffira de retirer la ligne breakpoint() dans le programme pour le supprimer.

La fonction breakpoint peut aussi directement s’utiliser depuis des fonctions de test, et ainsi être invoquée lors de l’exécution des tests.

Pour plus d’informations sur les commandes comprises par Pdb, je vous invite à consulter sa page de documentation.

Déboguer avec votre IDE

Jongler entre l’éditeur de texte d’un côté et le débogueur de l’autre n’est pas toujours évident. Certains éditeurs permettent d’intégrer directement le débogueur dans leur interface. C’est le cas de PyCharm par exemple.

Il est ainsi possible de cliquer à gauche des lignes de code pour placer des points d’arrêt. Ils sont illustrés par un rond rouge dans la marge. Cliquer sur un tel rond permet de supprimer un point d’arrêt déjà posé.

Point d'arrêt dans PyCharm.
Point d’arrêt dans PyCharm.

Il faut ensuite exécuter le programme en mode debug (Run > Debug) pour les prendre en compte. L’exécution se déroule alors normalement nous demandant d’entrer les différentes saisies, puis le programme se met en pause et bascule sur l’interface de débogage quand le point d’arrêt est rencontré.

Interface de débogage.
Interface de débogage.

Là nous pouvons directement avoir un aperçu des variables présentes dans la fonction, et les boutons reprennent les actions que nous avons pu voir avec Pdb : passer à l’instruction suivante, reprendre l’exécution du programme, redémarrer depuis le début, etc.

Pour plus d’informations sur le débogage avec PyCharm je vous invite à consulter cette page d’aide (en anglais).