type hint of function exceptions
Hi everyone, I've read https://stackoverflow.com/questions/44282268/python-type-hinting-with-except... and especially the https://jonathan-scholbach.github.io/2023/02/06/what-does-it-mean-to-be-exce... blog post, but I want to keep coding with exceptions rather than returning `Ok | Err`. Here is a simple pattern I'm thinking to use, to let a function indicate the `Exception`s it may raised, which can be used: - by the developper as documentation, - by the static type checker for exhaustiveness ```python from typing import NoReturn, cast, TypeAlias class SomeError(Exception): ... class AnotherError(Exception): ... class StillAnotherError(Exception): ... FooError: TypeAlias = ValueError | SomeError | AnotherError def assert_never(value: NoReturn) -> NoReturn: # This also works at runtime as well assert False, f"This code should never be reached, got: {value}" def foo(i: int): if i == 0: raise ValueError("Must not be zero") elif i == 1: raise SomeError("Must not be one") elif i == 2: raise AnotherError("Must not be two") print("that's ok") try: foo(0) except Exception as e: e = cast(FooError, e) if isinstance(e, ValueError): print("value error") elif isinstance(e, SomeError): print("some error") else: assert_never(e) raise ``` It's far from being perfect, but it's simple and not too much intrusive nor obscure. In the above example, `mypy` can tell me which exception I forgot to manage: ``` foo.py:43: error: Argument 1 to "assert_never" has incompatible type "AnotherError"; expected "NoReturn" [arg-type] Found 1 error in 1 file (checked 1 source file) ``` What do you thing of this? Here is a proposal where the exception are annotated in the return type, and the try/except would directly check for exhaustiveness: ``` from typing import NoReturn, Annotated class SomeError(Exception): ... class AnotherError(Exception): ... class StillAnotherError(Exception): ... def assert_never(value: NoReturn) -> NoReturn: # This also works at runtime as well assert False, f"This code should never be reached, got: {value}" def Raises(exceptions): ... # TODO class NeverException(Exception): ... # TODO def foo(i: int) -> Annotated[None, Raises(ValueError | SomeError | AnotherError)]: if i == 0: ¦ raise ValueError("Must not be zero") elif i == 1: ¦ raise SomeError("Must not be one") elif i == 2: ¦ raise AnotherError("Must not be two") print("that's ok") try: foo(0) except ValueError: print("value error") except SomeError: print("some error") except NeverException: # static type checker warns here that AnotherError is not managed raise ``` and here is another fictive example with one intermediary function managing one of the 3 exceptions: ```python from typing import NoReturn, Annotated class SomeError(Exception): ... class AnotherError(Exception): ... class StillAnotherError(Exception): ... def assert_never(value: NoReturn) -> NoReturn: # This also works at runtime as well assert False, f"This code should never be reached, got: {value}" def Raises(exceptions): ... # TODO class NeverException(Exception): ... # TODO def foo(i: int) -> Annotated[None, Raises(ValueError | SomeError | AnotherError)]: if i == 0: ¦ raise ValueError("Must not be zero") elif i == 1: ¦ raise SomeError("Must not be one") elif i == 2: ¦ raise AnotherError("Must not be two") print("that's ok") def bar(i: int) -> Annotated[None, Raises(ValueError | SomeError)]: try: ¦ foo(i) except AnotherError: ¦ print("another error") try: foo(0) except ValueError: print("value error") except SomeError: print("some error") except NeverException: # no errer reported by the static type checker raise ``` Thanks!
The first version can be simplified with a tuple of exceptions rather than a union: ``` from typing import NoReturn class SomeError(Exception): ... class AnotherError(Exception): ... class StillAnotherError(Exception): ... def assert_never(value: NoReturn) -> NoReturn: # This also works at runtime as well assert False, f"This code should never be reached, got: {value}" FooError = (ValueError, SomeError, AnotherError) def foo(i: int): if i == 0: raise ValueError("Must not be zero") elif i == 1: raise SomeError("Must not be one") elif i == 2: raise AnotherError("Must not be two") print("that's ok") try: foo(0) except FooError as e: if isinstance(e, ValueError): print("value error") elif isinstance(e, SomeError): print("some error") else: # mypy successfully check for exhaustiveness: Argument 1 to "assert_never" has incompatible type "AnotherError"; expected "NoReturn" [arg-type] assert_never(e) raise ```
You can use something like https://github.com/dry-python/returns#result-container It just works right now! вс, 12 мар. 2023 г. в 21:40, <david.froger@mailoo.org>:
The first version can be simplified with a tuple of exceptions rather than a union:
``` from typing import NoReturn
class SomeError(Exception): ...
class AnotherError(Exception): ...
class StillAnotherError(Exception): ...
def assert_never(value: NoReturn) -> NoReturn: # This also works at runtime as well assert False, f"This code should never be reached, got: {value}"
FooError = (ValueError, SomeError, AnotherError)
def foo(i: int): if i == 0: raise ValueError("Must not be zero") elif i == 1: raise SomeError("Must not be one") elif i == 2: raise AnotherError("Must not be two") print("that's ok")
try: foo(0) except FooError as e: if isinstance(e, ValueError): print("value error") elif isinstance(e, SomeError): print("some error") else: # mypy successfully check for exhaustiveness: Argument 1 to "assert_never" has incompatible type "AnotherError"; expected "NoReturn" [arg-type] assert_never(e) raise ``` _______________________________________________ Typing-sig mailing list -- typing-sig@python.org To unsubscribe send an email to typing-sig-leave@python.org https://mail.python.org/mailman3/lists/typing-sig.python.org/ Member address: n.a.sobolev@gmail.com
Excerpts from Никита Соболев's message of mars 13, 2023 12:31 am:
You can use something like https://github.com/dry-python/returns#result-container It just works right now!
Thanks! I like to see new paradagims and well design frameworks like this. I was not aware of "returns" and I'll study it, however I'm pretty happy how Python exceptions work by default, just looking for more static typing. Back to the first example, I plan to work on a decorator like this: @raises(ValueError, SomeError, AnotherError) def foo(i: int): if i == 0: raise ValueError("Must not be zero") elif i == 1: raise SomeError("Must not be one") elif i == 2: raise AnotherError("Must not be two") print("that's ok")
So here is the new code with the @raises decorator: ```python from typing import Callable, NoReturn, ParamSpec, TypeVar, Protocol, cast # ============================================================================== # tooling # ============================================================================== def assert_never(value: NoReturn) -> NoReturn: # This also works at runtime as well assert False, f"This code should never be reached, got: {value}" P = ParamSpec("P") R = TypeVar("R", covariant=True) E = TypeVar("E", bound=tuple) class RaisingFunc(Protocol[P, R, E]): errors: E def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R: ... def raises(errors: E) -> Callable[[Callable[P, R]], RaisingFunc[P, R, E]]: def decorator(func: Callable[P, R]) -> RaisingFunc[P, R, E]: func = cast(RaisingFunc[P, R, E], func) func.errors = errors return func return decorator # ============================================================================== # demo code # ============================================================================== class SomeError(Exception): ... class AnotherError(Exception): ... @raises((ValueError, SomeError, AnotherError)) def foo(i: int): if i == 0: raise ValueError("Must not be zero") elif i == 1: raise SomeError("Must not be one") elif i == 2: raise AnotherError("Must not be two") print("that's ok") @raises((ValueError, AnotherError)) def bar(i: int): try: foo(i) except foo.errors as e: if isinstance(e, SomeError): print("ignore some error") else: raise try: bar(0) except bar.errors as e: if isinstance(e, ValueError): print("value error") elif isinstance(e, AnotherError): print("some error") else: # Here mypy checks for exhaustiveness assert_never(e) raise ``` If the static type checker could be aware of the exceptions that a function can raise, we could imagine such code: ```python class SomeError(Exception): ... class AnotherError(Exception): ... @raises(ValueError, SomeError, AnotherError) def foo(i: int): if i == 0: raise ValueError("Must not be zero") elif i == 1: raise SomeError("Must not be one") elif i == 2: raise AnotherError("Must not be two") print("that's ok") # mypy checks that @raises arguments are correct, as mypy has deduced # from the source code that (ValueError, SomeError, AnotherError) # can be raised. @raises(ValueError, AnotherError) def bar(i: int): try: foo(i) except foo.errors as e: if isinstance(e, SomeError): print("ignore some error") else: raise # mypy checks that @raises arguments are correct, as mypy has deduced # from the source code that (ValueError, AnotherError) can be # raised, because mypy see that foo(i) can raise # (ValueError, SomeError, AnotherError), but SomeError is catched try: bar(0) except ValueError: print("value error") except AnotherError: print("some error") # Here mypy checks for exhaustiveness, as it kwnow that bar(i) can raise # ValueError or AnotherError ``` What do you tkink about this? Thanks!
participants (3)
-
David Froger -
david.froger@mailoo.org -
Никита Соболев