Skip to content

type hint of function exceptions #14900

Closed as not planned
Closed as not planned
@dfroger

Description

@dfroger

Feature

More support on exception static checking.

Pitch

Statically declare the exceptions E that a function f can raise, which will be statically checked by comparing E with the exceptions F that can be raised by the functions called inside f minus the exceptions C caught by f: E = F - C.

Also discussed in typing-sig mailing list.

Example

Working with Python 3.11

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

Requires Mypy plugin, or new feature

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 deduces
    # 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 deduces
    # 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 knows that bar(i) can raise
# ValueError or AnotherError

Edge cases

I didn't think to much for now to edge cases or counter examples that do not work.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions