Skip to content

Commit

Permalink
add function plugin concept and decorator
Browse files Browse the repository at this point in the history
  • Loading branch information
thrau committed Nov 4, 2021
1 parent 87bebcd commit 6bca8c4
Show file tree
Hide file tree
Showing 7 changed files with 150 additions and 13 deletions.
4 changes: 4 additions & 0 deletions plugin/__init__.py
@@ -1,11 +1,13 @@
from .core import (
FunctionPlugin,
Plugin,
PluginDisabled,
PluginException,
PluginFinder,
PluginLifecycleListener,
PluginSpec,
PluginType,
plugin,
)
from .manager import PluginManager, PluginSpecResolver

Expand All @@ -14,6 +16,7 @@
__version__ = "1.0.0"

__all__ = [
"FunctionPlugin",
"Plugin",
"PluginSpec",
"PluginType",
Expand All @@ -23,4 +26,5 @@
"PluginSpecResolver",
"PluginException",
"PluginDisabled",
"plugin",
]
75 changes: 73 additions & 2 deletions plugin/core.py
@@ -1,4 +1,5 @@
import abc
import functools
import inspect
from typing import Any, Callable, Dict, List, Tuple, Type, Union

Expand Down Expand Up @@ -118,8 +119,9 @@ def resolve(self, source: Any) -> PluginSpec:
return PluginSpec(source.namespace, source.name, source)

if inspect.isfunction(source):
# TODO: implement a plugin decorator and check if function is of that type.
pass
spec = getattr(source, "__pluginspec__", None)
if spec and isinstance(spec, PluginSpec):
return spec

# TODO: add more options to specify plugin specs

Expand Down Expand Up @@ -157,3 +159,72 @@ def on_load_after(self, plugin_spec: PluginSpec, plugin: Plugin, load_result: An

def on_load_exception(self, plugin_spec: PluginSpec, plugin: Plugin, exception: Exception):
pass


class FunctionPlugin(Plugin):
"""
Exposes a function as a Plugin.
"""

fn: Callable

def __init__(
self,
fn: Callable,
should_load: Union[bool, Callable[[], bool]] = None,
load: Callable = None,
) -> None:
super().__init__()
self.fn = fn
self._should_load = should_load
self._load = load

def __call__(self, *args, **kwargs):
return self.fn(*args, **kwargs)

def load(self, *args, **kwargs):
if self._load:
return self._load(*args, **kwargs)

def should_load(self) -> bool:
if self._should_load:
if type(self._should_load) == bool:
return self._should_load
else:
return self._should_load()

return True


def plugin(
namespace, name=None, should_load: Union[bool, Callable[[], bool]] = None, load: Callable = None
):
"""
Expose a function as discoverable and loadable FunctionPlugin.
:param namespace: the plugin namespace
:param name: the name of the plugin (by default the function name will be used)
:param should_load: optional either a boolean value or a callable returning a boolean
:param load: optional load function
:return: plugin decorator
"""

def wrapper(fn):
plugin_name = name or fn.__name__

# this causes the plugin framework to point the entrypoint to the original function rather than the
# nested factory function (which would not be resolvable)
@functools.wraps(fn)
def factory():
fn_plugin = FunctionPlugin(fn, should_load=should_load, load=load)
fn_plugin.namespace = namespace
fn_plugin.name = plugin_name
return fn_plugin

# at discovery-time the factory will point to the method being decorated, and at load-time the factory from
# this spec instance be used instead of the one being created
fn.__pluginspec__ = PluginSpec(namespace, plugin_name, factory)

return fn

return wrapper
12 changes: 11 additions & 1 deletion plugin/manager.py
Expand Up @@ -246,7 +246,7 @@ def _load_plugin(self, container: PluginContainer):
if not container.is_init:
try:
LOG.debug("instantiating plugin %s", plugin_spec)
container.plugin = plugin_spec.factory()
container.plugin = self._plugin_from_spec(plugin_spec)
container.is_init = True
self._fire_on_init_after(plugin_spec, container.plugin)
except Exception as e:
Expand Down Expand Up @@ -280,6 +280,16 @@ def _load_plugin(self, container: PluginContainer):
self._fire_on_load_exception(plugin_spec, plugin, e)
container.load_error = e

def _plugin_from_spec(self, plugin_spec: PluginSpec) -> P:
factory = plugin_spec.factory

# functional decorators can overwrite the spec factory (pointing to the decorator) with a custom factory
spec = getattr(factory, "__pluginspec__", None)
if spec:
factory = spec.factory

return factory()

def _init_plugin_index(self) -> Dict[str, PluginContainer]:
return {plugin.name: plugin for plugin in self._import_plugins() if plugin}

Expand Down
10 changes: 10 additions & 0 deletions tests/conftest.py
@@ -0,0 +1,10 @@
import pytest

from plugin.discovery import ModuleScanningPluginFinder
from tests.plugins import sample_plugins


@pytest.fixture
def sample_plugin_finder():
finder = ModuleScanningPluginFinder(modules=[sample_plugins])
yield finder
13 changes: 12 additions & 1 deletion tests/plugins/sample_plugins.py
@@ -1,4 +1,4 @@
from plugin import Plugin, PluginSpec
from plugin import Plugin, PluginSpec, plugin


# this is not a discoverable plugin (no name and namespace specified)
Expand All @@ -21,3 +21,14 @@ def load(self):
plugin_spec_2 = PluginSpec("namespace_1", "plugin_2", AbstractSamplePlugin)

some_member = "this string should not be interpreted as a plugin"


@plugin(namespace="namespace_3")
def plugin_3():
# this plugin is discoverable via the FunctionPlugin decorator
return "foobar"


@plugin(name="plugin_4", namespace="namespace_3")
def functional_plugin():
return "another"
27 changes: 18 additions & 9 deletions tests/test_discovery.py
@@ -1,6 +1,5 @@
import os

from plugin import PluginSpec
from plugin.discovery import ModuleScanningPluginFinder, PackagePathPluginFinder

from .plugins import sample_plugins
Expand All @@ -9,24 +8,34 @@
class TestModuleScanningPluginFinder:
def test_find_plugins(self):
finder = ModuleScanningPluginFinder(modules=[sample_plugins])

plugins = finder.find_plugins()
assert len(plugins) == 5

plugins = [(spec.namespace, spec.name) for spec in plugins]

# update when adding plugins to sample_plugins
assert PluginSpec("namespace_2", "simple", sample_plugins.SimplePlugin) in plugins
assert PluginSpec("namespace_1", "plugin_1", sample_plugins.AbstractSamplePlugin) in plugins
assert PluginSpec("namespace_1", "plugin_2", sample_plugins.AbstractSamplePlugin) in plugins
assert len(plugins) == 3
assert ("namespace_2", "simple") in plugins
assert ("namespace_1", "plugin_1") in plugins
assert ("namespace_1", "plugin_2") in plugins
assert ("namespace_3", "plugin_3") in plugins
assert ("namespace_3", "plugin_4") in plugins


class TestPackagePathPluginFinder:
def test_find_plugins(self):
where = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))

finder = PackagePathPluginFinder(where=where, include=("tests.plugins",))

plugins = finder.find_plugins()
assert len(plugins) == 5

plugins = [(spec.namespace, spec.name) for spec in plugins]

# update when adding plugins to sample_plugins
assert PluginSpec("namespace_2", "simple", sample_plugins.SimplePlugin) in plugins
assert PluginSpec("namespace_1", "plugin_1", sample_plugins.AbstractSamplePlugin) in plugins
assert PluginSpec("namespace_1", "plugin_2", sample_plugins.AbstractSamplePlugin) in plugins
assert len(plugins) == 3
assert ("namespace_2", "simple") in plugins
assert ("namespace_1", "plugin_1") in plugins
assert ("namespace_1", "plugin_2") in plugins
assert ("namespace_3", "plugin_3") in plugins
assert ("namespace_3", "plugin_4") in plugins
22 changes: 22 additions & 0 deletions tests/test_function_plugin.py
@@ -0,0 +1,22 @@
from plugin import PluginManager


def test_load_functional_plugins(sample_plugin_finder):
manager = PluginManager("namespace_3", finder=sample_plugin_finder)

plugins = manager.load_all()
assert len(plugins) == 2

assert plugins[0].name in ["plugin_3", "plugin_4"]
assert plugins[1].name in ["plugin_3", "plugin_4"]

if plugins[0].name == "plugin_3":
plugin_3 = plugins[0]
plugin_4 = plugins[1]
else:
plugin_3 = plugins[1]
plugin_4 = plugins[0]

# function plugins are callable directly
assert plugin_3() == "foobar"
assert plugin_4() == "another"

0 comments on commit 6bca8c4

Please sign in to comment.