Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

DialogTestClient for Py #341

Merged
merged 3 commits into from
Oct 16, 2019
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -80,10 +80,8 @@ def __init__(
recipient=ChannelAccount(id="bot", name="Bot"),
conversation=ConversationAccount(id="Convo1"),
)
if self.template is not None:
self.template.service_url = self.template.service_url
self.template.conversation = self.template.conversation
self.template.channel_id = self.template.channel_id
if conversation is not None and conversation.channel_id is not None:
self.template.channel_id = conversation.channel_id

async def send_activities(self, context, activities: List[Activity]):
"""
Expand Down
84 changes: 84 additions & 0 deletions libraries/botbuilder-testing/README.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@

============================
BotBuilder-Testing SDK for Python
============================

.. image:: https://fuselabs.visualstudio.com/SDK_v4/_apis/build/status/Python/SDK_v4-Python-CI?branchName=master
:target: https://fuselabs.visualstudio.com/SDK_v4/_apis/build/status/Python/SDK_v4-Python-CI
:align: right
:alt: Azure DevOps status for master branch
.. image:: https://badge.fury.io/py/botbuilder-testing.svg
:target: https://badge.fury.io/py/botbuilder-testing
:alt: Latest PyPI package version

Some helper classes useful for testing bots built with Microsoft BotBuilder.


How to Install
==============

.. code-block:: python

pip install botbuilder-testing


Documentation/Wiki
==================

You can find more information on the botbuilder-python project by visiting our `Wiki`_.

Requirements
============

* `Python >= 3.7.0`_


Source Code
===========
The latest developer version is available in a github repository:
https://github.com/Microsoft/botbuilder-python/


Contributing
============

This project welcomes contributions and suggestions. Most contributions require you to agree to a
Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us
the rights to use your contribution. For details, visit https://cla.microsoft.com.

When you submit a pull request, a CLA-bot will automatically determine whether you need to provide
a CLA and decorate the PR appropriately (e.g., label, comment). Simply follow the instructions
provided by the bot. You will only need to do this once across all repos using our CLA.

This project has adopted the `Microsoft Open Source Code of Conduct`_.
For more information see the `Code of Conduct FAQ`_ or
contact `[email protected]`_ with any additional questions or comments.

Reporting Security Issues
=========================

Security issues and bugs should be reported privately, via email, to the Microsoft Security
Response Center (MSRC) at `[email protected]`_. You should
receive a response within 24 hours. If for some reason you do not, please follow up via
email to ensure we received your original message. Further information, including the
`MSRC PGP`_ key, can be found in
the `Security TechCenter`_.

License
=======

Copyright (c) Microsoft Corporation. All rights reserved.

Licensed under the MIT_ License.

.. _Wiki: https://github.com/Microsoft/botbuilder-python/wiki
.. _Python >= 3.7.0: https://www.python.org/downloads/
.. _MIT: https://github.com/Microsoft/vscode/blob/master/LICENSE.txt
.. _Microsoft Open Source Code of Conduct: https://opensource.microsoft.com/codeofconduct/
.. _Code of Conduct FAQ: https://opensource.microsoft.com/codeofconduct/faq/
.. [email protected]: mailto:[email protected]
.. [email protected]: mailto:[email protected]
.. _MSRC PGP: https://technet.microsoft.com/en-us/security/dn606155
.. _Security TechCenter: https://github.com/Microsoft/vscode/blob/master/LICENSE.txt

.. <https://technet.microsoft.com/en-us/security/default>`_
8 changes: 8 additions & 0 deletions libraries/botbuilder-testing/botbuilder/testing/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License.

from .dialog_test_client import DialogTestClient
from .dialog_test_logger import DialogTestLogger


__all__ = ["DialogTestClient", "DialogTestLogger"]
15 changes: 15 additions & 0 deletions libraries/botbuilder-testing/botbuilder/testing/about.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License.

import os


__title__ = "botbuilder-testing"
__version__ = (
os.environ["packageVersion"] if "packageVersion" in os.environ else "4.4.0b1"
)
__uri__ = "https://www.github.com/Microsoft/botbuilder-python"
__author__ = "Microsoft"
__description__ = "Microsoft Bot Framework Bot Builder"
__summary__ = "Microsoft Bot Framework Bot Builder SDK for Python."
__license__ = "MIT"
122 changes: 122 additions & 0 deletions libraries/botbuilder-testing/botbuilder/testing/dialog_test_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License.

from typing import List, Union

from botbuilder.core import (
AutoSaveStateMiddleware,
ConversationState,
MemoryStorage,
Middleware,
StatePropertyAccessor,
TurnContext,
)
from botbuilder.core.adapters import TestAdapter
from botbuilder.dialogs import Dialog, DialogSet, DialogTurnResult, DialogTurnStatus
from botbuilder.schema import Activity, ConversationReference


class DialogTestClient:
"""A client for testing dialogs in isolation."""

def __init__(
self,
channel_or_adapter: Union[str, TestAdapter],
daveta marked this conversation as resolved.
Show resolved Hide resolved
target_dialog: Dialog,
initial_dialog_options: object = None,
middlewares: List[Middleware] = None,
conversation_state: ConversationState = None,
):
"""
Create a DialogTestClient to test a dialog without having to create a full-fledged adapter.

```python
client = DialogTestClient("test", MY_DIALOG, MY_OPTIONS)
reply = await client.send_activity("first message")
self.assertEqual(reply.text, "first reply", "reply failed")
```

:param channel_or_adapter: The channel Id or test adapter to be used for the test.
For channel Id, use 'emulator' or 'test' if you are uncertain of the channel you are targeting.
Otherwise, it is recommended that you use the id for the channel(s) your bot will be using and
write a test case for each channel.
Or, a test adapter instance can be used.
:type channel_or_adapter: Union[str, TestAdapter]
:param target_dialog: The dialog to be tested. This will be the root dialog for the test client.
:type target_dialog: Dialog
:param initial_dialog_options: (Optional) additional argument(s) to pass to the dialog being started.
:type initial_dialog_options: object
:param middlewares: (Optional) The test adapter to use. If this parameter is not provided, the test client will
use a default TestAdapter.
:type middlewares: List[Middleware]
:param conversation_state: (Optional) A ConversationState instance to use in the test client.
:type conversation_state: ConversationState
"""
self.dialog_turn_result: DialogTurnResult = None
self.conversation_state: ConversationState = (
ConversationState(MemoryStorage())
if conversation_state is None
else conversation_state
)
dialog_state = self.conversation_state.create_property("DialogState")
self._callback = self._get_default_callback(
target_dialog, initial_dialog_options, dialog_state
)

if isinstance(channel_or_adapter, str):
conversation_reference = ConversationReference(
channel_id=channel_or_adapter
)
self.test_adapter = TestAdapter(self._callback, conversation_reference)
self.test_adapter.use(
AutoSaveStateMiddleware().add(self.conversation_state)
)
else:
self.test_adapter = channel_or_adapter

self._add_user_middlewares(middlewares)

async def send_activity(self, activity) -> Activity:
"""
Send an activity into the dialog.

:param activity: an activity potentially with text.
:type activity:
:return: a TestFlow that can be used to assert replies etc.
:rtype: Activity
"""
await self.test_adapter.receive_activity(activity)
return self.test_adapter.get_next_activity()

def get_next_reply(self) -> Activity:
"""
Get the next reply waiting to be delivered (if one exists)

:return: a TestFlow that can be used to assert replies etc.
:rtype: Activity
"""
return self.test_adapter.get_next_activity()

def _get_default_callback(
self,
target_dialog: Dialog,
initial_dialog_options: object,
dialog_state: StatePropertyAccessor,
):
async def default_callback(turn_context: TurnContext) -> None:
dialog_set = DialogSet(dialog_state)
dialog_set.add(target_dialog)

dialog_context = await dialog_set.create_context(turn_context)
self.dialog_turn_result = await dialog_context.continue_dialog()
if self.dialog_turn_result.status == DialogTurnStatus.Empty:
self.dialog_turn_result = await dialog_context.begin_dialog(
target_dialog.id, initial_dialog_options
)

return default_callback

def _add_user_middlewares(self, middlewares: List[Middleware]) -> None:
if middlewares is not None:
for middleware in middlewares:
self.test_adapter.use(middleware)
103 changes: 103 additions & 0 deletions libraries/botbuilder-testing/botbuilder/testing/dialog_test_logger.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License.

import json
import logging
import time
import uuid
from datetime import datetime
from typing import Awaitable, Callable, List

from botbuilder.core import Middleware, TurnContext
from botbuilder.schema import Activity, ActivityTypes, ResourceResponse


class DialogTestLogger(Middleware):
"""
A middleware to output incoming and outgoing activities as json strings to the console during
unit tests.
"""

def __init__(
self,
log_func: Callable[..., None] = None,
json_indent: int = 4,
daveta marked this conversation as resolved.
Show resolved Hide resolved
time_func: Callable[[], float] = None,
):
"""
Initialize a new instance of the dialog test logger.

:param log_func: A callable method or object that can log a message,
default to `logging.getLogger(__name__).info`.
:type log_func: Callable[..., None]
:param json_indent: An indent for json output, default indent is 4.
:type json_indent: int
:param time_func: A time function to record time spans, default to `time.monotonic`.
:type time_func: Callable[[], float]
"""
self._log = logging.getLogger(__name__).info if log_func is None else log_func
self._stopwatch_state_key = f"stopwatch.{uuid.uuid4()}"
self._json_indent = json_indent
self._time_func = time.monotonic if time_func is None else time_func

async def on_turn(
self, context: TurnContext, logic: Callable[[TurnContext], Awaitable]
):
context.turn_state[self._stopwatch_state_key] = self._time_func()
await self._log_incoming_activity(context, context.activity)
context.on_send_activities(self._send_activities_handler)
await logic()

async def _log_incoming_activity(
self, context: TurnContext, activity: Activity
) -> None:
self._log("")
if context.activity.type == ActivityTypes.message:
self._log("User: Text = %s", context.activity.text)
else:
self._log_activity_as_json(actor="User", activity=activity)

timestamp = self._get_timestamp()
self._log("-> ts: %s", timestamp)

async def _send_activities_handler(
self,
context: TurnContext,
activities: List[Activity],
next_send: Callable[[], Awaitable[None]],
) -> List[ResourceResponse]:
for activity in activities:
await self._log_outgoing_activity(context, activity)
responses = await next_send()
return responses

async def _log_outgoing_activity(
self, context: TurnContext, activity: Activity
) -> None:
self._log("")
start_time = context.turn_state[self._stopwatch_state_key]
if activity.type == ActivityTypes.message:
message = (
f"Bot: Text = {activity.text}\r\n"
f" Speak = {activity.speak}\r\n"
f" InputHint = {activity.input_hint}"
)
self._log(message)
else:
self._log_activity_as_json(actor="Bot", activity=activity)

now = self._time_func()
mms = int(round((now - start_time) * 1000))
timestamp = self._get_timestamp()
self._log("-> ts: %s elapsed %d ms", timestamp, mms)

def _log_activity_as_json(self, actor: str, activity: Activity) -> None:
activity_dict = activity.serialize()
activity_json = json.dumps(activity_dict, indent=self._json_indent)
message = f"{actor}: Activity = {activity.type}\r\n" f"{activity_json}"
self._log(message)

@staticmethod
def _get_timestamp() -> str:
timestamp = datetime.now().strftime("%H:%M:%S")
return timestamp
4 changes: 4 additions & 0 deletions libraries/botbuilder-testing/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
botbuilder-schema>=4.4.0b1
botbuilder-core>=4.4.0b1
botbuilder-dialogs>=4.4.0b1
aiounittest>=1.1.0
Loading