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

Commit

Permalink
Add unit- and integration tests (#6)
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

* Wait 5 seconds between starting bot and sending messages

* Use new docker image with different tokens
  • Loading branch information
jneeven authored Feb 18, 2021
1 parent 3246f90 commit e07364c
Show file tree
Hide file tree
Showing 28 changed files with 858 additions and 70 deletions.
15 changes: 15 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
version: 2
updates:
- package-ecosystem: pip
directory: "/"
schedule:
interval: weekly
open-pull-requests-limit: 10
allow:
- dependency-type: direct
- dependency-type: indirect
- package-ecosystem: github-actions
directory: "/"
schedule:
interval: weekly
open-pull-requests-limit: 10
39 changes: 39 additions & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
name: Linting

on:
push:
branches:
- main
pull_request: {}

jobs:
lint:
runs-on: ubuntu-latest

steps:
- name: Cancel Outdated Runs
uses: styfle/[email protected]
with:
access_token: ${{ github.token }}
- uses: actions/checkout@v2
- name: Set up Python 3.8
uses: actions/setup-python@v2
with:
python-version: 3.8
- uses: actions/cache@v2
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip3-${{ hashFiles('*requirements.txt') }}
- run: pip install wheel
- name: Install dependencies
run: pip install -e .[dev]
- name: Run Flake8
run: flake8
- name: Black code style
run: black . --check --target-version py38 --exclude '\.mypy_cache/|\.venv/|env/|(.*/)*snapshots/|.pytype/'
- name: Docstring formatting
run: docformatter -c -r . --wrap-summaries 88 --wrap-descriptions 88
- name: Check import order with isort
run: isort . --check --diff
- name: PyType type-check
run: pytype -j auto .
55 changes: 55 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
name: Tests

on:
push:
branches:
- main
pull_request: {}

jobs:
unit_test:
runs-on: ubuntu-latest

steps:
- name: Cancel Outdated Runs
uses: styfle/[email protected]
with:
access_token: ${{ github.token }}
- uses: actions/checkout@v2
- name: Set up Python 3.8
uses: actions/setup-python@v2
with:
python-version: 3.8
- uses: actions/cache@v2
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip3-${{ hashFiles('*requirements.txt') }}
- name: Install dependencies
run: pip install -e .[dev]
- name: Check package version conflicts
run: pip check
- name: Run unit tests
run: pytest -vv tests/unit_tests -n auto

integration_test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up Python 3.8
uses: actions/setup-python@v2
with:
python-version: 3.8
- uses: actions/cache@v2
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip3-${{ hashFiles('*requirements.txt') }}
- name: Install dependencies
run: pip install -e .[dev]
- name: Launch test server
working-directory: tests/integration_tests
run: docker-compose up -d && sleep 45
- name: Print docker info
run: docker ps -a
- name: Run integration tests
working-directory: tests/integration_tests
run: pytest . -vv -n auto
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
**/__pycache__
*.egg-info
**/*.egg-info
**/*.log
**/*.lock
8 changes: 8 additions & 0 deletions dev-requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
black==20.8b1
docformatter==1.4
filelock==3.0.12
flake8==3.8.4
isort==5.7.0
pytest==6.2.1
pytest-xdist==2.2.0
pytype==2021.1.28
42 changes: 42 additions & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
[flake8]

# Black compatibility.
ignore = E203,E501,W503
exclude = build,dist,env,venv,.env,.venv,.pytype,**/snapshots,ignored


[isort]

profile = black
# Don't misclassify larq as a first-party import.
known_third_party = larq
skip =
build
dist
venv
.env
.venv
.git
.pytype
ignored
skip_glob = **/snapshots


[pytype]

inputs = .
output = .pytype
exclude =
dist
env
venv
.env
.venv
.pytype
**/snapshots
tests
**/*_test.py
ignored
# Keep going past errors to analyse as many files as possible.
keep_going = True
python_version = 3.8
12 changes: 8 additions & 4 deletions setup.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
from setuptools import find_packages, setup

install_requires = open("requirements.txt").read().splitlines()

def requires(filename: str):
return open(filename).read().splitlines()


setup(
name="snaketalk",
Expand All @@ -10,11 +13,12 @@
license="MIT",
description="A simple python bot for Mattermost",
keywords="chat bot mattermost python",
# long_description=open("README.md").read(),
# long_description_content_type="text/markdown",
long_description=open("README.md").read(),
long_description_content_type="text/markdown",
platforms=["Linux"],
packages=find_packages(),
install_requires=install_requires,
install_requires=requires("requirements.txt"),
extras_require={"dev": requires("dev-requirements.txt")},
classifiers=[
"Development Status :: 5 - Production/Stable",
"License :: OSI Approved :: MIT License",
Expand Down
14 changes: 14 additions & 0 deletions snaketalk/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
from snaketalk.bot import Bot
from snaketalk.message import Message
from snaketalk.plugins import ExamplePlugin, Function, Plugin, listen_to
from snaketalk.settings import Settings

__all__ = [
"Bot",
"Message",
"Function",
"Plugin",
"listen_to",
"ExamplePlugin",
"Settings",
]
30 changes: 18 additions & 12 deletions snaketalk/bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

from snaketalk.driver import Driver
from snaketalk.message_handler import MessageHandler
from snaketalk.plugins import DefaultPlugin, Plugin
from snaketalk.plugins import ExamplePlugin, Plugin
from snaketalk.settings import Settings


Expand All @@ -15,9 +15,7 @@ class Bot:
and settings. To start the bot, simply call bot.run().
"""

instance = None

def __init__(self, settings=Settings(), plugins=[DefaultPlugin()]):
def __init__(self, settings=Settings(), plugins=[ExamplePlugin()]):
logging.basicConfig(
**{
"format": "[%(asctime)s] %(message)s",
Expand All @@ -29,11 +27,11 @@ def __init__(self, settings=Settings(), plugins=[DefaultPlugin()]):
self.settings = settings
self.driver = Driver(
{
"url": settings.BOT_URL,
"port": 443,
"url": settings.MATTERMOST_URL,
"port": settings.MATTERMOST_PORT,
"token": settings.BOT_TOKEN,
"scheme": settings.SCHEME,
"verify": settings.SSL_VERIFY,
"timeout": 0.5,
}
)
self.driver.login()
Expand All @@ -48,12 +46,20 @@ def _initialize_plugins(self, plugins: Sequence[Plugin]):
return plugins

def run(self):
logging.info(f"Starting bot {self.__class__.__name__}.")
try:
self.driver.threadpool.start()
for plugin in self.plugins:
plugin.on_start()
self.message_handler.start()
except KeyboardInterrupt as e:
# Shutdown the running plugins
for plugin in self.plugins:
plugin.on_stop()
# Stop the threadpool
self.driver.threadpool.stop()
self.stop()
raise e

def stop(self):
logging.info("Stopping bot.")
# Shutdown the running plugins
for plugin in self.plugins:
plugin.on_stop()
# Stop the threadpool
self.driver.threadpool.stop()
32 changes: 25 additions & 7 deletions snaketalk/driver.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,32 +17,36 @@ def __init__(self, num_workers: int):
- num_workers: int, how many threads to run simultaneously.
"""
self.num_workers = num_workers
self.alive = True
self.alive = False
self._queue = queue.Queue()
self._busy_workers = queue.Queue()

# Spawn num_workers threads that will wait for work to be added to the queue
self._threads = []
for _ in range(self.num_workers):
worker = threading.Thread(target=self.handle_work)
self._threads.append(worker)
worker.start()

def add_task(self, *args):
self._queue.put(args)

def get_busy_workers(self):
return self._busy_workers.qsize()

def start(self):
self.alive = True
# Spawn num_workers threads that will wait for work to be added to the queue
for _ in range(self.num_workers):
worker = threading.Thread(target=self.handle_work)
self._threads.append(worker)
worker.start()

def stop(self):
"""Signals all threads that they should stop and waits for them to finish."""
self.alive = False
# Signal every thread that it's time to stop
for _ in range(self.num_workers):
self._queue.put((self._stop_thread, tuple()))
# Wait for each of them to finish
print("Stopping threadpool, waiting for threads...")
for thread in self._threads:
thread.join()
print("Threadpool stopped.")

def _stop_thread(self):
"""Used to stop individual threads."""
Expand Down Expand Up @@ -121,6 +125,20 @@ def create_post(
}
)

def get_thread(self, post_id: str):
"""Wrapper around driver.posts.get_thread, which for some reason returns
duplicate and wrongly ordered entries in the ordered list."""
thread_info = self.posts.get_thread(post_id)

id_stamps = []
for id, post in thread_info["posts"].items():
id_stamps.append((id, int(post["create_at"])))
# Sort the posts by their timestamps
sorted_stamps = sorted(id_stamps, key=lambda x: x[-1])
# Overwrite the order with the sorted list
thread_info["order"] = list([id for id, stamp in sorted_stamps])
return thread_info

def get_user_info(self, user_id: str):
"""Returns a dictionary of user info."""
return self.users.get_user(user_id)
Expand Down
18 changes: 2 additions & 16 deletions snaketalk/message_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,23 +17,15 @@ def __init__(
settings: Settings,
plugins: Sequence[Plugin],
ignore_own_messages=True,
filter_actions=[
"posted",
"added_to_team",
"leave_team",
"user_added",
"user_removed",
],
):
"""The MessageHandler class takes care of the connection to mattermost and
calling the appropriate response function to each event."""
self.driver = driver
self.settings = settings
self.filter_actions = filter_actions
self.ignore_own_messages = ignore_own_messages
self.plugins = plugins

self._name_matcher = re.compile(rf"^@{self.driver.username}\:?\s?")
self._name_matcher = re.compile(rf"^@?{self.driver.username}\:?\s?")

# Collect the listeners from all plugins
self.listeners = defaultdict(list)
Expand Down Expand Up @@ -63,14 +55,11 @@ def _should_ignore(self, message):
if message.sender_name.lower()
in (name.lower() for name in self.settings.IGNORE_USERS)
else False
) or (self.ignore_own_messages and message.user_id == self.driver.user_id)
) or (self.ignore_own_messages and message.sender_name == self.driver.username)

async def handle_event(self, data):
post = json.loads(data)
event_action = post.get("event")
if event_action not in self.filter_actions:
return

if event_action == "posted":
await self._handle_post(post)

Expand All @@ -84,13 +73,10 @@ async def _handle_post(self, post):
post["data"]["post"]["message"] = self._name_matcher.sub(
"", post["data"]["post"]["message"]
)

message = Message(post)
if self._should_ignore(message):
return

print(json.dumps(post, indent=4))

# Find all the listeners that match this message, and have their plugins handle
# the rest.
tasks = []
Expand Down
4 changes: 2 additions & 2 deletions snaketalk/plugins/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from snaketalk.plugins.base import Function, Plugin, listen_to
from snaketalk.plugins.default import DefaultPlugin
from snaketalk.plugins.example import ExamplePlugin

__all__ = ["Function", "Plugin", "listen_to", "DefaultPlugin"]
__all__ = ["Function", "Plugin", "listen_to", "ExamplePlugin"]
Loading

0 comments on commit e07364c

Please sign in to comment.