Skip to content
This repository was archived by the owner on Mar 22, 2021. It is now read-only.

Commit

Permalink
Print help string on help command (#16)
Browse files Browse the repository at this point in the history
* Add unit test framework and integration test description

* Add docker-compose file and integration test infrastructure

* Remove debug print from message handler

* Add linting and testing workflows

* Add linter config

* Fix flake errors

* Add docformatter dependency

* Fix isort

* Fix integration test script

* Finish unit tests in bot_test.py (#11)

* Message handler unit tests (#12)

* Finish unit tests in bot_test.py

* WIP

* Remove debug prints

* Finish unit tests (#13)

* Add integration tests (#14)

* Make integration tests multiprocess

* Add the first integration tests

* Add uncommitted files'

* Simplify code to expect reply

* Fix weird merge errors

* Fix unit test

* Add file and reaction test

* Minor adjustments

* Finish tests, add one retry to expect_reply

* Increase docker startup wait

* Try de-flaking sleep test

* Add random delay before each test to prevent overloading

* Remove random delay, explicitly sort thread order

* Add help string function, support multiple listeners

Finishes `Plugin.get_help_string` and supports registering multiple
listeners on the same function.

* Add docstrings to ExamplePlugin functions

* Update snapshot with docstrings

* prevent global mock

* Crank up integration test response timeout

* Try changing reply_start from async to sync

* Give the bot 5 seconds to start up

* Wait 5 seconds between starting bot and sending messages

* Use new docker image with different tokens
  • Loading branch information
jneeven authored Feb 20, 2021
1 parent dcb26a4 commit c8242a6
Show file tree
Hide file tree
Showing 7 changed files with 140 additions and 15 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@
**/*.egg-info
**/*.log
**/*.lock
test.py
1 change: 1 addition & 0 deletions dev-requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ isort==5.7.0
pytest==6.2.2
pytest-xdist==2.2.1
pytype==2021.1.28
snapshottest==0.6.0
58 changes: 52 additions & 6 deletions snaketalk/plugins/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,14 @@ def __init__(
needs_mention: bool = False,
allowed_users: Sequence[str] = [],
):
# If another Function was passed, keep track of all these siblings.
# We later use them to register not only the outermost Function, but also any
# stacked ones.
self.siblings = []
while isinstance(function, Function):
self.siblings.append(function)
function = function.function

self.function = function
self.is_coroutine = asyncio.iscoroutinefunction(function)
self.name = function.__qualname__
Expand Down Expand Up @@ -88,6 +94,11 @@ def __call__(self, message: Message, *args):
return self.function(self.plugin, message, *args)


def spaces(num: int):
"""Utility function to easily indent strings."""
return " " * num


class Plugin(ABC):
"""A Plugin is a self-contained class that defines what functions should be executed
given different inputs.
Expand All @@ -108,8 +119,10 @@ def initialize(self, driver: Driver):
for attribute in dir(self):
attribute = getattr(self, attribute)
if isinstance(attribute, Function):
attribute.plugin = self
self.listeners[attribute.matcher].append(attribute)
# Register this function and any potential siblings
for function in [attribute] + attribute.siblings:
function.plugin = self
self.listeners[function.matcher].append(function)

return self

Expand Down Expand Up @@ -140,9 +153,42 @@ async def call_function(
self.driver.threadpool.add_task(function, (message, *groups))

def get_help_string(self):
# TODO: implement help string
return ""

string = f"Plugin {self.__class__.__name__} has the following functions:\n"
string += "----\n"
for matcher, functions in self.listeners.items():
for function in functions:
string += f"- `{matcher.pattern}`:\n"
func = function.function
# Add a docstring
doc = func.__doc__ or "No description provided."
string += f"{spaces(8)}{doc}\n"

if any(
[
function.needs_mention,
function.direct_only,
function.allowed_users,
]
):
# Print some information describing the usage settings.
string += "Additional information:\n"
if function.needs_mention:
string += (
f"{spaces(4)}- Needs to either mention @{self.driver.username}"
" or be a direct message.\n"
)
if function.direct_only:
string += f"{spaces(4)}- Needs to be a direct message.\n"

if function.allowed_users:
string += f"{spaces(4)}- Restricted to certain users.\n"

string += "----\n"

return string

@listen_to("^help$", needs_mention=True)
@listen_to("^!help$")
async def help_request(self, message: Message):
async def help(self, message: Message):
"""Prints the list of functions registered on every active plugin."""
self.driver.reply_to(message, self.get_help_string())
13 changes: 12 additions & 1 deletion snaketalk/plugins/example.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,12 @@ class ExamplePlugin(Plugin):

@listen_to("^admin$", direct_only=True, allowed_users=["admin", "root"])
async def users_access(self, message: Message):
"""Showcases a function with restricted access."""
self.driver.reply_to(message, "Access allowed!")

@listen_to("^busy|jobs$", re.IGNORECASE, needs_mention=True)
async def busy_reply(self, message: Message):
"""Show the number of budy worker threads."""
"""Show the number of busy worker threads."""
busy = self.driver.threadpool.get_busy_workers()
self.driver.reply_to(
message,
Expand All @@ -26,11 +27,14 @@ async def busy_reply(self, message: Message):

@listen_to("^hello_channel$", needs_mention=True)
async def hello_channel(self, message: Message):
"""Responds with a channel post rather than a reply."""
self.driver.create_post(channel_id=message.channel_id, message="hello channel!")

# Needs admin permissions
@listen_to("^hello_ephemeral$", needs_mention=True)
async def hello_ephemeral(self, message: Message):
"""Tries to reply with an ephemeral message, if the bot has system admin
permissions."""
try:
self.driver.reply_to(message, "hello sender!", ephemeral=True)
except mattermostdriver.exceptions.NotEnoughPermissions:
Expand All @@ -40,10 +44,12 @@ async def hello_ephemeral(self, message: Message):

@listen_to("^hello_react$", re.IGNORECASE, needs_mention=True)
async def hello_react(self, message: Message):
"""Responds by giving a thumbs up reaction."""
self.driver.react_to(message, "+1")

@listen_to("^hello_file$", re.IGNORECASE, needs_mention=True)
async def hello_file(self, message: Message):
"""Responds by uploading a text file."""
file = Path("/tmp/hello.txt")
file.write_text("Hello from this file!")
self.driver.reply_to(message, "Here you go", file_paths=[file])
Expand All @@ -70,6 +76,7 @@ async def hello_webhook(self, message: Message):

@listen_to("^!info$")
async def info(self, message: Message):
"""Responds with the user info of the requesting user."""
user_email = self.driver.get_user_info(message.user_id)["email"]
reply = (
f"TEAM-ID: {message.team_id}\nUSERNAME: {message.sender_name}\n"
Expand All @@ -81,10 +88,14 @@ async def info(self, message: Message):

@listen_to("^ping$", re.IGNORECASE, needs_mention=True)
async def ping_reply(self, message: Message):
"""Pong."""
self.driver.reply_to(message, "pong")

@listen_to("^sleep ([0-9]+)", needs_mention=True)
async def sleep_reply(self, message: Message, seconds: str):
"""Sleeps for the specified number of seconds.
Arguments:
- seconds: How many seconds to sleep for."""
self.driver.reply_to(message, f"Okay, I will be waiting {seconds} seconds.")
await asyncio.sleep(int(seconds))
self.driver.reply_to(message, "Done!")
45 changes: 37 additions & 8 deletions tests/unit_tests/plugins_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ def wrapped(instance, message, arg1, arg2):
new_f = Function(f, matcher=re.compile("a"))
assert new_f.function is wrapped
assert new_f.matcher.pattern == "a"
assert f in new_f.siblings

@mock.patch("snaketalk.driver.Driver.user_id", "qmw86q7qsjriura9jos75i4why")
def test_needs_mention(self): # noqa
Expand Down Expand Up @@ -109,24 +110,45 @@ def fake_reply(message, text):
# Used in the plugin tests below
class FakePlugin(Plugin):
@listen_to("pattern")
def my_function(self, message):
def my_function(self, message, needs_mention=True):
"""This is the docstring of my_function."""
pass

@listen_to("direct_pattern", direct_only=True, allowed_users=["admin"])
def direct_function(self, message):
pass

@listen_to("async_pattern")
@listen_to("another_async_pattern", direct_only=True)
async def my_async_function(self, message):
"""Async function docstring."""
pass


class TestPlugin:
def test_initialize(self):
p = FakePlugin().initialize(Driver())
# Simply test whether the function was registered properly
assert p.listeners[FakePlugin.my_function.matcher] == [FakePlugin.my_function]
# Test whether the function was registered properly
assert p.listeners[re.compile("pattern")] == [
FakePlugin.my_function,
]

# This function should be registered twice, once for each listener
assert len(p.listeners[re.compile("async_pattern")]) == 1
assert (
p.listeners[re.compile("async_pattern")][0].function
== FakePlugin.my_async_function.function
)

assert len(p.listeners[re.compile("another_async_pattern")]) == 1
assert (
p.listeners[re.compile("another_async_pattern")][0].function
== FakePlugin.my_async_function.function
)

@mock.patch("snaketalk.driver.ThreadPool.add_task")
def test_call_function(self, add_task):
driver = Driver()
p = FakePlugin().initialize(driver)
p = FakePlugin().initialize(Driver())

# Since this is not an async function, a task should be added to the threadpool
message = create_message(text="pattern")
Expand All @@ -139,6 +161,13 @@ def test_call_function(self, add_task):

# Since this is an async function, it should be called directly through asyncio.
message = create_message(text="async_pattern")
p.my_async_function.function = mock.Mock(wraps=p.my_async_function.function)
asyncio.run(p.call_function(FakePlugin.my_async_function, message, groups=[]))
p.my_async_function.function.assert_called_once_with(p, message)
with mock.patch.object(p.my_async_function, "function") as mock_function:
asyncio.run(
p.call_function(FakePlugin.my_async_function, message, groups=[])
)
mock_function.assert_called_once_with(p, message)

def test_help_string(self, snapshot):
p = FakePlugin().initialize(Driver())
# Compare the help string with the snapshotted version.
snapshot.assert_match(p.get_help_string())
Empty file.
37 changes: 37 additions & 0 deletions tests/unit_tests/snapshots/snap_plugins_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# -*- coding: utf-8 -*-
# snapshottest: v1 - https://goo.gl/zC4yUc
from __future__ import unicode_literals

from snapshottest import Snapshot


snapshots = Snapshot()

snapshots['TestPlugin.test_help_string 1'] = '''Plugin FakePlugin has the following functions:
----
- `direct_pattern`:
No description provided.
Additional information:
- Needs to be a direct message.
- Restricted to certain users.
----
- `^help$`:
Prints the list of functions registered on every active plugin.
Additional information:
- Needs to either mention @ or be a direct message.
----
- `^!help$`:
Prints the list of functions registered on every active plugin.
----
- `async_pattern`:
Async function docstring.
----
- `another_async_pattern`:
Async function docstring.
Additional information:
- Needs to be a direct message.
----
- `pattern`:
This is the docstring of my_function.
----
'''

0 comments on commit c8242a6

Please sign in to comment.