Repository: microsoft/botbuilder-python

Issue:
port: Duplicate code across classic DialogExtensions.RunAsync and DialogManager
The changes in [Johtaylo/dialogrunasync (#5294)](https://github.com/microsoft/botbuilder-dotnet/pull/5294) may need to be ported to maintain parity with `microsoft/botbuilder-dotnet`.

<blockquote>
Fixes #5293 

This fix removes the duplicate code. It follows the suggestion made in the associated issue to share an _internal_ implementation.

The main reason for this is because the DialogManager is expected to return a DialogTurnResult. RunAsync never returned this, because the very point of the function was to hide this, and the associated continue-begin dance, as it is meaningless to the caller. (Note when use directly in an IBot implementation the result is, naturally, ignored.)

The other, even more obscure, reason is that the DialogManager exposes indirectly the DialogSet the DialogContext uses. As it is, it seems hard to predict whether an application could make use of a potential side effect of that leak in the abstraction. Making this change this way with the internal function doesn't impact that as the internal function takes an already created DialogContext.
</blockquote>

Please review and, if necessary, port the changes.


Discussion:

Resolving patch:
diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_extensions.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_extensions.py
--- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_extensions.py
+++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_extensions.py
@@ -9,8 +9,11 @@
 )
 from botbuilder.core import BotAdapter, StatePropertyAccessor, TurnContext
 from botbuilder.core.skills import SkillHandler, SkillConversationReference
+import botbuilder.dialogs as dialogs  # pylint: disable=unused-import
+from botbuilder.dialogs.memory import DialogStateManager
+from botbuilder.dialogs.dialog_context import DialogContext
+from botbuilder.dialogs.dialog_turn_result import DialogTurnResult
 from botbuilder.dialogs import (
-    Dialog,
     DialogEvents,
     DialogSet,
     DialogTurnStatus,
@@ -21,7 +24,9 @@
 class DialogExtensions:
     @staticmethod
     async def run_dialog(
-        dialog: Dialog, turn_context: TurnContext, accessor: StatePropertyAccessor
+        dialog: "dialogs.Dialog",
+        turn_context: TurnContext,
+        accessor: StatePropertyAccessor,
     ):
         """
         Creates a dialog stack and starts a dialog, pushing it onto the stack.
@@ -30,20 +35,71 @@ async def run_dialog(
         dialog_set = DialogSet(accessor)
         dialog_set.add(dialog)
 
-        dialog_context = await dialog_set.create_context(turn_context)
+        dialog_context: DialogContext = await dialog_set.create_context(turn_context)
 
+        await DialogExtensions._internal_run(turn_context, dialog.id, dialog_context)
+
+    @staticmethod
+    async def _internal_run(
+        context: TurnContext, dialog_id: str, dialog_context: DialogContext
+    ) -> DialogTurnResult:
+        # map TurnState into root dialog context.services
+        for key, service in context.turn_state.items():
+            dialog_context.services[key] = service
+
+        # get the DialogStateManager configuration
+        dialog_state_manager = DialogStateManager(dialog_context)
+        await dialog_state_manager.load_all_scopes()
+        dialog_context.context.turn_state[
+            dialog_state_manager.__class__.__name__
+        ] = dialog_state_manager
+
+        # Loop as long as we are getting valid OnError handled we should continue executing the actions for the turn.
+
+        # NOTE: We loop around this block because each pass through we either complete the turn and break out of the
+        # loop or we have had an exception AND there was an OnError action which captured the error.  We need to
+        # continue the turn based on the actions the OnError handler introduced.
+        end_of_turn = False
+        while not end_of_turn:
+            try:
+                dialog_turn_result = await DialogExtensions.__inner_run(
+                    context, dialog_id, dialog_context
+                )
+
+                # turn successfully completed, break the loop
+                end_of_turn = True
+            except Exception as err:
+                # fire error event, bubbling from the leaf.
+                handled = await dialog_context.emit_event(
+                    DialogEvents.error, err, bubble=True, from_leaf=True
+                )
+
+                if not handled:
+                    # error was NOT handled, throw the exception and end the turn. (This will trigger the
+                    # Adapter.OnError handler and end the entire dialog stack)
+                    raise
+
+        # save all state scopes to their respective botState locations.
+        await dialog_state_manager.save_all_changes()
+
+        # return the redundant result because the DialogManager contract expects it
+        return dialog_turn_result
+
+    @staticmethod
+    async def __inner_run(
+        turn_context: TurnContext, dialog_id: str, dialog_context: DialogContext
+    ) -> DialogTurnResult:
         # Handle EoC and Reprompt event from a parent bot (can be root bot to skill or skill to skill)
         if DialogExtensions.__is_from_parent_to_skill(turn_context):
             # Handle remote cancellation request from parent.
             if turn_context.activity.type == ActivityTypes.end_of_conversation:
                 if not dialog_context.stack:
                     # No dialogs to cancel, just return.
-                    return
+                    return DialogTurnResult(DialogTurnStatus.Empty)
 
                 # Send cancellation message to the dialog to ensure all the parents are canceled
                 # in the right order.
-                await dialog_context.cancel_all_dialogs()
-                return
+                return await dialog_context.cancel_all_dialogs(True)
 
             # Handle a reprompt event sent from the parent.
             if (
@@ -52,15 +108,17 @@ async def run_dialog(
             ):
                 if not dialog_context.stack:
                     # No dialogs to reprompt, just return.
-                    return
+                    return DialogTurnResult(DialogTurnStatus.Empty)
 
                 await dialog_context.reprompt_dialog()
-                return
+                return DialogTurnResult(DialogTurnStatus.Waiting)
 
         # Continue or start the dialog.
         result = await dialog_context.continue_dialog()
         if result.status == DialogTurnStatus.Empty:
-            result = await dialog_context.begin_dialog(dialog.id)
+            result = await dialog_context.begin_dialog(dialog_id)
+
+        await DialogExtensions._send_state_snapshot_trace(dialog_context)
 
         # Skills should send EoC when the dialog completes.
         if (
@@ -78,6 +136,8 @@ async def run_dialog(
                 )
                 await turn_context.send_activity(activity)
 
+        return result
+
     @staticmethod
     def __is_from_parent_to_skill(turn_context: TurnContext) -> bool:
         if turn_context.turn_state.get(SkillHandler.SKILL_CONVERSATION_REFERENCE_KEY):
@@ -88,6 +148,34 @@ def __is_from_parent_to_skill(turn_context: TurnContext) -> bool:
             claims_identity, ClaimsIdentity
         ) and SkillValidation.is_skill_claim(claims_identity.claims)
 
+    @staticmethod
+    async def _send_state_snapshot_trace(dialog_context: DialogContext):
+        """
+        Helper to send a trace activity with a memory snapshot of the active dialog DC.
+        :param dialog_context:
+        :return:
+        """
+        claims_identity = dialog_context.context.turn_state.get(
+            BotAdapter.BOT_IDENTITY_KEY, None
+        )
+        trace_label = (
+            "Skill State"
+            if isinstance(claims_identity, ClaimsIdentity)
+            and SkillValidation.is_skill_claim(claims_identity.claims)
+            else "Bot State"
+        )
+        # send trace of memory
+        snapshot = DialogExtensions._get_active_dialog_context(
+            dialog_context
+        ).state.get_memory_snapshot()
+        trace_activity = Activity.create_trace_activity(
+            "BotState",
+            "https://www.botframework.com/schemas/botState",
+            snapshot,
+            trace_label,
+        )
+        await dialog_context.context.send_activity(trace_activity)
+
     @staticmethod
     def __send_eoc_to_parent(turn_context: TurnContext) -> bool:
         claims_identity = turn_context.turn_state.get(BotAdapter.BOT_IDENTITY_KEY)
@@ -111,3 +199,16 @@ def __send_eoc_to_parent(turn_context: TurnContext) -> bool:
             return True
 
         return False
+
+    @staticmethod
+    def _get_active_dialog_context(dialog_context: DialogContext) -> DialogContext:
+        """
+        Recursively walk up the DC stack to find the active DC.
+        :param dialog_context:
+        :return:
+        """
+        child = dialog_context.child
+        if not child:
+            return dialog_context
+
+        return DialogExtensions._get_active_dialog_context(child)
diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_manager.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_manager.py
--- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_manager.py
+++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_manager.py
@@ -3,6 +3,7 @@
 
 from datetime import datetime, timedelta
 from threading import Lock
+from warnings import warn
 
 from botbuilder.core import (
     BotAdapter,
@@ -12,10 +13,7 @@
     TurnContext,
 )
 from botbuilder.core.skills import SkillConversationReference, SkillHandler
-from botbuilder.dialogs.memory import (
-    DialogStateManager,
-    DialogStateManagerConfiguration,
-)
+from botbuilder.dialogs.memory import DialogStateManagerConfiguration
 from botbuilder.schema import Activity, ActivityTypes, EndOfConversationCodes
 from botframework.connector.auth import (
     AuthenticationConstants,
@@ -27,6 +25,7 @@
 from .dialog import Dialog
 from .dialog_context import DialogContext
 from .dialog_events import DialogEvents
+from .dialog_extensions import DialogExtensions
 from .dialog_set import DialogSet
 from .dialog_state import DialogState
 from .dialog_manager_result import DialogManagerResult
@@ -142,60 +141,10 @@ async def on_turn(self, context: TurnContext) -> DialogManagerResult:
         # Create DialogContext
         dialog_context = DialogContext(self.dialogs, context, dialog_state)
 
-        # promote initial TurnState into dialog_context.services for contextual services
-        for key, service in dialog_context.services.items():
-            dialog_context.services[key] = service
-
-        # map TurnState into root dialog context.services
-        for key, service in context.turn_state.items():
-            dialog_context.services[key] = service
-
-        # get the DialogStateManager configuration
-        dialog_state_manager = DialogStateManager(
-            dialog_context, self.state_configuration
+        # Call the common dialog "continue/begin" execution pattern shared with the classic RunAsync extension method
+        turn_result = await DialogExtensions._internal_run(  # pylint: disable=protected-access
+            context, self._root_dialog_id, dialog_context
         )
-        await dialog_state_manager.load_all_scopes()
-        dialog_context.context.turn_state[
-            dialog_state_manager.__class__.__name__
-        ] = dialog_state_manager
-
-        turn_result: DialogTurnResult = None
-
-        # Loop as long as we are getting valid OnError handled we should continue executing the actions for the turn.
-
-        # NOTE: We loop around this block because each pass through we either complete the turn and break out of the
-        # loop or we have had an exception AND there was an OnError action which captured the error.  We need to
-        # continue the turn based on the actions the OnError handler introduced.
-        end_of_turn = False
-        while not end_of_turn:
-            try:
-                claims_identity: ClaimsIdentity = context.turn_state.get(
-                    BotAdapter.BOT_IDENTITY_KEY, None
-                )
-                if isinstance(
-                    claims_identity, ClaimsIdentity
-                ) and SkillValidation.is_skill_claim(claims_identity.claims):
-                    # The bot is running as a skill.
-                    turn_result = await self.handle_skill_on_turn(dialog_context)
-                else:
-                    # The bot is running as root bot.
-                    turn_result = await self.handle_bot_on_turn(dialog_context)
-
-                # turn successfully completed, break the loop
-                end_of_turn = True
-            except Exception as err:
-                # fire error event, bubbling from the leaf.
-                handled = await dialog_context.emit_event(
-                    DialogEvents.error, err, bubble=True, from_leaf=True
-                )
-
-                if not handled:
-                    # error was NOT handled, throw the exception and end the turn. (This will trigger the
-                    # Adapter.OnError handler and end the entire dialog stack)
-                    raise
-
-        # save all state scopes to their respective botState locations.
-        await dialog_state_manager.save_all_changes()
 
         # save BotState changes
         await bot_state_set.save_all_changes(dialog_context.context, False)
@@ -204,7 +153,8 @@ async def on_turn(self, context: TurnContext) -> DialogManagerResult:
 
     @staticmethod
     async def send_state_snapshot_trace(
-        dialog_context: DialogContext, trace_label: str
+        dialog_context: DialogContext,
+        trace_label: str = None,  # pylint: disable=unused-argument
     ):
         """
         Helper to send a trace activity with a memory snapshot of the active dialog DC.
@@ -212,17 +162,13 @@ async def send_state_snapshot_trace(
         :param trace_label:
         :return:
         """
-        # send trace of memory
-        snapshot = DialogManager.get_active_dialog_context(
+        warn(
+            "This method will be deprecated as no longer is necesary",
+            PendingDeprecationWarning,
+        )
+        await DialogExtensions._send_state_snapshot_trace(  # pylint: disable=protected-access
             dialog_context
-        ).state.get_memory_snapshot()
-        trace_activity = Activity.create_trace_activity(
-            "BotState",
-            "https://www.botframework.com/schemas/botState",
-            snapshot,
-            trace_label,
         )
-        await dialog_context.context.send_activity(trace_activity)
 
     @staticmethod
     def is_from_parent_to_skill(turn_context: TurnContext) -> bool:
@@ -246,11 +192,13 @@ def get_active_dialog_context(dialog_context: DialogContext) -> DialogContext:
         :param dialog_context:
         :return:
         """
-        child = dialog_context.child
-        if not child:
-            return dialog_context
-
-        return DialogManager.get_active_dialog_context(child)
+        warn(
+            "This method will be deprecated as no longer is necesary",
+            PendingDeprecationWarning,
+        )
+        return DialogExtensions._get_active_dialog_context(  # pylint: disable=protected-access
+            dialog_context
+        )
 
     @staticmethod
     def should_send_end_of_conversation_to_parent(
@@ -294,6 +242,10 @@ def should_send_end_of_conversation_to_parent(
     async def handle_skill_on_turn(
         self, dialog_context: DialogContext
     ) -> DialogTurnResult:
+        warn(
+            "This method will be deprecated as no longer is necesary",
+            PendingDeprecationWarning,
+        )
         # the bot is running as a skill.
         turn_context = dialog_context.context
 
@@ -348,6 +300,10 @@ async def handle_skill_on_turn(
     async def handle_bot_on_turn(
         self, dialog_context: DialogContext
     ) -> DialogTurnResult:
+        warn(
+            "This method will be deprecated as no longer is necesary",
+            PendingDeprecationWarning,
+        )
         # the bot is running as a root bot.
         if dialog_context.active_dialog is None:
             # start root dialog


Test patch: