8. Erori și excepții

Până la momentul de față, mesajele de eroare au cam fost trecute cu vederea, însă, dacă ați încercat toate exemplele, probabil că v-ați întâlnit cu ele. Există (cel puțin) două feluri distincte de erori: erorile de sintaxă și excepțiile.

8.1. Erori de sintaxă

Erorile de sintaxă, cunoscute și ca erori de parsare, sunt, de obicei, cele mai obișnuite tipuri de plângeri pe care vi le va adresa interpretorul cât timp n-ați terminat de învățat Python-ul:

>>> while True print('Salutare, lume')
  File "<stdin>", line 1
    while True print('Salutare, lume')
               ^^^^^
SyntaxError: invalid syntax

Parserul (parte a interpretorului) afișează linia care l-a supărat și îi adaugă vârfuri de săgeată îndreptate către porțiunea din linie unde a găsit o eroare. Remarcați că porțiunea indicată nu este, neapărat, cea în care trebuie făcute corecturi. În exemplu, greșeala este detectată în dreptul funcției print(), iar aceasta deoarece semnul de punctuație două puncte (':') lipsește chiar din fața numelui funcției.

Numele fișierului (în cazul nostru, <stdin>) și numărul liniei de cod sunt și ele afișate pentru ca dumneavoastră să știți de unde să începeți căutarea erorii atunci când codul eronat provine dintr-un fișier.

8.2. Excepții

Chiar și atunci când o instrucțiune ori o expresie sunt corecte sintactic, ele pot cauza erori dacă încercăm să le executăm. Erorile detectate în timpul execuției codului se numesc excepții și nu sunt, obligatoriu, fatale: vom învăța în curând cum să le manevrăm în programele scrise în Python. Totuși, majoritatea excepțiilor nu sunt tratate de codul programului, conducând la mesaje de eroare aidoma celui arătat aici:

>>> 10 * (1/0)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
    10 * (1/0)
          ~^~
ZeroDivisionError: division by zero
>>> 4 + varză*3
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
    4 + varză*3
        ^^^^
NameError: name 'varză' is not defined
>>> '2' + 2
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
    '2' + 2
    ~~~~^~~
TypeError: can only concatenate str (not "int") to str

Ultimul rând al mesajului de eroare ne informează despre ce s-a întâmplat. Excepțiile sunt de tipuri diferite, iar tipul lor este afișat ca parte a mesajului: tipurile excepțiilor din exemplu sunt ZeroDivisionError, NameError și TypeError. Șirul de caractere afișat pe post de tip al excepției este numele excepției predefinite care s-a produs. Acest comportament al interpretorului este regulă în ceea ce privește excepțiile predefinite, însă se poate să nu se adeverească și pentru indiferent care excepție definită de utilizator (chiar dacă reprezintă o convenție foarte utilă). Numele de excepții standard sunt identificatori predefiniți (dar nu cuvinte cheie rezervate).

Restul rândului de mesaj oferă detalii bazate pe tipul de excepție și pe cauza (producerii) ei.

Partea precedentă a mesajului de eroare ne arată contextul în care a apărut excepția, sub forma derulării (de la englezescul, ca jargon, traceback) unei stive. De obicei, această parte va conține derularea liniilor (rândurilor) de conținut ale stivei; cu toate acestea, nu vor fi afișate rândurile citite de la intrarea standard.

În Built-in Exceptions se găsesc lista excepțiilor predefinite împreună cu semnificațiile acestora.

8.3. Tratarea excepțiilor

Putem scrie programe care să trateze (doar) anumite excepții. Haideți să aruncăm o privire asupra exemplului de mai jos, în care utilizatorului i se cere să introducă numere până când un număr întreg convenabil va fi tastat, îngăduindu-i-se, în schimb, să oprească execuția programului oricând dorește (cu combinația de taste Control-C sau cu orice permite sistemul de operare folosit); trebuie remarcat că întreruperea (execuției) produsă de utilizator va fi semnalată prin ridicarea (de la englezescul raising; sau lansarea) excepției (de tipul) KeyboardInterrupt.

>>> while True:
...     try:
...         x = int(input("Vă rog să introduceți un număr: "))
...         break
...     except ValueError:
...         print("Vai! Acest număr nu e bun. Mai încercați...")
...

Instrucțiunea try funcționează după cum urmează.

  • Prima se execută clauza try (adică, grupul de instrucțiuni situate între cuvintele-cheie try și except).

  • Dacă nu sunt întâlnite excepții, atunci se va sări peste clauza except, execuția instrucțiunii try încheindu-se.

  • Dacă survine vreo excepție în timpul execuției clauzei try, atunci se va sări peste restul clauzei. Presupunând că tipul excepției se potrivește (de la englezescul match) numelui de (tip de) excepție scris după cuvântul-cheie except, se va executa clauza except, apoi execuția codului Python va trece la instrucțiunile situate după blocul try/except.

  • În cazul când numele excepției survenite nu se potrivește cu cel precizat în clauza except, acest nume va fi transmis către instrucțiunile try exterioare; dacă nu va fi găsit niciun manipulator de excepție (de la englezescul handler; sau procedură de tratare a excepției) convenabil, atunci ne vom afla în fața unei excepții netratate (de către codul nostru Python; sau a unei excepții neinterceptate) iar execuția programului se va încheia brusc, furnizându-se un mesaj de eroare.

O instrucțiune try poate avea mai mult de o singură clauză except, deținând, din acest motiv, manipulatori (de excepție) pentru felurite excepții. Cel mult unul dintre acești manipulatori va fi executat. Un manipulator tratează acea excepție care survine în timpul execuției clauzei try corespunzătoare, nu și acele (eventuale) excepții care intervin în ceilalți manipulatori din aceeași instrucțiune try. O clauză except poate numi mai multe excepții, sub forma unui tuplu încadrat de paranteze rotunde, precum:

... except (RuntimeError, TypeError, NameError):
...     pass

O clasă (numită) într-o clauză except li se potrivește acelor excepții care sunt instanțe ale clasei însăși ori ale vreuneia din clasele derivate din clasa în cauză (dar nu și invers – o clauză except care conține numele unei clase derivate nu se va potrivi cu nicio instanță a vreunei clase de bază). De exemplu, codul următor va afișa B, C, D în exact această ordine:

class B(Exception):
    pass

class C(B):
    pass

class D(C):
    pass

for clase in [B, C, D]:
    try:
        raise clase()
    except D:
        print("D")
    except C:
        print("C")
    except B:
        print("B")

Observați că, dacă am fi inversat clauzele except (așezând-o pe except B prima), atunci s-ar fi afișat B, B, B — deoarece prima clauză except potrivită (excepțiilor) s-ar fi declanșat.

Atunci când intervine o excepție, ei îi pot fi asociate diverse valori, cunoscute și sub numele de argumente ale excepției în cauză. Atât existența unor asemenea argumente cât și tipurile lor depind de tipul de excepție.

Clauza except poate specifica o variabilă la dreapta numelui excepției. Această variabilă este legată de instanța excepției, excepția având, de obicei, un atribut args în care sunt stocate asemenea valori (argumente ale excepției). Din rațiuni de utilitate, tipurile builtin (predefinite) de excepții definesc metoda __str__() ca să putem afișa argumentele unor asemenea excepții fără să mai fie nevoie de accesarea în mod explicit a lui .args.

>>> try:
...     raise Exception('șuncă presată', 'ouă')
... except Exception as hrană:
...     print(type(hrană))    # tipul de excepție
...     print(hrană.args)     # valorile stocate în .args
...     print(hrană)          # __str__ ne permite să afișăm direct argumentele,
...                           # însă poate fi suprascrisă în moștenitorii clasei
...     x, y = hrană.args     # despachetarea lui args
...     print('x =', x)
...     print('y =', y)
...
<class 'Exception'>
('șuncă presată', 'ouă')
('șuncă presată', 'ouă')
x = șuncă presată
y = ouă

Șirul returnat de metoda __str__() a unei excepții va fi afișat ca ultimă parte (cea de «detalii») a mesajului de eroare produs de intervenția unei excepții netratate.

BaseException este clasa de bază (superclasa) a tuturor excepțiilor. Una din moștenitoarele sale, Exception, este clasa de bază a tuturor excepțiilor non-fatale. Excepțiile care nu-i sunt subclase (urmași) lui Exception nu sunt, de obicei, tratate (cu manipulatori de excepție), deoarece ele sunt întrebuințate la a ne arăta că execuția programului trebuie oprită. Asemenea excepții includ SystemExit, ridicată de sys.exit(), respectiv KeyboardInterrupt, ridicată atunci când utilizatorul dorește să întrerupă programul.

Exception poate fi utilizată pe post de înlocuitor (de excepții specifice) capabil să intercepteze (de la englezescul catch) aproape orice fel de excepție. Cu toate acestea, bunele practici recomandă să fim cât mai la obiect cu putință vizavi de tipurile de excepții pe care suntem preocupați să le tratăm, permițându-le, în schimb, celorlalte excepții să se propage.

Șablonul (cel mai) obișnuit de tratare a lui Exception este fie prin afișarea excepției fie prin jurnalizarea ei, urmată de o nouă ridicare a excepției (ceea ce îi va permite unui apelant — al codului — să trateze, la rândul său, excepția):

import sys

try:
    f = open('fișierul_meu.txt')
    s = f.readline()
    i = int(s.strip())
except OSError as eroare:
    print("Eroare SO:", eroare)
except ValueError:
    print("Data nu poate fi convertită într-un număr întreg.")
except Exception as eroare:
    print(f"O (neașteptată) {eroare=}, {type(eroare)=}")
    raise

Instrucțiunea tryexcept dispune și de o clauză else opțională, care, dacă o introducem în programul nostru, trebuie să fie poziționată după toate clauzele except. Ea este utilă atunci când execuția clauzei try nu va ridica nicio excepție. Ca exemplu:

for argumentul in sys.argv[1:]:
    try:
        f = open(argumentul, 'r')
    except OSError:
        print('nu poate fi deschis', argumentul)
    else:
        print(argumentul, 'are', len(f.readlines()), 'linii')
        f.close()

Întrebuințarea clauzei else este mai nimerită, în practică, decât adăugirile de cod în corpul clauzei try și aceasta în special pentru că se evită interceptarea unor excepții care să nu fi fost ridicate chiar de către codul pe care l-am protejat cu instrucțiunea tryexcept.

Manipulatorii de excepții nu tratează doar acele excepții care intervin direct în codul din clauza try, ci și pe acelea care survin în codul funcțiilor apelate (fie și indirect) în cadrul clauzei try. Precum aici (expresia englezească run-time, de jargon informatic, se poate traduce prin în timpul execuției):

>>> def cod_care_nu_funcționează():
...     x = 1/0
...
>>> try:
...     cod_care_nu_funcționează()
... except ZeroDivisionError as eroarea:
...     print('Tratând o eroare din timpul execuției:', eroarea)
...
Tratând o eroare din timpul execuției: division by zero

8.4. Ridicând excepții

Instrucțiunea raise îi îngăduie programatorului să forțeze apariția unei anumite excepții. De exemplu:

>>> raise NameError('Salutare')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
    raise NameError('Salutare')
NameError: Salutare

Argumentul (unic al) lui raise indică excepția care urmează a fi ridicată. Aceasta trebuie să fie sau instanța unei (clase de) excepții sau chiar o clasă de excepții (adică, o clasă derivată din BaseException, precum Exception ori vreunul din urmașii ei). Atunci când se transmite (drept argument unic) o clasă de excepții, aceasta va fi instanțiată în mod implicit prin apelarea constructorului fără argumente de care dispune:

raise ValueError  # o prescurtare a lui 'raise ValueError()'

Presupunând că doriți să știți dacă s-a ridicat vreo excepție însă nu vă interesează și să o tratați, puteți folosi forma simplificată a instrucțiunii raise în cadrul căreia vi se permite relansarea excepției în cauză:

>>> try:
...     raise NameError('Salutare')
... except NameError:
...     print('Tocmai ne-a depășit o excepție!')
...     raise
...
Tocmai ne-a depășit o excepție!
Traceback (most recent call last):
  File "<stdin>", line 2, in <module>
    raise NameError('Salutare')
NameError: Salutare

8.5. Înlănțuirea excepțiilor

Atunci când o excepție netratată survine în interiorul unei secțiuni (clauze) except, excepția care chiar este tratată îi va fi atașată, respectiv va fi menționată în mesajul de eroare:

>>> try:
...     open("baza_de_date.sqlite")
... except OSError:
...     raise RuntimeError("nu putem trata eroarea")
...
Traceback (most recent call last):
  File "<stdin>", line 2, in <module>
    open("baza_de_date.sqlite")
    ~~~~^^^^^^^^^^^^^^^^^^^
FileNotFoundError: [Errno 2] No such file or directory: 'baza_de_date.sqlite'

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "<stdin>", line 4, in <module>
    raise RuntimeError("nu putem trata eroarea")
RuntimeError: nu putem trata eroarea

Pentru a indica faptul că o excepție este consecința directă a altei excepții, instrucțiunea raise dispune de clauza opțională from:

# excepția este fie instanța unei excepții fie None.
raise RuntimeError from excepția

Această clauză ne poate fi de folos atunci când transformăm excepții. Ca aici:

>>> def funcția():
...     raise ConnectionError
...
>>> try:
...     funcția()
... except ConnectionError as excepția:
...     raise RuntimeError('Nu putem deschide baza de date') from excepția
...
Traceback (most recent call last):
  File "<stdin>", line 2, in <module>
    funcția()
    ~~~~^^
  File "<stdin>", line 2, in funcția
ConnectionError

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

Traceback (most recent call last):
  File "<stdin>", line 4, in <module>
    raise RuntimeError('Nu putem deschide baza de date') from excepția
RuntimeError: Nu putem deschide baza de date

Tot ea ne ajută să dezafectăm înlănțuirea automată de excepții cu ajutorul idiomului from None:

>>> try:
...     open('baza_de_date.sqlite')
... except OSError:
...     raise RuntimeError from None
...
Traceback (most recent call last):
  File "<stdin>", line 4, in <module>
    raise RuntimeError from None
RuntimeError

Pentru mai multe informații privitoare la mecanismul înlănțuirii, vezi Built-in Exceptions.

8.6. Excepții definite de utilizator

Programs may name their own exceptions by creating a new exception class (see Classes for more about Python classes). Exceptions should typically be derived from the Exception class, either directly or indirectly.

Exception classes can be defined which do anything any other class can do, but are usually kept simple, often only offering a number of attributes that allow information about the error to be extracted by handlers for the exception.

Most exceptions are defined with names that end in „Error”, similar to the naming of the standard exceptions.

Many standard modules define their own exceptions to report errors that may occur in functions they define.

8.7. Defining Clean-up Actions

The try statement has another optional clause which is intended to define clean-up actions that must be executed under all circumstances. For example:

>>> try:
...     raise KeyboardInterrupt
... finally:
...     print('Goodbye, world!')
...
Goodbye, world!
Traceback (most recent call last):
  File "<stdin>", line 2, in <module>
    raise KeyboardInterrupt
KeyboardInterrupt

If a finally clause is present, the finally clause will execute as the last task before the try statement completes. The finally clause runs whether or not the try statement produces an exception. The following points discuss more complex cases when an exception occurs:

  • If an exception occurs during execution of the try clause, the exception may be handled by an except clause. If the exception is not handled by an except clause, the exception is re-raised after the finally clause has been executed.

  • An exception could occur during execution of an except or else clause. Again, the exception is re-raised after the finally clause has been executed.

  • If the finally clause executes a break, continue or return statement, exceptions are not re-raised.

  • If the try statement reaches a break, continue or return statement, the finally clause will execute just prior to the break, continue or return statement’s execution.

  • If a finally clause includes a return statement, the returned value will be the one from the finally clause’s return statement, not the value from the try clause’s return statement.

For example:

>>> def bool_return():
...     try:
...         return True
...     finally:
...         return False
...
>>> bool_return()
False

A more complicated example:

>>> def divide(x, y):
...     try:
...         result = x / y
...     except ZeroDivisionError:
...         print("division by zero!")
...     else:
...         print("result is", result)
...     finally:
...         print("executing finally clause")
...
>>> divide(2, 1)
result is 2.0
executing finally clause
>>> divide(2, 0)
division by zero!
executing finally clause
>>> divide("2", "1")
executing finally clause
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
    divide("2", "1")
    ~~~~~~^^^^^^^^^^
  File "<stdin>", line 3, in divide
    result = x / y
             ~~^~~
TypeError: unsupported operand type(s) for /: 'str' and 'str'

As you can see, the finally clause is executed in any event. The TypeError raised by dividing two strings is not handled by the except clause and therefore re-raised after the finally clause has been executed.

In real world applications, the finally clause is useful for releasing external resources (such as files or network connections), regardless of whether the use of the resource was successful.

8.8. Predefined Clean-up Actions

Some objects define standard clean-up actions to be undertaken when the object is no longer needed, regardless of whether or not the operation using the object succeeded or failed. Look at the following example, which tries to open a file and print its contents to the screen.

for line in open("myfile.txt"):
    print(line, end="")

The problem with this code is that it leaves the file open for an indeterminate amount of time after this part of the code has finished executing. This is not an issue in simple scripts, but can be a problem for larger applications. The with statement allows objects like files to be used in a way that ensures they are always cleaned up promptly and correctly.

with open("myfile.txt") as f:
    for line in f:
        print(line, end="")

After the statement is executed, the file f is always closed, even if a problem was encountered while processing the lines. Objects which, like files, provide predefined clean-up actions will indicate this in their documentation.

8.9. Raising and Handling Multiple Unrelated Exceptions

There are situations where it is necessary to report several exceptions that have occurred. This is often the case in concurrency frameworks, when several tasks may have failed in parallel, but there are also other use cases where it is desirable to continue execution and collect multiple errors rather than raise the first exception.

The builtin ExceptionGroup wraps a list of exception instances so that they can be raised together. It is an exception itself, so it can be caught like any other exception.

>>> def f():
...     excs = [OSError('error 1'), SystemError('error 2')]
...     raise ExceptionGroup('there were problems', excs)
...
>>> f()
  + Exception Group Traceback (most recent call last):
  |   File "<stdin>", line 1, in <module>
  |     f()
  |     ~^^
  |   File "<stdin>", line 3, in f
  |     raise ExceptionGroup('there were problems', excs)
  | ExceptionGroup: there were problems (2 sub-exceptions)
  +-+---------------- 1 ----------------
    | OSError: error 1
    +---------------- 2 ----------------
    | SystemError: error 2
    +------------------------------------
>>> try:
...     f()
... except Exception as e:
...     print(f'caught {type(e)}: e')
...
caught <class 'ExceptionGroup'>: e
>>>

By using except* instead of except, we can selectively handle only the exceptions in the group that match a certain type. In the following example, which shows a nested exception group, each except* clause extracts from the group exceptions of a certain type while letting all other exceptions propagate to other clauses and eventually to be reraised.

>>> def f():
...     raise ExceptionGroup(
...         "group1",
...         [
...             OSError(1),
...             SystemError(2),
...             ExceptionGroup(
...                 "group2",
...                 [
...                     OSError(3),
...                     RecursionError(4)
...                 ]
...             )
...         ]
...     )
...
>>> try:
...     f()
... except* OSError as e:
...     print("There were OSErrors")
... except* SystemError as e:
...     print("There were SystemErrors")
...
There were OSErrors
There were SystemErrors
  + Exception Group Traceback (most recent call last):
  |   File "<stdin>", line 2, in <module>
  |     f()
  |     ~^^
  |   File "<stdin>", line 2, in f
  |     raise ExceptionGroup(
  |     ...<12 lines>...
  |     )
  | ExceptionGroup: group1 (1 sub-exception)
  +-+---------------- 1 ----------------
    | ExceptionGroup: group2 (1 sub-exception)
    +-+---------------- 1 ----------------
      | RecursionError: 4
      +------------------------------------
>>>

Note that the exceptions nested in an exception group must be instances, not types. This is because in practice the exceptions would typically be ones that have already been raised and caught by the program, along the following pattern:

>>> excs = []
... for test in tests:
...     try:
...         test.run()
...     except Exception as e:
...         excs.append(e)
...
>>> if excs:
...    raise ExceptionGroup("Test Failures", excs)
...

8.10. Enriching Exceptions with Notes

When an exception is created in order to be raised, it is usually initialized with information that describes the error that has occurred. There are cases where it is useful to add information after the exception was caught. For this purpose, exceptions have a method add_note(note) that accepts a string and adds it to the exception’s notes list. The standard traceback rendering includes all notes, in the order they were added, after the exception.

>>> try:
...     raise TypeError('bad type')
... except Exception as e:
...     e.add_note('Add some information')
...     e.add_note('Add some more information')
...     raise
...
Traceback (most recent call last):
  File "<stdin>", line 2, in <module>
    raise TypeError('bad type')
TypeError: bad type
Add some information
Add some more information
>>>

For example, when collecting exceptions into an exception group, we may want to add context information for the individual errors. In the following each exception in the group has a note indicating when this error has occurred.

>>> def f():
...     raise OSError('operation failed')
...
>>> excs = []
>>> for i in range(3):
...     try:
...         f()
...     except Exception as e:
...         e.add_note(f'Happened in Iteration {i+1}')
...         excs.append(e)
...
>>> raise ExceptionGroup('We have some problems', excs)
  + Exception Group Traceback (most recent call last):
  |   File "<stdin>", line 1, in <module>
  |     raise ExceptionGroup('We have some problems', excs)
  | ExceptionGroup: We have some problems (3 sub-exceptions)
  +-+---------------- 1 ----------------
    | Traceback (most recent call last):
    |   File "<stdin>", line 3, in <module>
    |     f()
    |     ~^^
    |   File "<stdin>", line 2, in f
    |     raise OSError('operation failed')
    | OSError: operation failed
    | Happened in Iteration 1
    +---------------- 2 ----------------
    | Traceback (most recent call last):
    |   File "<stdin>", line 3, in <module>
    |     f()
    |     ~^^
    |   File "<stdin>", line 2, in f
    |     raise OSError('operation failed')
    | OSError: operation failed
    | Happened in Iteration 2
    +---------------- 3 ----------------
    | Traceback (most recent call last):
    |   File "<stdin>", line 3, in <module>
    |     f()
    |     ~^^
    |   File "<stdin>", line 2, in f
    |     raise OSError('operation failed')
    | OSError: operation failed
    | Happened in Iteration 3
    +------------------------------------
>>>