diff --git a/mellea/formatters/granite/retrievers/util.py b/mellea/formatters/granite/retrievers/util.py index eeaf6fea3..4da4dd041 100644 --- a/mellea/formatters/granite/retrievers/util.py +++ b/mellea/formatters/granite/retrievers/util.py @@ -10,8 +10,8 @@ import zipfile # Third Party -import pyarrow as pa -import pyarrow.json as pj +import pyarrow as pa # type: ignore[import-not-found] +import pyarrow.json as pj # type: ignore[import-not-found] def download_mtrag_corpus(target_dir: str, corpus_name: str) -> pathlib.Path: diff --git a/mellea/plugins/base.py b/mellea/plugins/base.py index 9727f2a4e..357e2a515 100644 --- a/mellea/plugins/base.py +++ b/mellea/plugins/base.py @@ -134,23 +134,22 @@ def __init__( # noqa: D107 super().__init__(f"Plugin blocked {hook_type}: {detail}{reason}") -class MelleaBasePayload(PluginPayload): - """Frozen base — all payloads are immutable by design. - - Plugins must use ``model_copy(update={...})`` to propose modifications - and return the copy via ``PluginResult.modified_payload``. The plugin - manager applies the hook's ``HookPayloadPolicy`` to filter changes to - writable fields only. - """ +if _HAS_PLUGIN_FRAMEWORK: - session_id: str | None = None - request_id: str = "" - timestamp: datetime = Field(default_factory=lambda: datetime.now(UTC)) - hook: str = "" - user_metadata: dict[str, Any] = Field(default_factory=dict) + class MelleaBasePayload(PluginPayload): + """Frozen base — all payloads are immutable by design. + Plugins must use ``model_copy(update={...})`` to propose modifications + and return the copy via ``PluginResult.modified_payload``. The plugin + manager applies the hook's ``HookPayloadPolicy`` to filter changes to + writable fields only. + """ -if _HAS_PLUGIN_FRAMEWORK: + session_id: str | None = None + request_id: str = "" + timestamp: datetime = Field(default_factory=lambda: datetime.now(UTC)) + hook: str = "" + user_metadata: dict[str, Any] = Field(default_factory=dict) class MelleaPlugin(_CpexPlugin): """Base class for Mellea plugins with lifecycle hooks and typed accessors. @@ -229,14 +228,24 @@ async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: PluginResult: TypeAlias = _CFPluginResult # type: ignore[misc] else: + # Provide a stub when the plugin framework is not installed. + class MelleaBasePayload: # type: ignore[no-redef] + """Stub — install ``"mellea[hooks]"`` for full plugin support.""" + + def __init__(self, *args: Any, **kwargs: Any) -> None: # noqa: D107 + raise ImportError( + "MelleaPlugin requires the ContextForge plugin framework. " + "Install it with: pip install 'mellea[hooks]'" + ) + # Provide a stub when the plugin framework is not installed. class MelleaPlugin: # type: ignore[no-redef] - """Stub — install ``mcp-contextforge-gateway`` for full plugin support.""" + """Stub — install ``"mellea[hooks]"`` for full plugin support.""" def __init__(self, *args: Any, **kwargs: Any) -> None: # noqa: D107 raise ImportError( "MelleaPlugin requires the ContextForge plugin framework. " - "Install it with: pip install 'mellea[contextforge]'" + "Install it with: pip install 'mellea[hooks]'" ) # Provide an alias when the plugin framework is not installed. diff --git a/mellea/plugins/manager.py b/mellea/plugins/manager.py index 375341eda..25598f520 100644 --- a/mellea/plugins/manager.py +++ b/mellea/plugins/manager.py @@ -57,7 +57,7 @@ def ensure_plugin_manager() -> Any: if not _HAS_PLUGIN_FRAMEWORK: raise ImportError( "Plugin system requires the ContextForge plugin framework. " - "Install it with: pip install 'mellea[contextforge]'" + "Install it with: pip install 'mellea[hooks]'" ) if _plugin_manager is None: @@ -93,7 +93,7 @@ async def initialize_plugins( if not _HAS_PLUGIN_FRAMEWORK: raise ImportError( "Plugin system requires the ContextForge plugin framework. " - "Install it with: pip install 'mellea[contextforge]'" + "Install it with: pip install 'mellea[hooks]'" ) register_mellea_hooks() diff --git a/mellea/plugins/policies.py b/mellea/plugins/policies.py index 3b327cc1d..262114e29 100644 --- a/mellea/plugins/policies.py +++ b/mellea/plugins/policies.py @@ -5,7 +5,9 @@ from typing import Any try: - from cpex.framework.hooks.policies import HookPayloadPolicy + from cpex.framework.hooks.policies import ( + HookPayloadPolicy, # type: ignore[import-not-found] + ) _HAS_PLUGIN_FRAMEWORK = True except ImportError: diff --git a/mellea/plugins/registry.py b/mellea/plugins/registry.py index a8063e38a..181044ec6 100644 --- a/mellea/plugins/registry.py +++ b/mellea/plugins/registry.py @@ -68,7 +68,7 @@ def modify(payload: Any, **field_updates: Any) -> Any: if not _HAS_PLUGIN_FRAMEWORK: raise ImportError( "modify() requires the ContextForge plugin framework. " - "Install it with: pip install 'mellea[contextforge]'" + "Install it with: pip install 'mellea[hooks]'" ) return PluginResult( continue_processing=True, @@ -94,7 +94,7 @@ def block( if not _HAS_PLUGIN_FRAMEWORK: raise ImportError( "block() requires the ContextForge plugin framework. " - "Install it with: pip install 'mellea[contextforge]'" + "Install it with: pip install 'mellea[hooks]'" ) return PluginResult( continue_processing=False, @@ -123,7 +123,7 @@ def register( if not _HAS_PLUGIN_FRAMEWORK: raise ImportError( "register() requires the ContextForge plugin framework. " - "Install it with: pip install 'mellea[contextforge]'" + "Install it with: pip install 'mellea[hooks]'" ) from mellea.plugins.manager import ensure_plugin_manager @@ -232,115 +232,137 @@ def _register_single( ) -class _FunctionHookAdapter(Plugin): - """Adapts a standalone ``@hook``-decorated function into a ContextForge Plugin.""" - - def __init__( - self, - fn: Callable, - session_id: str | None = None, - priority_override: int | None = None, - ): - meta: HookMeta = fn._mellea_hook_meta # type: ignore[attr-defined] - priority = ( - priority_override - if priority_override is not None - else (meta.priority if meta.priority is not None else 50) - ) - config = PluginConfig( - name=f"{fn.__module__}.{fn.__qualname__}", - kind=f"{fn.__module__}.{fn.__qualname__}", - hooks=[meta.hook_type], - mode=_map_mode(meta.mode), - priority=priority, - on_error=_CFOnError.IGNORE, - ) - super().__init__(config) - self._fn = fn - self._session_id = session_id - - async def initialize(self) -> None: - pass - - async def shutdown(self) -> None: - pass - - # The hook method is discovered by convention: method name == hook_type. - # We dynamically add it so ContextForge's HookRef can find it. - def __getattr__(self, name: str) -> Any: - meta: HookMeta | None = getattr(self._fn, "_mellea_hook_meta", None) - if meta and name == meta.hook_type: - return self._invoke - raise AttributeError( - f"'{type(self).__name__}' object has no attribute '{name}'" - ) - - async def _invoke(self, payload: Any, context: Any) -> Any: - result = await self._fn(payload, context) - if result is None: - return PluginResult(continue_processing=True) - return result +if _HAS_PLUGIN_FRAMEWORK: + class _FunctionHookAdapter(Plugin): + """Adapts a standalone ``@hook``-decorated function into a ContextForge Plugin.""" -class _MethodHookAdapter(Plugin): - """Adapts a single ``@hook``-decorated bound method from a ``Plugin`` class. + def __init__( + self, + fn: Callable, + session_id: str | None = None, + priority_override: int | None = None, + ): + meta: HookMeta = fn._mellea_hook_meta # type: ignore[attr-defined] + priority = ( + priority_override + if priority_override is not None + else (meta.priority if meta.priority is not None else 50) + ) + config = PluginConfig( + name=f"{fn.__module__}.{fn.__qualname__}", + kind=f"{fn.__module__}.{fn.__qualname__}", + hooks=[meta.hook_type], + mode=_map_mode(meta.mode), + priority=priority, + on_error=_CFOnError.IGNORE, + ) + super().__init__(config) + self._fn = fn + self._session_id = session_id + + async def initialize(self) -> None: + pass + + async def shutdown(self) -> None: + pass + + # The hook method is discovered by convention: method name == hook_type. + # We dynamically add it so ContextForge's HookRef can find it. + def __getattr__(self, name: str) -> Any: + meta: HookMeta | None = getattr(self._fn, "_mellea_hook_meta", None) + if meta and name == meta.hook_type: + return self._invoke + raise AttributeError( + f"'{type(self).__name__}' object has no attribute '{name}'" + ) - Each ``@hook`` method on a ``@plugin``-decorated class gets its own adapter - so that per-method execution modes (``SEQUENTIAL``, ``FIRE_AND_FORGET``, etc.) - are respected. The adapter name is ``"."``. + async def _invoke(self, payload: Any, context: Any) -> Any: + result = await self._fn(payload, context) + if result is None: + return PluginResult(continue_processing=True) + return result + + class _MethodHookAdapter(Plugin): + """Adapts a single ``@hook``-decorated bound method from a ``Plugin`` class. + + Each ``@hook`` method on a ``@plugin``-decorated class gets its own adapter + so that per-method execution modes (``SEQUENTIAL``, ``FIRE_AND_FORGET``, etc.) + are respected. The adapter name is ``"."``. + + Note: ``initialize()`` and ``shutdown()`` delegate to the underlying class + instance and may be called once per registered hook method. Make them + idempotent when using the ``Plugin`` base class with multiple hook methods. + """ + + def __init__( + self, + instance: Any, + bound_method: Callable, + hook_meta: HookMeta, + plugin_name: str, + plugin_module: str, + priority: int, + session_id: str | None = None, + ): + hook_val = getattr(hook_meta.hook_type, "value", hook_meta.hook_type) + adapter_name = f"{plugin_name}.{hook_val}" + config = PluginConfig( + name=adapter_name, + kind=f"{plugin_module}.{hook_val}", + hooks=[hook_meta.hook_type], + mode=_map_mode(hook_meta.mode), + priority=priority, + on_error=_CFOnError.IGNORE, + ) + super().__init__(config) + self._instance = instance + self._bound_method = bound_method + self._session_id = session_id + + async def initialize(self) -> None: + init = getattr(self._instance, "initialize", None) + if init and callable(init): + await init() + + async def shutdown(self) -> None: + shut = getattr(self._instance, "shutdown", None) + if shut and callable(shut): + await shut() + + def __getattr__(self, name: str) -> Any: + if self._config.hooks and name == self._config.hooks[0]: + return self._invoke + raise AttributeError( + f"'{type(self).__name__}' object has no attribute '{name}'" + ) - Note: ``initialize()`` and ``shutdown()`` delegate to the underlying class - instance and may be called once per registered hook method. Make them - idempotent when using the ``Plugin`` base class with multiple hook methods. - """ + async def _invoke(self, payload: Any, context: Any) -> Any: + result = await self._bound_method(payload, context) + if result is None: + return PluginResult(continue_processing=True) + return result + +else: + # Provide a stub when the plugin framework is not installed. + class _FunctionHookAdapter: # type: ignore[no-redef] + """Stub — install ``"mellea[hooks]"`` for full plugin support.""" + + def __init__(self, *args: Any, **kwargs: Any) -> None: + raise ImportError( + "MelleaPlugin requires the ContextForge plugin framework. " + "Install it with: pip install 'mellea[hooks]'" + ) - def __init__( - self, - instance: Any, - bound_method: Callable, - hook_meta: HookMeta, - plugin_name: str, - plugin_module: str, - priority: int, - session_id: str | None = None, - ): - hook_val = getattr(hook_meta.hook_type, "value", hook_meta.hook_type) - adapter_name = f"{plugin_name}.{hook_val}" - config = PluginConfig( - name=adapter_name, - kind=f"{plugin_module}.{hook_val}", - hooks=[hook_meta.hook_type], - mode=_map_mode(hook_meta.mode), - priority=priority, - on_error=_CFOnError.IGNORE, - ) - super().__init__(config) - self._instance = instance - self._bound_method = bound_method - self._session_id = session_id - - async def initialize(self) -> None: - init = getattr(self._instance, "initialize", None) - if init and callable(init): - await init() - - async def shutdown(self) -> None: - shut = getattr(self._instance, "shutdown", None) - if shut and callable(shut): - await shut() - - def __getattr__(self, name: str) -> Any: - if self._config.hooks and name == self._config.hooks[0]: - return self._invoke - raise AttributeError( - f"'{type(self).__name__}' object has no attribute '{name}'" - ) + # Provide a stub when the plugin framework is not installed. + class _MethodHookAdapter: # type: ignore[no-redef] + """Stub — install ``"mellea[hooks]"`` for full plugin support.""" - async def _invoke(self, payload: Any, context: Any) -> Any: - result = await self._bound_method(payload, context) - if result is None: - return PluginResult(continue_processing=True) - return result + def __init__(self, *args: Any, **kwargs: Any) -> None: + raise ImportError( + "MelleaPlugin requires the ContextForge plugin framework. " + "Install it with: pip install 'mellea[hooks]'" + ) class _PluginScope: @@ -440,7 +462,7 @@ def unregister( if not _HAS_PLUGIN_FRAMEWORK: raise ImportError( "unregister() requires the ContextForge plugin framework. " - "Install it with: pip install 'mellea[contextforge]'" + "Install it with: pip install 'mellea[hooks]'" ) from mellea.plugins.manager import get_plugin_manager diff --git a/test/backends/test_mellea_tool.py b/test/backends/test_mellea_tool.py index 0409f4276..34ca4f4ef 100644 --- a/test/backends/test_mellea_tool.py +++ b/test/backends/test_mellea_tool.py @@ -129,7 +129,7 @@ def test_from_smolagents_basic(): 4. The tool can be executed with arguments """ try: - from smolagents import Tool + from smolagents import Tool # type: ignore[import-not-found] except ImportError: pytest.skip( "smolagents not installed - install with: uv pip install 'mellea[smolagents]'" diff --git a/test/conftest.py b/test/conftest.py index b441e7a88..70f3bf839 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -601,6 +601,12 @@ async def register_acceptance_sets(request): # If plugins are enabled, we don't need to re-enable them for this specific test. return + from mellea.plugins.registry import _HAS_PLUGIN_FRAMEWORK + + if not _HAS_PLUGIN_FRAMEWORK: + yield + return + from mellea.plugins import register from mellea.plugins.manager import shutdown_plugins from test.plugins._acceptance_sets import ALL_ACCEPTANCE_SETS @@ -621,6 +627,12 @@ async def auto_register_acceptance_sets(request): yield return + from mellea.plugins.registry import _HAS_PLUGIN_FRAMEWORK + + if not _HAS_PLUGIN_FRAMEWORK: + yield + return + from mellea.plugins import register from mellea.plugins.manager import shutdown_plugins from test.plugins._acceptance_sets import ALL_ACCEPTANCE_SETS diff --git a/test/plugins/test_payloads.py b/test/plugins/test_payloads.py index 162763694..a6c06beef 100644 --- a/test/plugins/test_payloads.py +++ b/test/plugins/test_payloads.py @@ -7,6 +7,7 @@ from mellea.plugins.hooks.component import ComponentPreExecutePayload from mellea.plugins.hooks.generation import GenerationPreCallPayload from mellea.plugins.hooks.session import SessionPreInitPayload +from mellea.plugins.registry import _HAS_PLUGIN_FRAMEWORK class TestMelleaBasePayload: @@ -94,6 +95,7 @@ def test_modified_field_readable_after_model_copy(self): assert modified.action.name == "replaced" assert payload.action.name == "original" + @pytest.mark.skipif(not _HAS_PLUGIN_FRAMEWORK, reason="cpex not installed") def test_writable_fields_in_policies(self): """Writable fields should be listed in their hook policies.""" from mellea.plugins.policies import MELLEA_HOOK_PAYLOAD_POLICIES diff --git a/test/plugins/test_policies.py b/test/plugins/test_policies.py index ad78d7fce..f950a7da7 100644 --- a/test/plugins/test_policies.py +++ b/test/plugins/test_policies.py @@ -1,5 +1,9 @@ """Tests for hook payload policies.""" +import pytest + +pytest.importorskip("cpex", reason="cpex not installed") + from mellea.plugins.policies import MELLEA_HOOK_PAYLOAD_POLICIES diff --git a/test/stdlib/requirements/test_reqlib_python.py b/test/stdlib/requirements/test_reqlib_python.py index 859427168..4c5654381 100644 --- a/test/stdlib/requirements/test_reqlib_python.py +++ b/test/stdlib/requirements/test_reqlib_python.py @@ -3,7 +3,7 @@ import pytest try: - import llm_sandbox + import llm_sandbox # type: ignore[import-not-found] try: with llm_sandbox.SandboxSession(