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 Excepții predefinite 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 "try" ... "except" 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 "try" ...
"except".

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("database.sqlite")
   ... except OSError:
   ...     raise RuntimeError("unable to handle error")
   ...
   Traceback (most recent call last):
     File "<stdin>", line 2, in <module>
       open("database.sqlite")
       ~~~~^^^^^^^^^^^^^^^^^^^
   FileNotFoundError: [Errno 2] No such file or directory: 'database.sqlite'

   During handling of the above exception, another exception occurred:

   Traceback (most recent call last):
     File "<stdin>", line 4, in <module>
       raise RuntimeError("unable to handle error")
   RuntimeError: unable to handle error

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():
   ...     raise ConnectionError
   ...
   >>> try:
   ...     func()
   ... except ConnectionError as exc:
   ...     raise RuntimeError('Failed to open database') from exc
   ...
   Traceback (most recent call last):
     File "<stdin>", line 2, in <module>
       func()
       ~~~~^^
     File "<stdin>", line 2, in func
   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('Failed to open database') from exc
   RuntimeError: Failed to open database

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
Excepții predefinite.


8.6. Excepții definite de utilizator
====================================

Programatorii își pot denumi propriile excepții prin crearea unei noi
clase de excepții (a se vedea Clase pentru mai multe despre clase în
Python). Excepțiile derivă, în mod obișnuit, din clasa "Exception",
atât direct cât și indirect.

Clasele de excepții pot realiza tot ce poate realiza oricare altă
clasă în Python, însă se recomandă să fie păstrate simple, dispunând
doar de un număr limitat de atribute care să le permită
manipulatorilor de excepții asociați lor să extragă informații despre
excepția survenită.

Majoritatea excepțiilor se definesc cu nume terminate în "Error"
(Eroare), aidoma denumirii excepțiilor standard.

Multe module standard își definesc propriile excepții pentru a raporta
erorile ce pot apărea la execuția funcțiilor conținute în modulele
respective.


8.7. Definirea unor acțiuni de curățare
=======================================

Instrucțiunea "try" posedă încă o clauză opțională, destinată (mai
ales) acțiunilor de *curățare* care trebuie realizate în absolut toate
împrejurările. Cum ar fi:

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

Atunci când clauza "finally" este prezentă, această clauză "finally"
va fi executată ca *ultima* activitate din cadrul instrucțiunii "try".
Codul din clauza "finally" va fi rulat indiferent dacă instrucțiunea
"try" a condus sau nu la apariția de excepții. La punctele care
urmează, discutăm despre situațiile mai complicate când poate apărea
vreo excepție:

* Dacă survine o excepție în timpul executării clauzei "try", excepția
  în cauză poate fi tratată de una din clauzele "except". Presupunând
  că excepția nu va fi interceptată de niciuna din clauzele "except",
  ea va fi relansată după încheierea execuției clauzei "finally".

* Se poate ca vreo excepție să survină când este executată fie una din
  clauzele "except" fie clauza "else". Și aici, excepția va fi
  relansată după ce se va încheia executarea codului din clauza
  "finally".

* If the "finally" clause executes a "break", "continue" or "return"
  statement, exceptions are not re-raised. This can be confusing and
  is therefore discouraged. From version 3.14 the compiler emits a
  "SyntaxWarning" for it (see **PEP 765**).

* Atunci când, la execuția codului din instrucțiunea "try" întâlnim
  vreuna din instrucțiunile "break", "continue" sau "return", clauza
  "finally" va fi executată *chiar înainte* de execuția oricăreia din
  instrucțiunile "break", "continue" ori "return".

* 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.
  This can be confusing and is therefore discouraged. From version
  3.14 the compiler emits a "SyntaxWarning" for it (see **PEP 765**).

Un exemplu:

   >>> def întoarce_valoare_de_adevăr():
   ...     try:
   ...         return True
   ...     finally:
   ...         return False
   ...
   >>> întoarce_valoare_de_adevăr()
   False

Alt exemplu, mai sofisticat:

   >>> def împărțire(x, y):
   ...     try:
   ...         rezultat = x / y
   ...     except ZeroDivisionError:
   ...         print("împărțire la zero!")
   ...     else:
   ...         print("rezultatul este", rezultat)
   ...     finally:
   ...         print("executăm clauza finally")
   ...
   >>> împărțire(2, 1)
   rezultatul este 2.0
   executăm clauza finally
   >>> împărțire(2, 0)
   împărțire la zero!
   executăm clauza finally
   >>> împărțire("2", "1")
   executăm clauza finally
   Traceback (most recent call last):
     File "<stdin>", line 1, in <module>
       împărțire("2", "1")
       ~~~~~~~~~^^^^^^^^^^
     File "<stdin>", line 3, in împărțire
       rezultat = x / y
                  ~~^~~
   TypeError: unsupported operand type(s) for /: 'str' and 'str'

După cum puteți vedea, clauza "finally" a fost executată în toate
situațiile. Excepția "TypeError", ridicată de încercarea de a realiza
împărțirea a două șiruri de caractere, nu este tratată de clauza
"except" și, prin urmare, a fost relansată după ce s-a încheiat
execuția clauzei "finally".

În aplicațiile *din lumea reală* (sau *de producție*), clauza
"finally" se întrebuințează (mai ales) la *eliberarea* resurselor
externe (precum fișierele sau conexiunile la rețeaua Internet),
indiferent dacă accesul la acestea a putut fi realizat.


8.8. Acțiuni de curățare predefinite
====================================

Anumite obiecte definesc acțiuni de curățare standard, acestea urmând
să fie realizate atunci când nu mai avem nevoie de obiectul în cauză,
indiferent dacă operația care a folosit obiectul respectiv s-a putut
sau nu realiza. Să aruncăm o privire asupra exemplului de mai jos, în
care se încearcă deschiderea unui fișier, urmată de afișarea
conținutului acestuia.

   for rândul in open("fișierul_meu.txt"):
       print(rândul, end="")

Scăparea (programatică) din acest cod este aceea că fișierul va fi
lăsat deschis pe o durată nedeterminată după ce interpretorul a
terminat de executat codul. O asemenea situație nu le impietează prea
mult unor scripturi simple, însă poate deveni problematică pentru
aplicații complexe. Instrucțiunea "with" le facilitează obiectelor
precum fișierele o utilizare corectă, astfel încât resursele blocate
de ele să fie eliberate prompt și corect, întotdeauna.

   with open("fișierul_meu.txt") as fișier:
       for rândul in fișier:
           print(rândul, end="")

Odată ce instrucțiunea a fost executată, fișierul *fișier* va fi
închis, în mod garantat, inclusiv în situația în care ar fi existat
dificultăți la procesarea rândurilor sale. Acele obiecte care, aidoma
obiectelor fișier, oferă acțiuni de curățare (eliberare de resurse)
predefinite, trebuie să indice o atare capacitate în documentația
aferentă.


8.9. Ridicarea și tratarea de excepții grupate, fără legătură una cu alta
=========================================================================

Vom întâlni situații în care va fi nevoie să raportăm laolaltă
apariția mai multor excepții. Un exemplu frecvent este dat de
incidentele de pe platformele de programare concurentă (de la
englezescul, în jargon, *concurency framework*), atunci când mai multe
activități eșuează simultan, dar nu sunt rare nici cazurile când dorim
ca execuția unui program să continue în pofida apariției unei erori,
colectând toate erorile survenite pe parcurs în loc să lansăm o
excepție chiar de la producerea primului incident.

Clasa predefinită "ExceptionGroup" aduce împreună o listă de instanțe
ale unor (clase de) excepții astfel încât acestea să poată fi ridicate
simultan. Ea însăși este o excepție, așadar poate fi interceptată ca
orice altă excepție.

   >>> def funcția():
   ...     excepțiile = [OSError('eroarea 1'), SystemError('eroarea 2')]
   ...     raise ExceptionGroup('am avut probleme', excepțiile)
   ...
   >>> funcția()
     + Exception Group Traceback (most recent call last):
     |   File "<stdin>", line 1, in <module>
     |     funcția()
     |     ~~~~~~~^^
     |   File "<stdin>", line 3, in funcția
     |     raise ExceptionGroup('am avut probleme', excepțiile)
     | ExceptionGroup: am avut probleme (2 sub-exceptions)
     +-+---------------- 1 ----------------
       | OSError: eroarea 1
       +---------------- 2 ----------------
       | SystemError: eroarea 2
       +------------------------------------
   >>> try:
   ...     funcția()
   ... except Exception as excepție:
   ...     print(f'am interceptat {type(excepție)}: excepție')
   ...
   am interceptat <class 'ExceptionGroup'>: excepție
   >>>

Întrebuințându-l pe "except*" în locul lui "except", vom putea trata
selectiv excepțiile, adică ne vom putea ocupa *doar* de acelea care se
potrivesc unui anumit tip. În exemplul dat în continuare, care
evidențiază grupuri imbricate de excepții, fiecare clauză "except*"
extrage din aceste grupuri imbricate doar excepțiile de un anume tip,
lăsându-le pe toate celelalte să se propage la restul clauzelor pentru
a fi, eventual, relansate.

   >>> def funcția():
   ...     raise ExceptionGroup(
   ...         "grupul1",
   ...         [
   ...             OSError(1),
   ...             SystemError(2),
   ...             ExceptionGroup(
   ...                 "grupul2",
   ...                 [
   ...                     OSError(3),
   ...                     RecursionError(4)
   ...                 ]
   ...             )
   ...         ]
   ...     )
   ...
   >>> try:
   ...     funcția()
   ... except* OSError as excepție:
   ...     print("Au apărut erori SO")
   ... except* SystemError as excepție:
   ...     print("Au apărut erori de sistem")
   ...
   Au apărut erori SO
   Au apărut erori de sistem
     + Exception Group Traceback (most recent call last):
     |   File "<stdin>", line 2, in <module>
     |     funcția()
     |     ~~~~~~~^^
     |   File "<stdin>", line 2, in funcția
     |     raise ExceptionGroup(
     |     ...<12 lines>...
     |     )
     | ExceptionGroup: grupul1 (1 sub-exception)
     +-+---------------- 1 ----------------
       | ExceptionGroup: grupul2 (1 sub-exception)
       +-+---------------- 1 ----------------
         | RecursionError: 4
         +------------------------------------
   >>>

Să remarcăm că excepțiile imbricate într-un grup de excepții trebuie
să fie instanțe (ale unor clase de excepții) și nu tipuri (adică,
clase de excepții). Aceasta pentru că, în practică, asemenea excepții
imbricate sunt, de obicei, acelea pe care programul deja le-a lansat
și le-a interceptat, urmând șablonul de aici:

   >>> excepțiile = []
   ... for testul in testele:
   ...     try:
   ...         testul.run()
   ...     except Exception as excepția:
   ...         excepțiile.append(excepția)
   ...
   >>> if excepțiile:
   ...    raise ExceptionGroup("Au căzut la test", excepțiile)
   ...


8.10. Îmbogățind excepțiile cu notițe
=====================================

Atunci când o excepție este creată, urmând a fi lansată, ea se
inițializată, de obicei, cu informații referitoare la eroarea tocmai
survenită. În anumite situații, ne interesează să le adăugăm
informațiilor în cauză (ale instanței excepției) detalii suplimentare,
însă doar *după* ce am interceptat excepția. Pentru un atare scop,
excepțiile dispun de metoda "add_note(notițe)", care acceptă un șir de
caractere drept argument și îl adaugă listei de notițe ale excepției.
Derularea tipică (a stivei) afișează toate notițele, în ordinea
introducerii lor, în continuarea excepției.

   >>> try:
   ...     raise TypeError('tip greșit')
   ... except Exception as excepția:
   ...     excepția.add_note('Mai adaug informații')
   ...     excepția.add_note('Mai adaug și alte informații')
   ...     raise
   ...
   Traceback (most recent call last):
     File "<stdin>", line 2, in <module>
       raise TypeError('tip greșit')
   TypeError: tip greșit
   Mai adaug informații
   Mai adaug și alte informații
   >>>

Cu titlu de exemplu, atunci când așezăm împreună variate excepții
într-un grup de excepții, este de așteptat să ne dorim să adăugăm
informații contextuale pentru erorile individuale (luate în parte). În
codul de mai jos, fiecare excepție din grup va fi urmată de o notiță
în care se precizează când a avut loc eroarea respectivă.

   >>> def funcția():
   ...     raise OSError('operația a dat greș')
   ...
   >>> excepțiile = []
   >>> for i in range(3):
   ...     try:
   ...         funcția()
   ...     except Exception as excepția:
   ...         excepția.add_note(f'A survenit la iterația {i+1}')
   ...         excepțiile.append(excepția)
   ...
   >>> raise ExceptionGroup('Au apărut probleme', excepțiile)
     + Exception Group Traceback (most recent call last):
     |   File "<stdin>", line 1, in <module>
     |     raise ExceptionGroup('Au apărut probleme', excepțiile)
     | ExceptionGroup: Au apărut probleme (3 sub-exceptions)
     +-+---------------- 1 ----------------
       | Traceback (most recent call last):
       |   File "<stdin>", line 3, in <module>
       |     funcția()
       |     ~~~~~~~^^
       |   File "<stdin>", line 2, in funcția
       |     raise OSError('operația a dat greș')
       | OSError: operația a dat greș
       | A survenit la iterația 1
       +---------------- 2 ----------------
       | Traceback (most recent call last):
       |   File "<stdin>", line 3, in <module>
       |     funcția()
       |     ~~~~~~~^^
       |   File "<stdin>", line 2, in funcția
       |     raise OSError('operația a dat greș')
       | OSError: operația a dat greș
       | A survenit la iterația 2
       +---------------- 3 ----------------
       | Traceback (most recent call last):
       |   File "<stdin>", line 3, in <module>
       |     funcția()
       |     ~~~~~~~^^
       |   File "<stdin>", line 2, in funcția
       |     raise OSError('operația a dat greș')
       | OSError: operația a dat greș
       | A survenit la iterația 3
       +------------------------------------
   >>>
