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