@@ -120,6 +120,13 @@ async def __aexit__(
120120 _HAS_PLUGIN_FRAMEWORK = False
121121
122122if TYPE_CHECKING :
123+ from cpex .framework .base import Plugin as _CpexPlugin
124+ from cpex .framework .models import (
125+ PluginContext ,
126+ PluginPayload ,
127+ PluginResult as _CFPluginResult ,
128+ )
129+
123130 from mellea .core .backend import Backend
124131 from mellea .core .base import Context
125132 from mellea .stdlib .session import MelleaSession
@@ -146,119 +153,146 @@ def __init__( # noqa: D107
146153 super ().__init__ (f"Plugin blocked { hook_type } : { detail } { reason } " )
147154
148155
149- if _HAS_PLUGIN_FRAMEWORK :
156+ # ---------------------------------------------------------------------------
157+ # Dynamic base classes — a single ClassDef node per class ensures Griffe's
158+ # static AST parser always picks up the authoritative docstring. See #667.
159+ # ---------------------------------------------------------------------------
150160
151- class MelleaBasePayload (PluginPayload ):
152- """Frozen base — all payloads are immutable by design.
161+ if _HAS_PLUGIN_FRAMEWORK or TYPE_CHECKING :
162+ _PayloadBase = PluginPayload
163+ _PluginBase = _CpexPlugin
164+ else :
165+ _PayloadBase = object
166+ _PluginBase = object
153167
154- Plugins must use ``model_copy(update={...})`` to propose modifications
155- and return the copy via ``PluginResult.modified_payload``. The plugin
156- manager applies the hook's ``HookPayloadPolicy`` to filter changes to
157- writable fields only.
158- """
159168
160- session_id : str | None = None
161- request_id : str = ""
162- timestamp : datetime = Field (default_factory = lambda : datetime .now (UTC ))
163- hook : str = ""
164- user_metadata : dict [str , Any ] = Field (default_factory = dict )
169+ class MelleaBasePayload (_PayloadBase ): # type: ignore[misc,valid-type]
170+ """Frozen base — all payloads are immutable by design.
165171
166- class MelleaPlugin (_CpexPlugin ):
167- """Base class for Mellea plugins with lifecycle hooks and typed accessors.
172+ Plugins must use ``model_copy(update={...})`` to propose modifications
173+ and return the copy via ``PluginResult.modified_payload``. The plugin
174+ manager applies the hook's ``HookPayloadPolicy`` to filter changes to
175+ writable fields only.
176+ """
168177
169- Use this when you need lifecycle hooks (``initialize``/``shutdown``)
170- or typed context accessors. For simpler plugins, prefer ``@hook``
171- on standalone functions or ``@plugin`` on plain classes.
178+ session_id : str | None = None
179+ request_id : str = ""
180+ timestamp : datetime = Field (default_factory = lambda : datetime .now (UTC ))
181+ hook : str = ""
182+ user_metadata : dict [str , Any ] = Field (default_factory = dict )
172183
173- Instances support the context manager protocol for temporary activation::
174184
175- class MyPlugin(MelleaPlugin):
176- def __init__(self):
177- super().__init__(PluginConfig(name="my-plugin", hooks=[...]))
185+ class MelleaPlugin (_PluginBase ): # type: ignore[misc,valid-type]
186+ """Base class for Mellea plugins with lifecycle hooks and typed accessors.
178187
179- async def some_hook(self, payload, ctx):
180- ...
188+ Use this when you need lifecycle hooks (``initialize``/``shutdown``)
189+ or typed context accessors. For simpler plugins, prefer ``@hook``
190+ on standalone functions or ``@plugin`` on plain classes.
181191
182- with MyPlugin() as p:
183- result, ctx = instruct("...", ctx, backend)
192+ Instances support the context manager protocol for temporary activation::
184193
185- # or async
186- async with MyPlugin() as p:
187- result, ctx = await ainstruct("...", ctx, backend)
188- """
194+ class MyPlugin(MelleaPlugin):
195+ def __init__(self):
196+ super().__init__(PluginConfig(name="my-plugin", hooks=[...]))
189197
190- def get_backend (self , context : PluginContext ) -> Backend | None :
191- """Get the Backend from the plugin context."""
192- return context .global_context .state .get ("backend" )
198+ async def some_hook(self, payload, ctx):
199+ ...
193200
194- def get_mellea_context (self , context : PluginContext ) -> Context | None :
195- """Get the Mellea Context from the plugin context."""
196- return context .global_context .state .get ("context" )
201+ with MyPlugin() as p:
202+ result, ctx = instruct("...", ctx, backend)
197203
198- def get_session (self , context : PluginContext ) -> MelleaSession | None :
199- """Get the MelleaSession from the plugin context."""
200- return context .global_context .state .get ("session" )
204+ # or async
205+ async with MyPlugin() as p:
206+ result, ctx = await ainstruct("...", ctx, backend)
207+ """
201208
202- @property
203- def plugin_config (self ) -> dict [str , Any ]:
204- """Plugin-specific configuration from PluginConfig.config."""
205- return self ._config .config or {}
209+ def get_backend (self , context : PluginContext ) -> Backend | None :
210+ """Get the Backend from the plugin context.
206211
207- def __enter__ (self ) -> MelleaPlugin :
208- """Register this plugin for the duration of a ``with`` block."""
209- if getattr (self , "_scope_id" , None ) is not None :
210- raise RuntimeError (
211- f"MelleaPlugin { self .name !r} is already active as a context manager. "
212- "Concurrent or nested reuse of the same instance is not supported; "
213- "create a new instance instead."
214- )
215- import uuid
212+ Args:
213+ context: The plugin context provided by the hook framework.
216214
217- from mellea .plugins .registry import register
215+ Returns:
216+ The active Backend, or ``None`` if unavailable.
217+ """
218+ return context .global_context .state .get ("backend" )
218219
219- self ._scope_id = str (uuid .uuid4 ())
220- register (self , session_id = self ._scope_id )
221- return self
220+ def get_mellea_context (self , context : PluginContext ) -> Context | None :
221+ """Get the Mellea Context from the plugin context.
222222
223- def __exit__ (self , exc_type : Any , exc_val : Any , exc_tb : Any ) -> None :
224- """Deregister this plugin on exit."""
225- scope_id = getattr (self , "_scope_id" , None )
226- if scope_id is not None :
227- from mellea .plugins .manager import deregister_session_plugins
223+ Args:
224+ context: The plugin context provided by the hook framework.
228225
229- deregister_session_plugins (scope_id )
230- self ._scope_id = None # type: ignore[assignment]
226+ Returns:
227+ The active Mellea Context, or ``None`` if unavailable.
228+ """
229+ return context .global_context .state .get ("context" )
231230
232- async def __aenter__ (self ) -> MelleaPlugin :
233- """Async variant — delegates to the synchronous ``__enter__``."""
234- return self .__enter__ ()
231+ def get_session (self , context : PluginContext ) -> MelleaSession | None :
232+ """Get the MelleaSession from the plugin context.
235233
236- async def __aexit__ (self , exc_type : Any , exc_val : Any , exc_tb : Any ) -> None :
237- """Async variant — delegates to the synchronous ``__exit__``."""
238- self .__exit__ (exc_type , exc_val , exc_tb )
234+ Args:
235+ context: The plugin context provided by the hook framework.
239236
240- PluginResult : TypeAlias = _CFPluginResult # type: ignore[misc]
237+ Returns:
238+ The active MelleaSession, or ``None`` if unavailable.
239+ """
240+ return context .global_context .state .get ("session" )
241241
242- else :
243- # Provide a stub when the plugin framework is not installed.
244- class MelleaBasePayload : # type: ignore[no-redef]
245- """Stub — install ``"mellea[hooks]"`` for full plugin support."""
246-
247- def __init__ (self , * args : Any , ** kwargs : Any ) -> None : # noqa: D107
248- raise ImportError (
249- "MelleaPlugin requires the ContextForge plugin framework. "
250- "Install it with: pip install 'mellea[hooks]'"
242+ @property
243+ def plugin_config (self ) -> dict [str , Any ]:
244+ """Plugin-specific configuration from PluginConfig.config."""
245+ return self ._config .config or {}
246+
247+ def __enter__ (self ) -> MelleaPlugin :
248+ """Register this plugin for the duration of a ``with`` block."""
249+ if getattr (self , "_scope_id" , None ) is not None :
250+ raise RuntimeError (
251+ f"MelleaPlugin { self .name !r} is already active as a context manager. "
252+ "Concurrent or nested reuse of the same instance is not supported; "
253+ "create a new instance instead."
251254 )
255+ import uuid
252256
253- # Provide a stub when the plugin framework is not installed.
254- class MelleaPlugin : # type: ignore[no-redef]
255- """Stub — install ``"mellea[hooks]"`` for full plugin support."""
257+ from mellea .plugins .registry import register
256258
257- def __init__ (self , * args : Any , ** kwargs : Any ) -> None : # noqa: D107
258- raise ImportError (
259- "MelleaPlugin requires the ContextForge plugin framework. "
260- "Install it with: pip install 'mellea[hooks]'"
261- )
259+ self ._scope_id = str (uuid .uuid4 ())
260+ register (self , session_id = self ._scope_id )
261+ return self
262+
263+ def __exit__ (self , exc_type : Any , exc_val : Any , exc_tb : Any ) -> None :
264+ """Deregister this plugin on exit."""
265+ scope_id = getattr (self , "_scope_id" , None )
266+ if scope_id is not None :
267+ from mellea .plugins .manager import deregister_session_plugins
268+
269+ deregister_session_plugins (scope_id )
270+ self ._scope_id = None # type: ignore[assignment]
262271
263- # Provide an alias when the plugin framework is not installed.
264- PluginResult : TypeAlias = Any # type: ignore[no-redef, misc]
272+ async def __aenter__ (self ) -> MelleaPlugin :
273+ """Async variant — delegates to the synchronous ``__enter__``."""
274+ return self .__enter__ ()
275+
276+ async def __aexit__ (self , exc_type : Any , exc_val : Any , exc_tb : Any ) -> None :
277+ """Async variant — delegates to the synchronous ``__exit__``."""
278+ self .__exit__ (exc_type , exc_val , exc_tb )
279+
280+
281+ # When cpex is not installed, override __init__ to provide a clear error
282+ # message. This is done post-hoc so there is only one ClassDef node in the
283+ # AST for each class (the docstring-carrying definition above).
284+ if not _HAS_PLUGIN_FRAMEWORK :
285+
286+ def _stub_init (_self : Any , * _args : Any , ** _kwargs : Any ) -> None :
287+ raise ImportError (
288+ "This class requires the ContextForge plugin framework. "
289+ "Install it with: pip install 'mellea[hooks]'"
290+ )
291+
292+ MelleaBasePayload .__init__ = _stub_init # type: ignore[assignment,misc]
293+ MelleaPlugin .__init__ = _stub_init # type: ignore[assignment,misc]
294+
295+ if _HAS_PLUGIN_FRAMEWORK :
296+ PluginResult : TypeAlias = _CFPluginResult # type: ignore[misc]
297+ else :
298+ PluginResult : TypeAlias = Any # type: ignore[no-redef,misc]
0 commit comments