diff --git a/CHANGELOG.md b/CHANGELOG.md index 17acdb8283..2e875217b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Releases +## Version 1.6.0 + +### Enhancements + +- Allow `ChatFeed.callback_exception` to accept a callable and 'traceback' (alias for verbose) ([#7558](https://github.com/holoviz/panel/pull/7558)) + ## Version 1.5.5 This release fixes a regression causing .node_modules to be bundled into our released wheel and introduces a number of bug fixes and enhancements. Many thanks to @mayonnaisecolouredbenz7, @pmeier, @Italirz, @Coderambling and our maintainer team @MarcSkovMadsen, @hoxbro, @ahuang11, @thuydotm, @maximlt and @philippjfr. diff --git a/doc/about/releases.md b/doc/about/releases.md index 610549bae8..9dff0755f5 100644 --- a/doc/about/releases.md +++ b/doc/about/releases.md @@ -2,6 +2,12 @@ See [the HoloViz blog](https://blog.holoviz.org/#category=panel) for a visual summary of the major features added in each release. +## Version 1.6.0 + +### Enhancements + +- Allow `ChatFeed.callback_exception` to accept a callable and 'traceback' (alias for verbose) ([#7558](https://github.com/holoviz/panel/pull/7558)) + ## Version 1.5.5 This release fixes a regression causing .node_modules to be bundled into our released wheel and introduces a number of bug fixes and enhancements. Many thanks to @mayonnaisecolouredbenz7, @pmeier, @Italirz, @Coderambling and our maintainer team @MarcSkovMadsen, @hoxbro, @ahuang11, @thuydotm, @maximlt and @philippjfr. diff --git a/examples/reference/chat/ChatFeed.ipynb b/examples/reference/chat/ChatFeed.ipynb index d754d02248..95f80b0152 100644 --- a/examples/reference/chat/ChatFeed.ipynb +++ b/examples/reference/chat/ChatFeed.ipynb @@ -50,6 +50,7 @@ "* **`header`** (Any): The header of the chat feed; commonly used for the title. Can be a string, pane, or widget.\n", "* **`callback_user`** (str): The default user name to use for the message provided by the callback.\n", "* **`callback_avatar`** (str, BytesIO, bytes, ImageBase): The default avatar to use for the entry provided by the callback. Takes precedence over `ChatMessage.default_avatars` if set; else, if None, defaults to the avatar set in `ChatMessage.default_avatars` if matching key exists. Otherwise defaults to the first character of the `callback_user`.\n", + "* **`callback_exception`** (str, Callable): How to handle exceptions raised by the callback. If \"raise\", the exception will be raised. If \"summary\", a summary will be sent to the chat feed. If \"verbose\" or \"traceback\", the full traceback will be sent to the chat feed. If \"ignore\", the exception will be ignored. If a callable is provided, the signature must contain the `exception` and `instance` arguments and it will be called with the exception.\n", "* **`help_text`** (str): If provided, initializes a chat message in the chat log using the provided help text as the message object and `help` as the user. This is useful for providing instructions, and will not be included in the `serialize` method by default.\n", "* **`placeholder_text`** (str): The text to display next to the placeholder icon.\n", "* **`placeholder_params`** (dict) Defaults to `{\"user\": \" \", \"reaction_icons\": {}, \"show_copy_icon\": False, \"show_timestamp\": False}` Params to pass to the placeholder `ChatMessage`, like `reaction_icons`, `timestamp_format`, `show_avatar`, `show_user`, `show_timestamp`.\n", @@ -433,6 +434,52 @@ "chat_feed.send(\"This will fail...\")" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Alternatively, you can provide a callable that accepts the exception and the instance as arguments to handle different exception scenarios." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import random\n", + "\n", + "\n", + "def callback(content):\n", + " if random.random() < 0.5:\n", + " raise RuntimeError(\"This is an unhandled error\")\n", + " raise ValueError(\"This is a handled error\")\n", + "\n", + "\n", + "def callback_exception_handler(exception, instance):\n", + " if isinstance(exception, ValueError):\n", + " instance.stream(\"I can handle this\", user=\"System\")\n", + " return\n", + " instance.stream(\"Fatal error occurred\", user=\"System\")\n", + "\n", + " # you can raise a new exception here if desired\n", + " # raise RuntimeError(\"Fatal error occurred\") from exception\n", + "\n", + "\n", + "chat_feed = pn.chat.ChatFeed(\n", + " callback=callback, callback_exception=callback_exception_handler\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "chat_feed.send(\"This will sometimes fail...\")" + ] + }, { "cell_type": "markdown", "metadata": {}, diff --git a/panel/chat/_param.py b/panel/chat/_param.py new file mode 100644 index 0000000000..9f54a12e70 --- /dev/null +++ b/panel/chat/_param.py @@ -0,0 +1,23 @@ +from param import Parameter + + +class CallbackException(Parameter): + """ + A Parameter type to validate callback_exception options. Supports + "raise", "summary", "verbose", "traceback", "ignore", or a callable. + """ + + def __init__(self, default="summary", **params): + super().__init__(default=default, **params) + self._validate(default) + + def _validate(self, val): + self._validate_value(val, self.allow_None) + + def _validate_value(self, val, allow_None, valid=("raise", "summary", "verbose", "traceback", "ignore")): + if ((val is None and allow_None) or val in valid or callable(val)): + return + raise ValueError( + f"Callback exception mode {val} not recognized. " + f"Valid options are {valid} or a callable." + ) diff --git a/panel/chat/feed.py b/panel/chat/feed.py index c299b948db..f273755463 100644 --- a/panel/chat/feed.py +++ b/panel/chat/feed.py @@ -34,6 +34,7 @@ from ..viewable import Children from ..widgets import Widget from ..widgets.button import Button +from ._param import CallbackException from .icon import ChatReactionIcons from .message import ChatMessage from .step import ChatStep @@ -100,13 +101,16 @@ class ChatFeed(ListPanel): the previous message value `contents`, the previous `user` name, and the component `instance`.""") - callback_exception = param.Selector( - default="summary", objects=["raise", "summary", "verbose", "ignore"], doc=""" + callback_exception = CallbackException( + default="summary", doc=""" How to handle exceptions raised by the callback. If "raise", the exception will be raised. If "summary", a summary will be sent to the chat feed. - If "verbose", the full traceback will be sent to the chat feed. + If "verbose" or "traceback", the full traceback will be sent to the chat feed. If "ignore", the exception will be ignored. + If a callable is provided, the signature must contain the + `exception` and `instance` arguments and it + will be called with the exception. """) callback_user = param.String(default="Assistant", doc=""" @@ -574,13 +578,15 @@ async def _prepare_response(self, *_) -> None: self._callback_state = CallbackState.STOPPED except Exception as e: send_kwargs: dict[str, Any] = dict(user="Exception", respond=False) - if self.callback_exception == "summary": + if callable(self.callback_exception): + self.callback_exception(e, self) + elif self.callback_exception == "summary": self.send( f"Encountered `{e!r}`. " f"Set `callback_exception='verbose'` to see the full traceback.", **send_kwargs ) - elif self.callback_exception == "verbose": + elif self.callback_exception in ("verbose", "traceback"): self.send(f"```python\n{traceback.format_exc()}\n```", **send_kwargs) elif self.callback_exception == "ignore": return diff --git a/panel/tests/chat/test_feed.py b/panel/tests/chat/test_feed.py index ce3b20a217..4be2de8f4d 100644 --- a/panel/tests/chat/test_feed.py +++ b/panel/tests/chat/test_feed.py @@ -23,7 +23,7 @@ "max_width": 201, } -ChatFeed.callback_exception = "raise" +ChatFeed.callback_exception = "raise" # type: ignore @pytest.fixture @@ -1083,12 +1083,13 @@ def callback(msg, user, instance): assert "division by zero" in chat_feed.objects[-1].object assert chat_feed.objects[-1].user == "Exception" - def test_callback_exception_traceback(self, chat_feed): + @pytest.mark.parametrize("callback_exception", ["traceback", "verbose"]) + def test_callback_exception_traceback(self, chat_feed, callback_exception): def callback(msg, user, instance): return 1 / 0 chat_feed.callback = callback - chat_feed.callback_exception = "verbose" + chat_feed.callback_exception = callback_exception chat_feed.send("Message", respond=True) assert chat_feed.objects[-1].object.startswith( "```python\nTraceback (most recent call last):" @@ -1114,6 +1115,23 @@ def callback(msg, user, instance): chat_feed.send("Message", respond=True) wait_until(lambda: len(chat_feed.objects) == 1) + def test_callback_exception_callable(self, chat_feed): + def callback(msg, user, instance): + raise ValueError("Expected error") + + def exception_callback(exception, instance): + instance.stream(f"The exception: {exception}") + + chat_feed.callback = callback + chat_feed.callback_exception = exception_callback + chat_feed.send("Message", respond=True) + wait_until(lambda: len(chat_feed.objects) == 2) + assert chat_feed.objects[-1].object == "The exception: Expected error" + + def test_callback_exception_invalid_option(self, chat_feed): + with pytest.raises(ValueError, match="Valid options are"): + chat_feed.callback_exception = "abc" + def test_callback_stop_generator(self, chat_feed): def callback(msg, user, instance): yield "A" diff --git a/panel/tests/chat/test_interface.py b/panel/tests/chat/test_interface.py index 86e2809b51..1552b2fcdd 100644 --- a/panel/tests/chat/test_interface.py +++ b/panel/tests/chat/test_interface.py @@ -15,7 +15,7 @@ from panel.widgets.input import FileInput, TextAreaInput, TextInput from panel.widgets.select import RadioButtonGroup -ChatInterface.callback_exception = "raise" +ChatInterface.callback_exception = "raise" # type: ignore class TestChatInterface: