From 86777d838cdb69b5fdedadf785d81479be94ba4d Mon Sep 17 00:00:00 2001 From: Hemna Date: Fri, 20 Aug 2021 15:21:47 -0400 Subject: [PATCH] Added threads functions to APRSDPluginBase This patch updates the APRSDPluginBase class to include standard methods for allowing plugins to create, start, stop threads that the plugin might need/use. Also update the aprsd-dev to correctly start the threads and stop them for testing plugin functionality. Also added more unit tests and fake objects for unit tests. --- aprsd/dev.py | 10 ++- aprsd/plugin.py | 49 +++++++++++-- aprsd/plugins/email.py | 12 ++-- tests/fake.py | 66 ++++++++++++++++++ tests/test_plugin.py | 152 ++++++++++++++++++++++++++++++++--------- 5 files changed, 242 insertions(+), 47 deletions(-) create mode 100644 tests/fake.py diff --git a/aprsd/dev.py b/aprsd/dev.py index a8052dd..5c70835 100644 --- a/aprsd/dev.py +++ b/aprsd/dev.py @@ -179,7 +179,6 @@ def test_plugin( """APRSD Plugin test app.""" config = utils.parse_config(config_file) - email.CONFIG = config setup_logging(config, loglevel, False) LOG.info(f"Test APRSD PLugin version: {aprsd.__version__}") @@ -189,12 +188,19 @@ def test_plugin( client.Client(config) pm = plugin.PluginManager(config) - obj = pm._create_class(plugin_path, plugin.APRSDMessagePluginBase, config=config) + obj = pm._create_class(plugin_path, plugin.APRSDPluginBase, config=config) packet = {"from": fromcall, "message_text": message, "msgNo": 1} +<<<<<<< HEAD reply = obj.run(packet) LOG.info(f"Result = '{reply}'") +======= + reply = obj.filter(packet) + # Plugin might have threads, so lets stop them so we can exit. + obj.stop_threads() + LOG.info("Result = '{}'".format(reply)) +>>>>>>> f8f84c4 (Added threads functions to APRSDPluginBase) if __name__ == "__main__": diff --git a/aprsd/plugin.py b/aprsd/plugin.py index 5bedee7..56b8fd7 100644 --- a/aprsd/plugin.py +++ b/aprsd/plugin.py @@ -8,7 +8,7 @@ import os import re import threading -from aprsd import messaging, packets +from aprsd import messaging, packets, threads import pluggy from thesmuggler import smuggle @@ -51,10 +51,40 @@ class APRSDPluginBase(metaclass=abc.ABCMeta): message_counter = 0 version = "1.0" + # Holds the list of APRSDThreads that the plugin creates + threads = [] + def __init__(self, config): self.config = config self.message_counter = 0 self.setup() + self.threads = self.create_threads() + if self.threads: + self.start_threads() + + def start_threads(self): + if self.threads: + if not isinstance(self.threads, list): + self.threads = [self.threads] + + try: + for thread in self.threads: + if isinstance(thread, threads.APRSDThread): + thread.start() + else: + LOG.error( + "Can't start thread {}:{}, Must be a child " + "of aprsd.threads.APRSDThread".format( + self, + thread, + ), + ) + except Exception: + LOG.error( + "Failed to start threads for plugin {}".format( + self, + ), + ) @property def message_count(self): @@ -69,6 +99,16 @@ class APRSDPluginBase(metaclass=abc.ABCMeta): """Do any plugin setup here.""" pass + def create_threads(self): + """Gives the plugin writer the ability start a background thread.""" + return [] + + def stop_threads(self): + """Stop any threads this plugin might have created.""" + for thread in self.threads: + if isinstance(thread, threads.APRSDThread): + thread.stop() + @hookimpl @abc.abstractmethod def filter(self, packet): @@ -129,11 +169,6 @@ class APRSDRegexCommandPluginBase(APRSDPluginBase, metaclass=abc.ABCMeta): """The regex to match from the caller""" raise NotImplementedError - @property - def version(self): - """Version""" - raise NotImplementedError - @hookimpl def filter(self, packet): result = None @@ -225,7 +260,7 @@ class PluginManager: ): """ Method to create a class from a fqn python string. - :param module_class_string: full name of the class to create an object of + :param module_class_string: full name of the class to create an object :param super_cls: expected super class for validity, None if bypass :param kwargs: parameters to pass :return: diff --git a/aprsd/plugins/email.py b/aprsd/plugins/email.py index f5d6449..317eb37 100644 --- a/aprsd/plugins/email.py +++ b/aprsd/plugins/email.py @@ -46,14 +46,16 @@ class EmailPlugin(plugin.APRSDRegexCommandPluginBase): LOG.error("EmailPlugin DISABLED!!!!") else: self.enabled = True - email_thread = APRSDEmailThread( - msg_queues=threads.msg_queues, - config=self.config, - ) - email_thread.start() else: LOG.info("Email services not enabled.") + def create_threads(self): + if self.enabled: + return APRSDEmailThread( + msg_queues=threads.msg_queues, + config=self.config, + ) + @trace.trace def process(self, packet): LOG.info("Email COMMAND") diff --git a/tests/fake.py b/tests/fake.py new file mode 100644 index 0000000..24c3ee1 --- /dev/null +++ b/tests/fake.py @@ -0,0 +1,66 @@ +from aprsd import packets, plugin, threads + +FAKE_MESSAGE_TEXT = "fake MeSSage" +FAKE_FROM_CALLSIGN = "KFART" +FAKE_TO_CALLSIGN = "KMINE" + + +def fake_packet( + fromcall=FAKE_FROM_CALLSIGN, + tocall=FAKE_TO_CALLSIGN, + message=None, + msg_number=None, + message_format=packets.PACKET_TYPE_MESSAGE, +): + packet = { + "from": fromcall, + "addresse": tocall, + "format": message_format, + } + if message: + packet["message_text"] = message + + if msg_number: + packet["msgNo"] = msg_number + + return packet + + +class FakeBaseNoThreadsPlugin(plugin.APRSDPluginBase): + version = "1.0" + + def filter(self, packet): + return None + + def process(self, packet): + return "process" + + +class FakeThread(threads.APRSDThread): + def __init__(self): + super().__init__("FakeThread") + + def loop(self): + return True + + +class FakeBaseThreadsPlugin(plugin.APRSDPluginBase): + version = "1.0" + + def filter(self, packet): + return None + + def process(self, packet): + return "process" + + def create_threads(self): + return FakeThread() + + +class FakeRegexCommandPlugin(plugin.APRSDRegexCommandPluginBase): + version = "1.0" + command_regex = "^[fF]" + command_name = "fake" + + def process(self, packet): + return FAKE_MESSAGE_TEXT diff --git a/tests/test_plugin.py b/tests/test_plugin.py index fd1d406..23380e0 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -4,7 +4,7 @@ from unittest import mock import pytz import aprsd -from aprsd import messaging, stats, utils +from aprsd import messaging, packets, stats, utils from aprsd.fuzzyclock import fuzzy from aprsd.plugins import fortune as fortune_plugin from aprsd.plugins import ping as ping_plugin @@ -12,36 +12,122 @@ from aprsd.plugins import query as query_plugin from aprsd.plugins import time as time_plugin from aprsd.plugins import version as version_plugin +from . import fake + class TestPlugin(unittest.TestCase): def setUp(self): - self.fromcall = "KFART" + self.fromcall = fake.FAKE_FROM_CALLSIGN self.ack = 1 self.config = utils.DEFAULT_CONFIG_DICT self.config["ham"]["callsign"] = self.fromcall + self.config["aprs"]["login"] = fake.FAKE_TO_CALLSIGN # Inintialize the stats object with the config stats.APRSDStats(self.config) - def fake_packet(self, fromcall="KFART", message=None, msg_number=None): - packet = { - "from": fromcall, - "addresse": self.config["aprs"]["login"], - "format": "message", - } - if message: - packet["message_text"] = message + @mock.patch.object(fake.FakeBaseNoThreadsPlugin, "process") + def test_base_plugin_no_threads(self, mock_process): + p = fake.FakeBaseNoThreadsPlugin(self.config) - if msg_number: - packet["msgNo"] = msg_number + expected = [] + actual = p.create_threads() + self.assertEqual(expected, actual) - return packet + expected = "1.0" + actual = p.version + self.assertEqual(expected, actual) + expected = 0 + actual = p.message_counter + self.assertEqual(expected, actual) + + expected = None + actual = p.filter(fake.fake_packet()) + self.assertEqual(expected, actual) + mock_process.assert_not_called() + + @mock.patch.object(fake.FakeBaseThreadsPlugin, "create_threads") + def test_base_plugin_threads_created(self, mock_create): + fake.FakeBaseThreadsPlugin(self.config) + mock_create.assert_called_once() + + def test_base_plugin_threads(self): + p = fake.FakeBaseThreadsPlugin(self.config) + actual = p.create_threads() + self.assertTrue(isinstance(actual, fake.FakeThread)) + p.stop_threads() + + @mock.patch.object(fake.FakeRegexCommandPlugin, "process") + def test_regex_base_not_called(self, mock_process): + p = fake.FakeRegexCommandPlugin(self.config) + packet = fake.fake_packet(message="a") + expected = None + actual = p.filter(packet) + self.assertEqual(expected, actual) + mock_process.assert_not_called() + + packet = fake.fake_packet(tocall="notMe", message="f") + expected = None + actual = p.filter(packet) + self.assertEqual(expected, actual) + mock_process.assert_not_called() + + packet = fake.fake_packet( + message="F", + message_format=packets.PACKET_TYPE_MICE, + ) + expected = None + actual = p.filter(packet) + self.assertEqual(expected, actual) + mock_process.assert_not_called() + + packet = fake.fake_packet( + message="f", + message_format=packets.PACKET_TYPE_ACK, + ) + expected = None + actual = p.filter(packet) + self.assertEqual(expected, actual) + mock_process.assert_not_called() + + @mock.patch.object(fake.FakeRegexCommandPlugin, "process") + def test_regex_base_assert_called(self, mock_process): + p = fake.FakeRegexCommandPlugin(self.config) + packet = fake.fake_packet(message="f") + p.filter(packet) + mock_process.assert_called_once() + + def test_regex_base_process_called(self): + p = fake.FakeRegexCommandPlugin(self.config) + + packet = fake.fake_packet(message="f") + expected = fake.FAKE_MESSAGE_TEXT + actual = p.filter(packet) + self.assertEqual(expected, actual) + + packet = fake.fake_packet(message="F") + expected = fake.FAKE_MESSAGE_TEXT + actual = p.filter(packet) + self.assertEqual(expected, actual) + + packet = fake.fake_packet(message="fake") + expected = fake.FAKE_MESSAGE_TEXT + actual = p.filter(packet) + self.assertEqual(expected, actual) + + packet = fake.fake_packet(message="FAKE") + expected = fake.FAKE_MESSAGE_TEXT + actual = p.filter(packet) + self.assertEqual(expected, actual) + + +class TestFortunePlugin(TestPlugin): @mock.patch("shutil.which") def test_fortune_fail(self, mock_which): fortune = fortune_plugin.FortunePlugin(self.config) mock_which.return_value = None expected = "Fortune command not installed" - packet = self.fake_packet(message="fortune") + packet = fake.fake_packet(message="fortune") actual = fortune.filter(packet) self.assertEqual(expected, actual) @@ -54,13 +140,15 @@ class TestPlugin(unittest.TestCase): mock_output.return_value = "Funny fortune" expected = "Funny fortune" - packet = self.fake_packet(message="fortune") + packet = fake.fake_packet(message="fortune") actual = fortune.filter(packet) self.assertEqual(expected, actual) + +class TestQueryPlugin(TestPlugin): @mock.patch("aprsd.messaging.MsgTrack.flush") def test_query_flush(self, mock_flush): - packet = self.fake_packet(message="!delete") + packet = fake.fake_packet(message="!delete") query = query_plugin.QueryPlugin(self.config) expected = "Deleted ALL pending msgs." @@ -72,7 +160,7 @@ class TestPlugin(unittest.TestCase): def test_query_restart_delayed(self, mock_restart): track = messaging.MsgTrack() track.track = {} - packet = self.fake_packet(message="!4") + packet = fake.fake_packet(message="!4") query = query_plugin.QueryPlugin(self.config) expected = "No pending msgs to resend" @@ -87,6 +175,8 @@ class TestPlugin(unittest.TestCase): actual = query.filter(packet) mock_restart.assert_called_once() + +class TestTimePlugins(TestPlugin): @mock.patch("aprsd.plugins.time.TimePlugin._get_local_tz") @mock.patch("aprsd.plugins.time.TimePlugin._get_utcnow") def test_time(self, mock_utcnow, mock_localtz): @@ -104,8 +194,7 @@ class TestPlugin(unittest.TestCase): fake_time.tm_sec = 13 time = time_plugin.TimePlugin(self.config) - packet = self.fake_packet( - fromcall="KFART", + packet = fake.fake_packet( message="location", msg_number=1, ) @@ -115,8 +204,7 @@ class TestPlugin(unittest.TestCase): cur_time = fuzzy(h, m, 1) - packet = self.fake_packet( - fromcall="KFART", + packet = fake.fake_packet( message="time", msg_number=1, ) @@ -128,6 +216,8 @@ class TestPlugin(unittest.TestCase): actual = time.filter(packet) self.assertEqual(expected, actual) + +class TestPingPlugin(TestPlugin): @mock.patch("time.localtime") def test_ping(self, mock_time): fake_time = mock.MagicMock() @@ -138,8 +228,7 @@ class TestPlugin(unittest.TestCase): ping = ping_plugin.PingPlugin(self.config) - packet = self.fake_packet( - fromcall="KFART", + packet = fake.fake_packet( message="location", msg_number=1, ) @@ -157,8 +246,7 @@ class TestPlugin(unittest.TestCase): + str(s).zfill(2) ) - packet = self.fake_packet( - fromcall="KFART", + packet = fake.fake_packet( message="Ping", msg_number=1, ) @@ -166,21 +254,21 @@ class TestPlugin(unittest.TestCase): expected = ping_str(h, m, s) self.assertEqual(expected, actual) - packet = self.fake_packet( - fromcall="KFART", + packet = fake.fake_packet( message="ping", msg_number=1, ) actual = ping.filter(packet) self.assertEqual(expected, actual) + +class TestVersionPlugin(TestPlugin): @mock.patch("aprsd.plugin.PluginManager.get_plugins") def test_version(self, mock_get_plugins): expected = f"APRSD ver:{aprsd.__version__} uptime:0:0:0" version = version_plugin.VersionPlugin(self.config) - packet = self.fake_packet( - fromcall="KFART", + packet = fake.fake_packet( message="No", msg_number=1, ) @@ -188,16 +276,14 @@ class TestPlugin(unittest.TestCase): actual = version.filter(packet) self.assertEqual(None, actual) - packet = self.fake_packet( - fromcall="KFART", + packet = fake.fake_packet( message="version", msg_number=1, ) actual = version.filter(packet) self.assertEqual(expected, actual) - packet = self.fake_packet( - fromcall="KFART", + packet = fake.fake_packet( message="Version", msg_number=1, )