Plugin Development Guideο
Overviewο
SmartSwitch supports a flexible plugin system that allows you to extend handler functionality. Plugins can add logging, monitoring, type checking, caching, validation, or any other cross-cutting concern.
π 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.
Quick Linksο
Middleware Pattern - Understand the bidirectional execution flow (onCalling/onCalled)
Plugin Naming Guidelines - How to name your plugins correctly
Global Registration - Register plugins for string-based loading (RECOMMENDED)
LoggingPlugin as Reference - Use this as your template
The Plugin Protocolο
All plugins must implement the SwitcherPlugin protocol (updated in v0.10.0):
from typing import Callable, Protocol
class SwitcherPlugin(Protocol):
"""Protocol for SmartSwitch plugins (v0.6.0+, updated v0.10.0)."""
def on_decorate(self, switch: "Switcher", func: Callable, entry: "MethodEntry") -> None:
"""
Optional hook called during decoration, before wrap_handler().
Use this for expensive setup (model creation, compilation, etc.)
and to store metadata in entry.metadata.
Args:
switch: The Switcher instance
func: The handler function being decorated
entry: MethodEntry object with handler metadata
"""
... # Optional - default no-op in BasePlugin
def wrap_handler(self, switch: "Switcher", entry: "MethodEntry", call_next: Callable) -> Callable:
"""
Wrap a handler function using middleware pattern.
Args:
switch: The Switcher instance
entry: MethodEntry object with handler metadata
call_next: The next layer in the middleware chain
Returns:
Wrapped function that calls call_next()
"""
... # Required
Plugin Lifecycle (v0.6.0+)ο
New in v0.6.0: SmartSwitch plugins now have a complete lifecycle with three distinct phases.
Understanding the Three Phasesο
SmartSwitch plugins operate in three distinct phases:
Phase 1: Decoration (Happens ONCE when @sw is applied)ο
When you decorate a function with @sw, SmartSwitch calls each plugin twice:
on_decorate(switch, func, entry)- Setup phase (NEW in v0.6.0)Called BEFORE wrap_handler()
Receives the switcher, original function, and MethodEntry
Used for expensive setup (creating models, compiling regexes, etc.)
Can store metadata in
entry.metadatafor other plugins
wrap_handler(switch, entry, call_next)- Wrapping phaseCalled AFTER on_decorate()
Can read metadata from
entry.metadataReturns wrapper function using middleware pattern with
call_next
Example:
@sw
def my_handler(x: int) -> str:
return str(x)
# Internally SmartSwitch does:
# 1. entry = MethodEntry(..., metadata={})
# 2. For each plugin:
# - plugin.on_decorate(sw, my_handler, entry)
# - wrapper = plugin.wrap_handler(sw, entry, call_next)
Phase 2: Call (Happens EVERY TIME the function is called)ο
When you call the handler, the wrapper chain executes:
result = sw("my_handler")(42)
# Execution flow:
# Plugin3.wrapper(
# Plugin2.wrapper(
# Plugin1.wrapper(
# original_func(42)
# )
# )
# )
Phase 3: Exit (Happens EVERY TIME, on return or exception)ο
The return value (or exception) propagates back through the wrapper chain, allowing each plugin to:
Log the result
Transform the return value
Handle exceptions
Clean up resources
The on_decorate() Hookο
New in v0.6.0: Optional hook called during decoration, before wrapping.
from smartswitch import BasePlugin
class MyPlugin(BasePlugin):
def on_decorate(self, switch: "Switcher", func: Callable, entry: "MethodEntry") -> None:
"""
Called ONCE during decoration, BEFORE wrap_handler().
Use this to:
- Analyze function signature, type hints, docstring
- Prepare expensive resources (models, compiled patterns, etc.)
- Store metadata in entry.metadata
"""
# Access function metadata
import inspect
from typing import get_type_hints
sig = inspect.signature(func)
hints = get_type_hints(func)
# Store in entry metadata under our plugin's namespace
if not hasattr(entry, '_myplugin_meta'):
entry._myplugin_meta = {}
entry._myplugin_meta["signature"] = sig
entry._myplugin_meta["hints"] = hints
entry._myplugin_meta["analyzed"] = True
def wrap_handler(self, switch: "Switcher", entry: "MethodEntry", call_next: Callable) -> Callable:
"""
Called AFTER on_decorate().
Can read metadata prepared in on_decorate().
"""
def wrapper(*args, **kwargs):
# Use metadata during execution
if hasattr(entry, '_myplugin_meta') and entry._myplugin_meta.get("analyzed"):
# Do something with prepared data
pass
return call_next(*args, **kwargs)
return wrapper
Metadata Sharing Between Pluginsο
New in v0.6.0: Plugins can share metadata using entry attributes or entry.metadata dict.
Recommended Approach: Store metadata as entry attributes with plugin-specific prefix:
Use
entry._pluginname_datafor plugin-specific dataOr use
entry.metadata['pluginname']for dict-based storageLater plugins can read earlier pluginsβ metadata
Why Namespacing Matters:
Multiple plugins can store data without conflicts
Plugins can read data from other plugins
Metadata persists throughout the handlerβs lifetime
Each plugin owns its namespace
class PydanticPlugin(BasePlugin):
def on_decorate(self, switch, func, entry):
from typing import get_type_hints
from pydantic import create_model
# Create Pydantic model from type hints
hints = get_type_hints(func)
validation_model = create_model(f"{func.__name__}_Model", **hints)
# Store in entry as attribute (recommended)
if not hasattr(entry, '_pydantic'):
entry._pydantic = {}
entry._pydantic["model"] = validation_model
entry._pydantic["hints"] = hints
class FastAPIPlugin(BasePlugin):
def __init__(self, app):
super().__init__()
self.app = app
def on_decorate(self, switch, func, entry):
# Read from Pydantic plugin metadata
if hasattr(entry, '_pydantic'):
pydantic_meta = entry._pydantic
# Use pre-created Pydantic model for FastAPI
model = pydantic_meta["model"]
self.app.post(f"/{func.__name__}", response_model=model)(func)
# Store our own metadata
if not hasattr(entry, '_fastapi'):
entry._fastapi = {}
entry._fastapi["registered"] = True
entry._fastapi["endpoint"] = f"/{func.__name__}"
# Usage - order matters! Pydantic must come before FastAPI
# Register plugins
Switcher.register_plugin("pydantic", PydanticPlugin)
Switcher.register_plugin("fastapi", lambda app: FastAPIPlugin(app))
sw = Switcher().plug("pydantic").plug("fastapi", app=my_app)
Key Points:
Store metadata as
entry._pluginname_dataattributesLater plugins can read earlier pluginsβ metadata by accessing entry attributes
Plugin order matters for metadata dependencies
Metadata is for immutable setup data (signatures, compiled patterns, models)
BasePlugin Classο
Recommended: Inherit from BasePlugin for common functionality.
from smartswitch import BasePlugin
class MyPlugin(BasePlugin):
"""BasePlugin provides:
- plugin_name property (auto-generated from class name)
- configure() for per-handler configuration
- get_config() and is_enabled() utilities
- Default no-op on_decorate() implementation
"""
def __init__(self, **config):
super().__init__(**config)
# Your initialization
def on_decorate(self, switch, func, entry):
# Optional setup phase - called before wrap_handler
# Store metadata in entry.metadata if needed
pass
def wrap_handler(self, switch, entry, call_next):
# Required wrapping logic
def wrapper(*args, **kwargs):
# Pre-processing
result = call_next(*args, **kwargs)
# Post-processing
return result
return wrapper
Benefits of BasePlugin:
Automatic
plugin_namegeneration (removes βPluginβ suffix, lowercase)Per-handler configuration via
configure()Enable/disable functionality built-in
No-op
on_decorate()provided (override if needed)
Global Plugin Registryο
New in v0.6.0: Plugin users should register external plugins globally to use the same string-based loading semantics as built-in plugins.
Who registers plugins? USERS, not developers!
Plugin developers: Just publish the plugin class
Plugin users: Should register it in their own code to enable string-based loading
# User's application code
from smartswitch import Switcher
from smartcache import SmartCachePlugin # External plugin
# USER registers the plugin to enable string-based loading
Switcher.register_plugin("cache", SmartCachePlugin)
# Now can use same semantics as built-in plugins
sw = Switcher().plug("cache", ttl=300)
# Without registration, would need:
# sw = Switcher().plug(SmartCachePlugin(ttl=300)) # Different semantics
Built-in plugins are pre-registered:
"logging"- Call history and monitoring"pydantic"- Type validation via Pydantic models
Recommended practice: Register external plugins once at application startup to maintain consistent plugin loading semantics throughout your codebase.
Plugin Namingο
Core Principle: Name by Function, Not Frameworkο
IMPORTANT: Plugin names should describe what the plugin does, not that itβs a SmartSwitch plugin.
β BAD - Names reference SmartSwitch:
class SmartSwitchLoggerPlugin:
plugin_name = "smartswitch-logger" # β Redundant!
class SwitcherMetricsPlugin:
plugin_name = "switcher-metrics" # β Redundant!
β GOOD - Names describe functionality:
class LoggingPlugin:
plugin_name = "logging" # β
Clear: logs things
class MetricsPlugin:
plugin_name = "metrics" # β
Clear: tracks metrics
class CachePlugin:
plugin_name = "cache" # β
Clear: caches results
class ValidationPlugin:
plugin_name = "validation" # β
Clear: validates inputs
Why This Mattersο
The plugin_name becomes the attribute for accessing the plugin:
# Good naming
sw = Switcher().plug('logging')
# Bad naming (redundant)
sw = Switcher().plug('smartswitch-logger')
External Package Namingο
When publishing plugin packages to PyPI:
Package name (PyPI): Can reference ecosystem for discoverability
β
smartretry- OK for PyPI package nameβ
gtext-cache- OK for PyPI package name
Plugin name (in code): Should describe functionality only
β
plugin_name = "retry"- Clean attribute accessβ
plugin_name = "cache"- Clean attribute access
Example:
# Package on PyPI: "smartretry"
# pip install smartretry
from smartretry import SmartRetryPlugin
class SmartRetryPlugin(BasePlugin):
def __init__(self, **kwargs):
super().__init__(name="retry", **kwargs) # β
Not "smartretry"!
# Usage
Switcher.register_plugin('retry', SmartRetryPlugin)
sw = Switcher().plug('retry')
sw.retry.set_max_attempts(3) # β
Clean attribute name
Naming Guidelines Summaryο
plugin_name: Describes functionality (e.g.,
"logger","cache","metrics")Class name: Can reference ecosystem (e.g.,
SmartRetryPlugin,GtextCachePlugin)PyPI package: Can reference ecosystem (e.g.,
smartretry,gtext-cache)Attribute access: Uses
plugin_name, should be clean and concise
Basic Plugin Exampleο
Hereβs a minimal plugin that tracks call counts:
from smartswitch import Switcher, BasePlugin
class CallCounterPlugin(BasePlugin):
"""Plugin that counts handler calls."""
def __init__(self, **kwargs):
super().__init__(name="counter", **kwargs) # Access via sw.counter
self._counts = {}
def wrap_handler(self, switch, entry, call_next):
"""Wrap function to count calls."""
handler_name = entry.name
self._counts[handler_name] = 0
def wrapper(*args, **kwargs):
self._counts[handler_name] += 1
return call_next(*args, **kwargs)
return wrapper
def get_count(self, handler: str) -> int:
"""Get call count for a handler."""
return self._counts.get(handler, 0)
def reset(self):
"""Reset all counts."""
self._counts.clear()
# Register plugin globally
Switcher.register_plugin("counter", CallCounterPlugin)
# Usage:
sw = Switcher().plug("counter")
@sw
def my_handler(x):
return x * 2
sw('my_handler')(5)
sw('my_handler')(10)
print(sw.counter.get_count('my_handler')) # Output: 2
Accessing the Pluginο
After registration with .plug(), your plugin is accessible via attribute access:
# Register plugin
Switcher.register_plugin("myplugin", MyPlugin)
# Use plugin
sw = Switcher().plug("myplugin")
# Access plugin methods:
sw.myplugin.some_method()
sw.myplugin.another_method()
Custom Plugin Namesο
You can use a custom name when calling .plug():
# Register with one name
Switcher.register_plugin("original", MyPlugin)
# Can use with different instance name if plugin supports it
sw.plug("original", name='custom_name')
sw.custom_name.some_method() # Access with custom name
Advanced Plugin Example: Retry Logicο
Hereβs a more complex example showing how to add retry logic with exponential backoff:
import time
from smartswitch import Switcher, BasePlugin
from pydantic import BaseModel, Field
class RetryConfig(BaseModel):
max_attempts: int = Field(default=3, description="Maximum retry attempts")
backoff: float = Field(default=1.0, description="Backoff multiplier")
exceptions: tuple = Field(default=(Exception,), description="Exceptions to retry")
class RetryPlugin(BasePlugin):
"""Plugin that retries failed operations with exponential backoff."""
config_model = RetryConfig
def wrap_handler(self, switch, entry, call_next):
"""Wrap handler with retry logic."""
handler_name = entry.name
def retry_wrapper(*args, **kwargs):
cfg = self.get_config(handler_name)
max_attempts = cfg.get('max_attempts', 3)
backoff = cfg.get('backoff', 1.0)
exceptions = cfg.get('exceptions', (Exception,))
last_exception = None
for attempt in range(max_attempts):
try:
return call_next(*args, **kwargs)
except exceptions as e:
last_exception = e
if attempt < max_attempts - 1:
wait_time = backoff * (2 ** attempt)
print(f"Retry {handler_name}: attempt {attempt + 1} failed, waiting {wait_time}s")
time.sleep(wait_time)
else:
print(f"Retry {handler_name}: all {max_attempts} attempts failed")
# All retries exhausted
raise last_exception
return retry_wrapper
# Register plugin
Switcher.register_plugin("retry", RetryPlugin)
# Usage:
sw = Switcher().plug("retry", flags='')
@sw
def unstable_api_call(endpoint: str):
"""Handler that might fail and needs retries."""
# Simulate flaky API
import random
if random.random() < 0.7: # 70% failure rate
raise ConnectionError("API unavailable")
return f"Success: {endpoint}"
# Configure per-method retries
sw.retry.configure['unstable_api_call'].max_attempts = 5
sw.retry.configure['unstable_api_call'].backoff = 0.5
# Will retry up to 5 times with exponential backoff
result = sw('unstable_api_call')('/users')
Plugin Initialization Parametersο
Plugins can accept configuration parameters:
from smartswitch import Switcher, BasePlugin
class ConfigurablePlugin(BasePlugin):
def __init__(self, option1: str, option2: int = 10, **kwargs):
super().__init__(name="config", **kwargs)
self.option1 = option1
self.option2 = option2
def wrap_handler(self, switch, entry, call_next):
# Use self.option1 and self.option2 in wrapping logic
def wrapper(*args, **kwargs):
# Access configuration
return call_next(*args, **kwargs)
return wrapper
# Register and use:
Switcher.register_plugin('config', ConfigurablePlugin)
sw = Switcher().plug('config', option1="value", option2=20)
Storing Stateο
Plugins can store state and provide query methods:
from smartswitch import Switcher, BasePlugin
from functools import wraps
import time
class StatefulPlugin(BasePlugin):
def __init__(self, **kwargs):
super().__init__(name="stats", **kwargs)
self._call_times = []
def wrap_handler(self, switch, entry, call_next):
@wraps(entry.func)
def wrapper(*args, **kwargs):
start = time.time()
result = call_next(*args, **kwargs)
elapsed = time.time() - start
self._call_times.append(elapsed)
return result
return wrapper
def average_time(self) -> float:
"""Get average call time."""
return sum(self._call_times) / len(self._call_times) if self._call_times else 0
def total_calls(self) -> int:
"""Get total number of calls."""
return len(self._call_times)
# Register and use:
Switcher.register_plugin('stats', StatefulPlugin)
sw = Switcher().plug('stats')
@sw
def handler():
return 42
sw('handler')()
sw('handler')()
print(f"Average time: {sw.stats.average_time():.4f}s")
print(f"Total calls: {sw.stats.total_calls()}")
Chaining Multiple Pluginsο
Plugins can be chained together:
sw = (Switcher()
.plug('logging', flags='print,enabled')
.plug('validation')
.plug('cache'))
@sw
def expensive_computation(x):
import time
time.sleep(0.1)
return x * 2
# Handler is wrapped by all three plugins:
# 1. LoggingPlugin tracks the call
# 2. ValidationPlugin validates inputs
# 3. CachePlugin caches results
result = sw('expensive_computation')(5) # First call: slow
result = sw('expensive_computation')(5) # Second call: cached, fast
Standard vs External Pluginsο
Standard plugins (shipped with SmartSwitch):
Pre-registered, loaded by string name:
sw.plug('logging', flags='enabled')Registered via
Switcher.register_plugin()Examples:
logging
External plugins (third-party packages):
Must be registered first, then used by name
Distributed as separate packages
Examples:
smartretry,smartcache, custom monitoring tools
Example with external plugin:
from smartswitch import Switcher
from smartretry import SmartRetryPlugin
# Register the external plugin
Switcher.register_plugin("retry", SmartRetryPlugin)
# Use by name (recommended)
sw = Switcher().plug("retry", max_attempts=3)
Creating an External Plugin Packageο
For a package like smartretry, the structure would be:
smartretry/
βββ pyproject.toml
βββ src/
β βββ smartretry/
β βββ __init__.py
β βββ plugin.py
βββ tests/
βββ test_plugin.py
src/smartretry/init.py:
"""SmartRetry - Retry logic plugin for SmartSwitch."""
from .plugin import SmartRetryPlugin
__version__ = "0.1.0"
__all__ = ["SmartRetryPlugin"]
src/smartretry/plugin.py:
"""SmartRetry Plugin implementation."""
import time
from smartswitch import BasePlugin
from pydantic import BaseModel, Field
class RetryConfig(BaseModel):
max_attempts: int = Field(default=3)
backoff: float = Field(default=1.0)
class SmartRetryPlugin(BasePlugin):
"""Plugin that retries failed operations."""
config_model = RetryConfig
def wrap_handler(self, switch, entry, call_next):
"""Wrap handler with retry logic."""
handler_name = entry.name
def retry_wrapper(*args, **kwargs):
cfg = self.get_config(handler_name)
max_attempts = cfg.get('max_attempts', 3)
backoff = cfg.get('backoff', 1.0)
for attempt in range(max_attempts):
try:
return call_next(*args, **kwargs)
except Exception as e:
if attempt == max_attempts - 1:
raise
time.sleep(backoff * (2 ** attempt))
return retry_wrapper
Usage in user code:
from smartswitch import Switcher
from smartretry import SmartRetryPlugin
# Register plugin
Switcher.register_plugin("retry", SmartRetryPlugin)
# Use by name
sw = Switcher().plug("retry", max_attempts=5)
@sw
def flaky_api_call(url: str):
# May fail and needs retries
pass
Best Practicesο
1. Use @wraps to Preserve Metadataο
Always use functools.wraps to preserve function metadata:
from functools import wraps
def wrap_handler(self, switch, entry, call_next):
@wraps(entry.func) # Preserves __name__, __doc__, etc.
def wrapper(*args, **kwargs):
return call_next(*args, **kwargs)
return wrapper
2. Store Switcher Reference Carefullyο
If you need the Switcher instance later, store it in on_decorate():
def on_decorate(self, switch, func, entry):
if self._switcher is None:
self._switcher = switch
# ... rest of setup
def wrap_handler(self, switch, entry, call_next):
# Access self._switcher here
# ... rest of implementation
3. Provide Clear Public Methodsο
Design a clean API for users:
class MyPlugin(BasePlugin):
def wrap_handler(self, switch, entry, call_next):
# Internal implementation
def wrapper(*args, **kwargs):
return call_next(*args, **kwargs)
return wrapper
# Public API methods:
def get_stats(self):
"""Get statistics (user-facing)."""
pass
def reset(self):
"""Reset internal state (user-facing)."""
pass
def _internal_helper(self):
"""Internal helper (private)."""
pass
4. Document Plugin Behaviorο
Provide clear docstrings:
class MyPlugin:
"""
Plugin that adds X functionality to SmartSwitch handlers.
Features:
- Feature 1
- Feature 2
Examples:
>>> sw = Switcher().plug(MyPlugin())
>>> @sw
... def handler():
... return 42
>>> sw.myplugin.some_method()
"""
5. Handle Edge Casesο
Consider what happens when:
Handler has no arguments
Handler raises exceptions
Handler is called multiple times
Multiple plugins are active
6. Test Thoroughlyο
Write comprehensive tests:
def test_plugin_basic():
sw = Switcher().plug(MyPlugin())
@sw
def handler(x):
return x * 2
assert sw('handler')(5) == 10
# Test plugin-specific behavior
def test_plugin_with_logging():
sw = Switcher().plug('logging').plug(MyPlugin())
@sw
def handler():
return 42
sw('handler')()
# Verify both plugins work together
Plugin Protocol Referenceο
Requiredο
wrap(func, switcher) -> Callable: Transform/wrap the handler function
Recommendedο
plugin_name: str: Class attribute defining default access name
Optionalο
__init__(...): Accept configuration parametersPublic methods for user interaction (queries, configuration, etc.)
FAQο
Q: Can plugins modify the Switcher instance?
A: Yes, but be careful. The switcher parameter gives you access to the Switcher instance. You can read its state, but modifying it directly is discouraged. Use your pluginβs state instead.
Q: Whatβs the execution order when multiple plugins are chained?
A: Plugins are applied in registration order. Each pluginβs wrap_handler() creates a layer in the middleware chain:
sw.plug('plugin1').plug('plugin2').plug('plugin3')
# Execution flows through the chain:
# plugin1.wrap_handler() β plugin2.wrap_handler() β plugin3.wrap_handler() β original function
Q: Can I make a plugin that doesnβt wrap the function?
A: Yes! Just return the original function via call_next unchanged. The plugin can still store information in the setup phase:
def on_decorate(self, switch, func, entry):
# Register function during setup
self._registered_functions.add(entry.name)
def wrap_handler(self, switch, entry, call_next):
# Return handler unchanged
return call_next
Q: How do I access plugin methods from wrapped functions?
A: Access self directly in the wrapper - itβs already available:
def wrap_handler(self, switch, entry, call_next):
@wraps(entry.func)
def wrapper(*args, **kwargs):
# Access self (the plugin instance) here
self.do_something()
return call_next(*args, **kwargs)
return wrapper
Q: Can plugins add new decorator parameters?
A: Not directly. The decorator syntax is controlled by Switcher. But you can create a custom decorator that adds metadata:
def async_handler(func):
"""Decorator to mark handler as async."""
func._is_async = True
return func
# In plugin:
def on_decorate(self, switch, func, entry):
if hasattr(func, '_is_async'):
# Store metadata about async function
entry.metadata['async'] = True
def wrap_handler(self, switch, entry, call_next):
if entry.metadata.get('async'):
# Handle async function
pass
return call_next
Summaryο
Creating a SmartSwitch plugin (v0.6.0+):
β Inherit from
BasePluginfor common functionalityβ Override
on_decorate(switch, func, entry)for setup phase (optional)β Implement
wrap_handler(switch, entry, call_next)for wrapping logic (required)β Use
entry.metadata[plugin_namespace]to store/share metadataβ Use middleware pattern with
call_nextfor proper execution flowβ Provide public methods for user interaction
β Test with multiple handlers and in combination with other plugins
β Document behavior and examples clearly
β Register globally with
Switcher.register_plugin()(optional)
Your plugin will integrate seamlessly with SmartSwitchβs .plug() system and be accessible via switcher.plugin_name.method().
Need help? Check existing plugins like LoggingPlugin in src/smartswitch/plugins/logging.py for reference implementation.