Skip to content

Commit

Permalink
✨Provide input parameters to callback functions + some rework of inte…
Browse files Browse the repository at this point in the history
…rnal structure (#6)

* ✨Provide input parameters to callback functions

* 🚧wip

* 🚧Finished

* Fancy generator shit to avoid code duplication error

* 🚨pylint

* Expose less to __init__

* Better error

* Add concurrency Unittests

* Add unittests for callback errors

* 🔥

* More coverage

* 🚨pylint

* 📄

* Better typing

* 🔥
  • Loading branch information
lord-haffi authored Mar 4, 2024
1 parent f4c2610 commit ed8ecad
Show file tree
Hide file tree
Showing 17 changed files with 863 additions and 334 deletions.
40 changes: 31 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ This framework provides several error handlers to catch errors and call callback
(or successes). It comes fully equipped with:

- A decorator to handle errors in functions or coroutines
- A decorator to retry a function or coroutine if it fails
- A decorator to retry a function or coroutine if it fails (can be useful for network requests)
- A context manager to handle errors in a block of code

Additionally, if you use `aiostream` (e.g. using `pip install seviper[aiostream]`), you can use the following features:
Expand All @@ -31,37 +31,40 @@ pip install seviper[aiostream]
```

## Usage
Here is a complex example as showcase of the features of this library:
Here is a more or less complex example as showcase of the features of this library:

```python
import asyncio
import logging
import sys
import aiostream
import error_handler
import logging

logging.basicConfig(stream=sys.stdout, level=logging.DEBUG, force=True)
logger = logging.root
op = aiostream.stream.iterate(range(10))

def log_error(error: Exception):
def log_error(error: Exception, num: int):
"""Only log error and reraise it"""
logging.error(error)
logger.error("double_only_odd_nums_except_5 failed for input %d. ", num)
raise error

@error_handler.decorator(on_error=log_error)
async def double_only_odd_nums_except_5(num: int) -> int:
if num % 2 == 0:
raise ValueError(num)
with error_handler.context_manager(on_success=lambda: logging.info(f"Success: {num}")):
with error_handler.context_manager(on_success=lambda: logging.info("Success: %s", num)):
if num == 5:
raise RuntimeError("Another unexpected error. Number 5 will not be doubled.")
num *= 2
return num

def catch_value_errors(error: Exception):
def catch_value_errors(error: Exception, _: int):
if not isinstance(error, ValueError):
raise error

def log_success(num: int):
logging.info(f"Success: {num}")
def log_success(result_num: int, provided_num: int):
logger.info("Success: %d -> %d", provided_num, result_num)

op = op | error_handler.pipe.map(
double_only_odd_nums_except_5,
Expand All @@ -76,6 +79,25 @@ result = asyncio.run(aiostream.stream.list(op))
assert result == [2, 6, 5, 14, 18]
```

This outputs:

```
ERROR:root:double_only_odd_nums_except_5 failed for input 0.
INFO:root:Success: 2
INFO:root:Success: 1 -> 2
ERROR:root:double_only_odd_nums_except_5 failed for input 2.
INFO:root:Success: 6
INFO:root:Success: 3 -> 6
ERROR:root:double_only_odd_nums_except_5 failed for input 4.
INFO:root:Success: 5 -> 5
ERROR:root:double_only_odd_nums_except_5 failed for input 6.
INFO:root:Success: 14
INFO:root:Success: 7 -> 14
ERROR:root:double_only_odd_nums_except_5 failed for input 8.
INFO:root:Success: 18
INFO:root:Success: 9 -> 18
```

## How to use this Repository on Your Machine

Please refer to the respective section in our [Python template repository](https://github.com/Hochfrequenz/python_template_repository?tab=readme-ov-file#how-to-use-this-repository-on-your-machine)
Expand Down
12 changes: 6 additions & 6 deletions src/error_handler/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,23 @@
"""

import importlib
from typing import TYPE_CHECKING

from .context_manager import context_manager
from .core import Catcher
from .decorator import decorator, retry_on_error
from .types import (
ERRORED,
UNSET,
AsyncFunctionType,
ErroredType,
FunctionType,
NegativeResult,
PositiveResult,
ResultType,
SecuredAsyncFunctionType,
SecuredFunctionType,
UnsetType,
)

stream = importlib.import_module("error_handler.stream")
pipe = importlib.import_module("error_handler.pipe")
if TYPE_CHECKING:
from . import pipe, stream
else:
stream = importlib.import_module("error_handler.stream")
pipe = importlib.import_module("error_handler.pipe")
150 changes: 150 additions & 0 deletions src/error_handler/callback.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
"""
This module contains the Callback class, which is used to wrap a callable and its expected signature.
The expected signature is only used to give nicer error messages when the callback is called with the wrong
arguments. Just in case that the type checker is not able to spot callback functions with wrong signatures.
"""

import inspect
from typing import Any, Callable, Generic, ParamSpec, Sequence, TypeVar, cast

from .types import UNSET

_P = ParamSpec("_P")
_T = TypeVar("_T")
_CallbackT = TypeVar("_CallbackT", bound="Callback")
_ErrorCallbackT = TypeVar("_ErrorCallbackT", bound="ErrorCallback")
_SuccessCallbackT = TypeVar("_SuccessCallbackT", bound="SuccessCallback")


class Callback(Generic[_P, _T]):
"""
This class wraps a callable and its expected signature.
"""

def __init__(self, callback: Callable[_P, _T], expected_signature: inspect.Signature):
self.callback = callback
self.expected_signature = expected_signature
self._actual_signature: inspect.Signature | None = None

@property
def actual_signature(self) -> inspect.Signature:
"""
The actual signature of the callback
"""
if self._actual_signature is None:
self._actual_signature = inspect.signature(self.callback)
return self._actual_signature

@property
def expected_signature_str(self) -> str:
"""
The expected signature as string
"""
return str(self.expected_signature)

@property
def actual_signature_str(self) -> str:
"""
The actual signature as string
"""
return str(self.actual_signature)

@classmethod
def from_callable(
cls: type[_CallbackT],
callback: Callable,
signature_from_callable: Callable[..., Any] | inspect.Signature | None = None,
add_params: Sequence[inspect.Parameter] | None = None,
return_type: Any = UNSET,
) -> _CallbackT:
"""
Create a new Callback instance from a callable. The expected signature will be taken from the
signature_from_callable. You can add additional parameters or change the return type for the
expected signature.
"""
if signature_from_callable is None:
sig = inspect.Signature()
elif isinstance(signature_from_callable, inspect.Signature):
sig = signature_from_callable
else:
sig = inspect.signature(signature_from_callable)
if add_params is not None or return_type is not None:
params = list(sig.parameters.values())
if add_params is not None:
params = [*add_params, *params]
if return_type is UNSET:
return_type = sig.return_annotation
sig = sig.replace(parameters=params, return_annotation=return_type)
return cls(callback, sig)

def __call__(self, *args: _P.args, **kwargs: _P.kwargs) -> _T:
"""
Call the callback with the given arguments and keyword arguments. The arguments will be checked against the
expected signature. If the callback does not match the expected signature, a TypeError explaining which
signature was expected will be raised.
"""
try:
filled_signature = self.actual_signature.bind(*args, **kwargs)
except TypeError:
# pylint: disable=raise-missing-from
# I decided to leave this out because the original exception is less helpful and spams the stack trace.
# Please read: https://docs.python.org/3/library/exceptions.html#BaseException.__suppress_context__
raise TypeError(
f"Arguments do not match signature of callback {self.callback.__name__}{self.actual_signature_str}. "
f"Callback function must match signature: {self.callback.__name__}{self.expected_signature_str}"
) from None
return self.callback(*filled_signature.args, **filled_signature.kwargs)


class ErrorCallback(Callback[_P, _T]):
"""
This class wraps an error callback. It is a subclass of Callback and adds the error parameter to the expected
signature.
"""

_CALLBACK_ERROR_PARAM = inspect.Parameter("error", inspect.Parameter.POSITIONAL_OR_KEYWORD, annotation=Exception)

@classmethod
def from_callable(
cls: type[_ErrorCallbackT],
callback: Callable,
signature_from_callable: Callable[..., Any] | inspect.Signature | None = None,
add_params: Sequence[inspect.Parameter] | None = None,
return_type: Any = UNSET,
) -> _ErrorCallbackT:
if add_params is None:
add_params = []
inst = cast(
_ErrorCallbackT,
super().from_callable(
callback, signature_from_callable, [cls._CALLBACK_ERROR_PARAM, *add_params], return_type
),
)
return inst


class SuccessCallback(Callback[_P, _T]):
"""
This class wraps a success callback. It is a subclass of Callback and adds the result parameter to the expected
signature. The annotation type is taken from the return annotation of the `signature_from_callable`.
"""

@classmethod
def from_callable(
cls: type[_SuccessCallbackT],
callback: Callable,
signature_from_callable: Callable[..., Any] | inspect.Signature | None = None,
add_params: Sequence[inspect.Parameter] | None = None,
return_type: Any = UNSET,
) -> _SuccessCallbackT:
inst = cast(_SuccessCallbackT, super().from_callable(callback, signature_from_callable, add_params))
add_param = inspect.Parameter(
"result", inspect.Parameter.POSITIONAL_OR_KEYWORD, annotation=inst.expected_signature.return_annotation
)
if return_type is UNSET:
return_type = inst.expected_signature.return_annotation
inst.expected_signature = inst.expected_signature.replace(
parameters=[add_param, *inst.expected_signature.parameters.values()],
return_annotation=return_type,
)
return inst
21 changes: 16 additions & 5 deletions src/error_handler/context_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,22 @@
This module provides a context manager to handle errors in a convenient way.
"""

from typing import Any, Callable, ContextManager
from contextlib import contextmanager
from typing import Any, Callable, Iterator

from .callback import Callback, ErrorCallback
from .core import Catcher
from .types import UnsetType


# pylint: disable=unsubscriptable-object
@contextmanager
def context_manager(
on_success: Callable[[], Any] | None = None,
on_error: Callable[[Exception], Any] | Callable[[], Any] | None = None,
on_error: Callable[[Exception], Any] | None = None,
on_finalize: Callable[[], Any] | None = None,
suppress_recalling_on_error: bool = True,
) -> ContextManager[Catcher[None]]:
) -> Iterator[Catcher[UnsetType]]:
"""
This context manager catches all errors inside the context and calls the corresponding callbacks.
It is a shorthand for creating a Catcher instance and using its secure_context method.
Expand All @@ -24,5 +28,12 @@ def context_manager(
If suppress_recalling_on_error is True, the on_error callable will not be called if the error were already
caught by a previous catcher.
"""
catcher = Catcher[None](on_success, on_error, on_finalize, suppress_recalling_on_error=suppress_recalling_on_error)
return catcher.secure_context()
catcher = Catcher[UnsetType](
Callback.from_callable(on_success, return_type=Any) if on_success is not None else None,
ErrorCallback.from_callable(on_error, return_type=Any) if on_error is not None else None,
Callback.from_callable(on_finalize, return_type=Any) if on_finalize is not None else None,
suppress_recalling_on_error=suppress_recalling_on_error,
)
with catcher.secure_context():
yield catcher
catcher.handle_result_and_call_callbacks(catcher.result)
Loading

0 comments on commit ed8ecad

Please sign in to comment.