Skip to content

Commit a942ffb

Browse files
committed
feat(dialogs): Incorporate DialogDependencies
Dialog dependencies is a feature in - [botbuilder-js](https://github.com/microsoft/botbuilder-js/blob/87d0ad1a889a38396e761f10614c78a4526590d1/libraries/botbuilder-dialogs/src/dialogSet.ts#L169), - and in [botbuilder-dotnet](https://github.com/microsoft/botbuilder-dotnet/blob/bc17a5db2c717fec0d4c109d884bea88d29f75cb/libraries/Microsoft.Bot.Builder.Dialogs/DialogSet.cs#L147). To maintain feature parity, and because this functionality is extremely useful this PR brings it to `botbuilder-python`. Tecnically in the `botbuilder-js` and dotnet implementations dialogs that are found to conflict get stored with a different ID, but I am sure that would likely be a breaking change (and IMO a very counter-intuitive way of working). If you are adding dialog dependencies and they conflict, the logic as it was would be retained. Note that, although by making it a `@runtime_checkable` Protocol with DialogDependencies one can check `isinstance(object, DialogDependencies)`, the recommendation from the python docs is: "check against a runtime-checkable protocol can be surprisingly slow" — [python typing](https://docs.python.org/3/library/typing.html#typing.Protocol) For that reason we use `hasattr` and `callable` instead. Also, note that protocol is avaialble from python 3.8 onwards, which is already the minimum version for this library. The purpose of using Iterable instead of List is just to keep it generic. Since python has the concepts of `tuple`, `List`, etc and to keep it as a structural type.
1 parent e2015eb commit a942ffb

File tree

4 files changed

+102
-2
lines changed

4 files changed

+102
-2
lines changed

libraries/botbuilder-dialogs/botbuilder/dialogs/__init__.py

+2
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from .component_dialog import ComponentDialog
1010
from .dialog_container import DialogContainer
1111
from .dialog_context import DialogContext
12+
from .dialog_dependencies import DialogDependencies
1213
from .dialog_event import DialogEvent
1314
from .dialog_events import DialogEvents
1415
from .dialog_instance import DialogInstance
@@ -35,6 +36,7 @@
3536
"ComponentDialog",
3637
"DialogContainer",
3738
"DialogContext",
39+
"DialogDependencies",
3840
"DialogEvent",
3941
"DialogEvents",
4042
"DialogInstance",
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
from typing import Iterable, Protocol, runtime_checkable
2+
3+
from .dialog import Dialog
4+
5+
6+
@runtime_checkable
7+
class DialogDependencies(Protocol):
8+
"""Protocol for dialogs that have dependencies on other dialogs.
9+
10+
If implemented, when the dialog is added to a DialogSet all of its dependencies will be added as well.
11+
"""
12+
13+
def get_dependencies(self) -> Iterable[Dialog]:
14+
"""Returns an iterable of the dialogs that this dialog depends on.
15+
16+
:return: The dialog dependencies."""

libraries/botbuilder-dialogs/botbuilder/dialogs/dialog_set.py

+11-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
# Licensed under the MIT License.
33
import inspect
44
from hashlib import sha256
5-
from typing import Dict
5+
from typing import Dict, TYPE_CHECKING
66

77
from botbuilder.core import (
88
NullTelemetryClient,
@@ -14,6 +14,9 @@
1414
from .dialog import Dialog
1515
from .dialog_state import DialogState
1616

17+
if TYPE_CHECKING:
18+
from .dialog_context import DialogContext
19+
1720

1821
class DialogSet:
1922
def __init__(self, dialog_state: StatePropertyAccessor = None):
@@ -92,6 +95,8 @@ def add(self, dialog: Dialog):
9295
)
9396

9497
if dialog.id in self._dialogs:
98+
if self._dialogs[dialog.id] == dialog:
99+
return self
95100
raise TypeError(
96101
"DialogSet.add(): A dialog with an id of '%s' already added."
97102
% dialog.id
@@ -100,6 +105,11 @@ def add(self, dialog: Dialog):
100105
# dialog.telemetry_client = this._telemetry_client;
101106
self._dialogs[dialog.id] = dialog
102107

108+
# Automatically add any child dependencies the dialog might have, see DialogDependencies.
109+
if hasattr(dialog, "get_dependencies") and callable(dialog.get_dependencies):
110+
for child in dialog.get_dependencies():
111+
self.add(child)
112+
103113
return self
104114

105115
async def create_context(self, turn_context: TurnContext) -> "DialogContext":

libraries/botbuilder-dialogs/tests/test_dialog_set.py

+73-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@
22
# Licensed under the MIT License.
33

44
import aiounittest
5-
from botbuilder.dialogs import DialogSet, ComponentDialog, WaterfallDialog
5+
from botbuilder.dialogs import (
6+
DialogDependencies,
7+
DialogSet,
8+
ComponentDialog,
9+
WaterfallDialog,
10+
)
611
from botbuilder.core import ConversationState, MemoryStorage, NullTelemetryClient
712

813

@@ -90,6 +95,73 @@ def test_dialogset_nulltelemetryset(self):
9095
)
9196
)
9297

98+
def test_dialogset_raises_on_repeated_id(self):
99+
convo_state = ConversationState(MemoryStorage())
100+
dialog_state_property = convo_state.create_property("dialogstate")
101+
dialog_set = DialogSet(dialog_state_property)
102+
103+
dialog_set.add(WaterfallDialog("A"))
104+
with self.assertRaises(TypeError):
105+
dialog_set.add(WaterfallDialog("A"))
106+
107+
self.assertTrue(dialog_set.find_dialog("A") is not None)
108+
109+
def test_dialogset_idempotenticy_add(self):
110+
convo_state = ConversationState(MemoryStorage())
111+
dialog_state_property = convo_state.create_property("dialogstate")
112+
dialog_set = DialogSet(dialog_state_property)
113+
dialog_a = WaterfallDialog("A")
114+
dialog_set.add(dialog_a)
115+
dialog_set.add(dialog_a)
116+
117+
async def test_dialogset_dependency_tree_add(self):
118+
class MyDialog(WaterfallDialog, DialogDependencies):
119+
def __init__(self, *args, **kwargs):
120+
super().__init__(*args, **kwargs)
121+
self._dependencies = []
122+
123+
def add_dependency(self, dialog):
124+
self._dependencies.append(dialog)
125+
126+
def get_dependencies(self):
127+
return self._dependencies
128+
129+
convo_state = ConversationState(MemoryStorage())
130+
dialog_state_property = convo_state.create_property("dialogstate")
131+
dialog_set = DialogSet(dialog_state_property)
132+
133+
dialog_a = MyDialog("A")
134+
dialog_b = MyDialog("B")
135+
dialog_c = MyDialog("C")
136+
dialog_d = MyDialog("D")
137+
dialog_e = MyDialog("E")
138+
dialog_i = MyDialog("I")
139+
140+
dialog_a.add_dependency(dialog_b)
141+
142+
# Multi-hierarchy should be OK
143+
dialog_b.add_dependency(dialog_d)
144+
dialog_b.add_dependency(dialog_e)
145+
146+
# circular dependencies should be OK
147+
dialog_c.add_dependency(dialog_d)
148+
dialog_d.add_dependency(dialog_c)
149+
150+
assert dialog_set.find_dialog(dialog_a.id) is None
151+
dialog_set.add(dialog_a)
152+
153+
for dialog in [
154+
dialog_a,
155+
dialog_b,
156+
dialog_c,
157+
dialog_d,
158+
dialog_e,
159+
]:
160+
self.assertTrue(dialog_set.find_dialog(dialog.id) is dialog)
161+
self.assertTrue(await dialog_set.find(dialog.id) is dialog)
162+
163+
assert dialog_set.find_dialog(dialog_i.id) is None
164+
93165
# pylint: disable=pointless-string-statement
94166
"""
95167
This test will be enabled when telematry tests are fixed for DialogSet telemetry

0 commit comments

Comments
 (0)