Commit 108e846e authored by Guillaume Binet's avatar Guillaume Binet

Merge branch 'master' into 6.0

parents 65ea2902 fcc606a8
v6.0.0 (2019-03-23)
- TestBot: Implement inject_mocks method (#1235)
- TestBot: Add multi-line command test support (#1238)
- Added optional room arg to inroom
- Adds ability to go back to a previous room
- Pass telegram message id to the callback
- Remove extra spaces in uptime output
- Fix/backend import error messages (#1248)
- Add docker support for installing package dependencies (#1245)
- variable name typo (#1244)
- Fix invalid variable name (#1241)
- sanitize comma quotation marks too (#1236)
- Fix missing string formatting in "Command not found" output (#1259)
- Fix webhook test to not call fixture directly
- fix: arg_botcmd decorator now can be used as plain method
- setup: removing dnspython
- pin markdown <3.0 because safe is deprecated
v6.0.0-alpha (2018-06-10)
......@@ -59,6 +59,7 @@ Chat servers support
- `Gitter support <>`_ (See `instructions <>`__)
- `Matrix <>`_ (See `instructions <>`__)
- `Mattermost <>`_ (See `instructions <>`__)
- `RocketChat <>`_ (See `instructions <>`__)
- `Skype <>`_ (See `instructions <>`__)
- `TOX <>`_ (See `instructions <>`__)
- `VK <>`_ (See `instructions <>`__)
......@@ -215,10 +216,10 @@ As an example, this is all it takes to create a "Hello, world!" plugin for Errbo
.. code:: python
from errbot import BotPlugin, botcmd
class Hello(BotPlugin):
"""Example 'Hello, world!' plugin for Errbot"""
def hello(self, msg, args):
"""Return the phrase "Hello, world!" to you"""
......@@ -32,5 +32,5 @@ If you don't specify any predicate when you build your flow, every single step i
wait for the user to execute one of the possible commands at every step to advance along the graph.
Predicates can be used to trigger a command automatically. Predicates are simple functions saying to Errbot,
"this command has enough in the context to be able to executed without any user intervention".
"this command has enough in the context to be able to execute without any user intervention".
At any time if a predicate is verified after a step is executed, Errbot will proceed and execute the next step.
......@@ -119,7 +119,40 @@ What we need now is get access to the instance of our plugin itself. Fortunately
result = plugin.mycommand_helper()
assert result == expected
There we go, we first grab out plugin thanks to a helper method on :mod:`~errbot.plugin_manager` and then simply execute the method and compare what we get with what we expect. You can also access `@classmethod` or `@staticmethod` methods this way, you just don't have to.
There we go, we first grab our plugin using a helper method on :mod:`~errbot.plugin_manager` and then simply execute the method and compare the result with the expected result. You can also access `@classmethod` or `@staticmethod` methods this way, but you don't have to.
Sometimes a helper method will be making HTTP or API requests which might not be possible to test directly. In that case, we need to mock that particular method and make it return the expected value without actually making the request.
.. code-block:: python
URL = ''
class MyPlugin(BotPlugin):
def mycommand(self, message, args):
return self.mycommand_helper()
def mycommand_helper(self):
return (requests.get(URL).status_code)
What we need now is to somehow replace the method making the request with our mock object and `inject_mocks` method comes in handy.
Refer `unittest.mock <>`_ for more information about mock.
.. code-block:: python
from unittest.mock import MagicMock
extra_plugin_dir = '.'
def test_mycommand_helper(testbot):
helper_mock = MagicMock(return_value='200')
mock_dict = {'mycommand_helper': helper_mock}
testbot.inject_mocks('MyPlugin', mock_dict)
expected = '200'
result = testbot.pop_message()
assert result == expected
......@@ -28,7 +28,7 @@ webroute = route # this allows plugins to expose dynamic webpages on Errbot emb
# Same happens with quotations marks, which are required for parsing
# complex strings in arguments
# Map of characters to sanitized equivalents
ARG_BOTCMD_CHARACTER_REPLACEMENTS = {'—': '--', '“': '"', '”': '"'}
ARG_BOTCMD_CHARACTER_REPLACEMENTS = {'—': '--', '“': '"', '”': '"', '’': '\'', '‘': '\''}
class ArgumentParseError(Exception):
......@@ -335,6 +335,9 @@ def arg_botcmd(*args,
The `unpack_args=False` only needs to be specified once, on the bottom `@args_botcmd`
argparse_args = args
if len(args) >= 1 and callable(args[0]):
argparse_args = args[1:]
def decorator(func):
......@@ -394,14 +397,14 @@ def arg_botcmd(*args,
# alias it so we can update it's arguments below
wrapper = func
wrapper._err_command_parser.add_argument(*args, **kwargs)
wrapper._err_command_parser.add_argument(*argparse_args, **kwargs)
wrapper.__doc__ = wrapper._err_command_parser.format_help()
fmt = wrapper._err_command_parser.format_usage()
wrapper._err_command_syntax = fmt[len('usage: ') + len(wrapper._err_command_parser.prog) + 1:-1]
return wrapper
return decorator
return decorator(args[0]) if callable(args[0]) else decorator
def _tag_webhook(func, uri_rule, methods, form_param, raw):
......@@ -34,7 +34,7 @@ class BackendPluginManager:
if == plugin_name:
self.plugin_info = plugin_info
raise PluginNotFoundException(f'Could not find the plugin named {plugin_name} in {all_plugin_paths}.')
raise PluginNotFoundException(f'Could not find the plugin named {plugin_name} in {all_plugins_paths}.')
def load_plugin(self) -> Any:
plugin_path = self.plugin_info.location.parent
......@@ -23,7 +23,7 @@ try:
except ImportError:
log.exception("Could not start the HipChat backend")
"You need to install the hipchat support in order to use the HipChat.\n "
"You need to install the hipchat support in order to use the HipChat backend.\n "
"You should be able to install this package using:\n"
"pip install errbot[hipchat]"
......@@ -6,12 +6,13 @@ import re
import sys
import pprint
from functools import lru_cache
from typing import BinaryIO
from markdown import Markdown
from markdown.extensions.extra import ExtraExtension
from markdown.preprocessors import Preprocessor
from errbot.backends.base import Message, Presence, ONLINE, AWAY, Room, RoomError, RoomDoesNotExistError, \
from errbot.backends.base import Identifier, Message, Presence, ONLINE, AWAY, Room, RoomError, RoomDoesNotExistError, \
UserDoesNotExistError, RoomOccupant, Person, Card, Stream
from errbot.core import ErrBot
from errbot.utils import split_string_after
......@@ -25,7 +26,7 @@ try:
except ImportError:
log.exception("Could not start the Slack back-end")
"You need to install the slackclient support in order to use the Slack.\n"
"You need to install the slackclient support in order to use the Slack backend.\n"
"You can do `pip install errbot[slack]` to install it"
......@@ -673,8 +674,12 @@ class SlackBackend(ErrBot):
log.exception(f'An exception occurred while trying to send the following message '
f'to {to_humanreadable}: {msg.body}.')
def _slack_upload(self, stream):
"""Perform upload defined in a stream."""
def _slack_upload(self, stream: Stream) -> None:
Performs an upload defined in a stream
:param stream: Stream object
:return: None
resp = self.api_call('files.upload', data={
......@@ -689,11 +694,26 @@ class SlackBackend(ErrBot):
except Exception:
log.exception(f'Upload of {} to {stream.identifier.channelname} failed.')
def send_stream_request(self, identifier, fsource, name='file', size=None, stream_type=None):
"""Starts a file transfer. For Slack, the size and stream_type are unsupported"""
stream = Stream(identifier, fsource, name, size, stream_type)
def send_stream_request(self,
user: Identifier,
fsource: BinaryIO,
name: str = None,
size: int = None,
stream_type: str = None) -> Stream:
Starts a file transfer. For Slack, the size and stream_type are unsupported
:param user: is the identifier of the person you want to send it to.
:param fsource: is a file object you want to send.
:param name: is an optional filename for it.
:param size: not supported in Slack backend
:param stream_type: not supported in Slack backend
:return Stream: object on which you can monitor the progress of it.
stream = Stream(user, fsource, name, size, stream_type)
log.debug('Requesting upload of %s to %s (size hint: %d, stream type: %s).',
name, identifier.channelname, size, stream_type)
name, user.channelname, size, stream_type)
self.thread_pool.apply_async(self._slack_upload, (stream,))
return stream
......@@ -274,6 +274,7 @@ class TelegramBackend(ErrBot):
) = room
message_instance.extras['message_id'] = message.message_id
def send_message(self, msg):
......@@ -2,6 +2,7 @@ import importlib
import logging
import sys
import unittest
import textwrap
from os.path import sep, abspath
from queue import Queue
from tempfile import mkdtemp
......@@ -450,8 +451,10 @@ class TestBot(object):
def zap_queues(self):
def assertCommand(self, command, response, timeout=5):
def assertCommand(self, command, response, timeout=5, dedent=False):
"""Assert the given command returns the given response"""
if dedent:
command = '\n'.join(textwrap.dedent(command).splitlines()[1:])
msg =
assert response in msg, f'{response} not in {msg}.'
......@@ -461,6 +464,25 @@ class TestBot(object):
assert 'not found' not in
def inject_mocks(self, plugin_name: str, mock_dict: dict):
"""Inject mock objects into the plugin
mock_dict = {
'field_1': obj_1,
'field_2': obj_2,
testbot.inject_mocks(HelloWorld, mock_dict)
assert 'blah' in testbot.exec_command('!hello')
plugin =
if plugin is None:
raise Exception(f'"{plugin_name}" is not loaded.')
for field, mock_obj in mock_dict.items():
if not hasattr(plugin, field):
raise ValueError(f'No property/attribute named "{field}" attached.')
setattr(plugin, field, mock_obj)
class FullStackTest(unittest.TestCase, TestBot):
......@@ -383,7 +383,9 @@ class TextBackend(ErrBot):
raise ValueError('A Room name must start by #.')
text_room = TextRoom(room[1:], self)
if text_room not in self._rooms:
self._rooms.insert(0, text_room)
self._rooms.insert(0, self._rooms.pop(self._rooms.index(text_room)))
return text_room
......@@ -21,7 +21,7 @@ try:
except ImportError:
log.exception("Could not start the XMPP backend")
If you intend to use the XMPP backend pleas install the support for XMPP with:
If you intend to use the XMPP backend please install the support for XMPP with:
pip install errbot[XMPP]
......@@ -103,12 +103,12 @@ def setup_bot(backend_name: str, logger, config, restore=None) -> ErrBot:
if hasattr(config, 'SENTRY_TRANSPORT') and isinstance(config.SENTRY_TRANSPORT, tuple):
mod = importlib.import_module(config.SENTRY_TRANSPORT[1])
transport = getattr(mod, config.SENTRY_TRANSPORT[0])
mod = importlib.import_module(config.SENTRY_TRANSPORT[1])
transport = getattr(mod, config.SENTRY_TRANSPORT[0])
sentryhandler = SentryHandler(config.SENTRY_DSN,
sentryhandler = SentryHandler(config.SENTRY_DSN,
sentryhandler = SentryHandler(config.SENTRY_DSN, level=config.SENTRY_LOGLEVEL)
......@@ -361,7 +361,7 @@ class BotPlugin(BotPluginBase):
2. in case of an array or tuple, it will assume array members of the
same type of first element of the template (no mix typed is supported)
In case of validation error it should raise a errbot.utils.ValidationException
In case of validation error it should raise a errbot.ValidationException
:param configuration: the configuration to be checked.
......@@ -484,7 +484,7 @@ class ErrBot(Backend, StoreMixin):
matches = set(matches)
if matches:
alternatives = ('" or "' + self.bot_config.BOT_PREFIX).join(matches)
msg += '\n\nDid you mean "{self.bot_config.BOT_PREFIX}{alternatives}" ?'
msg += f'\n\nDid you mean "{self.bot_config.BOT_PREFIX}{alternatives}" ?'
return msg
def inject_commands_from(self, instance_to_inject):
......@@ -70,7 +70,7 @@ class Health(BotPlugin):
u = format_timedelta( - self._bot.startup_time)
since = self._bot.startup_time.strftime('%A, %b %d at %H:%M')
return f"I've been up for {args} {u} (since {since})."
return f"I've been up for {u} (since {since})."
# noinspection PyUnusedLocal
......@@ -2,6 +2,7 @@ from ast import literal_eval
from pprint import pformat
import os
import shutil
import logging
from errbot import BotPlugin, botcmd
from errbot.plugin_manager import PluginConfigurationException, PluginActivationException
......@@ -303,3 +304,14 @@ class Plugins(BotPlugin):
return f'Error activating plugin: {pae}'
return self._bot.plugin_manager.unblacklist_plugin(args)
@botcmd(admin_only=True, template='plugin_info')
def plugin_info(self, _, args):
"""Gives you a more technical information about a specific plugin."""
pm = self._bot.plugin_manager
if args not in pm.get_all_plugin_names():
return (f"{args} isn't a valid plugin name. The current plugins are:\n"
return {'plugin_info': pm.plugin_infos[args],
'plugin': pm.plugins[args],
'logging': logging}
#### Plugin info from {{ plugin_info.location }}
name: {{ }}
module: {{ plugin_info.module }}
full_module_path: {{ plugin_info.location.parent / (plugin_info.module + '.py') }}
core: {{ plugin_info.core }}
{% if plugin_info.dependencies %}
dependencies: {{ ', '.join(plugin_info.dependencies) }}
{% endif %}
class: {{ plugin.__module__ + "." + plugin.__class__.__name__ }}
storage namespace: {{ plugin.namespace }}
log destination: {{ }}
log level: {{ logging.getLevelName(plugin.log.level) }}
{% if plugin.keys %}
**storage content**
Key | Value
-------------------- | -----------------------
{% for key, value in plugin.items() %}{{ key.ljust(20) }} | `{{ value }}`
{% endfor %}
{% endif %}
{% if plugin.config %}
**config content**
Key | Value
-------------------- | -----------------------
{% for key, value in plugin.config.items() %}{{ key.ljust(20) }} | `{{ value }}`
{% endfor %}
{% endif %}
......@@ -43,12 +43,17 @@ class TextModeCmds(BotPlugin):
def inroom(self, msg, _):
def inroom(self, msg, args):
This puts you in a room with the bot.
self._bot._inroom = True
return f'Joined Room {self._bot._rooms[0]}.'
if args:
room = args
room = '#testroom'
return f'Joined Room {room}.'
def inperson(self, msg, _):
......@@ -63,6 +63,10 @@ def install_packages(req_path: Path):
Return an exc_info if it fails otherwise None.
def is_docker():
with open('/proc/1/cgroup') as d:
return 'docker' in'Installing packages from "%s".', req_path)
# use sys.executable explicitly instead of just 'pip' because depending on how the bot is deployed
# 'pip' might not be available on PATH: for example when installing errbot on a virtualenv and
......@@ -75,6 +79,9 @@ def install_packages(req_path: Path):
if hasattr(sys, 'real_prefix') or (hasattr(sys, 'base_prefix') and (sys.base_prefix != sys.prefix)):
# this is a virtualenv, so we can use it directly
subprocess.check_call(pip_cmdline + ['install', '--requirement', str(req_path)])
elif is_docker():
# this is a docker container, so we can use it directly
subprocess.check_call(pip_cmdline + ['install', '--requirement', str(req_path)])
# otherwise only install it as a user package
subprocess.check_call(pip_cmdline + ['install', '--user', '--requirement', str(req_path)])
......@@ -10,7 +10,7 @@ from markdown.postprocessors import Postprocessor
from markdown.inlinepatterns import SubstituteTagPattern
from markdown.extensions.fenced_code import FencedBlockPreprocessor
from ansi.color import fg, bg, fx
from ansi.colour import fg, bg, fx
from html import unescape
......@@ -498,7 +498,7 @@ class AnsiPreprocessor(FencedBlockPreprocessor):
if m:
code = self._escape('code'))
placeholder =, safe=False)
placeholder =
text = f'{text[:m.start()]}\n{placeholder}\n{text[m.end():]}'
......@@ -37,11 +37,10 @@ deps = ['webtest',
'markdown', # rendering stuff
'markdown<3.0', # rendering stuff, 3.0+ deprecates 'safe()'
'pygments-markdown-lexer>=0.1.0.dev39', # sytax coloring to debug md
if not PY37_OR_GREATER:
......@@ -6,6 +6,7 @@ from os import path, mkdir
from queue import Empty
from shutil import rmtree
from tempfile import mkdtemp
from mock import MagicMock
import pytest
import tarfile
......@@ -355,3 +356,31 @@ def test_command_not_found_with_space_in_bot_prefix(testbot):
testbot.bot_config.BOT_PREFIX = '! '
assert 'Command "blah" not found.' in testbot.exec_command('! blah')
assert 'Command "blah" / "blah toto" not found.' in testbot.exec_command('! blah toto')
def test_mock_injection(testbot):
helper_mock = MagicMock()
helper_mock.return_value = 'foo'
mock_dict = {'helper_method': helper_mock}
testbot.inject_mocks('Dummy', mock_dict)
assert 'foo' in testbot.exec_command('!baz')
def test_multiline_command(testbot):
!bar title
first line of body
second line of body
'!bar title\nfirst line of body\nsecond line of body',
def test_plugin_info_command(testbot):
output = testbot.exec_command('!plugin info Help')
assert 'name: Help' in output
assert 'module: help' in output
assert '' in output
assert 'log level: NOTSET' in output
......@@ -29,3 +29,16 @@ class DummyTest(BotPlugin):
def run_lots_of_subcommands(self, msg, args):
"""Tests multiple subcommands"""
return args
def helper_method(self, arg):
return arg
def baz(self, msg, args):
"""Tests mock injection method"""
return self.helper_method('baz')
def bar(self, msg, args):
"""This runs bar."""
return msg
from __future__ import absolute_import
from errbot import BotPlugin, botcmd, Command, botmatch
from errbot import BotPlugin, botcmd, Command, botmatch, arg_botcmd
def say_foo(plugin, msg, args):
......@@ -7,7 +7,7 @@ def say_foo(plugin, msg, args):
class Dyna(BotPlugin):
"""Just a test plugin to see if synamic plugin API works.
"""Just a test plugin to see if dynamic plugin API works.
def add_simple(self, _, _1):
......@@ -23,6 +23,23 @@ class Dyna(BotPlugin):
self.destroy_dynamic_plugin('simple with special#')
return 'removed'
def add_arg(self, _, _1):
cmd1_name = 'echo_to_me'
cmd1 = Command(lambda plugin, msg, args: 'string to echo is %s' % args.positional_arg,
cmd_type=arg_botcmd, cmd_args=('positional_arg',),
cmd_kwargs={'unpack_args': False, 'name': cmd1_name},
self.create_dynamic_plugin('arg', (cmd1,), doc='documented')
return 'added'
def remove_arg(self, msg, args):
return 'removed'
def add_re(self, _, _1):
re1 = Command(lambda plugin, msg, match: 'fffound',
......@@ -12,6 +12,14 @@ def test_simple(testbot):
assert 'Command "say_foo" not found' in testbot.exec_command('!say_foo')
def test_arg(testbot):
assert 'added' in testbot.exec_command('!add_arg')
assert 'string to echo is string_to_echo' in testbot.exec_command('!echo_to_me string_to_echo')
assert 'removed' in testbot.exec_command('!remove_arg')
assert ('Command "echo_to_me" / "echo_to_me string_to_echo" not found'
in testbot.exec_command('!echo_to_me string_to_echo'))
def test_re(testbot):
assert 'added' in testbot.exec_command('!add_re')
assert 'fffound' in testbot.exec_command('I said cheese')
......@@ -45,12 +45,11 @@ def wait_for_server(port: int):
def webhook_testbot(request):
tbot = testbot(request)
tbot.push_message("!plugin config Webserver {'HOST': 'localhost', 'PORT': %s, 'SSL': None}" % WEBSERVER_PORT)
def webhook_testbot(request, testbot):
testbot.push_message("!plugin config Webserver {'HOST': 'localhost', 'PORT': %s, 'SSL': None}" % WEBSERVER_PORT)
return tbot
return testbot
def test_not_configured_url_returns_404(webhook_testbot):
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment