Exception Groups
與except*
是在Python3.11新增加的例外處理功能。一直以來都想好好搞懂,但...(下略三千字)。這次終於趁著鐵人賽的機會,靜下心來研究如何使用這個新功能,其及相關應用場景。
本翼的程式碼幾乎全取自PEP654
及參考資料。我們特別推薦Or Chen
於EuroPython中的講解。其內容深入淺出,很快就能掌握基本原理。
本翼將使用EG
來代稱BaseExceptionGroup
與/或ExceptionGroup
。
[Day25]一起來閱讀PEP654
。
[Day26]了解Exception Groups
與except*
的相關應用。
This document proposes language extensions that allow programs to raise and handle multiple unrelated exceptions simultaneously:
* A new standard exception type, the ExceptionGroup, which represents a group of unrelated exceptions being propagated together.
* A new syntax except* for handling ExceptionGroups.
PEP654摘要裡說明,ExceptionGroup
的功用是可以「同時」收集「沒有相關」的exception
往後傳遞,並由except*
語法來處理例外。
由於Python3.11前的例外處理機制是,一次最多只能處理一個例外。但是有些情況,我們希望能同時raise
多個「沒有關係」的例外,這在沒有引進新語法的情況下很難做到。
文中舉了五個例子:
asyncio.gather
是一般大家處理concurrent
問題時,會呼叫的API。它提供了一個參數return_exceptions
來協助例外處理,當其為True
時,會返回一個list
,裡面包含所有成功的結果及例外;當其為False
時,當遇到第一個例外時就會馬上raise
。但使用asyncio.gather
無法同時處理多種例外,雖然有像Trio
這樣的library試著解決這些問題,但使用起來比較不便。Hypothesis
這樣的library來整理歸類錯誤。__exit__
時,其會掩蓋於with
區塊中發生的錯誤。EG
與except*
並非想要全面取代Exception
與except
語法,只是希望多提供一個選擇給開發者。已存在的library,若決定改使用EG
與except*
語法,應視為API-breaking change
。文件建議應該引入新的API呼叫邏輯,而不要直接修改既有的。
為了解決上述問題,Python3.11引進兩個新的例外型態,BaseExceptionGroup
與ExceptionGroup
。其中BaseExceptionGroup
繼承BaseException
,而ExceptionGroup
同時繼承BaseExceptionGroup
及Exception
。從這邊可以觀察出ExceptionGroup
除了是BaseExceptionGroup
,也是我們熟悉的Exception
。
class BaseExceptionGroup(BaseException): ...
class ExceptionGroup(BaseExceptionGroup, Exception):
BaseExceptionGroup
與ExceptionGroup
的signature
如下:
BaseExceptionGroup(message, exceptions) : ...
ExceptionGroup(message, exceptions) : ...
兩者都是接收兩個參數,message
與exceptions
。message
為str
型態,而exceptions
是一個可以nested
的sequence
,也就是說EG
可以包在另一個EG
的exceptions
內,例如ExceptionGroup('issues', [ValueError('bad value'), TypeError('bad type')])
。
ExceptionGroup
只能包住Exception
的subclass
,其於生成前會先檢查是否所有的exception
都是Exception
的instance
,如果不是的話,會raise TypeError
。BaseExceptionGroup
可以包住任何BaseExceptionGroup
的subclass
。其於生成前會先檢查如果所有的exception
都是ExceptionGroup
的subclass
,則其會直接生成ExceptionGroup
的instance
。BaseExceptionGroup.subgroup(condition)
可以根據給定condition
,生成符合條件的EG
,如# 01
所示。# 01
import traceback
eg = ExceptionGroup(
"one",
[
TypeError(1),
ExceptionGroup(
"two",
[TypeError(2), ValueError(3)]
),
ExceptionGroup(
"three",
[OSError(4)]
)
]
)
if __name__ == '__main__':
traceback.print_exception(eg)
print('subgroup: ')
type_errors = eg.subgroup(lambda e: isinstance(e, TypeError))
traceback.print_exception(type_errors)
...
使用traceback.print_exception
可以印出EG
的樹狀結構。當使用了subgroup
後,可以看出與TypeError
有關的例外都被挑選出來,形成一個新的ExceptionGroup
。
| ExceptionGroup: one (3 sub-exceptions)
+-+---------------- 1 ----------------
| TypeError: 1
+---------------- 2 ----------------
| ExceptionGroup: two (2 sub-exceptions)
+-+---------------- 1 ----------------
| TypeError: 2
+---------------- 2 ----------------
| ValueError: 3
+------------------------------------
+---------------- 3 ----------------
| ExceptionGroup: three (1 sub-exception)
+-+---------------- 1 ----------------
| OSError: 4
+------------------------------------
subgroup:
| ExceptionGroup: one (2 sub-exceptions)
+-+---------------- 1 ----------------
| TypeError: 1
+---------------- 2 ----------------
| ExceptionGroup: two (1 sub-exception)
+-+---------------- 1 ----------------
| TypeError: 2
+------------------------------------
如果是使用split
的話,則會將結果分為選中與跟沒選中兩個EG
(但當兩邊有一邊是空的話,則會回傳None
,不是空的EG
)。
# 01
match, rest = eg.split(lambda e: isinstance(e, TypeError))
print('match:')
traceback.print_exception(match)
print('rest:')
traceback.print_exception(rest)
...
match:
| ExceptionGroup: one (2 sub-exceptions)
+-+---------------- 1 ----------------
| TypeError: 1
+---------------- 2 ----------------
| ExceptionGroup: two (1 sub-exception)
+-+---------------- 1 ----------------
| TypeError: 2
+------------------------------------
rest:
| ExceptionGroup: one (2 sub-exceptions)
+-+---------------- 1 ----------------
| ExceptionGroup: two (1 sub-exception)
+-+---------------- 1 ----------------
| ValueError: 3
+------------------------------------
+---------------- 2 ----------------
| ExceptionGroup: three (1 sub-exception)
+-+---------------- 1 ----------------
| OSError: 4
+------------------------------------
subgroup
與split
除了可以接受callable
外,也可以直接接受例外,
# 01
...
type_errors2 = eg.subgroup(TypeError)
match, rest = eg.split(TypeError)
或是包含於tuple
內的多個例外。
# 01
...
type_errors3 = eg.subgroup((TypeError,))
match, rest = eg.split((TypeError,))
與Exception
一樣,我們可以透過繼承EG
來客製化自己的EG
。當然您可以選擇自己實作subgroup
與split
,但是文件建議實作derive
,因為無論呼叫subgroup
或split
,derive
都會被使用。# 02
中我們繼承了ExceptionGroup
,建立了客製化的MyExceptionGroup
class
。
__new__
中,添加給obj
一個errcode
attribute
。請注意文件中特別提到,必須使用__new__
而不能使用__init__
,因為BaseExceptionGroup.__new__
需要知道我們接收的參數。derive
中,回傳了MyExceptionGroup
的instance
。如果沒有overwrite
derive
的話,當回傳全都是Exception
的instance
時會回傳ExceptionGroup
,否則回傳BaseExceptionGroup
。# 02
class MyExceptionGroup(ExceptionGroup):
def __new__(cls, message, excs, errcode):
obj = super().__new__(cls, message, excs)
obj.errcode = errcode
return obj
def derive(self, excs):
return MyExceptionGroup(self.message, excs, self.errcode)
當想針對某些例外做一些處理時,文件中提到可以使用subgroup
來做。# 03
中log_and_ignore_ENOENT
除了提供適當的布林值回傳外,還偷偷在 if isinstance(err, OSError) and err.errno == ENOENT
的條件下,做了log
,這相當於在使用subgroup
時,順手添加了額外的功能。
# 03
def log_and_ignore_ENOENT(err):
if isinstance(err, OSError) and err.errno == ENOENT:
log(err)
return False
else:
return True
try:
. . .
except ExceptionGroup as eg:
eg = eg.subgroup(log_and_ignore_ENOENT)
if eg is not None:
raise eg
文件中提供了一個leaf_generator
,可以得到EG
的全部trackback
。
# 04
def leaf_generator(exc, tbs=None):
if tbs is None:
tbs = []
tbs.append(exc.__traceback__)
if isinstance(exc, BaseExceptionGroup):
for e in exc.exceptions:
yield from leaf_generator(e, tbs)
else:
yield exc, tbs
tbs.pop()
try-except*
是新增可以處理EG
的語法。
except* xxxError
是根據xxxError
是否為EG
的subclass
來判斷是否符合。except* xxxError as e
的e
,一定會是EG
而不是Exception
。except*
都可以被執行最多一次。換句話說,一個EG
可以走訪多個except*
。exception
只會:
except*
處理。except*
處理,最後被reraise
。Exception
是根據不同的except*
區塊來處理,而與EG
內其它Exception
無關。EG
在try-except*
的過程中會不斷丟棄已經符合條件的Exception
。其原理為,EG
會利用split
遞迴地進行match
,而當最後若還有沒被處理的例外時,將會reraise
剩餘的EG
。
文件中舉了一個概念性的例子,我們將它寫為# 05a
。
# 05a
import traceback
class SpamError(Exception): ...
class FooError(Exception): ...
class BarError(Exception): ...
class BazError(Exception): ...
eg = ExceptionGroup('msg', [FooError(1), FooError(2), BazError()])
try:
raise eg
except* SpamError:
...
except* FooError as e:
print('Handling FooError: ')
traceback.print_exception(e)
except* (BarError, BazError) as e:
print('Handling (BarError, BazError): ')
traceback.print_exception(e)
其概念大致上如# 05b
。
EG
被raise
,假設命名為unhandled
。我們使用unhandled.split(SpamError)
來確認unhandled
中有沒有SpamError
。由於EG
中沒有SpamError
,所以match
其實是None
,rest
就是unhandled
。我們將unhandled
設為rest
繼續往下。unhandled.split(FooError)
確認FooError
於EG
中,此時match
為ExceptionGroup('msg', [FooError(1), FooError(2)])
,而rest
為ExceptionGroup('msg', [BazError()])
。我們將e
及sys.exc_info()
設為match
,並將unhandled
設為rest
繼續往下。unhandled.split((BarError, BazError))
確認(BarError, BazError)
其中最少有一個例外在EG
中,此時match
為ExceptionGroup('msg', [BazError()])
,而rest
為None
。我們將e
及sys.exc_info()
設為match
,而rest
因為是None
,代表EG
內的例外都已被處理,所以沒有例外需要reraise
,程式順利結束。# 05b
... # SpamError, FooError, FooError,BazError定義同`# 05a`
eg = ExceptionGroup('msg', [FooError(1), FooError(2), BazError()])
# try:
unhandled = eg
# except* SpamError:
match, rest = unhandled.split(SpamError)
print(f'{match=}, {rest=}')
unhandled = rest
# except* FooError as e:
match, rest = unhandled.split(FooError)
print(f'{match=}, {rest=}')
unhandled = rest
# except* (BarError, BazError) as e:
match, rest = unhandled.split((BarError, BazError))
print(f'{match=}, {rest=}')
match=None, rest=ExceptionGroup('msg', [FooError(1), FooError(2), BazError()])
match=ExceptionGroup('msg', [FooError(1), FooError(2)]), rest=ExceptionGroup('msg', [BazError()])
match=ExceptionGroup('msg', [BazError()]), rest=None
當於try-except*
的try
中,raise
一個不是EG
的Excpetion
,稱作naked exception
。
except*
區塊中有符合的條件時,會將該Exception
打包成為一個EG
,並給予空的message
。這樣也符合as e
的e
一定會是EG
的原則,如# 06a
。# 06a
try:
raise BlockingIOError
except* OSError as e:
print(repr(e)) # ExceptionGroup('', [BlockingIOError()])
except*
中沒有符合的條件時,則raise Exception
,如# 06b
。# 06b
try:
try:
raise ValueError(12)
except* TypeError as e:
print('never')
except ValueError as e:
print(f'caught ValueError: {e!r}') # caught ValueError: ValueError(12)
except*
只考慮try
中的EG
,至於在except*
中再被raise
的EG
或Exception
會直接raise
,不會進到剩餘的except*
。
# 07a
中,我們於except* ValueError
又raise
了一個two
EG
。
# 07a
try:
raise ExceptionGroup("one", [ValueError('a')])
except* ValueError:
raise ExceptionGroup("two", [KeyError('x')])
except* KeyError:
print('never')
從traceback
可以確認,two
EG
並沒有被except* KeyError
抓到。
+ Exception Group Traceback (most recent call last):
| File "<stdin>", line 3, in <module>
| raise ExceptionGroup("one", [ValueError('a')])
| ExceptionGroup: one (1 sub-exception)
+-+---------------- 1 ----------------
| ValueError: a
+------------------------------------
During handling of the above exception, another exception occurred:
+ Exception Group Traceback (most recent call last):
| File "<stdin>", line 5, in <module>
| raise ExceptionGroup("two", [KeyError('x')])
| ExceptionGroup: two (1 sub-exception)
+-+---------------- 1 ----------------
| KeyError: 'x'
+------------------------------------
# 07b
中,我們於except* TypeError
又raise
了一個ValueError(2)
。
# 07b
try:
raise TypeError(1)
except* TypeError:
raise ValueError(2) from None # <- not caught in the next clause
except* ValueError:
print('never')
從traceback
可以確認,ValueError(2)
也沒有被except* ValueError
抓到。
Traceback (most recent call last):
File "<stdin>", line 5, in <module>
raise ValueError(2) from None # <- not caught in the next clause
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
ValueError: 2
於except*
中raise
EG
,會有chaining的效果。這是一個有趣的行為,可能直接從文件示例來看,會比較容易了解。
# 08a
於except* ValueError
中又raise
了一個EG
。
# 08a
try:
raise ExceptionGroup("one", [ValueError('a'), TypeError('b')])
except* ValueError:
raise ExceptionGroup("two", [KeyError('x'), KeyError('y')])
觀察其traceback
可以發現,於two
EG
中,也可以看到one
EG
中的ValueError('a')
。至於one
EG
中的TypeError('b')
因為沒有適當的except*
處理,所以最終被reraise
。
| ExceptionGroup: (2 sub-exceptions)
+-+---------------- 1 ----------------
| Exception Group Traceback (most recent call last):
| File "<stdin>", line 3, in <module>
| raise ExceptionGroup("one", [ValueError('a'), TypeError('b')])
| ExceptionGroup: one (1 sub-exception)
+-+---------------- 1 ----------------
| ValueError: a
+------------------------------------
|
| During handling of the above exception, another exception occurred:
|
| Exception Group Traceback (most recent call last):
| File "<stdin>", line 5, in <module>
| raise ExceptionGroup("two", [KeyError('x'), KeyError('y')])
| ExceptionGroup: two (2 sub-exceptions)
+-+---------------- 1 ----------------
| KeyError: 'x'
+---------------- 2 ----------------
| KeyError: 'y'
+------------------------------------
+---------------- 2 ----------------
| Exception Group Traceback (most recent call last):
| File "<stdin>", line 3, in <module>
| raise ExceptionGroup("one", [ValueError('a'), TypeError('b')])
| ExceptionGroup: one (1 sub-exception)
+-+---------------- 1 ----------------
| TypeError: b
+------------------------------------
我們可以將# 08a
改寫為# 08b
來處理TypeError
。
# 08b
try:
raise ExceptionGroup("one", [ValueError('a'), TypeError('b')])
except* ValueError:
raise ExceptionGroup("two", [KeyError('x'), KeyError('y')])
except* TypeError:
...
此時觀察traceback
可以發現,TypeError('b')
被except* TypeError
處理後,就不會再reraise
。
+ Exception Group Traceback (most recent call last):
| File "<stdin>", line 3, in <module>
| raise ExceptionGroup("one", [ValueError('a'), TypeError('b')])
| ExceptionGroup: one (1 sub-exception)
+-+---------------- 1 ----------------
| ValueError: a
+------------------------------------
During handling of the above exception, another exception occurred:
+ Exception Group Traceback (most recent call last):
| File "<stdin>", line 5, in <module>
| raise ExceptionGroup("two", [KeyError('x'), KeyError('y')])
| ExceptionGroup: two (2 sub-exceptions)
+-+---------------- 1 ----------------
| KeyError: 'x'
+---------------- 2 ----------------
| KeyError: 'y'
+------------------------------------
文件對此段的說明是:
Raising a new instance of a naked exception does not cause this exception to be wrapped by an exception group. Rather, the exception is raised as is, and if it needs to be combined with other propagated exceptions, it becomes a direct child of the new exception group created for that:
而我們的理解是,當於except*
中raise
Exception
時,若其需要與其它EG
合併的話,會生成新的EG
,並將此Exception
加入到最後。雖然與chaining不太一樣,但純看trackback
的感覺卻很相似,所以我們決定將此段一起整理到這邊。
# 08c
try:
raise ExceptionGroup("eg", [ValueError('a')])
except* ValueError:
raise KeyError('x')
| ExceptionGroup: (1 sub-exception)
+-+---------------- 1 ----------------
| Exception Group Traceback (most recent call last):
| File "<stdin>", line 2, in <module>
| ExceptionGroup: eg (1 sub-exception)
+-+---------------- 1 ----------------
| ValueError: a
+------------------------------------
|
| During handling of the above exception, another exception occurred:
|
| Traceback (most recent call last):
| File "<stdin>", line 4, in <module>
| KeyError: 'x'
+------------------------------------
此段文件花了不少篇幅講解,但主要就是說明下面兩種例外處理方式,即raise e
與raise
其實是不一樣的。差別是當顯性地raise e
時,其會有自己的metadata
。
def foo(): | def foo():
try: | try:
1 / 0 | 1 / 0
except ZeroDivisionError as e: | except ZeroDivisionError:
raise e | raise
|
foo() | foo()
|
Traceback (most recent call last): | Traceback (most recent call last):
File "/Users/guido/a.py", line 7 | File "/Users/guido/b.py", line 7
foo() | foo()
File "/Users/guido/a.py", line 5 | File "/Users/guido/b.py", line 3
raise e | 1/0
File "/Users/guido/a.py", line 3 | ZeroDivisionError: division by zero
1/0 |
ZeroDivisionError: division by zero |
從# 09
中可以看出except* ValueError as e
的處理是顯性raise e
,而except* OSError as e
的處理是直接raise
。
# 09
try:
raise ExceptionGroup(
"eg",
[
ValueError(1),
TypeError(2),
OSError(3),
ExceptionGroup(
"nested",
[OSError(4), TypeError(5), ValueError(6)])
]
)
except* ValueError as e:
print(f'*ValueError: {e!r}')
raise e
except* OSError as e:
print(f'*OSError: {e!r}')
raise
從traceback
可以清楚看出:
except* ValueError as e
由於是顯性raise e
,所以有自己的metadata
,獨立了一個EG
出來。except* OSError as e
於raise
前,也是一個獨立的EG
(記得as e
之後,e
一定是EG
嗎?),但因其又重新raise
,所以會與還沒有被處理過的TypeError
合併,成為最後被raise
的EG
。*ValueError: ExceptionGroup('eg', [ValueError(1), ExceptionGroup('nested', [ValueError(6)])])
*OSError: ExceptionGroup('eg', [OSError(3), ExceptionGroup('nested', [OSError(4)])])
| ExceptionGroup: (2 sub-exceptions)
+-+---------------- 1 ----------------
| Exception Group Traceback (most recent call last):
| File "stdin", line 16, in <module>
| raise e
| File "stdin", line 3, in <module>
| raise ExceptionGroup(
| ExceptionGroup: eg (2 sub-exceptions)
+-+---------------- 1 ----------------
| ValueError: 1
+---------------- 2 ----------------
| ExceptionGroup: nested (1 sub-exception)
+-+---------------- 1 ----------------
| ValueError: 6
+------------------------------------
+---------------- 2 ----------------
| Exception Group Traceback (most recent call last):
| File "stdin", line 3, in <module>
| raise ExceptionGroup(
| ExceptionGroup: eg (3 sub-exceptions)
+-+---------------- 1 ----------------
| TypeError: 2
+---------------- 2 ----------------
| OSError: 3
+---------------- 3 ----------------
| ExceptionGroup: nested (2 sub-exceptions)
+-+---------------- 1 ----------------
| OSError: 4
+---------------- 2 ----------------
| TypeError: 5
+------------------------------------
這一小段概念很直觀,我們直接看# 10
。當直接對except* TypeError as e
中的e
進行操作時,並不會mutate
eg
。
# 10
eg = ExceptionGroup("eg", [TypeError(12)])
eg.foo = 'foo'
try:
raise eg
except* TypeError as e:
e.foo = 'bar'
print(eg.foo) # 'foo'
try
中,混合使用except
及except*
。try:
...
except ValueError:
pass
except* CancelledError: # <- SyntaxError:
pass # combining ``except`` and ``except*``
# is prohibited
except
直接補抓ExceptionGroup
,但不能使用except*
。try:
...
except ExceptionGroup: # <- This works
pass
try:
...
except* ExceptionGroup: # <- Runtime error
pass
try:
...
except* (TypeError, ExceptionGroup): # <- Runtime error
pass
except*
。try:
...
except*: # <- SyntaxError
pass
ExceptionGroup
是繼承Exception
而來,所以我們可以使用except* Exception as ex
,此時ex
一樣會是EG
。# 11
try:
raise ExceptionGroup("one", [ValueError('a'), TypeError('b')])
except* Exception as ex: # This works
print(type(ex), ex) # <class 'ExceptionGroup'> one (2 sub-exceptions)