From 3d20366a9c7cbd9d4ccd8150ec38387f42507947 Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Thu, 15 Oct 2020 16:55:48 -0700 Subject: [PATCH 01/18] Initial commit for dialog manager **WIP state** --- .../botbuilder/core/__init__.py | 2 + .../botbuilder/core/component_registration.py | 17 + .../botbuilder/dialogs/__init__.py | 10 + .../botbuilder/dialogs/dialog.py | 82 +++ .../dialogs/dialog_component_registration.py | 22 + .../botbuilder/dialogs/dialog_container.py | 83 +++ .../botbuilder/dialogs/dialog_context.py | 121 +++- .../botbuilder/dialogs/dialog_event.py | 9 + .../botbuilder/dialogs/dialog_events.py | 2 + .../botbuilder/dialogs/dialog_manager.py | 305 ++++++++ .../dialogs/dialog_manager_result.py | 16 + .../botbuilder/dialogs/dialog_set.py | 38 +- .../botbuilder/dialogs/memory/__init__.py | 22 + .../memory/component_memory_scopes_base.py | 14 + .../memory/component_path_resolvers_base.py | 14 + .../dialogs/memory/dialog_state_manager.py | 684 ++++++++++++++++++ .../dialog_state_manager_configuration.py | 10 + .../dialogs/memory/path_resolver_base.py | 7 + .../dialogs/memory/path_resolvers/__init__.py | 7 + .../botbuilder/dialogs/memory/scope_path.py | 35 + .../dialogs/memory/scopes/__init__.py | 13 + .../scopes/dialog_class_memory_scope.py | 37 + .../scopes/dialog_context_memory_scope.py | 64 ++ .../memory/scopes/dialog_memory_scope.py | 56 ++ .../dialogs/memory/scopes/memory_scope.py | 73 ++ .../memory/scopes/settings_memory_scope.py | 32 + .../memory/scopes/turn_memory_scope.py | 78 ++ .../botbuilder/dialogs/persisted_state.py | 20 + .../dialogs/persisted_state_keys.py | 8 + 29 files changed, 1870 insertions(+), 11 deletions(-) create mode 100644 libraries/botbuilder-core/botbuilder/core/component_registration.py create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_component_registration.py create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_container.py create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_event.py create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_manager.py create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_manager_result.py create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/memory/__init__.py create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/memory/component_memory_scopes_base.py create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/memory/component_path_resolvers_base.py create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/memory/dialog_state_manager.py create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/memory/dialog_state_manager_configuration.py create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/memory/path_resolver_base.py create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/memory/path_resolvers/__init__.py create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scope_path.py create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/__init__.py create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/dialog_class_memory_scope.py create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/dialog_context_memory_scope.py create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/dialog_memory_scope.py create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/memory_scope.py create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/settings_memory_scope.py create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/turn_memory_scope.py create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/persisted_state.py create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/persisted_state_keys.py diff --git a/libraries/botbuilder-core/botbuilder/core/__init__.py b/libraries/botbuilder-core/botbuilder/core/__init__.py index ec7b45807..a5fcef72d 100644 --- a/libraries/botbuilder-core/botbuilder/core/__init__.py +++ b/libraries/botbuilder-core/botbuilder/core/__init__.py @@ -18,6 +18,7 @@ from .bot_telemetry_client import BotTelemetryClient, Severity from .card_factory import CardFactory from .channel_service_handler import BotActionNotImplementedError, ChannelServiceHandler +from .component_registration import ComponentRegistration from .conversation_state import ConversationState from .oauth.extended_user_token_provider import ExtendedUserTokenProvider from .oauth.user_token_provider import UserTokenProvider @@ -61,6 +62,7 @@ "calculate_change_hash", "CardFactory", "ChannelServiceHandler", + "ComponentRegistration", "ConversationState", "conversation_reference_extension", "ExtendedUserTokenProvider", diff --git a/libraries/botbuilder-core/botbuilder/core/component_registration.py b/libraries/botbuilder-core/botbuilder/core/component_registration.py new file mode 100644 index 000000000..03023abbf --- /dev/null +++ b/libraries/botbuilder-core/botbuilder/core/component_registration.py @@ -0,0 +1,17 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import Dict, Iterable, Type + + +class ComponentRegistration: + @staticmethod + def get_components() -> Iterable["ComponentRegistration"]: + return _components.values() + + @staticmethod + def add(component_registration: "ComponentRegistration"): + _components[component_registration.__class__] = component_registration + + +_components: Dict[Type, ComponentRegistration] = {} diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/__init__.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/__init__.py index fd2a74a76..86fd4627e 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/__init__.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/__init__.py @@ -7,7 +7,9 @@ from .about import __version__ from .component_dialog import ComponentDialog +from .dialog_container import DialogContainer from .dialog_context import DialogContext +from .dialog_event import DialogEvent from .dialog_events import DialogEvents from .dialog_instance import DialogInstance from .dialog_reason import DialogReason @@ -15,7 +17,10 @@ from .dialog_state import DialogState from .dialog_turn_result import DialogTurnResult from .dialog_turn_status import DialogTurnStatus +from .dialog_manager_result import DialogManagerResult from .dialog import Dialog +from .persisted_state_keys import PersistedStateKeys +from .persisted_state import PersistedState from .waterfall_dialog import WaterfallDialog from .waterfall_step_context import WaterfallStepContext from .dialog_extensions import DialogExtensions @@ -26,7 +31,9 @@ __all__ = [ "ComponentDialog", + "DialogContainer", "DialogContext", + "DialogEvent", "DialogEvents", "DialogInstance", "DialogReason", @@ -34,6 +41,7 @@ "DialogState", "DialogTurnResult", "DialogTurnStatus", + "DialogManagerResult", "Dialog", "WaterfallDialog", "WaterfallStepContext", @@ -43,6 +51,8 @@ "NumberPrompt", "OAuthPrompt", "OAuthPromptSettings", + "PersistedStateKeys", + "PersistedState", "PromptRecognizerResult", "PromptValidatorContext", "Prompt", diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog.py index 63d816b94..6b985d48f 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog.py @@ -4,6 +4,7 @@ from botbuilder.core import TurnContext, NullTelemetryClient, BotTelemetryClient from .dialog_reason import DialogReason +from .dialog_event import DialogEvent from .dialog_turn_status import DialogTurnStatus from .dialog_turn_result import DialogTurnResult from .dialog_instance import DialogInstance @@ -105,3 +106,84 @@ async def end_dialog( # pylint: disable=unused-argument """ # No-op by default return + + def get_version(self) -> str: + return self.id + + async def on_dialog_event( + self, dialog_context: "DialogContext", dialog_event: DialogEvent + ) -> bool: + """ + # Called when an event has been raised, using `DialogContext.emitEvent()`, by either the current dialog or a dialog that the current dialog started. + # The dialog context for the current turn of conversation. + # The event being raised. + # The cancellation token. + # True if the event is handled by the current dialog and bubbling should stop. + # Before bubble + """ + handled = await self._on_pre_bubble_event(dialog_context, dialog_event) + + # Bubble as needed + if (not handled) and dialog_event.bubble and dialog_context.parent: + handled = await dialog_context.parent.emit( + dialog_event.name, dialog_event.value, True, False + ) + + # Post bubble + if not handled: + handled = await self._on_post_bubble_event(dialog_context, dialog_event) + + return handled + + async def _on_pre_bubble_event( + self, dialog_context: "DialogContext", dialog_event: DialogEvent + ) -> bool: + """ + # Called before an event is bubbled to its parent. + # This is a good place to perform interception of an event as returning `true` will prevent + # any further bubbling of the event to the dialogs parents and will also prevent any child + # dialogs from performing their default processing. + # The dialog context for the current turn of conversation. + # The event being raised. + # Cancellation token. + # Whether the event is handled by the current dialog and further processing should stop. + """ + return False + + async def _on_post_bubble_event( + self, dialog_context: "DialogContext", dialog_event: DialogEvent + ) -> bool: + """ + # Called after an event was bubbled to all parents and wasn't handled. + # This is a good place to perform default processing logic for an event. Returning `true` will + # prevent any processing of the event by child dialogs. + # The dialog context for the current turn of conversation. + # The event being raised. + # Cancellation token. + # Whether the event is handled by the current dialog and further processing should stop. + """ + return False + + def _on_compute_id(self) -> str: + """ + # Computes an unique ID for a dialog. + # An unique ID. + """ + return self.__class__.__name__ + + def _register_source_location(self, path: str, line_number: int): + """ + # Registers a cref="SourceRange"/> in the provided location. + # The path to the source file. + # The line number where the source will be located on the file. + """ + if path: + """ + This will be added when debbuging support is ported. + DebugSupport.source_map.add(self, SourceRange( + path = path, + start_point = SourcePoint(line_index = line_number, char_index = 0 ), + end_point = SourcePoint(line_index = line_number + 1, char_index = 0 ), + ) + """ + return diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_component_registration.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_component_registration.py new file mode 100644 index 000000000..606ddadb0 --- /dev/null +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_component_registration.py @@ -0,0 +1,22 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +from typing import Iterable + +from botbuilder.core import ComponentRegistration + +from botbuilder.dialogs.memory import ( + ComponentMemoryScopesBase, + ComponentPathResolversBase, + PathResolverBase, +) +from botbuilder.dialogs.memory.scopes import MemoryScope + + +class DialogComponentRegistration( + ComponentRegistration, ComponentMemoryScopesBase, ComponentPathResolversBase +): + def get_memory_scopes(self) -> Iterable[MemoryScope]: + pass + + def get_path_resolvers(self) -> Iterable[PathResolverBase]: + pass diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_container.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_container.py new file mode 100644 index 000000000..2831d9a0c --- /dev/null +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_container.py @@ -0,0 +1,83 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from abc import ABC, abstractmethod + + +from .dialog import Dialog +from .dialog_context import DialogContext +from .dialog_event import DialogEvent +from .dialog_events import DialogEvents +from .dialog_set import DialogSet + + +class DialogContainer(ABC, Dialog): + def __init__(self, dialog_id: str = None): + super().__init__(dialog_id) + + self.dialogs = DialogSet() + + @abstractmethod + def create_child_context(self, dialog_context: DialogContext) -> DialogContext: + raise NotImplementedError() + + def find_dialog(self, dialog_id: str) -> Dialog: + # TODO: deprecate DialogSet.find + return self.dialogs.find_dialog(dialog_id) + + async def on_dialog_event( + self, dialog_context: DialogContext, dialog_event: DialogEvent + ) -> bool: + """ + Called when an event has been raised, using `DialogContext.emitEvent()`, by either the current dialog or a dialog that the current dialog started. + The dialog context for the current turn of conversation. + The event being raised. + The cancellation token. + True if the event is handled by the current dialog and bubbling should stop. + """ + handled = await super().on_dialog_event(dialog_context, dialog_event) + + # Trace unhandled "versionChanged" events. + if not handled and dialog_event.name == DialogEvents.version_changed: + + trace_message = f"Unhandled dialog event: {dialog_event.name}. Active Dialog: {dialog_context.active_dialog.id}" + + # dialog_context.dialogs.telemetry_client.TrackTrace(trace_message, Severity.Warning, null) + + await dialog_context.context.send_trace_activity(trace_message) + + return handled + + def get_internal_version(self) -> str: + """ + GetInternalVersion - Returns internal version identifier for this container. + DialogContainers detect changes of all sub-components in the container and map that to an DialogChanged event. + Because they do this, DialogContainers "hide" the internal changes and just have the .id. This isolates changes + to the container level unless a container doesn't handle it. To support this DialogContainers define a + protected virtual method GetInternalVersion() which computes if this dialog or child dialogs have changed + which is then examined via calls to check_for_version_change_async(). + version which represents the change of the internals of this container. + """ + return self.dialogs.get_internal_version() + + async def check_for_version_change_async(self, dialog_context: DialogContext): + """ + dialog context. + cancellationToken. + task. + Checks to see if a containers child dialogs have changed since the current dialog instance + was started. + + This should be called at the start of `beginDialog()`, `continueDialog()`, and `resumeDialog()`. + """ + current = dialog_context.active_dialog.version + dialog_context.active_dialog.version = self.get_internal_version() + + # Check for change of previously stored hash + if current and current != dialog_context.active_dialog.version: + # Give bot an opportunity to handle the change. + # - If bot handles it the changeHash will have been updated as to avoid triggering the + # change again. + await dialog_context.emit_event( + DialogEvents.version_changed, self.id, True, False + ) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_context.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_context.py index b081cdea5..8dbf05071 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_context.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_context.py @@ -1,18 +1,25 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +from typing import List, Optional + from botbuilder.core.turn_context import TurnContext +from botbuilder.dialogs.memory import DialogStateManager + +from .dialog_event import DialogEvent +from .dialog_set import DialogSet from .dialog_state import DialogState from .dialog_turn_status import DialogTurnStatus from .dialog_turn_result import DialogTurnResult from .dialog_reason import DialogReason from .dialog_instance import DialogInstance from .dialog import Dialog +from .dialog_container import DialogContainer class DialogContext: def __init__( - self, dialog_set: object, turn_context: TurnContext, state: DialogState + self, dialog_set: DialogSet, turn_context: TurnContext, state: DialogState ): if dialog_set is None: raise TypeError("DialogContext(): dialog_set cannot be None.") @@ -21,16 +28,17 @@ def __init__( raise TypeError("DialogContext(): turn_context cannot be None.") self._turn_context = turn_context self._dialogs = dialog_set - # self._id = dialog_id; self._stack = state.dialog_stack - self.parent = None + self.services = {} + self.parent: DialogContext = None + self.state = DialogStateManager(self) @property - def dialogs(self): + def dialogs(self) -> DialogSet: """Gets the set of dialogs that can be called from this context. :param: - :return str: + :return DialogSet: """ return self._dialogs @@ -39,16 +47,16 @@ def context(self) -> TurnContext: """Gets the context for the current turn of conversation. :param: - :return str: + :return TurnContext: """ return self._turn_context @property - def stack(self): + def stack(self) -> List: """Gets the current dialog stack. :param: - :return str: + :return list: """ return self._stack @@ -57,12 +65,29 @@ def active_dialog(self): """Return the container link in the database. :param: - :return str: + :return: """ if self._stack: return self._stack[0] return None + @property + def child(self) -> Optional["DialogContext"]: + """Return the container link in the database. + + :param: + :return DialogContext: + """ + instance = self.active_dialog + + if instance: + dialog = self.find_dialog(instance.id) + + if isinstance(dialog, DialogContainer): + return dialog.create_child_context(self) + + return None + async def begin_dialog(self, dialog_id: str, options: object = None): """ Pushes a new dialog onto the dialog stack. @@ -230,3 +255,81 @@ async def end_active_dialog(self, reason: DialogReason): # Pop dialog off stack self._stack.pop(0) + + async def emit_event( + self, + name: str, + value: object = None, + bubble: bool = True, + from_leaf: bool = False, + ) -> bool: + """ + Searches for a dialog with a given ID. + Emits a named event for the current dialog, or someone who started it, to handle. + Name of the event to raise. + Value to send along with the event. + Flag to control whether the event should be bubbled to its parent if not handled locally. Defaults to a value of `true`. + Whether the event is emitted from a leaf node. + The cancellation token. + True if the event was handled. + """ + try: + # Initialize event + dialog_event = DialogEvent(bubble=bubble, name=name, value=value,) + + dialog_context = self + + # Find starting dialog + if from_leaf: + while True: + child_dc = dialog_context.child + + if child_dc: + dialog_context = child_dc + else: + break + + # Dispatch to active dialog first + instance = dialog_context.active_dialog + + if instance: + dialog = await dialog_context.find_dialog(instance.id) + + if dialog: + return await dialog.on_dialog_event(dialog_context, dialog_event) + + return False + except Exception as err: + self.set_exception_context_data(err) + raise + + def set_exception_context_data(self, exception: Exception) -> Exception: + if DialogContext.__class__.__name__ not in str(exception): + stack = list([]) + current_dc = self + + while current_dc: + # (PORTERS NOTE: javascript stack is reversed with top of stack on end) + for item in current_dc.stack: + # filter out ActionScope items because they are internal bookkeeping. + if not item.id.startswith("ActionScope["): + stack.append(item.id) + + current_dc: DialogContext = current_dc.parent + + return type(exception)( + exception.message + + "DialogContext: " + + str( + { + "ActiveDialog": self.active_dialog.id + if self.active_dialog + else None, + "Parent": self.parent.active_dialog.id + if self.parent and self.parent.active_dialog + else None, + "Stack": str(stack), + "State": self.state.get_memory_snapshot(), + } + ) + ) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_event.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_event.py new file mode 100644 index 000000000..64753e824 --- /dev/null +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_event.py @@ -0,0 +1,9 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + + +class DialogEvent: + def __init__(self, bubble: bool = False, name: str = "", value: object = None): + self.bubble = bubble + self.name = name + self.value: object = value diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_events.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_events.py index 0c28a7e02..d3d0cb4a1 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_events.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_events.py @@ -10,4 +10,6 @@ class DialogEvents(str, Enum): reprompt_dialog = "repromptDialog" cancel_dialog = "cancelDialog" activity_received = "activityReceived" + version_changed = "versionChanged" error = "error" + custom = "custom" diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_manager.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_manager.py new file mode 100644 index 000000000..2dfa7f99c --- /dev/null +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_manager.py @@ -0,0 +1,305 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from datetime import datetime, timedelta +from threading import Lock + +from botbuilder.core import BotStateSet, ConversationState, UserState, TurnContext +from botbuilder.dialogs.memory import DialogStateManagerConfiguration + +from .dialog import Dialog +from .dialog_context import DialogContext +from .dialog_set import DialogSet +from .dialog_state import DialogState +from .dialog_manager_result import DialogManagerResult + +# +# Class which runs the dialog system. +# +class DialogManager: + + # + # Initializes a instance of the class. + # + # Root dialog to use. + # alternate name for the dialog_state property. (Default is "DialogState"). + def __init__(self, root_dialog: Dialog = None, dialog_state_property: str = None): + self.last_access = "_lastAccess" + self._root_dialog_id = "" + self._dialog_state_property = dialog_state_property or "DialogState" + self._lock = Lock() + self.root_dialog = root_dialog + # + # Gets or sets the ConversationState. + # + # + # The ConversationState. + # + self.conversation_state: ConversationState = None + + # + # Gets or sets the UserState. + # + # + # The UserState. + # + self.user_state: UserState = None + + # + # Gets InitialTurnState collection to copy into the TurnState on every turn. + # + # + # TurnState. + # + self.initial_turn_state = {} + + # + # Gets or sets root dialog to use to start conversation. + # + # + # Root dialog to use to start conversation. + # + self.root_dialog: Dialog = None + + # + # Gets or sets global dialogs that you want to have be callable. + # + # Dialogs set. + self.dialogs = DialogSet() + + # + # Gets or sets the DialogStateManagerConfiguration. + # + # + # The DialogStateManagerConfiguration. + # + self.state_configuration: DialogStateManagerConfiguration = None + + # + # Gets or sets (optional) number of milliseconds to expire the bot's state after. + # + # + # Number of milliseconds. + # + self.expire_after: int = None + + # + # Runs dialog system in the context of an ITurnContext. + # + # turn context. + # Cancellation token. + # result of the running the logic against the activity. + async def on_turn(self, context: TurnContext) -> DialogManagerResult: + # Lazy initialize RootDialog so it can refer to assets like LG function templates + if self._root_dialog_id is None: + with self._lock: + if self._root_dialog_id is None: + self._root_dialog_id = self.root_dialog.id + #self.dialogs = self.root_dialog.telemetry_client + self.dialogs.add(self.root_dialog) + + bot_state_set = BotStateSet() + + # Preload TurnState with DM TurnState. + for key, val in self.initial_turn_state: + context.turn_state[key] = val + + # register DialogManager with TurnState. + context.turn_state[DialogManager.__class__.__name__] = self + conversation_state_name = ConversationState.__class__.__name__ + if self.conversation_state is None: + if conversation_state_name not in context.turn_state: + raise Exception(f"Unable to get an instance of {conversation_state_name} from turn_context.") + self.conversation_state: ConversationState = context.turn_state[conversation_state_name] + else: + context.turn_state[conversation_state_name] = self.conversation_state + + bot_state_set.add(self.conversation_state) + + user_state_name = UserState.__class__.__name__ + if self.user_state is None: + self.user_state = context.turn_state.get(user_state_name, None) + else: + context.turn_state[user_state_name] = self.user_state + + if self.user_state is not None: + self.user_state: UserState = self.user_state + bot_state_set.add(self.user_state) + + # create property accessors + # (last_access) + last_access_property = self.conversation_state.create_property(self.last_access) + last_access: datetime = await last_access_property.get(context, lambda: datetime.now()) + + # Check for expired conversation + if self.expire_after is not None and (datetime.now() - last_access) >= timedelta(milliseconds=float(self.expire_after)): + # Clear conversation state + await self.conversation_state.clear_state(context) + + last_access = datetime.now() + await last_access_property.set(context, last_access) + + # get dialog stack + dialogs_property = self.conversation_state.create_property(self._dialog_state_property) + dialog_state: DialogState = await dialogs_property.get(context, lambda: DialogState()) + + # 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: + dialog_context.services[key] = service + + # map TurnState into root dialog context.services + for key, service in context.turn_state: + dialog_context.services[key] = service + + # get the DialogStateManager configuration + dialog_state_manager = DialogStateManager(dialog_context, StateConfiguration) + await dialog_state_manager.LoadAllScopesAsync(cancellationToken) + dialog_context.Context.TurnState.Add(dialog_state_manager) + + DialogTurnResult turnResult = 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. + endOfTurn = false + while (!endOfTurn) + try + if (context.TurnState.Get(BotAdapter.BotIdentityKey) is ClaimsIdentity claimIdentity && SkillValidation.IsSkillClaim(claimIdentity.Claims)) + # The bot is running as a skill. + turnResult = await HandleSkillOnTurnAsync(dialog_context, cancellationToken) + else + # The bot is running as root bot. + turnResult = await HandleBotOnTurnAsync(dialog_context, cancellationToken) + + # turn successfully completed, break the loop + endOfTurn = true + catch (Exception err) + # fire error event, bubbling from the leaf. + handled = await dialog_context.EmitEventAsync(DialogEvents.Error, err, bubble: true, fromLeaf: true, cancellationToken: cancellationToken) + + if (!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) + throw + + # save all state scopes to their respective botState locations. + await dialog_state_manager.SaveAllChangesAsync(cancellationToken) + + # save BotState changes + await bot_state_set.SaveAllChangesAsync(dialog_context.Context, false, cancellationToken) + + return DialogManagerResult { TurnResult = turnResult } + + # + # Helper to send a trace activity with a memory snapshot of the active dialog DC. + # + static async Task SendStateSnapshotTraceAsync(DialogContext dialog_context, str traceLabel, CancellationToken cancellationToken) + # send trace of memory + snapshot = GetActiveDialogContext(dialog_context).State.GetMemorySnapshot() + traceActivity = (Activity)Activity.CreateTraceActivity("BotState", "https://www.botframework.com/schemas/botState", snapshot, traceLabel) + await dialog_context.Context.SendActivityAsync(traceActivity, cancellationToken) + + static bool IsFromParentToSkill(ITurnContext turnContext) + if (turnContext.TurnState.Get(SkillHandler.SkillConversationReferenceKey) != None) + return false + + return turnContext.TurnState.Get(BotAdapter.BotIdentityKey) is ClaimsIdentity claimIdentity && SkillValidation.IsSkillClaim(claimIdentity.Claims) + + # Recursively walk up the DC stack to find the active DC. + static DialogContext GetActiveDialogContext(DialogContext dialogContext) + child = dialogContext.Child + if (childis None) + return dialogContext + + return GetActiveDialogContext(child) + + # + # Helper to determine if we should send an EndOfConversation to the parent or not. + # + static bool ShouldSendEndOfConversationToParent(ITurnContext context, DialogTurnResult turnResult) + if (!(turnResult.Status == DialogTurnStatus.Complete || turnResult.Status == DialogTurnStatus.Cancelled)) + # The dialog is still going, don't return EoC. + return false + + if (context.TurnState.Get(BotAdapter.BotIdentityKey) is ClaimsIdentity claimIdentity && SkillValidation.IsSkillClaim(claimIdentity.Claims)) + # EoC Activities returned by skills are bounced back to the bot by SkillHandler. + # In those cases we will have a SkillConversationReference instance in state. + skillConversationReference = context.TurnState.Get(SkillHandler.SkillConversationReferenceKey) + if (skillConversationReference != None) + # If the skillConversationReference.OAuthScope is for one of the supported channels, we are at the root and we should not send an EoC. + return skillConversationReference.OAuthScope != AuthenticationConstants.ToChannelFromBotOAuthScope && skillConversationReference.OAuthScope != GovernmentAuthenticationConstants.ToChannelFromBotOAuthScope + + return true + + return false + + async Task HandleSkillOnTurnAsync(DialogContext dialog_context, CancellationToken cancellationToken) + # the bot is running as a skill. + turnContext = dialog_context.Context + + # Process remote cancellation + if (turnContext.Activity.Type == ActivityTypes.EndOfConversation && dialog_context.ActiveDialog != None && IsFromParentToSkill(turnContext)) + # Handle remote cancellation request from parent. + activeDialogContext = GetActiveDialogContext(dialog_context) + + remoteCancelText = "Skill was canceled through an EndOfConversation activity from the parent." + await turnContext.TraceActivityAsync($"{GetType().Name}.OnTurnAsync()", label: $"{remoteCancelText}", cancellationToken: cancellationToken) + + # Send cancellation message to the top dialog in the stack to ensure all the parents are canceled in the right order. + return await activeDialogContext.CancelAllDialogsAsync(true, cancellationToken: cancellationToken) + + # Handle reprompt + # Process a reprompt event sent from the parent. + if (turnContext.Activity.Type == ActivityTypes.Event && turnContext.Activity.Name == DialogEvents.RepromptDialog) + if (dialog_context.ActiveDialogis None) + return DialogTurnResult(DialogTurnStatus.Empty) + + await dialog_context.RepromptDialogAsync(cancellationToken) + return DialogTurnResult(DialogTurnStatus.Waiting) + + # Continue execution + # - This will apply any queued up interruptions and execute the current/next step(s). + turnResult = await dialog_context.ContinueDialogAsync(cancellationToken) + if (turnResult.Status == DialogTurnStatus.Empty) + # restart root dialog + startMessageText = $"Starting {_root_dialog_id}." + await turnContext.TraceActivityAsync($"{GetType().Name}.OnTurnAsync()", label: $"{startMessageText}", cancellationToken: cancellationToken) + turnResult = await dialog_context.BeginDialogAsync(_root_dialog_id, cancellationToken: cancellationToken) + + await SendStateSnapshotTraceAsync(dialog_context, "Skill State", cancellationToken) + + if (ShouldSendEndOfConversationToParent(turnContext, turnResult)) + endMessageText = $"Dialog {_root_dialog_id} has **completed**. Sending EndOfConversation." + await turnContext.TraceActivityAsync($"{GetType().Name}.OnTurnAsync()", label: $"{endMessageText}", value: turnResult.Result, cancellationToken: cancellationToken) + + # Send End of conversation at the end. + activity = Activity(ActivityTypes.EndOfConversation) + Value = turnResult.Result, + Locale = turnContext.Activity.Locale + await turnContext.SendActivityAsync(activity, cancellationToken) + + return turnResult + + async Task HandleBotOnTurnAsync(DialogContext dialog_context, CancellationToken cancellationToken) + DialogTurnResult turnResult + + # the bot is running as a root bot. + if (dialog_context.ActiveDialogis None) + # start root dialog + turnResult = await dialog_context.BeginDialogAsync(_root_dialog_id, cancellationToken: cancellationToken) + else + # Continue execution + # - This will apply any queued up interruptions and execute the current/next step(s). + turnResult = await dialog_context.ContinueDialogAsync(cancellationToken) + + if (turnResult.Status == DialogTurnStatus.Empty) + # restart root dialog + turnResult = await dialog_context.BeginDialogAsync(_root_dialog_id, cancellationToken: cancellationToken) + + await SendStateSnapshotTraceAsync(dialog_context, "Bot State", cancellationToken) + + return turnResult diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_manager_result.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_manager_result.py new file mode 100644 index 000000000..5b7215e52 --- /dev/null +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_manager_result.py @@ -0,0 +1,16 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import List + +from botbuilder.schema import Activity + +from .dialog_turn_result import DialogTurnResult +from .persisted_state import PersistedState + + +class DialogManagerResult: + def __init__(self): + self.turn_result: DialogTurnResult = None + self.activities: List[Activity] = None + self.persisted_state: PersistedState = None diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_set.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_set.py index d6870128a..767699f98 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_set.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_set.py @@ -1,6 +1,7 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. import inspect +from hashlib import sha256 from typing import Dict from botbuilder.core import TurnContext, BotAssert, StatePropertyAccessor @@ -32,7 +33,24 @@ def __init__(self, dialog_state: StatePropertyAccessor = None): self._dialog_state = dialog_state # self.__telemetry_client = NullBotTelemetryClient.Instance; - self._dialogs: Dict[str, object] = {} + self._dialogs: Dict[str, Dialog] = {} + self._version: str = None + + def get_version(self) -> str: + """ + Gets a unique string which represents the combined versions of all dialogs in this this dialogset. + Version will change when any of the child dialogs version changes. + """ + if not self._version: + version = "" + for _, dialog in self._dialogs.items(): + aux_version = dialog.get_version() + if aux_version: + version += aux_version + + self._version = sha256(version) + + return self._version def add(self, dialog: Dialog): """ @@ -64,7 +82,9 @@ async def create_context(self, turn_context: TurnContext) -> DialogContext: "DialogSet.CreateContextAsync(): DialogSet created with a null IStatePropertyAccessor." ) - state = await self._dialog_state.get(turn_context, lambda: DialogState()) + state: DialogState = await self._dialog_state.get( + turn_context, lambda: DialogState() + ) return DialogContext(self, turn_context, state) @@ -82,6 +102,20 @@ async def find(self, dialog_id: str) -> Dialog: return None + def find_dialog(self, dialog_id: str) -> Dialog: + """ + Finds a dialog that was previously added to the set using add(dialog) + :param dialog_id: ID of the dialog/prompt to look up. + :return: The dialog if found, otherwise null. + """ + if not dialog_id: + raise TypeError("DialogContext.find(): dialog_id cannot be None.") + + if dialog_id in self._dialogs: + return self._dialogs[dialog_id] + + return None + def __str__(self): if self._dialogs: return "dialog set empty!" diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/__init__.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/__init__.py new file mode 100644 index 000000000..72a0271de --- /dev/null +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/__init__.py @@ -0,0 +1,22 @@ +# coding=utf-8 +# -------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# -------------------------------------------------------------------------- + +from .dialog_state_manager import DialogStateManager +from .dialog_state_manager_configuration import DialogStateManagerConfiguration +from .component_memory_scopes_base import ComponentMemoryScopesBase +from .component_path_resolvers_base import ComponentPathResolversBase +from .path_resolver_base import PathResolverBase +from . import scope_path + +__all__ = [ + "DialogStateManager", + "DialogStateManagerConfiguration", + "ComponentMemoryScopesBase", + "ComponentPathResolversBase", + "PathResolverBase", + "scope_path", +] diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/component_memory_scopes_base.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/component_memory_scopes_base.py new file mode 100644 index 000000000..428e631ff --- /dev/null +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/component_memory_scopes_base.py @@ -0,0 +1,14 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + + +from abc import ABC, abstractmethod +from typing import Iterable + +from botbuilder.dialogs.memory.scopes import MemoryScope + + +class ComponentMemoryScopesBase(ABC): + @abstractmethod + def get_memory_scopes(self) -> Iterable[MemoryScope]: + raise NotImplementedError() diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/component_path_resolvers_base.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/component_path_resolvers_base.py new file mode 100644 index 000000000..4c3c0ec73 --- /dev/null +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/component_path_resolvers_base.py @@ -0,0 +1,14 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + + +from abc import ABC, abstractmethod +from typing import Iterable + +from .path_resolver_base import PathResolverBase + + +class ComponentPathResolversBase(ABC): + @abstractmethod + def get_path_resolvers(self) -> Iterable[PathResolverBase]: + raise NotImplementedError() diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/dialog_state_manager.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/dialog_state_manager.py new file mode 100644 index 000000000..853381e5b --- /dev/null +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/dialog_state_manager.py @@ -0,0 +1,684 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.core import ComponentRegistration + +# +# The DialogStateManager manages memory scopes and pathresolvers +# MemoryScopes are named root level objects, which can exist either in the dialogcontext or off of turn state +# PathResolvers allow for shortcut behavior for mapping things like $foo -> dialog.foo. +# +class DialogStateManager: + + separators = [',', '['] + + # + # Initializes a new instance of the class. + # + # The dialog context for the current turn of the conversation. + # Configuration for the dialog state manager. Default is null. + def __init__(self, dialog_context: DialogContext, configuration: DialogStateManagerConfiguration = null) + # + # Information for tracking when path was last modified. + # + self.path_tracker = "dialog._tracker.paths" + + self._dialogContext = dialog_context + self._version: int = None + + ComponentRegistration.add(DialogsComponentRegistration()) + + _dialogContext = dialog_context ?? throw new ArgumentNullException(nameof(dialog_context)) + Configuration = configuration ?? dialog_context.Context.TurnState.Get() + if (Configuration == null) + { + Configuration = new DialogStateManagerConfiguration() + + # get all of the component memory scopes + foreach (var component in ComponentRegistration.Components.OfType()) + { + foreach (var memoryScope in component.GetMemoryScopes()) + { + Configuration.MemoryScopes.Add(memoryScope) + } + } + + # get all of the component path resolvers + foreach (var component in ComponentRegistration.Components.OfType()) + { + foreach (var pathResolver in component.GetPathResolvers()) + { + Configuration.PathResolvers.Add(pathResolver) + } + } + } + + # cache for any other new dialogStatemanager instances in this turn. + dialog_context.Context.TurnState.Set(Configuration) + } + + # + # Gets or sets the configured path resolvers and memory scopes for the dialog state manager. + # + # A with the configuration. + DialogStateManagerConfiguration Configuration { get set } + + # + # Gets an containing the keys of the memory scopes. + # + # Keys of the memory scopes. + ICollection Keys => Configuration.MemoryScopes.Select(ms => ms.Name).ToList() + + # + # Gets an containing the values of the memory scopes. + # + # Values of the memory scopes. + ICollection Values => Configuration.MemoryScopes.Select(ms => ms.GetMemory(_dialogContext)).ToList() + + # + # Gets the number of memory scopes in the dialog state manager. + # + # Number of memory scopes in the configuration. + int Count => Configuration.MemoryScopes.Count + + # + # Gets a value indicating whether the dialog state manager is read-only. + # + # true. + bool IsReadOnly => true + + # + # Gets or sets the elements with the specified key. + # + # Key to get or set the element. + # The element with the specified key. + object this[string key] + { + get => GetValue(key, () => null) + set + { + if (key.IndexOfAny(Separators) == -1) + { + # Root is handled by SetMemory rather than SetValue + var scope = GetMemoryScope(key) ?? throw new ArgumentOutOfRangeException(nameof(key), GetBadScopeMessage(key)) + scope.SetMemory(_dialogContext, JToken.FromObject(value)) + } + else + { + SetValue(key, value) + } + } + } + + # + # Get MemoryScope by name. + # + # Name of scope. + # A memory scope. + MemoryScope GetMemoryScope(string name) + { + if (name == null) + { + throw new ArgumentNullException(nameof(name)) + } + + return Configuration.MemoryScopes.FirstOrDefault(ms => string.Compare(ms.Name, name, StringComparison.OrdinalIgnoreCase) == 0) + } + + # + # Version help caller to identify the updates and decide cache or not. + # + # Current version. + string Version() + { + return _version.ToString(CultureInfo.InvariantCulture) + } + + # + # ResolveMemoryScope will find the MemoryScope for and return the remaining path. + # + # Incoming path to resolve to scope and remaining path. + # Remaining subpath in scope. + # The memory scope. + virtual MemoryScope ResolveMemoryScope(string path, out string remainingPath) + { + var scope = path + var sepIndex = -1 + var dot = path.IndexOf(".", StringComparison.OrdinalIgnoreCase) + var openSquareBracket = path.IndexOf("[", StringComparison.OrdinalIgnoreCase) + + if (dot > 0 && openSquareBracket > 0) + { + sepIndex = Math.Min(dot, openSquareBracket) + } + else if (dot > 0) + { + sepIndex = dot + } + else if (openSquareBracket > 0) + { + sepIndex = openSquareBracket + } + + if (sepIndex > 0) + { + scope = path.Substring(0, sepIndex) + var memoryScope = GetMemoryScope(scope) + if (memoryScope != null) + { + remainingPath = path.Substring(sepIndex + 1) + return memoryScope + } + } + + remainingPath = string.Empty + return GetMemoryScope(scope) ?? throw new ArgumentOutOfRangeException(GetBadScopeMessage(path)) + } + + # + # Transform the path using the registered PathTransformers. + # + # Path to transform. + # The transformed path. + virtual string TransformPath(string path) + { + foreach (var pathResolver in Configuration.PathResolvers) + { + path = pathResolver.TransformPath(path) + } + + return path + } + + # + # Get the value from memory using path expression (NOTE: This always returns clone of value). + # + # This always returns a CLONE of the memory, any modifications to the result of this will not be affect memory. + # the value type to return. + # path expression to use. + # Value out parameter. + # True if found, false if not. + bool TryGetValue(string path, out T value) + { + value = default + path = TransformPath(path ?? throw new ArgumentNullException(nameof(path))) + + MemoryScope memoryScope = null + string remainingPath + + try + { + memoryScope = ResolveMemoryScope(path, out remainingPath) + } +#pragma warning disable CA1031 # Do not catch general exception types (Unable to get the value for some reason, catch, log and return false, ignoring exception) + catch (Exception err) +#pragma warning restore CA1031 # Do not catch general exception types + { + Trace.TraceError(err.Message) + return false + } + + if (memoryScope == null) + { + return false + } + + if (string.IsNullOrEmpty(remainingPath)) + { + var memory = memoryScope.GetMemory(_dialogContext) + if (memory == null) + { + return false + } + + value = ObjectPath.MapValueTo(memory) + return true + } + + # TODO: HACK to support .First() retrieval on turn.recognized.entities.foo, replace with Expressions once expression ship + string first = ".FIRST()" + var iFirst = path.ToUpperInvariant().LastIndexOf(first, StringComparison.Ordinal) + if (iFirst >= 0) + { + object entity = null + remainingPath = path.Substring(iFirst + first.Length) + path = path.Substring(0, iFirst) + if (TryGetFirstNestedValue(ref entity, ref path, this)) + { + if (string.IsNullOrEmpty(remainingPath)) + { + value = ObjectPath.MapValueTo(entity) + return true + } + + return ObjectPath.TryGetPathValue(entity, remainingPath, out value) + } + + return false + } + + return ObjectPath.TryGetPathValue(this, path, out value) + } + + # + # Get the value from memory using path expression (NOTE: This always returns clone of value). + # + # This always returns a CLONE of the memory, any modifications to the result of this will not be affect memory. + # The value type to return. + # Path expression to use. + # Function to give default value if there is none (OPTIONAL). + # Result or null if the path is not valid. + T GetValue(string pathExpression, Func defaultValue = null) + { + if (TryGetValue(pathExpression ?? throw new ArgumentNullException(nameof(pathExpression)), out var value)) + { + return value + } + + return defaultValue != null ? defaultValue() : default + } + + # + # Get a int value from memory using a path expression. + # + # Path expression. + # Default value if the value doesn't exist. + # Value or null if path is not valid. + int GetIntValue(string pathExpression, int defaultValue = 0) + { + if (TryGetValue(pathExpression ?? throw new ArgumentNullException(nameof(pathExpression)), out var value)) + { + return value + } + + return defaultValue + } + + # + # Get a bool value from memory using a path expression. + # + # The path expression. + # Default value if the value doesn't exist. + # Bool or null if path is not valid. + bool GetBoolValue(string pathExpression, bool defaultValue = false) + { + if (TryGetValue(pathExpression ?? throw new ArgumentNullException(nameof(pathExpression)), out var value)) + { + return value + } + + return defaultValue + } + + # + # Get a string value from memory using a path expression. + # + # The path expression. + # Default value if the value doesn't exist. + # string or null if path is not valid. + string GetStringValue(string pathExpression, string defaultValue = default) + { + return GetValue(pathExpression, () => defaultValue) + } + + # + # Set memory to value. + # + # Path to memory. + # Object to set. + void SetValue(string path, object value) + { + if (value is Task) + { + throw new Exception($"{path} = You can't pass an unresolved Task to SetValue") + } + + if (value != null) + { + value = JToken.FromObject(value) + } + + path = TransformPath(path ?? throw new ArgumentNullException(nameof(path))) + if (TrackChange(path, value)) + { + ObjectPath.SetPathValue(this, path, value) + } + + # Every set will increase version + _version++ + } + + # + # Remove property from memory. + # + # Path to remove the leaf property. + void RemoveValue(string path) + { + path = TransformPath(path ?? throw new ArgumentNullException(nameof(path))) + if (TrackChange(path, null)) + { + ObjectPath.RemovePathValue(this, path) + } + } + + # + # Gets all memoryscopes suitable for logging. + # + # object which represents all memory scopes. + JObject GetMemorySnapshot() + { + var result = new JObject() + + foreach (var scope in Configuration.MemoryScopes.Where(ms => ms.IncludeInSnapshot)) + { + var memory = scope.GetMemory(_dialogContext) + if (memory != null) + { + result[scope.Name] = JToken.FromObject(memory) + } + } + + return result + } + + # + # Load all of the scopes. + # + # cancellationToken. + # Task. + async Task LoadAllScopesAsync(CancellationToken cancellationToken = default) + { + foreach (var scope in Configuration.MemoryScopes) + { + await scope.LoadAsync(_dialogContext, cancellationToken: cancellationToken).ConfigureAwait(false) + } + } + + # + # Save all changes for all scopes. + # + # cancellationToken. + # Task. + async Task SaveAllChangesAsync(CancellationToken cancellationToken = default) + { + foreach (var scope in Configuration.MemoryScopes) + { + await scope.SaveChangesAsync(_dialogContext, cancellationToken: cancellationToken).ConfigureAwait(false) + } + } + + # + # Delete the memory for a scope. + # + # name of the scope. + # cancellationToken. + # Task. + async Task DeleteScopesMemoryAsync(string name, CancellationToken cancellationToken = default) + { + name = name.ToUpperInvariant() + var scope = Configuration.MemoryScopes.SingleOrDefault(s => s.Name.ToUpperInvariant() == name) + if (scope != null) + { + await scope.DeleteAsync(_dialogContext, cancellationToken).ConfigureAwait(false) + } + } + + # + # Adds an element to the dialog state manager. + # + # Key of the element to add. + # Value of the element to add. + void Add(string key, object value) + { + throw new NotSupportedException() + } + + # + # Determines whether the dialog state manager contains an element with the specified key. + # + # The key to locate in the dialog state manager. + # true if the dialog state manager contains an element with + # the key otherwise, false. + bool ContainsKey(string key) + { + return Configuration.MemoryScopes.Any(ms => ms.Name.ToUpperInvariant() == key.ToUpperInvariant()) + } + + # + # Removes the element with the specified key from the dialog state manager. + # + # The key of the element to remove. + # true if the element is succesfully removed otherwise, false. + # This method is not supported. + bool Remove(string key) + { + throw new NotSupportedException() + } + + # + # Gets the value associated with the specified key. + # + # The key whose value to get. + # When this method returns, the value associated with the specified key, if the + # key is found otherwise, the default value for the type of the value parameter. + # This parameter is passed uninitialized. + # true if the dialog state manager contains an element with the specified key + # otherwise, false. + bool TryGetValue(string key, out object value) + { + return TryGetValue(key, out value) + } + + # + # Adds an item to the dialog state manager. + # + # The with the key and object of + # the item to add. + # This method is not supported. + void Add(KeyValuePair item) + { + throw new NotSupportedException() + } + + # + # Removes all items from the dialog state manager. + # + # This method is not supported. + void Clear() + { + throw new NotSupportedException() + } + + # + # Determines whether the dialog state manager contains a specific value. + # + # The of the item to locate. + # true if item is found in the dialog state manager otherwise, + # false. + # This method is not supported. + bool Contains(KeyValuePair item) + { + throw new NotSupportedException() + } + + # + # Copies the elements of the dialog state manager to an array starting at a particular index. + # + # The one-dimensional array that is the destination of the elements copied + # from the dialog state manager. The array must have zero-based indexing. + # The zero-based index in array at which copying begins. + void CopyTo(KeyValuePair[] array, int arrayIndex) + { + foreach (var ms in Configuration.MemoryScopes) + { + array[arrayIndex++] = new KeyValuePair(ms.Name, ms.GetMemory(_dialogContext)) + } + } + + # + # Removes the first occurrence of a specific object from the dialog state manager. + # + # The object to remove from the dialog state manager. + # true if the item was successfully removed from the dialog state manager + # otherwise, false. + # This method is not supported. + bool Remove(KeyValuePair item) + { + throw new NotSupportedException() + } + + # + # Returns an enumerator that iterates through the collection. + # + # An enumerator that can be used to iterate through the collection. + IEnumerator> GetEnumerator() + { + foreach (var ms in Configuration.MemoryScopes) + { + yield return new KeyValuePair(ms.Name, ms.GetMemory(_dialogContext)) + } + } + + # + # Track when specific paths are changed. + # + # Paths to track. + # Normalized paths to pass to . + List TrackPaths(IEnumerable paths) + { + var allPaths = new List() + foreach (var path in paths) + { + var tpath = TransformPath(path) + + # Track any path that resolves to a constant path + if (ObjectPath.TryResolvePath(this, tpath, out var segments)) + { + var npath = string.Join("_", segments) + SetValue(PathTracker + "." + npath, 0) + allPaths.Add(npath) + } + } + + return allPaths + } + + # + # Check to see if any path has changed since watermark. + # + # Time counter to compare to. + # Paths from to check. + # True if any path has changed since counter. + bool AnyPathChanged(uint counter, IEnumerable paths) + { + var found = false + if (paths != null) + { + foreach (var path in paths) + { + if (GetValue(PathTracker + "." + path) > counter) + { + found = true + break + } + } + } + + return found + } + + IEnumerator IEnumerable.GetEnumerator() + { + foreach (var ms in Configuration.MemoryScopes) + { + yield return new KeyValuePair(ms.Name, ms.GetMemory(_dialogContext)) + } + } + + static bool TryGetFirstNestedValue(ref T value, ref string remainingPath, object memory) + { + if (ObjectPath.TryGetPathValue(memory, remainingPath, out var array)) + { + if (array != null && array.Count > 0) + { + if (array[0] is JArray first) + { + if (first.Count > 0) + { + var second = first[0] + value = ObjectPath.MapValueTo(second) + return true + } + + return false + } + + value = ObjectPath.MapValueTo(array[0]) + return true + } + } + + return false + } + + string GetBadScopeMessage(string path) + { + return $"'{path}' does not match memory scopes:[{string.Join(",", Configuration.MemoryScopes.Select(ms => ms.Name))}]" + } + + bool TrackChange(string path, object value) + { + var hasPath = false + if (ObjectPath.TryResolvePath(this, path, out var segments)) + { + var root = segments.Count > 1 ? segments[1] as string : string.Empty + + # Skip _* as first scope, i.e. _adaptive, _tracker, ... + if (!root.StartsWith("_", StringComparison.Ordinal)) + { + # Convert to a simple path with _ between segments + var pathName = string.Join("_", segments) + var trackedPath = $"{PathTracker}.{pathName}" + uint? counter = null + + void Update() + { + if (TryGetValue(trackedPath, out var lastChanged)) + { + if (!counter.HasValue) + { + counter = GetValue(DialogPath.EventCounter) + } + + SetValue(trackedPath, counter.Value) + } + } + + Update() + if (value is object obj) + { + # For an object we need to see if any children path are being tracked + void CheckChildren(string property, object instance) + { + # Add new child segment + trackedPath += "_" + property.ToLowerInvariant() + Update() + if (instance is object child) + { + ObjectPath.ForEachProperty(child, CheckChildren) + } + + # Remove added child segment + trackedPath = trackedPath.Substring(0, trackedPath.LastIndexOf('_')) + } + + ObjectPath.ForEachProperty(obj, CheckChildren) + } + } + + hasPath = true + } + + return hasPath + } +} diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/dialog_state_manager_configuration.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/dialog_state_manager_configuration.py new file mode 100644 index 000000000..b1565a53d --- /dev/null +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/dialog_state_manager_configuration.py @@ -0,0 +1,10 @@ +from typing import List + +from botbuilder.dialogs.memory.scopes import MemoryScope +from .path_resolver_base import PathResolverBase + + +class DialogStateManagerConfiguration: + def __init__(self): + self.path_resolvers: List[PathResolverBase] = list() + self.memory_scopes: List[MemoryScope] = list() diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/path_resolver_base.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/path_resolver_base.py new file mode 100644 index 000000000..42b80c93f --- /dev/null +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/path_resolver_base.py @@ -0,0 +1,7 @@ +from abc import ABC, abstractmethod + + +class PathResolverBase(ABC): + @abstractmethod + def transform_path(self, path: str): + raise NotImplementedError() diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/path_resolvers/__init__.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/path_resolvers/__init__.py new file mode 100644 index 000000000..133ae5315 --- /dev/null +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/path_resolvers/__init__.py @@ -0,0 +1,7 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + + +from . import _ + +__all__ = [""] diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scope_path.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scope_path.py new file mode 100644 index 000000000..faf906699 --- /dev/null +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scope_path.py @@ -0,0 +1,35 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +# User memory scope root path. +# This property is deprecated, use ScopePath.User instead. +USER = "user" + +# Conversation memory scope root path. +# This property is deprecated, use ScopePath.Conversation instead.This property is deprecated, use ScopePath.Dialog instead.This property is deprecated, use ScopePath.DialogClass instead.This property is deprecated, use ScopePath.This instead.This property is deprecated, use ScopePath.Class instead. +CLASS = "class" + +# Settings memory scope root path. +# This property is deprecated, use ScopePath.Settings instead. + +SETTINGS = "settings" + +# Turn memory scope root path. +# This property is deprecated, use ScopePath.Turn instead. +TURN = "turn" diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/__init__.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/__init__.py new file mode 100644 index 000000000..3bbce5935 --- /dev/null +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/__init__.py @@ -0,0 +1,13 @@ +# coding=utf-8 +# -------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# -------------------------------------------------------------------------- + +from .memory_scope import MemoryScope +from .settings_memory_scope import SettingsMemoryScope +from .turn_memory_scope import TurnMemoryScope + + +__all__ = ["MemoryScope", "SettingsMemoryScope", "TurnMemoryScope"] diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/dialog_class_memory_scope.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/dialog_class_memory_scope.py new file mode 100644 index 000000000..2f132d5b7 --- /dev/null +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/dialog_class_memory_scope.py @@ -0,0 +1,37 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from copy import deepcopy + +from botbuilder.dialogs import DialogContainer, DialogContext +from botbuilder.dialogs.memory import scope_path + +from .memory_scope import MemoryScope + + +class DialogClassMemoryScope(MemoryScope): + def __init__(self): + super().__init__(scope_path.DIALOG_CLASS, include_in_snapshot=False) + + def get_memory(self, dialog_context: DialogContext) -> object: + # if active dialog is a container dialog then "dialogclass" binds to it. + if dialog_context.active_dialog: + dialog = dialog_context.find_dialog(dialog_context.active_dialog.id) + if isinstance(dialog, DialogContainer): + return deepcopy(dialog) + + # Otherwise we always bind to parent, or if there is no parent the active dialog + parent_id = ( + dialog_context.parent.active_dialog.id + if dialog_context.parent and dialog_context.parent.active_dialog + else None + ) + active_id = ( + dialog_context.active_dialog.id if dialog_context.active_dialog else None + ) + return deepcopy(dialog_context.find_dialog(parent_id or active_id)) + + def set_memory(self, dialog_context: DialogContext, memory: object): + raise Exception( + f"{self.__class__.__name__}.set_memory not supported (read only)" + ) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/dialog_context_memory_scope.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/dialog_context_memory_scope.py new file mode 100644 index 000000000..f779f7c5b --- /dev/null +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/dialog_context_memory_scope.py @@ -0,0 +1,64 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.dialogs import DialogContext +from botbuilder.dialogs.memory import scope_path + +from .memory_scope import MemoryScope + + +class DialogContextMemoryScope(MemoryScope): + def __init__(self): + super().__init__(scope_path.SETTINGS, include_in_snapshot=False) + # Stack name. + self.STACK = "stack" + + # Active dialog name. + self.ACTIVE_DIALOG = "activeDialog" + + # Parent name. + self.PARENT = "parent" + + def get_memory(self, dialog_context: DialogContext) -> object: + """ + Gets the backing memory for this scope. + The object for this turn. + Memory for the scope. + """ + if not dialog_context: + raise TypeError(f"Expecting: {DialogContext.__name__}, but received None") + + # TODO: make sure that every object in the dict is serializable + memory = {} + stack = list([]) + current_dc = dialog_context + + # go to leaf node + while current_dc.child: + current_dc = current_dc.child + + while current_dc: + # (PORTERS NOTE: javascript stack is reversed with top of stack on end) + for item in current_dc.stack: + # filter out ActionScope items because they are internal bookkeeping. + if not item.id.startswith("ActionScope["): + stack.append(item.id) + + current_dc = current_dc.parent + + # top of stack is stack[0]. + memory[self.STACK] = stack + memory[self.ACTIVE_DIALOG] = ( + dialog_context.active_dialog.id if dialog_context.active_dialog else None + ) + memory[self.PARENT] = ( + dialog_context.parent.active_dialog.id + if dialog_context.parent and dialog_context.parent.active_dialog + else None + ) + return memory + + def set_memory(self, dialog_context: DialogContext, memory: object): + raise Exception( + f"{self.__class__.__name__}.set_memory not supported (read only)" + ) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/dialog_memory_scope.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/dialog_memory_scope.py new file mode 100644 index 000000000..19eaa0716 --- /dev/null +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/dialog_memory_scope.py @@ -0,0 +1,56 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.dialogs import DialogContainer, DialogContext +from botbuilder.dialogs.memory import scope_path + +from .memory_scope import MemoryScope + + +class DialogMemoryScope(MemoryScope): + def __init__(self): + super().__init__(scope_path.DIALOG) + + def get_memory(self, dialog_context: DialogContext) -> object: + if not dialog_context: + raise TypeError(f"Expecting: {DialogContext.__name__}, but received None") + + # if active dialog is a container dialog then "dialog" binds to it. + if dialog_context.active_dialog: + dialog = dialog_context.find_dialog(dialog_context.active_dialog.id) + if isinstance(dialog, DialogContainer): + return dialog_context.active_dialog.state + + # Otherwise we always bind to parent, or if there is no parent the active dialog + parent_state = ( + dialog_context.parent.active_dialog.state + if dialog_context.parent and dialog_context.parent.active_dialog + else None + ) + dc_state = ( + dialog_context.active_dialog.state if dialog_context.active_dialog else None + ) + return parent_state or dc_state + + def set_memory(self, dialog_context: DialogContext, memory: object): + if not dialog_context: + raise TypeError(f"Expecting: {DialogContext.__name__}, but received None") + + if not memory: + raise TypeError(f"Expecting: memory object, but received None") + + # if active dialog is a container dialog then "dialog" binds to it + if dialog_context.active_dialog: + dialog = dialog_context.find_dialog(dialog_context.active_dialog.id) + if isinstance(dialog, DialogContainer): + dialog_context.active_dialog.state = memory + return + elif dialog_context.parent and dialog_context.parent.active_dialog: + dialog_context.parent.active_dialog.state = memory + return + elif dialog_context.active_dialog: + dialog_context.active_dialog.state = memory + + raise Exception( + "Cannot set DialogMemoryScope. There is no active dialog dialog or parent dialog in the context" + ) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/memory_scope.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/memory_scope.py new file mode 100644 index 000000000..0802b8ed0 --- /dev/null +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/memory_scope.py @@ -0,0 +1,73 @@ +from abc import ABC, abstractmethod + +from botbuilder.dialogs import DialogContext + + +class MemoryScope(ABC): + def __init__(self, name: str, include_in_snapshot: bool = True): + # + # Gets or sets name of the scope. + # + # + # Name of the scope. + # + self.include_in_snapshot = include_in_snapshot + # + # Gets or sets a value indicating whether this memory should be included in snapshot. + # + # + # True or false. + # + self.name = name + + # + # Get the backing memory for this scope. + # + # dc. + # memory for the scope. + @abstractmethod + def get_memory(self, dialog_context: DialogContext) -> object: + raise NotImplementedError() + + # + # Changes the backing object for the memory scope. + # + # dc. + # memory. + @abstractmethod + def set_memory(self, dialog_context: DialogContext, memory: object): + raise NotImplementedError() + + # + # Populates the state cache for this from the storage layer. + # + # The dialog context object for this turn. + # Optional, true to overwrite any existing state cache + # or false to load state from storage only if the cache doesn't already exist. + # A cancellation token that can be used by other objects + # or threads to receive notice of cancellation. + # A task that represents the work queued to execute. + async def load(self, dialog_context: DialogContext, force: bool = False): + return + + # + # Writes the state cache for this to the storage layer. + # + # The dialog context object for this turn. + # Optional, true to save the state cache to storage + # or false to save state to storage only if a property in the cache has changed. + # A cancellation token that can be used by other objects + # or threads to receive notice of cancellation. + # A task that represents the work queued to execute. + async def save_changes(self, dialog_context: DialogContext, force: bool = False): + return + + # + # Deletes any state in storage and the cache for this . + # + # The dialog context object for this turn. + # A cancellation token that can be used by other objects + # or threads to receive notice of cancellation. + # A task that represents the work queued to execute. + async def delete(self, dialog_context: DialogContext): + return diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/settings_memory_scope.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/settings_memory_scope.py new file mode 100644 index 000000000..5d301a3a2 --- /dev/null +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/settings_memory_scope.py @@ -0,0 +1,32 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.dialogs import DialogContext +from botbuilder.dialogs.memory import scope_path + +from .memory_scope import MemoryScope + + +class SettingsMemoryScope(MemoryScope): + def __init__(self): + super().__init__(scope_path.SETTINGS) + self._empty_settings = {} + self.include_in_snapshot = False + + def get_memory(self, dialog_context: DialogContext) -> object: + if not dialog_context: + raise TypeError(f"Expecting: {DialogContext.__name__}, but received None") + + settings: dict = dialog_context.context.turn_state.get( + scope_path.SETTINGS, None + ) + + if not settings: + settings = self._empty_settings + + return settings + + def set_memory(self, dialog_context: DialogContext, memory: object): + raise Exception( + f"{self.__class__.__name__}.set_memory not supported (read only)" + ) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/turn_memory_scope.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/turn_memory_scope.py new file mode 100644 index 000000000..7a829152c --- /dev/null +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/turn_memory_scope.py @@ -0,0 +1,78 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.dialogs import DialogContext +from botbuilder.dialogs.memory import scope_path + +from .memory_scope import MemoryScope + + +class CaseInsensitiveDict(dict): + @classmethod + def _k(cls, key): + return key.lower() if isinstance(key, str) else key + + def __init__(self, *args, **kwargs): + super(CaseInsensitiveDict, self).__init__(*args, **kwargs) + self._convert_keys() + + def __getitem__(self, key): + return super(CaseInsensitiveDict, self).__getitem__(self.__class__._k(key)) + + def __setitem__(self, key, value): + super(CaseInsensitiveDict, self).__setitem__(self.__class__._k(key), value) + + def __delitem__(self, key): + return super(CaseInsensitiveDict, self).__delitem__(self.__class__._k(key)) + + def __contains__(self, key): + return super(CaseInsensitiveDict, self).__contains__(self.__class__._k(key)) + + def pop(self, key, *args, **kwargs): + return super(CaseInsensitiveDict, self).pop( + self.__class__._k(key), *args, **kwargs + ) + + def get(self, key, *args, **kwargs): + return super(CaseInsensitiveDict, self).get( + self.__class__._k(key), *args, **kwargs + ) + + def setdefault(self, key, *args, **kwargs): + return super(CaseInsensitiveDict, self).setdefault( + self.__class__._k(key), *args, **kwargs + ) + + def update(self, e=None, **f): + if e is None: + e = {} + super(CaseInsensitiveDict, self).update(self.__class__(e)) + super(CaseInsensitiveDict, self).update(self.__class__(**f)) + + def _convert_keys(self): + for k in list(self.keys()): + v = super(CaseInsensitiveDict, self).pop(k) + self.__setitem__(k, v) + + +class TurnMemoryScope(MemoryScope): + def __init__(self): + super().__init__(scope_path.TURN) + + def get_memory(self, dialog_context: DialogContext) -> object: + if not dialog_context: + raise TypeError(f"Expecting: {DialogContext.__name__}, but received None") + + turn_value = dialog_context.context.turn_state.get(scope_path.TURN, None) + + if not turn_value: + turn_value = CaseInsensitiveDict() + dialog_context.context.turn_state[scope_path.TURN] = turn_value + + return turn_value + + def set_memory(self, dialog_context: DialogContext, memory: object): + if not dialog_context: + raise TypeError(f"Expecting: {DialogContext.__name__}, but received None") + + dialog_context.context.turn_state[scope_path.TURN] = memory diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/persisted_state.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/persisted_state.py new file mode 100644 index 000000000..e4fc016e8 --- /dev/null +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/persisted_state.py @@ -0,0 +1,20 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import Dict + +from .persisted_state_keys import PersistedStateKeys + + +class PersistedState: + def __init__(self, keys: PersistedStateKeys = None, data: Dict[str, object] = None): + if keys and data: + self.user_state: Dict[str, object] = data[ + keys.user_state + ] if keys.user_state in data else {} + self.conversation_state: Dict[str, object] = data[ + keys.conversation_state + ] if keys.conversation_state in data else {} + else: + self.user_state: Dict[str, object] = {} + self.conversation_state: Dict[str, object] = {} diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/persisted_state_keys.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/persisted_state_keys.py new file mode 100644 index 000000000..59f7c34cd --- /dev/null +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/persisted_state_keys.py @@ -0,0 +1,8 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + + +class PersistedStateKeys: + def __init__(self): + self.user_state: str = None + self.conversation_state: str = None From 7e884bb91a656eefd78e9252e55f1c620ee78023 Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Fri, 16 Oct 2020 13:04:06 -0700 Subject: [PATCH 02/18] Adding more memory classes --- .../memory/scopes/class_memory_scope.py | 31 +++++++++++++++++++ .../scopes/dialog_class_memory_scope.py | 3 ++ 2 files changed, 34 insertions(+) create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/class_memory_scope.py diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/class_memory_scope.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/class_memory_scope.py new file mode 100644 index 000000000..9c3f573f2 --- /dev/null +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/class_memory_scope.py @@ -0,0 +1,31 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from copy import deepcopy + +from botbuilder.dialogs import DialogContext +from botbuilder.dialogs.memory import scope_path + +from .memory_scope import MemoryScope + + +class ClassMemoryScope(MemoryScope): + def __init__(self): + super().__init__(scope_path.SETTINGS, include_in_snapshot=False) + + def get_memory(self, dialog_context: DialogContext) -> object: + if not dialog_context: + raise TypeError(f"Expecting: {DialogContext.__name__}, but received None") + + # if active dialog is a container dialog then "dialogclass" binds to it. + if dialog_context.active_dialog: + dialog = dialog_context.find_dialog(dialog_context.active_dialog.id) + if dialog: + return deepcopy(dialog) + + return None + + def set_memory(self, dialog_context: DialogContext, memory: object): + raise Exception( + f"{self.__class__.__name__}.set_memory not supported (read only)" + ) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/dialog_class_memory_scope.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/dialog_class_memory_scope.py index 2f132d5b7..ac2904c91 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/dialog_class_memory_scope.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/dialog_class_memory_scope.py @@ -14,6 +14,9 @@ def __init__(self): super().__init__(scope_path.DIALOG_CLASS, include_in_snapshot=False) def get_memory(self, dialog_context: DialogContext) -> object: + if not dialog_context: + raise TypeError(f"Expecting: {DialogContext.__name__}, but received None") + # if active dialog is a container dialog then "dialogclass" binds to it. if dialog_context.active_dialog: dialog = dialog_context.find_dialog(dialog_context.active_dialog.id) From faf1b54917086dbbef0d717a34bf28589ff8e76b Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Wed, 28 Oct 2020 10:14:56 -0700 Subject: [PATCH 03/18] memory scopes and path resolvers added --- .../botbuilder/dialogs/__init__.py | 2 + .../dialogs/dialog_component_registration.py | 22 -- .../dialogs/dialogs_component_registration.py | 55 +++++ .../dialogs/memory/dialog_state_manager.py | 201 ++++++++++-------- .../dialogs/memory/path_resolvers/__init__.py | 16 +- .../path_resolvers/alias_path_resolver.py | 53 +++++ .../path_resolvers/at_at_path_resolver.py | 9 + .../memory/path_resolvers/at_path_resolver.py | 43 ++++ .../path_resolvers/dollar_path_resolver.py | 9 + .../path_resolvers/hash_path_resolver.py | 9 + .../path_resolvers/percent_path_resolver.py | 9 + .../dialogs/memory/scopes/__init__.py | 23 +- .../memory/scopes/bot_state_memory_scope.py | 44 ++++ .../scopes/conversation_memory_scope.py | 12 ++ .../memory/scopes/this_memory_scope.py | 29 +++ .../memory/scopes/user_memory_scope.py | 12 ++ 16 files changed, 434 insertions(+), 114 deletions(-) delete mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_component_registration.py create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/dialogs_component_registration.py create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/memory/path_resolvers/alias_path_resolver.py create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/memory/path_resolvers/at_at_path_resolver.py create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/memory/path_resolvers/at_path_resolver.py create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/memory/path_resolvers/dollar_path_resolver.py create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/memory/path_resolvers/hash_path_resolver.py create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/memory/path_resolvers/percent_path_resolver.py create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/bot_state_memory_scope.py create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/conversation_memory_scope.py create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/this_memory_scope.py create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/user_memory_scope.py diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/__init__.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/__init__.py index 86fd4627e..676a36c40 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/__init__.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/__init__.py @@ -19,6 +19,7 @@ from .dialog_turn_status import DialogTurnStatus from .dialog_manager_result import DialogManagerResult from .dialog import Dialog +from .dialogs_component_registration import DialogsComponentRegistration from .persisted_state_keys import PersistedStateKeys from .persisted_state import PersistedState from .waterfall_dialog import WaterfallDialog @@ -43,6 +44,7 @@ "DialogTurnStatus", "DialogManagerResult", "Dialog", + "DialogsComponentRegistration", "WaterfallDialog", "WaterfallStepContext", "ConfirmPrompt", diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_component_registration.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_component_registration.py deleted file mode 100644 index 606ddadb0..000000000 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_component_registration.py +++ /dev/null @@ -1,22 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. -from typing import Iterable - -from botbuilder.core import ComponentRegistration - -from botbuilder.dialogs.memory import ( - ComponentMemoryScopesBase, - ComponentPathResolversBase, - PathResolverBase, -) -from botbuilder.dialogs.memory.scopes import MemoryScope - - -class DialogComponentRegistration( - ComponentRegistration, ComponentMemoryScopesBase, ComponentPathResolversBase -): - def get_memory_scopes(self) -> Iterable[MemoryScope]: - pass - - def get_path_resolvers(self) -> Iterable[PathResolverBase]: - pass diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialogs_component_registration.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialogs_component_registration.py new file mode 100644 index 000000000..8242c7e6b --- /dev/null +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialogs_component_registration.py @@ -0,0 +1,55 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +from typing import Iterable + +from botbuilder.core import ComponentRegistration + +from botbuilder.dialogs.memory import ( + ComponentMemoryScopesBase, + ComponentPathResolversBase, + PathResolverBase, +) +from botbuilder.dialogs.memory.scopes import ( + TurnMemoryScope, + SettingsMemoryScope, + DialogMemoryScope, + DialogContextMemoryScope, + DialogClassMemoryScope, + ClassMemoryScope, + MemoryScope, + ThisMemoryScope, + ConversationMemoryScope, + UserMemoryScope, +) + +from botbuilder.dialogs.memory.path_resolvers import ( + AliasPathResolver, + AtAtPathResolver, + AtPathResolver, + DollarPathResolver, + HashPathResolver, + PercentPathResolver, +) + + +class DialogsComponentRegistration( + ComponentRegistration, ComponentMemoryScopesBase, ComponentPathResolversBase +): + def get_memory_scopes(self) -> Iterable[MemoryScope]: + yield TurnMemoryScope() + yield SettingsMemoryScope() + yield DialogMemoryScope() + yield DialogContextMemoryScope() + yield DialogClassMemoryScope() + yield ClassMemoryScope() + yield ThisMemoryScope() + yield ConversationMemoryScope() + yield UserMemoryScope() + + def get_path_resolvers(self) -> Iterable[PathResolverBase]: + yield AliasPathResolver() + yield AtAtPathResolver() + yield AtPathResolver() + yield DollarPathResolver() + yield HashPathResolver() + yield PercentPathResolver() diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/dialog_state_manager.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/dialog_state_manager.py index 853381e5b..e3bb5b0d3 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/dialog_state_manager.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/dialog_state_manager.py @@ -1,8 +1,17 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +from typing import Collection + from botbuilder.core import ComponentRegistration +from botbuilder.dialogs import DialogContext, DialogsComponentRegistration +from botbuilder.dialogs.memory.scopes import MemoryScope + +from .component_memory_scopes_base import ComponentMemoryScopesBase +from .component_path_resolvers_base import ComponentPathResolversBase +from .dialog_state_manager_configuration import DialogStateManagerConfiguration + # # The DialogStateManager manages memory scopes and pathresolvers # MemoryScopes are named root level objects, which can exist either in the dialogcontext or off of turn state @@ -10,129 +19,145 @@ # class DialogStateManager: - separators = [',', '['] + SEPARATORS = [',', '['] # # Initializes a new instance of the class. # # The dialog context for the current turn of the conversation. # Configuration for the dialog state manager. Default is null. - def __init__(self, dialog_context: DialogContext, configuration: DialogStateManagerConfiguration = null) + def __init__(self, dialog_context: DialogContext, configuration: DialogStateManagerConfiguration = None): # # Information for tracking when path was last modified. # self.path_tracker = "dialog._tracker.paths" - self._dialogContext = dialog_context + self._dialog_context = dialog_context self._version: int = None ComponentRegistration.add(DialogsComponentRegistration()) - _dialogContext = dialog_context ?? throw new ArgumentNullException(nameof(dialog_context)) - Configuration = configuration ?? dialog_context.Context.TurnState.Get() - if (Configuration == null) - { - Configuration = new DialogStateManagerConfiguration() + if not dialog_context: + raise TypeError(f"Expecting: {DialogContext.__name__}, but received None") + + self._configuration = configuration or dialog_context.context.turn_state[DialogStateManagerConfiguration.__name__] + if not self._configuration: + self._configuration = DialogStateManagerConfiguration() # get all of the component memory scopes - foreach (var component in ComponentRegistration.Components.OfType()) - { - foreach (var memoryScope in component.GetMemoryScopes()) - { - Configuration.MemoryScopes.Add(memoryScope) - } - } + memory_component: ComponentMemoryScopesBase + for memory_component in filter(lambda comp: isinstance(comp, ComponentMemoryScopesBase), ComponentRegistration.get_components()): + for memory_scope in memory_component.get_memory_scopes(): + self._configuration.memory_scopes.append(memory_scope) # get all of the component path resolvers - foreach (var component in ComponentRegistration.Components.OfType()) - { - foreach (var pathResolver in component.GetPathResolvers()) - { - Configuration.PathResolvers.Add(pathResolver) - } - } - } - - # cache for any other new dialogStatemanager instances in this turn. - dialog_context.Context.TurnState.Set(Configuration) - } - - # - # Gets or sets the configured path resolvers and memory scopes for the dialog state manager. - # - # A with the configuration. - DialogStateManagerConfiguration Configuration { get set } - - # - # Gets an containing the keys of the memory scopes. - # - # Keys of the memory scopes. - ICollection Keys => Configuration.MemoryScopes.Select(ms => ms.Name).ToList() - - # - # Gets an containing the values of the memory scopes. - # - # Values of the memory scopes. - ICollection Values => Configuration.MemoryScopes.Select(ms => ms.GetMemory(_dialogContext)).ToList() - - # - # Gets the number of memory scopes in the dialog state manager. - # - # Number of memory scopes in the configuration. - int Count => Configuration.MemoryScopes.Count + path_component: ComponentPathResolversBase + for path_component in filter(lambda comp: isinstance(comp, ComponentPathResolversBase), ComponentRegistration.get_components()): + for path_resolver in path_component.get_path_resolvers(): + self._configuration.path_resolvers.append(path_resolver) + + # cache for any other new dialog_state_manager instances in this turn. + dialog_context.context.turn_state[self._configuration.__class__.__name__] = self._configuration + + def __len__(self) -> int: + """ + Gets the number of memory scopes in the dialog state manager. + :return: Number of memory scopes in the configuration. + """ + return len(self._configuration.memory_scopes) + + @property + def configuration(self) -> DialogStateManagerConfiguration: + """ + Gets or sets the configured path resolvers and memory scopes for the dialog state manager. + :return: The configuration object. + """ + return self._configuration + + @property + def keys(self) -> Collection[str]: + """ + Gets a Collection containing the keys of the memory scopes + :return: Keys of the memory scopes. + """ + return [memory_scope.name for memory_scope in self.configuration.memory_scopes] + + @property + def values(self) -> Collection[object]: + """ + Gets a Collection containing the values of the memory scopes. + :return: Values of the memory scopes. + """ + return [memory_scope.get_memory(self._dialog_context) for memory_scope in self.configuration.memory_scopes] # # Gets a value indicating whether the dialog state manager is read-only. # # true. - bool IsReadOnly => true + @property + def is_read_only(self) -> bool: + """ + Gets a value indicating whether the dialog state manager is read-only. + :return: True. + """ + return True # # Gets or sets the elements with the specified key. # # Key to get or set the element. # The element with the specified key. - object this[string key] - { - get => GetValue(key, () => null) - set - { - if (key.IndexOfAny(Separators) == -1) - { - # Root is handled by SetMemory rather than SetValue - var scope = GetMemoryScope(key) ?? throw new ArgumentOutOfRangeException(nameof(key), GetBadScopeMessage(key)) - scope.SetMemory(_dialogContext, JToken.FromObject(value)) - } - else - { - SetValue(key, value) - } - } - } - - # - # Get MemoryScope by name. - # - # Name of scope. - # A memory scope. - MemoryScope GetMemoryScope(string name) - { - if (name == null) - { - throw new ArgumentNullException(nameof(name)) - } - - return Configuration.MemoryScopes.FirstOrDefault(ms => string.Compare(ms.Name, name, StringComparison.OrdinalIgnoreCase) == 0) - } + def __getitem__(self, key): + """ + :param key: + :return The value stored at key's position: + """ + return self.get_value(key, lambda: None) + + def __setitem__(self, key, value): + if self._index_of_any(key, self.SEPARATORS) == -1: + # Root is handled by SetMemory rather than SetValue + scope = self.get_memory_scope(key) + if not scope: + raise IndexError(self._get_bad_scope_message(key)) + # TODO: C# transforms value to JToken + scope.set_memory(self._dialog_context, value) + else: + self.set_value(key, value) + + def _get_bad_scope_message(self, path: str) -> str: + return f"'{path}' does not match memory scopes:[{', '.join((memory_scope.name for memory_scope in self.configuration.memory_scopes))}]" + + @staticmethod + def _index_of_any(string: str, elements_to_search_for) -> int: + for element in elements_to_search_for: + index = string.find(element) + if index != -1: + return index + + return -1 + + def get_memory_scope(self, name: str) -> MemoryScope: + """ + Get MemoryScope by name. + :param name: + :return: A memory scope. + """ + if not name: + raise TypeError(f"Expecting: {str.__name__}, but received None") + + return next((memory_scope for memory_scope in self.configuration.memory_scopes if memory_scope.name.lower() == name.lower()), None) # # Version help caller to identify the updates and decide cache or not. # # Current version. - string Version() - { - return _version.ToString(CultureInfo.InvariantCulture) - } + def version(self) -> str: + """ + Version help caller to identify the updates and decide cache or not. + :return: Current version. + """ + return str(self._version) # # ResolveMemoryScope will find the MemoryScope for and return the remaining path. diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/path_resolvers/__init__.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/path_resolvers/__init__.py index 133ae5315..b22ac063a 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/path_resolvers/__init__.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/path_resolvers/__init__.py @@ -2,6 +2,18 @@ # Licensed under the MIT License. -from . import _ +from .alias_path_resolver import AliasPathResolver +from .at_at_path_resolver import AtAtPathResolver +from .at_path_resolver import AtPathResolver +from .dollar_path_resolver import DollarPathResolver +from .hash_path_resolver import HashPathResolver +from .percent_path_resolver import PercentPathResolver -__all__ = [""] +__all__ = [ + "AliasPathResolver", + "AtAtPathResolver", + "AtPathResolver", + "DollarPathResolver", + "HashPathResolver", + "PercentPathResolver", +] diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/path_resolvers/alias_path_resolver.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/path_resolvers/alias_path_resolver.py new file mode 100644 index 000000000..b16930284 --- /dev/null +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/path_resolvers/alias_path_resolver.py @@ -0,0 +1,53 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.dialogs.memory import PathResolverBase + + +class AliasPathResolver(PathResolverBase): + def __init__(self, alias: str, prefix: str, postfix: str = None): + """ + Initializes a new instance of the class. + Alias name. + Prefix name. + Postfix name. + """ + if alias is None: + raise TypeError(f"Expecting: alias, but received None") + if prefix is None: + raise TypeError(f"Expecting: prefix, but received None") + + # Gets the alias name. + self.alias = alias.strip() + self._prefix = prefix.strip() + self._postfix = postfix.strip() if postfix else "" + + def transform_path(self, path: str): + """ + Transforms the path. + Path to inspect. + Transformed path. + """ + if not path: + raise TypeError(f"Expecting: path, but received None") + + path = path.strip() + if ( + path.startswith(self.alias) + and len(path) > len(self.alias) + and AliasPathResolver._is_path_char(path[len(self.alias)]) + ): + # here we only deals with trailing alias, alias in middle be handled in further breakdown + # $xxx -> path.xxx + return f"{self._prefix}{path[len(self.alias):]}{self._postfix}".rstrip(".") + + return path + + @staticmethod + def _is_path_char(char: str) -> bool: + """ + Verifies if a character is valid for a path. + Character to verify. + true if the character is valid for a path otherwise, false. + """ + return len(char) == 1 and (char.isalpha() or char == "_") diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/path_resolvers/at_at_path_resolver.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/path_resolvers/at_at_path_resolver.py new file mode 100644 index 000000000..d440c040a --- /dev/null +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/path_resolvers/at_at_path_resolver.py @@ -0,0 +1,9 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .alias_path_resolver import AliasPathResolver + + +class AtAtPathResolver(AliasPathResolver): + def __init__(self): + super().__init__(alias="@@", prefix="turn.recognized.entities.") diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/path_resolvers/at_path_resolver.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/path_resolvers/at_path_resolver.py new file mode 100644 index 000000000..037577cfc --- /dev/null +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/path_resolvers/at_path_resolver.py @@ -0,0 +1,43 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .alias_path_resolver import AliasPathResolver + + +class AtPathResolver(AliasPathResolver): + + _DELIMITERS = [".", "["] + + def __init__(self): + super().__init__(alias="@", prefix="") + + self._PREFIX = "turn.recognized.entities." + + def transform_path(self, path: str): + if not path: + raise TypeError(f"Expecting: path, but received None") + + path = path.strip() + if ( + path.startswith("@") + and len(path) > 1 + and AtPathResolver._is_path_char(path[1]) + ): + end = any(delimiter in path for delimiter in AtPathResolver._DELIMITERS) + if end == -1: + end = len(path) + + prop = path[1:end] + suffix = path[end:] + path = f"{self._PREFIX}{prop}.first(){suffix}" + + return path + + @staticmethod + def _index_of_any(string: str, elements_to_search_for) -> int: + for element in elements_to_search_for: + index = string.find(element) + if index != -1: + return index + + return -1 diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/path_resolvers/dollar_path_resolver.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/path_resolvers/dollar_path_resolver.py new file mode 100644 index 000000000..8152d23c5 --- /dev/null +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/path_resolvers/dollar_path_resolver.py @@ -0,0 +1,9 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .alias_path_resolver import AliasPathResolver + + +class DollarPathResolver(AliasPathResolver): + def __init__(self): + super().__init__(alias="$", prefix="dialog.") diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/path_resolvers/hash_path_resolver.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/path_resolvers/hash_path_resolver.py new file mode 100644 index 000000000..b00376e59 --- /dev/null +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/path_resolvers/hash_path_resolver.py @@ -0,0 +1,9 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .alias_path_resolver import AliasPathResolver + + +class HashPathResolver(AliasPathResolver): + def __init__(self): + super().__init__(alias="#", prefix="turn.recognized.intents.") diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/path_resolvers/percent_path_resolver.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/path_resolvers/percent_path_resolver.py new file mode 100644 index 000000000..dd0fa2e17 --- /dev/null +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/path_resolvers/percent_path_resolver.py @@ -0,0 +1,9 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .alias_path_resolver import AliasPathResolver + + +class PercentPathResolver(AliasPathResolver): + def __init__(self): + super().__init__(alias="%", prefix="class.") diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/__init__.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/__init__.py index 3bbce5935..ec2e2b61c 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/__init__.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/__init__.py @@ -4,10 +4,29 @@ # Licensed under the MIT License. See License.txt in the project root for # license information. # -------------------------------------------------------------------------- - +from .bot_state_memory_scope import BotStateMemoryScope +from .class_memory_scope import ClassMemoryScope +from .conversation_memory_scope import ConversationMemoryScope +from .dialog_class_memory_scope import DialogClassMemoryScope +from .dialog_context_memory_scope import DialogContextMemoryScope +from .dialog_memory_scope import DialogMemoryScope from .memory_scope import MemoryScope from .settings_memory_scope import SettingsMemoryScope +from .this_memory_scope import ThisMemoryScope from .turn_memory_scope import TurnMemoryScope +from .user_memory_scope import UserMemoryScope -__all__ = ["MemoryScope", "SettingsMemoryScope", "TurnMemoryScope"] +__all__ = [ + "BotStateMemoryScope", + "ClassMemoryScope", + "ConversationMemoryScope", + "DialogClassMemoryScope", + "DialogContextMemoryScope", + "DialogMemoryScope", + "MemoryScope", + "SettingsMemoryScope", + "ThisMemoryScope", + "TurnMemoryScope", + "UserMemoryScope", +] diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/bot_state_memory_scope.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/bot_state_memory_scope.py new file mode 100644 index 000000000..7f15474cf --- /dev/null +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/bot_state_memory_scope.py @@ -0,0 +1,44 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import Type + +from botbuilder.core import BotState +from botbuilder.dialogs import DialogContext + +from .memory_scope import MemoryScope + + +class BotStateMemoryScope(MemoryScope): + def __init__(self, bot_state_type: Type[BotState], name: str): + super().__init__(name, include_in_snapshot=True) + self.bot_state_type = bot_state_type + + def get_memory(self, dialog_context: DialogContext) -> object: + if not dialog_context: + raise TypeError(f"Expecting: {DialogContext.__name__}, but received None") + + bot_state: BotState = BotStateMemoryScope._get_bot_state(dialog_context) + cached_state = ( + bot_state.get_cached_state(dialog_context.context) if bot_state else None + ) + + return cached_state.state if cached_state else None + + def set_memory(self, dialog_context: DialogContext, memory: object): + raise RuntimeError("You cannot replace the root BotState object") + + async def load(self, dialog_context: DialogContext, force: bool = False): + bot_state: BotState = self._get_bot_state(dialog_context) + + if bot_state: + await bot_state.load(dialog_context.context, force) + + async def save_changes(self, dialog_context: DialogContext, force: bool = False): + bot_state: BotState = self._get_bot_state(dialog_context) + + if bot_state: + await bot_state.save_changes(dialog_context.context, force) + + def _get_bot_state(self, dialog_context: DialogContext) -> BotState: + return dialog_context.context.turn_state.get(self.bot_state_type.__name__, None) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/conversation_memory_scope.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/conversation_memory_scope.py new file mode 100644 index 000000000..2f88dd57a --- /dev/null +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/conversation_memory_scope.py @@ -0,0 +1,12 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.core import ConversationState +from botbuilder.dialogs.memory import scope_path + +from .bot_state_memory_scope import BotStateMemoryScope + + +class ConversationMemoryScope(BotStateMemoryScope): + def __init__(self): + super().__init__(ConversationState, scope_path.CONVERSATION) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/this_memory_scope.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/this_memory_scope.py new file mode 100644 index 000000000..ad0bc85c6 --- /dev/null +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/this_memory_scope.py @@ -0,0 +1,29 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.dialogs import DialogContext +from botbuilder.dialogs.memory import scope_path + +from .memory_scope import MemoryScope + + +class ThisMemoryScope(MemoryScope): + def __init__(self): + super().__init__(scope_path.THIS) + + def get_memory(self, dialog_context: DialogContext) -> object: + if not dialog_context: + raise TypeError(f"Expecting: {DialogContext.__name__}, but received None") + + return ( + dialog_context.active_dialog.state if dialog_context.active_dialog else None + ) + + def set_memory(self, dialog_context: DialogContext, memory: object): + if not dialog_context: + raise TypeError(f"Expecting: {DialogContext.__name__}, but received None") + + if not memory: + raise TypeError(f"Expecting: object, but received None") + + dialog_context.active_dialog.state = memory diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/user_memory_scope.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/user_memory_scope.py new file mode 100644 index 000000000..b1bc6351d --- /dev/null +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/user_memory_scope.py @@ -0,0 +1,12 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from botbuilder.core import UserState +from botbuilder.dialogs.memory import scope_path + +from .bot_state_memory_scope import BotStateMemoryScope + + +class UserMemoryScope(BotStateMemoryScope): + def __init__(self): + super().__init__(UserState, scope_path.USER) From e867a0ef951dd54bcf6ee1db46774305691f2c06 Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Wed, 4 Nov 2020 18:59:06 -0800 Subject: [PATCH 04/18] Updates on try_get_value --- .../dialogs/memory/dialog_state_manager.py | 251 ++++++++---------- 1 file changed, 108 insertions(+), 143 deletions(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/dialog_state_manager.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/dialog_state_manager.py index e3bb5b0d3..c3811afb8 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/dialog_state_manager.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/dialog_state_manager.py @@ -1,17 +1,20 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -from typing import Collection +from traceback import print_tb +from typing import Callable, Collection, Generic, Tuple, Type, TypeVar from botbuilder.core import ComponentRegistration -from botbuilder.dialogs import DialogContext, DialogsComponentRegistration +from botbuilder.dialogs import DialogContext, DialogsComponentRegistration, ObjectPath from botbuilder.dialogs.memory.scopes import MemoryScope from .component_memory_scopes_base import ComponentMemoryScopesBase from .component_path_resolvers_base import ComponentPathResolversBase from .dialog_state_manager_configuration import DialogStateManagerConfiguration +T = TypeVar('T') # Declare type variable + # # The DialogStateManager manages memory scopes and pathresolvers # MemoryScopes are named root level objects, which can exist either in the dialogcontext or off of turn state @@ -148,10 +151,6 @@ def get_memory_scope(self, name: str) -> MemoryScope: return next((memory_scope for memory_scope in self.configuration.memory_scopes if memory_scope.name.lower() == name.lower()), None) - # - # Version help caller to identify the updates and decide cache or not. - # - # Current version. def version(self) -> str: """ Version help caller to identify the updates and decide cache or not. @@ -159,131 +158,98 @@ def version(self) -> str: """ return str(self._version) - # - # ResolveMemoryScope will find the MemoryScope for and return the remaining path. - # - # Incoming path to resolve to scope and remaining path. - # Remaining subpath in scope. - # The memory scope. - virtual MemoryScope ResolveMemoryScope(string path, out string remainingPath) - { - var scope = path - var sepIndex = -1 - var dot = path.IndexOf(".", StringComparison.OrdinalIgnoreCase) - var openSquareBracket = path.IndexOf("[", StringComparison.OrdinalIgnoreCase) + def resolve_memory_scope(self, path: str) -> Tuple[MemoryScope, str]: + """ + Will find the MemoryScope for and return the remaining path. + :param path: + :return: The memory scope and remaining subpath in scope. + """ + scope = path + sep_index = -1 + dot = path.find(".") + open_square_bracket = path.find("[") - if (dot > 0 && openSquareBracket > 0) - { - sepIndex = Math.Min(dot, openSquareBracket) - } - else if (dot > 0) - { - sepIndex = dot - } - else if (openSquareBracket > 0) - { - sepIndex = openSquareBracket - } + if dot > 0 and open_square_bracket > 0: + sep_index = min(dot, open_square_bracket) - if (sepIndex > 0) - { - scope = path.Substring(0, sepIndex) - var memoryScope = GetMemoryScope(scope) - if (memoryScope != null) - { - remainingPath = path.Substring(sepIndex + 1) - return memoryScope - } - } + elif dot > 0: + sep_index = dot - remainingPath = string.Empty - return GetMemoryScope(scope) ?? throw new ArgumentOutOfRangeException(GetBadScopeMessage(path)) - } + elif open_square_bracket > 0: + sep_index = open_square_bracket - # - # Transform the path using the registered PathTransformers. - # - # Path to transform. - # The transformed path. - virtual string TransformPath(string path) - { - foreach (var pathResolver in Configuration.PathResolvers) - { - path = pathResolver.TransformPath(path) - } + if sep_index > 0: + scope = path[0:sep_index] + memory_scope = self.get_memory_scope(scope) + if memory_scope: + remaining_path = path[sep_index + 1:] + return memory_scope, remaining_path + + memory_scope = self.get_memory_scope(scope) + if not scope: + raise IndexError(self._get_bad_scope_message(scope)) + return memory_scope, "" + + def transform_path(self, path: str) -> str: + """ + Transform the path using the registered PathTransformers. + :param path: Path to transform. + :return: The transformed path. + """ + for path_resolver in self.configuration.path_resolvers: + path = path_resolver.transform_path(path) return path - } - # - # Get the value from memory using path expression (NOTE: This always returns clone of value). - # - # This always returns a CLONE of the memory, any modifications to the result of this will not be affect memory. - # the value type to return. - # path expression to use. - # Value out parameter. - # True if found, false if not. - bool TryGetValue(string path, out T value) - { - value = default - path = TransformPath(path ?? throw new ArgumentNullException(nameof(path))) + def _is_primitive(self, cls: Type) -> bool: + return cls in (int, float, bool, str, complex, float, list, tuple, range, dict, bytes, bytearray, memoryview, set, frozenset, map) - MemoryScope memoryScope = null - string remainingPath + def try_get_value(self, path: str, class_type: Type) -> Tuple[bool, object]: + """ + Get the value from memory using path expression (NOTE: This always returns clone of value). + :param path: + :param class_type: + :return: True if found, false if not and the value. + """ + if not path: + raise TypeError(f"Expecting: {str.__name__}, but received None") + return_value = class_type() if self._is_primitive(class_type) else None + path = self.transform_path(path) - try - { - memoryScope = ResolveMemoryScope(path, out remainingPath) - } -#pragma warning disable CA1031 # Do not catch general exception types (Unable to get the value for some reason, catch, log and return false, ignoring exception) - catch (Exception err) -#pragma warning restore CA1031 # Do not catch general exception types - { - Trace.TraceError(err.Message) - return false - } + try: + memory_scope, remaining_path = self.resolve_memory_scope(path) + except Exception as error: + print_tb(error.__traceback__) + return False, return_value - if (memoryScope == null) - { - return false - } + if not memory_scope: + return False, return_value - if (string.IsNullOrEmpty(remainingPath)) - { - var memory = memoryScope.GetMemory(_dialogContext) - if (memory == null) - { - return false - } + if not remaining_path: + memory = memory_scope.get_memory(self._dialog_context) + if not memory: + return False, return_value - value = ObjectPath.MapValueTo(memory) - return true - } + return True, memory # TODO: HACK to support .First() retrieval on turn.recognized.entities.foo, replace with Expressions once expression ship - string first = ".FIRST()" - var iFirst = path.ToUpperInvariant().LastIndexOf(first, StringComparison.Ordinal) - if (iFirst >= 0) - { - object entity = null - remainingPath = path.Substring(iFirst + first.Length) - path = path.Substring(0, iFirst) - if (TryGetFirstNestedValue(ref entity, ref path, this)) - { - if (string.IsNullOrEmpty(remainingPath)) - { - value = ObjectPath.MapValueTo(entity) - return true - } + first = ".FIRST()" + i_first = path.upper().rindex(first) + if i_first >= 0: + remaining_path = path[i_first + len(first):] + path = path[0:i_first] + success, first_value = self.try_get_first_nested_value(path, self) + if success: + if not remaining_path: + return True, first_value - return ObjectPath.TryGetPathValue(entity, remainingPath, out value) - } + path_value = ObjectPath.try_get_path_value(first_value, remaining_path) + return bool(path_value), path_value - return false - } + return False, return_value - return ObjectPath.TryGetPathValue(this, path, out value) - } + path_value = ObjectPath.try_get_path_value(self, path) + return bool(path_value), path_value # # Get the value from memory using path expression (NOTE: This always returns clone of value). @@ -293,9 +259,8 @@ def version(self) -> str: # Path expression to use. # Function to give default value if there is none (OPTIONAL). # Result or null if the path is not valid. - T GetValue(string pathExpression, Func defaultValue = null) - { - if (TryGetValue(pathExpression ?? throw new ArgumentNullException(nameof(pathExpression)), out var value)) + def get_value(self, path_expression: str, default_value: Callable[[],Generic[T]] = None) -> T: + if (TryGetValue(pathExpression ?? throw new ArgumentNullException(nameof(pathExpression)), out value)) { return value } @@ -311,7 +276,7 @@ def version(self) -> str: # Value or null if path is not valid. int GetIntValue(string pathExpression, int defaultValue = 0) { - if (TryGetValue(pathExpression ?? throw new ArgumentNullException(nameof(pathExpression)), out var value)) + if (TryGetValue(pathExpression ?? throw new ArgumentNullException(nameof(pathExpression)), out value)) { return value } @@ -327,7 +292,7 @@ def version(self) -> str: # Bool or null if path is not valid. bool GetBoolValue(string pathExpression, bool defaultValue = false) { - if (TryGetValue(pathExpression ?? throw new ArgumentNullException(nameof(pathExpression)), out var value)) + if (TryGetValue(pathExpression ?? throw new ArgumentNullException(nameof(pathExpression)), out value)) { return value } @@ -392,11 +357,11 @@ def version(self) -> str: # object which represents all memory scopes. JObject GetMemorySnapshot() { - var result = new JObject() + result = new JObject() - foreach (var scope in Configuration.MemoryScopes.Where(ms => ms.IncludeInSnapshot)) + foreach (scope in Configuration.MemoryScopes.Where(ms => ms.IncludeInSnapshot)) { - var memory = scope.GetMemory(_dialogContext) + memory = scope.GetMemory(_dialogContext) if (memory != null) { result[scope.Name] = JToken.FromObject(memory) @@ -413,7 +378,7 @@ def version(self) -> str: # Task. async Task LoadAllScopesAsync(CancellationToken cancellationToken = default) { - foreach (var scope in Configuration.MemoryScopes) + foreach (scope in Configuration.MemoryScopes) { await scope.LoadAsync(_dialogContext, cancellationToken: cancellationToken).ConfigureAwait(false) } @@ -426,7 +391,7 @@ def version(self) -> str: # Task. async Task SaveAllChangesAsync(CancellationToken cancellationToken = default) { - foreach (var scope in Configuration.MemoryScopes) + foreach (scope in Configuration.MemoryScopes) { await scope.SaveChangesAsync(_dialogContext, cancellationToken: cancellationToken).ConfigureAwait(false) } @@ -441,7 +406,7 @@ def version(self) -> str: async Task DeleteScopesMemoryAsync(string name, CancellationToken cancellationToken = default) { name = name.ToUpperInvariant() - var scope = Configuration.MemoryScopes.SingleOrDefault(s => s.Name.ToUpperInvariant() == name) + scope = Configuration.MemoryScopes.SingleOrDefault(s => s.Name.ToUpperInvariant() == name) if (scope != null) { await scope.DeleteAsync(_dialogContext, cancellationToken).ConfigureAwait(false) @@ -534,7 +499,7 @@ def version(self) -> str: # The zero-based index in array at which copying begins. void CopyTo(KeyValuePair[] array, int arrayIndex) { - foreach (var ms in Configuration.MemoryScopes) + foreach (ms in Configuration.MemoryScopes) { array[arrayIndex++] = new KeyValuePair(ms.Name, ms.GetMemory(_dialogContext)) } @@ -558,7 +523,7 @@ def version(self) -> str: # An enumerator that can be used to iterate through the collection. IEnumerator> GetEnumerator() { - foreach (var ms in Configuration.MemoryScopes) + foreach (ms in Configuration.MemoryScopes) { yield return new KeyValuePair(ms.Name, ms.GetMemory(_dialogContext)) } @@ -571,15 +536,15 @@ def version(self) -> str: # Normalized paths to pass to . List TrackPaths(IEnumerable paths) { - var allPaths = new List() - foreach (var path in paths) + allPaths = new List() + foreach (path in paths) { - var tpath = TransformPath(path) + tpath = TransformPath(path) # Track any path that resolves to a constant path - if (ObjectPath.TryResolvePath(this, tpath, out var segments)) + if (ObjectPath.TryResolvePath(this, tpath, out segments)) { - var npath = string.Join("_", segments) + npath = string.Join("_", segments) SetValue(PathTracker + "." + npath, 0) allPaths.Add(npath) } @@ -596,10 +561,10 @@ def version(self) -> str: # True if any path has changed since counter. bool AnyPathChanged(uint counter, IEnumerable paths) { - var found = false + found = false if (paths != null) { - foreach (var path in paths) + foreach (path in paths) { if (GetValue(PathTracker + "." + path) > counter) { @@ -614,7 +579,7 @@ def version(self) -> str: IEnumerator IEnumerable.GetEnumerator() { - foreach (var ms in Configuration.MemoryScopes) + foreach (ms in Configuration.MemoryScopes) { yield return new KeyValuePair(ms.Name, ms.GetMemory(_dialogContext)) } @@ -622,7 +587,7 @@ def version(self) -> str: static bool TryGetFirstNestedValue(ref T value, ref string remainingPath, object memory) { - if (ObjectPath.TryGetPathValue(memory, remainingPath, out var array)) + if (ObjectPath.TryGetPathValue < JArray > (memory, remaining_path, out array)) { if (array != null && array.Count > 0) { @@ -630,7 +595,7 @@ def version(self) -> str: { if (first.Count > 0) { - var second = first[0] + second = first[0] value = ObjectPath.MapValueTo(second) return true } @@ -653,22 +618,22 @@ def version(self) -> str: bool TrackChange(string path, object value) { - var hasPath = false - if (ObjectPath.TryResolvePath(this, path, out var segments)) + hasPath = false + if (ObjectPath.TryResolvePath(this, path, out segments)) { - var root = segments.Count > 1 ? segments[1] as string : string.Empty + root = segments.Count > 1 ? segments[1] as string : string.Empty # Skip _* as first scope, i.e. _adaptive, _tracker, ... if (!root.StartsWith("_", StringComparison.Ordinal)) { # Convert to a simple path with _ between segments - var pathName = string.Join("_", segments) - var trackedPath = $"{PathTracker}.{pathName}" + pathName = string.Join("_", segments) + trackedPath = $"{PathTracker}.{pathName}" uint? counter = null void Update() { - if (TryGetValue(trackedPath, out var lastChanged)) + if (TryGetValue(trackedPath, out lastChanged)) { if (!counter.HasValue) { From 3abc9794c57203b4edb51f30c0bffb704b1a346d Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Mon, 9 Nov 2020 11:43:30 -0800 Subject: [PATCH 05/18] DialogStateManager code complete --- .../botbuilder/dialogs/memory/__init__.py | 2 + .../botbuilder/dialogs/memory/dialog_path.py | 32 + .../dialogs/memory/dialog_state_manager.py | 731 +++++++++--------- .../botbuilder/dialogs/object_path.py | 9 + 4 files changed, 400 insertions(+), 374 deletions(-) create mode 100644 libraries/botbuilder-dialogs/botbuilder/dialogs/memory/dialog_path.py diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/__init__.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/__init__.py index 72a0271de..a43b4cfb8 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/__init__.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/__init__.py @@ -5,6 +5,7 @@ # license information. # -------------------------------------------------------------------------- +from .dialog_path import DialogPath from .dialog_state_manager import DialogStateManager from .dialog_state_manager_configuration import DialogStateManagerConfiguration from .component_memory_scopes_base import ComponentMemoryScopesBase @@ -13,6 +14,7 @@ from . import scope_path __all__ = [ + "DialogPath", "DialogStateManager", "DialogStateManagerConfiguration", "ComponentMemoryScopesBase", diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/dialog_path.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/dialog_path.py new file mode 100644 index 000000000..be11cb2fb --- /dev/null +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/dialog_path.py @@ -0,0 +1,32 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + + +class DialogPath: + # Counter of emitted events. + EVENT_COUNTER = "dialog.eventCounter" + + # Currently expected properties. + EXPECTED_PROPERTIES = "dialog.expectedProperties" + + # Default operation to use for entities where there is no identified operation entity. + DEFAULT_OPERATION = "dialog.defaultOperation" + + # Last surfaced entity ambiguity event. + LAST_EVENT = "dialog.lastEvent" + + # Currently required properties. + REQUIRED_PROPERTIES = "dialog.requiredProperties" + + # Number of retries for the current Ask. + RETRIES = "dialog.retries" + + # Last intent. + LAST_INTENT = "dialog.lastIntent" + + # Last trigger event: defined in FormEvent, ask, clarifyEntity etc.. + LAST_TRIGGER_EVENT = "dialog.lastTriggerEvent" + + @staticmethod + def get_property_name(prop: str) -> str: + return prop.replace("dialog.", "") diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/dialog_state_manager.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/dialog_state_manager.py index c3811afb8..f97bef33a 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/dialog_state_manager.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/dialog_state_manager.py @@ -1,8 +1,20 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +import builtins +from inspect import isawaitable from traceback import print_tb -from typing import Callable, Collection, Generic, Tuple, Type, TypeVar +from typing import ( + Callable, + Collection, + Dict, + Generic, + Iterator, + List, + Tuple, + Type, + TypeVar, +) from botbuilder.core import ComponentRegistration @@ -11,9 +23,12 @@ from .component_memory_scopes_base import ComponentMemoryScopesBase from .component_path_resolvers_base import ComponentPathResolversBase +from .dialog_path import DialogPath from .dialog_state_manager_configuration import DialogStateManagerConfiguration -T = TypeVar('T') # Declare type variable +T = TypeVar("T") # Declare type variable + +builtin_types = list(filter(lambda x: not x.startswith("_"), dir(builtins))) # # The DialogStateManager manages memory scopes and pathresolvers @@ -22,45 +37,62 @@ # class DialogStateManager: - SEPARATORS = [',', '['] + SEPARATORS = [",", "["] # # Initializes a new instance of the class. # # The dialog context for the current turn of the conversation. # Configuration for the dialog state manager. Default is null. - def __init__(self, dialog_context: DialogContext, configuration: DialogStateManagerConfiguration = None): + def __init__( + self, + dialog_context: DialogContext, + configuration: DialogStateManagerConfiguration = None, + ): # # Information for tracking when path was last modified. # self.path_tracker = "dialog._tracker.paths" self._dialog_context = dialog_context - self._version: int = None + self._version: int = 0 ComponentRegistration.add(DialogsComponentRegistration()) if not dialog_context: raise TypeError(f"Expecting: {DialogContext.__name__}, but received None") - self._configuration = configuration or dialog_context.context.turn_state[DialogStateManagerConfiguration.__name__] + self._configuration = ( + configuration + or dialog_context.context.turn_state[ + DialogStateManagerConfiguration.__name__ + ] + ) if not self._configuration: self._configuration = DialogStateManagerConfiguration() # get all of the component memory scopes memory_component: ComponentMemoryScopesBase - for memory_component in filter(lambda comp: isinstance(comp, ComponentMemoryScopesBase), ComponentRegistration.get_components()): + for memory_component in filter( + lambda comp: isinstance(comp, ComponentMemoryScopesBase), + ComponentRegistration.get_components(), + ): for memory_scope in memory_component.get_memory_scopes(): self._configuration.memory_scopes.append(memory_scope) # get all of the component path resolvers path_component: ComponentPathResolversBase - for path_component in filter(lambda comp: isinstance(comp, ComponentPathResolversBase), ComponentRegistration.get_components()): + for path_component in filter( + lambda comp: isinstance(comp, ComponentPathResolversBase), + ComponentRegistration.get_components(), + ): for path_resolver in path_component.get_path_resolvers(): self._configuration.path_resolvers.append(path_resolver) # cache for any other new dialog_state_manager instances in this turn. - dialog_context.context.turn_state[self._configuration.__class__.__name__] = self._configuration + dialog_context.context.turn_state[ + self._configuration.__class__.__name__ + ] = self._configuration def __len__(self) -> int: """ @@ -91,7 +123,10 @@ def values(self) -> Collection[object]: Gets a Collection containing the values of the memory scopes. :return: Values of the memory scopes. """ - return [memory_scope.get_memory(self._dialog_context) for memory_scope in self.configuration.memory_scopes] + return [ + memory_scope.get_memory(self._dialog_context) + for memory_scope in self.configuration.memory_scopes + ] # # Gets a value indicating whether the dialog state manager is read-only. @@ -149,7 +184,14 @@ def get_memory_scope(self, name: str) -> MemoryScope: if not name: raise TypeError(f"Expecting: {str.__name__}, but received None") - return next((memory_scope for memory_scope in self.configuration.memory_scopes if memory_scope.name.lower() == name.lower()), None) + return next( + ( + memory_scope + for memory_scope in self.configuration.memory_scopes + if memory_scope.name.lower() == name.lower() + ), + None, + ) def version(self) -> str: """ @@ -182,7 +224,7 @@ def resolve_memory_scope(self, path: str) -> Tuple[MemoryScope, str]: scope = path[0:sep_index] memory_scope = self.get_memory_scope(scope) if memory_scope: - remaining_path = path[sep_index + 1:] + remaining_path = path[sep_index + 1 :] return memory_scope, remaining_path memory_scope = self.get_memory_scope(scope) @@ -201,19 +243,24 @@ def transform_path(self, path: str) -> str: return path - def _is_primitive(self, cls: Type) -> bool: - return cls in (int, float, bool, str, complex, float, list, tuple, range, dict, bytes, bytearray, memoryview, set, frozenset, map) + @staticmethod + def _is_primitive(cls: Type) -> bool: + return cls.__name__ in builtin_types - def try_get_value(self, path: str, class_type: Type) -> Tuple[bool, object]: + def try_get_value( + self, path: str, class_type: Type = object + ) -> Tuple[bool, object]: """ Get the value from memory using path expression (NOTE: This always returns clone of value). - :param path: - :param class_type: + :param class_type: The value type to return. + :param path: Path expression to use. :return: True if found, false if not and the value. """ if not path: raise TypeError(f"Expecting: {str.__name__}, but received None") - return_value = class_type() if self._is_primitive(class_type) else None + return_value = ( + class_type() if DialogStateManager._is_primitive(class_type) else None + ) path = self.transform_path(path) try: @@ -236,7 +283,7 @@ def try_get_value(self, path: str, class_type: Type) -> Tuple[bool, object]: first = ".FIRST()" i_first = path.upper().rindex(first) if i_first >= 0: - remaining_path = path[i_first + len(first):] + remaining_path = path[i_first + len(first) :] path = path[0:i_first] success, first_value = self.try_get_first_nested_value(path, self) if success: @@ -251,151 +298,142 @@ def try_get_value(self, path: str, class_type: Type) -> Tuple[bool, object]: path_value = ObjectPath.try_get_path_value(self, path) return bool(path_value), path_value - # - # Get the value from memory using path expression (NOTE: This always returns clone of value). - # - # This always returns a CLONE of the memory, any modifications to the result of this will not be affect memory. - # The value type to return. - # Path expression to use. - # Function to give default value if there is none (OPTIONAL). - # Result or null if the path is not valid. - def get_value(self, path_expression: str, default_value: Callable[[],Generic[T]] = None) -> T: - if (TryGetValue(pathExpression ?? throw new ArgumentNullException(nameof(pathExpression)), out value)) - { + def get_value( + self, + class_type: Type, + path_expression: str, + default_value: Callable[[], Generic[T]] = None, + ) -> T: + """ + Get the value from memory using path expression (NOTE: This always returns clone of value). + :param class_type: The value type to return. + :param path_expression: Path expression to use. + :param default_value: Function to give default value if there is none (OPTIONAL). + :return: Result or null if the path is not valid. + """ + if not path_expression: + raise TypeError(f"Expecting: {str.__name__}, but received None") + + success, value = self.try_get_value(path_expression, class_type) + if success: return value - } - return defaultValue != null ? defaultValue() : default - } + return default_value() if default_value else None - # - # Get a int value from memory using a path expression. - # - # Path expression. - # Default value if the value doesn't exist. - # Value or null if path is not valid. - int GetIntValue(string pathExpression, int defaultValue = 0) - { - if (TryGetValue(pathExpression ?? throw new ArgumentNullException(nameof(pathExpression)), out value)) - { + def get_int_value(self, path_expression: str, default_value: int = 0) -> int: + """ + Get an int value from memory using a path expression. + :param path_expression: Path expression to use. + :param default_value: Default value if there is none (OPTIONAL). + :return: + """ + if not path_expression: + raise TypeError(f"Expecting: {str.__name__}, but received None") + success, value = self.try_get_value(path_expression, int) + if success: return value - } - return defaultValue - } + return default_value - # - # Get a bool value from memory using a path expression. - # - # The path expression. - # Default value if the value doesn't exist. - # Bool or null if path is not valid. - bool GetBoolValue(string pathExpression, bool defaultValue = false) - { - if (TryGetValue(pathExpression ?? throw new ArgumentNullException(nameof(pathExpression)), out value)) - { + def get_bool_value(self, path_expression: str, default_value: bool = False) -> bool: + """ + Get a bool value from memory using a path expression. + :param path_expression: Path expression to use. + :param default_value: Default value if there is none (OPTIONAL). + :return: + """ + if not path_expression: + raise TypeError(f"Expecting: {str.__name__}, but received None") + success, value = self.try_get_value(path_expression, bool) + if success: return value - } - return defaultValue - } + return default_value - # - # Get a string value from memory using a path expression. - # - # The path expression. - # Default value if the value doesn't exist. - # string or null if path is not valid. - string GetStringValue(string pathExpression, string defaultValue = default) - { - return GetValue(pathExpression, () => defaultValue) - } + def get_string_value(self, path_expression: str, default_value: str = "") -> str: + """ + Get a string value from memory using a path expression. + :param path_expression: Path expression to use. + :param default_value: Default value if there is none (OPTIONAL). + :return: + """ + if not path_expression: + raise TypeError(f"Expecting: {str.__name__}, but received None") + success, value = self.try_get_value(path_expression, str) + if success: + return value - # - # Set memory to value. - # - # Path to memory. - # Object to set. - void SetValue(string path, object value) - { - if (value is Task) - { - throw new Exception($"{path} = You can't pass an unresolved Task to SetValue") - } - - if (value != null) - { - value = JToken.FromObject(value) - } - - path = TransformPath(path ?? throw new ArgumentNullException(nameof(path))) - if (TrackChange(path, value)) - { - ObjectPath.SetPathValue(this, path, value) - } + return default_value + + def set_value(self, path: str, value: object): + """ + Set memory to value. + :param path: Path to memory. + :param value: Object to set. + :return: + """ + if isawaitable(value): + raise Exception(f"{path} = You can't pass an awaitable to set_value") + + if not path: + raise TypeError(f"Expecting: {str.__name__}, but received None") + + path = self.transform_path(path) + if self._track_change(path, value): + ObjectPath.set_path_value(self, path, value) # Every set will increase version - _version++ - } + self._version += 1 # # Remove property from memory. # # Path to remove the leaf property. - void RemoveValue(string path) - { - path = TransformPath(path ?? throw new ArgumentNullException(nameof(path))) - if (TrackChange(path, null)) - { - ObjectPath.RemovePathValue(this, path) - } - } + def remove_value(self, path: str): + """ + Set memory to value. + :param path: Path to memory. + :param value: Object to set. + :return: + """ + if not path: + raise TypeError(f"Expecting: {str.__name__}, but received None") - # - # Gets all memoryscopes suitable for logging. - # - # object which represents all memory scopes. - JObject GetMemorySnapshot() - { - result = new JObject() - - foreach (scope in Configuration.MemoryScopes.Where(ms => ms.IncludeInSnapshot)) - { - memory = scope.GetMemory(_dialogContext) - if (memory != null) - { - result[scope.Name] = JToken.FromObject(memory) - } - } + path = self.transform_path(path) + if self._track_change(path, None): + ObjectPath.remove_path_value(self, path) + + def get_memory_snapshot(self) -> Dict[str, object]: + """ + Gets all memoryscopes suitable for logging. + :return: object which represents all memory scopes. + """ + result = {} + + for scope in [ + ms for ms in self.configuration.memory_scopes if ms.include_in_snapshot + ]: + memory = scope.get_memory(self._dialog_context) + if memory: + result[scope.name] = memory return result - } - # - # Load all of the scopes. - # - # cancellationToken. - # Task. - async Task LoadAllScopesAsync(CancellationToken cancellationToken = default) - { - foreach (scope in Configuration.MemoryScopes) - { - await scope.LoadAsync(_dialogContext, cancellationToken: cancellationToken).ConfigureAwait(false) - } - } + async def load_all_scopes(self): + """ + Load all of the scopes. + :return: + """ + for scope in self.configuration.memory_scopes: + await scope.load(self._dialog_context) - # - # Save all changes for all scopes. - # - # cancellationToken. - # Task. - async Task SaveAllChangesAsync(CancellationToken cancellationToken = default) - { - foreach (scope in Configuration.MemoryScopes) - { - await scope.SaveChangesAsync(_dialogContext, cancellationToken: cancellationToken).ConfigureAwait(false) - } - } + async def save_all_changes(self): + """ + Save all changes for all scopes. + :return: + """ + for scope in self.configuration.memory_scopes: + await scope.save_changes(self._dialog_context) # # Delete the memory for a scope. @@ -403,272 +441,217 @@ def get_value(self, path_expression: str, default_value: Callable[[],Generic[T]] # name of the scope. # cancellationToken. # Task. - async Task DeleteScopesMemoryAsync(string name, CancellationToken cancellationToken = default) - { - name = name.ToUpperInvariant() - scope = Configuration.MemoryScopes.SingleOrDefault(s => s.Name.ToUpperInvariant() == name) - if (scope != null) - { - await scope.DeleteAsync(_dialogContext, cancellationToken).ConfigureAwait(false) - } - } - - # - # Adds an element to the dialog state manager. - # - # Key of the element to add. - # Value of the element to add. - void Add(string key, object value) - { - throw new NotSupportedException() - } - - # - # Determines whether the dialog state manager contains an element with the specified key. - # - # The key to locate in the dialog state manager. - # true if the dialog state manager contains an element with - # the key otherwise, false. - bool ContainsKey(string key) - { - return Configuration.MemoryScopes.Any(ms => ms.Name.ToUpperInvariant() == key.ToUpperInvariant()) - } - - # - # Removes the element with the specified key from the dialog state manager. - # - # The key of the element to remove. - # true if the element is succesfully removed otherwise, false. - # This method is not supported. - bool Remove(string key) - { - throw new NotSupportedException() - } - - # - # Gets the value associated with the specified key. - # - # The key whose value to get. - # When this method returns, the value associated with the specified key, if the - # key is found otherwise, the default value for the type of the value parameter. - # This parameter is passed uninitialized. - # true if the dialog state manager contains an element with the specified key - # otherwise, false. - bool TryGetValue(string key, out object value) - { - return TryGetValue(key, out value) - } + async def delete_scopes_memory_async(self, name: str): + """ + Delete the memory for a scope. + :param name: name of the scope. + :return: + """ + name = name.upper() + scope_list = [ + ms for ms in self.configuration.memory_scopes if ms.name.upper == name + ] + if len(scope_list) > 1: + raise RuntimeError(f"More than 1 scopes found with the name '{name}'") + scope = scope_list[0] if scope_list else None + if scope: + await scope.delete(self._dialog_context) + + def add(self, key: str, value: object): + """ + Adds an element to the dialog state manager. + :param key: Key of the element to add. + :param value: Value of the element to add. + :return: + """ + raise RuntimeError("Not supported") - # - # Adds an item to the dialog state manager. - # - # The with the key and object of - # the item to add. - # This method is not supported. - void Add(KeyValuePair item) - { - throw new NotSupportedException() - } + def contains_key(self, key: str) -> bool: + """ + Determines whether the dialog state manager contains an element with the specified key. + :param key: The key to locate in the dialog state manager. + :return: True if the dialog state manager contains an element with the key otherwise, False. + """ + scopes_with_key = [ + ms + for ms in self.configuration.memory_scopes + if ms.name.upper == key.upper() + ] + return bool(scopes_with_key) + + def remove(self, key: str): + """ + Removes the element with the specified key from the dialog state manager. + :param key: Key of the element to remove. + :return: + """ + raise RuntimeError("Not supported") # # Removes all items from the dialog state manager. # # This method is not supported. - void Clear() - { - throw new NotSupportedException() - } + def clear(self, key: str): + """ + Removes all items from the dialog state manager. + :param key: Key of the element to remove. + :return: + """ + raise RuntimeError("Not supported") - # - # Determines whether the dialog state manager contains a specific value. - # - # The of the item to locate. - # true if item is found in the dialog state manager otherwise, - # false. - # This method is not supported. - bool Contains(KeyValuePair item) - { - throw new NotSupportedException() - } + def contains(self, item: Tuple[str, object]) -> bool: + """ + Determines whether the dialog state manager contains a specific value (should use __contains__). + :param item: The tuple of the item to locate. + :return bool: True if item is found in the dialog state manager otherwise, False + """ + raise RuntimeError("Not supported") - # - # Copies the elements of the dialog state manager to an array starting at a particular index. - # - # The one-dimensional array that is the destination of the elements copied - # from the dialog state manager. The array must have zero-based indexing. - # The zero-based index in array at which copying begins. - void CopyTo(KeyValuePair[] array, int arrayIndex) - { - foreach (ms in Configuration.MemoryScopes) - { - array[arrayIndex++] = new KeyValuePair(ms.Name, ms.GetMemory(_dialogContext)) - } - } + def __contains__(self, item: Tuple[str, object]) -> bool: + """ + Determines whether the dialog state manager contains a specific value. + :param item: The tuple of the item to locate. + :return bool: True if item is found in the dialog state manager otherwise, False + """ + raise RuntimeError("Not supported") - # - # Removes the first occurrence of a specific object from the dialog state manager. - # - # The object to remove from the dialog state manager. - # true if the item was successfully removed from the dialog state manager - # otherwise, false. - # This method is not supported. - bool Remove(KeyValuePair item) - { - throw new NotSupportedException() - } + def copy_to(self, array: List[Tuple[str, object]], array_index: int): + """ + Copies the elements of the dialog state manager to an array starting at a particular index. + :param array: The one-dimensional array that is the destination of the elements copied + from the dialog state manager. The array must have zero-based indexing. + :param array_index: + :return: + """ + for memory_scope in self.configuration.memory_scopes: + array[array_index] = ( + memory_scope.name, + memory_scope.get_memory(self._dialog_context), + ) + array_index += 1 + + def remove_item(self, item: Tuple[str, object]) -> bool: + """ + Determines whether the dialog state manager contains a specific value (should use __contains__). + :param item: The tuple of the item to locate. + :return bool: True if item is found in the dialog state manager otherwise, False + """ + raise RuntimeError("Not supported") # # Returns an enumerator that iterates through the collection. # # An enumerator that can be used to iterate through the collection. - IEnumerator> GetEnumerator() - { - foreach (ms in Configuration.MemoryScopes) - { - yield return new KeyValuePair(ms.Name, ms.GetMemory(_dialogContext)) - } - } + def get_enumerator(self) -> Iterator[Tuple[str, object]]: + """ + Returns an enumerator that iterates through the collection. + :return: An enumerator that can be used to iterate through the collection. + """ + for memory_scope in self.configuration.memory_scopes: + yield (memory_scope.name, memory_scope.get_memory(self._dialog_context)) # # Track when specific paths are changed. # # Paths to track. # Normalized paths to pass to . - List TrackPaths(IEnumerable paths) - { - allPaths = new List() - foreach (path in paths) - { - tpath = TransformPath(path) + def track_paths(self, paths: Collection[str]) -> List[str]: + """ + Track when specific paths are changed. + :param paths: Paths to track. + :return: Normalized paths to pass to any_path_changed. + """ + all_paths = [] + for path in paths: + t_path = self.transform_path(path) # Track any path that resolves to a constant path - if (ObjectPath.TryResolvePath(this, tpath, out segments)) - { - npath = string.Join("_", segments) - SetValue(PathTracker + "." + npath, 0) - allPaths.Add(npath) - } - } + segments = ObjectPath.try_resolve_path(self, t_path) + if segments: + n_path = "_".join(segments) + self.set_value(self.path_tracker + "." + n_path, 0) + all_paths.append(n_path) - return allPaths - } + return all_paths - # - # Check to see if any path has changed since watermark. - # - # Time counter to compare to. - # Paths from to check. - # True if any path has changed since counter. - bool AnyPathChanged(uint counter, IEnumerable paths) - { - found = false - if (paths != null) - { - foreach (path in paths) - { - if (GetValue(PathTracker + "." + path) > counter) - { - found = true + def any_path_changed(self, counter: int, paths: Collection[str]) -> bool: + """ + Check to see if any path has changed since watermark. + :param counter: Time counter to compare to. + :param paths: Paths from track_paths to check. + :return: True if any path has changed since counter. + """ + found = False + if paths: + for path in paths: + if self.get_value(int, self.path_tracker + "." + path) > counter: + found = True break - } - } - } return found - } - - IEnumerator IEnumerable.GetEnumerator() - { - foreach (ms in Configuration.MemoryScopes) - { - yield return new KeyValuePair(ms.Name, ms.GetMemory(_dialogContext)) - } - } - - static bool TryGetFirstNestedValue(ref T value, ref string remainingPath, object memory) - { - if (ObjectPath.TryGetPathValue < JArray > (memory, remaining_path, out array)) - { - if (array != null && array.Count > 0) - { - if (array[0] is JArray first) - { - if (first.Count > 0) - { - second = first[0] - value = ObjectPath.MapValueTo(second) - return true - } - - return false - } - - value = ObjectPath.MapValueTo(array[0]) - return true - } - } - - return false - } - - string GetBadScopeMessage(string path) - { - return $"'{path}' does not match memory scopes:[{string.Join(",", Configuration.MemoryScopes.Select(ms => ms.Name))}]" - } - - bool TrackChange(string path, object value) - { - hasPath = false - if (ObjectPath.TryResolvePath(this, path, out segments)) - { - root = segments.Count > 1 ? segments[1] as string : string.Empty + + def __iter__(self): + for memory_scope in self.configuration.memory_scopes: + yield (memory_scope.name, memory_scope.get_memory(self._dialog_context)) + + @staticmethod + def _try_get_first_nested_value( + remaining_path: str, memory: object + ) -> Tuple[bool, object]: + array = ObjectPath.try_get_path_value(memory, remaining_path) + if array: + if isinstance(array[0], list): + first = array[0] + if first: + second = first[0] + return True, second + + return False, None + + return True, array[0] + + return False, None + + def _track_change(self, path: str, value: object) -> bool: + has_path = False + segments = ObjectPath.try_resolve_path(self, path) + if segments: + root = segments[1] if len(segments) > 1 else "" # Skip _* as first scope, i.e. _adaptive, _tracker, ... - if (!root.StartsWith("_", StringComparison.Ordinal)) - { + if not root.startswith("_"): # Convert to a simple path with _ between segments - pathName = string.Join("_", segments) - trackedPath = $"{PathTracker}.{pathName}" - uint? counter = null - - void Update() - { - if (TryGetValue(trackedPath, out lastChanged)) - { - if (!counter.HasValue) - { - counter = GetValue(DialogPath.EventCounter) - } - - SetValue(trackedPath, counter.Value) - } - } - - Update() - if (value is object obj) - { + path_name = "_".join(segments) + tracked_path = f"{self.path_tracker}.{path_name}" + counter = None + + def update(): + nonlocal counter + last_changed = self.try_get_value(tracked_path, int) + if last_changed: + if counter is not None: + counter = self.get_value(int, DialogPath.EVENT_COUNTER) + + self.set_value(tracked_path, counter) + + update() + if not self._is_primitive(type(value)): # For an object we need to see if any children path are being tracked - void CheckChildren(string property, object instance) - { + def check_children(property: str, instance: object): + nonlocal tracked_path # Add new child segment - trackedPath += "_" + property.ToLowerInvariant() - Update() - if (instance is object child) - { - ObjectPath.ForEachProperty(child, CheckChildren) - } + tracked_path += "_" + property.lower() + update() + if not self._is_primitive(type(instance)): + ObjectPath.for_each_property(property, check_children) # Remove added child segment - trackedPath = trackedPath.Substring(0, trackedPath.LastIndexOf('_')) - } + tracked_path = tracked_path.Substring( + 0, tracked_path.LastIndexOf("_") + ) - ObjectPath.ForEachProperty(obj, CheckChildren) - } - } + ObjectPath.for_each_property(value, check_children) - hasPath = true - } + has_path = True - return hasPath - } -} + return has_path diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/object_path.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/object_path.py index 6e6435582..80f722519 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/object_path.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/object_path.py @@ -267,6 +267,15 @@ def emit(): return so_far + @staticmethod + def for_each_property(obj: object, action: Callable[[str, object], None]): + if isinstance(obj, dict): + for key, value in obj.items(): + action(key, value) + elif hasattr(obj, "__dict__"): + for key, value in vars(obj).items(): + action(key, value) + @staticmethod def __resolve_segments(current, segments: []) -> object: result = current From 40fa13dc151cada59ef06f43aa05d1a2d36b84c4 Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Mon, 9 Nov 2020 21:26:18 -0800 Subject: [PATCH 06/18] Dialog manager code complete (tests pending) --- .../botbuilder/dialogs/dialog_context.py | 54 +- .../botbuilder/dialogs/dialog_manager.py | 468 ++++++++++-------- .../dialogs/dialog_manager_result.py | 13 +- 3 files changed, 336 insertions(+), 199 deletions(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_context.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_context.py index 8dbf05071..3d8beec3a 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_context.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_context.py @@ -7,6 +7,7 @@ from botbuilder.dialogs.memory import DialogStateManager from .dialog_event import DialogEvent +from .dialog_events import DialogEvents from .dialog_set import DialogSet from .dialog_state import DialogState from .dialog_turn_status import DialogTurnStatus @@ -184,12 +185,61 @@ async def end_dialog(self, result: object = None): return DialogTurnResult(DialogTurnStatus.Complete, result) - async def cancel_all_dialogs(self): + async def cancel_all_dialogs( + self, + cancel_parents: bool = None, + event_name: str = None, + event_value: object = None, + ): """ Deletes any existing dialog stack thus cancelling all dialogs on the stack. - :param result: (Optional) result to pass to the parent dialogs. + :param cancel_parents: + :param event_name: + :param event_value: :return: """ + if cancel_parents is None: + try: + event_name = event_name or DialogEvents.cancel_dialog + + if self.stack or self.parent: + # Cancel all local and parent dialogs while checking for interception + notify = False + dialog_context = self + + while dialog_context: + if dialog_context.stack: + # Check to see if the dialog wants to handle the event + if notify: + event_handled = await dialog_context.emit_event( + event_name, + event_value, + bubble=False, + from_leaf=False, + ) + + if event_handled: + break + + # End the active dialog + await dialog_context.end_active_dialog( + DialogReason.CancelCalled + ) + else: + dialog_context = ( + dialog_context.parent if cancel_parents else None + ) + + notify = True + + return DialogTurnResult(DialogTurnStatus.Cancelled) + else: + # Stack was empty and no parent + return DialogTurnResult(DialogTurnStatus.Empty) + except Exception as err: + self.set_exception_context_data(err) + raise + if self.stack: while self.stack: await self.end_active_dialog(DialogReason.CancelCalled) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_manager.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_manager.py index 2dfa7f99c..029d7868d 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_manager.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_manager.py @@ -4,98 +4,86 @@ from datetime import datetime, timedelta from threading import Lock -from botbuilder.core import BotStateSet, ConversationState, UserState, TurnContext -from botbuilder.dialogs.memory import DialogStateManagerConfiguration +from botbuilder.core import ( + BotAdapter, + BotStateSet, + ConversationState, + UserState, + TurnContext, +) +from botbuilder.core.skills import SkillConversationReference, SkillHandler +from botbuilder.dialogs.memory import ( + DialogStateManager, + DialogStateManagerConfiguration, +) +from botbuilder.schema import Activity, ActivityTypes +from botframework.connector.auth import ( + AuthenticationConstants, + ClaimsIdentity, + GovernmentConstants, + SkillValidation, +) from .dialog import Dialog from .dialog_context import DialogContext +from .dialog_events import DialogEvents from .dialog_set import DialogSet from .dialog_state import DialogState from .dialog_manager_result import DialogManagerResult +from .dialog_turn_status import DialogTurnStatus +from .dialog_turn_result import DialogTurnResult + -# -# Class which runs the dialog system. -# class DialogManager: + """ + Class which runs the dialog system. + """ - # - # Initializes a instance of the class. - # - # Root dialog to use. - # alternate name for the dialog_state property. (Default is "DialogState"). def __init__(self, root_dialog: Dialog = None, dialog_state_property: str = None): + """ + Initializes a instance of the class. + :param root_dialog: Root dialog to use. + :param dialog_state_property: alternate name for the dialog_state property. (Default is "DialogState"). + """ self.last_access = "_lastAccess" self._root_dialog_id = "" self._dialog_state_property = dialog_state_property or "DialogState" self._lock = Lock() self.root_dialog = root_dialog - # + # Gets or sets the ConversationState. - # - # - # The ConversationState. - # self.conversation_state: ConversationState = None - # # Gets or sets the UserState. - # - # - # The UserState. - # self.user_state: UserState = None - # # Gets InitialTurnState collection to copy into the TurnState on every turn. - # - # - # TurnState. - # self.initial_turn_state = {} - # # Gets or sets root dialog to use to start conversation. - # - # - # Root dialog to use to start conversation. - # self.root_dialog: Dialog = None - # # Gets or sets global dialogs that you want to have be callable. - # - # Dialogs set. self.dialogs = DialogSet() - # # Gets or sets the DialogStateManagerConfiguration. - # - # - # The DialogStateManagerConfiguration. - # self.state_configuration: DialogStateManagerConfiguration = None - # # Gets or sets (optional) number of milliseconds to expire the bot's state after. - # - # - # Number of milliseconds. - # self.expire_after: int = None - # - # Runs dialog system in the context of an ITurnContext. - # - # turn context. - # Cancellation token. - # result of the running the logic against the activity. async def on_turn(self, context: TurnContext) -> DialogManagerResult: + """ + Runs dialog system in the context of an ITurnContext. + :param context: turn context. + :return: + """ # Lazy initialize RootDialog so it can refer to assets like LG function templates if self._root_dialog_id is None: with self._lock: if self._root_dialog_id is None: self._root_dialog_id = self.root_dialog.id - #self.dialogs = self.root_dialog.telemetry_client + # self.dialogs = self.root_dialog.telemetry_client self.dialogs.add(self.root_dialog) bot_state_set = BotStateSet() @@ -103,17 +91,21 @@ async def on_turn(self, context: TurnContext) -> DialogManagerResult: # Preload TurnState with DM TurnState. for key, val in self.initial_turn_state: context.turn_state[key] = val - + # register DialogManager with TurnState. context.turn_state[DialogManager.__class__.__name__] = self conversation_state_name = ConversationState.__class__.__name__ if self.conversation_state is None: if conversation_state_name not in context.turn_state: - raise Exception(f"Unable to get an instance of {conversation_state_name} from turn_context.") - self.conversation_state: ConversationState = context.turn_state[conversation_state_name] + raise Exception( + f"Unable to get an instance of {conversation_state_name} from turn_context." + ) + self.conversation_state: ConversationState = context.turn_state[ + conversation_state_name + ] else: context.turn_state[conversation_state_name] = self.conversation_state - + bot_state_set.add(self.conversation_state) user_state_name = UserState.__class__.__name__ @@ -121,185 +113,275 @@ async def on_turn(self, context: TurnContext) -> DialogManagerResult: self.user_state = context.turn_state.get(user_state_name, None) else: context.turn_state[user_state_name] = self.user_state - + if self.user_state is not None: self.user_state: UserState = self.user_state bot_state_set.add(self.user_state) - + # create property accessors - # (last_access) + # DateTime(last_access) last_access_property = self.conversation_state.create_property(self.last_access) - last_access: datetime = await last_access_property.get(context, lambda: datetime.now()) + last_access: datetime = await last_access_property.get( + context, lambda: datetime.now() + ) # Check for expired conversation - if self.expire_after is not None and (datetime.now() - last_access) >= timedelta(milliseconds=float(self.expire_after)): + if self.expire_after is not None and ( + datetime.now() - last_access + ) >= timedelta(milliseconds=float(self.expire_after)): # Clear conversation state await self.conversation_state.clear_state(context) - + last_access = datetime.now() await last_access_property.set(context, last_access) - # get dialog stack - dialogs_property = self.conversation_state.create_property(self._dialog_state_property) - dialog_state: DialogState = await dialogs_property.get(context, lambda: DialogState()) + # get dialog stack + dialogs_property = self.conversation_state.create_property( + self._dialog_state_property + ) + dialog_state: DialogState = await dialogs_property.get( + context, lambda: DialogState() + ) # 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: - dialog_context.services[key] = service - + dialog_context.services[key] = service + # map TurnState into root dialog context.services - for key, service in context.turn_state: - dialog_context.services[key] = service - + for key, service in context.turn_state: + dialog_context.services[key] = service + # get the DialogStateManager configuration - dialog_state_manager = DialogStateManager(dialog_context, StateConfiguration) - await dialog_state_manager.LoadAllScopesAsync(cancellationToken) - dialog_context.Context.TurnState.Add(dialog_state_manager) + dialog_state_manager = DialogStateManager( + dialog_context, self.state_configuration + ) + await dialog_state_manager.load_all_scopes() + dialog_context.context.turn_state[ + dialog_state_manager.__class__.__name__ + ] = dialog_state_manager - DialogTurnResult turnResult = None + 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. - endOfTurn = false - while (!endOfTurn) - try - if (context.TurnState.Get(BotAdapter.BotIdentityKey) is ClaimsIdentity claimIdentity && SkillValidation.IsSkillClaim(claimIdentity.Claims)) - # The bot is running as a skill. - turnResult = await HandleSkillOnTurnAsync(dialog_context, cancellationToken) - else - # The bot is running as root bot. - turnResult = await HandleBotOnTurnAsync(dialog_context, cancellationToken) - - # turn successfully completed, break the loop - endOfTurn = true - catch (Exception err) - # fire error event, bubbling from the leaf. - handled = await dialog_context.EmitEventAsync(DialogEvents.Error, err, bubble: true, fromLeaf: true, cancellationToken: cancellationToken) - - if (!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) - throw - - # save all state scopes to their respective botState locations. - await dialog_state_manager.SaveAllChangesAsync(cancellationToken) - # save BotState changes - await bot_state_set.SaveAllChangesAsync(dialog_context.Context, false, cancellationToken) + # 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) - return DialogManagerResult { TurnResult = turnResult } + # 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 - # - # Helper to send a trace activity with a memory snapshot of the active dialog DC. - # - static async Task SendStateSnapshotTraceAsync(DialogContext dialog_context, str traceLabel, CancellationToken cancellationToken) - # send trace of memory - snapshot = GetActiveDialogContext(dialog_context).State.GetMemorySnapshot() - traceActivity = (Activity)Activity.CreateTraceActivity("BotState", "https://www.botframework.com/schemas/botState", snapshot, traceLabel) - await dialog_context.Context.SendActivityAsync(traceActivity, cancellationToken) + # save all state scopes to their respective botState locations. + await dialog_state_manager.save_all_changes() - static bool IsFromParentToSkill(ITurnContext turnContext) - if (turnContext.TurnState.Get(SkillHandler.SkillConversationReferenceKey) != None) - return false - - return turnContext.TurnState.Get(BotAdapter.BotIdentityKey) is ClaimsIdentity claimIdentity && SkillValidation.IsSkillClaim(claimIdentity.Claims) + # save BotState changes + await bot_state_set.save_all_changes(dialog_context.context, False) + + return DialogManagerResult(turn_result=turn_result) + + @staticmethod + async def send_state_snapshot_trace( + dialog_context: DialogContext, trace_label: str + ): + """ + Helper to send a trace activity with a memory snapshot of the active dialog DC. + :param dialog_context: + :param trace_label: + :return: + """ + # send trace of memory + snapshot = DialogManager.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 is_from_parent_to_skill(turn_context: TurnContext) -> bool: + if turn_context.turn_state.get( + SkillHandler.SKILL_CONVERSATION_REFERENCE_KEY, None + ): + return False + + claims_identity: ClaimsIdentity = turn_context.turn_state.get( + BotAdapter.BOT_IDENTITY_KEY, None + ) + return isinstance( + claims_identity, ClaimsIdentity + ) and SkillValidation.is_skill_claim(claims_identity.claims) # Recursively walk up the DC stack to find the active DC. - static DialogContext GetActiveDialogContext(DialogContext dialogContext) - child = dialogContext.Child - if (childis None) - return dialogContext - - return GetActiveDialogContext(child) - - # - # Helper to determine if we should send an EndOfConversation to the parent or not. - # - static bool ShouldSendEndOfConversationToParent(ITurnContext context, DialogTurnResult turnResult) - if (!(turnResult.Status == DialogTurnStatus.Complete || turnResult.Status == DialogTurnStatus.Cancelled)) - # The dialog is still going, don't return EoC. - return false - - if (context.TurnState.Get(BotAdapter.BotIdentityKey) is ClaimsIdentity claimIdentity && SkillValidation.IsSkillClaim(claimIdentity.Claims)) - # EoC Activities returned by skills are bounced back to the bot by SkillHandler. + @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 DialogManager.get_active_dialog_context(child) + + @staticmethod + def should_send_end_of_conversation_to_parent( + context: TurnContext, turn_result: DialogTurnResult + ) -> bool: + """ + Helper to determine if we should send an EndOfConversation to the parent or not. + :param context: + :param turn_result: + :return: + """ + if not ( + turn_result.status == DialogTurnStatus.Complete + or turn_result.status == DialogTurnStatus.Cancelled + ): + # The dialog is still going, don't return EoC. + return False + 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): + # EoC Activities returned by skills are bounced back to the bot by SkillHandler. # In those cases we will have a SkillConversationReference instance in state. - skillConversationReference = context.TurnState.Get(SkillHandler.SkillConversationReferenceKey) - if (skillConversationReference != None) - # If the skillConversationReference.OAuthScope is for one of the supported channels, we are at the root and we should not send an EoC. - return skillConversationReference.OAuthScope != AuthenticationConstants.ToChannelFromBotOAuthScope && skillConversationReference.OAuthScope != GovernmentAuthenticationConstants.ToChannelFromBotOAuthScope - - return true - - return false - - async Task HandleSkillOnTurnAsync(DialogContext dialog_context, CancellationToken cancellationToken) - # the bot is running as a skill. - turnContext = dialog_context.Context + skill_conversation_reference: SkillConversationReference = context.turn_state.get( + SkillHandler.SKILL_CONVERSATION_REFERENCE_KEY + ) + if skill_conversation_reference: + # If the skill_conversation_reference.OAuthScope is for one of the supported channels, we are at the + # root and we should not send an EoC. + return ( + skill_conversation_reference.oauth_scope + != AuthenticationConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE + and skill_conversation_reference.oauth_scope + != GovernmentConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE + ) + + return True + + return False + + async def handle_skill_on_turn( + self, dialog_context: DialogContext + ) -> DialogTurnResult: + # the bot is running as a skill. + turn_context = dialog_context.context # Process remote cancellation - if (turnContext.Activity.Type == ActivityTypes.EndOfConversation && dialog_context.ActiveDialog != None && IsFromParentToSkill(turnContext)) - # Handle remote cancellation request from parent. - activeDialogContext = GetActiveDialogContext(dialog_context) + if ( + turn_context.activity.type == ActivityTypes.end_of_conversation + and dialog_context.active_dialog is not None + and self.is_from_parent_to_skill(turn_context) + ): + # Handle remote cancellation request from parent. + active_dialog_context = self.get_active_dialog_context(dialog_context) + + remote_cancel_text = "Skill was canceled through an EndOfConversation activity from the parent." + await turn_context.send_trace_activity( + f"{self.__class__.__name__}.on_turn_async()", + label=f"{remote_cancel_text}", + ) + + # Send cancellation message to the top dialog in the stack to ensure all the parents are canceled in the + # right order. + return await active_dialog_context.cancel_all_dialogs(True) - remoteCancelText = "Skill was canceled through an EndOfConversation activity from the parent." - await turnContext.TraceActivityAsync($"{GetType().Name}.OnTurnAsync()", label: $"{remoteCancelText}", cancellationToken: cancellationToken) - - # Send cancellation message to the top dialog in the stack to ensure all the parents are canceled in the right order. - return await activeDialogContext.CancelAllDialogsAsync(true, cancellationToken: cancellationToken) - # Handle reprompt # Process a reprompt event sent from the parent. - if (turnContext.Activity.Type == ActivityTypes.Event && turnContext.Activity.Name == DialogEvents.RepromptDialog) - if (dialog_context.ActiveDialogis None) - return DialogTurnResult(DialogTurnStatus.Empty) - - await dialog_context.RepromptDialogAsync(cancellationToken) + if ( + turn_context.activity.type == ActivityTypes.event + and turn_context.activity.name == DialogEvents.reprompt_dialog + ): + if not dialog_context.active_dialog: + return DialogTurnResult(DialogTurnStatus.Empty) + + await dialog_context.reprompt_dialog() return DialogTurnResult(DialogTurnStatus.Waiting) - + # Continue execution # - This will apply any queued up interruptions and execute the current/next step(s). - turnResult = await dialog_context.ContinueDialogAsync(cancellationToken) - if (turnResult.Status == DialogTurnStatus.Empty) - # restart root dialog - startMessageText = $"Starting {_root_dialog_id}." - await turnContext.TraceActivityAsync($"{GetType().Name}.OnTurnAsync()", label: $"{startMessageText}", cancellationToken: cancellationToken) - turnResult = await dialog_context.BeginDialogAsync(_root_dialog_id, cancellationToken: cancellationToken) - - await SendStateSnapshotTraceAsync(dialog_context, "Skill State", cancellationToken) - - if (ShouldSendEndOfConversationToParent(turnContext, turnResult)) - endMessageText = $"Dialog {_root_dialog_id} has **completed**. Sending EndOfConversation." - await turnContext.TraceActivityAsync($"{GetType().Name}.OnTurnAsync()", label: $"{endMessageText}", value: turnResult.Result, cancellationToken: cancellationToken) + turn_result = await dialog_context.continue_dialog() + if turn_result.status == DialogTurnStatus.Empty: + # restart root dialog + start_message_text = f"Starting {self._root_dialog_id}." + await turn_context.send_trace_activity( + f"{self.__class__.__name__}.handle_skill_on_turn_async()", + label=f"{start_message_text}", + ) + turn_result = await dialog_context.begin_dialog(self._root_dialog_id) + + await DialogManager.send_state_snapshot_trace(dialog_context, "Skill State") + + if self.should_send_end_of_conversation_to_parent(turn_context, turn_result): + end_message_text = f"Dialog {self._root_dialog_id} has **completed**. Sending EndOfConversation." + await turn_context.send_trace_activity( + f"{self.__class__.__name__}.handle_skill_on_turn_async()", + label=f"{end_message_text}", + value=turn_result.result, + ) # Send End of conversation at the end. - activity = Activity(ActivityTypes.EndOfConversation) - Value = turnResult.Result, - Locale = turnContext.Activity.Locale - await turnContext.SendActivityAsync(activity, cancellationToken) - - return turnResult - - async Task HandleBotOnTurnAsync(DialogContext dialog_context, CancellationToken cancellationToken) - DialogTurnResult turnResult - - # the bot is running as a root bot. - if (dialog_context.ActiveDialogis None) - # start root dialog - turnResult = await dialog_context.BeginDialogAsync(_root_dialog_id, cancellationToken: cancellationToken) - else - # Continue execution + activity = Activity( + type=ActivityTypes.end_of_conversation, + value=turn_result.result, + locale=turn_context.activity.locale, + ) + await turn_context.send_activity(activity) + + return turn_result + + async def handle_bot_on_turn( + self, dialog_context: DialogContext + ) -> DialogTurnResult: + # the bot is running as a root bot. + if dialog_context.active_dialog is None: + # start root dialog + turn_result = await dialog_context.begin_dialog(self._root_dialog_id) + else: + # Continue execution # - This will apply any queued up interruptions and execute the current/next step(s). - turnResult = await dialog_context.ContinueDialogAsync(cancellationToken) + turn_result = await dialog_context.continue_dialog() + + if turn_result.status == DialogTurnStatus.Empty: + # restart root dialog + turn_result = await dialog_context.begin_dialog(self._root_dialog_id) - if (turnResult.Status == DialogTurnStatus.Empty) - # restart root dialog - turnResult = await dialog_context.BeginDialogAsync(_root_dialog_id, cancellationToken: cancellationToken) - - await SendStateSnapshotTraceAsync(dialog_context, "Bot State", cancellationToken) + await self.send_state_snapshot_trace(dialog_context, "Bot State") - return turnResult + return turn_result diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_manager_result.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_manager_result.py index 5b7215e52..c184f0df2 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_manager_result.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_manager_result.py @@ -10,7 +10,12 @@ class DialogManagerResult: - def __init__(self): - self.turn_result: DialogTurnResult = None - self.activities: List[Activity] = None - self.persisted_state: PersistedState = None + def __init__( + self, + turn_result: DialogTurnResult = None, + activities: List[Activity] = None, + persisted_state: PersistedState = None, + ): + self.turn_result = turn_result + self.activities = activities + self.persisted_state = persisted_state From 80f2ba56f02dab220c80973dfb544be2db804dbd Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Tue, 10 Nov 2020 14:18:06 -0800 Subject: [PATCH 07/18] Solved circular dependency issues, bugfix in DialogCOmponentRegistration --- .../botbuilder/dialogs/__init__.py | 2 + .../botbuilder/dialogs/dialog_container.py | 2 +- .../botbuilder/dialogs/dialog_context.py | 4 +- .../botbuilder/dialogs/dialog_set.py | 6 +- .../dialogs/dialogs_component_registration.py | 2 - .../dialogs/memory/dialog_state_manager.py | 74 +++++++++---------- .../memory/scopes/bot_state_memory_scope.py | 13 ++-- .../memory/scopes/class_memory_scope.py | 7 +- .../scopes/dialog_class_memory_scope.py | 14 ++-- .../scopes/dialog_context_memory_scope.py | 7 +- .../memory/scopes/dialog_memory_scope.py | 18 +++-- .../dialogs/memory/scopes/memory_scope.py | 15 ++-- .../memory/scopes/settings_memory_scope.py | 7 +- .../memory/scopes/this_memory_scope.py | 9 +-- .../memory/scopes/turn_memory_scope.py | 9 +-- 15 files changed, 94 insertions(+), 95 deletions(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/__init__.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/__init__.py index 676a36c40..37c305536 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/__init__.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/__init__.py @@ -17,6 +17,7 @@ from .dialog_state import DialogState from .dialog_turn_result import DialogTurnResult from .dialog_turn_status import DialogTurnStatus +from .dialog_manager import DialogManager from .dialog_manager_result import DialogManagerResult from .dialog import Dialog from .dialogs_component_registration import DialogsComponentRegistration @@ -42,6 +43,7 @@ "DialogState", "DialogTurnResult", "DialogTurnStatus", + "DialogManager", "DialogManagerResult", "Dialog", "DialogsComponentRegistration", diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_container.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_container.py index 2831d9a0c..373cabb51 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_container.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_container.py @@ -11,7 +11,7 @@ from .dialog_set import DialogSet -class DialogContainer(ABC, Dialog): +class DialogContainer(Dialog, ABC): def __init__(self, dialog_id: str = None): super().__init__(dialog_id) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_context.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_context.py index 3d8beec3a..f5ec274ad 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_context.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_context.py @@ -15,7 +15,6 @@ from .dialog_reason import DialogReason from .dialog_instance import DialogInstance from .dialog import Dialog -from .dialog_container import DialogContainer class DialogContext: @@ -84,6 +83,9 @@ def child(self) -> Optional["DialogContext"]: if instance: dialog = self.find_dialog(instance.id) + # This import prevents circular dependency issues + from .dialog_container import DialogContainer + if isinstance(dialog, DialogContainer): return dialog.create_child_context(self) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_set.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_set.py index 767699f98..bb32e7b83 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_set.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_set.py @@ -7,7 +7,6 @@ from botbuilder.core import TurnContext, BotAssert, StatePropertyAccessor from .dialog import Dialog from .dialog_state import DialogState -from .dialog_context import DialogContext class DialogSet: @@ -73,7 +72,10 @@ def add(self, dialog: Dialog): return self - async def create_context(self, turn_context: TurnContext) -> DialogContext: + async def create_context(self, turn_context: TurnContext) -> "DialogContext": + # This import prevents circular dependency issues + from .dialog_context import DialogContext + # pylint: disable=unnecessary-lambda BotAssert.context_not_none(turn_context) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialogs_component_registration.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialogs_component_registration.py index 8242c7e6b..acbddd1e0 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialogs_component_registration.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialogs_component_registration.py @@ -23,7 +23,6 @@ ) from botbuilder.dialogs.memory.path_resolvers import ( - AliasPathResolver, AtAtPathResolver, AtPathResolver, DollarPathResolver, @@ -47,7 +46,6 @@ def get_memory_scopes(self) -> Iterable[MemoryScope]: yield UserMemoryScope() def get_path_resolvers(self) -> Iterable[PathResolverBase]: - yield AliasPathResolver() yield AtAtPathResolver() yield AtPathResolver() yield DollarPathResolver() diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/dialog_state_manager.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/dialog_state_manager.py index f97bef33a..ab037427b 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/dialog_state_manager.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/dialog_state_manager.py @@ -8,7 +8,6 @@ Callable, Collection, Dict, - Generic, Iterator, List, Tuple, @@ -18,7 +17,6 @@ from botbuilder.core import ComponentRegistration -from botbuilder.dialogs import DialogContext, DialogsComponentRegistration, ObjectPath from botbuilder.dialogs.memory.scopes import MemoryScope from .component_memory_scopes_base import ComponentMemoryScopesBase @@ -30,6 +28,7 @@ builtin_types = list(filter(lambda x: not x.startswith("_"), dir(builtins))) + # # The DialogStateManager manages memory scopes and pathresolvers # MemoryScopes are named root level objects, which can exist either in the dialogcontext or off of turn state @@ -39,34 +38,35 @@ class DialogStateManager: SEPARATORS = [",", "["] - # - # Initializes a new instance of the class. - # - # The dialog context for the current turn of the conversation. - # Configuration for the dialog state manager. Default is null. def __init__( self, - dialog_context: DialogContext, + dialog_context: "DialogContext", configuration: DialogStateManagerConfiguration = None, ): - # + """ + Initializes a new instance of the DialogStateManager class. + :param dialog_context: The dialog context for the current turn of the conversation. + :param configuration: Configuration for the dialog state manager. Default is None. + """ + # These modules are imported at static level to avoid circular dependency problems + from botbuilder.dialogs import DialogsComponentRegistration, ObjectPath + + self._object_path_cls = ObjectPath + self._dialog_component_registration_cls = DialogsComponentRegistration + # Information for tracking when path was last modified. - # self.path_tracker = "dialog._tracker.paths" self._dialog_context = dialog_context self._version: int = 0 - ComponentRegistration.add(DialogsComponentRegistration()) + ComponentRegistration.add(self._dialog_component_registration_cls()) if not dialog_context: - raise TypeError(f"Expecting: {DialogContext.__name__}, but received None") + raise TypeError(f"Expecting: DialogContext, but received None") - self._configuration = ( - configuration - or dialog_context.context.turn_state[ - DialogStateManagerConfiguration.__name__ - ] + self._configuration = configuration or dialog_context.context.turn_state.get( + DialogStateManagerConfiguration.__name__, None ) if not self._configuration: self._configuration = DialogStateManagerConfiguration() @@ -290,19 +290,21 @@ def try_get_value( if not remaining_path: return True, first_value - path_value = ObjectPath.try_get_path_value(first_value, remaining_path) + path_value = self._object_path_cls.try_get_path_value( + first_value, remaining_path + ) return bool(path_value), path_value return False, return_value - path_value = ObjectPath.try_get_path_value(self, path) + path_value = self._object_path_cls.try_get_path_value(self, path) return bool(path_value), path_value def get_value( self, class_type: Type, path_expression: str, - default_value: Callable[[], Generic[T]] = None, + default_value: Callable[[], T] = None, ) -> T: """ Get the value from memory using path expression (NOTE: This always returns clone of value). @@ -380,15 +382,11 @@ def set_value(self, path: str, value: object): path = self.transform_path(path) if self._track_change(path, value): - ObjectPath.set_path_value(self, path, value) + self._object_path_cls.set_path_value(self, path, value) # Every set will increase version self._version += 1 - # - # Remove property from memory. - # - # Path to remove the leaf property. def remove_value(self, path: str): """ Set memory to value. @@ -401,7 +399,7 @@ def remove_value(self, path: str): path = self.transform_path(path) if self._track_change(path, None): - ObjectPath.remove_path_value(self, path) + self._object_path_cls.remove_path_value(self, path) def get_memory_snapshot(self) -> Dict[str, object]: """ @@ -435,12 +433,6 @@ async def save_all_changes(self): for scope in self.configuration.memory_scopes: await scope.save_changes(self._dialog_context) - # - # Delete the memory for a scope. - # - # name of the scope. - # cancellationToken. - # Task. async def delete_scopes_memory_async(self, name: str): """ Delete the memory for a scope. @@ -550,11 +542,6 @@ def get_enumerator(self) -> Iterator[Tuple[str, object]]: for memory_scope in self.configuration.memory_scopes: yield (memory_scope.name, memory_scope.get_memory(self._dialog_context)) - # - # Track when specific paths are changed. - # - # Paths to track. - # Normalized paths to pass to . def track_paths(self, paths: Collection[str]) -> List[str]: """ Track when specific paths are changed. @@ -566,7 +553,7 @@ def track_paths(self, paths: Collection[str]) -> List[str]: t_path = self.transform_path(path) # Track any path that resolves to a constant path - segments = ObjectPath.try_resolve_path(self, t_path) + segments = self._object_path_cls.try_resolve_path(self, t_path) if segments: n_path = "_".join(segments) self.set_value(self.path_tracker + "." + n_path, 0) @@ -598,6 +585,9 @@ def __iter__(self): def _try_get_first_nested_value( remaining_path: str, memory: object ) -> Tuple[bool, object]: + # These modules are imported at static level to avoid circular dependency problems + from botbuilder.dialogs import ObjectPath + array = ObjectPath.try_get_path_value(memory, remaining_path) if array: if isinstance(array[0], list): @@ -614,7 +604,7 @@ def _try_get_first_nested_value( def _track_change(self, path: str, value: object) -> bool: has_path = False - segments = ObjectPath.try_resolve_path(self, path) + segments = self._object_path_cls.try_resolve_path(self, path) if segments: root = segments[1] if len(segments) > 1 else "" @@ -643,14 +633,16 @@ def check_children(property: str, instance: object): tracked_path += "_" + property.lower() update() if not self._is_primitive(type(instance)): - ObjectPath.for_each_property(property, check_children) + self._object_path_cls.for_each_property( + property, check_children + ) # Remove added child segment tracked_path = tracked_path.Substring( 0, tracked_path.LastIndexOf("_") ) - ObjectPath.for_each_property(value, check_children) + self._object_path_cls.for_each_property(value, check_children) has_path = True diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/bot_state_memory_scope.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/bot_state_memory_scope.py index 7f15474cf..f1c40a86e 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/bot_state_memory_scope.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/bot_state_memory_scope.py @@ -4,7 +4,6 @@ from typing import Type from botbuilder.core import BotState -from botbuilder.dialogs import DialogContext from .memory_scope import MemoryScope @@ -14,9 +13,9 @@ def __init__(self, bot_state_type: Type[BotState], name: str): super().__init__(name, include_in_snapshot=True) self.bot_state_type = bot_state_type - def get_memory(self, dialog_context: DialogContext) -> object: + def get_memory(self, dialog_context: "DialogContext") -> object: if not dialog_context: - raise TypeError(f"Expecting: {DialogContext.__name__}, but received None") + raise TypeError(f"Expecting: DialogContext, but received None") bot_state: BotState = BotStateMemoryScope._get_bot_state(dialog_context) cached_state = ( @@ -25,20 +24,20 @@ def get_memory(self, dialog_context: DialogContext) -> object: return cached_state.state if cached_state else None - def set_memory(self, dialog_context: DialogContext, memory: object): + def set_memory(self, dialog_context: "DialogContext", memory: object): raise RuntimeError("You cannot replace the root BotState object") - async def load(self, dialog_context: DialogContext, force: bool = False): + async def load(self, dialog_context: "DialogContext", force: bool = False): bot_state: BotState = self._get_bot_state(dialog_context) if bot_state: await bot_state.load(dialog_context.context, force) - async def save_changes(self, dialog_context: DialogContext, force: bool = False): + async def save_changes(self, dialog_context: "DialogContext", force: bool = False): bot_state: BotState = self._get_bot_state(dialog_context) if bot_state: await bot_state.save_changes(dialog_context.context, force) - def _get_bot_state(self, dialog_context: DialogContext) -> BotState: + def _get_bot_state(self, dialog_context: "DialogContext") -> BotState: return dialog_context.context.turn_state.get(self.bot_state_type.__name__, None) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/class_memory_scope.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/class_memory_scope.py index 9c3f573f2..b9cb1b75f 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/class_memory_scope.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/class_memory_scope.py @@ -3,7 +3,6 @@ from copy import deepcopy -from botbuilder.dialogs import DialogContext from botbuilder.dialogs.memory import scope_path from .memory_scope import MemoryScope @@ -13,9 +12,9 @@ class ClassMemoryScope(MemoryScope): def __init__(self): super().__init__(scope_path.SETTINGS, include_in_snapshot=False) - def get_memory(self, dialog_context: DialogContext) -> object: + def get_memory(self, dialog_context: "DialogContext") -> object: if not dialog_context: - raise TypeError(f"Expecting: {DialogContext.__name__}, but received None") + raise TypeError(f"Expecting: DialogContext, but received None") # if active dialog is a container dialog then "dialogclass" binds to it. if dialog_context.active_dialog: @@ -25,7 +24,7 @@ def get_memory(self, dialog_context: DialogContext) -> object: return None - def set_memory(self, dialog_context: DialogContext, memory: object): + def set_memory(self, dialog_context: "DialogContext", memory: object): raise Exception( f"{self.__class__.__name__}.set_memory not supported (read only)" ) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/dialog_class_memory_scope.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/dialog_class_memory_scope.py index ac2904c91..0c7e06e60 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/dialog_class_memory_scope.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/dialog_class_memory_scope.py @@ -3,7 +3,6 @@ from copy import deepcopy -from botbuilder.dialogs import DialogContainer, DialogContext from botbuilder.dialogs.memory import scope_path from .memory_scope import MemoryScope @@ -13,14 +12,19 @@ class DialogClassMemoryScope(MemoryScope): def __init__(self): super().__init__(scope_path.DIALOG_CLASS, include_in_snapshot=False) - def get_memory(self, dialog_context: DialogContext) -> object: + # This import is to avoid circular dependency issues + from botbuilder.dialogs import DialogContainer + + self._dialog_container_cls = DialogContainer + + def get_memory(self, dialog_context: "DialogContext") -> object: if not dialog_context: - raise TypeError(f"Expecting: {DialogContext.__name__}, but received None") + raise TypeError(f"Expecting: DialogContext, but received None") # if active dialog is a container dialog then "dialogclass" binds to it. if dialog_context.active_dialog: dialog = dialog_context.find_dialog(dialog_context.active_dialog.id) - if isinstance(dialog, DialogContainer): + if isinstance(dialog, self._dialog_container_cls): return deepcopy(dialog) # Otherwise we always bind to parent, or if there is no parent the active dialog @@ -34,7 +38,7 @@ def get_memory(self, dialog_context: DialogContext) -> object: ) return deepcopy(dialog_context.find_dialog(parent_id or active_id)) - def set_memory(self, dialog_context: DialogContext, memory: object): + def set_memory(self, dialog_context: "DialogContext", memory: object): raise Exception( f"{self.__class__.__name__}.set_memory not supported (read only)" ) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/dialog_context_memory_scope.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/dialog_context_memory_scope.py index f779f7c5b..1c7cc0608 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/dialog_context_memory_scope.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/dialog_context_memory_scope.py @@ -1,7 +1,6 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -from botbuilder.dialogs import DialogContext from botbuilder.dialogs.memory import scope_path from .memory_scope import MemoryScope @@ -19,14 +18,14 @@ def __init__(self): # Parent name. self.PARENT = "parent" - def get_memory(self, dialog_context: DialogContext) -> object: + def get_memory(self, dialog_context: "DialogContext") -> object: """ Gets the backing memory for this scope. The object for this turn. Memory for the scope. """ if not dialog_context: - raise TypeError(f"Expecting: {DialogContext.__name__}, but received None") + raise TypeError(f"Expecting: DialogContext, but received None") # TODO: make sure that every object in the dict is serializable memory = {} @@ -58,7 +57,7 @@ def get_memory(self, dialog_context: DialogContext) -> object: ) return memory - def set_memory(self, dialog_context: DialogContext, memory: object): + def set_memory(self, dialog_context: "DialogContext", memory: object): raise Exception( f"{self.__class__.__name__}.set_memory not supported (read only)" ) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/dialog_memory_scope.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/dialog_memory_scope.py index 19eaa0716..a37120b28 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/dialog_memory_scope.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/dialog_memory_scope.py @@ -1,7 +1,6 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -from botbuilder.dialogs import DialogContainer, DialogContext from botbuilder.dialogs.memory import scope_path from .memory_scope import MemoryScope @@ -11,14 +10,19 @@ class DialogMemoryScope(MemoryScope): def __init__(self): super().__init__(scope_path.DIALOG) - def get_memory(self, dialog_context: DialogContext) -> object: + # This import is to avoid circular dependency issues + from botbuilder.dialogs import DialogContainer + + self._dialog_container_cls = DialogContainer + + def get_memory(self, dialog_context: "DialogContext") -> object: if not dialog_context: - raise TypeError(f"Expecting: {DialogContext.__name__}, but received None") + raise TypeError(f"Expecting: DialogContext, but received None") # if active dialog is a container dialog then "dialog" binds to it. if dialog_context.active_dialog: dialog = dialog_context.find_dialog(dialog_context.active_dialog.id) - if isinstance(dialog, DialogContainer): + if isinstance(dialog, self._dialog_container_cls): return dialog_context.active_dialog.state # Otherwise we always bind to parent, or if there is no parent the active dialog @@ -32,9 +36,9 @@ def get_memory(self, dialog_context: DialogContext) -> object: ) return parent_state or dc_state - def set_memory(self, dialog_context: DialogContext, memory: object): + def set_memory(self, dialog_context: "DialogContext", memory: object): if not dialog_context: - raise TypeError(f"Expecting: {DialogContext.__name__}, but received None") + raise TypeError(f"Expecting: DialogContext, but received None") if not memory: raise TypeError(f"Expecting: memory object, but received None") @@ -42,7 +46,7 @@ def set_memory(self, dialog_context: DialogContext, memory: object): # if active dialog is a container dialog then "dialog" binds to it if dialog_context.active_dialog: dialog = dialog_context.find_dialog(dialog_context.active_dialog.id) - if isinstance(dialog, DialogContainer): + if isinstance(dialog, self._dialog_container_cls): dialog_context.active_dialog.state = memory return elif dialog_context.parent and dialog_context.parent.active_dialog: diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/memory_scope.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/memory_scope.py index 0802b8ed0..65112aecf 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/memory_scope.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/memory_scope.py @@ -1,6 +1,7 @@ -from abc import ABC, abstractmethod +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. -from botbuilder.dialogs import DialogContext +from abc import ABC, abstractmethod class MemoryScope(ABC): @@ -26,7 +27,7 @@ def __init__(self, name: str, include_in_snapshot: bool = True): # dc. # memory for the scope. @abstractmethod - def get_memory(self, dialog_context: DialogContext) -> object: + def get_memory(self, dialog_context: "DialogContext") -> object: raise NotImplementedError() # @@ -35,7 +36,7 @@ def get_memory(self, dialog_context: DialogContext) -> object: # dc. # memory. @abstractmethod - def set_memory(self, dialog_context: DialogContext, memory: object): + def set_memory(self, dialog_context: "DialogContext", memory: object): raise NotImplementedError() # @@ -47,7 +48,7 @@ def set_memory(self, dialog_context: DialogContext, memory: object): # A cancellation token that can be used by other objects # or threads to receive notice of cancellation. # A task that represents the work queued to execute. - async def load(self, dialog_context: DialogContext, force: bool = False): + async def load(self, dialog_context: "DialogContext", force: bool = False): return # @@ -59,7 +60,7 @@ async def load(self, dialog_context: DialogContext, force: bool = False): # A cancellation token that can be used by other objects # or threads to receive notice of cancellation. # A task that represents the work queued to execute. - async def save_changes(self, dialog_context: DialogContext, force: bool = False): + async def save_changes(self, dialog_context: "DialogContext", force: bool = False): return # @@ -69,5 +70,5 @@ async def save_changes(self, dialog_context: DialogContext, force: bool = False) # A cancellation token that can be used by other objects # or threads to receive notice of cancellation. # A task that represents the work queued to execute. - async def delete(self, dialog_context: DialogContext): + async def delete(self, dialog_context: "DialogContext"): return diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/settings_memory_scope.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/settings_memory_scope.py index 5d301a3a2..790137aea 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/settings_memory_scope.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/settings_memory_scope.py @@ -1,7 +1,6 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -from botbuilder.dialogs import DialogContext from botbuilder.dialogs.memory import scope_path from .memory_scope import MemoryScope @@ -13,9 +12,9 @@ def __init__(self): self._empty_settings = {} self.include_in_snapshot = False - def get_memory(self, dialog_context: DialogContext) -> object: + def get_memory(self, dialog_context: "DialogContext") -> object: if not dialog_context: - raise TypeError(f"Expecting: {DialogContext.__name__}, but received None") + raise TypeError(f"Expecting: DialogContext, but received None") settings: dict = dialog_context.context.turn_state.get( scope_path.SETTINGS, None @@ -26,7 +25,7 @@ def get_memory(self, dialog_context: DialogContext) -> object: return settings - def set_memory(self, dialog_context: DialogContext, memory: object): + def set_memory(self, dialog_context: "DialogContext", memory: object): raise Exception( f"{self.__class__.__name__}.set_memory not supported (read only)" ) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/this_memory_scope.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/this_memory_scope.py index ad0bc85c6..3de53bab3 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/this_memory_scope.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/this_memory_scope.py @@ -1,7 +1,6 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -from botbuilder.dialogs import DialogContext from botbuilder.dialogs.memory import scope_path from .memory_scope import MemoryScope @@ -11,17 +10,17 @@ class ThisMemoryScope(MemoryScope): def __init__(self): super().__init__(scope_path.THIS) - def get_memory(self, dialog_context: DialogContext) -> object: + def get_memory(self, dialog_context: "DialogContext") -> object: if not dialog_context: - raise TypeError(f"Expecting: {DialogContext.__name__}, but received None") + raise TypeError(f"Expecting: DialogContext, but received None") return ( dialog_context.active_dialog.state if dialog_context.active_dialog else None ) - def set_memory(self, dialog_context: DialogContext, memory: object): + def set_memory(self, dialog_context: "DialogContext", memory: object): if not dialog_context: - raise TypeError(f"Expecting: {DialogContext.__name__}, but received None") + raise TypeError(f"Expecting: DialogContext, but received None") if not memory: raise TypeError(f"Expecting: object, but received None") diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/turn_memory_scope.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/turn_memory_scope.py index 7a829152c..1a47cb550 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/turn_memory_scope.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/turn_memory_scope.py @@ -1,7 +1,6 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -from botbuilder.dialogs import DialogContext from botbuilder.dialogs.memory import scope_path from .memory_scope import MemoryScope @@ -59,9 +58,9 @@ class TurnMemoryScope(MemoryScope): def __init__(self): super().__init__(scope_path.TURN) - def get_memory(self, dialog_context: DialogContext) -> object: + def get_memory(self, dialog_context: "DialogContext") -> object: if not dialog_context: - raise TypeError(f"Expecting: {DialogContext.__name__}, but received None") + raise TypeError(f"Expecting: DialogContext, but received None") turn_value = dialog_context.context.turn_state.get(scope_path.TURN, None) @@ -71,8 +70,8 @@ def get_memory(self, dialog_context: DialogContext) -> object: return turn_value - def set_memory(self, dialog_context: DialogContext, memory: object): + def set_memory(self, dialog_context: "DialogContext", memory: object): if not dialog_context: - raise TypeError(f"Expecting: {DialogContext.__name__}, but received None") + raise TypeError(f"Expecting: DialogContext, but received None") dialog_context.context.turn_state[scope_path.TURN] = memory From 6153d7679aa4d081f89131108b4bf58227f9a56b Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Thu, 12 Nov 2020 16:42:12 -0800 Subject: [PATCH 08/18] Pylint compliance and bugfixing --- .../botbuilder/core/bot_state_set.py | 2 +- .../botbuilder/dialogs/dialog.py | 73 +++++++++---------- .../botbuilder/dialogs/dialog_container.py | 12 ++- .../botbuilder/dialogs/dialog_context.py | 22 +++--- .../botbuilder/dialogs/dialog_manager.py | 11 +-- .../botbuilder/dialogs/dialog_set.py | 5 +- .../dialogs/memory/dialog_state_manager.py | 29 +++++--- .../memory/path_resolvers/at_path_resolver.py | 2 +- .../memory/scopes/bot_state_memory_scope.py | 2 +- .../scopes/dialog_class_memory_scope.py | 1 + .../scopes/dialog_context_memory_scope.py | 2 + .../memory/scopes/dialog_memory_scope.py | 1 + .../dialogs/memory/scopes/memory_scope.py | 20 +++-- .../memory/scopes/turn_memory_scope.py | 6 +- 14 files changed, 110 insertions(+), 78 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/bot_state_set.py b/libraries/botbuilder-core/botbuilder/core/bot_state_set.py index 99016af48..1f265b6ee 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_state_set.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_state_set.py @@ -8,7 +8,7 @@ class BotStateSet: - def __init__(self, bot_states: List[BotState]): + def __init__(self, *bot_states: List[BotState]): self.bot_states = list(bot_states) def add(self, bot_state: BotState) -> "BotStateSet": diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog.py index 6b985d48f..22dfe342b 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog.py @@ -114,13 +114,13 @@ async def on_dialog_event( self, dialog_context: "DialogContext", dialog_event: DialogEvent ) -> bool: """ - # Called when an event has been raised, using `DialogContext.emitEvent()`, by either the current dialog or a dialog that the current dialog started. - # The dialog context for the current turn of conversation. - # The event being raised. - # The cancellation token. - # True if the event is handled by the current dialog and bubbling should stop. - # Before bubble + Called when an event has been raised, using `DialogContext.emitEvent()`, by either the current dialog or a + dialog that the current dialog started. + :param dialog_context: The dialog context for the current turn of conversation. + :param dialog_event: The event being raised. + :return: True if the event is handled by the current dialog and bubbling should stop. """ + # Before bubble handled = await self._on_pre_bubble_event(dialog_context, dialog_event) # Bubble as needed @@ -135,55 +135,54 @@ async def on_dialog_event( return handled - async def _on_pre_bubble_event( + async def _on_pre_bubble_event( # pylint: disable=unused-argument self, dialog_context: "DialogContext", dialog_event: DialogEvent ) -> bool: """ - # Called before an event is bubbled to its parent. - # This is a good place to perform interception of an event as returning `true` will prevent - # any further bubbling of the event to the dialogs parents and will also prevent any child - # dialogs from performing their default processing. - # The dialog context for the current turn of conversation. - # The event being raised. - # Cancellation token. - # Whether the event is handled by the current dialog and further processing should stop. + Called before an event is bubbled to its parent. + This is a good place to perform interception of an event as returning `true` will prevent + any further bubbling of the event to the dialogs parents and will also prevent any child + dialogs from performing their default processing. + :param dialog_context: The dialog context for the current turn of conversation. + :param dialog_event: The event being raised. + :return: Whether the event is handled by the current dialog and further processing should stop. """ return False - async def _on_post_bubble_event( + async def _on_post_bubble_event( # pylint: disable=unused-argument self, dialog_context: "DialogContext", dialog_event: DialogEvent ) -> bool: """ - # Called after an event was bubbled to all parents and wasn't handled. - # This is a good place to perform default processing logic for an event. Returning `true` will - # prevent any processing of the event by child dialogs. - # The dialog context for the current turn of conversation. - # The event being raised. - # Cancellation token. - # Whether the event is handled by the current dialog and further processing should stop. + Called after an event was bubbled to all parents and wasn't handled. + This is a good place to perform default processing logic for an event. Returning `true` will + prevent any processing of the event by child dialogs. + :param dialog_context: The dialog context for the current turn of conversation. + :param dialog_event: The event being raised. + :return: Whether the event is handled by the current dialog and further processing should stop. """ return False def _on_compute_id(self) -> str: """ - # Computes an unique ID for a dialog. - # An unique ID. + Computes an unique ID for a dialog. + :return: An unique ID for a dialog """ return self.__class__.__name__ - def _register_source_location(self, path: str, line_number: int): + def _register_source_location( + self, path: str, line_number: int + ): # pylint: disable=unused-argument """ - # Registers a cref="SourceRange"/> in the provided location. - # The path to the source file. - # The line number where the source will be located on the file. + Registers a SourceRange in the provided location. + :param path: The path to the source file. + :param line_number: The line number where the source will be located on the file. + :return: """ if path: - """ - This will be added when debbuging support is ported. - DebugSupport.source_map.add(self, SourceRange( - path = path, - start_point = SourcePoint(line_index = line_number, char_index = 0 ), - end_point = SourcePoint(line_index = line_number + 1, char_index = 0 ), - ) - """ + # This will be added when debbuging support is ported. + # DebugSupport.source_map.add(self, SourceRange( + # path = path, + # start_point = SourcePoint(line_index = line_number, char_index = 0 ), + # end_point = SourcePoint(line_index = line_number + 1, char_index = 0 ), + # ) return diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_container.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_container.py index 373cabb51..7fd316f52 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_container.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_container.py @@ -29,7 +29,8 @@ async def on_dialog_event( self, dialog_context: DialogContext, dialog_event: DialogEvent ) -> bool: """ - Called when an event has been raised, using `DialogContext.emitEvent()`, by either the current dialog or a dialog that the current dialog started. + Called when an event has been raised, using `DialogContext.emitEvent()`, by either the current dialog or a + dialog that the current dialog started. The dialog context for the current turn of conversation. The event being raised. The cancellation token. @@ -40,7 +41,10 @@ async def on_dialog_event( # Trace unhandled "versionChanged" events. if not handled and dialog_event.name == DialogEvents.version_changed: - trace_message = f"Unhandled dialog event: {dialog_event.name}. Active Dialog: {dialog_context.active_dialog.id}" + trace_message = ( + f"Unhandled dialog event: {dialog_event.name}. Active Dialog: " + f"{dialog_context.active_dialog.id}" + ) # dialog_context.dialogs.telemetry_client.TrackTrace(trace_message, Severity.Warning, null) @@ -58,7 +62,7 @@ def get_internal_version(self) -> str: which is then examined via calls to check_for_version_change_async(). version which represents the change of the internals of this container. """ - return self.dialogs.get_internal_version() + return self.dialogs.get_version() async def check_for_version_change_async(self, dialog_context: DialogContext): """ @@ -67,7 +71,7 @@ async def check_for_version_change_async(self, dialog_context: DialogContext): task. Checks to see if a containers child dialogs have changed since the current dialog instance was started. - + This should be called at the start of `beginDialog()`, `continueDialog()`, and `resumeDialog()`. """ current = dialog_context.active_dialog.version diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_context.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_context.py index f5ec274ad..643f1eff0 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_context.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_context.py @@ -78,6 +78,7 @@ def child(self) -> Optional["DialogContext"]: :param: :return DialogContext: """ + # pylint: disable=import-outside-toplevel instance = self.active_dialog if instance: @@ -200,6 +201,7 @@ async def cancel_all_dialogs( :param event_value: :return: """ + # pylint: disable=too-many-nested-blocks if cancel_parents is None: try: event_name = event_name or DialogEvents.cancel_dialog @@ -235,12 +237,11 @@ async def cancel_all_dialogs( notify = True return DialogTurnResult(DialogTurnStatus.Cancelled) - else: - # Stack was empty and no parent - return DialogTurnResult(DialogTurnStatus.Empty) + # Stack was empty and no parent + return DialogTurnResult(DialogTurnStatus.Empty) except Exception as err: - self.set_exception_context_data(err) - raise + err = self.set_exception_context_data(err) + raise err if self.stack: while self.stack: @@ -320,7 +321,8 @@ async def emit_event( Emits a named event for the current dialog, or someone who started it, to handle. Name of the event to raise. Value to send along with the event. - Flag to control whether the event should be bubbled to its parent if not handled locally. Defaults to a value of `true`. + Flag to control whether the event should be bubbled to its parent if not handled locally. + Defaults to a value of `true`. Whether the event is emitted from a leaf node. The cancellation token. True if the event was handled. @@ -352,8 +354,8 @@ async def emit_event( return False except Exception as err: - self.set_exception_context_data(err) - raise + err = self.set_exception_context_data(err) + raise err def set_exception_context_data(self, exception: Exception) -> Exception: if DialogContext.__class__.__name__ not in str(exception): @@ -370,7 +372,7 @@ def set_exception_context_data(self, exception: Exception) -> Exception: current_dc: DialogContext = current_dc.parent return type(exception)( - exception.message + str(exception) + "DialogContext: " + str( { @@ -385,3 +387,5 @@ def set_exception_context_data(self, exception: Exception) -> Exception: } ) ) + + return exception diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_manager.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_manager.py index 029d7868d..e57fa7f73 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_manager.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_manager.py @@ -78,6 +78,7 @@ async def on_turn(self, context: TurnContext) -> DialogManagerResult: :param context: turn context. :return: """ + # pylint: disable=too-many-statements # Lazy initialize RootDialog so it can refer to assets like LG function templates if self._root_dialog_id is None: with self._lock: @@ -121,9 +122,7 @@ async def on_turn(self, context: TurnContext) -> DialogManagerResult: # create property accessors # DateTime(last_access) last_access_property = self.conversation_state.create_property(self.last_access) - last_access: datetime = await last_access_property.get( - context, lambda: datetime.now() - ) + last_access: datetime = await last_access_property.get(context, datetime.now) # Check for expired conversation if self.expire_after is not None and ( @@ -139,9 +138,7 @@ async def on_turn(self, context: TurnContext) -> DialogManagerResult: dialogs_property = self.conversation_state.create_property( self._dialog_state_property ) - dialog_state: DialogState = await dialogs_property.get( - context, lambda: DialogState() - ) + dialog_state: DialogState = await dialogs_property.get(context, DialogState) # Create DialogContext dialog_context = DialogContext(self.dialogs, context, dialog_state) @@ -190,7 +187,7 @@ async def on_turn(self, context: TurnContext) -> DialogManagerResult: 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 + DialogEvents.error, err, bubble=True, from_leaf=True ) if not handled: diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_set.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_set.py index bb32e7b83..1769f1102 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_set.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_set.py @@ -11,6 +11,7 @@ class DialogSet: def __init__(self, dialog_state: StatePropertyAccessor = None): + # pylint: disable=import-outside-toplevel if dialog_state is None: frame = inspect.currentframe().f_back try: @@ -20,7 +21,6 @@ def __init__(self, dialog_state: StatePropertyAccessor = None): except KeyError: raise TypeError("DialogSet(): dialog_state cannot be None.") # Only ComponentDialog can initialize with None dialog_state - # pylint: disable=import-outside-toplevel from .component_dialog import ComponentDialog if not isinstance(self_obj, ComponentDialog): @@ -37,7 +37,7 @@ def __init__(self, dialog_state: StatePropertyAccessor = None): def get_version(self) -> str: """ - Gets a unique string which represents the combined versions of all dialogs in this this dialogset. + Gets a unique string which represents the combined versions of all dialogs in this this dialogset. Version will change when any of the child dialogs version changes. """ if not self._version: @@ -74,6 +74,7 @@ def add(self, dialog: Dialog): async def create_context(self, turn_context: TurnContext) -> "DialogContext": # This import prevents circular dependency issues + # pylint: disable=import-outside-toplevel from .dialog_context import DialogContext # pylint: disable=unnecessary-lambda diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/dialog_state_manager.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/dialog_state_manager.py index ab037427b..74c28199f 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/dialog_state_manager.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/dialog_state_manager.py @@ -24,9 +24,10 @@ from .dialog_path import DialogPath from .dialog_state_manager_configuration import DialogStateManagerConfiguration -T = TypeVar("T") # Declare type variable +# Declare type variable +T = TypeVar("T") # pylint: disable=invalid-name -builtin_types = list(filter(lambda x: not x.startswith("_"), dir(builtins))) +BUILTIN_TYPES = list(filter(lambda x: not x.startswith("_"), dir(builtins))) # @@ -48,8 +49,12 @@ def __init__( :param dialog_context: The dialog context for the current turn of the conversation. :param configuration: Configuration for the dialog state manager. Default is None. """ + # pylint: disable=import-outside-toplevel # These modules are imported at static level to avoid circular dependency problems - from botbuilder.dialogs import DialogsComponentRegistration, ObjectPath + from botbuilder.dialogs import ( + DialogsComponentRegistration, + ObjectPath, + ) self._object_path_cls = ObjectPath self._dialog_component_registration_cls = DialogsComponentRegistration @@ -150,7 +155,7 @@ def __getitem__(self, key): :param key: :return The value stored at key's position: """ - return self.get_value(key, lambda: None) + return self.get_value(object, key, default_value=lambda: None) def __setitem__(self, key, value): if self._index_of_any(key, self.SEPARATORS) == -1: @@ -164,7 +169,10 @@ def __setitem__(self, key, value): self.set_value(key, value) def _get_bad_scope_message(self, path: str) -> str: - return f"'{path}' does not match memory scopes:[{', '.join((memory_scope.name for memory_scope in self.configuration.memory_scopes))}]" + return ( + f"'{path}' does not match memory scopes:[" + f"{', '.join((memory_scope.name for memory_scope in self.configuration.memory_scopes))}]" + ) @staticmethod def _index_of_any(string: str, elements_to_search_for) -> int: @@ -244,8 +252,8 @@ def transform_path(self, path: str) -> str: return path @staticmethod - def _is_primitive(cls: Type) -> bool: - return cls.__name__ in builtin_types + def _is_primitive(type_to_check: Type) -> bool: + return type_to_check.__name__ in BUILTIN_TYPES def try_get_value( self, path: str, class_type: Type = object @@ -279,13 +287,14 @@ def try_get_value( return True, memory - # TODO: HACK to support .First() retrieval on turn.recognized.entities.foo, replace with Expressions once expression ship + # TODO: HACK to support .First() retrieval on turn.recognized.entities.foo, replace with Expressions once + # expressions ship first = ".FIRST()" i_first = path.upper().rindex(first) if i_first >= 0: remaining_path = path[i_first + len(first) :] path = path[0:i_first] - success, first_value = self.try_get_first_nested_value(path, self) + success, first_value = self._try_get_first_nested_value(path, self) if success: if not remaining_path: return True, first_value @@ -586,6 +595,8 @@ def _try_get_first_nested_value( remaining_path: str, memory: object ) -> Tuple[bool, object]: # These modules are imported at static level to avoid circular dependency problems + # pylint: disable=import-outside-toplevel + from botbuilder.dialogs import ObjectPath array = ObjectPath.try_get_path_value(memory, remaining_path) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/path_resolvers/at_path_resolver.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/path_resolvers/at_path_resolver.py index 037577cfc..91bbb6564 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/path_resolvers/at_path_resolver.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/path_resolvers/at_path_resolver.py @@ -11,7 +11,7 @@ class AtPathResolver(AliasPathResolver): def __init__(self): super().__init__(alias="@", prefix="") - self._PREFIX = "turn.recognized.entities." + self._PREFIX = "turn.recognized.entities." # pylint: disable=invalid-name def transform_path(self, path: str): if not path: diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/bot_state_memory_scope.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/bot_state_memory_scope.py index f1c40a86e..088c7a0fb 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/bot_state_memory_scope.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/bot_state_memory_scope.py @@ -17,7 +17,7 @@ def get_memory(self, dialog_context: "DialogContext") -> object: if not dialog_context: raise TypeError(f"Expecting: DialogContext, but received None") - bot_state: BotState = BotStateMemoryScope._get_bot_state(dialog_context) + bot_state: BotState = self._get_bot_state(dialog_context) cached_state = ( bot_state.get_cached_state(dialog_context.context) if bot_state else None ) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/dialog_class_memory_scope.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/dialog_class_memory_scope.py index 0c7e06e60..67a907c8f 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/dialog_class_memory_scope.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/dialog_class_memory_scope.py @@ -10,6 +10,7 @@ class DialogClassMemoryScope(MemoryScope): def __init__(self): + # pylint: disable=import-outside-toplevel super().__init__(scope_path.DIALOG_CLASS, include_in_snapshot=False) # This import is to avoid circular dependency issues diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/dialog_context_memory_scope.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/dialog_context_memory_scope.py index 1c7cc0608..200f71b8c 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/dialog_context_memory_scope.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/dialog_context_memory_scope.py @@ -8,6 +8,8 @@ class DialogContextMemoryScope(MemoryScope): def __init__(self): + # pylint: disable=invalid-name + super().__init__(scope_path.SETTINGS, include_in_snapshot=False) # Stack name. self.STACK = "stack" diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/dialog_memory_scope.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/dialog_memory_scope.py index a37120b28..0bdda8a02 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/dialog_memory_scope.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/dialog_memory_scope.py @@ -8,6 +8,7 @@ class DialogMemoryScope(MemoryScope): def __init__(self): + # pylint: disable=import-outside-toplevel super().__init__(scope_path.DIALOG) # This import is to avoid circular dependency issues diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/memory_scope.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/memory_scope.py index 65112aecf..3b00401fc 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/memory_scope.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/memory_scope.py @@ -27,7 +27,9 @@ def __init__(self, name: str, include_in_snapshot: bool = True): # dc. # memory for the scope. @abstractmethod - def get_memory(self, dialog_context: "DialogContext") -> object: + def get_memory( + self, dialog_context: "DialogContext" + ) -> object: # pylint: disable=unused-argument raise NotImplementedError() # @@ -36,7 +38,9 @@ def get_memory(self, dialog_context: "DialogContext") -> object: # dc. # memory. @abstractmethod - def set_memory(self, dialog_context: "DialogContext", memory: object): + def set_memory( + self, dialog_context: "DialogContext", memory: object + ): # pylint: disable=unused-argument raise NotImplementedError() # @@ -48,7 +52,9 @@ def set_memory(self, dialog_context: "DialogContext", memory: object): # A cancellation token that can be used by other objects # or threads to receive notice of cancellation. # A task that represents the work queued to execute. - async def load(self, dialog_context: "DialogContext", force: bool = False): + async def load( + self, dialog_context: "DialogContext", force: bool = False + ): # pylint: disable=unused-argument return # @@ -60,7 +66,9 @@ async def load(self, dialog_context: "DialogContext", force: bool = False): # A cancellation token that can be used by other objects # or threads to receive notice of cancellation. # A task that represents the work queued to execute. - async def save_changes(self, dialog_context: "DialogContext", force: bool = False): + async def save_changes( + self, dialog_context: "DialogContext", force: bool = False + ): # pylint: disable=unused-argument return # @@ -70,5 +78,7 @@ async def save_changes(self, dialog_context: "DialogContext", force: bool = Fals # A cancellation token that can be used by other objects # or threads to receive notice of cancellation. # A task that represents the work queued to execute. - async def delete(self, dialog_context: "DialogContext"): + async def delete( + self, dialog_context: "DialogContext" + ): # pylint: disable=unused-argument return diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/turn_memory_scope.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/turn_memory_scope.py index 1a47cb550..3773edf6b 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/turn_memory_scope.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/turn_memory_scope.py @@ -7,6 +7,8 @@ class CaseInsensitiveDict(dict): + # pylint: disable=protected-access + @classmethod def _k(cls, key): return key.lower() if isinstance(key, str) else key @@ -50,8 +52,8 @@ def update(self, e=None, **f): def _convert_keys(self): for k in list(self.keys()): - v = super(CaseInsensitiveDict, self).pop(k) - self.__setitem__(k, v) + val = super(CaseInsensitiveDict, self).pop(k) + self.__setitem__(k, val) class TurnMemoryScope(MemoryScope): From a3f08b85e0374acefcde5936c83ca0b6f0eae133 Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Fri, 13 Nov 2020 13:46:30 -0800 Subject: [PATCH 09/18] Reverting regression in DialogManager --- libraries/botbuilder-core/botbuilder/core/bot_state_set.py | 2 +- .../botbuilder-dialogs/botbuilder/dialogs/dialog_manager.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/bot_state_set.py b/libraries/botbuilder-core/botbuilder/core/bot_state_set.py index 1f265b6ee..99016af48 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_state_set.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_state_set.py @@ -8,7 +8,7 @@ class BotStateSet: - def __init__(self, *bot_states: List[BotState]): + def __init__(self, bot_states: List[BotState]): self.bot_states = list(bot_states) def add(self, bot_state: BotState) -> "BotStateSet": diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_manager.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_manager.py index e57fa7f73..660257f56 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_manager.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_manager.py @@ -87,7 +87,7 @@ async def on_turn(self, context: TurnContext) -> DialogManagerResult: # self.dialogs = self.root_dialog.telemetry_client self.dialogs.add(self.root_dialog) - bot_state_set = BotStateSet() + bot_state_set = BotStateSet([]) # Preload TurnState with DM TurnState. for key, val in self.initial_turn_state: From ff3f5b5f4119cc9e4a5251d1ea4ab8a02eca35dc Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Fri, 13 Nov 2020 14:23:47 -0800 Subject: [PATCH 10/18] Compatibility with 3.6 typing --- .../dialogs/memory/dialog_state_manager.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/dialog_state_manager.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/dialog_state_manager.py index 74c28199f..0610f3ac5 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/dialog_state_manager.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/dialog_state_manager.py @@ -6,8 +6,8 @@ from traceback import print_tb from typing import ( Callable, - Collection, Dict, + Iterable, Iterator, List, Tuple, @@ -115,17 +115,17 @@ def configuration(self) -> DialogStateManagerConfiguration: return self._configuration @property - def keys(self) -> Collection[str]: + def keys(self) -> Iterable[str]: """ - Gets a Collection containing the keys of the memory scopes + Gets a Iterable containing the keys of the memory scopes :return: Keys of the memory scopes. """ return [memory_scope.name for memory_scope in self.configuration.memory_scopes] @property - def values(self) -> Collection[object]: + def values(self) -> Iterable[object]: """ - Gets a Collection containing the values of the memory scopes. + Gets a Iterable containing the values of the memory scopes. :return: Values of the memory scopes. """ return [ @@ -551,7 +551,7 @@ def get_enumerator(self) -> Iterator[Tuple[str, object]]: for memory_scope in self.configuration.memory_scopes: yield (memory_scope.name, memory_scope.get_memory(self._dialog_context)) - def track_paths(self, paths: Collection[str]) -> List[str]: + def track_paths(self, paths: Iterable[str]) -> List[str]: """ Track when specific paths are changed. :param paths: Paths to track. @@ -570,7 +570,7 @@ def track_paths(self, paths: Collection[str]) -> List[str]: return all_paths - def any_path_changed(self, counter: int, paths: Collection[str]) -> bool: + def any_path_changed(self, counter: int, paths: Iterable[str]) -> bool: """ Check to see if any path has changed since watermark. :param counter: Time counter to compare to. From d35307660827c60613ba3af04b71b253a5f415b3 Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Thu, 19 Nov 2020 01:21:55 -0800 Subject: [PATCH 11/18] General DialogManager testing added. Several bugfixes --- .../botbuilder/core/bot_state.py | 2 +- .../botbuilder/core/conversation_state.py | 2 +- .../botbuilder/core/user_state.py | 2 +- .../botbuilder/dialogs/dialog_context.py | 19 +- .../botbuilder/dialogs/dialog_manager.py | 29 +- .../botbuilder/dialogs/dialog_set.py | 3 +- .../scopes/dialog_class_memory_scope.py | 4 +- .../memory/scopes/dialog_memory_scope.py | 4 +- .../tests/test_dialog_manager.py | 329 ++++++++++++++++++ 9 files changed, 367 insertions(+), 27 deletions(-) create mode 100644 libraries/botbuilder-dialogs/tests/test_dialog_manager.py diff --git a/libraries/botbuilder-core/botbuilder/core/bot_state.py b/libraries/botbuilder-core/botbuilder/core/bot_state.py index 0e38e9af0..867fb07e0 100644 --- a/libraries/botbuilder-core/botbuilder/core/bot_state.py +++ b/libraries/botbuilder-core/botbuilder/core/bot_state.py @@ -14,7 +14,7 @@ class CachedBotState: """ - Internal cached bot state. + Internal cached bot state. """ def __init__(self, state: Dict[str, object] = None): diff --git a/libraries/botbuilder-core/botbuilder/core/conversation_state.py b/libraries/botbuilder-core/botbuilder/core/conversation_state.py index 4605700f6..174ca0883 100644 --- a/libraries/botbuilder-core/botbuilder/core/conversation_state.py +++ b/libraries/botbuilder-core/botbuilder/core/conversation_state.py @@ -25,7 +25,7 @@ def __init__(self, storage: Storage): :param storage: The storage containing the conversation state. :type storage: :class:`Storage` """ - super(ConversationState, self).__init__(storage, "ConversationState") + super(ConversationState, self).__init__(storage, "Internal.ConversationState") def get_storage_key(self, turn_context: TurnContext) -> object: """ diff --git a/libraries/botbuilder-core/botbuilder/core/user_state.py b/libraries/botbuilder-core/botbuilder/core/user_state.py index ab4b3f676..909839dfe 100644 --- a/libraries/botbuilder-core/botbuilder/core/user_state.py +++ b/libraries/botbuilder-core/botbuilder/core/user_state.py @@ -23,7 +23,7 @@ def __init__(self, storage: Storage, namespace=""): """ self.namespace = namespace - super(UserState, self).__init__(storage, "UserState") + super(UserState, self).__init__(storage, "Internal.UserState_type") def get_storage_key(self, turn_context: TurnContext) -> str: """ diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_context.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_context.py index 643f1eff0..900288ec9 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_context.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_context.py @@ -82,7 +82,7 @@ def child(self) -> Optional["DialogContext"]: instance = self.active_dialog if instance: - dialog = self.find_dialog(instance.id) + dialog = self.find_dialog_sync(instance.id) # This import prevents circular dependency issues from .dialog_container import DialogContainer @@ -99,7 +99,7 @@ async def begin_dialog(self, dialog_id: str, options: object = None): :param options: (Optional) additional argument(s) to pass to the dialog being started. """ if not dialog_id: - raise TypeError("Dialog(): dialogId cannot be None.") + raise TypeError("Dialog(): dialog_id cannot be None.") # Look up dialog dialog = await self.find_dialog(dialog_id) if dialog is None: @@ -263,6 +263,19 @@ async def find_dialog(self, dialog_id: str) -> Dialog: dialog = await self.parent.find_dialog(dialog_id) return dialog + def find_dialog_sync(self, dialog_id: str) -> Dialog: + """ + If the dialog cannot be found within the current `DialogSet`, the parent `DialogContext` + will be searched if there is one. + :param dialog_id: ID of the dialog to search for. + :return: + """ + dialog = self.dialogs.find_dialog(dialog_id) + + if dialog is None and self.parent is not None: + dialog = self.parent.find_dialog_sync(dialog_id) + return dialog + async def replace_dialog( self, dialog_id: str, options: object = None ) -> DialogTurnResult: @@ -358,7 +371,7 @@ async def emit_event( raise err def set_exception_context_data(self, exception: Exception) -> Exception: - if DialogContext.__class__.__name__ not in str(exception): + if DialogContext.__name__ not in str(exception): stack = list([]) current_dc = self diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_manager.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_manager.py index 660257f56..28dbe6e74 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_manager.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_manager.py @@ -49,6 +49,8 @@ def __init__(self, root_dialog: Dialog = None, dialog_state_property: str = None self._root_dialog_id = "" self._dialog_state_property = dialog_state_property or "DialogState" self._lock = Lock() + + # Gets or sets root dialog to use to start conversation. self.root_dialog = root_dialog # Gets or sets the ConversationState. @@ -60,9 +62,6 @@ def __init__(self, root_dialog: Dialog = None, dialog_state_property: str = None # Gets InitialTurnState collection to copy into the TurnState on every turn. self.initial_turn_state = {} - # Gets or sets root dialog to use to start conversation. - self.root_dialog: Dialog = None - # Gets or sets global dialogs that you want to have be callable. self.dialogs = DialogSet() @@ -80,9 +79,9 @@ async def on_turn(self, context: TurnContext) -> DialogManagerResult: """ # pylint: disable=too-many-statements # Lazy initialize RootDialog so it can refer to assets like LG function templates - if self._root_dialog_id is None: + if not self._root_dialog_id: with self._lock: - if self._root_dialog_id is None: + if not self._root_dialog_id: self._root_dialog_id = self.root_dialog.id # self.dialogs = self.root_dialog.telemetry_client self.dialogs.add(self.root_dialog) @@ -90,12 +89,12 @@ async def on_turn(self, context: TurnContext) -> DialogManagerResult: bot_state_set = BotStateSet([]) # Preload TurnState with DM TurnState. - for key, val in self.initial_turn_state: + for key, val in self.initial_turn_state.items(): context.turn_state[key] = val # register DialogManager with TurnState. - context.turn_state[DialogManager.__class__.__name__] = self - conversation_state_name = ConversationState.__class__.__name__ + context.turn_state[DialogManager.__name__] = self + conversation_state_name = ConversationState.__name__ if self.conversation_state is None: if conversation_state_name not in context.turn_state: raise Exception( @@ -109,7 +108,7 @@ async def on_turn(self, context: TurnContext) -> DialogManagerResult: bot_state_set.add(self.conversation_state) - user_state_name = UserState.__class__.__name__ + user_state_name = UserState.__name__ if self.user_state is None: self.user_state = context.turn_state.get(user_state_name, None) else: @@ -144,11 +143,11 @@ async def on_turn(self, context: TurnContext) -> DialogManagerResult: 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: + 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: + for key, service in context.turn_state.items(): dialog_context.services[key] = service # get the DialogStateManager configuration @@ -283,11 +282,9 @@ def should_send_end_of_conversation_to_parent( if skill_conversation_reference: # If the skill_conversation_reference.OAuthScope is for one of the supported channels, we are at the # root and we should not send an EoC. - return ( - skill_conversation_reference.oauth_scope - != AuthenticationConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE - and skill_conversation_reference.oauth_scope - != GovernmentConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE + return skill_conversation_reference.oauth_scope not in ( + AuthenticationConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE, + GovernmentConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE, ) return True diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_set.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_set.py index 1769f1102..454a6b94b 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_set.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_set.py @@ -22,8 +22,9 @@ def __init__(self, dialog_state: StatePropertyAccessor = None): raise TypeError("DialogSet(): dialog_state cannot be None.") # Only ComponentDialog can initialize with None dialog_state from .component_dialog import ComponentDialog + from .dialog_manager import DialogManager - if not isinstance(self_obj, ComponentDialog): + if not isinstance(self_obj, (ComponentDialog, DialogManager)): raise TypeError("DialogSet(): dialog_state cannot be None.") finally: # make sure to clean up the frame at the end to avoid ref cycles diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/dialog_class_memory_scope.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/dialog_class_memory_scope.py index 67a907c8f..b363d1065 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/dialog_class_memory_scope.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/dialog_class_memory_scope.py @@ -24,7 +24,7 @@ def get_memory(self, dialog_context: "DialogContext") -> object: # if active dialog is a container dialog then "dialogclass" binds to it. if dialog_context.active_dialog: - dialog = dialog_context.find_dialog(dialog_context.active_dialog.id) + dialog = dialog_context.find_dialog_sync(dialog_context.active_dialog.id) if isinstance(dialog, self._dialog_container_cls): return deepcopy(dialog) @@ -37,7 +37,7 @@ def get_memory(self, dialog_context: "DialogContext") -> object: active_id = ( dialog_context.active_dialog.id if dialog_context.active_dialog else None ) - return deepcopy(dialog_context.find_dialog(parent_id or active_id)) + return deepcopy(dialog_context.find_dialog_sync(parent_id or active_id)) def set_memory(self, dialog_context: "DialogContext", memory: object): raise Exception( diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/dialog_memory_scope.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/dialog_memory_scope.py index 0bdda8a02..22bf5931f 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/dialog_memory_scope.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/dialog_memory_scope.py @@ -22,7 +22,7 @@ def get_memory(self, dialog_context: "DialogContext") -> object: # if active dialog is a container dialog then "dialog" binds to it. if dialog_context.active_dialog: - dialog = dialog_context.find_dialog(dialog_context.active_dialog.id) + dialog = dialog_context.find_dialog_sync(dialog_context.active_dialog.id) if isinstance(dialog, self._dialog_container_cls): return dialog_context.active_dialog.state @@ -46,7 +46,7 @@ def set_memory(self, dialog_context: "DialogContext", memory: object): # if active dialog is a container dialog then "dialog" binds to it if dialog_context.active_dialog: - dialog = dialog_context.find_dialog(dialog_context.active_dialog.id) + dialog = dialog_context.find_dialog_sync(dialog_context.active_dialog.id) if isinstance(dialog, self._dialog_container_cls): dialog_context.active_dialog.state = memory return diff --git a/libraries/botbuilder-dialogs/tests/test_dialog_manager.py b/libraries/botbuilder-dialogs/tests/test_dialog_manager.py new file mode 100644 index 000000000..985a1f33b --- /dev/null +++ b/libraries/botbuilder-dialogs/tests/test_dialog_manager.py @@ -0,0 +1,329 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +# pylint: disable=pointless-string-statement + +from enum import Enum +from typing import Callable, List, Tuple + +import aiounittest + +from botbuilder.core import ( + AutoSaveStateMiddleware, + BotAdapter, + ConversationState, + MemoryStorage, + MessageFactory, + UserState, + TurnContext, +) +from botbuilder.core.adapters import TestAdapter +from botbuilder.core.skills import SkillHandler, SkillConversationReference +from botbuilder.dialogs import ( + ComponentDialog, + Dialog, + DialogContext, + DialogInstance, + DialogReason, + TextPrompt, + WaterfallDialog, + DialogManager, + DialogManagerResult, + DialogTurnStatus, + WaterfallStepContext, +) +from botbuilder.dialogs.prompts import PromptOptions +from botbuilder.schema import ( + Activity, + ActivityTypes, + ChannelAccount, + ConversationAccount, + InputHints, +) +from botframework.connector.auth import AuthenticationConstants, ClaimsIdentity + + +class SkillFlowTestCase(str, Enum): + # DialogManager is executing on a root bot with no skills (typical standalone bot). + root_bot_only = "RootBotOnly" + + # DialogManager is executing on a root bot handling replies from a skill. + root_bot_consuming_skill = "RootBotConsumingSkill" + + # DialogManager is executing in a skill that is called from a root and calling another skill. + middle_skill = "MiddleSkill" + + # DialogManager is executing in a skill that is called from a parent (a root or another skill) but doesn"t call + # another skill. + leaf_skill = "LeafSkill" + + +class SimpleComponentDialog(ComponentDialog): + # An App ID for a parent bot. + parent_bot_id = "00000000-0000-0000-0000-0000000000PARENT" + + # An App ID for a skill bot. + skill_bot_id = "00000000-0000-0000-0000-00000000000SKILL" + + # Captures an EndOfConversation if it was sent to help with assertions. + eoc_sent: Activity = None + + # Property to capture the DialogManager turn results and do assertions. + dm_turn_result: DialogManagerResult = None + + def __init__( + self, id: str = None, prop: str = None + ): # pylint: disable=unused-argument + super().__init__(id or "SimpleComponentDialog") + self.text_prompt = "TextPrompt" + self.waterfall_dialog = "WaterfallDialog" + self.add_dialog(TextPrompt(self.text_prompt)) + self.add_dialog( + WaterfallDialog( + self.waterfall_dialog, [self.prompt_for_name, self.final_step,] + ) + ) + self.initial_dialog_id = self.waterfall_dialog + self.end_reason = None + + @staticmethod + async def create_test_flow( + dialog: Dialog, + test_case: SkillFlowTestCase = SkillFlowTestCase.root_bot_only, + enabled_trace=False, + ) -> TestAdapter: + conversation_id = "testFlowConversationId" + storage = MemoryStorage() + conversation_state = ConversationState(storage) + user_state = UserState(storage) + + activity = Activity( + channel_id="test", + service_url="https://test.com", + from_property=ChannelAccount(id="user1", name="User1"), + recipient=ChannelAccount(id="bot", name="Bot"), + conversation=ConversationAccount( + is_group=False, conversation_type=conversation_id, id=conversation_id + ), + ) + + dialog_manager = DialogManager(dialog) + dialog_manager.user_state = user_state + dialog_manager.conversation_state = conversation_state + + async def logic(context: TurnContext): + if test_case != SkillFlowTestCase.root_bot_only: + # Create a skill ClaimsIdentity and put it in turn_state so isSkillClaim() returns True. + claims_identity = ClaimsIdentity({}, False) + claims_identity.claims[ + "ver" + ] = "2.0" # AuthenticationConstants.VersionClaim + claims_identity.claims[ + "aud" + ] = ( + SimpleComponentDialog.skill_bot_id + ) # AuthenticationConstants.AudienceClaim + claims_identity.claims[ + "azp" + ] = ( + SimpleComponentDialog.parent_bot_id + ) # AuthenticationConstants.AuthorizedParty + context.turn_state[BotAdapter.BOT_IDENTITY_KEY] = claims_identity + + if test_case == SkillFlowTestCase.root_bot_consuming_skill: + # Simulate the SkillConversationReference with a channel OAuthScope stored in turn_state. + # This emulates a response coming to a root bot through SkillHandler. + context.turn_state[ + SkillHandler.SKILL_CONVERSATION_REFERENCE_KEY + ] = SkillConversationReference( + None, AuthenticationConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE + ) + + if test_case == SkillFlowTestCase.middle_skill: + # Simulate the SkillConversationReference with a parent Bot ID stored in turn_state. + # This emulates a response coming to a skill from another skill through SkillHandler. + context.turn_state[ + SkillHandler.SKILL_CONVERSATION_REFERENCE_KEY + ] = SkillConversationReference( + None, SimpleComponentDialog.parent_bot_id + ) + + async def aux( + turn_context: TurnContext, # pylint: disable=unused-argument + activities: List[Activity], + next: Callable, + ): + for activity in activities: + if activity.type == ActivityTypes.end_of_conversation: + SimpleComponentDialog.eoc_sent = activity + break + + return await next() + + # Interceptor to capture the EoC activity if it was sent so we can assert it in the tests. + context.on_send_activities(aux) + + SimpleComponentDialog.dm_turn_result = await dialog_manager.on_turn(context) + + adapter = TestAdapter(logic, activity, enabled_trace) + adapter.use(AutoSaveStateMiddleware([user_state, conversation_state])) + + return adapter + + async def on_end_dialog( + self, context: DialogContext, instance: DialogInstance, reason: DialogReason + ): + self.end_reason = reason + return await super().on_end_dialog(context, instance, reason) + + async def prompt_for_name(self, step: WaterfallStepContext): + return await step.prompt( + self.text_prompt, + PromptOptions( + prompt=MessageFactory.text( + "Hello, what is your name?", None, InputHints.expecting_input + ), + retry_prompt=MessageFactory.text( + "Hello, what is your name again?", None, InputHints.expecting_input + ), + ), + ) + + async def final_step(self, step: WaterfallStepContext): + await step.context.send_activity(f"Hello { step.result }, nice to meet you!") + return await step.end_dialog(step.result) + + +class DialogManagerTests(aiounittest.AsyncTestCase): + """ + self.beforeEach(() => { + _dmTurnResult = undefined + }) + """ + + async def test_handles_bot_and_skills(self): + construction_data: List[Tuple[SkillFlowTestCase, bool]] = [ + (SkillFlowTestCase.root_bot_only, False), + (SkillFlowTestCase.root_bot_consuming_skill, False), + (SkillFlowTestCase.middle_skill, True), + (SkillFlowTestCase.leaf_skill, True), + ] + + for test_case, should_send_eoc in construction_data: + with self.subTest(test_case=test_case, should_send_eoc=should_send_eoc): + SimpleComponentDialog.dm_turn_result = None + SimpleComponentDialog.eoc_sent = None + dialog = SimpleComponentDialog() + test_flow = await SimpleComponentDialog.create_test_flow( + dialog, test_case + ) + step1 = await test_flow.send("Hi") + step2 = await step1.assert_reply("Hello, what is your name?") + step3 = await step2.send("SomeName") + await step3.assert_reply("Hello SomeName, nice to meet you!") + + self.assertEqual( + SimpleComponentDialog.dm_turn_result.turn_result.status, + DialogTurnStatus.Complete, + ) + + self.assertEqual(dialog.end_reason, DialogReason.EndCalled) + if should_send_eoc: + self.assertTrue( + bool(SimpleComponentDialog.eoc_sent), + "Skills should send EndConversation to channel", + ) + self.assertEqual( + SimpleComponentDialog.eoc_sent.type, + ActivityTypes.end_of_conversation, + ) + self.assertEqual(SimpleComponentDialog.eoc_sent.value, "SomeName") + else: + self.assertIsNone( + SimpleComponentDialog.eoc_sent, + "Root bot should not send EndConversation to channel", + ) + + """ + it("SkillHandlesEoCFromParent", async () => { + dialog = SimpleComponentDialog() + testFlow = createTestFlow(dialog, SkillFlowTestCase.LeafSkill) + await testFlow.send("Hi") + .assertReply("Hello, what is your name?") + .send({ type= ActivityTypes.EndOfConversation }) + .startTest() + strictEqual(_dmTurnResult.turnResult.status, DialogTurnStatus.cancelled) + }) + + it("SkillHandlesRepromptFromParent", async () => { + dialog = SimpleComponentDialog() + testFlow = createTestFlow(dialog, SkillFlowTestCase.LeafSkill) + await testFlow.send("Hi") + .assertReply("Hello, what is your name?") + .send({ type= ActivityTypes.Event, name= DialogEvents.repromptDialog }) + .assertReply("Hello, what is your name?") + .startTest() + strictEqual(_dmTurnResult.turnResult.status, DialogTurnStatus.waiting) + }) + + it("SkillShouldReturnEmptyOnRepromptWithNoDialog", async () => { + dialog = SimpleComponentDialog() + testFlow = createTestFlow(dialog, SkillFlowTestCase.LeafSkill) + await testFlow.send({ type= ActivityTypes.Event, name= DialogEvents.repromptDialog }) + .startTest() + strictEqual(_dmTurnResult.turnResult.status, DialogTurnStatus.empty) + }) + + it("Trace skill state", async () => { + dialog = SimpleComponentDialog() + testFlow = createTestFlow(dialog, SkillFlowTestCase.LeafSkill, True) + await testFlow.send("Hi") + .assertReply(reply => { + strictEqual(reply.type, ActivityTypes.Trace) + }) + .assertReply("Hello, what is your name?") + .assertReply(reply => { + strictEqual(reply.type, ActivityTypes.Trace) + strictEqual(reply.label, "Skill State") + }) + .send("SomeName") + .assertReply("Hello SomeName, nice to meet you!") + .assertReply(reply => { + strictEqual(reply.type, ActivityTypes.Trace) + strictEqual(reply.label, "Skill State") + }) + .assertReply(reply => { + strictEqual(reply.type, ActivityTypes.Trace) + }) + .startTest() + strictEqual(_dmTurnResult.turnResult.status, DialogTurnStatus.complete) + }) + + it("Trace bot state", async () => { + dialog = SimpleComponentDialog() + testFlow = createTestFlow(dialog, SkillFlowTestCase.RootBotOnly, True) + await testFlow.send("Hi") + .assertReply("Hello, what is your name?") + .assertReply(reply => { + strictEqual(reply.type, ActivityTypes.Trace) + strictEqual(reply.label, "Bot State") + }) + .send("SomeName") + .assertReply("Hello SomeName, nice to meet you!") + .assertReply(reply => { + strictEqual(reply.type, ActivityTypes.Trace) + strictEqual(reply.label, "Bot State") + }) + .startTest() + strictEqual(_dmTurnResult.turnResult.status, DialogTurnStatus.complete) + }) + + it("Gets or sets root dialog", () => { + dialog_manager = DialogManager() + rootDialog = SimpleComponentDialog() + dialog_manager.rootDialog = rootDialog + assert(dialog_manager.dialogs.find(rootDialog.id)) + strictEqual(dialog_manager.rootDialog.id, rootDialog.id) + dialog_manager.rootDialog = undefined + strictEqual(dialog_manager.rootDialog, undefined) + }) + """ From d9919fb41431bb8c69c5aafd5e3a3f8d10ae0cf5 Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Mon, 23 Nov 2020 16:30:53 -0800 Subject: [PATCH 12/18] Added tests for Dialog Manager --- .../tests/test_dialog_manager.py | 171 ++++++++++-------- 1 file changed, 97 insertions(+), 74 deletions(-) diff --git a/libraries/botbuilder-dialogs/tests/test_dialog_manager.py b/libraries/botbuilder-dialogs/tests/test_dialog_manager.py index 985a1f33b..6ed5198f7 100644 --- a/libraries/botbuilder-dialogs/tests/test_dialog_manager.py +++ b/libraries/botbuilder-dialogs/tests/test_dialog_manager.py @@ -22,6 +22,7 @@ ComponentDialog, Dialog, DialogContext, + DialogEvents, DialogInstance, DialogReason, TextPrompt, @@ -243,87 +244,109 @@ async def test_handles_bot_and_skills(self): "Root bot should not send EndConversation to channel", ) - """ - it("SkillHandlesEoCFromParent", async () => { + async def test_skill_handles_eoc_from_parent(self): + SimpleComponentDialog.dm_turn_result = None dialog = SimpleComponentDialog() - testFlow = createTestFlow(dialog, SkillFlowTestCase.LeafSkill) - await testFlow.send("Hi") - .assertReply("Hello, what is your name?") - .send({ type= ActivityTypes.EndOfConversation }) - .startTest() - strictEqual(_dmTurnResult.turnResult.status, DialogTurnStatus.cancelled) - }) + test_flow = await SimpleComponentDialog.create_test_flow( + dialog, SkillFlowTestCase.leaf_skill + ) - it("SkillHandlesRepromptFromParent", async () => { + step1 = await test_flow.send("Hi") + step2 = await step1.assert_reply("Hello, what is your name?") + await step2.send(Activity(type=ActivityTypes.end_of_conversation)) + + self.assertEqual( + SimpleComponentDialog.dm_turn_result.turn_result.status, + DialogTurnStatus.Cancelled, + ) + + async def test_skill_handles_reprompt_from_parent(self): + SimpleComponentDialog.dm_turn_result = None dialog = SimpleComponentDialog() - testFlow = createTestFlow(dialog, SkillFlowTestCase.LeafSkill) - await testFlow.send("Hi") - .assertReply("Hello, what is your name?") - .send({ type= ActivityTypes.Event, name= DialogEvents.repromptDialog }) - .assertReply("Hello, what is your name?") - .startTest() - strictEqual(_dmTurnResult.turnResult.status, DialogTurnStatus.waiting) - }) + test_flow = await SimpleComponentDialog.create_test_flow( + dialog, SkillFlowTestCase.leaf_skill + ) - it("SkillShouldReturnEmptyOnRepromptWithNoDialog", async () => { + step1 = await test_flow.send("Hi") + step2 = await step1.assert_reply("Hello, what is your name?") + step3 = await step2.send( + Activity(type=ActivityTypes.event, name=DialogEvents.reprompt_dialog) + ) + await step3.assert_reply("Hello, what is your name?") + + self.assertEqual( + SimpleComponentDialog.dm_turn_result.turn_result.status, + DialogTurnStatus.Waiting, + ) + + async def test_skill_should_return_empty_on_reprompt_with_no_dialog(self): + SimpleComponentDialog.dm_turn_result = None dialog = SimpleComponentDialog() - testFlow = createTestFlow(dialog, SkillFlowTestCase.LeafSkill) - await testFlow.send({ type= ActivityTypes.Event, name= DialogEvents.repromptDialog }) - .startTest() - strictEqual(_dmTurnResult.turnResult.status, DialogTurnStatus.empty) - }) + test_flow = await SimpleComponentDialog.create_test_flow( + dialog, SkillFlowTestCase.leaf_skill + ) + + await test_flow.send( + Activity(type=ActivityTypes.event, name=DialogEvents.reprompt_dialog) + ) + + self.assertEqual( + SimpleComponentDialog.dm_turn_result.turn_result.status, + DialogTurnStatus.Empty, + ) - it("Trace skill state", async () => { + async def test_trace_skill_state(self): + SimpleComponentDialog.dm_turn_result = None dialog = SimpleComponentDialog() - testFlow = createTestFlow(dialog, SkillFlowTestCase.LeafSkill, True) - await testFlow.send("Hi") - .assertReply(reply => { - strictEqual(reply.type, ActivityTypes.Trace) - }) - .assertReply("Hello, what is your name?") - .assertReply(reply => { - strictEqual(reply.type, ActivityTypes.Trace) - strictEqual(reply.label, "Skill State") - }) - .send("SomeName") - .assertReply("Hello SomeName, nice to meet you!") - .assertReply(reply => { - strictEqual(reply.type, ActivityTypes.Trace) - strictEqual(reply.label, "Skill State") - }) - .assertReply(reply => { - strictEqual(reply.type, ActivityTypes.Trace) - }) - .startTest() - strictEqual(_dmTurnResult.turnResult.status, DialogTurnStatus.complete) - }) - it("Trace bot state", async () => { + def assert_is_trace(activity, description): # pylint: disable=unused-argument + assert activity.type == ActivityTypes.trace + + def assert_is_trace_and_label(activity, description): + assert_is_trace(activity, description) + assert activity.label == "Skill State" + + test_flow = await SimpleComponentDialog.create_test_flow( + dialog, SkillFlowTestCase.leaf_skill, True + ) + + step1 = await test_flow.send("Hi") + step2 = await step1.assert_reply(assert_is_trace) + step2 = await step2.assert_reply("Hello, what is your name?") + step3 = await step2.assert_reply(assert_is_trace_and_label) + step4 = await step3.send("SomeName") + step5 = await step4.assert_reply("Hello SomeName, nice to meet you!") + step6 = await step5.assert_reply(assert_is_trace_and_label) + await step6.assert_reply(assert_is_trace) + + self.assertEqual( + SimpleComponentDialog.dm_turn_result.turn_result.status, + DialogTurnStatus.Complete, + ) + + async def test_trace_bot_state(self): + SimpleComponentDialog.dm_turn_result = None dialog = SimpleComponentDialog() - testFlow = createTestFlow(dialog, SkillFlowTestCase.RootBotOnly, True) - await testFlow.send("Hi") - .assertReply("Hello, what is your name?") - .assertReply(reply => { - strictEqual(reply.type, ActivityTypes.Trace) - strictEqual(reply.label, "Bot State") - }) - .send("SomeName") - .assertReply("Hello SomeName, nice to meet you!") - .assertReply(reply => { - strictEqual(reply.type, ActivityTypes.Trace) - strictEqual(reply.label, "Bot State") - }) - .startTest() - strictEqual(_dmTurnResult.turnResult.status, DialogTurnStatus.complete) - }) - it("Gets or sets root dialog", () => { - dialog_manager = DialogManager() - rootDialog = SimpleComponentDialog() - dialog_manager.rootDialog = rootDialog - assert(dialog_manager.dialogs.find(rootDialog.id)) - strictEqual(dialog_manager.rootDialog.id, rootDialog.id) - dialog_manager.rootDialog = undefined - strictEqual(dialog_manager.rootDialog, undefined) - }) - """ + def assert_is_trace(activity, description): # pylint: disable=unused-argument + assert activity.type == ActivityTypes.trace + + def assert_is_trace_and_label(activity, description): + assert_is_trace(activity, description) + assert activity.label == "Bot State" + + test_flow = await SimpleComponentDialog.create_test_flow( + dialog, SkillFlowTestCase.root_bot_only, True + ) + + step1 = await test_flow.send("Hi") + step2 = await step1.assert_reply("Hello, what is your name?") + step3 = await step2.assert_reply(assert_is_trace_and_label) + step4 = await step3.send("SomeName") + step5 = await step4.assert_reply("Hello SomeName, nice to meet you!") + await step5.assert_reply(assert_is_trace_and_label) + + self.assertEqual( + SimpleComponentDialog.dm_turn_result.turn_result.status, + DialogTurnStatus.Complete, + ) From 7d40eed72b4e0ae69dfc9f3808b8161547005dba Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Wed, 25 Nov 2020 01:44:41 -0800 Subject: [PATCH 13/18] Fixing ClassMemoryScope binding, adding tests for scopes classes --- .../botbuilder/dialogs/dialog_instance.py | 8 +- .../memory/scopes/class_memory_scope.py | 33 +- .../tests/memory/scopes/test_memory_scopes.py | 672 ++++++++++++++++++ 3 files changed, 707 insertions(+), 6 deletions(-) create mode 100644 libraries/botbuilder-dialogs/tests/memory/scopes/test_memory_scopes.py diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_instance.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_instance.py index add9e2dc6..c90f85c7c 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_instance.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_instance.py @@ -9,7 +9,9 @@ class DialogInstance: Tracking information for a dialog on the stack. """ - def __init__(self): + def __init__( + self, id: str = None, state: Dict[str, object] = None + ): # pylint: disable=invalid-name """ Gets or sets the ID of the dialog and gets or sets the instance's persisted state. @@ -18,9 +20,9 @@ def __init__(self): :var self.state: The instance's persisted state. :vartype self.state: :class:`typing.Dict[str, object]` """ - self.id: str = None # pylint: disable=invalid-name + self.id = id or None # pylint: disable=invalid-name - self.state: Dict[str, object] = {} + self.state = state or {} def __str__(self): """ diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/class_memory_scope.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/class_memory_scope.py index b9cb1b75f..1589ac152 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/class_memory_scope.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/class_memory_scope.py @@ -1,7 +1,7 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -from copy import deepcopy +from collections import namedtuple from botbuilder.dialogs.memory import scope_path @@ -18,9 +18,9 @@ def get_memory(self, dialog_context: "DialogContext") -> object: # if active dialog is a container dialog then "dialogclass" binds to it. if dialog_context.active_dialog: - dialog = dialog_context.find_dialog(dialog_context.active_dialog.id) + dialog = dialog_context.find_dialog_sync(dialog_context.active_dialog.id) if dialog: - return deepcopy(dialog) + return ClassMemoryScope._bind_to_dialog_context(dialog, dialog_context) return None @@ -28,3 +28,30 @@ def set_memory(self, dialog_context: "DialogContext", memory: object): raise Exception( f"{self.__class__.__name__}.set_memory not supported (read only)" ) + + @staticmethod + def _bind_to_dialog_context(obj, dialog_context: "DialogContext") -> object: + clone = {} + for prop in dir(obj): + # don't process double underscore attributes + if prop[:1] != "_": + prop_value = getattr(obj, prop) + if not callable(prop_value): + # the only objects + if hasattr(prop_value, "try_get_value"): + clone[prop] = prop_value.try_get_value(dialog_context.state) + elif hasattr(prop_value, "__dict__") and not isinstance( + prop_value, type + ): + clone[prop] = ClassMemoryScope._bind_to_dialog_context( + prop_value, dialog_context + ) + else: + clone[prop] = prop_value + if clone: + ReadOnlyObject = namedtuple( # pylint: disable=invalid-name + "ReadOnlyObject", clone + ) + return ReadOnlyObject(**clone) + + return None diff --git a/libraries/botbuilder-dialogs/tests/memory/scopes/test_memory_scopes.py b/libraries/botbuilder-dialogs/tests/memory/scopes/test_memory_scopes.py new file mode 100644 index 000000000..74aa4ddab --- /dev/null +++ b/libraries/botbuilder-dialogs/tests/memory/scopes/test_memory_scopes.py @@ -0,0 +1,672 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +# pylint: disable=pointless-string-statement + +from collections import namedtuple + +import aiounittest + +from botbuilder.core import ( + ConversationState, + MemoryStorage, + TurnContext, +) +from botbuilder.core.adapters import TestAdapter +from botbuilder.dialogs import ( + Dialog, + DialogContext, + DialogContainer, + DialogInstance, + DialogSet, + DialogState, +) +from botbuilder.dialogs.memory.scopes import ClassMemoryScope +from botbuilder.schema import ( + Activity, + ActivityTypes, + ChannelAccount, + ConversationAccount, +) + + +class TestDialog(Dialog): + def __init__(self, id: str, message: str): + super().__init__(id) + + def aux_try_get_value(state): # pylint: disable=unused-argument + return "resolved value" + + ExpressionObject = namedtuple("ExpressionObject", "try_get_value") + self.message = message + self.expression = ExpressionObject(aux_try_get_value) + + async def begin_dialog(self, dialog_context: DialogContext, options: object = None): + dialog_context.active_dialog.state.is_dialog = True + await dialog_context.context.send_activity(self.message) + return Dialog.end_of_turn + + +class TestContainer(DialogContainer): + def __init__(self, id: str, child: Dialog): + super().__init__(id) + if child: + self.dialogs.add(child) + self.child_id = child.id + + async def begin_dialog(self, dialog_context: DialogContext, options: object = None): + state = dialog_context.active_dialog.state + state.is_container = True + if self.child_id: + state.dialog = {} + child_dc = self.create_child_context(dialog_context) + return await child_dc.begin_dialog(self.child_id, options) + + return Dialog.end_of_turn + + async def continue_dialog(self, dialog_context: DialogContext): + child_dc = self.create_child_context(dialog_context) + if child_dc: + return await child_dc.continue_dialog() + + return Dialog.end_of_turn + + def create_child_context(self, dialog_context: DialogContext): + state = dialog_context.active_dialog.state + if state.dialog: + child_dc = DialogContext(self.dialogs, dialog_context.context, state.dialog) + child_dc.parent = dialog_context + return child_dc + + return None + + +class MemoryScopesTests(aiounittest.AsyncTestCase): + begin_message = Activity( + text="begin", + type=ActivityTypes.message, + channel_id="test", + from_property=ChannelAccount(id="user"), + recipient=ChannelAccount(id="bot"), + conversation=ConversationAccount(id="convo1"), + ) + + async def test_class_memory_scope_should_find_registered_dialog(self): + # Create ConversationState with MemoryStorage and register the state as middleware. + conversation_state = ConversationState(MemoryStorage()) + + # Create a DialogState property, DialogSet and register the dialogs. + dialog_state = conversation_state.create_property("dialogs") + dialogs = DialogSet(dialog_state) + dialog = TestDialog("test", "test message") + dialogs.add(dialog) + + # Create test context + context = TurnContext(TestAdapter(), MemoryScopesTests.begin_message) + await dialog_state.set( + context, DialogState(stack=[DialogInstance(id="test", state={})]) + ) + + dialog_context = await dialogs.create_context(context) + + # Run test + scope = ClassMemoryScope() + memory = scope.get_memory(dialog_context) + self.assertTrue(memory, "memory not returned") + self.assertEqual("test message", memory.message) + self.assertEqual("resolved value", memory.expression) + + async def test_class_memory_scope_should_not_allow_set_memory_call(self): + # Create ConversationState with MemoryStorage and register the state as middleware. + conversation_state = ConversationState(MemoryStorage()) + + # Create a DialogState property, DialogSet and register the dialogs. + dialog_state = conversation_state.create_property("dialogs") + dialogs = DialogSet(dialog_state) + dialog = TestDialog("test", "test message") + dialogs.add(dialog) + + # Create test context + context = TurnContext(TestAdapter(), MemoryScopesTests.begin_message) + await dialog_state.set( + context, DialogState(stack=[DialogInstance(id="test", state={})]) + ) + + dialog_context = await dialogs.create_context(context) + + # Run test + scope = ClassMemoryScope() + with self.assertRaises(Exception) as context: + scope.set_memory(dialog_context, {}) + + self.assertTrue("not supported" in str(context.exception)) + + async def test_class_memory_scope_should_not_allow_load_and_save_changes_calls( + self, + ): + # Create ConversationState with MemoryStorage and register the state as middleware. + conversation_state = ConversationState(MemoryStorage()) + + # Create a DialogState property, DialogSet and register the dialogs. + dialog_state = conversation_state.create_property("dialogs") + dialogs = DialogSet(dialog_state) + dialog = TestDialog("test", "test message") + dialogs.add(dialog) + + # Create test context + context = TurnContext(TestAdapter(), MemoryScopesTests.begin_message) + await dialog_state.set( + context, DialogState(stack=[DialogInstance(id="test", state={})]) + ) + + dialog_context = await dialogs.create_context(context) + + # Run test + scope = ClassMemoryScope() + await scope.load(dialog_context) + memory = scope.get_memory(dialog_context) + with self.assertRaises(AttributeError) as context: + memory.message = "foo" + + self.assertTrue("can't set attribute" in str(context.exception)) + await scope.save_changes(dialog_context) + self.assertEqual("test message", dialog.message) + + """ + it("ConversationMemoryScope should return conversation state.", async function(): + # Create ConversationState with MemoryStorage and register the state as middleware. + conversation_state = ConversationState(MemoryStorage()) + + # Create a DialogState property, DialogSet and register the dialogs. + dialog_state = conversation_state.create_property("dialogs") + dialogs = DialogSet(dialog_state) + dialog = TestDialog("test", "test message") + dialogs.add(dialog) + + # Create test context + context = TurnContext(TestAdapter(), MemoryScopesTests.begin_message) + context.turn_state.set("ConversationState", conversation_state) + dialog_context = await dialogs.createContext(context) + + # Initialize conversation state + await conversation_state.create_property("conversation").set(context, { foo: "bar" }) + await conversation_state.saveChanges(context) + + # Run test + scope = ConversationMemoryScope() + memory = scope.get_memory(dialog_context) + self.assertEqual(typeof memory, "object", "state not returned") + self.assertEqual(memory.conversation.foo, "bar") + }) + + it("UserMemoryScope should not return state if not loaded.", async function(): + # Initialize user state + storage = MemoryStorage() + context = TurnContext(TestAdapter(), MemoryScopesTests.begin_message) + userState = UserState(storage) + context.turn_state.set("UserState", userState) + await userState.create_property("user").set(context, { foo: "bar" }) + await userState.saveChanges(context) + + # Replace context and conversation_state with new instances + context = TurnContext(TestAdapter(), MemoryScopesTests.begin_message) + userState = UserState(storage) + context.turn_state.set("UserState", userState) + + # Create a DialogState property, DialogSet and register the dialogs. + conversation_state = ConversationState(storage) + dialog_state = conversation_state.create_property("dialogs") + dialogs = DialogSet(dialog_state) + dialog = TestDialog("test", "test message") + dialogs.add(dialog) + + # Create test context + dialog_context = await dialogs.createContext(context) + + # Run test + scope = UserMemoryScope(userState) + memory = scope.get_memory(dialog_context) + self.assertEqual(memory, undefined, "state returned") + }) + + it("UserMemoryScope should return state once loaded.", async function(): + # Initialize user state + storage = MemoryStorage() + context = TurnContext(TestAdapter(), MemoryScopesTests.begin_message) + userState = UserState(storage) + context.turn_state.set("UserState", userState) + await userState.create_property("user").set(context, { foo: "bar" }) + await userState.saveChanges(context) + + # Replace context and conversation_state with instances + context = TurnContext(TestAdapter(), MemoryScopesTests.begin_message) + userState = UserState(storage) + context.turn_state.set("UserState", userState) + + # Create a DialogState property, DialogSet and register the dialogs. + conversation_state = ConversationState(storage) + context.turn_state.set("ConversationState", conversation_state) + dialog_state = conversation_state.create_property("dialogs") + dialogs = DialogSet(dialog_state) + dialog = TestDialog("test", "test message") + dialogs.add(dialog) + + # Create test context + dialog_context = await dialogs.createContext(context) + + # Run test + scope = UserMemoryScope() + memory = scope.get_memory(dialog_context) + self.assertEqual(memory, undefined, "state returned") + + await scope.load(dialog_context) + memory = scope.get_memory(dialog_context) + self.assertEqual(typeof memory, "object", "state not returned") + self.assertEqual(memory.user.foo, "bar") + }) + + it("DialogMemoryScope should return containers state.", async function(): + # Create a DialogState property, DialogSet and register the dialogs. + storage = MemoryStorage() + conversation_state = ConversationState(storage) + dialog_state = conversation_state.create_property("dialogs") + dialogs = DialogSet(dialog_state) + container = TestContainer("container") + dialogs.add(container) + + # Create test context + context = TurnContext(TestAdapter(), MemoryScopesTests.begin_message) + dialog_context = await dialogs.createContext(context) + + # Run test + scope = DialogMemoryScope() + await dialog_context.beginDialog("container") + memory = scope.get_memory(dialog_context) + assert(typeof memory == "object", "state not returned") + assert(memory.isContainer == True) + }) + + it("DialogMemoryScope should return parent containers state for children.", async function(): + # Create a DialogState property, DialogSet and register the dialogs. + storage = MemoryStorage() + conversation_state = ConversationState(storage) + dialog_state = conversation_state.create_property("dialogs") + dialogs = DialogSet(dialog_state) + container = TestContainer("container", TestDialog("child", "test message")) + dialogs.add(container) + + # Create test context + context = TurnContext(TestAdapter(), MemoryScopesTests.begin_message) + dialog_context = await dialogs.createContext(context) + + # Run test + scope = DialogMemoryScope() + await dialog_context.beginDialog("container") + child_dc = dialog_context.child + assert(child_dc != undefined, "No child DC") + memory = scope.get_memory(child_dc) + assert(typeof memory == "object", "state not returned") + assert(memory.isContainer == True) + }) + + it("DialogMemoryScope should return childs state when no parent.", async function(): + # Create a DialogState property, DialogSet and register the dialogs. + storage = MemoryStorage() + conversation_state = ConversationState(storage) + dialog_state = conversation_state.create_property("dialogs") + dialogs = DialogSet(dialog_state) + dialog = TestDialog("test", "test message") + dialogs.add(dialog) + + # Create test context + context = TurnContext(TestAdapter(), MemoryScopesTests.begin_message) + dialog_context = await dialogs.createContext(context) + + # Run test + scope = DialogMemoryScope() + await dialog_context.beginDialog("test") + memory = scope.get_memory(dialog_context) + assert(typeof memory != undefined, "state not returned") + assert(memory.is_dialog == True) + }) + + it("DialogMemoryScope should overwrite parents memory.", async function(): + # Create a DialogState property, DialogSet and register the dialogs. + storage = MemoryStorage() + conversation_state = ConversationState(storage) + dialog_state = conversation_state.create_property("dialogs") + dialogs = DialogSet(dialog_state) + container = TestContainer("container", TestDialog("child", "test message")) + dialogs.add(container) + + # Create test context + context = TurnContext(TestAdapter(), MemoryScopesTests.begin_message) + dialog_context = await dialogs.createContext(context) + + # Run test + scope = DialogMemoryScope() + await dialog_context.beginDialog("container") + child_dc = dialog_context.child + assert(child_dc != undefined, "No child DC") + scope.setMemory(child_dc, { foo: "bar" }) + memory = scope.get_memory(child_dc) + assert(typeof memory == "object", "state not returned") + assert(memory.foo == "bar") + }) + + it("DialogMemoryScope should overwrite active dialogs memory.", async function(): + # Create a DialogState property, DialogSet and register the dialogs. + storage = MemoryStorage() + conversation_state = ConversationState(storage) + dialog_state = conversation_state.create_property("dialogs") + dialogs = DialogSet(dialog_state) + container = TestContainer("container") + dialogs.add(container) + + # Create test context + context = TurnContext(TestAdapter(), MemoryScopesTests.begin_message) + dialog_context = await dialogs.createContext(context) + + # Run test + scope = DialogMemoryScope() + await dialog_context.beginDialog("container") + scope.setMemory(dialog_context, { foo: "bar" }) + memory = scope.get_memory(dialog_context) + assert(typeof memory == "object", "state not returned") + assert(memory.foo == "bar") + }) + + it("DialogMemoryScope should raise error if setMemory() called without memory.", async function(): + # Create a DialogState property, DialogSet and register the dialogs. + storage = MemoryStorage() + conversation_state = ConversationState(storage) + dialog_state = conversation_state.create_property("dialogs") + dialogs = DialogSet(dialog_state) + container = TestContainer("container") + dialogs.add(container) + + # Create test context + context = TurnContext(TestAdapter(), MemoryScopesTests.begin_message) + dialog_context = await dialogs.createContext(context) + + # Run test + error = False + try: + scope = DialogMemoryScope() + await dialog_context.beginDialog("container") + scope.setMemory(dialog_context, undefined) + } catch (err): + error = True + } + assert(error) + }) + + it("DialogMemoryScope should raise error if delete() called.", async function(): + # Create a DialogState property, DialogSet and register the dialogs. + storage = MemoryStorage() + conversation_state = ConversationState(storage) + dialog_state = conversation_state.create_property("dialogs") + dialogs = DialogSet(dialog_state) + container = TestContainer("container") + dialogs.add(container) + + # Create test context + context = TurnContext(TestAdapter(), MemoryScopesTests.begin_message) + dialog_context = await dialogs.createContext(context) + + # Run test + error = False + try: + scope = DialogMemoryScope() + await scope.delete(dialog_context) + } catch (err): + error = True + } + assert(error) + }) + + it("SettingsMemoryScope should return content of settings.", async function(): + # Create a DialogState property, DialogSet and register the dialogs. + conversation_state = ConversationState(MemoryStorage()) + dialog_state = conversation_state.create_property("dialogs") + dialogs = DialogSet(dialog_state).add(TestDialog("test", "test message")) + + # Create test context + context = TurnContext(TestAdapter(), MemoryScopesTests.begin_message) + dialog_context = await dialogs.createContext(context) + settings = require("./test.settings.json") + dialog_context.context.turn_state.set("settings", settings) + + # Run test + scope = SettingsMemoryScope() + memory = scope.get_memory(dialog_context) + self.assertEqual(typeof memory, "object", "settings not returned") + self.assertEqual(memory.string, "test") + self.assertEqual(memory.int, 3) + self.assertEqual(memory.array[0], "zero") + self.assertEqual(memory.array[1], "one") + self.assertEqual(memory.array[2], "two") + self.assertEqual(memory.array[3], "three") + self.assertEqual(dialog_context.state.getValue("settings.fakeArray.0"), "zero") + self.assertEqual(dialog_context.state.getValue("settings.fakeArray.1"), "one") + self.assertEqual(dialog_context.state.getValue("settings.fakeArray.2"), "two") + self.assertEqual(dialog_context.state.getValue("settings.fakeArray.3"), "three") + self.assertEqual(dialog_context.state.getValue("settings.fakeArray.zzz"), "cat") + for (key in process.env): + if (typeof process.env[key] == "string"): + assert(memory[key] == process.env[key]) + } + } + + # override settings with process.env + self.assertEqual(dialog_context.state.getValue("settings.to_be_overridden"), "one") + process.env["to_be_overridden"] = "two" + self.assertEqual(dialog_context.state.getValue("settings.not_to_be_overridden"), "one") + self.assertEqual(dialog_context.state.getValue("settings.to_be_overridden"), "two", "settings should be overriden by environment variables") + }) + + it("ThisMemoryScope should return active dialogs state.", async function(): + # Create a DialogState property, DialogSet and register the dialogs. + storage = MemoryStorage() + conversation_state = ConversationState(storage) + dialog_state = conversation_state.create_property("dialogs") + dialogs = DialogSet(dialog_state) + dialog = TestDialog("test", "test message") + dialogs.add(dialog) + + # Create test context + context = TurnContext(TestAdapter(), MemoryScopesTests.begin_message) + dialog_context = await dialogs.createContext(context) + + # Run test + scope = ThisMemoryScope() + await dialog_context.beginDialog("test") + memory = scope.get_memory(dialog_context) + assert(typeof memory != undefined, "state not returned") + assert(memory.is_dialog == True) + }) + + it("ThisMemoryScope should overwrite active dialogs memory.", async function(): + # Create a DialogState property, DialogSet and register the dialogs. + storage = MemoryStorage() + conversation_state = ConversationState(storage) + dialog_state = conversation_state.create_property("dialogs") + dialogs = DialogSet(dialog_state) + container = TestContainer("container") + dialogs.add(container) + + # Create test context + context = TurnContext(TestAdapter(), MemoryScopesTests.begin_message) + dialog_context = await dialogs.createContext(context) + + # Run test + scope = ThisMemoryScope() + await dialog_context.beginDialog("container") + scope.setMemory(dialog_context, { foo: "bar" }) + memory = scope.get_memory(dialog_context) + assert(typeof memory == "object", "state not returned") + assert(memory.foo == "bar") + }) + + it("ThisMemoryScope should raise error if setMemory() called without memory.", async function(): + # Create a DialogState property, DialogSet and register the dialogs. + storage = MemoryStorage() + conversation_state = ConversationState(storage) + dialog_state = conversation_state.create_property("dialogs") + dialogs = DialogSet(dialog_state) + container = TestContainer("container") + dialogs.add(container) + + # Create test context + context = TurnContext(TestAdapter(), MemoryScopesTests.begin_message) + dialog_context = await dialogs.createContext(context) + + # Run test + error = False + try: + scope = ThisMemoryScope() + await dialog_context.beginDialog("container") + scope.setMemory(dialog_context, undefined) + } catch (err): + error = True + } + assert(error) + }) + + it("ThisMemoryScope should raise error if setMemory() called without active dialog.", async function(): + # Create a DialogState property, DialogSet and register the dialogs. + storage = MemoryStorage() + conversation_state = ConversationState(storage) + dialog_state = conversation_state.create_property("dialogs") + dialogs = DialogSet(dialog_state) + container = TestContainer("container") + dialogs.add(container) + + # Create test context + context = TurnContext(TestAdapter(), MemoryScopesTests.begin_message) + dialog_context = await dialogs.createContext(context) + + # Run test + error = False + try: + scope = ThisMemoryScope() + scope.setMemory(dialog_context, { foo: "bar" }) + } catch (err): + error = True + } + assert(error) + }) + + it("ThisMemoryScope should raise error if delete() called.", async function(): + # Create a DialogState property, DialogSet and register the dialogs. + storage = MemoryStorage() + conversation_state = ConversationState(storage) + dialog_state = conversation_state.create_property("dialogs") + dialogs = DialogSet(dialog_state) + container = TestContainer("container") + dialogs.add(container) + + # Create test context + context = TurnContext(TestAdapter(), MemoryScopesTests.begin_message) + dialog_context = await dialogs.createContext(context) + + # Run test + error = False + try: + scope = ThisMemoryScope() + await scope.delete(dialog_context) + } catch (err): + error = True + } + assert(error) + }) + + it("TurnMemoryScope should persist changes to turn state.", async function(): + # Create a DialogState property, DialogSet and register the dialogs. + storage = MemoryStorage() + conversation_state = ConversationState(storage) + dialog_state = conversation_state.create_property("dialogs") + dialogs = DialogSet(dialog_state) + dialog = TestDialog("test", "test message") + dialogs.add(dialog) + + # Create test context + context = TurnContext(TestAdapter(), MemoryScopesTests.begin_message) + dialog_context = await dialogs.createContext(context) + + # Run test + scope = TurnMemoryScope() + memory = scope.get_memory(dialog_context) + assert(typeof memory != undefined, "state not returned") + memory.foo = "bar" + memory = scope.get_memory(dialog_context) + assert(memory.foo == "bar") + }) + + it("TurnMemoryScope should overwrite values in turn state.", async function(): + # Create a DialogState property, DialogSet and register the dialogs. + storage = MemoryStorage() + conversation_state = ConversationState(storage) + dialog_state = conversation_state.create_property("dialogs") + dialogs = DialogSet(dialog_state) + dialog = TestDialog("test", "test message") + dialogs.add(dialog) + + # Create test context + context = TurnContext(TestAdapter(), MemoryScopesTests.begin_message) + dialog_context = await dialogs.createContext(context) + + # Run test + scope = TurnMemoryScope() + scope.setMemory(dialog_context, { foo: "bar" }) + memory = scope.get_memory(dialog_context) + assert(typeof memory != undefined, "state not returned") + assert(memory.foo == "bar") + }) + + it("TurnMemoryScope should raise error when setMemory() called without memory.", async function(): + # Create a DialogState property, DialogSet and register the dialogs. + storage = MemoryStorage() + conversation_state = ConversationState(storage) + dialog_state = conversation_state.create_property("dialogs") + dialogs = DialogSet(dialog_state) + dialog = TestDialog("test", "test message") + dialogs.add(dialog) + + # Create test context + context = TurnContext(TestAdapter(), MemoryScopesTests.begin_message) + dialog_context = await dialogs.createContext(context) + + # Run test + error = False + try: + scope = TurnMemoryScope() + scope.setMemory(dialog_context, undefined) + } catch (err): + error = True + } + assert(error) + }) + + it("TurnMemoryScope should raise error when delete() called.", async function(): + # Create a DialogState property, DialogSet and register the dialogs. + storage = MemoryStorage() + conversation_state = ConversationState(storage) + dialog_state = conversation_state.create_property("dialogs") + dialogs = DialogSet(dialog_state) + dialog = TestDialog("test", "test message") + dialogs.add(dialog) + + # Create test context + context = TurnContext(TestAdapter(), MemoryScopesTests.begin_message) + dialog_context = await dialogs.createContext(context) + + # Run test + error = False + try: + scope = TurnMemoryScope() + await scope.delete(dialog_context) + } catch (err): + error = True + } + assert(error) + """ From 17c343f9a299bb721064fd828dad269a86c70f1f Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Wed, 2 Dec 2020 00:27:12 -0800 Subject: [PATCH 14/18] ConversationState scope test --- .../tests/memory/scopes/test_memory_scopes.py | 37 +++++++++++++++++-- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/libraries/botbuilder-dialogs/tests/memory/scopes/test_memory_scopes.py b/libraries/botbuilder-dialogs/tests/memory/scopes/test_memory_scopes.py index 74aa4ddab..1a90753ae 100644 --- a/libraries/botbuilder-dialogs/tests/memory/scopes/test_memory_scopes.py +++ b/libraries/botbuilder-dialogs/tests/memory/scopes/test_memory_scopes.py @@ -19,8 +19,9 @@ DialogInstance, DialogSet, DialogState, + ObjectPath, ) -from botbuilder.dialogs.memory.scopes import ClassMemoryScope +from botbuilder.dialogs.memory.scopes import ClassMemoryScope, ConversationMemoryScope from botbuilder.schema import ( Activity, ActivityTypes, @@ -171,8 +172,39 @@ async def test_class_memory_scope_should_not_allow_load_and_save_changes_calls( await scope.save_changes(dialog_context) self.assertEqual("test message", dialog.message) + async def test_conversation_memory_scope_should_return_conversation_state(self): + # Create ConversationState with MemoryStorage and register the state as middleware. + conversation_state = ConversationState(MemoryStorage()) + + # Create a DialogState property, DialogSet and register the dialogs. + dialog_state = conversation_state.create_property("dialogs") + dialogs = DialogSet(dialog_state) + dialog = TestDialog("test", "test message") + dialogs.add(dialog) + + # Create test context + context = TurnContext(TestAdapter(), MemoryScopesTests.begin_message) + context.turn_state["ConversationState"] = conversation_state + + dialog_context = await dialogs.create_context(context) + + # Initialize conversation state + foo_cls = namedtuple("TestObject", "foo") + conversation_prop = conversation_state.create_property("conversation") + await conversation_prop.set(context, foo_cls(foo="bar")) + await conversation_state.save_changes(context) + + # Run test + scope = ConversationMemoryScope() + memory = scope.get_memory(dialog_context) + self.assertTrue(memory, "memory not returned") + + # TODO: Make get_path_value take conversation.foo + test_obj = ObjectPath.get_path_value(memory, "conversation") + self.assertEqual("bar", test_obj.foo) + """ - it("ConversationMemoryScope should return conversation state.", async function(): + async def test_conversation_memory_scope_should_return_conversation_state(self): # Create ConversationState with MemoryStorage and register the state as middleware. conversation_state = ConversationState(MemoryStorage()) @@ -196,7 +228,6 @@ async def test_class_memory_scope_should_not_allow_load_and_save_changes_calls( memory = scope.get_memory(dialog_context) self.assertEqual(typeof memory, "object", "state not returned") self.assertEqual(memory.conversation.foo, "bar") - }) it("UserMemoryScope should not return state if not loaded.", async function(): # Initialize user state From ef26d5fbb488fb0489cf1728d5d1af7223efc05f Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Mon, 7 Dec 2020 22:39:22 -0800 Subject: [PATCH 15/18] Adding more scopes tests --- .../botbuilder/dialogs/dialog_set.py | 5 +- .../memory/scopes/dialog_memory_scope.py | 33 +- .../tests/memory/scopes/test_memory_scopes.py | 297 ++++++++---------- 3 files changed, 150 insertions(+), 185 deletions(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_set.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_set.py index 454a6b94b..5820a3422 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_set.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_set.py @@ -23,8 +23,11 @@ def __init__(self, dialog_state: StatePropertyAccessor = None): # Only ComponentDialog can initialize with None dialog_state from .component_dialog import ComponentDialog from .dialog_manager import DialogManager + from .dialog_container import DialogContainer - if not isinstance(self_obj, (ComponentDialog, DialogManager)): + if not isinstance( + self_obj, (ComponentDialog, DialogContainer, DialogManager) + ): raise TypeError("DialogSet(): dialog_state cannot be None.") finally: # make sure to clean up the frame at the end to avoid ref cycles diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/dialog_memory_scope.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/dialog_memory_scope.py index 22bf5931f..490ad23a1 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/dialog_memory_scope.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/memory/scopes/dialog_memory_scope.py @@ -44,18 +44,25 @@ def set_memory(self, dialog_context: "DialogContext", memory: object): if not memory: raise TypeError(f"Expecting: memory object, but received None") - # if active dialog is a container dialog then "dialog" binds to it - if dialog_context.active_dialog: + # If active dialog is a container dialog then "dialog" binds to it. + # Otherwise the "dialog" will bind to the dialogs parent assuming it + # is a container. + parent = dialog_context + if not self.is_container(parent) and self.is_container(parent.parent): + parent = parent.parent + + # If there's no active dialog then throw an error. + if not parent.active_dialog: + raise Exception( + "Cannot set DialogMemoryScope. There is no active dialog dialog or parent dialog in the context" + ) + + parent.active_dialog.state = memory + + def is_container(self, dialog_context: "DialogContext"): + if dialog_context and dialog_context.active_dialog: dialog = dialog_context.find_dialog_sync(dialog_context.active_dialog.id) if isinstance(dialog, self._dialog_container_cls): - dialog_context.active_dialog.state = memory - return - elif dialog_context.parent and dialog_context.parent.active_dialog: - dialog_context.parent.active_dialog.state = memory - return - elif dialog_context.active_dialog: - dialog_context.active_dialog.state = memory - - raise Exception( - "Cannot set DialogMemoryScope. There is no active dialog dialog or parent dialog in the context" - ) + return True + + return False diff --git a/libraries/botbuilder-dialogs/tests/memory/scopes/test_memory_scopes.py b/libraries/botbuilder-dialogs/tests/memory/scopes/test_memory_scopes.py index 1a90753ae..d38544553 100644 --- a/libraries/botbuilder-dialogs/tests/memory/scopes/test_memory_scopes.py +++ b/libraries/botbuilder-dialogs/tests/memory/scopes/test_memory_scopes.py @@ -6,11 +6,7 @@ import aiounittest -from botbuilder.core import ( - ConversationState, - MemoryStorage, - TurnContext, -) +from botbuilder.core import ConversationState, MemoryStorage, TurnContext, UserState from botbuilder.core.adapters import TestAdapter from botbuilder.dialogs import ( Dialog, @@ -21,7 +17,12 @@ DialogState, ObjectPath, ) -from botbuilder.dialogs.memory.scopes import ClassMemoryScope, ConversationMemoryScope +from botbuilder.dialogs.memory.scopes import ( + ClassMemoryScope, + ConversationMemoryScope, + DialogMemoryScope, + UserMemoryScope, +) from botbuilder.schema import ( Activity, ActivityTypes, @@ -42,23 +43,24 @@ def aux_try_get_value(state): # pylint: disable=unused-argument self.expression = ExpressionObject(aux_try_get_value) async def begin_dialog(self, dialog_context: DialogContext, options: object = None): - dialog_context.active_dialog.state.is_dialog = True + dialog_context.active_dialog.state["is_dialog"] = True await dialog_context.context.send_activity(self.message) return Dialog.end_of_turn class TestContainer(DialogContainer): - def __init__(self, id: str, child: Dialog): + def __init__(self, id: str, child: Dialog = None): super().__init__(id) + self.child_id = None if child: self.dialogs.add(child) self.child_id = child.id async def begin_dialog(self, dialog_context: DialogContext, options: object = None): state = dialog_context.active_dialog.state - state.is_container = True + state["is_container"] = True if self.child_id: - state.dialog = {} + state["dialog"] = DialogState() child_dc = self.create_child_context(dialog_context) return await child_dc.begin_dialog(self.child_id, options) @@ -73,8 +75,10 @@ async def continue_dialog(self, dialog_context: DialogContext): def create_child_context(self, dialog_context: DialogContext): state = dialog_context.active_dialog.state - if state.dialog: - child_dc = DialogContext(self.dialogs, dialog_context.context, state.dialog) + if state["dialog"] is not None: + child_dc = DialogContext( + self.dialogs, dialog_context.context, state["dialog"] + ) child_dc.parent = dialog_context return child_dc @@ -203,45 +207,21 @@ async def test_conversation_memory_scope_should_return_conversation_state(self): test_obj = ObjectPath.get_path_value(memory, "conversation") self.assertEqual("bar", test_obj.foo) - """ - async def test_conversation_memory_scope_should_return_conversation_state(self): - # Create ConversationState with MemoryStorage and register the state as middleware. - conversation_state = ConversationState(MemoryStorage()) - - # Create a DialogState property, DialogSet and register the dialogs. - dialog_state = conversation_state.create_property("dialogs") - dialogs = DialogSet(dialog_state) - dialog = TestDialog("test", "test message") - dialogs.add(dialog) - - # Create test context - context = TurnContext(TestAdapter(), MemoryScopesTests.begin_message) - context.turn_state.set("ConversationState", conversation_state) - dialog_context = await dialogs.createContext(context) - - # Initialize conversation state - await conversation_state.create_property("conversation").set(context, { foo: "bar" }) - await conversation_state.saveChanges(context) - - # Run test - scope = ConversationMemoryScope() - memory = scope.get_memory(dialog_context) - self.assertEqual(typeof memory, "object", "state not returned") - self.assertEqual(memory.conversation.foo, "bar") - - it("UserMemoryScope should not return state if not loaded.", async function(): + async def test_user_memory_scope_should_not_return_state_if_not_loaded(self): # Initialize user state storage = MemoryStorage() context = TurnContext(TestAdapter(), MemoryScopesTests.begin_message) - userState = UserState(storage) - context.turn_state.set("UserState", userState) - await userState.create_property("user").set(context, { foo: "bar" }) - await userState.saveChanges(context) + user_state = UserState(storage) + context.turn_state["UserState"] = user_state + foo_cls = namedtuple("TestObject", "foo") + user_prop = user_state.create_property("conversation") + await user_prop.set(context, foo_cls(foo="bar")) + await user_state.save_changes(context) - # Replace context and conversation_state with new instances + # Replace context and user_state with new instances context = TurnContext(TestAdapter(), MemoryScopesTests.begin_message) - userState = UserState(storage) - context.turn_state.set("UserState", userState) + user_state = UserState(storage) + context.turn_state["UserState"] = user_state # Create a DialogState property, DialogSet and register the dialogs. conversation_state = ConversationState(storage) @@ -251,51 +231,54 @@ async def test_conversation_memory_scope_should_return_conversation_state(self): dialogs.add(dialog) # Create test context - dialog_context = await dialogs.createContext(context) + dialog_context = await dialogs.create_context(context) # Run test - scope = UserMemoryScope(userState) + scope = UserMemoryScope() memory = scope.get_memory(dialog_context) - self.assertEqual(memory, undefined, "state returned") - }) + self.assertIsNone(memory, "state returned") - it("UserMemoryScope should return state once loaded.", async function(): + async def test_user_memory_scope_should_return_state_once_loaded(self): # Initialize user state storage = MemoryStorage() context = TurnContext(TestAdapter(), MemoryScopesTests.begin_message) - userState = UserState(storage) - context.turn_state.set("UserState", userState) - await userState.create_property("user").set(context, { foo: "bar" }) - await userState.saveChanges(context) + user_state = UserState(storage) + context.turn_state["UserState"] = user_state + foo_cls = namedtuple("TestObject", "foo") + user_prop = user_state.create_property("conversation") + await user_prop.set(context, foo_cls(foo="bar")) + await user_state.save_changes(context) # Replace context and conversation_state with instances context = TurnContext(TestAdapter(), MemoryScopesTests.begin_message) - userState = UserState(storage) - context.turn_state.set("UserState", userState) + user_state = UserState(storage) + context.turn_state["UserState"] = user_state # Create a DialogState property, DialogSet and register the dialogs. conversation_state = ConversationState(storage) - context.turn_state.set("ConversationState", conversation_state) + context.turn_state["ConversationState"] = conversation_state dialog_state = conversation_state.create_property("dialogs") dialogs = DialogSet(dialog_state) dialog = TestDialog("test", "test message") dialogs.add(dialog) # Create test context - dialog_context = await dialogs.createContext(context) + dialog_context = await dialogs.create_context(context) # Run test scope = UserMemoryScope() memory = scope.get_memory(dialog_context) - self.assertEqual(memory, undefined, "state returned") - + self.assertIsNone(memory, "state returned") + await scope.load(dialog_context) memory = scope.get_memory(dialog_context) - self.assertEqual(typeof memory, "object", "state not returned") - self.assertEqual(memory.user.foo, "bar") - }) + self.assertIsNotNone(memory, "state not returned") + + # TODO: Make get_path_value take conversation.foo + test_obj = ObjectPath.get_path_value(memory, "conversation") + self.assertEqual("bar", test_obj.foo) - it("DialogMemoryScope should return containers state.", async function(): + async def test_dialog_memory_scope_should_return_containers_state(self): # Create a DialogState property, DialogSet and register the dialogs. storage = MemoryStorage() conversation_state = ConversationState(storage) @@ -306,17 +289,18 @@ async def test_conversation_memory_scope_should_return_conversation_state(self): # Create test context context = TurnContext(TestAdapter(), MemoryScopesTests.begin_message) - dialog_context = await dialogs.createContext(context) + dialog_context = await dialogs.create_context(context) # Run test scope = DialogMemoryScope() - await dialog_context.beginDialog("container") + await dialog_context.begin_dialog("container") memory = scope.get_memory(dialog_context) - assert(typeof memory == "object", "state not returned") - assert(memory.isContainer == True) - }) + self.assertIsNotNone(memory, "state not returned") + self.assertTrue(memory["is_container"]) - it("DialogMemoryScope should return parent containers state for children.", async function(): + async def test_dialog_memory_scope_should_return_parent_containers_state_for_children( + self, + ): # Create a DialogState property, DialogSet and register the dialogs. storage = MemoryStorage() conversation_state = ConversationState(storage) @@ -327,19 +311,18 @@ async def test_conversation_memory_scope_should_return_conversation_state(self): # Create test context context = TurnContext(TestAdapter(), MemoryScopesTests.begin_message) - dialog_context = await dialogs.createContext(context) + dialog_context = await dialogs.create_context(context) # Run test scope = DialogMemoryScope() - await dialog_context.beginDialog("container") + await dialog_context.begin_dialog("container") child_dc = dialog_context.child - assert(child_dc != undefined, "No child DC") + self.assertIsNotNone(child_dc, "No child DC") memory = scope.get_memory(child_dc) - assert(typeof memory == "object", "state not returned") - assert(memory.isContainer == True) - }) + self.assertIsNotNone(memory, "state not returned") + self.assertTrue(memory["is_container"]) - it("DialogMemoryScope should return childs state when no parent.", async function(): + async def test_dialog_memory_scope_should_return_childs_state_when_no_parent(self): # Create a DialogState property, DialogSet and register the dialogs. storage = MemoryStorage() conversation_state = ConversationState(storage) @@ -350,17 +333,16 @@ async def test_conversation_memory_scope_should_return_conversation_state(self): # Create test context context = TurnContext(TestAdapter(), MemoryScopesTests.begin_message) - dialog_context = await dialogs.createContext(context) + dialog_context = await dialogs.create_context(context) # Run test scope = DialogMemoryScope() - await dialog_context.beginDialog("test") + await dialog_context.begin_dialog("test") memory = scope.get_memory(dialog_context) - assert(typeof memory != undefined, "state not returned") - assert(memory.is_dialog == True) - }) + self.assertIsNotNone(memory, "state not returned") + self.assertTrue(memory["is_dialog"]) - it("DialogMemoryScope should overwrite parents memory.", async function(): + async def test_dialog_memory_scope_should_overwrite_parents_memory(self): # Create a DialogState property, DialogSet and register the dialogs. storage = MemoryStorage() conversation_state = ConversationState(storage) @@ -371,20 +353,21 @@ async def test_conversation_memory_scope_should_return_conversation_state(self): # Create test context context = TurnContext(TestAdapter(), MemoryScopesTests.begin_message) - dialog_context = await dialogs.createContext(context) + dialog_context = await dialogs.create_context(context) # Run test scope = DialogMemoryScope() - await dialog_context.beginDialog("container") + await dialog_context.begin_dialog("container") child_dc = dialog_context.child - assert(child_dc != undefined, "No child DC") - scope.setMemory(child_dc, { foo: "bar" }) + self.assertIsNotNone(child_dc, "No child DC") + + foo_cls = namedtuple("TestObject", "foo") + scope.set_memory(child_dc, foo_cls("bar")) memory = scope.get_memory(child_dc) - assert(typeof memory == "object", "state not returned") - assert(memory.foo == "bar") - }) + self.assertIsNotNone(memory, "state not returned") + self.assertEqual(memory.foo, "bar") - it("DialogMemoryScope should overwrite active dialogs memory.", async function(): + async def test_dialog_memory_scope_should_overwrite_active_dialogs_memory(self): # Create a DialogState property, DialogSet and register the dialogs. storage = MemoryStorage() conversation_state = ConversationState(storage) @@ -395,43 +378,20 @@ async def test_conversation_memory_scope_should_return_conversation_state(self): # Create test context context = TurnContext(TestAdapter(), MemoryScopesTests.begin_message) - dialog_context = await dialogs.createContext(context) + dialog_context = await dialogs.create_context(context) # Run test scope = DialogMemoryScope() - await dialog_context.beginDialog("container") - scope.setMemory(dialog_context, { foo: "bar" }) + await dialog_context.begin_dialog("container") + foo_cls = namedtuple("TestObject", "foo") + scope.set_memory(dialog_context, foo_cls("bar")) memory = scope.get_memory(dialog_context) - assert(typeof memory == "object", "state not returned") - assert(memory.foo == "bar") - }) - - it("DialogMemoryScope should raise error if setMemory() called without memory.", async function(): - # Create a DialogState property, DialogSet and register the dialogs. - storage = MemoryStorage() - conversation_state = ConversationState(storage) - dialog_state = conversation_state.create_property("dialogs") - dialogs = DialogSet(dialog_state) - container = TestContainer("container") - dialogs.add(container) - - # Create test context - context = TurnContext(TestAdapter(), MemoryScopesTests.begin_message) - dialog_context = await dialogs.createContext(context) - - # Run test - error = False - try: - scope = DialogMemoryScope() - await dialog_context.beginDialog("container") - scope.setMemory(dialog_context, undefined) - } catch (err): - error = True - } - assert(error) - }) + self.assertIsNotNone(memory, "state not returned") + assert memory.foo == "bar" - it("DialogMemoryScope should raise error if delete() called.", async function(): + async def test_dialog_memory_scope_should_raise_error_if_set_memory_called_without_memory( + self, + ): # Create a DialogState property, DialogSet and register the dialogs. storage = MemoryStorage() conversation_state = ConversationState(storage) @@ -442,20 +402,16 @@ async def test_conversation_memory_scope_should_return_conversation_state(self): # Create test context context = TurnContext(TestAdapter(), MemoryScopesTests.begin_message) - dialog_context = await dialogs.createContext(context) + dialog_context = await dialogs.create_context(context) # Run test - error = False - try: + with self.assertRaises(Exception): scope = DialogMemoryScope() - await scope.delete(dialog_context) - } catch (err): - error = True - } - assert(error) - }) + await dialog_context.begin_dialog("container") + scope.set_memory(dialog_context, None) - it("SettingsMemoryScope should return content of settings.", async function(): + """ + async def test_settings_memory_scope_should_return_content_of_settings(self): # Create a DialogState property, DialogSet and register the dialogs. conversation_state = ConversationState(MemoryStorage()) dialog_state = conversation_state.create_property("dialogs") @@ -463,7 +419,7 @@ async def test_conversation_memory_scope_should_return_conversation_state(self): # Create test context context = TurnContext(TestAdapter(), MemoryScopesTests.begin_message) - dialog_context = await dialogs.createContext(context) + dialog_context = await dialogs.create_context(context) settings = require("./test.settings.json") dialog_context.context.turn_state.set("settings", settings) @@ -493,9 +449,8 @@ async def test_conversation_memory_scope_should_return_conversation_state(self): process.env["to_be_overridden"] = "two" self.assertEqual(dialog_context.state.getValue("settings.not_to_be_overridden"), "one") self.assertEqual(dialog_context.state.getValue("settings.to_be_overridden"), "two", "settings should be overriden by environment variables") - }) - it("ThisMemoryScope should return active dialogs state.", async function(): + async def test_this_memory_scope_should_return_active_dialogs_state(self): # Create a DialogState property, DialogSet and register the dialogs. storage = MemoryStorage() conversation_state = ConversationState(storage) @@ -506,17 +461,17 @@ async def test_conversation_memory_scope_should_return_conversation_state(self): # Create test context context = TurnContext(TestAdapter(), MemoryScopesTests.begin_message) - dialog_context = await dialogs.createContext(context) + dialog_context = await dialogs.create_context(context) # Run test scope = ThisMemoryScope() - await dialog_context.beginDialog("test") + await dialog_context.begin_dialog("test") memory = scope.get_memory(dialog_context) - assert(typeof memory != undefined, "state not returned") + assert(typeof memory != None, "state not returned") assert(memory.is_dialog == True) - }) - it("ThisMemoryScope should overwrite active dialogs memory.", async function(): + + async def test_this_memory_scope_should_overwrite_active_dialogs_memory(self): # Create a DialogState property, DialogSet and register the dialogs. storage = MemoryStorage() conversation_state = ConversationState(storage) @@ -527,18 +482,18 @@ async def test_conversation_memory_scope_should_return_conversation_state(self): # Create test context context = TurnContext(TestAdapter(), MemoryScopesTests.begin_message) - dialog_context = await dialogs.createContext(context) + dialog_context = await dialogs.create_context(context) # Run test scope = ThisMemoryScope() - await dialog_context.beginDialog("container") + await dialog_context.begin_dialog("container") scope.setMemory(dialog_context, { foo: "bar" }) memory = scope.get_memory(dialog_context) - assert(typeof memory == "object", "state not returned") + self.assertIsNotNone(memory, "state not returned") assert(memory.foo == "bar") - }) - it("ThisMemoryScope should raise error if setMemory() called without memory.", async function(): + + async def test_this_memory_scope_should_raise_error_if_set_memory_called_without_memory(self): # Create a DialogState property, DialogSet and register the dialogs. storage = MemoryStorage() conversation_state = ConversationState(storage) @@ -549,21 +504,21 @@ async def test_conversation_memory_scope_should_return_conversation_state(self): # Create test context context = TurnContext(TestAdapter(), MemoryScopesTests.begin_message) - dialog_context = await dialogs.createContext(context) + dialog_context = await dialogs.create_context(context) # Run test error = False try: scope = ThisMemoryScope() - await dialog_context.beginDialog("container") - scope.setMemory(dialog_context, undefined) + await dialog_context.begin_dialog("container") + scope.setMemory(dialog_context, None) } catch (err): error = True } assert(error) - }) - it("ThisMemoryScope should raise error if setMemory() called without active dialog.", async function(): + + async def test_this_memory_scope_should_raise_error_if_set_memory_called_without_active_dialog(self): # Create a DialogState property, DialogSet and register the dialogs. storage = MemoryStorage() conversation_state = ConversationState(storage) @@ -574,7 +529,7 @@ async def test_conversation_memory_scope_should_return_conversation_state(self): # Create test context context = TurnContext(TestAdapter(), MemoryScopesTests.begin_message) - dialog_context = await dialogs.createContext(context) + dialog_context = await dialogs.create_context(context) # Run test error = False @@ -585,9 +540,9 @@ async def test_conversation_memory_scope_should_return_conversation_state(self): error = True } assert(error) - }) - it("ThisMemoryScope should raise error if delete() called.", async function(): + + async def test_this_memory_scope_should_raise_error_if_delete_called(self): # Create a DialogState property, DialogSet and register the dialogs. storage = MemoryStorage() conversation_state = ConversationState(storage) @@ -598,7 +553,7 @@ async def test_conversation_memory_scope_should_return_conversation_state(self): # Create test context context = TurnContext(TestAdapter(), MemoryScopesTests.begin_message) - dialog_context = await dialogs.createContext(context) + dialog_context = await dialogs.create_context(context) # Run test error = False @@ -609,9 +564,9 @@ async def test_conversation_memory_scope_should_return_conversation_state(self): error = True } assert(error) - }) - it("TurnMemoryScope should persist changes to turn state.", async function(): + + async def test_turn_memory_scope_should_persist_changes_to_turn_state(self): # Create a DialogState property, DialogSet and register the dialogs. storage = MemoryStorage() conversation_state = ConversationState(storage) @@ -622,18 +577,18 @@ async def test_conversation_memory_scope_should_return_conversation_state(self): # Create test context context = TurnContext(TestAdapter(), MemoryScopesTests.begin_message) - dialog_context = await dialogs.createContext(context) + dialog_context = await dialogs.create_context(context) # Run test scope = TurnMemoryScope() memory = scope.get_memory(dialog_context) - assert(typeof memory != undefined, "state not returned") + assert(typeof memory != None, "state not returned") memory.foo = "bar" memory = scope.get_memory(dialog_context) assert(memory.foo == "bar") - }) - it("TurnMemoryScope should overwrite values in turn state.", async function(): + + async def test_turn_memory_scope_should_overwrite_values_in_turn_state(self): # Create a DialogState property, DialogSet and register the dialogs. storage = MemoryStorage() conversation_state = ConversationState(storage) @@ -644,17 +599,17 @@ async def test_conversation_memory_scope_should_return_conversation_state(self): # Create test context context = TurnContext(TestAdapter(), MemoryScopesTests.begin_message) - dialog_context = await dialogs.createContext(context) + dialog_context = await dialogs.create_context(context) # Run test scope = TurnMemoryScope() scope.setMemory(dialog_context, { foo: "bar" }) memory = scope.get_memory(dialog_context) - assert(typeof memory != undefined, "state not returned") + assert(typeof memory != None, "state not returned") assert(memory.foo == "bar") - }) - it("TurnMemoryScope should raise error when setMemory() called without memory.", async function(): + + async def test_turn_memory_scope_should_raise_error_when_set_memory_called_without_memory(self): # Create a DialogState property, DialogSet and register the dialogs. storage = MemoryStorage() conversation_state = ConversationState(storage) @@ -665,20 +620,20 @@ async def test_conversation_memory_scope_should_return_conversation_state(self): # Create test context context = TurnContext(TestAdapter(), MemoryScopesTests.begin_message) - dialog_context = await dialogs.createContext(context) + dialog_context = await dialogs.create_context(context) # Run test error = False try: scope = TurnMemoryScope() - scope.setMemory(dialog_context, undefined) + scope.setMemory(dialog_context, None) } catch (err): error = True } assert(error) - }) - it("TurnMemoryScope should raise error when delete() called.", async function(): + + async def test_turn_memory_scope_should_raise_error_when_delete_called(self): # Create a DialogState property, DialogSet and register the dialogs. storage = MemoryStorage() conversation_state = ConversationState(storage) @@ -689,7 +644,7 @@ async def test_conversation_memory_scope_should_return_conversation_state(self): # Create test context context = TurnContext(TestAdapter(), MemoryScopesTests.begin_message) - dialog_context = await dialogs.createContext(context) + dialog_context = await dialogs.create_context(context) # Run test error = False From 196152926d0ff707eaf6047d2bc59c823eb8afe5 Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Fri, 18 Dec 2020 16:16:37 -0800 Subject: [PATCH 16/18] Added all scopes tests --- .../tests/memory/scopes/test_memory_scopes.py | 170 ++++-------------- .../tests/memory/scopes/test_settings.py | 14 ++ 2 files changed, 53 insertions(+), 131 deletions(-) create mode 100644 libraries/botbuilder-dialogs/tests/memory/scopes/test_settings.py diff --git a/libraries/botbuilder-dialogs/tests/memory/scopes/test_memory_scopes.py b/libraries/botbuilder-dialogs/tests/memory/scopes/test_memory_scopes.py index d38544553..5101c7070 100644 --- a/libraries/botbuilder-dialogs/tests/memory/scopes/test_memory_scopes.py +++ b/libraries/botbuilder-dialogs/tests/memory/scopes/test_memory_scopes.py @@ -22,6 +22,9 @@ ConversationMemoryScope, DialogMemoryScope, UserMemoryScope, + SettingsMemoryScope, + ThisMemoryScope, + TurnMemoryScope, ) from botbuilder.schema import ( Activity, @@ -387,7 +390,7 @@ async def test_dialog_memory_scope_should_overwrite_active_dialogs_memory(self): scope.set_memory(dialog_context, foo_cls("bar")) memory = scope.get_memory(dialog_context) self.assertIsNotNone(memory, "state not returned") - assert memory.foo == "bar" + self.assertEqual(memory.foo, "bar") async def test_dialog_memory_scope_should_raise_error_if_set_memory_called_without_memory( self, @@ -410,8 +413,10 @@ async def test_dialog_memory_scope_should_raise_error_if_set_memory_called_witho await dialog_context.begin_dialog("container") scope.set_memory(dialog_context, None) - """ async def test_settings_memory_scope_should_return_content_of_settings(self): + # pylint: disable=import-outside-toplevel + from test_settings import DefaultConfig + # Create a DialogState property, DialogSet and register the dialogs. conversation_state = ConversationState(MemoryStorage()) dialog_state = conversation_state.create_property("dialogs") @@ -420,35 +425,19 @@ async def test_settings_memory_scope_should_return_content_of_settings(self): # Create test context context = TurnContext(TestAdapter(), MemoryScopesTests.begin_message) dialog_context = await dialogs.create_context(context) - settings = require("./test.settings.json") - dialog_context.context.turn_state.set("settings", settings) + settings = DefaultConfig() + dialog_context.context.turn_state["settings"] = settings # Run test scope = SettingsMemoryScope() memory = scope.get_memory(dialog_context) - self.assertEqual(typeof memory, "object", "settings not returned") - self.assertEqual(memory.string, "test") - self.assertEqual(memory.int, 3) - self.assertEqual(memory.array[0], "zero") - self.assertEqual(memory.array[1], "one") - self.assertEqual(memory.array[2], "two") - self.assertEqual(memory.array[3], "three") - self.assertEqual(dialog_context.state.getValue("settings.fakeArray.0"), "zero") - self.assertEqual(dialog_context.state.getValue("settings.fakeArray.1"), "one") - self.assertEqual(dialog_context.state.getValue("settings.fakeArray.2"), "two") - self.assertEqual(dialog_context.state.getValue("settings.fakeArray.3"), "three") - self.assertEqual(dialog_context.state.getValue("settings.fakeArray.zzz"), "cat") - for (key in process.env): - if (typeof process.env[key] == "string"): - assert(memory[key] == process.env[key]) - } - } - - # override settings with process.env - self.assertEqual(dialog_context.state.getValue("settings.to_be_overridden"), "one") - process.env["to_be_overridden"] = "two" - self.assertEqual(dialog_context.state.getValue("settings.not_to_be_overridden"), "one") - self.assertEqual(dialog_context.state.getValue("settings.to_be_overridden"), "two", "settings should be overriden by environment variables") + self.assertIsNotNone(memory) + self.assertEqual(memory.STRING, "test") + self.assertEqual(memory.INT, 3) + self.assertEqual(memory.LIST[0], "zero") + self.assertEqual(memory.LIST[1], "one") + self.assertEqual(memory.LIST[2], "two") + self.assertEqual(memory.LIST[3], "three") async def test_this_memory_scope_should_return_active_dialogs_state(self): # Create a DialogState property, DialogSet and register the dialogs. @@ -467,9 +456,8 @@ async def test_this_memory_scope_should_return_active_dialogs_state(self): scope = ThisMemoryScope() await dialog_context.begin_dialog("test") memory = scope.get_memory(dialog_context) - assert(typeof memory != None, "state not returned") - assert(memory.is_dialog == True) - + self.assertIsNotNone(memory, "state not returned") + self.assertTrue(memory["is_dialog"]) async def test_this_memory_scope_should_overwrite_active_dialogs_memory(self): # Create a DialogState property, DialogSet and register the dialogs. @@ -487,13 +475,15 @@ async def test_this_memory_scope_should_overwrite_active_dialogs_memory(self): # Run test scope = ThisMemoryScope() await dialog_context.begin_dialog("container") - scope.setMemory(dialog_context, { foo: "bar" }) + foo_cls = namedtuple("TestObject", "foo") + scope.set_memory(dialog_context, foo_cls("bar")) memory = scope.get_memory(dialog_context) self.assertIsNotNone(memory, "state not returned") - assert(memory.foo == "bar") - + self.assertEqual(memory.foo, "bar") - async def test_this_memory_scope_should_raise_error_if_set_memory_called_without_memory(self): + async def test_this_memory_scope_should_raise_error_if_set_memory_called_without_memory( + self, + ): # Create a DialogState property, DialogSet and register the dialogs. storage = MemoryStorage() conversation_state = ConversationState(storage) @@ -507,42 +497,14 @@ async def test_this_memory_scope_should_raise_error_if_set_memory_called_without dialog_context = await dialogs.create_context(context) # Run test - error = False - try: + with self.assertRaises(Exception): scope = ThisMemoryScope() await dialog_context.begin_dialog("container") - scope.setMemory(dialog_context, None) - } catch (err): - error = True - } - assert(error) - - - async def test_this_memory_scope_should_raise_error_if_set_memory_called_without_active_dialog(self): - # Create a DialogState property, DialogSet and register the dialogs. - storage = MemoryStorage() - conversation_state = ConversationState(storage) - dialog_state = conversation_state.create_property("dialogs") - dialogs = DialogSet(dialog_state) - container = TestContainer("container") - dialogs.add(container) - - # Create test context - context = TurnContext(TestAdapter(), MemoryScopesTests.begin_message) - dialog_context = await dialogs.create_context(context) - - # Run test - error = False - try: - scope = ThisMemoryScope() - scope.setMemory(dialog_context, { foo: "bar" }) - } catch (err): - error = True - } - assert(error) - + scope.set_memory(dialog_context, None) - async def test_this_memory_scope_should_raise_error_if_delete_called(self): + async def test_this_memory_scope_should_raise_error_if_set_memory_called_without_active_dialog( + self, + ): # Create a DialogState property, DialogSet and register the dialogs. storage = MemoryStorage() conversation_state = ConversationState(storage) @@ -556,15 +518,10 @@ async def test_this_memory_scope_should_raise_error_if_delete_called(self): dialog_context = await dialogs.create_context(context) # Run test - error = False - try: + with self.assertRaises(Exception): scope = ThisMemoryScope() - await scope.delete(dialog_context) - } catch (err): - error = True - } - assert(error) - + foo_cls = namedtuple("TestObject", "foo") + scope.set_memory(dialog_context, foo_cls("bar")) async def test_turn_memory_scope_should_persist_changes_to_turn_state(self): # Create a DialogState property, DialogSet and register the dialogs. @@ -582,11 +539,10 @@ async def test_turn_memory_scope_should_persist_changes_to_turn_state(self): # Run test scope = TurnMemoryScope() memory = scope.get_memory(dialog_context) - assert(typeof memory != None, "state not returned") - memory.foo = "bar" + self.assertIsNotNone(memory, "state not returned") + memory["foo"] = "bar" memory = scope.get_memory(dialog_context) - assert(memory.foo == "bar") - + self.assertEqual(memory["foo"], "bar") async def test_turn_memory_scope_should_overwrite_values_in_turn_state(self): # Create a DialogState property, DialogSet and register the dialogs. @@ -603,56 +559,8 @@ async def test_turn_memory_scope_should_overwrite_values_in_turn_state(self): # Run test scope = TurnMemoryScope() - scope.setMemory(dialog_context, { foo: "bar" }) + foo_cls = namedtuple("TestObject", "foo") + scope.set_memory(dialog_context, foo_cls("bar")) memory = scope.get_memory(dialog_context) - assert(typeof memory != None, "state not returned") - assert(memory.foo == "bar") - - - async def test_turn_memory_scope_should_raise_error_when_set_memory_called_without_memory(self): - # Create a DialogState property, DialogSet and register the dialogs. - storage = MemoryStorage() - conversation_state = ConversationState(storage) - dialog_state = conversation_state.create_property("dialogs") - dialogs = DialogSet(dialog_state) - dialog = TestDialog("test", "test message") - dialogs.add(dialog) - - # Create test context - context = TurnContext(TestAdapter(), MemoryScopesTests.begin_message) - dialog_context = await dialogs.create_context(context) - - # Run test - error = False - try: - scope = TurnMemoryScope() - scope.setMemory(dialog_context, None) - } catch (err): - error = True - } - assert(error) - - - async def test_turn_memory_scope_should_raise_error_when_delete_called(self): - # Create a DialogState property, DialogSet and register the dialogs. - storage = MemoryStorage() - conversation_state = ConversationState(storage) - dialog_state = conversation_state.create_property("dialogs") - dialogs = DialogSet(dialog_state) - dialog = TestDialog("test", "test message") - dialogs.add(dialog) - - # Create test context - context = TurnContext(TestAdapter(), MemoryScopesTests.begin_message) - dialog_context = await dialogs.create_context(context) - - # Run test - error = False - try: - scope = TurnMemoryScope() - await scope.delete(dialog_context) - } catch (err): - error = True - } - assert(error) - """ + self.assertIsNotNone(memory, "state not returned") + self.assertEqual(memory.foo, "bar") diff --git a/libraries/botbuilder-dialogs/tests/memory/scopes/test_settings.py b/libraries/botbuilder-dialogs/tests/memory/scopes/test_settings.py new file mode 100644 index 000000000..ab83adef1 --- /dev/null +++ b/libraries/botbuilder-dialogs/tests/memory/scopes/test_settings.py @@ -0,0 +1,14 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import os + + +class DefaultConfig: + """ Bot Configuration """ + + STRING = os.environ.get("STRING", "test") + INT = os.environ.get("INT", 3) + LIST = os.environ.get("LIST", ["zero", "one", "two", "three"]) + NOT_TO_BE_OVERRIDDEN = os.environ.get("NOT_TO_BE_OVERRIDDEN", "one") + TO_BE_OVERRIDDEN = os.environ.get("TO_BE_OVERRIDDEN", "one") From ec96e65bc45a153cfc6405ab5fb41cdbf75f20a3 Mon Sep 17 00:00:00 2001 From: Axel Suarez Martinez Date: Wed, 6 Jan 2021 16:48:30 -0800 Subject: [PATCH 17/18] Fixing printing because of merge conflict --- .../botbuilder/dialogs/dialog_context.py | 68 +++++++++---------- 1 file changed, 32 insertions(+), 36 deletions(-) diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_context.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_context.py index c67c54a7c..16b38f33b 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_context.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_context.py @@ -222,45 +222,41 @@ async def cancel_all_dialogs( # pylint: disable=too-many-nested-blocks try: if cancel_parents is None: - try: - event_name = event_name or DialogEvents.cancel_dialog - - if self.stack or self.parent: - # Cancel all local and parent dialogs while checking for interception - notify = False - dialog_context = self - - while dialog_context: - if dialog_context.stack: - # Check to see if the dialog wants to handle the event - if notify: - event_handled = await dialog_context.emit_event( - event_name, - event_value, - bubble=False, - from_leaf=False, - ) - - if event_handled: - break - - # End the active dialog - await dialog_context.end_active_dialog( - DialogReason.CancelCalled - ) - else: - dialog_context = ( - dialog_context.parent if cancel_parents else None + event_name = event_name or DialogEvents.cancel_dialog + + if self.stack or self.parent: + # Cancel all local and parent dialogs while checking for interception + notify = False + dialog_context = self + + while dialog_context: + if dialog_context.stack: + # Check to see if the dialog wants to handle the event + if notify: + event_handled = await dialog_context.emit_event( + event_name, + event_value, + bubble=False, + from_leaf=False, ) - notify = True + if event_handled: + break + + # End the active dialog + await dialog_context.end_active_dialog( + DialogReason.CancelCalled + ) + else: + dialog_context = ( + dialog_context.parent if cancel_parents else None + ) + + notify = True - return DialogTurnResult(DialogTurnStatus.Cancelled) - # Stack was empty and no parent - return DialogTurnResult(DialogTurnStatus.Empty) - except Exception as err: - err = self.set_exception_context_data(err) - raise err + return DialogTurnResult(DialogTurnStatus.Cancelled) + # Stack was empty and no parent + return DialogTurnResult(DialogTurnStatus.Empty) if self.stack: while self.stack: From 9538fc68171cee188c39f1bdd8790a4514a13cba Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Sat, 23 Jan 2021 19:00:44 -0800 Subject: [PATCH 18/18] PR comments fixes --- .../botbuilder/core/user_state.py | 2 +- .../botbuilder/dialogs/dialog_container.py | 16 ++++++---------- .../botbuilder/dialogs/dialog_context.py | 14 +++++++------- .../botbuilder/dialogs/dialog_instance.py | 2 +- 4 files changed, 15 insertions(+), 19 deletions(-) diff --git a/libraries/botbuilder-core/botbuilder/core/user_state.py b/libraries/botbuilder-core/botbuilder/core/user_state.py index 909839dfe..7cd23f8b1 100644 --- a/libraries/botbuilder-core/botbuilder/core/user_state.py +++ b/libraries/botbuilder-core/botbuilder/core/user_state.py @@ -23,7 +23,7 @@ def __init__(self, storage: Storage, namespace=""): """ self.namespace = namespace - super(UserState, self).__init__(storage, "Internal.UserState_type") + super(UserState, self).__init__(storage, "Internal.UserState") def get_storage_key(self, turn_context: TurnContext) -> str: """ diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_container.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_container.py index 7fd316f52..ad2326419 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_container.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_container.py @@ -31,10 +31,9 @@ async def on_dialog_event( """ Called when an event has been raised, using `DialogContext.emitEvent()`, by either the current dialog or a dialog that the current dialog started. - The dialog context for the current turn of conversation. - The event being raised. - The cancellation token. - True if the event is handled by the current dialog and bubbling should stop. + :param dialog_context: The dialog context for the current turn of conversation. + :param dialog_event: The event being raised. + :return: True if the event is handled by the current dialog and bubbling should stop. """ handled = await super().on_dialog_event(dialog_context, dialog_event) @@ -46,8 +45,6 @@ async def on_dialog_event( f"{dialog_context.active_dialog.id}" ) - # dialog_context.dialogs.telemetry_client.TrackTrace(trace_message, Severity.Warning, null) - await dialog_context.context.send_trace_activity(trace_message) return handled @@ -60,15 +57,14 @@ def get_internal_version(self) -> str: to the container level unless a container doesn't handle it. To support this DialogContainers define a protected virtual method GetInternalVersion() which computes if this dialog or child dialogs have changed which is then examined via calls to check_for_version_change_async(). - version which represents the change of the internals of this container. + :return: version which represents the change of the internals of this container. """ return self.dialogs.get_version() async def check_for_version_change_async(self, dialog_context: DialogContext): """ - dialog context. - cancellationToken. - task. + :param dialog_context: dialog context. + :return: task. Checks to see if a containers child dialogs have changed since the current dialog instance was started. diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_context.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_context.py index 16b38f33b..b10a63978 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_context.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_context.py @@ -362,13 +362,13 @@ async def emit_event( """ Searches for a dialog with a given ID. Emits a named event for the current dialog, or someone who started it, to handle. - Name of the event to raise. - Value to send along with the event. - Flag to control whether the event should be bubbled to its parent if not handled locally. - Defaults to a value of `true`. - Whether the event is emitted from a leaf node. - The cancellation token. - True if the event was handled. + :param name: Name of the event to raise. + :param value: Value to send along with the event. + :param bubble: Flag to control whether the event should be bubbled to its parent if not handled locally. + Defaults to a value of `True`. + :param from_leaf: Whether the event is emitted from a leaf node. + :param cancellationToken: The cancellation token. + :return: True if the event was handled. """ try: # Initialize event diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_instance.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_instance.py index c90f85c7c..0d4e3400b 100644 --- a/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_instance.py +++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_instance.py @@ -20,7 +20,7 @@ def __init__( :var self.state: The instance's persisted state. :vartype self.state: :class:`typing.Dict[str, object]` """ - self.id = id or None # pylint: disable=invalid-name + self.id = id # pylint: disable=invalid-name self.state = state or {}