diff --git a/nitro_dispatch/__init__.py b/nitro_dispatch/__init__.py index f140e17..78a03a7 100644 --- a/nitro_dispatch/__init__.py +++ b/nitro_dispatch/__init__.py @@ -34,7 +34,7 @@ def validate_data(self, data): result = manager.trigger('before_save', {'key': 'value'}) """ -__version__ = "1.0.0" +__version__ = "1.0.2" __author__ = "Sean Nieuwoudt" __license__ = "MIT" diff --git a/nitro_dispatch/core/exceptions.py b/nitro_dispatch/core/exceptions.py index 960cbac..f242e2f 100644 --- a/nitro_dispatch/core/exceptions.py +++ b/nitro_dispatch/core/exceptions.py @@ -1,63 +1,126 @@ -""" -Custom exceptions for Nitro Plugins. +"""Exception hierarchy for Nitro Dispatch. + +All exceptions derive from :class:`NitroPluginError`, so callers can catch the +base class to handle any dispatch-related failure with a single ``except``. """ class NitroPluginError(Exception): - """Base exception for all Nitro Plugin errors.""" + """Base class for every exception raised by Nitro Dispatch. + + Catch this to handle any plugin or hook failure without enumerating + subclasses. All other exceptions in this module inherit from it. + """ pass class PluginLoadError(NitroPluginError): - """Raised when a plugin fails to load.""" + """Raised when a plugin cannot be loaded. + + Typical causes: the plugin's ``on_load`` raised, a dependency failed to + load, or hook registration errored. The original exception is attached + via ``__cause__``. + """ pass class PluginRegistrationError(NitroPluginError): - """Raised when plugin registration fails.""" + """Raised when a class cannot be registered as a plugin. + + Most commonly raised because the supplied class does not inherit from + :class:`PluginBase`. + """ pass class HookError(NitroPluginError): - """Raised when hook execution fails.""" + """Raised when a hook fails under the ``fail_fast`` error strategy. + + Wraps the underlying exception (available via ``__cause__``) and is only + raised when the registry's error strategy is set to ``fail_fast``. Under + ``log_and_continue`` or ``collect_all`` the original error is logged or + collected instead. + """ pass class PluginNotFoundError(NitroPluginError): - """Raised when a requested plugin is not found.""" + """Raised when an operation targets a plugin that is not known. + + Thrown by lookups such as :meth:`PluginManager.load`, + :meth:`PluginManager.unload`, :meth:`PluginManager.reload`, and + :meth:`PluginManager.enable_plugin` when the given plugin name is not + registered (or not loaded, where applicable). + """ pass class DependencyError(NitroPluginError): - """Raised when plugin dependencies cannot be resolved.""" + """Raised when a plugin's declared dependency cannot be loaded. + + Raised from :meth:`PluginManager.load` when a name listed in + ``dependencies`` is not registered or itself fails to load. The triggering + exception is attached via ``__cause__``. + """ pass class StopPropagation(NitroPluginError): - """Raised to stop hook propagation in the event chain.""" + """Raised by a hook to halt the remaining hook chain for an event. + + Hooks registered with a lower priority (or registered later at the same + priority) will not run. The current accumulated data is returned to the + caller of :meth:`trigger` / :meth:`trigger_async`. + + Example: + >>> class Gatekeeper(PluginBase): + ... name = "gatekeeper" + ... + ... @hook("user.login", priority=100) + ... def deny_banned(self, data): + ... if data.get("banned"): + ... raise StopPropagation("user is banned") + ... return data + """ pass class HookTimeoutError(NitroPluginError): - """Raised when a hook exceeds its timeout.""" + """Raised when a hook exceeds its configured ``timeout``. + + For sync hooks this surfaces a ``concurrent.futures.TimeoutError``; for + async hooks it surfaces an ``asyncio.TimeoutError``. The message includes + the configured timeout in seconds. + """ pass class ValidationError(NitroPluginError): - """Raised when plugin metadata validation fails.""" + """Raised when a plugin's metadata fails validation at registration. + + Triggered by :meth:`PluginManager.register` when ``name``, ``version``, + or ``dependencies`` are missing, empty, or of the wrong type. Disable by + passing ``validate_metadata=False`` to :class:`PluginManager` or + ``validate=False`` to :meth:`register`. + """ pass class PluginDiscoveryError(NitroPluginError): - """Raised when plugin discovery fails.""" + """Raised when :meth:`PluginManager.discover_plugins` fails. + + Most commonly because the target directory does not exist or is not a + directory. Errors loading individual discovered files are logged and + skipped rather than raised. + """ pass diff --git a/nitro_dispatch/core/hook_registry.py b/nitro_dispatch/core/hook_registry.py index 09a9319..16419e3 100644 --- a/nitro_dispatch/core/hook_registry.py +++ b/nitro_dispatch/core/hook_registry.py @@ -1,6 +1,4 @@ -""" -Hook registry for managing event subscriptions and triggers. -""" +"""Hook registry: event subscriptions and sync/async dispatch.""" import asyncio import concurrent.futures @@ -14,23 +12,31 @@ class HookRegistry: - """ - Manages hook registration and event triggering. + """Event bus storing hooks and dispatching them to listeners. + + Hooks are kept per event name and sorted by priority (higher first, + registration order for ties). On :meth:`trigger` / :meth:`trigger_async` + the registry gathers every hook whose registered name matches the fired + event — either literally or via a wildcard pattern like ``"user.*"`` — + and invokes them in priority order, threading the return value of each + hook into the next as its input. - This class maintains a registry of event hooks and provides both - synchronous and asynchronous execution with data filtering capabilities. + The manager owns an instance of this class; most application code does + not interact with it directly. Use it standalone when you want the hook + mechanism without plugins. Features: - - Priority-based execution - - Timeout protection - - Async/await support - - Event namespacing with wildcards - - Stop propagation support - - Enable/disable plugin filtering + - Priority-based execution with deterministic ordering. + - Per-hook timeout (thread-based for sync, ``asyncio.wait_for`` + for async). + - Wildcard event matching (``"user.*"``, ``"db.before_*"``). + - :class:`StopPropagation` to halt the chain from a hook. + - Plugin-level enable/disable: hooks from disabled plugins are + skipped without unregistering. """ - def __init__(self): - """Initialize the hook registry.""" + def __init__(self) -> None: + """Initialize an empty registry with the default error strategy.""" self._hooks: Dict[str, List[Dict[str, Any]]] = {} self._error_strategy: str = "log_and_continue" self._hook_tracing: bool = False @@ -43,15 +49,29 @@ def register( priority: int = 50, timeout: Optional[float] = None, ) -> None: - """ - Register a callback for an event. + """Register a callback to run when an event fires. + + Whether ``callback`` is treated as async is auto-detected via + :func:`asyncio.iscoroutinefunction`. Async callbacks are skipped + in :meth:`trigger` with a warning; use :meth:`trigger_async`. Args: - event_name: Name of the event (supports wildcards: 'user.*') - callback: Function to call when event is triggered - plugin: Plugin instance that owns this hook (optional) - priority: Execution priority (higher = earlier). Default: 50 - timeout: Maximum execution time in seconds + event_name: Event name to subscribe to. May be a literal like + ``"before_save"`` or a wildcard pattern like ``"user.*"`` + — wildcard patterns match multiple literal events at + dispatch time. + callback: Function invoked when the event fires. Receives + the event's data and may return modified data. + plugin: Owning plugin instance, used for attribution and to + honor ``enabled``/``disabled`` state. ``None`` for + anonymous hooks. + priority: Higher values run earlier. Default 50. + timeout: Per-hook execution limit in seconds. Exceeding + raises :class:`HookTimeoutError` inside dispatch. + + Example: + >>> reg = HookRegistry() + >>> reg.register("user.*", lambda d: d, priority=100) """ if event_name not in self._hooks: self._hooks[event_name] = [] @@ -77,16 +97,19 @@ def register( ) def unregister(self, event_name: str, callback: Callable, plugin: Optional[Any] = None) -> bool: - """ - Unregister a callback for an event. + """Remove a previously registered callback from an event. + + Match is on the exact ``(callback, plugin)`` pair. If the same + callback was registered for multiple events, each must be + unregistered separately. Args: - event_name: Name of the event - callback: Function to unregister - plugin: Plugin instance (optional) + event_name: Event name the callback was registered under. + callback: The exact callable passed to :meth:`register`. + plugin: The same owning plugin used at registration. Returns: - True if hook was found and removed, False otherwise + True if a hook was found and removed; False otherwise. """ if event_name not in self._hooks: return False @@ -204,22 +227,32 @@ async def _execute_async_hook_with_timeout( raise HookTimeoutError(f"Async hook execution exceeded timeout of {timeout}s") def trigger(self, event_name: str, data: Any = None) -> Any: - """ - Trigger an event and execute all registered hooks synchronously. + """Fire an event and run matching hooks synchronously. - Hooks are executed in priority order (highest first). - Each hook can modify the data, which is passed to the next hook. + Hooks run in priority order (highest first). Each hook's + non-``None`` return value becomes the ``data`` input of the next + hook. A hook raising :class:`StopPropagation` halts the chain + and the current ``data`` is returned immediately. Async hooks + are skipped with a warning — use :meth:`trigger_async` for + those. Args: - event_name: Name of the event to trigger - data: Data to pass to hooks (can be modified by hooks) + event_name: Event name to fire. Literal plus wildcard + matches are dispatched. + data: Payload threaded through the chain. Returns: - Modified data after passing through all hooks + The payload after the last hook returned. Raises: - HookError: If error_strategy is 'fail_fast' and a hook fails - StopPropagation: If a hook raises this to stop the chain + HookError: If the error strategy is ``"fail_fast"`` and a + hook raises. + + Example: + >>> reg = HookRegistry() + >>> reg.register("sum", lambda d: d + 1) + >>> reg.trigger("sum", 41) + 42 """ hooks = self._get_matching_hooks(event_name) @@ -331,22 +364,32 @@ def trigger(self, event_name: str, data: Any = None) -> Any: return result async def trigger_async(self, event_name: str, data: Any = None) -> Any: - """ - Trigger an event and execute all registered hooks asynchronously. + """Fire an event asynchronously, running matching hooks. - Both sync and async hooks are supported. Sync hooks are wrapped - to run in the async context. + Async hooks run natively via ``asyncio.wait_for``. Sync hooks + are dispatched to the default executor so they do not block + the event loop — which means sync hooks must be thread-safe + when invoked through this method. Ordering, stop-propagation, + and error-strategy semantics are identical to :meth:`trigger`. Args: - event_name: Name of the event to trigger - data: Data to pass to hooks + event_name: Event name to fire. + data: Payload threaded through the chain. Returns: - Modified data after passing through all hooks + The payload after the last hook returned. Raises: - HookError: If error_strategy is 'fail_fast' and a hook fails - StopPropagation: If a hook raises this to stop the chain + HookError: If the error strategy is ``"fail_fast"`` and a + hook raises. + + Example: + >>> import asyncio + >>> reg = HookRegistry() + >>> async def bump(d): return d + 1 + >>> reg.register("sum", bump) + >>> asyncio.run(reg.trigger_async("sum", 41)) + 42 """ hooks = self._get_matching_hooks(event_name) @@ -464,48 +507,67 @@ async def trigger_async(self, event_name: str, data: Any = None) -> Any: return result def get_hooks(self, event_name: str) -> List[Dict[str, Any]]: - """ - Get all hooks registered for an event (including wildcards). + """Return every hook that would run for an event, in priority order. + + Includes hooks registered against wildcard patterns that match + ``event_name``, not just literal matches. Args: - event_name: Name of the event + event_name: Event name to resolve. Returns: - List of hook information dictionaries + List of hook info dicts with keys ``callback``, ``plugin``, + ``plugin_name``, ``priority``, ``timeout``, ``is_async``. """ return self._get_matching_hooks(event_name) def get_all_events(self) -> List[str]: - """ - Get all registered event names. + """Return every registered event name. Returns: - List of event names + The literal strings used at registration — wildcard patterns + are returned as-is (e.g. ``"user.*"``). """ return list(self._hooks.keys()) def clear_event(self, event_name: str) -> None: - """ - Clear all hooks for a specific event. + """Remove every hook registered under a single event name. + + Only removes hooks registered with the literal ``event_name``; + wildcard patterns that happen to match are left intact. Args: - event_name: Name of the event to clear + event_name: Event name to clear. """ if event_name in self._hooks: del self._hooks[event_name] logger.debug(f"Cleared all hooks for event '{event_name}'") def clear_all(self) -> None: - """Clear all registered hooks.""" + """Remove every registered hook. + + Use between tests or when reconfiguring the registry from + scratch. + """ self._hooks.clear() logger.debug("Cleared all hooks") def set_error_strategy(self, strategy: str) -> None: - """ - Set the error handling strategy. + """Choose how hook exceptions are handled during dispatch. + + Strategies: + - ``"log_and_continue"`` (default): log the error and run + the next hook. + - ``"fail_fast"``: raise :class:`HookError` and abort the + chain. + - ``"collect_all"``: run every hook, then log a summary of + how many failed. Args: - strategy: One of 'log_and_continue', 'fail_fast', 'collect_all' + strategy: One of the values above. + + Raises: + ValueError: If ``strategy`` is not one of the listed names. """ valid_strategies = ["log_and_continue", "fail_fast", "collect_all"] if strategy not in valid_strategies: @@ -514,13 +576,13 @@ def set_error_strategy(self, strategy: str) -> None: logger.debug(f"Error strategy set to '{strategy}'") def enable_hook_tracing(self, enabled: bool = True) -> None: - """ - Enable or disable hook tracing for debugging. + """Toggle per-hook timing logs for debugging. - When enabled, logs detailed information about hook execution times. + When on, each dispatch logs the elapsed time of every hook at + DEBUG level. Configure the root logger at DEBUG to see output. Args: - enabled: Whether to enable tracing + enabled: True to turn tracing on, False to turn it off. """ self._hook_tracing = enabled logger.debug(f"Hook tracing {'enabled' if enabled else 'disabled'}") diff --git a/nitro_dispatch/core/plugin_base.py b/nitro_dispatch/core/plugin_base.py index c600077..4e07a42 100644 --- a/nitro_dispatch/core/plugin_base.py +++ b/nitro_dispatch/core/plugin_base.py @@ -1,21 +1,39 @@ -""" -Base class for all Nitro plugins. -""" +"""Base class every Nitro Dispatch plugin must inherit from.""" -from typing import Any, Dict, List, Optional +from typing import Any, Callable, Dict, List, Optional class PluginBase: - """ - Base class that all plugins must inherit from. + """Base class all plugins must inherit from. + + Subclass this and set class-level metadata (``name``, ``version``, ...). + Decorate methods with :func:`nitro_dispatch.hook` to register them as + event handlers, or override :meth:`on_load` and register them manually. + The :class:`PluginManager` instantiates the subclass, collects decorated + hooks, resolves dependencies, and calls :meth:`on_load`. Attributes: - name: Unique identifier for the plugin - version: Plugin version string - description: Human-readable description - author: Plugin author - dependencies: List of plugin names this plugin depends on - enabled: Whether the plugin is currently enabled + name: Unique plugin identifier. Defaults to the class name if left + empty. Used by the manager to look the plugin up. + version: Semantic version string for the plugin. + description: Short human-readable summary of what the plugin does. + author: Plugin author or team name. + dependencies: Names of other plugins that must load first. The + manager loads them recursively before this plugin. + enabled: Whether hooks from this plugin currently execute. Toggled + by :meth:`PluginManager.enable_plugin` / + :meth:`PluginManager.disable_plugin`. + + Example: + >>> from nitro_dispatch import PluginBase, hook + >>> class WelcomePlugin(PluginBase): + ... name = "welcome" + ... version = "1.0.0" + ... + ... @hook("user.login", priority=100) + ... def greet(self, data): + ... data["greeting"] = f"Hi, {data['user']}!" + ... return data """ name: str = "" @@ -24,11 +42,11 @@ class PluginBase: author: str = "" dependencies: List[str] = [] - def __init__(self): - """Initialize the plugin.""" + def __init__(self) -> None: + """Initialize the plugin instance and collect decorated hooks.""" self.enabled: bool = False self._manager: Optional[Any] = None - self._hooks: Dict[str, List[callable]] = {} + self._hooks: Dict[str, List[Any]] = {} # Shadow the class-level mutable list with a per-instance copy so # subclasses that mutate self.dependencies don't leak into siblings. @@ -43,52 +61,85 @@ def __init__(self): if "name" not in self.__class__.__dict__ and not self.name: self.name = self.__class__.__name__ - # Auto-collect decorated hooks self._collect_decorated_hooks() def on_load(self) -> None: - """ - Called when the plugin is loaded. - Override this method to register hooks and initialize resources. + """Run once when the plugin is loaded by the manager. + + Override to acquire resources, open connections, or register hooks + manually via :meth:`register_hook`. The default implementation is a + no-op, so subclasses that only use the ``@hook`` decorator do not + need to override this. + + Example: + >>> class LoggerPlugin(PluginBase): + ... name = "logger" + ... + ... def on_load(self): + ... self.register_hook("before_save", self._log, priority=10) """ pass def on_unload(self) -> None: - """ - Called when the plugin is unloaded. - Override this method to cleanup resources. + """Run once when the plugin is unloaded by the manager. + + Override to release resources acquired in :meth:`on_load`. Called + from :meth:`PluginManager.unload` and :meth:`PluginManager.reload` + before the plugin's hooks are detached from the registry. """ pass def on_error(self, error: Exception) -> None: - """ - Called when an error occurs during hook execution. + """Handle exceptions raised by this plugin's hooks. + + Called by the registry whenever one of this plugin's hooks raises + (including :class:`HookTimeoutError`). Does not supersede the + configured error strategy — the registry still logs, re-raises, or + collects the error as configured. Args: - error: The exception that was raised + error: The exception raised by the failing hook. """ pass def register_hook( self, event_name: str, - callback: callable, + callback: Callable, priority: int = 50, timeout: Optional[float] = None, ) -> None: - """ - Register a callback for a specific event. + """Register a callback for an event. + + Prefer the :func:`nitro_dispatch.hook` decorator for static hooks. + Use this for runtime registration, typically from :meth:`on_load`. + If the plugin is not yet attached to a manager, the hook is stored + and registered when the plugin loads. Args: - event_name: Name of the event to listen for - callback: Function to call when event is triggered - priority: Execution priority (higher = earlier). Default: 50 - timeout: Maximum execution time in seconds. None = no timeout + event_name: Event name to subscribe to. Supports wildcard + patterns such as ``"user.*"`` or ``"db.before_*"``. + callback: Callable invoked when the event fires. Receives the + event data and may return modified data. + priority: Execution order relative to other hooks for the same + event — higher runs first. Ties break by registration + order. + timeout: Maximum execution time in seconds, or ``None`` for no + limit. Exceeding raises :class:`HookTimeoutError`. + + Example: + >>> class MyPlugin(PluginBase): + ... name = "my_plugin" + ... + ... def on_load(self): + ... self.register_hook("user.login", self.audit, priority=90) + ... + ... def audit(self, data): + ... return data """ if self._manager: self._manager.register_hook(event_name, callback, self, priority, timeout) else: - # Store for later registration with metadata if event_name not in self._hooks: self._hooks[event_name] = [] self._hooks[event_name].append( @@ -99,54 +150,61 @@ def register_hook( } ) - def unregister_hook(self, event_name: str, callback: callable) -> None: - """ - Unregister a callback for a specific event. + def unregister_hook(self, event_name: str, callback: Callable) -> None: + """Detach a previously registered callback from an event. + + A no-op if the plugin is not attached to a manager, or if the + callback is not registered for ``event_name``. Args: - event_name: Name of the event - callback: Function to unregister + event_name: Event name the callback was registered under. + callback: The exact callable passed to :meth:`register_hook`. """ if self._manager: self._manager.unregister_hook(event_name, callback, self) def trigger(self, event_name: str, data: Any = None) -> Any: - """ - Trigger an event from within the plugin. + """Fire an event through this plugin's manager. + + Convenience wrapper so plugins can emit events without needing a + direct reference to the manager. Returns ``data`` unchanged if the + plugin is not yet attached to a manager. Args: - event_name: Name of the event to trigger - data: Data to pass to event handlers + event_name: Name of the event to trigger. + data: Payload passed to each matching hook. Returns: - Modified data after passing through all hooks + The payload after every hook in the chain has run. """ if self._manager: return self._manager.trigger(event_name, data) return data def get_config(self, key: str, default: Any = None) -> Any: - """ - Get a configuration value for this plugin. + """Read a config value scoped to this plugin. + + Looks up ``manager.config[self.name][key]``. Returns ``default`` if + the plugin is not attached to a manager or the key is absent. Args: - key: Configuration key - default: Default value if key not found + key: Configuration key within this plugin's namespace. + default: Value to return when the key is not configured. Returns: - Configuration value or default + The configured value, or ``default`` if unset. """ if self._manager: return self._manager.get_plugin_config(self.name, key, default) return default def _collect_decorated_hooks(self) -> None: - """ - Collect all methods decorated with @hook and store them. - They will be registered when the plugin is loaded. + """Gather @hook-decorated methods into ``self._hooks``. + + Called from ``__init__`` so the manager can register them at load + time. Skips private/magic attributes to avoid unnecessary access. """ for attr_name in dir(self): - # Skip private/magic methods if attr_name.startswith("_"): continue @@ -155,7 +213,6 @@ def _collect_decorated_hooks(self) -> None: except AttributeError: continue - # Check if it's a hook-decorated method if callable(attr) and hasattr(attr, "_is_hook") and attr._is_hook: event_name = attr._event_name priority = getattr(attr, "_priority", 50) @@ -172,5 +229,5 @@ def _collect_decorated_hooks(self) -> None: ) def __repr__(self) -> str: - """String representation of the plugin.""" + """Return a debug-friendly representation including name and version.""" return f"<{self.__class__.__name__} name='{self.name}' " f"version='{self.version}'>" diff --git a/nitro_dispatch/core/plugin_manager.py b/nitro_dispatch/core/plugin_manager.py index a0da8c9..8b5429e 100644 --- a/nitro_dispatch/core/plugin_manager.py +++ b/nitro_dispatch/core/plugin_manager.py @@ -1,6 +1,4 @@ -""" -Plugin manager for orchestrating plugin lifecycle and hooks. -""" +"""Plugin manager that orchestrates plugin lifecycle and hook dispatch.""" import importlib import importlib.util @@ -25,21 +23,39 @@ class PluginManager: - """ - Central orchestrator for managing plugins and their lifecycle. - - The PluginManager handles: - - Plugin registration and loading - - Hook management and event triggering (sync and async) - - Dependency resolution - - Plugin configuration - - Error handling and isolation - - Plugin discovery from directories - - Hot reloading - - Metadata validation + """Central orchestrator for plugin lifecycle and event dispatch. + + The manager is the primary entry point for application code. It + registers plugin classes, instantiates them in dependency order, + forwards their hooks into an internal :class:`HookRegistry`, and + exposes :meth:`trigger` / :meth:`trigger_async` to dispatch events. + + Typical workflow: ``register(cls)`` → ``load(name)`` (or + ``load_all()``) → ``trigger(event, data)``. Plugins can also be + auto-discovered from a directory with :meth:`discover_plugins` and + hot-swapped during development with :meth:`reload`. + + Built-in lifecycle events (exposed as class constants) fire at + registration, load, unload, and error, plus application-level + ``EVENT_APP_STARTUP`` / ``EVENT_APP_SHUTDOWN`` that callers can + trigger themselves. + + Example: + >>> from nitro_dispatch import PluginManager, PluginBase, hook + >>> class Greeter(PluginBase): + ... name = "greeter" + ... @hook("user.login") + ... def greet(self, data): + ... data["greeted"] = True + ... return data + >>> mgr = PluginManager() + >>> mgr.register(Greeter) + >>> mgr.load_all() + ['greeter'] + >>> mgr.trigger("user.login", {"user": "alice"}) + {'user': 'alice', 'greeted': True} """ - # Built-in lifecycle events EVENT_PLUGIN_REGISTERED = "nitro.plugin.registered" EVENT_PLUGIN_LOADED = "nitro.plugin.loaded" EVENT_PLUGIN_UNLOADED = "nitro.plugin.unloaded" @@ -52,15 +68,21 @@ def __init__( config: Optional[Dict[str, Any]] = None, log_level: str = "INFO", validate_metadata: bool = True, - ): - """ - Initialize the plugin manager. + ) -> None: + """Initialize the manager. Args: - config: Optional configuration dictionary for plugins - log_level: Logging level (DEBUG, INFO, WARNING, ERROR) - validate_metadata: Whether to validate plugin metadata on - registration + config: Optional per-plugin configuration, keyed by plugin + name. Exposed to plugins via + :meth:`PluginBase.get_config`. + log_level: Root logging level applied via + ``logging.basicConfig``. Accepts the usual names + (``DEBUG``, ``INFO``, ``WARNING``, ``ERROR``). + validate_metadata: When True (default), every registered + plugin has its ``name``, ``version``, and + ``dependencies`` attributes validated. Disable for + prototyping or when registering dynamically generated + classes. """ self._registry = HookRegistry() self._plugins: Dict[str, PluginBase] = {} @@ -69,29 +91,41 @@ def __init__( self._loaded: bool = False self._validate_metadata: bool = validate_metadata - # Configure logging logging.basicConfig(level=getattr(logging, log_level.upper())) def register(self, plugin_class: Type[PluginBase], validate: bool = True) -> None: - """ - Register a plugin class. + """Register a plugin class so it can later be loaded. + + Registration stores the class — no instance is kept. The class is + instantiated once temporarily to read its ``name`` and validate + metadata. Registering a name that already exists overwrites the + previous registration and logs a warning. + + Triggers ``EVENT_PLUGIN_REGISTERED`` on success. Args: - plugin_class: Plugin class that inherits from PluginBase - validate: Whether to validate plugin metadata + plugin_class: A subclass of :class:`PluginBase`. + validate: Per-call override for metadata validation. When + False, skips validation even if the manager was created + with ``validate_metadata=True``. Raises: - PluginRegistrationError: If registration fails - ValidationError: If metadata validation fails + PluginRegistrationError: If ``plugin_class`` does not inherit + from :class:`PluginBase`. + ValidationError: If metadata validation is enabled and the + plugin's ``name``, ``version``, or ``dependencies`` are + invalid. + + Example: + >>> mgr = PluginManager() + >>> mgr.register(MyPlugin) """ if not issubclass(plugin_class, PluginBase): raise PluginRegistrationError(f"{plugin_class.__name__} must inherit from PluginBase") - # Create temporary instance to get name and validate temp_instance = plugin_class() plugin_name = temp_instance.name - # Validate metadata if enabled if validate and self._validate_metadata: self._validate_plugin_metadata(temp_instance) @@ -101,22 +135,13 @@ def register(self, plugin_class: Type[PluginBase], validate: bool = True) -> Non self._plugin_classes[plugin_name] = plugin_class logger.info(f"Registered plugin class '{plugin_name}' v{temp_instance.version}") - # Trigger lifecycle event self.trigger( self.EVENT_PLUGIN_REGISTERED, {"plugin_name": plugin_name, "version": temp_instance.version}, ) def _validate_plugin_metadata(self, plugin: PluginBase) -> None: - """ - Validate plugin metadata. - - Args: - plugin: Plugin instance to validate - - Raises: - ValidationError: If validation fails - """ + """Validate ``name``, ``version``, and ``dependencies`` on an instance.""" if not plugin.name or not isinstance(plugin.name, str): raise ValidationError("Plugin must have a valid 'name' attribute") @@ -129,19 +154,17 @@ def _validate_plugin_metadata(self, plugin: PluginBase) -> None: logger.debug(f"Plugin '{plugin.name}' metadata validated successfully") def unregister(self, plugin_name: str) -> None: - """ - Unregister and unload a plugin. + """Remove a plugin's registration, unloading it first if needed. Args: - plugin_name: Name of the plugin to unregister + plugin_name: Name of the plugin to remove. Raises: - PluginNotFoundError: If plugin is not found + PluginNotFoundError: If the plugin is not registered. """ if plugin_name not in self._plugin_classes: raise PluginNotFoundError(f"Plugin '{plugin_name}' not found") - # Unload if loaded if plugin_name in self._plugins: self.unload(plugin_name) @@ -149,19 +172,28 @@ def unregister(self, plugin_name: str) -> None: logger.info(f"Unregistered plugin '{plugin_name}'") def load(self, plugin_name: str) -> PluginBase: - """ - Load a specific plugin. + """Instantiate a registered plugin and attach its hooks. + + Loading recursively loads every name in the plugin's + ``dependencies`` first, then instantiates the target class, wires + up its decorated hooks, and calls :meth:`PluginBase.on_load`. + If the plugin is already loaded, the existing instance is + returned and a warning is logged. + + Triggers ``EVENT_PLUGIN_LOADED`` on success and + ``EVENT_PLUGIN_ERROR`` on failure. Args: - plugin_name: Name of the plugin to load + plugin_name: Name of a registered plugin. Returns: - Loaded plugin instance + The loaded plugin instance. Raises: - PluginNotFoundError: If plugin is not registered - PluginLoadError: If loading fails - DependencyError: If dependencies cannot be resolved + PluginNotFoundError: If the plugin is not registered. + PluginLoadError: If instantiation, hook registration, or + ``on_load`` raises. + DependencyError: If any dependency fails to load. """ if plugin_name not in self._plugin_classes: raise PluginNotFoundError(f"Plugin '{plugin_name}' not registered") @@ -173,11 +205,9 @@ def load(self, plugin_name: str) -> PluginBase: plugin_class = self._plugin_classes[plugin_name] try: - # Create plugin instance plugin = plugin_class() plugin._manager = self - # Check and load dependencies for dep_name in plugin.dependencies: if dep_name not in self._plugins: logger.info(f"Loading dependency '{dep_name}' for '{plugin_name}'") @@ -188,11 +218,9 @@ def load(self, plugin_name: str) -> PluginBase: f"Failed to load dependency '{dep_name}' for " f"'{plugin_name}': {e}" ) from e - # Register hooks that were stored during initialization for event_name, hook_list in plugin._hooks.items(): for hook_data in hook_list: if isinstance(hook_data, dict): - # New format with metadata self.register_hook( event_name, hook_data["callback"], @@ -201,17 +229,15 @@ def load(self, plugin_name: str) -> PluginBase: hook_data.get("timeout"), ) else: - # Old format (just callback) + # Legacy format: bare callable stored without metadata. self.register_hook(event_name, hook_data, plugin) - # Call on_load hook plugin.on_load() plugin.enabled = True self._plugins[plugin_name] = plugin logger.info(f"Loaded plugin '{plugin_name}' v{plugin.version}") - # Trigger lifecycle event self.trigger( self.EVENT_PLUGIN_LOADED, {"plugin_name": plugin_name, "version": plugin.version}, @@ -229,19 +255,25 @@ def load(self, plugin_name: str) -> PluginBase: raise PluginLoadError(f"Failed to load plugin '{plugin_name}': {e}") from e def load_all(self) -> List[str]: - """ - Load all registered plugins in dependency order. + """Load every registered plugin, respecting dependencies. + + Iterates registered plugins and calls :meth:`load` on each. + Individual load failures are logged (not raised) so one broken + plugin does not block the rest; the returned list contains only + the names that loaded successfully. Returns: - List of loaded plugin names + Names of plugins that loaded successfully, in load order. - Raises: - PluginLoadError: If any plugin fails to load + Example: + >>> mgr = PluginManager() + >>> mgr.register(PluginA) + >>> mgr.register(PluginB) + >>> loaded = mgr.load_all() """ - loaded_plugins = [] - failed_plugins = [] + loaded_plugins: List[str] = [] + failed_plugins: List[str] = [] - # Try to load all plugins, dependencies will be loaded automatically for plugin_name in self._plugin_classes.keys(): if plugin_name not in self._plugins: try: @@ -260,14 +292,16 @@ def load_all(self) -> List[str]: return loaded_plugins def unload(self, plugin_name: str) -> None: - """ - Unload a specific plugin. + """Unload a single plugin and detach its hooks. + + Calls :meth:`PluginBase.on_unload` before removing hooks from + the registry. Triggers ``EVENT_PLUGIN_UNLOADED``. Args: - plugin_name: Name of the plugin to unload + plugin_name: Name of a currently-loaded plugin. Raises: - PluginNotFoundError: If plugin is not loaded + PluginNotFoundError: If the plugin is not currently loaded. """ if plugin_name not in self._plugins: raise PluginNotFoundError(f"Plugin '{plugin_name}' not loaded") @@ -275,11 +309,9 @@ def unload(self, plugin_name: str) -> None: plugin = self._plugins[plugin_name] try: - # Call on_unload hook plugin.on_unload() plugin.enabled = False - # Remove all hooks from this plugin for event_name in self._registry.get_all_events(): hooks = self._registry.get_hooks(event_name) for hook_info in hooks: @@ -289,7 +321,6 @@ def unload(self, plugin_name: str) -> None: del self._plugins[plugin_name] logger.info(f"Unloaded plugin '{plugin_name}'") - # Trigger lifecycle event self.trigger(self.EVENT_PLUGIN_UNLOADED, {"plugin_name": plugin_name}) except Exception as e: @@ -297,7 +328,11 @@ def unload(self, plugin_name: str) -> None: raise def unload_all(self) -> None: - """Unload all loaded plugins.""" + """Unload every currently-loaded plugin. + + Errors from individual unloads are logged and do not halt the + sweep; the manager's loaded-state flag is cleared on completion. + """ plugin_names = list(self._plugins.keys()) for plugin_name in plugin_names: try: @@ -309,28 +344,32 @@ def unload_all(self) -> None: logger.info("Unloaded all plugins") def reload(self, plugin_name: str) -> PluginBase: - """ - Hot reload a plugin (unload and load again). + """Hot-reload a plugin, picking up source changes on disk. + + Unloads the plugin (if loaded), reloads its defining module via + :func:`importlib.reload`, refreshes the stored class reference + so the new definition is used, then calls :meth:`load`. Useful + during development for editing a plugin without restarting the + host process. Args: - plugin_name: Name of the plugin to reload + plugin_name: Name of a registered plugin. Returns: - Reloaded plugin instance + The freshly-loaded plugin instance. Raises: - PluginNotFoundError: If plugin is not found + PluginNotFoundError: If the plugin is not registered. + PluginLoadError: If the post-reload load fails. """ if plugin_name not in self._plugin_classes: raise PluginNotFoundError(f"Plugin '{plugin_name}' not registered") logger.info(f"Reloading plugin '{plugin_name}'") - # Unload if currently loaded if plugin_name in self._plugins: self.unload(plugin_name) - # Reload the plugin module if it's a module-based plugin plugin_class = self._plugin_classes[plugin_name] if hasattr(plugin_class, "__module__"): module_name = plugin_class.__module__ @@ -350,7 +389,6 @@ def reload(self, plugin_name: str) -> PluginBase: self._plugin_classes[plugin_name] = obj break - # Load the plugin return self.load(plugin_name) def discover_plugins( @@ -359,19 +397,32 @@ def discover_plugins( pattern: str = "*_plugin.py", recursive: bool = False, ) -> List[str]: - """ - Discover and register plugins from a directory. + """Auto-register every plugin class found in a directory. + + Walks ``directory`` (optionally recursively), imports each file + matching ``pattern`` by file path, and registers every + :class:`PluginBase` subclass defined directly in that module. + Errors from individual files are logged and skipped so one + broken plugin does not halt discovery. Args: - directory: Directory path to search for plugins - pattern: File pattern to match (supports glob patterns) - recursive: Whether to search recursively + directory: Directory to search. Expanded and resolved to an + absolute path. + pattern: Glob pattern for plugin files. Defaults to + ``"*_plugin.py"`` — convention, not enforcement. + recursive: If True, descend into subdirectories. Returns: - List of discovered plugin names + Names of plugins successfully registered during this call. Raises: - PluginDiscoveryError: If discovery fails + PluginDiscoveryError: If the directory does not exist, is + not a directory, or the traversal itself errors. + + Example: + >>> mgr = PluginManager() + >>> mgr.discover_plugins("./plugins", recursive=True) + ['welcome', 'logger'] """ directory = Path(directory).expanduser().resolve() @@ -380,10 +431,9 @@ def discover_plugins( logger.info(f"Discovering plugins in '{directory}' (pattern: {pattern})") - discovered = [] + discovered: List[str] = [] try: - # Find matching files if recursive: plugin_files = directory.rglob(pattern) else: @@ -394,7 +444,6 @@ def discover_plugins( continue try: - # Load the module module_name = plugin_file.stem spec = importlib.util.spec_from_file_location(module_name, plugin_file) if spec and spec.loader: @@ -402,7 +451,6 @@ def discover_plugins( sys.modules[module_name] = module spec.loader.exec_module(module) - # Find PluginBase subclasses in the module for name, obj in inspect.getmembers(module, inspect.isclass): if ( issubclass(obj, PluginBase) @@ -435,15 +483,20 @@ def register_hook( priority: int = 50, timeout: Optional[float] = None, ) -> None: - """ - Register a hook for an event. + """Register a callback for an event on the underlying registry. + + Usually called indirectly via :meth:`PluginBase.register_hook` + or the :func:`nitro_dispatch.hook` decorator. Call this directly + to attach hooks that don't belong to a plugin (``plugin=None``). Args: - event_name: Name of the event - callback: Function to call when event is triggered - plugin: Plugin instance (optional) - priority: Execution priority (higher = earlier) - timeout: Maximum execution time in seconds + event_name: Event name to subscribe to. Supports wildcards + like ``"user.*"`` or ``"db.before_*"``. + callback: Callable invoked when the event fires. + plugin: Owning plugin instance, or ``None`` for anonymous + hooks. Disabled plugins have their hooks skipped. + priority: Execution order — higher runs first. + timeout: Maximum execution time in seconds, or ``None``. """ self._registry.register(event_name, callback, plugin, priority, timeout) @@ -453,102 +506,122 @@ def unregister_hook( callback: Callable, plugin: Optional[PluginBase] = None, ) -> None: - """ - Unregister a hook for an event. + """Detach a previously registered hook from the registry. Args: - event_name: Name of the event - callback: Function to unregister - plugin: Plugin instance (optional) + event_name: Event name the callback was registered under. + callback: The exact callable previously passed to + :meth:`register_hook`. + plugin: The same owning plugin (or ``None``) used at + registration; the pair must match exactly. """ self._registry.unregister(event_name, callback, plugin) def trigger(self, event_name: str, data: Any = None) -> Any: - """ - Trigger an event and execute all registered hooks synchronously. + """Fire an event and run matching hooks synchronously. + + Async hooks are skipped with a warning — use + :meth:`trigger_async` when any listener is ``async def``. Each + hook that returns a non-``None`` value replaces ``data`` for + the next hook in the chain. Args: - event_name: Name of the event to trigger - data: Data to pass to hooks + event_name: Event name to fire. Matched literally plus by + any wildcard patterns registered against it. + data: Payload threaded through the hook chain. Returns: - Modified data after passing through all hooks + The payload after the last hook returned. + + Raises: + HookError: If the error strategy is ``fail_fast`` and a + hook raises. + + Example: + >>> mgr.trigger("user.login", {"user": "alice"}) + {'user': 'alice', 'greeted': True} """ return self._registry.trigger(event_name, data) async def trigger_async(self, event_name: str, data: Any = None) -> Any: - """ - Trigger an event and execute all registered hooks asynchronously. + """Fire an event and run matching hooks asynchronously. + + Handles both sync and async hooks. Sync hooks are dispatched to + a thread-pool executor so they don't block the event loop; this + means sync hooks must be thread-safe when invoked this way. Args: - event_name: Name of the event to trigger - data: Data to pass to hooks + event_name: Event name to fire. + data: Payload threaded through the hook chain. Returns: - Modified data after passing through all hooks + The payload after the last hook returned. + + Raises: + HookError: If the error strategy is ``fail_fast`` and a + hook raises. + + Example: + >>> await mgr.trigger_async("fetch_data", {"id": 42}) """ return await self._registry.trigger_async(event_name, data) def get_plugin(self, plugin_name: str) -> Optional[PluginBase]: - """ - Get a loaded plugin by name. + """Return a loaded plugin by name, or ``None`` if not loaded. Args: - plugin_name: Name of the plugin + plugin_name: Name of the plugin to retrieve. Returns: - Plugin instance or None if not loaded + The plugin instance, or ``None`` if no plugin with that + name is currently loaded. """ return self._plugins.get(plugin_name) def get_all_plugins(self) -> Dict[str, PluginBase]: - """ - Get all loaded plugins. + """Return a shallow copy of the loaded-plugins map. Returns: - Dictionary of plugin name to plugin instance + A new dict mapping plugin name to plugin instance. Mutating + the returned dict does not affect the manager's state. """ return self._plugins.copy() def get_registered_plugins(self) -> List[str]: - """ - Get names of all registered plugin classes. + """Return names of every registered plugin class. Returns: - List of plugin names + Plugin names, including those not yet loaded. """ return list(self._plugin_classes.keys()) def get_loaded_plugins(self) -> List[str]: - """ - Get names of all loaded plugins. + """Return names of every currently-loaded plugin. Returns: - List of plugin names + Plugin names for loaded instances only. """ return list(self._plugins.keys()) def is_loaded(self, plugin_name: str) -> bool: - """ - Check if a plugin is loaded. + """Report whether a plugin is currently loaded. Args: - plugin_name: Name of the plugin + plugin_name: Name of the plugin to check. Returns: - True if plugin is loaded, False otherwise + True if a live instance exists; False otherwise. """ return plugin_name in self._plugins def enable_plugin(self, plugin_name: str) -> None: - """ - Enable a loaded plugin. + """Enable a loaded plugin so its hooks execute again. Args: - plugin_name: Name of the plugin + plugin_name: Name of a loaded plugin. Raises: - PluginNotFoundError: If plugin is not loaded + PluginNotFoundError: If the plugin is not loaded. """ if plugin_name not in self._plugins: raise PluginNotFoundError(f"Plugin '{plugin_name}' not loaded") @@ -557,14 +630,17 @@ def enable_plugin(self, plugin_name: str) -> None: logger.info(f"Enabled plugin '{plugin_name}'") def disable_plugin(self, plugin_name: str) -> None: - """ - Disable a loaded plugin (keeps it loaded but hooks won't execute). + """Disable a loaded plugin without unloading it. + + The plugin instance stays loaded, its hooks stay registered, + but the registry skips them on dispatch. Re-enable with + :meth:`enable_plugin`. Args: - plugin_name: Name of the plugin + plugin_name: Name of a loaded plugin. Raises: - PluginNotFoundError: If plugin is not loaded + PluginNotFoundError: If the plugin is not loaded. """ if plugin_name not in self._plugins: raise PluginNotFoundError(f"Plugin '{plugin_name}' not loaded") @@ -573,43 +649,55 @@ def disable_plugin(self, plugin_name: str) -> None: logger.info(f"Disabled plugin '{plugin_name}'") def get_plugin_config(self, plugin_name: str, key: str, default: Any = None) -> Any: - """ - Get configuration value for a plugin. + """Read a config value for a specific plugin. Args: - plugin_name: Name of the plugin - key: Configuration key - default: Default value if not found + plugin_name: Plugin namespace (top-level config key). + key: Key within that plugin's config dict. + default: Value to return when either the plugin or the key + is missing. Returns: - Configuration value or default + The configured value, or ``default`` if unset. """ plugin_config = self._config.get(plugin_name, {}) return plugin_config.get(key, default) def set_error_strategy(self, strategy: str) -> None: - """ - Set the error handling strategy for hooks. + """Choose how hook exceptions are handled during dispatch. + + Strategies: + - ``"log_and_continue"`` (default): log the error and run + the next hook. + - ``"fail_fast"``: raise :class:`HookError` and abort the + chain. + - ``"collect_all"``: run every hook, then log a summary. Args: - strategy: One of 'log_and_continue', 'fail_fast', 'collect_all' + strategy: One of the values above. + + Raises: + ValueError: If ``strategy`` is not one of the listed names. """ self._registry.set_error_strategy(strategy) def enable_hook_tracing(self, enabled: bool = True) -> None: - """ - Enable or disable hook execution tracing for debugging. + """Toggle per-hook timing logs for debugging. + + When enabled, every dispatch logs the elapsed time of each + hook at DEBUG level. Combine with ``log_level="DEBUG"`` on + the manager to actually see the output. Args: - enabled: Whether to enable tracing + enabled: True to turn tracing on, False to turn it off. """ self._registry.enable_hook_tracing(enabled) def get_events(self) -> List[str]: - """ - Get all registered event names. + """Return every event name with at least one registered hook. Returns: - List of event names + Event names — includes wildcard patterns (e.g. ``"user.*"``) + as they were registered. """ return self._registry.get_all_events() diff --git a/nitro_dispatch/utils/decorators.py b/nitro_dispatch/utils/decorators.py index e2a4ebd..d6582a1 100644 --- a/nitro_dispatch/utils/decorators.py +++ b/nitro_dispatch/utils/decorators.py @@ -1,6 +1,4 @@ -""" -Decorators for Nitro Plugins. -""" +"""Decorators for declaring plugin hooks.""" import asyncio from functools import wraps @@ -13,35 +11,45 @@ def hook( timeout: Optional[float] = None, async_hook: bool = False, ) -> Callable: - """ - Decorator to mark a method as a hook for a specific event. + """Mark a plugin method as a hook for an event. - This decorator automatically registers the method as a hook - when the plugin is loaded. + The decorator attaches metadata to the wrapped method; + :class:`PluginBase` collects every method marked this way when the + plugin is instantiated and registers them with the manager on load. + Works for both sync and ``async def`` methods — async is detected + automatically, so ``async_hook`` is only needed in unusual cases. Args: - event_name: Name of the event to listen for (supports wildcards: - 'user.*') - priority: Execution priority (higher = earlier). Default: 50 - timeout: Maximum execution time in seconds. None = no timeout - async_hook: Whether this is an async hook (auto-detected if not - specified) + event_name: Event to subscribe to. Supports wildcard patterns + like ``"user.*"`` or ``"db.before_*"``. + priority: Execution order relative to other hooks on the same + event — higher runs first. Default 50. + timeout: Per-call execution limit in seconds, or ``None`` for + no limit. Exceeding raises :class:`HookTimeoutError`. + async_hook: Force-mark the method as async. Normally left + ``False`` — auto-detection covers the common cases. + + Returns: + A decorator that wraps the method with hook metadata and + preserves its original signature via :func:`functools.wraps`. Example: - class MyPlugin(PluginBase): - @hook('before_save', priority=100) - def validate_data(self, data): - data['validated'] = True - return data - - @hook('user.*', priority=10) # Wildcard event - def log_user_action(self, data): - return data - - @hook('fetch_data', timeout=5.0) - async def fetch_async(self, data): - result = await some_api() - return result + >>> from nitro_dispatch import PluginBase, hook + >>> class MyPlugin(PluginBase): + ... name = "my_plugin" + ... + ... @hook("before_save", priority=100) + ... def validate(self, data): + ... data["validated"] = True + ... return data + ... + ... @hook("user.*", priority=10) + ... def log_user_action(self, data): + ... return data + ... + ... @hook("fetch_data", timeout=5.0) + ... async def fetch(self, data): + ... return data """ def decorator(func: Callable) -> Callable: diff --git a/pyproject.toml b/pyproject.toml index 38d7ab2..8aaef40 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "nitro-dispatch" -version = "1.0.1" +version = "1.0.2" description = "A powerful, framework-agnostic plugin system for Python with advanced features like async/await support, hook priorities, timeouts, event namespacing, and plugin discovery." readme = "README.md" authors = [