Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions plux/core/entrypoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,19 @@


class EntryPoint(t.NamedTuple):
"""
Lightweight data structure to represent an entry point. You can find out more about the data model here:
https://packaging.python.org/en/latest/specifications/entry-points/#data-model
"""

name: str
"""The name identifies this entry point within its group. The precise meaning of this is up to the consumer.
Within a group, entry point names should be unique."""
value: str
"""The object reference points to a Python object. It is either in the form ``importable.module``, or
importable.module:object.attr."""
group: str
"""The group that an entry point belongs to indicates what sort of object it provides."""


EntryPointDict = dict[str, list[str]]
Expand Down
198 changes: 175 additions & 23 deletions plux/core/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,28 @@
import inspect
import typing as t

if t.TYPE_CHECKING:
from importlib.metadata import EntryPoint


class PluginException(Exception):
"""
Generic ``PluginException`` that may be raised by the ``PluginManager``.
"""

def __init__(self, message, namespace: str = None, name: str = None) -> None:
super().__init__(message)
self.namespace = namespace
self.name = name


class PluginDisabled(PluginException):
"""
Exception that can be raised by a ``Plugin`` or a ``PluginLifecycleListener`` to indicate to the ``PluginManager``
that this plugin should be disabled. Later calls to ``PluginManager.load(...)``, will then also raise a
``PluginDisabled`` exception.
"""

reason: str

def __init__(self, namespace: str, name: str, reason: str = None):
Expand All @@ -25,24 +38,64 @@ def __init__(self, namespace: str, name: str, reason: str = None):


class Plugin(abc.ABC):
"""A generic LocalStack plugin.
"""
A generic Plugin.

A Plugin's purpose is to be loaded dynamically at runtime and defer code imports into the Plugin::load method.
A Plugin's purpose is to be loaded dynamically at runtime and defer code imports into the ``Plugin.load`` method.
Abstract subtypes of plugins (e.g., a LocalstackCliPlugin) may overwrite the load method with concrete call
arguments that they agree upon with the PluginManager. In other words, a Plugin and a PluginManager for that
particular Plugin have an informal contracts to use the same argument types when load is invoked.
particular Plugin have an informal contract to use the same argument types when ``load`` is invoked.

Note that ``PluginManagers`` will instantiate plugins without constructor args, but can pass arguments to the load
method.

Here's an example plugin that lazily imports server code and returns a ``Server`` instance--an example
abstraction in your application::

class TwistedServerPlugin(Plugin):
namespace = "my.server_plugins"
name = "twisted"

def load(self, host: str, port: int) -> Server:
from .twisted import TwistedServer

return TwistedServer(host, port)

Later through a plugin manager, this plugin can be loaded as::

server_name = "twisted"
pm = PluginManager("my.server_plugins", load_args=("localhost", 8080))
server = pm.load(server_name)
server.start()
"""

namespace: str
"""The namespace of the plugin (e.g., ``cli.commands``). Maps to the ``group`` attribute of an entrypoint."""
name: str
"""The name of the plugin (e.g., ``my_command``). Maps to the ``name`` attribute of an entrypoint."""
requirements: list[str]
"""The list of "depends on" clauses of the entry point, which is simply a list of strings and the meaning is up
to the consumer."""

def should_load(self) -> bool:
"""
Whether the ``PluginManager`` should load this plugin. This is called before the ``load`` method. Note that,
at the point where the ``PluginManager`` calls this function, the plugin has already been *resolved*, which
means that all ``import`` statements of the module where this plugin lives, have been executed. If you want to
defer imports, make sure to put them into the ``load`` method.

By default, this method always returns True.

:return: True if the plugin should be loaded, False otherwise.
"""
return True

def load(self, *args, **kwargs):
def load(self, *args, **kwargs) -> t.Any | None:
"""
Called by a PluginLoader when it loads the Plugin.
Called by a ``PluginLoader`` when it loads the Plugin. Ideally, any optional imports needed to run the plugin
are deferred to this method.

:return: An optional return value of the load method, which is passed to ``PluginManager.load(...)``.
"""
return None

Expand All @@ -57,15 +110,19 @@ class PluginSpec:
imported code that can instantiate the plugin (a PluginFactory). In the simplest case, the PluginFactory that can
just be the Plugin's class.

Internally a PluginSpec is essentially a wrapper around an importlib EntryPoint. An entrypoint is a tuple: (
"name", "module:object") inside a namespace that can be loaded. The entrypoint object of a Plugin can point to a
PluginSpec, or a Plugin that defines its own namespace and name, in which case the PluginSpec will be instantiated
dynamically by, e.g., a PluginSpecResolver.
Internally, a PluginSpec is essentially a wrapper around an importlib EntryPoint. An entrypoint is a tuple:
("group", "name", "module:object") inside a namespace that can be loaded. The entrypoint object of a Plugin can
point to a PluginSpec, or a Plugin that defines its own namespace and name, in which case the PluginSpec will be
instantiated dynamically by, e.g., a ``PluginSpecResolver``.
"""

namespace: str
"""The namespace of the plugin (e.g., ``cli.commands``). Maps to the ``group`` attribute of an entrypoint."""
name: str
"""The name of the plugin (e.g., ``my_command``). Maps to the ``name`` attribute of an entrypoint."""
factory: PluginFactory
"""The factory that can instantiate the plugin. This is the imported code referenced in the ``value`` attribute of
the entrypoint."""

def __init__(
self,
Expand All @@ -90,11 +147,17 @@ def __eq__(self, other):

class PluginFinder(abc.ABC):
"""
Basic abstractions to find plugins, either at build time (e.g., using the PackagePathPluginFinder) or at run time
(e.g., using ``MetadataPluginFinder`` that finds plugins from entrypoints)
High-level abstractions to find plugins, either at build time (e.g., using the PackagePathPluginFinder) or at run
time (e.g., using ``MetadataPluginFinder`` that finds plugins from entrypoints)
"""

def find_plugins(self) -> list[PluginSpec]:
"""
Find and return a list of ``PluginSpec`` instances. The implementation will vary drastically depending on the
context in which the specific ``PluginFinder`` is used.

:return: A list of ``PluginSpec`` instances.
"""
raise NotImplementedError # pragma: no cover


Expand All @@ -107,7 +170,7 @@ def resolve(self, source: t.Any) -> PluginSpec:
"""
Tries to create a PluginSpec from the given source.

:param source: anything that can produce a PluginSpec (Plugin class, ...)
:param source: Anything that can produce a PluginSpec (Plugin class, ...)
:return: a PluginSpec instance
"""
if isinstance(source, PluginSpec):
Expand All @@ -129,19 +192,60 @@ def resolve(self, source: t.Any) -> PluginSpec:

class PluginLifecycleListener: # pragma: no cover
"""
Listener that can be attached to a PluginManager to react to plugin lifecycle events.
Listener that can be attached to a ``PluginManager`` to react to plugin lifecycle events.

A ``Plugin`` managed by a ``PluginManager`` can be in three lifecycle phases:

* resolved: The entrypoint pointing to the PluginSpec was imported and the PluginSpec instance was created.
In technical terms, resolution is the process of calling importlib ``EntryPoint.load()`` and creating a
``PluginSpec`` instance from the result.
* init: The ``PluginFactory`` of the ``PluginSpec`` was successfully invoked
* loaded: The load method of the ``Plugin`` was successfully invoked

If an exception occurs during any of these steps, the plugin will be in an errored state.

A lifecycle listener can hook into any of these steps and perform custom actions.
"""

def on_resolve_exception(self, namespace: str, entrypoint, exception: Exception):
def on_resolve_exception(self, namespace: str, entrypoint: "EntryPoint", exception: Exception):
"""
This hook is called when an exception occurs during the resolution of a ``PluginSpec``. This can happen, for
example, when the module that holds the plugin raises an import exception, and therefore the ``PluginSpec``
cannot be created.

:param namespace: The namespace of the plugin that was being resolved
:param entrypoint: The importlib EntryPoint that was being used to resolve the plugin
:param exception: The exception that was raised during resolution
"""
pass

def on_resolve_after(self, plugin_spec: PluginSpec):
"""
This hook is called after the entry point was successfully loaded, and the ``PluginSpec`` instance was created.

:param plugin_spec: The created ``PluginSpec`` instance.
"""
pass

def on_init_exception(self, plugin_spec: PluginSpec, exception: Exception):
"""
This hook is called when an exception occurs during the construction of a ``Plugin`` instance. A Plugin is
instantiated by calling the ``PluginFactory`` of the ``PluginSpec``. If that call raises an exception, this
hook is called.

:param plugin_spec: The ``PluginSpec`` used to instantiate the plugin..
:param exception: The exception that was raised during plugin initialization.
"""
pass

def on_init_after(self, plugin_spec: PluginSpec, plugin: Plugin):
"""
This hook is called after a ``Plugin`` instance has been successfully created from a ``PluginSpec``.
This happens when the ``PluginFactory`` of the ``PluginSpec`` is called and returns a ``Plugin`` instance.

:param plugin_spec: The ``PluginSpec`` that was used to create the plugin.
:param plugin: The newly created ``Plugin`` instance.
"""
pass

def on_load_before(
Expand All @@ -151,17 +255,47 @@ def on_load_before(
load_args: list | tuple,
load_kwargs: dict,
):
"""
This hook is called just before the ``load`` method of a ``Plugin`` is invoked. It provides access to the
arguments that will be passed to the plugin's load method.

:param plugin_spec: The ``PluginSpec`` of the plugin being loaded.
:param plugin: The ``Plugin`` instance that is about to be loaded.
:param load_args: The positional arguments that will be passed to the plugin's ``load`` method.
:param load_kwargs: The keyword arguments that will be passed to the plugin's ``load`` method.
"""
pass

def on_load_after(self, plugin_spec: PluginSpec, plugin: Plugin, load_result: t.Any = None):
def on_load_after(self, plugin_spec: PluginSpec, plugin: Plugin, load_result: t.Any | None = None):
"""
This hook is called after the ``load`` method of a ``Plugin`` has been successfully invoked.
It provides access to the result returned by the plugin's load method.

:param plugin_spec: The ``PluginSpec`` of the plugin that was loaded.
:param plugin: The ``Plugin`` instance that was loaded.
:param load_result: The value returned by the plugin's ``load`` method, if any.
"""
pass

def on_load_exception(self, plugin_spec: PluginSpec, plugin: Plugin, exception: Exception):
"""
This hook is called when an exception occurs during the loading of a ``Plugin``. This happens when the
``load`` method of the plugin raises an exception.

:param plugin_spec: The ``PluginSpec`` of the plugin that failed to load.
:param plugin: The ``Plugin`` instance that failed to load.
:param exception: The exception that was raised during the loading process.
"""
pass


class CompositePluginLifecycleListener(PluginLifecycleListener):
"""A PluginLifecycleListener decorator that dispatches to multiple delegates."""
"""
A PluginLifecycleListener decorator that dispatches to multiple delegates. This can be used to execute multiple
listeners into one and pass them as a single listener to a ``PluginManager``.

TODO: might be a candidate for removal, since a ``PluginManager`` can be configured with multiple listeners.
"""

listeners: list[PluginLifecycleListener]

Expand Down Expand Up @@ -202,7 +336,10 @@ def on_load_exception(self, *args, **kwargs):

class FunctionPlugin(Plugin):
"""
Exposes a function as a Plugin.
This class can be used to create plugins that are not derived from the Plugin class, but are simply functions.
Plux provides a built-in mechanism for this with the ``@plugin`` decorator. The ``FunctionPlugin`` is primarily an
internal API, but the class can be extended to customize the behavior of the function plugins, though this is a
very advanced use case.
"""

fn: t.Callable
Expand Down Expand Up @@ -242,13 +379,28 @@ def plugin(
load: t.Callable = None,
):
"""
Expose a function as discoverable and loadable FunctionPlugin.
Expose a function as discoverable and loadable ``Plugin``.

Here's an example using the ``@plugin`` decorator::

from plux import plugin

@plugin(namespace="localstack.configurators")
def configure_logging(runtime):
logging.basicConfig(level=runtime.config.loglevel)


@plugin(namespace="localstack.configurators")
def configure_somethingelse(runtime):
# do other stuff with the runtime object
pass


: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
:param namespace: The plugin namespace
:param name: The name of the plugin (by default, the function name will be used)
:param should_load: Optionally either a boolean value or a callable returning a boolean
:param load: Optional load function
:return: Plugin decorator
"""

def wrapper(fn):
Expand Down
Loading