Commit 0eefbe2e authored by Jerome Flesch's avatar Jerome Flesch

Implements OpenPaperwork Core: Plugins, callbacks and interfaces management

Signed-off-by: Jerome Flesch's avatarJerome Flesch <jflesch@openpaper.work>
parent 762c8c24
# order matters (dependencies)
ALL_COMPONENTS = paperwork-backend paperwork-gtk
ALL_COMPONENTS = openpaperwork-core paperwork-backend paperwork-gtk
build:
openpaperwork-core_install_py:
echo "Installing openpaperwork-core"
$(MAKE) -C openpaperwork-core install_py
%_install_py: openpaperwork-core_install_py
echo "Installing $(@:%_install_py=%)"
$(MAKE) -C $(@:%_install_py=%) install_py
build: $(ALL_COMPONENTS:%=%_build)
clean: $(ALL_COMPONENTS:%=%_clean)
rm -rf build dist
......@@ -10,11 +20,9 @@ clean: $(ALL_COMPONENTS:%=%_clean)
make -C sub/libpillowfight clean || true
make -C sub/pyocr clean || true
install: $(ALL_COMPONENTS:%=%_install)
install_py: $(ALL_COMPONENTS:%=%_install_py)
install_c: $(ALL_COMPONENTS:%=%_install_c)
install: install_py
uninstall: $(ALL_COMPONENTS:%=%_uninstall)
......@@ -86,34 +94,10 @@ help:
echo "Checking $(@:%_doc=%)"
$(MAKE) -C $(@:%_doc=%) doc
%_build:
echo "Building $(@:%_build=%)"
$(MAKE) -C $(@:%_build=%) build
%_clean:
echo "Building $(@:%_clean=%)"
$(MAKE) -C $(@:%_clean=%) clean
%_install:
echo "Installing $(@:%_install=%)"
$(MAKE) -C $(@:%_install=%) install
%_build_py:
echo "Building $(@:%_build_py=%)"
$(MAKE) -C $(@:%_build=%) build_py
%_install_py:
echo "Installing $(@:%_install_py=%)"
$(MAKE) -C $(@:%_build=%) install_py
%_build_c:
echo "Building $(@:%_build_c=%)"
$(MAKE) -C $(@:%_build=%) build_c
%_install_c:
echo "Installing $(@:%_install_c=%)"
$(MAKE) -C $(@:%_build=%) install_c
%_uninstall:
echo "Uninstalling $(@:%_uninstall=%)"
$(MAKE) -C $(@:%_uninstall=%) uninstall
......@@ -144,8 +128,6 @@ help:
venv:
echo "Building virtual env"
git submodule init
git submodule update --recursive --remote --init
make -C sub/libinsane build_c
virtualenv -p python3 --system-site-packages venv
......
if ! [ -e sub/libinsane/Makefile ] ; then
git submodule init
git submodule update --recursive --remote --init
fi
make venv
source venv/bin/activate
cd sub/libinsane && source ./activate_test_env.sh ; cd ../..
VERSION_FILE = openpaperwork_core/_version.py
PYTHON ?= python3
build: build_c build_py
install: install_py install_c
uninstall: uninstall_py
build_py: ${VERSION_FILE}
${PYTHON} ./setup.py build
build_c:
version: ${VERSION_FILE}
${VERSION_FILE}:
echo -n "version = \"" >| $@
echo -n $(shell git describe --always) >> $@
echo "\"" >> $@
doc:
check:
flake8 openpaperwork_core
test: ${VERSION_FILE}
tox
linux_exe:
windows_exe:
release:
ifeq (${RELEASE}, )
@echo "You must specify a release version (make release RELEASE=1.2.3)"
else
@echo "Will release: ${RELEASE}"
@echo "Checking release is in ChangeLog ..."
grep ${RELEASE} ChangeLog | grep -v "/xx"
endif
release_pypi:
@echo "Releasing paperwork-backend ..."
${PYTHON} ./setup.py sdist upload
@echo "All done"
clean:
rm -f ${VERSION_FILE}
rm -rf build dist *.egg-info
# PIP_ARGS is used by Flatpak build
install_py: ${VERSION_FILE}
${PYTHON} ./setup.py install ${PIP_ARGS}
install_c:
uninstall_py:
pip3 uninstall -y paperwork-backend
uninstall_c:
help:
@echo "make build || make build_py"
@echo "make check"
@echo "make help: display this message"
@echo "make install || make install_py"
@echo "make uninstall || make uninstall_py"
@echo "make release"
.PHONY: \
build \
build_c \
build_py \
check \
doc \
exe \
help \
install \
install_c \
install_py \
release \
test \
uninstall \
uninstall_c \
version
import collections
import importlib
import logging
LOGGER = logging.getLogger(__name__)
class PluginBase(object):
"""
Indicates all the methods that must be implemented by any plugin
managed by OpenPaperwork core. Also provides default implementations.
"""
USER_VISIBLE = False
def __init__(self):
"""
Called as soon as the module is loaded. Should be as minimal as
possible. Most of the work should be done in `init()`.
"""
pass
def get_implemented_interfaces(self):
"""
Indicates the list of interfaces implemented by this plugin.
Interface names are arbitrarily defined. Methods provided by each
interface are arbitrarily defined (and no checks are done).
Returns a list of string.
"""
return []
def get_deps(self):
"""
Return the dependencies required by this plugin.
"""
return {
'plugins': [],
'interfaces': [],
}
def init(self):
pass
class Core(object):
"""
Manage plugins and their callbacks.
"""
def __init__(self):
self.explicits = []
self.plugins = {}
self._to_initialize = [] # because initialization order matters
self.interfaces = collections.defaultdict(list)
self.callbacks = collections.defaultdict(list)
def load(self, module_name, explicit=False):
"""
- Load the specified module
- Instantiate the class 'Plugin()' of this module
- Register all the methods of this plugin object (except those starting
by '_' and those from the class PluginBase) as callbacks
BEWARE of dependency loops !
Arguments:
- module_name: name of the Python module to load
- explicit: this plugin loading has been explicitly requested
by the user (used to track which modules must possibly be saved
in a configuration file)
"""
LOGGER.info(
"Loading plugin '%s' (explicit=%b) ...",
module_name, explicit
)
module = importlib.import_module(module_name)
plugin = module.Plugin()
self.plugins[module_name] = plugin
for interface in plugin.get_implemented_interfaces():
LOGGER.debug("- '%s' provides '%s'", module_name, interface)
self.interfaces[interface].append(plugin)
for attr_name in dir(plugin):
if attr_name[0] == "_":
continue
if attr_name in dir(PluginBase): # ignore base methods of plugins
continue
attr = getattr(plugin, attr_name)
if not hasattr(attr, '__call__'):
continue
LOGGER.debug("- %s.%s()", module_name, attr_name)
self.callbacks[attr_name].append(attr)
if explicit:
self.explicits.append(module_name)
self._to_initialize.append(plugin)
LOGGER.info("Plugin '%s' loaded", module_name)
return plugin
def get_explicits(self):
"""
Returns the list of module names that were loaded explicitly by user
request. Useful if you want to keep track of those modules in a
configuration file.
"""
return tuple(self.explicits) # makes it immutable
def _load_deps(self):
to_examine = list(self.plugins.values())
while len(to_examine) > 0:
plugin = to_examine[0]
to_examine = to_examine[1:]
LOGGER.info("Examining dependencies of '%s' ...", type(plugin))
deps = plugin.get_deps()
if 'plugins' in deps:
for dep_plugin in deps['plugins']:
if dep_plugin in self.plugins:
LOGGER.info("- Plugin '%s' already loaded", dep_plugin)
continue
to_examine.append(self.load(dep_plugin))
if 'interfaces' in deps:
for (dep_interface, dep_defaults) in deps['interfaces']:
if len(self.interfaces[dep_interface]) > 0:
LOGGER.info(
"- Interface '%s' already provided by %d plugins",
dep_interface, len(self.interfaces[dep_interface])
)
continue
LOGGER.info(
"Loading default plugins for interface '%s'"
" (%d plugins)",
dep_interface, len(dep_defaults)
)
for dep_default in dep_defaults:
to_examine.append(self.load(dep_default))
assert(len(self.interfaces[dep_interface]) > 0)
def _init(self, plugin, initialized=set()):
if plugin in initialized:
return
deps = plugin.get_deps()
if 'plugins' in deps:
for dep_plugin in deps['plugins']:
assert(dep_plugin in self.plugins)
self._init(self.plugins[dep_plugin], initialized)
if 'interfaces' in deps:
for (dep_interface, _) in deps['interfaces']:
dep_plugins = self.interfaces[dep_interface]
assert(len(dep_plugins) > 0)
for dep_plugin in dep_plugins:
self._init(dep_plugin, initialized)
LOGGER.info("Initializing plugin '%s' ...", type(plugin))
plugin.init()
initialized.add(plugin)
def init(self):
"""
- Make sure all the dependencies of all the plugins are satisfied.
- Call the method init() of each plugin following the dependency
order (those without dependencies are called first).
BEWARE of dependency loops !
"""
LOGGER.info("Initializing core")
self._load_deps()
for plugin in self._to_initialize:
self._init(plugin)
self._to_initialize = []
LOGGER.info("Core initialized")
def get(self, module_name):
"""
Returns a Plugin instance based on the corresponding module name
(assuming it has been loaded).
"""
return self.plugins[module_name]
def call(self, callback_name, *args, **kwargs):
"""
Call all the methods of all the plugins that have `callback_name`
as name. Arguments are passed as is.
"""
callbacks = self.callbacks[callback_name]
if len(callbacks) <= 0:
LOGGER.warning("No method '%s' available !", callback_name)
for callback in callbacks:
callback(*args, **kwargs)
[tool:pytest]
addopts = -ra
python_files = tests_*.py
#!/usr/bin/env python3
import sys
from setuptools import setup, find_packages
quiet = '--quiet' in sys.argv or '-q' in sys.argv
try:
with open("openpaperwork_core/_version.py", "r") as file_descriptor:
version = file_descriptor.read().strip()
version = version.split(" ")[2][1:-1]
if not quiet:
print("OpenPaperwork-core version: {}".format(version))
if "-" in version:
version = version.split("-")[0]
except FileNotFoundError:
print("ERROR: _version.py file is missing")
print("ERROR: Please run 'make version' first")
sys.exit(1)
setup(
name="openpaperwork-core",
version=version,
description=(
"OpenPaperwork's core"
),
long_description="""Paperwork is a GUI to make papers searchable.
This is the core part of Paperwork. It manages plugins.
There is no GUI here. The GUI is
<https://gitlab.gnome.org/World/OpenPaperwork/paperwork#readme>.
""",
url=(
"https://gitlab.gnome.org/World/OpenPaperwork/paperwork/tree/master/"
"openpaperwork-core"
),
download_url=(
"https://gitlab.gnome.org/World/OpenPaperwork/paperwork/-"
"/archive/{}/paperwork-{}.tar.gz".format(version, version)
),
classifiers=[
"Development Status :: 5 - Production/Stable",
("License :: OSI Approved ::"
" GNU General Public License v3 or later (GPLv3+)"),
"Operating System :: POSIX :: Linux",
"Programming Language :: Python :: 3",
],
license="GPLv3+",
author="Jerome Flesch",
author_email="jflesch@openpaper.work",
packages=find_packages(),
zip_safe=True,
install_requires=[]
)
import unittest
import unittest.mock
import openpaperwork_core
class TestLoading(unittest.TestCase):
@unittest.mock.patch("importlib.import_module")
def test_simple_loading(self, import_module):
class TestModule(object):
class Plugin(openpaperwork_core.PluginBase):
def __init__(self):
self.init_called = False
self.test_method_called = False
def init(self):
self.init_called = True
def test_method(self):
self.test_method_called = True
core = openpaperwork_core.Core()
import_module.return_value = TestModule()
core.load('whatever_module')
import_module.assert_called_once_with('whatever_module')
core.init()
self.assertTrue(core.get('whatever_module').init_called)
core.call('test_method')
self.assertTrue(core.get('whatever_module').test_method_called)
@unittest.mock.patch("importlib.import_module")
def test_default_interface(self, import_module):
class TestModuleA(object):
class Plugin(openpaperwork_core.PluginBase):
def __init__(self):
self.init_called = False
self.test_method_called = False
def get_implemented_interfaces(self):
return ["test_interface"]
def init(self):
self.init_called = True
def test_method(self):
self.test_method_called = True
class TestModuleB(object):
class Plugin(openpaperwork_core.PluginBase):
def __init__(self):
self.init_called = False
def get_deps(self):
return {
'plugins': [],
'interfaces': [
('test_interface', ['module_a']),
],
}
def init(self):
self.init_called = True
core = openpaperwork_core.Core()
import_module.return_value = TestModuleB()
core.load('module_b')
import_module.assert_called_once_with('module_b')
import_module.reset_mock()
import_module.return_value = TestModuleA()
core.init() # will load 'module_a' because of dependencies
import_module.assert_called_once_with('module_a')
self.assertTrue(core.get('module_a').init_called)
self.assertTrue(core.get('module_b').init_called)
core.call('test_method')
self.assertTrue(core.get('module_a').test_method_called)
class TestInit(unittest.TestCase):
@unittest.mock.patch("importlib.import_module")
def test_init_order(self, import_module):
global g_idx
g_idx = 0
class TestModuleA(object):
class Plugin(openpaperwork_core.PluginBase):
def __init__(self):
self.init_called_a = -1
def init(self):
global g_idx
self.init_called_a = g_idx
g_idx += 1
class TestModuleB(object):
class Plugin(openpaperwork_core.PluginBase):
def __init__(self):
self.init_called_b = -1
def get_deps(self):
return {
'plugins': ['module_a'],
}
def init(self):
global g_idx
self.init_called_b = g_idx
g_idx += 1
class TestModuleC(object):
class Plugin(openpaperwork_core.PluginBase):
def __init__(self):
self.init_called_c = -1
def get_deps(self):
return {
'plugins': ['module_b'],
}
def init(self):
global g_idx
self.init_called_c = g_idx
g_idx += 1
core = openpaperwork_core.Core()
import_module.return_value = TestModuleA()
core.load('module_a')
import_module.assert_called_once_with('module_a')
import_module.reset_mock()
import_module.return_value = TestModuleC()
core.load('module_c')
import_module.assert_called_once_with('module_c')
import_module.reset_mock()
import_module.return_value = TestModuleB()
core.init() # will load 'module_b' because of dependencies
import_module.assert_called_once_with('module_b')
self.assertEqual(core.get('module_a').init_called_a, 0)
self.assertEqual(core.get('module_b').init_called_b, 1)
self.assertEqual(core.get('module_c').init_called_c, 2)
class TestCall(unittest.TestCase):
@unittest.mock.patch("importlib.import_module")
def test_default_interface(self, import_module):
class TestModuleB(object):
class Plugin(openpaperwork_core.PluginBase):
def __init__(self):
self.init_called_b = False
self.test_method_called_b = False
def get_implemented_interfaces(self):
return ["test_interface"]
def init(self):
self.init_called_b = True
def test_method(self):
self.test_method_called_b = True
class TestModuleC(object):
class Plugin(openpaperwork_core.PluginBase):
def __init__(self):
self.init_called_c = False
self.test_method_called_c = False
def get_deps(self):
return {
'plugins': [],
'interfaces': [
('test_interface', [
'module_a',
'module_b',
]),
],
}
def init(self):
self.init_called_c = True
def test_method(self):
self.test_method_called_c = True
core = openpaperwork_core.Core()
import_module.return_value = TestModuleC()
core.load('module_c')
import_module.assert_called_once_with('module_c')
import_module.reset_mock()
import_module.return_value = TestModuleB()
core.load('module_b')
import_module.assert_called_once_with('module_b')
import_module.reset_mock()
# interface already satisfied --> won't load 'module_a'
core.init()
self.assertTrue(core.get('module_b').init_called_b)
self.assertTrue(core.get('module_c').init_called_c)
core.call('test_method')
self.assertTrue(core.get('module_b').test_method_called_b)
self.assertTrue(core.get('module_c').test_method_called_c)
[tox]
envlist=py3
[testenv]
deps=
pytest
setuptools >= 9.0.1
commands=pytest {posargs}
[flake8]
exclude =
.tox,
build,
dist,
venv*,
*.egg*,
.git,
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