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
2 changes: 1 addition & 1 deletion nitro_dispatch/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
21 changes: 11 additions & 10 deletions nitro_dispatch/core/hook_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
eventeither 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.

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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))

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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`.

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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())
Expand Down
6 changes: 3 additions & 3 deletions nitro_dispatch/core/plugin_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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`.
Expand Down
32 changes: 19 additions & 13 deletions nitro_dispatch/core/plugin_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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`.
Expand All @@ -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")
Expand All @@ -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):
Expand Down Expand Up @@ -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

Expand All @@ -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:
Expand Down Expand Up @@ -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}"
Expand Down Expand Up @@ -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)
Expand All @@ -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.
Expand Down Expand Up @@ -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()
13 changes: 10 additions & 3 deletions nitro_dispatch/utils/decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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', '<unknown>')}'."
)

is_async = async_hook or asyncio.iscoroutinefunction(func)

if is_async:
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
15 changes: 15 additions & 0 deletions tests/test_decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"""

import asyncio
import pytest
from nitro_dispatch.utils.decorators import hook
from nitro_dispatch import PluginBase

Expand Down Expand Up @@ -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
16 changes: 16 additions & 0 deletions tests/test_hook_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []
Expand Down
Loading