예제 #1
0
class TestPluginManager:
    def setup_method(self):
        mock_cardinal = self.cardinal = Mock(spec=CardinalBot)
        mock_cardinal.nickname = 'Cardinal'
        mock_cardinal.event_manager = self.event_manager = \
            EventManager(mock_cardinal)

        self.blacklist = {}

        plugins_directory = os.path.abspath(os.path.join(
            os.path.dirname(os.path.realpath(os.path.abspath(__file__))),
            '..',
            'cardinal/fixtures/fake_plugins',
        ))
        self.plugin_manager = PluginManager(
            mock_cardinal,
            [],
            self.blacklist,
            _plugin_module_import_prefix='fake_plugins',
            _plugin_module_directory=plugins_directory,
        )

    def assert_load_success(self,
                            plugins,
                            assert_callbacks_is_empty=True,
                            assert_commands_is_empty=True,
                            assert_blacklist_is_empty=True,
                            assert_config_is_none=True,
                            plugin_manager=None):
        plugin_manager = plugin_manager or self.plugin_manager
        failed_plugins = plugin_manager.load(plugins)

        # regardless of the whether plugins was a string or list, the rest of
        # this function requires it to be a list
        if not isinstance(plugins, list):
            plugins = [plugins]

        assert failed_plugins == []
        assert len(list(plugin_manager.plugins.keys())) == len(plugins)

        for name in plugins:
            # class name for plugins must be Test(CamelCaseName)Plugin
            # e.g. an_example -> TestAnExamplePlugin
            class_ = 'Test'
            name_pieces = name.split('_')
            for name_piece in name_pieces:
                class_ += name_piece[0].upper() + name_piece[1:].lower()
            class_ += 'Plugin'

            # check that everything was set correctly
            assert name in list(plugin_manager.plugins.keys())
            assert plugin_manager.plugins[name]['name'] == name
            assert isinstance(
                plugin_manager.plugins[name]['instance'],
                object,
            )
            if assert_commands_is_empty:
                assert plugin_manager.plugins[name]['commands'] == []
            if assert_callbacks_is_empty:
                assert plugin_manager.plugins[name]['callbacks'] == []
                assert plugin_manager.plugins[name]['callback_ids'] == {}
            if assert_config_is_none:
                assert plugin_manager.plugins[name]['config'] is None
            if assert_blacklist_is_empty:
                assert plugin_manager.plugins[name]['blacklist'] == []

    def assert_load_failed(self, plugins, plugin_manager=None):
        plugin_manager = plugin_manager or self.plugin_manager
        failed_plugins = plugin_manager.load(plugins)

        # regardless of the whether plugins was a string or list, the rest of
        # this function requires it to be a list
        if not isinstance(plugins, list):
            plugins = [plugins]

        assert failed_plugins == plugins
        assert plugin_manager.plugins == {}

    def test_constructor(self):
        manager = PluginManager(Mock(), [], [])
        assert len(manager.plugins) == 0

    @patch.object(PluginManager, 'load')
    def test_constructor_loads_plugins(self, load):
        plugins = ['foo', 'bar']
        PluginManager(Mock(), plugins, [])
        load.assert_called_with(plugins)

    @pytest.mark.parametrize("plugins", [
        'a string',
        12345,
        0.0,
        object(),
    ])
    def test_constructor_plugins_not_a_list_typeerror(self, plugins):
        with pytest.raises(TypeError):
            PluginManager(Mock(), plugins)

    @pytest.mark.parametrize("plugins", [
        12345,
        0.0,
        object(),
    ])
    def test_load_plugins_not_a_list_or_string_typeerror(self, plugins):
        with pytest.raises(TypeError):
            self.plugin_manager.load(plugins)

    @pytest.mark.parametrize("plugins", [
        # This plugin won't be found in the plugins directory
        'nonexistent',
        # This plugin is missing a setup() function
        'setup_missing',
        # This plugin's setup() function takes three arguments
        'setup_too_many_arguments',
        # This plugin's entrypoint isn't callable
        'entrypoint_invalid',
    ])
    def test_load_invalid(self, plugins):
        self.assert_load_failed(plugins)

    @pytest.mark.parametrize("plugins", [
        'valid',
        'entrypoint',
        ['valid'],  # test list format
        # This plugin has both a config.yaml and config.json which used to be
        # disallowed, but now config.yaml is ignored
        'config_ambiguous',
    ])
    def test_load_valid(self, plugins):
        self.assert_load_success(plugins, assert_config_is_none=False)

    def test_load_cardinal_passed(self):
        name = 'setup_one_argument'
        self.assert_load_success(name)
        assert self.plugin_manager.plugins[name]['instance'].cardinal is \
            self.cardinal

    def test_load_config_passed(self):
        name = 'setup_two_arguments'
        self.assert_load_success(name, assert_config_is_none=False)
        assert self.plugin_manager.plugins[name]['instance'].cardinal is \
            self.cardinal
        assert self.plugin_manager.plugins[name]['instance'].config == \
            {'test': True}

    def test_load_config_passed_opposite_order(self):
        name = 'setup_config_cardinal_order'
        self.assert_load_success(name, assert_config_is_none=False)
        assert self.plugin_manager.plugins[name]['instance'].cardinal is \
            self.cardinal
        assert self.plugin_manager.plugins[name]['instance'].config == \
            {'test': True}

    def test_load_invalid_json_config(self):
        name = 'config_invalid_json'
        self.assert_load_success(name)  # no error for some reason

        # invalid json should be ignored
        assert self.plugin_manager.plugins[name]['config'] is None

    def test_get_config_unloaded_plugin(self):
        name = 'nonexistent_plugin'
        with pytest.raises(exceptions.ConfigNotFoundError):
            self.plugin_manager.get_config(name)

    def test_get_config_plugin_without_config(self):
        name = 'valid'
        self.assert_load_success(name)

        with pytest.raises(exceptions.ConfigNotFoundError):
            self.plugin_manager.get_config(name)

    def test_get_config_json(self):
        name = 'config_valid_json'
        self.assert_load_success(name, assert_config_is_none=False)

        assert self.plugin_manager.get_config(name) == {'test': True}

    def test_get_config_yaml_ignored(self):
        name = 'config_valid_yaml'
        self.assert_load_success(name, assert_config_is_none=False)

        with pytest.raises(exceptions.ConfigNotFoundError):
            self.plugin_manager.get_config(name)

    def test_plugin_iteration(self):
        plugins = [
            'setup_one_argument',
            'setup_two_arguments',
            'valid',
        ]
        self.assert_load_success(plugins, assert_config_is_none=False)

        for plugin in self.plugin_manager:
            assert plugin == self.plugin_manager.plugins[plugin['name']]

    def test_reload_valid_succeeds(self):
        name = 'valid'
        plugins = [name]

        # first load is not a reload
        self.assert_load_success(plugins)

        # second load is
        self.assert_load_success(plugins)

        # and so on...
        self.assert_load_success(plugins)

    def test_reload_exception_in_close_succeeds(self):
        name = 'close_raises_exception'
        plugins = [name]

        self.assert_load_success(plugins)

        # should reload successfully despite bad close()
        self.assert_load_success(plugins)

    def test_reload_reregisters_event(self):
        name = 'registers_event'
        plugins = [name]

        assert 'test.event' not in self.event_manager.registered_events

        self.assert_load_success(plugins)
        assert 'test.event' in self.event_manager.registered_events

        self.assert_load_success(plugins)
        assert 'test.event' in self.event_manager.registered_events

    def test_reload_for_error_in_constructor_succeeds(self):
        """Cardinal has a bug where constructor exceptions brick a plugin.

        Basically, the plugin won't be reloadable without a restart of the bot
        itself. This is obviously problematic for plugin development.

        This test aims to solve the problem and act as a regression test.
        """
        plugin = 'valid'
        with tempdir('cardinal_fixtures') as fixture_dir:
            with open(os.path.join(fixture_dir, '__init__.py'), 'w') as f:
                pass

            plugins_dir = os.path.join(fixture_dir, 'test_plugins')
            os.mkdir(plugins_dir)
            with open(os.path.join(plugins_dir, '__init__.py'), 'w') as f:
                pass

            plugin_dir = os.path.join(
                plugins_dir,
                plugin,
            )
            os.mkdir(plugin_dir)
            with open(os.path.join(plugin_dir, '__init__.py'), 'w') as f:
                pass

            # Write a plugin that will load successfully
            with open(
                os.path.join(
                    FIXTURE_PATH,
                    'fake_plugins',
                    'valid',
                    'plugin.py',
                ),
                'r'
            ) as f:
                valid_plugin = f.read()

            with open(os.path.join(plugin_dir, 'plugin.py'), 'w') as f:
                f.write(valid_plugin)

            # Make sure the fake_plugins dir is in the import path
            sys.path.insert(0, os.path.join(fixture_dir))
            try:
                # Load the plugin successfully
                plugin_manager = PluginManager(
                    self.cardinal,
                    [],
                    self.blacklist,
                    _plugin_module_import_prefix='test_plugins',
                    _plugin_module_directory=plugins_dir)

                self.assert_load_success(
                    [plugin],
                    plugin_manager=plugin_manager,
                )

                # Now overwrite with a plugin that will fail during load
                with open(os.path.join(plugin_dir, 'plugin.py'), 'w') as f:
                    f.write("""
class TestPlugin:
    def __init__(self):
        self.x = y  # this should raise


def setup():
    return TestPlugin()
""")

                # Verify the load failed
                self.assert_load_failed([plugin], plugin_manager)

                # Now back to a valid version
                with open(os.path.join(plugin_dir, 'plugin.py'), 'w') as f:
                    f.write(valid_plugin)

                # and then make sure the valid version does load successfully
                self.assert_load_success(
                    [plugin],
                    plugin_manager=plugin_manager,
                )
            finally:
                sys.path.pop(0)

    @pytest.mark.parametrize("plugins", [
        12345,
        0.0,
        object(),
    ])
    def test_unload_plugins_not_a_list_or_string_typeerror(self, plugins):
        with pytest.raises(TypeError):
            self.plugin_manager.unload(plugins)

    def test_unload_plugins_never_loaded_plugin_fails(self):
        name = 'test_never_loaded_plugin'
        plugins = [name]

        assert self.plugin_manager.plugins == {}

        failed_plugins = self.plugin_manager.unload(plugins)

        assert failed_plugins == plugins
        assert self.plugin_manager.plugins == {}

    def test_unload_exception_in_close_fails(self):
        name = 'close_raises_exception'
        plugins = [name]

        self.assert_load_success(plugins)

        failed_plugins = self.plugin_manager.unload(plugins)

        # Failed plugins represents a list of plugins that errored on unload
        # but the plugin should still be removed from the PluginManager.
        #
        # FIXME Unfortunately, this means that a plugin might fail to remove
        # its callbacks from an event, and then be inaccessible by Cardinal.
        assert failed_plugins == plugins
        assert self.plugin_manager.plugins == {}

    @pytest.mark.parametrize("plugins", [
        # This plugin contains no close() method
        'valid',
        ['valid'],  # test list format
        # This plugin has a no-op close() method
        'close_no_arguments',
    ])
    def test_unload_valid_succeeds(self, plugins):
        self.assert_load_success(plugins)

        failed_plugins = self.plugin_manager.unload(plugins)

        assert failed_plugins == []
        assert list(self.plugin_manager.plugins.keys()) == []

    def test_unload_passes_cardinal(self):
        plugin = 'close_one_argument'

        self.assert_load_success(plugin)
        instance = self.plugin_manager.plugins[plugin]['instance']

        failed_plugins = self.plugin_manager.unload(plugin)

        assert failed_plugins == []
        assert list(self.plugin_manager.plugins.keys()) == []

        # Our close() method will set module.cardinal for us to inspect
        assert instance.cardinal is self.cardinal

    def test_unload_too_many_arguments_in_close(self):
        plugin = 'close_too_many_arguments'

        self.assert_load_success(plugin)
        instance = self.plugin_manager.plugins[plugin]['instance']

        failed_plugins = self.plugin_manager.unload(plugin)

        assert failed_plugins == [plugin]
        assert list(self.plugin_manager.plugins.keys()) == []

        # Our close() method will set module.called to True if called
        assert instance.called is False

    def test_unload_all(self):
        self.assert_load_success([
            'valid',
            'close_no_arguments',
            'close_too_many_arguments',
        ])

        assert len(self.plugin_manager.plugins) == 3

        # Doesn't return what failed to unload cleanly, but should unload
        # everything regardless
        self.plugin_manager.unload_all()

        assert self.plugin_manager.plugins == {}

    def test_event_callback_registered(self):
        name = 'event_callback'
        event = 'irc.raw'

        self.assert_load_success(name, assert_callbacks_is_empty=False)
        instance = self.plugin_manager.plugins[name]['instance']

        # test that plugin manager is tracking the callback
        assert len(self.plugin_manager.plugins[name]['callback_ids']) == 1
        assert self.plugin_manager.plugins[name]['callbacks'] == [
            {
                'event_names': [event],
                'method': instance.irc_raw_callback,
            }
        ]

        # test that event manager had callback registered
        self.event_manager.register(event, 1)

        message = 'this is a test message'
        self.event_manager.fire(event, message)

        assert instance.cardinal is self.cardinal
        assert instance.messages == [message]

        self.event_manager.fire(event, message)
        assert instance.messages == [message, message]

    def test_event_callback_unregistered(self):
        name = 'event_callback'
        event = 'irc.raw'

        self.assert_load_success(name, assert_callbacks_is_empty=False)
        instance = self.plugin_manager.plugins[name]['instance']

        # make sure an event is sent to the callback
        self.event_manager.register(event, 1)
        message = 'this is a test message'
        self.event_manager.fire(event, message)
        assert instance.messages == [message]

        # unload and make sure no more events are sent
        self.plugin_manager.unload(name)

        self.event_manager.fire(event, message)
        assert instance.messages == [message]

    def test_load_bad_callback_fails(self):
        name = 'event_callback'
        event = 'irc.raw'

        # this will cause registration to fail, as our callback only takes 1
        # param other than cardinal
        self.event_manager.register(event, 2)

        self.assert_load_failed([name])

    @defer.inlineCallbacks
    def test_load_multiple_callbacks_one_fails(self):
        name = 'multiple_event_callbacks_one_fails'
        event = 'foo'

        self.event_manager.register(event, 0)
        self.assert_load_failed([name])

        # if this actually fires the one good callback we should get an
        # exception (we don't expect one)
        res = yield self.event_manager.fire(event)

        # this should return False if no callbacks fired
        assert res is False

    def test_load_command_registration(self):
        name = 'commands'

        self.assert_load_success(name, assert_commands_is_empty=False)
        instance = self.plugin_manager.plugins[name]['instance']

        assert self.plugin_manager.plugins[name]['commands'] == [
            instance.command1,
            instance.command2,
            instance.regex_command,
        ]

    def test_itercommands(self):
        name = 'commands'

        self.assert_load_success(name, assert_commands_is_empty=False)
        instance = self.plugin_manager.plugins[name]['instance']
        commands = [
            instance.command1,
            instance.command2,
            instance.regex_command,
        ]

        for command in self.plugin_manager.itercommands():
            commands.remove(command)

        assert commands == []

    @defer.inlineCallbacks
    def test_call_command_no_regex_match(self):
        yield self.plugin_manager.call_command(('nick', 'ident', 'host'),
                                               "#channel",
                                               "this isn't a command")

    @defer.inlineCallbacks
    def test_call_command_no_command_match(self):
        # FIXME: this isn't really an error...
        with pytest.raises(exceptions.CommandNotFoundError):
            yield self.plugin_manager.call_command(('nick', 'ident', 'host'),
                                                   "#channel",
                                                   ".command")

    @defer.inlineCallbacks
    def test_command_called(self):
        name = 'commands'

        self.assert_load_success(name, assert_commands_is_empty=False)
        instance = self.plugin_manager.plugins[name]['instance']

        user = ('user', 'ident', 'vhost')
        channel = '#channel'
        message = '.command1 foobar'

        expected_calls = []
        assert instance.command1_calls == expected_calls
        assert instance.command2_calls == []
        assert instance.regex_command_calls == []

        expected_calls.append((self.cardinal, user, channel, message))
        yield self.plugin_manager.call_command(user, channel, message)
        assert instance.command1_calls == expected_calls
        assert instance.command2_calls == []
        assert instance.regex_command_calls == []

        message = '.command1_alias bar bar bar'
        expected_calls.append((self.cardinal, user, channel, message))
        yield self.plugin_manager.call_command(user, channel, message)
        assert instance.command1_calls == expected_calls
        assert instance.command2_calls == []
        assert instance.regex_command_calls == []

        message = 'this shouldnt trigger'
        yield self.plugin_manager.call_command(user, channel, message)
        assert instance.command1_calls == expected_calls
        assert instance.command2_calls == []
        assert instance.regex_command_calls == []

    @defer.inlineCallbacks
    def test_regex_command_called(self):
        name = 'commands'

        self.assert_load_success(name, assert_commands_is_empty=False)
        instance = self.plugin_manager.plugins[name]['instance']

        user = ('user', 'ident', 'vhost')
        channel = '#channel'
        message = 'regex foobar'

        expected_calls = []
        assert instance.regex_command_calls == expected_calls
        assert instance.command1_calls == []
        assert instance.command2_calls == []

        expected_calls.append((self.cardinal, user, channel, message))
        yield self.plugin_manager.call_command(user, channel, message)
        assert instance.regex_command_calls == expected_calls
        assert instance.command1_calls == []
        assert instance.command2_calls == []

        message = 'regex bar bar bar'
        expected_calls.append((self.cardinal, user, channel, message))
        yield self.plugin_manager.call_command(user, channel, message)
        assert instance.regex_command_calls == expected_calls
        assert instance.command1_calls == []
        assert instance.command2_calls == []

        message = 'this shouldnt trigger'
        yield self.plugin_manager.call_command(user, channel, message)
        assert instance.regex_command_calls == expected_calls
        assert instance.command1_calls == []
        assert instance.command2_calls == []

    @defer.inlineCallbacks
    def test_command_raises_exception_caught(self):
        name = 'command_raises_exception'

        self.assert_load_success(name, assert_commands_is_empty=False)
        instance = self.plugin_manager.plugins[name]['instance']

        user = ('user', 'ident', 'vhost')
        channel = '#channel'
        message = '.command foobar'

        expected_calls = []
        assert instance.command_calls == expected_calls

        expected_calls.append((self.cardinal, user, channel, message))
        yield self.plugin_manager.call_command(user, channel, message)
        assert instance.command_calls == expected_calls

    def test_blacklist_unloaded_plugin(self):
        name = 'commands'
        channel = '#channel'

        assert self.plugin_manager.blacklist(name, channel) is False

    def test_blacklist_invalid_channel(self):
        name = 'commands'
        channel = 321

        with pytest.raises(TypeError):
            self.plugin_manager.blacklist(name, channel)

    def test_blacklist(self):
        name = 'commands'
        channel = '#channel'
        self.assert_load_success(name, assert_commands_is_empty=False)

        assert self.plugin_manager.blacklist(name, channel) is True
        assert self.plugin_manager.plugins[name]['blacklist'] == [channel]

    def test_blacklist_from_config(self):
        name = 'commands'
        channel = '#channel'

        # this works since dict is by-reference in Python
        self.blacklist[name] = [channel]
        self.assert_load_success(name,
                                 assert_commands_is_empty=False,
                                 assert_blacklist_is_empty=False)

        assert self.plugin_manager.plugins[name]['blacklist'] == [channel]

    def test_blacklist_multiple_channels(self):
        name = 'commands'
        channels = ['#channel1', '#channel2']
        self.assert_load_success(name, assert_commands_is_empty=False)

        assert self.plugin_manager.blacklist(name, channels) is True
        assert self.plugin_manager.plugins[name]['blacklist'] == channels

    def test_unblacklist_invalid_channel(self):
        name = 'commands'
        channel = 321

        with pytest.raises(TypeError):
            self.plugin_manager.unblacklist(name, channel)

    def test_unblacklist_unloaded_plugin(self):
        name = 'commands'
        channel = '#channel'

        assert self.plugin_manager.unblacklist(name, channel) is False

    def test_unblacklist(self):
        name = 'commands'
        channel = '#channel'
        self.assert_load_success(name, assert_commands_is_empty=False)

        assert self.plugin_manager.blacklist(name, channel) is True
        assert self.plugin_manager.plugins[name]['blacklist'] == [channel]

        assert self.plugin_manager.unblacklist(name, channel) == []
        assert self.plugin_manager.plugins[name]['blacklist'] == []

    def test_unblacklist_multiple_channels(self):
        name = 'commands'
        channels = ['#channel1', '#channel2']
        self.assert_load_success(name, assert_commands_is_empty=False)

        assert self.plugin_manager.blacklist(name, channels) is True
        assert self.plugin_manager.plugins[name]['blacklist'] == channels

        assert self.plugin_manager.unblacklist(name, channels) == []
        assert self.plugin_manager.plugins[name]['blacklist'] == []

    def test_unblacklist_non_blacklisted_channels(self):
        name = 'commands'
        channel = '#channel'
        self.assert_load_success(name, assert_commands_is_empty=False)

        assert self.plugin_manager.blacklist(name, channel) is True
        assert self.plugin_manager.plugins[name]['blacklist'] == [channel]

        assert self.plugin_manager.unblacklist(
            name, [channel, '#notblacklisted']) == ['#notblacklisted']
        assert self.plugin_manager.plugins[name]['blacklist'] == []

    def test_itercommands_adheres_to_blacklist(self):
        name = 'commands'
        channel = '#channel'

        self.assert_load_success(name, assert_commands_is_empty=False)
        self.plugin_manager.blacklist(name, channel)

        commands = [command for command in self.plugin_manager.itercommands()]
        assert len(commands) == 3

        commands = [command for command in
                    self.plugin_manager.itercommands(channel)]
        assert len(commands) == 0

    @defer.inlineCallbacks
    def test_call_command_adheres_to_blacklist(self):
        name = 'commands'
        channel = '#channel'

        self.assert_load_success(name, assert_commands_is_empty=False)
        instance = self.plugin_manager.plugins[name]['instance']

        self.plugin_manager.blacklist(name, channel)

        user = ('user', 'ident', 'vhost')
        message = '.command1 foobar'

        expected_calls = []
        assert instance.command1_calls == expected_calls
        # FIXME this should maybe not fire if we find a command but it is
        # blacklisted
        with pytest.raises(exceptions.CommandNotFoundError):
            yield self.plugin_manager.call_command(user, channel, message)
        assert instance.command1_calls == expected_calls