Remonter correctement une erreur

Le problème exposé dans ce sujet a été résolu.

Bonsoir,

Version courte : une fonction A appelle une fonction B. Si celle-ci déclenche une erreur, une partie des informations de débogage ne sont accessibles que depuis A. Comment remonter de manière propre et lisible l'erreur ? De manière brutale et peu lisible, je sais faire, voir ci-après.


Dans le cadre d'un petit projet en python, je me retrouve à avoir l'architecture suivante :

  • quelques class ;
  • une fonction qui lit différents fichiers json selon des paramètres (sans rien en faire, seulement lister / récupérer une chaine dans les fichiers) ;
  • une fonction qui décode le json et crée plusieurs variables, instances des class.

Si je fais un exemple minimal équivalent, ça donne :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Objet:
  def __init__(self, param1, param2):
    self.param = float(param1) + float(param2)

def read_file(filename):
  with open(filename) as file_data:
    return parser(file_data.read())

def parser(data):
  data = data.split("\n")
  dictionary = {}
  # pour chaque ligne non vide, récupère un nom et une valeur associée.
  for el in data:
    if el != "":
      el = el.split(" ")
      dictionary[el[0]] = el[1]
  return Objet(**dictionary)

if __name__ == "__main__":
  to_read = "test"
  o = read_file(to_read)
  print(o.param)

avec l'entrée qui va bien,

1
2
param1 1
param2 2

Ça marche, ça affiche 3.0.

Le truc, c'est que le fichier json peut être mal écris, et dans ce cas, l'erreur retournée est illisible, on ne sait pas ce qui est faux dans le json, ni même dans quel fichier ! Par exemple, en enlevant la seconde ligne du fichier d'entrée,

1
2
3
4
5
6
7
8
9
Traceback (most recent call last):
  File "pyf.py", line 20, in <module>
    o = read_file(to_read)
  File "pyf.py", line 7, in read_file
    return parser(file_data.read())
  File "pyf.py", line 16, in parser
    return Objet(**dictionary)
TypeError: __init__() missing 1 required positional argument: 'param2'
[1]    29055 exit 1     python3 pyf.py

J'ai modifié le code pour remonter l'erreur assez bêtement (dans le cas d'un manque d'argument, un TypeError). Cependant, il faut vraiment remonter l'erreur, et non pas seulement la capturer, car parser ne sait pas quel est le nom du fichier qu'il traite.

 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
class Objet:
  def __init__(self, param1, param2):
    self.param = float(param1) + float(param2)

def read_file(filename):
  with open(filename) as file_data:
    # Modif ici
    try:
      return parser(file_data.read())
    except TypeError:
      raise TypeError("Error occured on file '{file}'.".format(file=filename))

def parser(data):
  data = data.split("\n")
  dictionary = {}
  for el in data:
    if el != "":
      el = el.split(" ")
      dictionary[el[0]] = el[1]
  # Modif ici aussi
  try:
    return Objet(**dictionary)
  except TypeError:
    raise TypeError("Error when parsing. Object can not be parsed with value {value}.".\
format(value=dictionary))

if __name__ == "__main__":
  to_read = "test"
  o = read_file(to_read)
  print(o.param)

L'erreur devient,

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
Traceback (most recent call last):
  File "pyf.py", line 20, in parser
    return Objet(**dictionary)
TypeError: __init__() missing 1 required positional argument: 'param2'

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "pyf.py", line 8, in read_file
    return parser(file_data.read())
  File "pyf.py", line 23, in parser
    format(value=dictionary))
TypeError: Error when parsing. Object can not be parsed with value {'param1': '1'}.

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "pyf.py", line 27, in <module>
    o = read_file(to_read)
  File "pyf.py", line 10, in read_file
    raise TypeError("Error occured on file '{file}'.".format(file=filename))
TypeError: Error occured on file 'test'.
[1]    29674 exit 1     python3 pyf.py

On a accès à toutes les infos. Mais non seulement, c'est très peu lisible (3 messages d'erreur à lire pour retrouver toutes les infos), mais en plus, ça ne couvre qu'une toute petite partie des erreurs (le manque d’argument).

D'où ma question : comment faire ça proprement, plutôt qu'un bête « affiche un message et passe au voisin du dessus ».

En espérant avoir été clair et concis. :)

+0 -0

En utilisant le module standard json et sa fonction json.loads (ou json.load sur un file-like), tu peux aisément contrôler les JSON invalides grâce à l'exception json.decode.JSONDecodeError. Petit exemple :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
>>> import json
>>> json.loads('{"foo": "bar}') # il manque un « " » à la fin de « bar »
Traceback (most recent call last):
  ...
json.decoder.JSONDecodeError: Unterminated string starting at: line 1 column 9 (char 8)

>>> try: json.loads('{"foo": "bar}')
... except json.decoder.JSONDecodeError: print('ton json est pas valide')
... 
ton json est pas valide

C'était ta question (ou une partie de ta question) ? Ou alors voulais-tu plutôt savoir comment bien organiser les levées d'exceptions sur plusieurs appels imbriqués ?

+0 -0

Salut,

Déjà il faut que tes exceptions aient du sens. Ici tu te contentes de reraiser la même exception (TypeError).

Donc premier réflexe:

1
2
class ParseError(Exception):
    pass

Je passe rapidement sur les idiomatismes pour créer des exceptions dans une lib. En général, on s'attend à ce que le package expose une packagename.Error et que toutes les exceptions custom héritent de cette classe d'erreur custom, ce qui permet de pouvoir rattraper en masse les exceptions levées par ta lib au moyen d'un except packagename.Error. Quant au nom Error, non, il n'est pas trop générique, parce que si les gens veulent importer seulement cette classe ils sont libres de l'aliaser avec from packagename import Error as ThisPackageError.

Bref, revenons à ton problème : le nom de fichier peut être connu un cran (techniquement, une stack frame) au-dessus de l'endroit où l'exception est détectée.

Il suffit de le prévoir dans ton exception custom :

 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
>>> class ParseError(Exception):
...     def __init__(self, msg, filename=None):
...         super().__init__(msg)
...         self.filename = filename
...     
...     def __str__(self):
...         if self.filename:
...             return "in file '{}': {}".format(
...                 self.filename, super().__str__()
...             )
...         else:
...             return super().__str__()
... 
>>> 
>>> def error_raiser_function():
...     raise TypeError("Something bad happened!")
... 
>>> 
>>> def direct_caller():
...     try:
...         error_raiser_function()
...     except TypeError as err:
...         raise ParseError(str(err)) from err
... 
>>> 
>>> def file_parser():
...     try:
...         direct_caller()
...     except ParseError as err:
...         err.filename = 'my_bad_file.json'
...         raise
... 
>>> 
>>> direct_caller()
Traceback (most recent call last):
  File "<stdin>", line 3, in direct_caller
  File "<stdin>", line 2, in error_raiser_function
TypeError: Something bad happened!

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 5, in direct_caller
__main__.ParseError: Something bad happened!
>>> file_parser()
Traceback (most recent call last):
  File "<stdin>", line 3, in direct_caller
  File "<stdin>", line 2, in error_raiser_function
TypeError: Something bad happened!

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 6, in file_parser
  File "<stdin>", line 3, in file_parser
  File "<stdin>", line 5, in direct_caller
__main__.ParseError: in file 'my_bad_file.json': Something bad happened!
+2 -0

En fait je m'aperçois que j'ai omis d'expliquer les deux éléments syntaxiques (méconnus) que j'utilise.

Le premier est raise A from B. Ça permet d'indiquer que l'exception B est la cause directe de A.

Le second est le raise sans aucun argument dans le bloc except. Celui-ci est assez subtil : si au final c'est la même exception qui est levée qu'on utilise raise err ou juste raise, il faut savoir qu'un raise tout seul va laisser le traceback intact (donc beaucoup plus explicite), alors que raise err va mettre à jour le traceback en utilisant la stackframe courante.

Bref, l'idée, c'est de faire un raise from pour convertir l'erreur primaire en quelque chose qui a du sens, puis rattraper cette exception custom plus haut, enrichir ses données, et la laisser repartir avec raise sans argument. De cette façon, non seulement tu enrichis ton message erreur pour l'utilisateur (si tu la rattrapes plus haut et que tu la logges sur stderr), mais en plus, dans le cas où elle n'est pas du tout rattrapée, elle te donne un traceback beaucoup plus facile à exploiter parce que tu ne le contamines pas en enrichissant l'exception : il te pointe directement sur la stackframe où l'erreur a été déclenchée, au lieu de celle où tu l'as enrichie.

PS : je crois bien que ton exemple est parfait sur le plan pédagogique parce qu'il permet de montrer pratiquement tout ce qu'il y a à savoir sur les exceptions. Je crois que les seuls truc qui manquent dedans c'est :

  • montrer comment créer une belle arborescence d'exceptions et surtout comment ça peut s'exploiter puissamment quand c'est bien fait,

  • la syntaxe except (A, B, C) as err:, assez peu connue également (comme tout ce qui touche à la gestion d'erreurs en Python), mais pourtant tellement pratique…

+0 -0
Connectez-vous pour pouvoir poster un message.
Connexion

Pas encore membre ?

Créez un compte en une minute pour profiter pleinement de toutes les fonctionnalités de Zeste de Savoir. Ici, tout est gratuit et sans publicité.
Créer un compte