iT邦幫忙

2023 iThome 鐵人賽

DAY 25
0
Software Development

Python十翼:與未來的自己對話系列 第 25

[Day25] 九翼 - Exception Groups與except*:導讀PEP654

  • 分享至 

  • xImage
  •  

九翼大綱

Exception Groupsexcept*是在Python3.11新增加的例外處理功能。一直以來都想好好搞懂,但...(下略三千字)。這次終於趁著鐵人賽的機會,靜下心來研究如何使用這個新功能,其及相關應用場景。

本翼的程式碼幾乎全取自PEP654及參考資料。我們特別推薦Or Chen於EuroPython中的講解。其內容深入淺出,很快就能掌握基本原理。

本翼將使用EG來代稱BaseExceptionGroup與/或ExceptionGroup

[Day25]一起來閱讀PEP654
[Day26]了解Exception Groupsexcept*的相關應用。

PEP654摘要

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*語法來處理例外。

PEP654動機

由於Python3.11前的例外處理機制是,一次最多只能處理一個例外。但是有些情況,我們希望能同時raise多個「沒有關係」的例外,這在沒有引進新語法的情況下很難做到。

文中舉了五個例子:

  1. Concurrent errors
    Python的asyncio.gather是一般大家處理concurrent問題時,會呼叫的API。它提供了一個參數return_exceptions來協助例外處理,當其為True時,會返回一個list,裡面包含所有成功的結果及例外;當其為False時,當遇到第一個例外時就會馬上raise。但使用asyncio.gather無法同時處理多種例外,雖然有像Trio這樣的library試著解決這些問題,但使用起來比較不便。
  2. Multiple failures when retrying an operation
    假如一個操作被retry多次後失敗,我們會想知道其全部失敗的情況,而不是最後一個。
  3. Multiple user callbacks fail
    假如一個操作有多個callback,我們會想知道其全部失敗的情況,而不是最後一個。
  4. Multiple errors in a complex calculation
    收集所有錯誤的情況,將提供更多資訊給如Hypothesis這樣的library來整理歸類錯誤。
  5. Errors in wrapper code
    當有錯誤發生在__exit__時,其會掩蓋於with區塊中發生的錯誤。

PEP654原則

EGexcept*並非想要全面取代Exceptionexcept語法,只是希望多提供一個選擇給開發者。已存在的library,若決定改使用EGexcept*語法,應視為API-breaking change。文件建議應該引入新的API呼叫邏輯,而不要直接修改既有的。

BaseExceptionGroup與ExceptionGroup

為了解決上述問題,Python3.11引進兩個新的例外型態,BaseExceptionGroupExceptionGroup。其中BaseExceptionGroup繼承BaseException,而ExceptionGroup同時繼承BaseExceptionGroupException。從這邊可以觀察出ExceptionGroup除了是BaseExceptionGroup,也是我們熟悉的Exception

class BaseExceptionGroup(BaseException): ... 
class ExceptionGroup(BaseExceptionGroup, Exception):

BaseExceptionGroupExceptionGroupsignature如下:

BaseExceptionGroup(message, exceptions) : ...
ExceptionGroup(message, exceptions) : ...

兩者都是接收兩個參數,messageexceptionsmessagestr型態,而exceptions是一個可以nestedsequence,也就是說EG可以包在另一個EGexceptions內,例如ExceptionGroup('issues', [ValueError('bad value'), TypeError('bad type')])

  • ExceptionGroup只能包住Exceptionsubclass,其於生成前會先檢查是否所有的exception都是Exceptioninstance,如果不是的話,會raise TypeError
  • BaseExceptionGroup可以包住任何BaseExceptionGroupsubclass。其於生成前會先檢查如果所有的exception都是ExceptionGroupsubclass,則其會直接生成ExceptionGroupinstance
  • 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
      +------------------------------------

subgroupsplit除了可以接受callable外,也可以直接接受例外,

# 01
    ...
    type_errors2 = eg.subgroup(TypeError)
    match, rest = eg.split(TypeError)

或是包含於tuple內的多個例外。

# 01
    ...
    type_errors3 = eg.subgroup((TypeError,))
    match, rest = eg.split((TypeError,))

Subclassing Exception Groups

Exception一樣,我們可以透過繼承EG來客製化自己的EG。當然您可以選擇自己實作subgroupsplit,但是文件建議實作derive,因為無論呼叫subgroupsplitderive都會被使用。
# 02中我們繼承了ExceptionGroup,建立了客製化的MyExceptionGroup class

  • __new__中,添加給obj一個errcode attribute。請注意文件中特別提到,必須使用__new__而不能使用__init__,因為BaseExceptionGroup.__new__需要知道我們接收的參數。
  • derive中,回傳了MyExceptionGroupinstance。如果沒有overwrite derive的話,當回傳全都是Exceptioninstance時會回傳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)

Handling Exception Groups

當想針對某些例外做一些處理時,文件中提到可以使用subgroup來做。
# 03log_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()

except*

try-except*是新增可以處理EG的語法。

  • except* xxxError是根據xxxError是否為EGsubclass來判斷是否符合。
  • except* xxxError as ee,一定會是EG而不是Exception
  • 每一個except*都可以被執行最多一次。換句話說,一個EG可以走訪多個except*
  • 而每一個exception只會:
    • 被其中一個except*處理。
    • 沒有被任何except*處理,最後被reraise
  • 在這樣的處理邏輯下,每一個Exception是根據不同的except*區塊來處理,而與EG內其它Exception無關。

EGtry-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

  • 一開始有一個EGraise,假設命名為unhandled。我們使用unhandled.split(SpamError)來確認unhandled中有沒有SpamError。由於EG中沒有SpamError,所以match其實是Nonerest就是unhandled。我們將unhandled設為rest繼續往下。
  • unhandled.split(FooError)確認FooErrorEG中,此時matchExceptionGroup('msg', [FooError(1), FooError(2)]),而restExceptionGroup('msg', [BazError()])。我們將esys.exc_info()設為match,並將unhandled設為rest繼續往下。
  • unhandled.split((BarError, BazError))確認(BarError, BazError)其中最少有一個例外在EG中,此時matchExceptionGroup('msg', [BazError()]),而restNone。我們將esys.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

Naked Exceptions

當於try-except*try中,raise一個不是EGExcpetion,稱作naked exception

  • 如果except*區塊中有符合的條件時,會將該Exception打包成為一個EG,並給予空的message。這樣也符合as ee一定會是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或Exception

except*只考慮try中的EG,至於在except*中再被raiseEGException會直接raise,不會進到剩餘的except*

# 07a中,我們於except* ValueErrorraise了一個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* TypeErrorraise了一個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

Chaining

Raise EG in except*

except*raise EG,會有chaining的效果。這是一個有趣的行為,可能直接從文件示例來看,會比較容易了解。

# 08aexcept* 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'
    +------------------------------------

Raise Exception in except*

文件對此段的說明是:

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 exceptions in except*

此段文件花了不少篇幅講解,但主要就是說明下面兩種例外處理方式,即raise eraise其實是不一樣的。差別是當顯性地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 eraise前,也是一個獨立的EG(記得as e之後,e一定是EG嗎?),但因其又重新raise,所以會與還沒有被處理過的TypeError合併,成為最後被raiseEG
*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
        +------------------------------------

Caught Exception Objects

這一小段概念很直觀,我們直接看# 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中,混合使用exceptexcept*
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)

參考資料

Code

本日程式碼傳送門


上一篇
[Day24] 八翼 - Scopes:常見錯誤2(global與nonlocal)
下一篇
[Day26] 九翼 - Exception Groups與except*:相關應用
系列文
Python十翼:與未來的自己對話30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言