diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f23a04..c8e653f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## v8.2.0 - 2026-10-12 + +* Added `raise_from()` and `Buzz.raise_from()` for wrapping exceptions while preserving their causes +* Added tests, documentation, examples, and demos for `raise_from()` + + ## v8.1.2 - 2026-04-10 * Added `BaseException` widenign from 8.1.1 to base Buzz class as well. @@ -175,6 +181,7 @@ reference the old callback signatures, you'll need to update them to use the new * Added `enforce_defined()` to Buzz class. + ## v3.2.0 - 2022-06-20 * Added `enforce_defined()` function with tests and docs. diff --git a/docs/source/features.md b/docs/source/features.md index c85697b..73095a7 100644 --- a/docs/source/features.md +++ b/docs/source/features.md @@ -541,6 +541,7 @@ with handle_errors("Something went wrong", do_finally=close_resource): some_dangerous_function_that_uses_resource(resource) ``` + #### `exc_builder` Functions the same as `require_condition()`. @@ -582,7 +583,8 @@ The `retry()` decorator accepts a required message parameter followed by several #### `message` -The message for the exception raised after all retries are exhausted. This is a **required positional parameter**. Supports the `{attempts}` placeholder which will be replaced with the actual number of attempts. +The message for the exception raised after all retries are exhausted. This is a **required positional parameter**. +Supports the `{attempts}` placeholder which will be replaced with the actual number of attempts. #### `max_attempts` @@ -693,7 +695,7 @@ with check_expressions(main_message='there will be errors') as check: If the above code was executed, an exception would be raised that looks like this: -``` +```text Exception: Checked expressions failed: there will be errors 2: 2nd expression failed 3: one is not two @@ -735,6 +737,39 @@ Functions the same as `require_condition()`. ## Additional Features +### `raise_from` + +Use `raise_from()` to wrap an exception when handling it yourself is more useful than using the `handle_errors()` +context manager. The new exception keeps the original exception as its cause, so Python's traceback shows both errors: + +```python +from buzz import raise_from + +try: + load_profile() +except OSError as err: + raise_from("Could not load profile", err, raise_exc_class=RuntimeError) +``` + +By default, the raised exception message includes the base message, the original exception type, and its message. Pass +`final_message` to supply a preformatted message, `trace` to attach a specific traceback, or `exc_builder` to construct +an exception with a nonstandard constructor. `raise_args` and `raise_kwargs` provide additional constructor arguments. + +For exceptions derived from `Buzz`, use the class method to raise an instance of that exception class: + +```python +from buzz import Buzz + +class ProfileError(Buzz): + pass + +try: + load_profile() +except OSError as err: + ProfileError.raise_from("Could not load profile", err) +``` + + ### `reformat_exception` This method is used internally by the `handle_errors()` context manager. However, it is @@ -750,7 +785,7 @@ except Exception as err: The above block would result in output like: -``` +```text > welp...that didn't work -- ValueError: I didn't like that ``` @@ -787,6 +822,7 @@ The `Buzz` base class provides the same sort of access for: - `ensure_type()` - `verify_literal()` - `handle_errors()` +- `raise_from()` - `check_expressions()` - `retry()` and `retry_async()` diff --git a/pyproject.toml b/pyproject.toml index e6127ed..e903cf1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "py-buzz" -version = "8.1.2" +version = "8.2.0" description = "\"That's not flying, it's falling with style\": Exceptions with extras" authors = [ {name = "Tucker Beck", email = "tucker.beck@gmail.com"} diff --git a/src/buzz/__init__.py b/src/buzz/__init__.py index 3936084..b8eaf7c 100644 --- a/src/buzz/__init__.py +++ b/src/buzz/__init__.py @@ -7,6 +7,7 @@ get_traceback, handle_errors, handle_errors_async, + raise_from, reformat_exception, require_condition, verify_literal, @@ -22,6 +23,7 @@ "handle_errors", "handle_errors_async", "reformat_exception", + "raise_from", "require_condition", "verify_literal", ] diff --git a/src/buzz/base.py b/src/buzz/base.py index 378f711..2dff00d 100644 --- a/src/buzz/base.py +++ b/src/buzz/base.py @@ -6,6 +6,7 @@ import textwrap from collections.abc import Iterable, Mapping +from types import TracebackType from typing import Any, TypeVar from typing_extensions import Self, TypeForm, override @@ -21,6 +22,7 @@ ensure_type, get_traceback, handle_errors, + raise_from, require_condition, retry, retry_async, @@ -354,6 +356,40 @@ def handle_errors( exc_builder=cls.exc_builder, ) + @classmethod + def raise_from( + cls, + base_message: str, + err: BaseException, + raise_args: Iterable[Any] | None = None, + raise_kwargs: Mapping[str, Any] | None = None, + final_message: str | None = None, + trace: TracebackType | None = None, + ) -> None: + """ + Raise an instance of this Buzz exception from an existing exception. + + Args: + base_message: The base message to include in the raised exception. + err: The original exception to raise from. + raise_args: Additional positional arguments to pass to the raised exception. + raise_kwargs: Keyword arguments to pass to the raised exception. + final_message: The message to use for the raised exception. If omitted, format it from `base_message` + and `err`. + trace: The traceback to attach to the raised exception. If omitted, use the active exception's + traceback. + """ + raise_from( + base_message, + err, + raise_exc_class=cls, + raise_args=raise_args, + raise_kwargs=raise_kwargs, + exc_builder=cls.exc_builder, + final_message=final_message, + trace=trace, + ) + @classmethod def get_traceback(cls, *args: Any, **kwargs: Any): """ diff --git a/src/buzz/tools.py b/src/buzz/tools.py index e666da6..c90c7da 100644 --- a/src/buzz/tools.py +++ b/src/buzz/tools.py @@ -15,6 +15,7 @@ from asyncio import iscoroutinefunction from collections.abc import AsyncIterator, Coroutine, Iterable, Iterator, Mapping from typing import Any, Callable, Literal, Protocol, TypeVar, cast, get_args, get_origin + from typing_extensions import TypeForm TNonNull = TypeVar("TNonNull") @@ -465,6 +466,47 @@ def get_traceback() -> types.TracebackType | None: return sys.exc_info()[2] +def raise_from( + base_message: str, + err: BaseException, + raise_exc_class: type[Exception] = Exception, + raise_args: Iterable[Any] | None = None, + raise_kwargs: Mapping[str, Any] | None = None, + exc_builder: Callable[[ExcBuilderParams], Exception] = default_exc_builder, + final_message: str | None = None, + trace: types.TracebackType | None = None, +) -> None: + """ + Raise a new exception from an existing exception. + + Build the raised exception using `exc_builder`, preserving `err` as its `__cause__`. By default, format the + message from `base_message` and `err`, and use the active exception's traceback. + + Args: + base_message: The base message to include in `ExcBuilderParams` passed to `exc_builder`. + err: The original exception to raise from. + raise_exc_class: The type of exception to raise. + raise_args: Additional positional arguments to pass to the raised exception. + raise_kwargs: Keyword arguments to pass to the raised exception. + exc_builder: A function that constructs the raised exception, useful for exception classes with + nonstandard constructors. + final_message: The message to use for the raised exception. If omitted, format it from `base_message` + and `err` with `reformat_exception()`. + trace: The traceback to attach to the raised exception. If omitted, use `get_traceback()`. + """ + final_message = final_message or reformat_exception(base_message, err) + trace = trace or get_traceback() + raise exc_builder( + ExcBuilderParams( + raise_exc_class=raise_exc_class, + message=final_message, + raise_args=raise_args or [], + raise_kwargs=raise_kwargs or {}, + base_message=base_message, + ) + ).with_traceback(trace) from err + + @contextlib.contextmanager def handle_errors( base_message: str, @@ -556,15 +598,16 @@ class _DefaultIgnoreException(Exception): ) ) if raise_exc_class is not None: - raise exc_builder( - ExcBuilderParams( - raise_exc_class=raise_exc_class, - message=final_message, - raise_args=raise_args or [], - raise_kwargs=raise_kwargs or {}, - base_message=base_message, - ) - ).with_traceback(trace) from err + raise_from( + base_message, + err, + raise_exc_class=raise_exc_class, + raise_args=raise_args, + raise_kwargs=raise_kwargs, + exc_builder=exc_builder, + final_message=final_message, + trace=trace, + ) else: if do_else: do_else() diff --git a/src/buzz_demo/main.py b/src/buzz_demo/main.py index 08adba0..1d292ac 100644 --- a/src/buzz_demo/main.py +++ b/src/buzz_demo/main.py @@ -24,6 +24,7 @@ class Feature(AutoNameEnum): ensure_type = auto() verify_literal = auto() require_condition = auto() + raise_from = auto() with_buzz_class = auto() using_exc_builder = auto() diff --git a/src/buzz_demo/raise_from.py b/src/buzz_demo/raise_from.py new file mode 100644 index 0000000..03b47eb --- /dev/null +++ b/src/buzz_demo/raise_from.py @@ -0,0 +1,35 @@ +""" +This demo shows how to raise a new exception from an existing exception with `raise_from()`. +""" + +from __future__ import annotations + +from buzz import Buzz, raise_from + + +def demo_1__preserve_cause(): + """ + This function demonstrates how the `raise_from()` function raises a new exception of the + provided type from an existing exception while reformatting the final message to wrap the + original exception message. + """ + try: + raise OSError("connection refused") + except OSError as err: + raise_from("Could not load the profile", err, raise_exc_class=RuntimeError) + + +def demo_2__buzz_class(): + """ + This function demonstrates how the `raise_from()` method on a Buzz-derived class raises a + new exception of the provided type from an existing exception while reformatting the final + message to wrap the original exception message. + """ + + class ProfileError(Buzz): + pass + + try: + raise OSError("connection refused") + except OSError as err: + ProfileError.raise_from("Could not load the profile", err) diff --git a/tests/test_base.py b/tests/test_base.py index 5f91669..6c0078d 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -93,6 +93,19 @@ class OtherBuzz(Buzz): Buzz.exc_builder(params) +def test_Buzz_raise_from__basic(): + original_error = ValueError("original problem") + + with pytest.raises(Buzz, match="request failed -- ValueError: original problem") as err_info: + try: + raise original_error + except ValueError as err: + Buzz.raise_from("request failed", err) + + assert err_info.value.__cause__ is original_error + assert err_info.value.base_message == "request failed" + + def test_Buzz_get_traceback(): """Verify get_traceback can be called via Buzz class.""" try: diff --git a/tests/test_derived.py b/tests/test_derived.py index 40b65f2..749e99d 100644 --- a/tests/test_derived.py +++ b/tests/test_derived.py @@ -79,6 +79,25 @@ def test_derived_check_expressions(): assert err_info.value.extra_kwarg == "extra kwarg" +def test_derived_raise_from(): + original_error = ValueError("original problem") + + with pytest.raises(MiraNova, match="request failed -- ValueError: original problem") as err_info: + try: + raise original_error + except ValueError as err: + MiraNova.raise_from( + "request failed", + err, + raise_args=["extra arg"], + raise_kwargs={"extra_kwarg": "extra kwarg"}, + ) + + assert err_info.value.__cause__ is original_error + assert err_info.value.extra_arg == "extra arg" + assert err_info.value.extra_kwarg == "extra kwarg" + + def test_derived_handle_errors(): with pytest.raises(MiraNova) as err_info: with MiraNova.handle_errors( diff --git a/tests/test_tools.py b/tests/test_tools.py index b7b4405..f93bc9a 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -4,9 +4,8 @@ from types import TracebackType from typing import Any, Literal, cast -from typing_extensions import override - import pytest +from typing_extensions import override from buzz.tools import ( DoExceptParams, @@ -17,6 +16,7 @@ get_traceback, handle_errors, handle_errors_async, + raise_from, reformat_exception, require_condition, retry, @@ -1178,3 +1178,64 @@ def test_get_traceback(): assert "test_tools.py" in last_frame assert "test_get_traceback" in last_frame assert 'DummyException("Original Error")' in last_frame + + +def test_raise_from__formats_message_and_chains_original_error(): + original_error = ValueError("original problem") + + with pytest.raises(Exception, match="request failed -- ValueError: original problem") as err_info: + try: + raise original_error + except ValueError as err: + raise_from("request failed", err) + + assert err_info.value.__cause__ is original_error + + +def test_raise_from__uses_custom_exception_message_args_kwargs_and_trace(): + def raise_original(): + try: + raise ValueError("original problem") + except ValueError as err: + return err, get_traceback() + + original_error, trace = raise_original() + + with pytest.raises(DummyArgsException, match="custom message") as err_info: + raise_from( + "request failed", + original_error, + raise_exc_class=DummyArgsException, + raise_args=["extra positional argument"], + raise_kwargs={"dummy_kwarg": "extra keyword argument"}, + final_message="custom message", + trace=trace, + ) + + assert err_info.value.__cause__ is original_error + assert err_info.value.dummy_arg == "extra positional argument" + assert err_info.value.dummy_kwarg == "extra keyword argument" + assert "raise_original" in "".join(format_tb(err_info.value.__traceback__)) + + +def test_raise_from__uses_custom_exception_builder(): + original_error = ValueError("original problem") + + with pytest.raises(DummyWeirdArgsException) as err_info: + try: + raise original_error + except ValueError as err: + raise_from( + "request failed", + err, + raise_exc_class=DummyWeirdArgsException, + raise_args=["extra positional argument"], + raise_kwargs={"dummy_kwarg": "extra keyword argument"}, + exc_builder=alt_builder, + ) + + assert err_info.value.__cause__ is original_error + assert err_info.value.dummy_arg == "extra positional argument" + assert err_info.value.dummy_kwarg == "extra keyword argument" + assert err_info.value.detail == "request failed -- ValueError: original problem" + assert err_info.value.base_message == "request failed" diff --git a/uv.lock b/uv.lock index 299aeae..ad9ccaa 100644 --- a/uv.lock +++ b/uv.lock @@ -914,7 +914,7 @@ wheels = [ [[package]] name = "py-buzz" -version = "8.1.2" +version = "8.2.0" source = { editable = "." } dependencies = [ { name = "typing-extensions" },