Skip to content

Commit b22d7e8

Browse files
committed
feat: Add functional metadata API for plugin inter-communication
Add BasePlugin.metadata() method for cleaner plugin metadata access and inter-plugin communication. This API provides namespaced storage on decorated functions with auto-creation for own namespace. Changes: - Add metadata() method to BasePlugin (plugin.py:205-265) - Auto-creates empty dict for own namespace access - Returns empty dict (not stored) for non-existent other namespaces - Update all development.md examples to use metadata() API - Fix plugin naming example ('logger' → 'logging') - Fix standard plugins list (remove typerule/valrule) - Rewrite Global Plugin Registry section (user-focused) - Strengthen plugin registration guidance (should vs can) - Emphasize same semantics for built-in and external plugins - Add 7 comprehensive tests for metadata API - Test cross-plugin dependencies and lifecycle patterns Test results: 204/204 passing, 82% coverage
1 parent 14b70ec commit b22d7e8

3 files changed

Lines changed: 344 additions & 40 deletions

File tree

docs/plugins/development.md

Lines changed: 78 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,14 @@
44

55
SmartSwitch supports a flexible plugin system that allows you to extend handler functionality. Plugins can add logging, monitoring, type checking, async support, or any other cross-cutting concern.
66

7-
> **📖 Deep Dive**: For a comprehensive understanding of the middleware pattern behind plugins, including detailed execution flow diagrams and the reference LoggingPlugin implementation, see the [Middleware Pattern Guide](middleware-pattern.md).
7+
> **📖 Deep Dive**: For a comprehensive understanding of the middleware pattern behind plugins, including detailed execution flow diagrams and the reference LoggingPlugin implementation, see the [Middleware Pattern Guide](middleware.md).
88
99
## Quick Links
1010

11-
- **[Middleware Pattern](middleware-pattern.md)** - Understand the bidirectional execution flow (onCalling/onCalled)
11+
- **[Middleware Pattern](middleware.md)** - Understand the bidirectional execution flow (onCalling/onCalled)
1212
- **[Plugin Naming Guidelines](#plugin-naming)** - How to name your plugins correctly
13-
- **[LoggingPlugin as Reference](middleware-pattern.md#reference-implementation-loggingplugin)** - Use this as your template
13+
- **[Global Registration](#global-plugin-registry)** - Register plugins for string-based loading (RECOMMENDED)
14+
- **[LoggingPlugin as Reference](middleware.md#reference-implementation-loggingplugin)** - Use this as your template
1415

1516
## The Plugin Protocol
1617

@@ -20,7 +21,20 @@ All plugins must implement the `SwitcherPlugin` protocol:
2021
from typing import Callable, Protocol
2122

2223
class SwitcherPlugin(Protocol):
23-
"""Protocol for SmartSwitch plugins."""
24+
"""Protocol for SmartSwitch plugins (v0.6.0+)."""
25+
26+
def on_decorate(self, func: Callable, switcher: "Switcher") -> None:
27+
"""
28+
Optional hook called during decoration, before wrap().
29+
30+
Use this for expensive setup (model creation, compilation, etc.)
31+
and to store metadata using self.metadata(func).
32+
33+
Args:
34+
func: The handler function being decorated
35+
switcher: The Switcher instance
36+
"""
37+
... # Optional - default no-op in BasePlugin
2438

2539
def wrap(self, func: Callable, switcher: "Switcher") -> Callable:
2640
"""
@@ -33,7 +47,7 @@ class SwitcherPlugin(Protocol):
3347
Returns:
3448
The function to be registered (original or wrapped)
3549
"""
36-
...
50+
... # Required
3751
```
3852

3953
## Plugin Lifecycle (v0.6.0+)
@@ -112,7 +126,7 @@ class MyPlugin(BasePlugin):
112126
Use this to:
113127
- Analyze function signature, type hints, docstring
114128
- Prepare expensive resources (models, compiled patterns, etc.)
115-
- Store metadata for other plugins
129+
- Store metadata using self.metadata(func)
116130
"""
117131
# Access function metadata
118132
import inspect
@@ -121,21 +135,20 @@ class MyPlugin(BasePlugin):
121135
sig = inspect.signature(func)
122136
hints = get_type_hints(func)
123137

124-
# Store for use by this plugin and others
125-
func._plugin_meta["my_plugin"] = {
126-
"signature": sig,
127-
"hints": hints,
128-
"analyzed": True
129-
}
138+
# Store in OUR namespace using metadata() API
139+
meta = self.metadata(func)
140+
meta["signature"] = sig
141+
meta["hints"] = hints
142+
meta["analyzed"] = True
130143

131-
def _wrap_handler(self, func: Callable, switcher: "Switcher") -> Callable:
144+
def wrap(self, func: Callable, switcher: "Switcher") -> Callable:
132145
"""
133146
Called AFTER on_decorate().
134147
135148
Can read metadata prepared in on_decorate().
136149
"""
137-
# Read pre-prepared metadata
138-
meta = func._plugin_meta.get("my_plugin", {})
150+
# Read pre-prepared metadata using metadata() API
151+
meta = self.metadata(func)
139152

140153
@wraps(func)
141154
def wrapper(*args, **kwargs):
@@ -150,42 +163,57 @@ class MyPlugin(BasePlugin):
150163

151164
### Metadata Sharing Between Plugins
152165

153-
**New in v0.6.0**: Plugins can share metadata via `func._plugin_meta`.
166+
**New in v0.6.0**: Plugins can share metadata using the `self.metadata()` API.
167+
168+
**Important**: Each plugin has its own namespace in the metadata dictionary:
169+
- Key: `plugin_name` (e.g., `"pydantic"`, `"logging"`, `"myanalyzer"`)
170+
- Value: Dictionary with plugin-specific data
171+
- Access: `self.metadata(func)` for own namespace, `self.metadata(func, "other")` for others
154172

155-
**Convention**: Each plugin uses its own key (usually `plugin_name`) as namespace.
173+
**Why Namespacing Matters:**
174+
- Multiple plugins can store data without conflicts
175+
- Plugins can read data from other plugins
176+
- Metadata persists throughout the handler's lifetime
177+
- Each plugin owns its namespace
156178

157179
```python
158180
class PydanticPlugin(BasePlugin):
159181
def on_decorate(self, func, switcher):
160182
# Create Pydantic model from type hints
161183
hints = get_type_hints(func)
162-
ValidationModel = create_model(f"{func.__name__}_Model", **hints)
184+
validation_model = create_model(f"{func.__name__}_Model", **hints)
163185

164-
# Store for other plugins to read
165-
func._plugin_meta["pydantic"] = {
166-
"model": ValidationModel,
167-
"hints": hints
168-
}
186+
# Store in OUR namespace using metadata() API
187+
meta = self.metadata(func)
188+
meta["model"] = validation_model
189+
meta["hints"] = hints
169190

170191
class FastAPIPlugin(BasePlugin):
171192
def on_decorate(self, func, switcher):
172-
# Read Pydantic metadata from previous plugin
173-
pydantic_meta = func._plugin_meta.get("pydantic", {})
193+
# Read from PYDANTIC's namespace
194+
pydantic_meta = self.metadata(func, "pydantic")
174195

175196
if pydantic_meta:
176197
# Use pre-created Pydantic model for FastAPI
177198
model = pydantic_meta["model"]
178199
self.app.post(f"/{func.__name__}", response_model=model)(func)
179200

201+
# Store in OUR namespace
202+
meta = self.metadata(func)
203+
meta["registered"] = True
204+
meta["endpoint"] = f"/{func.__name__}"
205+
180206
# Usage - order matters!
181207
sw = Switcher().plug("pydantic").plug(FastAPIPlugin(app))
182208
```
183209

184210
**Key Points:**
185-
- Metadata is shared via `func._plugin_meta` dictionary
186-
- Each plugin should use its own namespace (key)
211+
- Metadata is shared using `self.metadata(func)` API
212+
- `self.metadata(func)` accesses own namespace (auto-creates if needed)
213+
- `self.metadata(func, "other")` reads another plugin's namespace
187214
- Later plugins can read earlier plugins' metadata
188215
- Plugin order matters for metadata dependencies
216+
- Metadata is for immutable setup data (signatures, compiled patterns, models)
189217

190218
### BasePlugin Class
191219

@@ -226,26 +254,34 @@ class MyPlugin(BasePlugin):
226254

227255
### Global Plugin Registry
228256

229-
**New in v0.6.0**: Register plugins globally for string-based loading.
257+
**New in v0.6.0**: Plugin **users** should register external plugins globally to use the same string-based loading semantics as built-in plugins.
258+
259+
**Who registers plugins?** **USERS**, not developers!
260+
261+
- **Plugin developers**: Just publish the plugin class
262+
- **Plugin users**: Should register it in their own code to enable string-based loading
230263

231264
```python
232-
from smartswitch import Switcher, BasePlugin
265+
# User's application code
266+
from smartswitch import Switcher
267+
from smartasync import SmartAsyncPlugin # External plugin
233268

234-
class MyCustomPlugin(BasePlugin):
235-
def _wrap_handler(self, func, switcher):
236-
return func
269+
# USER registers the plugin to enable string-based loading
270+
Switcher.register_plugin("async", SmartAsyncPlugin)
237271

238-
# Register globally
239-
Switcher.register_plugin("custom", MyCustomPlugin)
272+
# Now can use same semantics as built-in plugins
273+
sw = Switcher().plug("async")
240274

241-
# Now usable by string name everywhere
242-
sw = Switcher().plug("custom")
275+
# Without registration, would need:
276+
# sw = Switcher().plug(SmartAsyncPlugin()) # Different semantics
243277
```
244278

245-
Built-in plugins are pre-registered:
279+
**Built-in plugins** are pre-registered:
246280
- `"logging"` - Call history and monitoring
247281
- `"pydantic"` - Type validation via Pydantic models
248282

283+
**Recommended practice**: Register external plugins once at application startup to maintain consistent plugin loading semantics throughout your codebase.
284+
249285
## Plugin Naming
250286

251287
### Core Principle: Name by Function, Not Framework
@@ -266,7 +302,7 @@ class SwitcherMetricsPlugin:
266302

267303
```python
268304
class LoggingPlugin:
269-
plugin_name = "logger" # ✅ Clear: logs things
305+
plugin_name = "logging" # ✅ Clear: logs things
270306

271307
class MetricsPlugin:
272308
plugin_name = "metrics" # ✅ Clear: tracks metrics
@@ -551,7 +587,9 @@ print(sw.counter.get_count('my_handler')) # From counter plugin
551587
**Standard plugins** (shipped with SmartSwitch):
552588
- Can be loaded by string name: `sw.plug('logging')`
553589
- Registered in `Switcher._get_standard_plugin()`
554-
- Examples: `logging`, `typerule`, `valrule`
590+
- Examples: `logging`, `pydantic`
591+
592+
**Note**: `typerule` and `valrule` are **decorators**, not plugins. They work differently.
555593

556594
**External plugins** (third-party packages):
557595
- Must be imported and instantiated: `sw.plug(MyPlugin())`
@@ -822,7 +860,7 @@ Creating a SmartSwitch plugin (v0.6.0+):
822860
1. ✅ Inherit from `BasePlugin` for common functionality
823861
2. ✅ Override `on_decorate(func, switcher)` for setup phase (optional)
824862
3. ✅ Implement `_wrap_handler(func, switcher)` for wrapping logic (required)
825-
4. ✅ Use `func._plugin_meta[plugin_name]` to store/share metadata
863+
4. ✅ Use `self.metadata(func)` to store/share metadata
826864
5. ✅ Use `@wraps` to preserve function metadata
827865
6. ✅ Provide public methods for user interaction
828866
7. ✅ Test with multiple handlers and in combination with other plugins

src/smartswitch/plugin.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,68 @@ def is_enabled(self, handler_name: str) -> bool:
202202
config = self.get_config(handler_name)
203203
return config.get("enabled", True)
204204

205+
def metadata(self, func: Callable, plugin_name: str | None = None) -> dict[str, Any]:
206+
"""
207+
Access plugin metadata for a function.
208+
209+
The returned dict can be modified directly to store plugin-specific
210+
metadata. When accessing own namespace (plugin_name=None), automatically
211+
creates an empty dict if it doesn't exist yet.
212+
213+
Metadata is stored in `func._plugin_meta[plugin_name]` and persists
214+
throughout the function's lifetime. Each plugin has its own namespace
215+
to avoid conflicts.
216+
217+
Args:
218+
func: The decorated function
219+
plugin_name: Plugin namespace to access (default: self.plugin_name).
220+
Use None (default) to access own namespace.
221+
Use explicit name to read other plugins' metadata.
222+
223+
Returns:
224+
Plugin metadata dict. Auto-creates empty dict for own namespace.
225+
Returns empty dict (not stored) if reading non-existent namespace.
226+
227+
Example:
228+
Writing to own namespace:
229+
>>> class ValidationPlugin(BasePlugin):
230+
... def on_decorate(self, func, switcher):
231+
... meta = self.metadata(func)
232+
... meta["rules"] = self._compile_rules(func)
233+
... meta["strict_mode"] = True
234+
235+
Reading own metadata in wrap():
236+
>>> def wrap(self, func, switcher):
237+
... meta = self.metadata(func)
238+
... rules = meta.get("rules", [])
239+
... # Use rules in wrapper...
240+
241+
Reading other plugin's metadata:
242+
>>> def on_decorate(self, func, switcher):
243+
... pydantic_meta = self.metadata(func, "pydantic")
244+
... if pydantic_meta:
245+
... model = pydantic_meta.get("model")
246+
... # Use pydantic model...
247+
248+
Note:
249+
Metadata is for immutable setup-time data (signatures, compiled
250+
patterns, models, etc.). For runtime mutable state (counters, etc.),
251+
store in the plugin instance with proper thread synchronization.
252+
"""
253+
# Ensure _plugin_meta exists on function
254+
if not hasattr(func, "_plugin_meta"):
255+
func._plugin_meta = {}
256+
257+
# Determine which namespace to access
258+
name = plugin_name if plugin_name is not None else self.plugin_name
259+
260+
# Auto-create ONLY for own namespace
261+
if plugin_name is None and name not in func._plugin_meta:
262+
func._plugin_meta[name] = {}
263+
264+
# Return metadata dict (or empty dict if not found)
265+
return func._plugin_meta.get(name, {})
266+
205267
def on_decorate(self, func: Callable, switcher: "Switcher") -> None:
206268
"""
207269
Hook called when a function is decorated (optional).

0 commit comments

Comments
 (0)