Skip to content

Add the ability to add debugging notes to the result.safe decorator. #2110

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 10 commits into
base: master
Choose a base branch
from
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
38 changes: 34 additions & 4 deletions returns/future.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
dekind,
)
from returns.primitives.reawaitable import ReAwaitable
from returns.result import Failure, Result, Success
from returns.result import Failure, Result, Success, add_note_to_exception

# Definitions:
_ValueType_co = TypeVar('_ValueType_co', covariant=True)
Expand Down Expand Up @@ -1342,7 +1342,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(
Expand All @@ -1366,7 +1366,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(
Expand All @@ -1393,7 +1393,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(
Expand Down Expand Up @@ -1537,6 +1537,7 @@ def future_safe(
@overload
def future_safe(
exceptions: tuple[type[_ExceptionType], ...],
add_note_on_failure: bool | str = False,
) -> Callable[
[
Callable[
Expand All @@ -1556,6 +1557,7 @@ def future_safe( # noqa: WPS212, WPS234,
]
| tuple[type[_ExceptionType], ...]
),
add_note_on_failure: bool | str = False,
) -> (
Callable[_FuncParams, FutureResultE[_ValueType_co]]
| Callable[
Expand Down Expand Up @@ -1615,6 +1617,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.

Expand All @@ -1634,6 +1663,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)
Expand Down
36 changes: 35 additions & 1 deletion returns/io.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
SupportsKind2,
dekind,
)
from returns.result import Failure, Result, Success
from returns.result import Failure, Result, Success, add_note_to_exception

_ValueType_co = TypeVar('_ValueType_co', covariant=True)
_NewValueType = TypeVar('_NewValueType')
Expand Down Expand Up @@ -905,6 +905,7 @@ 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]],
Expand All @@ -915,6 +916,7 @@ def impure_safe( # noqa: WPS234
exceptions: (
Callable[_FuncParams, _NewValueType] | tuple[type[_ExceptionType], ...]
),
add_note_on_failure: bool | str = False,
) -> (
Callable[_FuncParams, IOResultE[_NewValueType]]
| Callable[
Expand Down Expand Up @@ -960,6 +962,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.
"""
Expand All @@ -976,6 +1005,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
Expand Down
66 changes: 66 additions & 0 deletions returns/result.py
Original file line number Diff line number Diff line change
Expand Up @@ -470,6 +470,36 @@ def failure(self) -> Never:
_ExceptionType = TypeVar('_ExceptionType', bound=Exception)


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 message:
# 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 not None:
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


@overload
def safe(
function: Callable[_FuncParams, _ValueType_co],
Expand All @@ -480,16 +510,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[
Expand Down Expand Up @@ -534,6 +567,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.
"""
Expand All @@ -550,6 +611,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
Expand Down
79 changes: 79 additions & 0 deletions tests/test_future/test_add_note_future_safe.py
Original file line number Diff line number Diff line change
@@ -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)
Loading
Loading