Repository: microsoft/botbuilder-python

Issue:
[PORT] Emit better error messages for all dialogs
> Port this change from botbuilder-dotnet/master branch:
https://github.com/microsoft/botbuilder-dotnet/pull/4153

Fixes #3980 

## Description
Change dialogContext to capture exceptions and annotate exception with contextual information about where the exception happened.

## Specific Changes
* Add try/catch handlers to dialogContext methods to set Exception.Data["dialogContext'] with metadata about the context that the exception was thrown.

## Testing
* Updated existing OnError testing to test and validate for the dialog context data 

### Example exception metadata
```
{
   ...,
   "data": 
  {
       "DialogContext": 
       {
            "ActiveDialog": "SetProperty[=`foo`]",
            "Parent": "innerDialog",
           "Stack": [
               "SetProperty[=`foo`]",
               "innerDialog",
               "planningTest"
           ],
       }
}
```


# Changed projects
* Microsoft.Bot.Builder.Dialogs
* Microsoft.Bot.Builder.Dialogs.Tests






Discussion:

Resolving patch:
diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_context.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_context.py
--- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_context.py
+++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_context.py
@@ -69,26 +69,30 @@ async def begin_dialog(self, dialog_id: str, options: object = None):
         :param dialog_id: ID of the dialog to start
         :param options: (Optional) additional argument(s) to pass to the dialog being started.
         """
-        if not dialog_id:
-            raise TypeError("Dialog(): dialogId cannot be None.")
-        # Look up dialog
-        dialog = await self.find_dialog(dialog_id)
-        if dialog is None:
-            raise Exception(
-                "'DialogContext.begin_dialog(): A dialog with an id of '%s' wasn't found."
-                " The dialog must be included in the current or parent DialogSet."
-                " For example, if subclassing a ComponentDialog you can call add_dialog() within your constructor."
-                % dialog_id
-            )
-        # Push new instance onto stack
-        instance = DialogInstance()
-        instance.id = dialog_id
-        instance.state = {}
-
-        self._stack.insert(0, (instance))
-
-        # Call dialog's begin_dialog() method
-        return await dialog.begin_dialog(self, options)
+        try:
+            if not dialog_id:
+                raise TypeError("Dialog(): dialogId cannot be None.")
+            # Look up dialog
+            dialog = await self.find_dialog(dialog_id)
+            if dialog is None:
+                raise Exception(
+                    "'DialogContext.begin_dialog(): A dialog with an id of '%s' wasn't found."
+                    " The dialog must be included in the current or parent DialogSet."
+                    " For example, if subclassing a ComponentDialog you can call add_dialog() within your constructor."
+                    % dialog_id
+                )
+            # Push new instance onto stack
+            instance = DialogInstance()
+            instance.id = dialog_id
+            instance.state = {}
+
+            self._stack.insert(0, (instance))
+
+            # Call dialog's begin_dialog() method
+            return await dialog.begin_dialog(self, options)
+        except Exception as err:
+            self.__set_exception_context_data(err)
+            raise
 
     # TODO: Fix options: PromptOptions instead of object
     async def prompt(self, dialog_id: str, options) -> DialogTurnResult:
@@ -99,13 +103,17 @@ async def prompt(self, dialog_id: str, options) -> DialogTurnResult:
         :param options: Contains a Prompt, potentially a RetryPrompt and if using ChoicePrompt, Choices.
         :return:
         """
-        if not dialog_id:
-            raise TypeError("DialogContext.prompt(): dialogId cannot be None.")
+        try:
+            if not dialog_id:
+                raise TypeError("DialogContext.prompt(): dialogId cannot be None.")
 
-        if not options:
-            raise TypeError("DialogContext.prompt(): options cannot be None.")
+            if not options:
+                raise TypeError("DialogContext.prompt(): options cannot be None.")
 
-        return await self.begin_dialog(dialog_id, options)
+            return await self.begin_dialog(dialog_id, options)
+        except Exception as err:
+            self.__set_exception_context_data(err)
+            raise
 
     async def continue_dialog(self):
         """
@@ -114,20 +122,25 @@ async def continue_dialog(self):
         to determine if a dialog was run and a reply was sent to the user.
         :return:
         """
-        # Check for a dialog on the stack
-        if self.active_dialog is not None:
-            # Look up dialog
-            dialog = await self.find_dialog(self.active_dialog.id)
-            if not dialog:
-                raise Exception(
-                    "DialogContext.continue_dialog(): Can't continue dialog. A dialog with an id of '%s' wasn't found."
-                    % self.active_dialog.id
-                )
-
-            # Continue execution of dialog
-            return await dialog.continue_dialog(self)
-
-        return DialogTurnResult(DialogTurnStatus.Empty)
+        try:
+            # Check for a dialog on the stack
+            if self.active_dialog is not None:
+                # Look up dialog
+                dialog = await self.find_dialog(self.active_dialog.id)
+                if not dialog:
+                    raise Exception(
+                        "DialogContext.continue_dialog(): Can't continue dialog. "
+                        "A dialog with an id of '%s' wasn't found."
+                        % self.active_dialog.id
+                    )
+
+                # Continue execution of dialog
+                return await dialog.continue_dialog(self)
+
+            return DialogTurnResult(DialogTurnStatus.Empty)
+        except Exception as err:
+            self.__set_exception_context_data(err)
+            raise
 
     # TODO: instance is DialogInstance
     async def end_dialog(self, result: object = None):
@@ -142,22 +155,27 @@ async def end_dialog(self, result: object = None):
         :param result: (Optional) result to pass to the parent dialogs.
         :return:
         """
-        await self.end_active_dialog(DialogReason.EndCalled)
-
-        # Resume previous dialog
-        if self.active_dialog is not None:
-            # Look up dialog
-            dialog = await self.find_dialog(self.active_dialog.id)
-            if not dialog:
-                raise Exception(
-                    "DialogContext.EndDialogAsync(): Can't resume previous dialog."
-                    " A dialog with an id of '%s' wasn't found." % self.active_dialog.id
-                )
-
-            # Return result to previous dialog
-            return await dialog.resume_dialog(self, DialogReason.EndCalled, result)
-
-        return DialogTurnResult(DialogTurnStatus.Complete, result)
+        try:
+            await self.end_active_dialog(DialogReason.EndCalled)
+
+            # Resume previous dialog
+            if self.active_dialog is not None:
+                # Look up dialog
+                dialog = await self.find_dialog(self.active_dialog.id)
+                if not dialog:
+                    raise Exception(
+                        "DialogContext.EndDialogAsync(): Can't resume previous dialog."
+                        " A dialog with an id of '%s' wasn't found."
+                        % self.active_dialog.id
+                    )
+
+                # Return result to previous dialog
+                return await dialog.resume_dialog(self, DialogReason.EndCalled, result)
+
+            return DialogTurnResult(DialogTurnStatus.Complete, result)
+        except Exception as err:
+            self.__set_exception_context_data(err)
+            raise
 
     async def cancel_all_dialogs(self):
         """
@@ -165,12 +183,16 @@ async def cancel_all_dialogs(self):
         :param result: (Optional) result to pass to the parent dialogs.
         :return:
         """
-        if self.stack:
-            while self.stack:
-                await self.end_active_dialog(DialogReason.CancelCalled)
-            return DialogTurnResult(DialogTurnStatus.Cancelled)
+        try:
+            if self.stack:
+                while self.stack:
+                    await self.end_active_dialog(DialogReason.CancelCalled)
+                return DialogTurnResult(DialogTurnStatus.Cancelled)
 
-        return DialogTurnResult(DialogTurnStatus.Empty)
+            return DialogTurnResult(DialogTurnStatus.Empty)
+        except Exception as err:
+            self.__set_exception_context_data(err)
+            raise
 
     async def find_dialog(self, dialog_id: str) -> Dialog:
         """
@@ -179,11 +201,15 @@ async def find_dialog(self, dialog_id: str) -> Dialog:
         :param dialog_id: ID of the dialog to search for.
         :return:
         """
-        dialog = await self.dialogs.find(dialog_id)
+        try:
+            dialog = await self.dialogs.find(dialog_id)
 
-        if dialog is None and self.parent is not None:
-            dialog = await self.parent.find_dialog(dialog_id)
-        return dialog
+            if dialog is None and self.parent is not None:
+                dialog = await self.parent.find_dialog(dialog_id)
+            return dialog
+        except Exception as err:
+            self.__set_exception_context_data(err)
+            raise
 
     async def replace_dialog(
         self, dialog_id: str, options: object = None
@@ -195,29 +221,37 @@ async def replace_dialog(
         :param options: (Optional) additional argument(s) to pass to the new dialog.
         :return:
         """
-        # End the current dialog and giving the reason.
-        await self.end_active_dialog(DialogReason.ReplaceCalled)
+        try:
+            # End the current dialog and giving the reason.
+            await self.end_active_dialog(DialogReason.ReplaceCalled)
 
-        # Start replacement dialog
-        return await self.begin_dialog(dialog_id, options)
+            # Start replacement dialog
+            return await self.begin_dialog(dialog_id, options)
+        except Exception as err:
+            self.__set_exception_context_data(err)
+            raise
 
     async def reprompt_dialog(self):
         """
         Calls reprompt on the currently active dialog, if there is one. Used with Prompts that have a reprompt behavior.
         :return:
         """
-        # Check for a dialog on the stack
-        if self.active_dialog is not None:
-            # Look up dialog
-            dialog = await self.find_dialog(self.active_dialog.id)
-            if not dialog:
-                raise Exception(
-                    "DialogSet.reprompt_dialog(): Can't find A dialog with an id of '%s'."
-                    % self.active_dialog.id
-                )
-
-            # Ask dialog to re-prompt if supported
-            await dialog.reprompt_dialog(self.context, self.active_dialog)
+        try:
+            # Check for a dialog on the stack
+            if self.active_dialog is not None:
+                # Look up dialog
+                dialog = await self.find_dialog(self.active_dialog.id)
+                if not dialog:
+                    raise Exception(
+                        "DialogSet.reprompt_dialog(): Can't find A dialog with an id of '%s'."
+                        % self.active_dialog.id
+                    )
+
+                # Ask dialog to re-prompt if supported
+                await dialog.reprompt_dialog(self.context, self.active_dialog)
+        except Exception as err:
+            self.__set_exception_context_data(err)
+            raise
 
     async def end_active_dialog(self, reason: DialogReason):
         instance = self.active_dialog
@@ -230,3 +264,23 @@ async def end_active_dialog(self, reason: DialogReason):
 
             # Pop dialog off stack
             self._stack.pop(0)
+
+    def __set_exception_context_data(self, exception: Exception):
+        if not hasattr(exception, "data"):
+            exception.data = {}
+
+        if not type(self).__name__ in exception.data:
+            stack = []
+            current_dc = self
+
+            while current_dc is not None:
+                stack = stack + [x.id for x in current_dc.stack]
+                current_dc = current_dc.parent
+
+            exception.data[type(self).__name__] = {
+                "active_dialog": None
+                if self.active_dialog is None
+                else self.active_dialog.id,
+                "parent": None if self.parent is None else self.parent.active_dialog.id,
+                "stack": self.stack,
+            }


Test patch:
diff --git a/libraries/botbuilder-dialogs/tests/test_activity_prompt.py b/libraries/botbuilder-dialogs/tests/test_activity_prompt.py
--- a/libraries/botbuilder-dialogs/tests/test_activity_prompt.py
+++ b/libraries/botbuilder-dialogs/tests/test_activity_prompt.py
@@ -215,3 +215,91 @@ async def aux_validator(prompt_context: PromptValidatorContext):
         step1 = await adapter.send("hello")
         step2 = await step1.assert_reply("please send an event.")
         await step2.assert_reply("please send an event.")
+
+    async def test_activity_prompt_onerror_should_return_dialogcontext(self):
+        # Create ConversationState with MemoryStorage and register the state as middleware.
+        convo_state = ConversationState(MemoryStorage())
+
+        # Create a DialogState property, DialogSet and AttachmentPrompt.
+        dialog_state = convo_state.create_property("dialog_state")
+        dialogs = DialogSet(dialog_state)
+        dialogs.add(SimpleActivityPrompt("EventActivityPrompt", validator))
+
+        async def exec_test(turn_context: TurnContext):
+            dialog_context = await dialogs.create_context(turn_context)
+
+            results = await dialog_context.continue_dialog()
+
+            if results.status == DialogTurnStatus.Empty:
+                options = PromptOptions(
+                    prompt=Activity(
+                        type=ActivityTypes.message, text="please send an event."
+                    )
+                )
+
+                try:
+                    await dialog_context.prompt("EventActivityPrompt", options)
+                    await dialog_context.prompt("Non existent id", options)
+                except Exception as err:
+                    self.assertIsNotNone(
+                        err.data["DialogContext"]  # pylint: disable=no-member
+                    )
+                    self.assertEqual(
+                        err.data["DialogContext"][  # pylint: disable=no-member
+                            "active_dialog"
+                        ],
+                        "EventActivityPrompt",
+                    )
+                else:
+                    raise Exception("Should have thrown an error.")
+
+            elif results.status == DialogTurnStatus.Complete:
+                await turn_context.send_activity(results.result)
+
+            await convo_state.save_changes(turn_context)
+
+        # Initialize TestAdapter.
+        adapter = TestAdapter(exec_test)
+
+        await adapter.send("hello")
+
+    async def test_activity_replace_dialog_onerror_should_return_dialogcontext(self):
+        # Create ConversationState with MemoryStorage and register the state as middleware.
+        convo_state = ConversationState(MemoryStorage())
+
+        # Create a DialogState property, DialogSet and AttachmentPrompt.
+        dialog_state = convo_state.create_property("dialog_state")
+        dialogs = DialogSet(dialog_state)
+        dialogs.add(SimpleActivityPrompt("EventActivityPrompt", validator))
+
+        async def exec_test(turn_context: TurnContext):
+            dialog_context = await dialogs.create_context(turn_context)
+
+            results = await dialog_context.continue_dialog()
+
+            if results.status == DialogTurnStatus.Empty:
+                options = PromptOptions(
+                    prompt=Activity(
+                        type=ActivityTypes.message, text="please send an event."
+                    )
+                )
+
+                try:
+                    await dialog_context.prompt("EventActivityPrompt", options)
+                    await dialog_context.replace_dialog("Non existent id", options)
+                except Exception as err:
+                    self.assertIsNotNone(
+                        err.data["DialogContext"]  # pylint: disable=no-member
+                    )
+                else:
+                    raise Exception("Should have thrown an error.")
+
+            elif results.status == DialogTurnStatus.Complete:
+                await turn_context.send_activity(results.result)
+
+            await convo_state.save_changes(turn_context)
+
+        # Initialize TestAdapter.
+        adapter = TestAdapter(exec_test)
+
+        await adapter.send("hello")