diff --git a/CHANGELOG.md b/CHANGELOG.md index f8c474b29..20b914e9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ incremental in minor, bugfixes only are patches. See [0Ver](https://0ver.org/). +### Features + + - Add the ability to add debugging notes to the `safe`, `impure_safe`, and + `future_safe` decorators. + + ## 0.25.0 ### Features diff --git a/returns/future.py b/returns/future.py index ecee5cf4d..2e95946b8 100644 --- a/returns/future.py +++ b/returns/future.py @@ -16,7 +16,10 @@ from returns.interfaces.specific.future_result import FutureResultBased2 from returns.io import IO, IOResult from returns.primitives.container import BaseContainer -from returns.primitives.exceptions import UnwrapFailedError +from returns.primitives.exceptions import ( + UnwrapFailedError, + add_note_to_exception, +) from returns.primitives.hkt import ( Kind1, Kind2, @@ -1342,7 +1345,7 @@ def from_io( >>> anyio.run(main) """ - return FutureResult.from_value(inner_value._inner_value) # noqa: SLF001 + return FutureResult.from_value(inner_value._inner_value) @classmethod def from_failed_io( @@ -1366,7 +1369,7 @@ def from_failed_io( >>> anyio.run(main) """ - return FutureResult.from_failure(inner_value._inner_value) # noqa: SLF001 + return FutureResult.from_failure(inner_value._inner_value) @classmethod def from_ioresult( @@ -1393,7 +1396,7 @@ def from_ioresult( >>> anyio.run(main) """ - return FutureResult(async_identity(inner_value._inner_value)) # noqa: SLF001 + return FutureResult(async_identity(inner_value._inner_value)) @classmethod def from_result( @@ -1537,6 +1540,7 @@ def future_safe( @overload def future_safe( exceptions: tuple[type[_ExceptionType], ...], + add_note_on_failure: bool | str = False, ) -> Callable[ [ Callable[ @@ -1548,6 +1552,7 @@ def future_safe( ]: ... +# add_note_on_failure is optional for backwards compatibility. def future_safe( # noqa: WPS212, WPS234, exceptions: ( Callable[ @@ -1556,6 +1561,7 @@ def future_safe( # noqa: WPS212, WPS234, ] | tuple[type[_ExceptionType], ...] ), + add_note_on_failure: bool | str = False, ) -> ( Callable[_FuncParams, FutureResultE[_ValueType_co]] | Callable[ @@ -1615,6 +1621,33 @@ def future_safe( # noqa: WPS212, WPS234, In this case, only exceptions that are explicitly listed are going to be caught. + In order to add a note to the exception, you can use the + ``add_note_on_failure`` argument. It can be a string or a boolean value. + Either way, a generic note will be added to the exception that calls out + the file, line number, and function name where the error occured. If a + string is provided, it will be added as an additional note to the + exception. + + This feature can help with logging and debugging. + + Note that if you use this option, you must provide a tuple of exception + types as the first argument. + + Note that passing a blank string to ``add_note_on_failure`` will be treated + the same as passing False, and will not add a note. + + .. code:: python + + >>> from returns.future import future_safe + + >>> @future_safe((Exception,), add_note_on_failure=True) + ... def error_throwing_function() -> None: + ... raise ValueError("This is an error!") + + >>> @future_safe((Exception,), add_note_on_failure="A custom message") + ... def error_throwing_function() -> None: + ... raise ValueError("This is an error!") + Similar to :func:`returns.io.impure_safe` and :func:`returns.result.safe` decorators, but works with ``async`` functions. @@ -1634,6 +1667,7 @@ async def factory( try: return Success(await function(*args, **kwargs)) except inner_exceptions as exc: + exc = add_note_to_exception(exc, add_note_on_failure, function) return Failure(exc) @wraps(function) diff --git a/returns/io.py b/returns/io.py index 53933157a..09d7c5df1 100644 --- a/returns/io.py +++ b/returns/io.py @@ -8,7 +8,10 @@ from returns.interfaces.specific import io, ioresult from returns.primitives.container import BaseContainer, container_equality -from returns.primitives.exceptions import UnwrapFailedError +from returns.primitives.exceptions import ( + UnwrapFailedError, + add_note_to_exception, +) from returns.primitives.hkt import ( Kind1, Kind2, @@ -905,16 +908,19 @@ def impure_safe( @overload def impure_safe( exceptions: tuple[type[_ExceptionType], ...], + add_note_on_failure: bool | str = False, ) -> Callable[ [Callable[_FuncParams, _NewValueType]], Callable[_FuncParams, IOResult[_NewValueType, _ExceptionType]], ]: ... +# add_note_on_failure is optional for backwards compatibility. def impure_safe( # noqa: WPS234 exceptions: ( Callable[_FuncParams, _NewValueType] | tuple[type[_ExceptionType], ...] ), + add_note_on_failure: bool | str = False, ) -> ( Callable[_FuncParams, IOResultE[_NewValueType]] | Callable[ @@ -960,6 +966,33 @@ def impure_safe( # noqa: WPS234 In this case, only exceptions that are explicitly listed are going to be caught. + In order to add a note to the exception, you can use the + ``add_note_on_failure`` argument. It can be a string or a boolean value. + Either way, a generic note will be added to the exception that calls out + the file, line number, and function name where the error occured. If a + string is provided, it will be added as an additional note to the + exception. + + This feature can help with logging and debugging. + + Note that if you use this option, you must provide a tuple of exception + types as the first argument. + + Note that passing a blank string to ``add_note_on_failure`` will be treated + the same as passing False, and will not add a note. + + .. code:: python + + >>> from returns.io import impure_safe + + >>> @impure_safe((Exception,), add_note_on_failure=True) + ... def error_throwing_function() -> None: + ... raise ValueError("This is an error!") + + >>> @impure_safe((Exception,), add_note_on_failure="A custom message") + ... def error_throwing_function() -> None: + ... raise ValueError("This is an error!") + Similar to :func:`returns.future.future_safe` and :func:`returns.result.safe` decorators. """ @@ -976,6 +1009,11 @@ def decorator( try: return IOSuccess(inner_function(*args, **kwargs)) except inner_exceptions as exc: + exc = add_note_to_exception( + exc, + add_note_on_failure, + inner_function, + ) return IOFailure(exc) return decorator diff --git a/returns/primitives/exceptions.py b/returns/primitives/exceptions.py index 21a65d013..2648cb471 100644 --- a/returns/primitives/exceptions.py +++ b/returns/primitives/exceptions.py @@ -1,11 +1,19 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from collections.abc import Callable +from typing import TYPE_CHECKING, TypeVar + +from typing_extensions import ParamSpec if TYPE_CHECKING: from returns.interfaces.unwrappable import Unwrappable # noqa: WPS433 +_ValueType_co = TypeVar('_ValueType_co', covariant=True) +_FuncParams = ParamSpec('_FuncParams') +_ExceptionType = TypeVar('_ExceptionType', bound=Exception) + + class UnwrapFailedError(Exception): """Raised when a container can not be unwrapped into a meaningful value.""" @@ -45,3 +53,40 @@ class ImmutableStateError(AttributeError): See: https://github.com/dry-python/returns/issues/394 """ + + +def add_note_to_exception( + exception: _ExceptionType, + message: bool | str, + function: Callable[_FuncParams, _ValueType_co], +) -> _ExceptionType: + """ + A utility function to add a generic note with file name, line number, and + function name to the exception. If a custom message is provided, it will be + added as an additional note to the exception. + """ + if not message: + return exception + + # If the user provides a custom message, add it as a note + # to the exception. Otherwise just add a generic note. + if isinstance(message, str): + exception.add_note(message) + + # Add the generic note. + exc_traceback = exception.__traceback__ + if exc_traceback is None: + return exception + + if exc_traceback.tb_next is None: + return exception + + filename = exc_traceback.tb_next.tb_frame.f_code.co_filename + line_number = exc_traceback.tb_next.tb_lineno + exception.add_note( + f'Exception occurred in {function.__name__} ' + f'of {filename} ' + f'at line number {line_number}.' + ) + + return exception diff --git a/returns/result.py b/returns/result.py index 7ca217d2a..57c70c135 100644 --- a/returns/result.py +++ b/returns/result.py @@ -8,7 +8,10 @@ from returns.interfaces.specific import result from returns.primitives.container import BaseContainer, container_equality -from returns.primitives.exceptions import UnwrapFailedError +from returns.primitives.exceptions import ( + UnwrapFailedError, + add_note_to_exception, +) from returns.primitives.hkt import Kind2, SupportsKind2 # Definitions: @@ -466,7 +469,6 @@ def failure(self) -> Never: # Decorators: - _ExceptionType = TypeVar('_ExceptionType', bound=Exception) @@ -480,16 +482,19 @@ def safe( @overload def safe( exceptions: tuple[type[_ExceptionType], ...], + add_note_on_failure: bool | str = False, ) -> Callable[ [Callable[_FuncParams, _ValueType_co]], Callable[_FuncParams, Result[_ValueType_co, _ExceptionType]], ]: ... +# add_note_on_failure is optional for backwards compatibility. def safe( # noqa: WPS234 exceptions: ( Callable[_FuncParams, _ValueType_co] | tuple[type[_ExceptionType], ...] ), + add_note_on_failure: bool | str = False, ) -> ( Callable[_FuncParams, ResultE[_ValueType_co]] | Callable[ @@ -534,6 +539,34 @@ def safe( # noqa: WPS234 In this case, only exceptions that are explicitly listed are going to be caught. + In order to add a note to the exception, you can use the + ``add_note_on_failure`` argument. It can be a string or a boolean value. + Either way, a generic note will be added to the exception that calls out + the file, line number, and function name where the error occured. If a + string is provided, it will be added as an additional note to the + exception. + + This feature can help with logging and debugging. + + Note that if you use this option, you must provide a tuple of exception + types as the first argument. + + Note that passing a blank string to ``add_note_on_failure`` will be treated + the same as passing False, and will not add a note. + + + .. code:: python + + >>> from returns.result import safe + + >>> @safe((Exception,), add_note_on_failure=True) + ... def error_throwing_function() -> None: + ... raise ValueError("This is an error!") + + >>> @safe((Exception,), add_note_on_failure="A custom message") + ... def error_throwing_function() -> None: + ... raise ValueError("This is an error!") + Similar to :func:`returns.io.impure_safe` and :func:`returns.future.future_safe` decorators. """ @@ -550,6 +583,11 @@ def decorator( try: return Success(inner_function(*args, **kwargs)) except inner_exceptions as exc: + exc = add_note_to_exception( + exc, + add_note_on_failure, + inner_function, + ) return Failure(exc) return decorator diff --git a/tests/test_future/test_add_note_future_safe.py b/tests/test_future/test_add_note_future_safe.py new file mode 100644 index 000000000..830bd079b --- /dev/null +++ b/tests/test_future/test_add_note_future_safe.py @@ -0,0 +1,79 @@ +from returns.future import future_safe +from returns.pipeline import is_successful + + +@future_safe((Exception,), add_note_on_failure=True) +async def error_throwing_function() -> None: + """Raises an exception.""" + raise ValueError('This is an error!') + + +@future_safe((Exception,), add_note_on_failure='A custom message') +async def error_throwing_function_with_message() -> None: + """Raises an exception.""" + raise ValueError('This is an error!') + + +@future_safe((Exception,), add_note_on_failure='') +async def error_throwing_function_with_empty_str() -> None: + """Raises an exception.""" + raise ValueError('This is an error!') + + +@future_safe +async def error_throwing_function_without_note() -> None: + """Raises an exception.""" + raise ValueError('This is an error!') + + +async def test_add_note_safe() -> None: + """Tests the add_note decorator with safe.""" + result = await error_throwing_function() + + print(result) + print(result.failure()._inner_value.__notes__) + print(result.failure()) + assert not is_successful(result) + assert ( + 'Exception occurred in error_throwing_function' + in result.failure()._inner_value.__notes__[0] + ) + + +async def test_add_note_safe_with_message() -> None: + """Tests the add_note decorator with safe.""" + result = await error_throwing_function_with_message() + + print(result) + print(result.failure()._inner_value.__notes__) + print(result.failure()) + assert not is_successful(result) + assert 'A custom message' in result.failure()._inner_value.__notes__ + assert ( + 'Exception occurred in error_throwing_function_with_message' + in result.failure()._inner_value.__notes__[1] + ) + + +async def test_add_note_safe_with_empty_str() -> None: + """Tests the add_note decorator with safe.""" + result = await error_throwing_function_with_empty_str() + + print(result) + + # Passing an empty string to add_note_on_failure should be treated as + # passing False, so no note should be added + assert not hasattr(result.failure()._inner_value, '__notes__') + assert not is_successful(result) + + +async def test_add_note_safe_without_note() -> None: + """Tests the vanilla functionality of the safe decortaor.""" + result = await error_throwing_function_without_note() + + print(result) + + # Make sure that the add_note_on_failure functionality does not break the + # vanilla functionality of the safe decorator + assert not hasattr(result.failure()._inner_value, '__notes__') + assert not is_successful(result) diff --git a/tests/test_io/test_add_note_impure_safe.py b/tests/test_io/test_add_note_impure_safe.py new file mode 100644 index 000000000..a9a2fa808 --- /dev/null +++ b/tests/test_io/test_add_note_impure_safe.py @@ -0,0 +1,79 @@ +from returns.io import impure_safe +from returns.pipeline import is_successful + + +@impure_safe((Exception,), add_note_on_failure=True) +def error_throwing_function() -> None: + """Raises an exception.""" + raise ValueError('This is an error!') + + +@impure_safe((Exception,), add_note_on_failure='A custom message') +def error_throwing_function_with_message() -> None: + """Raises an exception.""" + raise ValueError('This is an error!') + + +@impure_safe((Exception,), add_note_on_failure='') +def error_throwing_function_with_empty_str() -> None: + """Raises an exception.""" + raise ValueError('This is an error!') + + +@impure_safe +def error_throwing_function_without_note() -> None: + """Raises an exception.""" + raise ValueError('This is an error!') + + +def test_add_note_impure_safe() -> None: + """Tests the add_note decorator with impure_safe.""" + result = error_throwing_function() + + print(result) + print(result.failure()._inner_value.__notes__) + print(result.failure()) + assert not is_successful(result) + assert ( + 'Exception occurred in error_throwing_function' + in result.failure()._inner_value.__notes__[0] + ) + + +def test_add_note_impure_safe_with_message() -> None: + """Tests the add_note decorator with safe.""" + result = error_throwing_function_with_message() + + print(result) + print(result.failure()._inner_value.__notes__) + print(result.failure()) + assert not is_successful(result) + assert 'A custom message' in result.failure()._inner_value.__notes__ + assert ( + 'Exception occurred in error_throwing_function_with_message' + in result.failure()._inner_value.__notes__[1] + ) + + +def test_add_note_impure_safe_with_empty_str() -> None: + """Tests the add_note decorator with safe.""" + result = error_throwing_function_with_empty_str() + + print(result) + + # Passing an empty string to add_note_on_failure should be treated as + # passing False, so no note should be added + assert not hasattr(result.failure()._inner_value, '__notes__') + assert not is_successful(result) + + +def test_add_note_impure_safe_without_note() -> None: + """Tests the vanilla functionality of the safe decortaor.""" + result = error_throwing_function_without_note() + + print(result) + + # Make sure that the add_note_on_failure functionality does not break the + # vanilla functionality of the safe decorator + assert not hasattr(result.failure()._inner_value, '__notes__') + assert not is_successful(result) diff --git a/tests/test_result/test_add_note_safe.py b/tests/test_result/test_add_note_safe.py new file mode 100644 index 000000000..32f93c04d --- /dev/null +++ b/tests/test_result/test_add_note_safe.py @@ -0,0 +1,79 @@ +from returns.pipeline import is_successful +from returns.result import safe + + +@safe((Exception,), add_note_on_failure=True) +def error_throwing_function() -> None: + """Raises an exception.""" + raise ValueError('This is an error!') + + +@safe((Exception,), add_note_on_failure='A custom message') +def error_throwing_function_with_message() -> None: + """Raises an exception.""" + raise ValueError('This is an error!') + + +@safe((Exception,), add_note_on_failure='') +def error_throwing_function_with_empty_str() -> None: + """Raises an exception.""" + raise ValueError('This is an error!') + + +@safe +def error_throwing_function_without_note() -> None: + """Raises an exception.""" + raise ValueError('This is an error!') + + +def test_add_note_safe() -> None: + """Tests the add_note decorator with safe.""" + result = error_throwing_function() + + print(result) + print(result.failure().__notes__) + print(result.failure()) + assert not is_successful(result) + assert ( + 'Exception occurred in error_throwing_function' + in result.failure().__notes__[0] + ) + + +def test_add_note_safe_with_message() -> None: + """Tests the add_note decorator with safe.""" + result = error_throwing_function_with_message() + + print(result) + print(result.failure().__notes__) + print(result.failure()) + assert not is_successful(result) + assert 'A custom message' in result.failure().__notes__ + assert ( + 'Exception occurred in error_throwing_function_with_message' + in result.failure().__notes__[1] + ) + + +def test_add_note_safe_with_empty_str() -> None: + """Tests the add_note decorator with safe.""" + result = error_throwing_function_with_empty_str() + + print(result) + + # Passing an empty string to add_note_on_failure should be treated as + # passing False, so no note should be added + assert not hasattr(result.failure(), '__notes__') + assert not is_successful(result) + + +def test_add_note_safe_without_note() -> None: + """Tests the vanilla functionality of the safe decortaor.""" + result = error_throwing_function_without_note() + + print(result) + + # Make sure that the add_note_on_failure functionality does not break the + # vanilla functionality of the safe decorator + assert not hasattr(result.failure(), '__notes__') + assert not is_successful(result) diff --git a/typesafety/test_future/test_future_result_container/test_future_safe_decorator.yml b/typesafety/test_future/test_future_result_container/test_future_safe_decorator.yml index 99b8fe91e..fe5991dd2 100644 --- a/typesafety/test_future/test_future_result_container/test_future_safe_decorator.yml +++ b/typesafety/test_future/test_future_result_container/test_future_safe_decorator.yml @@ -42,6 +42,21 @@ reveal_type(test) # N: Revealed type is "def (first: builtins.int, second: Union[builtins.str, None] =, *, kw: builtins.bool =) -> returns.future.FutureResult[builtins.int, builtins.ValueError]" +- case: future_safe_decorator_with_pos_params_add_note + disable_cache: false + main: | + from typing import Optional + from returns.future import future_safe + + @future_safe((ValueError,), add_note_on_failure=True) + async def test( + first: int, second: Optional[str] = None, *, kw: bool = True, + ) -> int: + return 1 + + reveal_type(test) # N: Revealed type is "def (first: builtins.int, second: Union[builtins.str, None] =, *, kw: builtins.bool =) -> returns.future.FutureResult[builtins.int, builtins.ValueError]" + + - case: future_safe_decorator_with_named_params disable_cache: false main: | diff --git a/typesafety/test_io/test_ioresult_container/test_impure_safe.yml b/typesafety/test_io/test_ioresult_container/test_impure_safe.yml index fff048e76..3f25b2402 100644 --- a/typesafety/test_io/test_ioresult_container/test_impure_safe.yml +++ b/typesafety/test_io/test_ioresult_container/test_impure_safe.yml @@ -26,3 +26,15 @@ return 1 reveal_type(test2) # N: Revealed type is "def (arg: builtins.str) -> returns.io.IOResult[builtins.int, builtins.ValueError]" + + +- case: impure_decorator_passing_exceptions_no_params_add_note + disable_cache: false + main: | + from returns.io import impure_safe + + @impure_safe((ValueError,), add_note_on_failure=True) + def test1(arg: str) -> int: + return 1 + + reveal_type(test1) # N: Revealed type is "def (arg: builtins.str) -> returns.io.IOResult[builtins.int, builtins.ValueError]" diff --git a/typesafety/test_result/test_safe.yml b/typesafety/test_result/test_safe.yml index 2ecc69496..362f80c8b 100644 --- a/typesafety/test_result/test_safe.yml +++ b/typesafety/test_result/test_safe.yml @@ -28,6 +28,18 @@ reveal_type(test2) # N: Revealed type is "def () -> returns.result.Result[builtins.int, builtins.ValueError]" +- case: safe_decorator_no_params_add_note + disable_cache: false + main: | + from returns.result import safe + + @safe((ValueError,), add_note_on_failure=True) + def test() -> int: + return 1 + + reveal_type(test) # N: Revealed type is "def () -> returns.result.Result[builtins.int, builtins.Exception]" + + - case: safe_composition_no_params disable_cache: false main: |