Source code for lembas.plugins
from __future__ import annotations
import importlib.util
import inspect
import sys
from collections.abc import Iterator
from pathlib import Path
from types import ModuleType
from pluggy import HookimplMarker
from pluggy import HookspecMarker
from pluggy import PluginManager
from rich import print
from lembas import Case
__all__ = ["register", "registry", "load_plugins_from_file", "CaseHandlerNotFound"]
[docs]
class CaseHandlerNotFound(AttributeError):
pass
class CaseHandlerRegistry:
"""The case handler registry contains all registered case handlers, which are provided by plugins."""
def __init__(self) -> None:
self._registry: dict[str, type[Case]] = {}
def add(self, cls: type[Case]) -> None:
"""Add a new class to the case handler registry."""
# FIXME: There is no de-duplication by path, so case handlers with the same class
# name will clobber each other.
self._registry[cls.__name__] = cls
def get(self, name: str) -> type[Case]:
"""Retrieve a case handler by name from the registry.
Args:
name: The name of the case handler class.
"""
try:
return self._registry[name]
except KeyError:
raise CaseHandlerNotFound(
f"Could not find [bold]{name}[/bold] in the case handler registry"
)
def clear(self) -> None:
"""Clear the case handler registry."""
self._registry.clear()
registry = CaseHandlerRegistry()
def _load_module_from_path(mod_path: Path) -> ModuleType:
"""Load a module from a filesystem Path.
Args:
mod_path: A path to the `.py` file.
Returns:
The imported module object.
"""
mod_name = mod_path.stem
spec = importlib.util.spec_from_file_location(mod_name, mod_path)
if spec is None: # pragma: no cover
raise LookupError(f"Cannot load module {mod_path}")
if spec.loader is None: # pragma: no cover
raise ValueError(f"Invalid spec loader while loading {mod_path}")
mod = importlib.util.module_from_spec(spec)
sys.modules[mod_name] = mod
spec.loader.exec_module(mod)
return mod
[docs]
def load_plugins_from_file(plugin_path: Path) -> None:
"""Load all defined plugins from a module from a filesystem Path.
Args:
plugin_path: A path to the `.py` file containing the `Case` subclasses.
"""
print("Loading plugins")
plugin_path = plugin_path.resolve()
mod = _load_module_from_path(plugin_path)
for name, obj in mod.__dict__.items():
if inspect.isclass(obj):
if issubclass(obj, Case) and obj != Case:
registry.add(obj)
print(f"Found [bold]{name}[/bold] in {plugin_path}")
hookspec = HookspecMarker("lembas")
register = HookimplMarker("lembas")
@hookspec
def lembas_case_handlers() -> Iterator[type[Case]]:
"""In a plugin package, yield multiple case handlers from this function."""
yield Case # pragma: no cover
def _load_plugins_via_entrypoints() -> None:
# Create the default PluginManager
pm = PluginManager("lembas")
# Register the hooks specifications available for lembas plugins
pm.add_hookspecs(sys.modules[__name__])
# Load plugins registered via setuptools entrypoints
pm.load_setuptools_entrypoints("lembas")
# Register the case handlers from plugins that have been loaded and used the `lembas_case_handlers` hook.
for ch in pm.hook.lembas_case_handlers(): # pragma: no cover
# Handle the case where we return a single case handler instead of using a generator
if inspect.isclass(ch):
ch = [ch]
for cls in ch:
registry.add(cls)
# Also, load any subclasses that were registered during imports
for cls in Case.__subclasses__():
registry.add(cls)
_load_plugins_via_entrypoints()