From 98f3ec6268e17600ad9b8d6cdef7f9f649bb70ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Odd=20Str=C3=A5b=C3=B8?= Date: Fri, 7 Aug 2020 07:30:14 +0200 Subject: [PATCH 1/3] Add basic plugin discovery --- MANIFEST.in | 7 ---- mcomix/plugins/__init__.py | 55 +++++++++++++++++++++++++++ mcomix/plugins/bar_plugin.py | 5 +++ mcomix/plugins/base/__init__.py | 18 +++++++++ mcomix/plugins/foo_plugin/__init__.py | 9 +++++ setup.py | 9 ++++- 6 files changed, 95 insertions(+), 8 deletions(-) delete mode 100644 MANIFEST.in create mode 100644 mcomix/plugins/__init__.py create mode 100644 mcomix/plugins/bar_plugin.py create mode 100644 mcomix/plugins/base/__init__.py create mode 100644 mcomix/plugins/foo_plugin/__init__.py diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index 8a1f5b65..00000000 --- a/MANIFEST.in +++ /dev/null @@ -1,7 +0,0 @@ -recursive-include mcomix/images *.png -recursive-include mcomix/messages *.po *.mo *.pot -recursive-include mime *.* -include mcomix/images/__init__.py mcomix/messages/__init__.py -include mime/comicthumb mime/comicthumb.thumbnailer -include mcomix.1.gz ChangeLog COPYING mcomixstarter.py -exclude mcomix/images/mcomix-large.png diff --git a/mcomix/plugins/__init__.py b/mcomix/plugins/__init__.py new file mode 100644 index 00000000..a90cc41d --- /dev/null +++ b/mcomix/plugins/__init__.py @@ -0,0 +1,55 @@ +# -*- coding: utf-8 -*- +import pkgutil +import importlib +import inspect + +import pkg_resources + +from mcomix.log import __logger, DEBUG +import mcomix.plugins.base + +__logger.setLevel(DEBUG) +log = __logger.getChild(__package__) + + +def get_plugins(): + plugins = [] + + for plugin in pkg_resources.iter_entry_points('mcomix.plugins'): + try: + obj = plugin.load() + except ImportError: + log.error("Unable to load plugin from entrypoint `{}`".format(plugin)) + continue + + # Make sure this is a plugin + if not issubclass(obj, mcomix.plugins.base.BasePlugin): + log.error("{}.{} is not a subclass of BasePlugin (found via entrypoint)".format(obj.__module__, obj.__name__)) + continue + plugins.append(obj) + log.info("Found plugin {} at {}.{} via entrypoint ({})".format(obj.name, obj.__module__, obj.__name__, plugin.name)) + + for finder, name, ispkg in pkgutil.iter_modules(__path__, __package__ + "."): + plugin_module = importlib.import_module(name) + + # Skip classes from mcomix.plugins.base + if plugin_module is mcomix.plugins.base: + continue + + for obj_name, obj in inspect.getmembers(plugin_module, inspect.isclass): + # Make sure this is a plugin + if not issubclass(obj, mcomix.plugins.base.BasePlugin): + log.debug("{}.{} is not a subclass of BasePlugin".format(name, obj.__name__)) + continue + + # Make sure class is defined in this module or a submodule + if inspect.getmodule(obj) is plugin_module or obj.__module__.startswith(name + '.'): + already_added = obj in plugins + + log.info("Found plugin {} at {}.{} via pkgutil{}".format(obj.name, name, obj_name, " (already added)" if already_added else "")) + + if not already_added: + plugins.append(obj) + + print() + print(plugins) diff --git a/mcomix/plugins/bar_plugin.py b/mcomix/plugins/bar_plugin.py new file mode 100644 index 00000000..bcade99c --- /dev/null +++ b/mcomix/plugins/bar_plugin.py @@ -0,0 +1,5 @@ +from mcomix.plugins.base import ArchiveReader + + +class TestArchiveReader2(ArchiveReader): + name = "FooBarBaz" diff --git a/mcomix/plugins/base/__init__.py b/mcomix/plugins/base/__init__.py new file mode 100644 index 00000000..73e863fc --- /dev/null +++ b/mcomix/plugins/base/__init__.py @@ -0,0 +1,18 @@ + + +class classproperty(object): + def __init__(self, fget): + self.fget = fget + + def __get__(self, owner_self, owner_cls): + return self.fget(owner_cls) + + +class BasePlugin(object): + @classproperty + def name(self): + return self.__name__ + + +class ArchiveReader(BasePlugin): + pass diff --git a/mcomix/plugins/foo_plugin/__init__.py b/mcomix/plugins/foo_plugin/__init__.py new file mode 100644 index 00000000..fe27912f --- /dev/null +++ b/mcomix/plugins/foo_plugin/__init__.py @@ -0,0 +1,9 @@ +from mcomix.plugins.base import ArchiveReader + + +class TestArchiveReader(ArchiveReader): + pass + + +class Potatoe(): + pass diff --git a/setup.py b/setup.py index 689f4d64..33259eb2 100755 --- a/setup.py +++ b/setup.py @@ -52,7 +52,14 @@ 'mcomix = mcomix.__main__:run', 'comicthumb = mcomix.comicthumb:main' ], - 'setuptools.installation': ['eggsecutable=mcomix.__main__:run'], + 'setuptools.installation': [ + 'eggsecutable = mcomix.__main__:run' + ], + 'mcomix.plugins': [ + 'ArchiveReader = mcomix.plugins.foo_plugin:TestArchiveReader', + 'ArchiveReader 2 = mcomix.plugins.foo_plugin:Potatoe', + 'ArchiveReader 3 = mcomix.plugins.foo_plugin:ErroringPotatoe', + ], }, test_suite="test", install_requires=requirements, From 895d162661b33a2439fc0fa30448cbe115f25328 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Odd=20Str=C3=A5b=C3=B8?= Date: Sat, 8 Aug 2020 01:00:56 +0200 Subject: [PATCH 2/3] Add tests for plugin system --- mcomix/plugins/__init__.py | 81 ++++++++++++++++++++------- mcomix/plugins/bar_plugin.py | 5 -- mcomix/plugins/base/__init__.py | 6 +- mcomix/plugins/dummy_1/__init__.py | 9 +++ mcomix/plugins/dummy_2.py | 5 ++ mcomix/plugins/foo_plugin/__init__.py | 9 --- setup.py | 6 +- test/test_plugins.py | 44 +++++++++++++++ 8 files changed, 127 insertions(+), 38 deletions(-) delete mode 100644 mcomix/plugins/bar_plugin.py create mode 100644 mcomix/plugins/dummy_1/__init__.py create mode 100644 mcomix/plugins/dummy_2.py delete mode 100644 mcomix/plugins/foo_plugin/__init__.py create mode 100644 test/test_plugins.py diff --git a/mcomix/plugins/__init__.py b/mcomix/plugins/__init__.py index a90cc41d..27a0fcfc 100644 --- a/mcomix/plugins/__init__.py +++ b/mcomix/plugins/__init__.py @@ -12,23 +12,50 @@ log = __logger.getChild(__package__) -def get_plugins(): - plugins = [] - +def _get_entrypoints(): for plugin in pkg_resources.iter_entry_points('mcomix.plugins'): - try: - obj = plugin.load() - except ImportError: - log.error("Unable to load plugin from entrypoint `{}`".format(plugin)) - continue + yield plugin - # Make sure this is a plugin - if not issubclass(obj, mcomix.plugins.base.BasePlugin): - log.error("{}.{} is not a subclass of BasePlugin (found via entrypoint)".format(obj.__module__, obj.__name__)) - continue - plugins.append(obj) - log.info("Found plugin {} at {}.{} via entrypoint ({})".format(obj.name, obj.__module__, obj.__name__, plugin.name)) +def _entrypoint_load_plugin(plugin, error=False): + try: + obj = plugin.load() + except ImportError: + log.error("Unable to load plugin from entrypoint `{}`".format(plugin)) + if error: + raise + return None + + # Make sure this is a plugin + if not issubclass(obj, mcomix.plugins.base.BasePlugin): + log.error("{}.{} is not a subclass of BasePlugin (found via entrypoint)".format(obj.__module__, obj.__name__)) + if error: + raise TypeError("Class {} is not a plugin.".format(obj)) + return None + + return obj + + +def get_entrypoint_plugins(): + "Returns an iterable containing all plugins found via the `mcomix.plugins` entrypoint." + plugins = set() + + # Iterate over plugin entrypoints + for plugin in _get_entrypoints(): + obj = _entrypoint_load_plugin(plugin) + + if obj is not None: + plugins.add(obj) + log.info("Found plugin {} at {}.{} via entrypoint ({})".format(obj.name, obj.__module__, obj.__name__, plugin.name)) + + return plugins + + +def get_module_plugins(): + "Returns an iterable containing all plugin classes found in the `mcomix.plugins` module." + plugins = set() + + # Iterate over submodules of mcomix.plugins for finder, name, ispkg in pkgutil.iter_modules(__path__, __package__ + "."): plugin_module = importlib.import_module(name) @@ -36,6 +63,7 @@ def get_plugins(): if plugin_module is mcomix.plugins.base: continue + # Iterate over classes in module for obj_name, obj in inspect.getmembers(plugin_module, inspect.isclass): # Make sure this is a plugin if not issubclass(obj, mcomix.plugins.base.BasePlugin): @@ -44,12 +72,25 @@ def get_plugins(): # Make sure class is defined in this module or a submodule if inspect.getmodule(obj) is plugin_module or obj.__module__.startswith(name + '.'): - already_added = obj in plugins - log.info("Found plugin {} at {}.{} via pkgutil{}".format(obj.name, name, obj_name, " (already added)" if already_added else "")) + plugins.add(obj) + log.info("Found plugin {} at {}.{} via pkgutil".format(obj.name, name, obj_name)) + + return plugins + + +def get_plugins(): + "Returns an iterable containing all known plugin classes." + plugins = set() + + plugins.update(get_entrypoint_plugins()) + plugins.update(get_module_plugins()) + + return plugins - if not already_added: - plugins.append(obj) - print() - print(plugins) +__all__ = [ + "get_plugins", + "get_entrypoint_plugins", + "get_module_plugins", +] diff --git a/mcomix/plugins/bar_plugin.py b/mcomix/plugins/bar_plugin.py deleted file mode 100644 index bcade99c..00000000 --- a/mcomix/plugins/bar_plugin.py +++ /dev/null @@ -1,5 +0,0 @@ -from mcomix.plugins.base import ArchiveReader - - -class TestArchiveReader2(ArchiveReader): - name = "FooBarBaz" diff --git a/mcomix/plugins/base/__init__.py b/mcomix/plugins/base/__init__.py index 73e863fc..d860d6d1 100644 --- a/mcomix/plugins/base/__init__.py +++ b/mcomix/plugins/base/__init__.py @@ -14,5 +14,9 @@ def name(self): return self.__name__ -class ArchiveReader(BasePlugin): +class TestingDummyPlugin(BasePlugin): + """ + This is a dummy class for testing plugin loading. + DO NOT USE! + """ pass diff --git a/mcomix/plugins/dummy_1/__init__.py b/mcomix/plugins/dummy_1/__init__.py new file mode 100644 index 00000000..3d33155a --- /dev/null +++ b/mcomix/plugins/dummy_1/__init__.py @@ -0,0 +1,9 @@ +from mcomix.plugins.base import TestingDummyPlugin + + +class DummyPlugin1(TestingDummyPlugin): + pass + + +class NotAPlugin(): + pass diff --git a/mcomix/plugins/dummy_2.py b/mcomix/plugins/dummy_2.py new file mode 100644 index 00000000..4b0894bb --- /dev/null +++ b/mcomix/plugins/dummy_2.py @@ -0,0 +1,5 @@ +from mcomix.plugins.base import TestingDummyPlugin + + +class DummyPlugin2(TestingDummyPlugin): + name = "FooBarBaz" diff --git a/mcomix/plugins/foo_plugin/__init__.py b/mcomix/plugins/foo_plugin/__init__.py deleted file mode 100644 index fe27912f..00000000 --- a/mcomix/plugins/foo_plugin/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -from mcomix.plugins.base import ArchiveReader - - -class TestArchiveReader(ArchiveReader): - pass - - -class Potatoe(): - pass diff --git a/setup.py b/setup.py index 33259eb2..743db745 100755 --- a/setup.py +++ b/setup.py @@ -56,9 +56,9 @@ 'eggsecutable = mcomix.__main__:run' ], 'mcomix.plugins': [ - 'ArchiveReader = mcomix.plugins.foo_plugin:TestArchiveReader', - 'ArchiveReader 2 = mcomix.plugins.foo_plugin:Potatoe', - 'ArchiveReader 3 = mcomix.plugins.foo_plugin:ErroringPotatoe', + 'DummyPlugin 1 = mcomix.plugins.dummy_1:DummyPlugin1', + 'Fail 1 = mcomix.plugins.dummy_1:NotAPlugin', + 'Fail 2 = mcomix.plugins.dummy_1:DoesNotExist', ], }, test_suite="test", diff --git a/test/test_plugins.py b/test/test_plugins.py new file mode 100644 index 00000000..48f8a828 --- /dev/null +++ b/test/test_plugins.py @@ -0,0 +1,44 @@ +# coding: utf-8 +from __future__ import absolute_import + +import pytest + +import mcomix.plugins +from mcomix.plugins.base import TestingDummyPlugin +from mcomix.plugins.dummy_1 import DummyPlugin1 +from mcomix.plugins.dummy_2 import DummyPlugin2 + + +def test_dummy_plugin_count(): + dummies = [plugin for plugin in mcomix.plugins.get_plugins() if issubclass(plugin, TestingDummyPlugin)] + + assert len(dummies) == 2 + + +@pytest.mark.parametrize("plugin,expected_name", [ + (DummyPlugin1, "DummyPlugin1"), + (DummyPlugin2, "FooBarBaz"), +]) +def test_get_plugin_names(plugin, expected_name): + assert plugin.name == expected_name + + +entrypoints = dict([("{}:{}".format(ep.module_name, '.'.join(ep.attrs)), ep) for ep in mcomix.plugins._get_entrypoints()]) + + +@pytest.mark.parametrize("plugin,exception", [ + (entrypoints['mcomix.plugins.dummy_1:DummyPlugin1'], None), + (entrypoints['mcomix.plugins.dummy_1:NotAPlugin'], TypeError), + (entrypoints['mcomix.plugins.dummy_1:DoesNotExist'], ImportError), +]) +def test_entrypoint_loading(plugin, exception): + if exception is not None: + with pytest.raises(exception): + mcomix.plugins._entrypoint_load_plugin(plugin, error=True) + else: + mcomix.plugins._entrypoint_load_plugin(plugin, error=True) + + +@pytest.mark.parametrize("name", mcomix.plugins.__all__) +def test_check_all(name): + assert hasattr(mcomix.plugins, name), "__all__ contains non-existant name `{}`".format(name) From fd73e2c70f7b7b09e118f68359055801f4538b5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Odd=20Str=C3=A5b=C3=B8?= Date: Sat, 8 Aug 2020 01:06:42 +0200 Subject: [PATCH 3/3] Install mcomix before running tests --- .github/workflows/python-test-matrix.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/python-test-matrix.yml b/.github/workflows/python-test-matrix.yml index e4c4367d..f1f0be67 100644 --- a/.github/workflows/python-test-matrix.yml +++ b/.github/workflows/python-test-matrix.yml @@ -33,6 +33,7 @@ jobs: python -m pip install --upgrade pip if [ -f requirements.txt ]; then pip install -r requirements.txt; fi if [ -f requirements-dev.txt ]; then pip install -r requirements-dev.txt; fi + python -m pip install --editable . - name: Lint with flake8 run: | flake8 --version