From b31a61f609bd0de6208902cdeccb9f1bea7d4f8f Mon Sep 17 00:00:00 2001 From: Sean N Date: Sat, 16 May 2026 21:55:40 +0200 Subject: [PATCH] Maintenance and performance tweaks --- nitro_dispatch/__init__.py | 2 +- nitro_dispatch/core/hook_registry.py | 21 +++++++++--------- nitro_dispatch/core/plugin_base.py | 6 ++--- nitro_dispatch/core/plugin_manager.py | 32 ++++++++++++++++----------- nitro_dispatch/utils/decorators.py | 13 ++++++++--- pyproject.toml | 2 +- tests/test_decorators.py | 15 +++++++++++++ tests/test_hook_registry.py | 16 ++++++++++++++ 8 files changed, 76 insertions(+), 31 deletions(-) diff --git a/nitro_dispatch/__init__.py b/nitro_dispatch/__init__.py index 78a03a7..3492d19 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.2" +__version__ = "1.1.0" __author__ = "Sean Nieuwoudt" __license__ = "MIT" diff --git a/nitro_dispatch/core/hook_registry.py b/nitro_dispatch/core/hook_registry.py index 16419e3..c5c88ff 100644 --- a/nitro_dispatch/core/hook_registry.py +++ b/nitro_dispatch/core/hook_registry.py @@ -17,7 +17,7 @@ class HookRegistry: 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.*"`` — + 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. @@ -57,8 +57,8 @@ def register( Args: 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 + ``"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. @@ -137,8 +137,9 @@ def _match_event_pattern(self, pattern: str, event: str) -> bool: Returns: True if event matches pattern """ - # Convert wildcard pattern to regex - regex_pattern = pattern.replace(".", r"\.").replace("*", ".*") + # `*` matches a single dot-delimited segment, mirroring glob semantics + # rather than regex `.*` (which would cross segment boundaries). + regex_pattern = pattern.replace(".", r"\.").replace("*", "[^.]*") regex_pattern = f"^{regex_pattern}$" return bool(re.match(regex_pattern, event)) @@ -190,7 +191,7 @@ def _execute_hook_with_timeout( # Thread-based timeout: portable (works on Windows and in non-main # threads, unlike signal.SIGALRM) and safe to call from executors. - # Note: the worker thread cannot be forcibly killed on timeout — this + # Note: the worker thread cannot be forcibly killed on timeout. This # matches asyncio.wait_for's behavior for async hooks. with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor: future = executor.submit(callback, data) @@ -233,7 +234,7 @@ def trigger(self, event_name: str, data: Any = None) -> Any: 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 + are skipped with a warning; use :meth:`trigger_async` for those. Args: @@ -368,7 +369,7 @@ async def trigger_async(self, event_name: str, data: Any = None) -> Any: 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 + 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`. @@ -428,7 +429,7 @@ async def trigger_async(self, event_name: str, data: Any = None) -> Any: ) else: # Run sync hook in executor to avoid blocking - loop = asyncio.get_event_loop() + loop = asyncio.get_running_loop() new_result = await loop.run_in_executor( None, self._execute_hook_with_timeout, @@ -525,7 +526,7 @@ def get_all_events(self) -> List[str]: """Return every registered event name. Returns: - The literal strings used at registration — wildcard patterns + The literal strings used at registration. Wildcard patterns are returned as-is (e.g. ``"user.*"``). """ return list(self._hooks.keys()) diff --git a/nitro_dispatch/core/plugin_base.py b/nitro_dispatch/core/plugin_base.py index 4e07a42..a39d64f 100644 --- a/nitro_dispatch/core/plugin_base.py +++ b/nitro_dispatch/core/plugin_base.py @@ -50,7 +50,7 @@ def __init__(self) -> None: # Shadow the class-level mutable list with a per-instance copy so # subclasses that mutate self.dependencies don't leak into siblings. - # Only shadow when the class value is actually a list — otherwise + # Only shadow when the class value is actually a list; otherwise # leave the invalid type intact for metadata validation to surface. if isinstance(self.__class__.dependencies, list): self.dependencies = list(self.__class__.dependencies) @@ -94,7 +94,7 @@ def on_error(self, error: Exception) -> None: 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 + configured error strategy; the registry still logs, re-raises, or collects the error as configured. Args: @@ -122,7 +122,7 @@ def register_hook( 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 + event; higher runs first. Ties break by registration order. timeout: Maximum execution time in seconds, or ``None`` for no limit. Exceeding raises :class:`HookTimeoutError`. diff --git a/nitro_dispatch/core/plugin_manager.py b/nitro_dispatch/core/plugin_manager.py index 8b5429e..90ef407 100644 --- a/nitro_dispatch/core/plugin_manager.py +++ b/nitro_dispatch/core/plugin_manager.py @@ -93,10 +93,10 @@ def __init__( logging.basicConfig(level=getattr(logging, log_level.upper())) - def register(self, plugin_class: Type[PluginBase], validate: bool = True) -> None: + def register(self, plugin_class: Type[PluginBase], validate: bool = True) -> str: """Register a plugin class so it can later be loaded. - Registration stores the class — no instance is kept. The class is + 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. @@ -109,6 +109,9 @@ def register(self, plugin_class: Type[PluginBase], validate: bool = True) -> Non False, skips validation even if the manager was created with ``validate_metadata=True``. + Returns: + The plugin's ``name`` as resolved at registration time. + Raises: PluginRegistrationError: If ``plugin_class`` does not inherit from :class:`PluginBase`. @@ -119,6 +122,7 @@ def register(self, plugin_class: Type[PluginBase], validate: bool = True) -> Non Example: >>> mgr = PluginManager() >>> mgr.register(MyPlugin) + 'my_plugin' """ if not issubclass(plugin_class, PluginBase): raise PluginRegistrationError(f"{plugin_class.__name__} must inherit from PluginBase") @@ -140,6 +144,8 @@ def register(self, plugin_class: Type[PluginBase], validate: bool = True) -> Non {"plugin_name": plugin_name, "version": temp_instance.version}, ) + return plugin_name + def _validate_plugin_metadata(self, plugin: PluginBase) -> None: """Validate ``name``, ``version``, and ``dependencies`` on an instance.""" if not plugin.name or not isinstance(plugin.name, str): @@ -380,12 +386,13 @@ def reload(self, plugin_name: str) -> PluginBase: # importlib.reload replaces the module's classes with new # objects. Refresh our stored class reference so the subsequent # load() instantiates the new code, not the pre-reload class. + # Read the name from class attrs to avoid running __init__ on + # every PluginBase subclass in the module during reload. for _, obj in inspect.getmembers(reloaded_module, inspect.isclass): - if ( - issubclass(obj, PluginBase) - and obj is not PluginBase - and obj().name == plugin_name - ): + if not issubclass(obj, PluginBase) or obj is PluginBase: + continue + candidate_name = obj.name if obj.__dict__.get("name") else obj.__name__ + if candidate_name == plugin_name: self._plugin_classes[plugin_name] = obj break @@ -409,7 +416,7 @@ def discover_plugins( directory: Directory to search. Expanded and resolved to an absolute path. pattern: Glob pattern for plugin files. Defaults to - ``"*_plugin.py"`` — convention, not enforcement. + ``"*_plugin.py"`` by convention, not enforcement. recursive: If True, descend into subdirectories. Returns: @@ -458,8 +465,7 @@ def discover_plugins( and obj.__module__ == module_name ): - self.register(obj) - plugin_name = obj().name + plugin_name = self.register(obj) discovered.append(plugin_name) logger.debug( f"Discovered plugin '{plugin_name}' from {plugin_file}" @@ -495,7 +501,7 @@ def register_hook( 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. + priority: Execution order; higher runs first. timeout: Maximum execution time in seconds, or ``None``. """ self._registry.register(event_name, callback, plugin, priority, timeout) @@ -520,7 +526,7 @@ def unregister_hook( def trigger(self, event_name: str, data: Any = None) -> Any: """Fire an event and run matching hooks synchronously. - Async hooks are skipped with a warning — use + 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. @@ -697,7 +703,7 @@ def get_events(self) -> List[str]: """Return every event name with at least one registered hook. Returns: - Event names — includes wildcard patterns (e.g. ``"user.*"``) + 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 d6582a1..be758fa 100644 --- a/nitro_dispatch/utils/decorators.py +++ b/nitro_dispatch/utils/decorators.py @@ -16,18 +16,18 @@ def hook( 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 + Works for both sync and ``async def`` methods. Async is detected automatically, so ``async_hook`` is only needed in unusual cases. Args: 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. + 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. + ``False``; auto-detection covers the common cases. Returns: A decorator that wraps the method with hook metadata and @@ -53,6 +53,13 @@ def hook( """ def decorator(func: Callable) -> Callable: + if getattr(func, "_is_hook", False): + raise TypeError( + f"@hook cannot be stacked on '{getattr(func, '__name__', func)}'. " + f"Each method subscribes to a single event. Already bound to " + f"'{getattr(func, '_event_name', '')}'." + ) + is_async = async_hook or asyncio.iscoroutinefunction(func) if is_async: diff --git a/pyproject.toml b/pyproject.toml index 8aaef40..c5f6c37 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "nitro-dispatch" -version = "1.0.2" +version = "1.1.0" 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 = [ diff --git a/tests/test_decorators.py b/tests/test_decorators.py index 2a27d1d..6972c7e 100644 --- a/tests/test_decorators.py +++ b/tests/test_decorators.py @@ -3,6 +3,7 @@ """ import asyncio +import pytest from nitro_dispatch.utils.decorators import hook from nitro_dispatch import PluginBase @@ -146,3 +147,17 @@ def hook2(self, data): # Hooks should be stored in _hooks dict assert "event1" in plugin._hooks assert "event2" in plugin._hooks + + +def test_hook_decorator_rejects_stacking(): + """Stacking @hook on the same method raises TypeError.""" + + with pytest.raises(TypeError, match="cannot be stacked"): + + class _Bad(PluginBase): + name = "bad" + + @hook("event.a") + @hook("event.b") + def handler(self, data): + return data diff --git a/tests/test_hook_registry.py b/tests/test_hook_registry.py index 224f51a..d72a5a7 100644 --- a/tests/test_hook_registry.py +++ b/tests/test_hook_registry.py @@ -198,6 +198,22 @@ def callback(data): assert "matched" not in result3 +def test_wildcard_matches_single_segment_only(registry): + """`*` matches one dot-delimited segment, not many.""" + + def callback(data): + data["matched"] = True + return data + + registry.register("user.*", callback) + + result_direct = registry.trigger("user.login", {}) + assert result_direct["matched"] is True + + result_nested = registry.trigger("user.profile.update", {}) + assert "matched" not in result_nested + + def test_stop_propagation(registry): """Test stop propagation.""" called = []