This guide will walk you through creating a working DVP CMS plugin from scratch.
What is a Plugin?
A DVP CMS plugin is a Python class that:
- Inherits from
Pluginbase class - Implements one or more hook protocols
- Can modify content, perform actions, or add functionality
- Is registered with the plugin registry
- Can be enabled/disabled independently
Plugins let you extend DVP CMS without modifying core code.
Plugin Architecture
Project Structure
A DVP CMS plugin is organized as a standard Python package:
my-plugin/
├── pyproject.toml # Package metadata and entry point
├── src/
│ └── my_plugin/
│ ├── __init__.py # Package exports
│ └── plugin.py # Your plugin code
└── tests/
└── test_plugin.py # Tests
Hook Types
DVP CMS hooks receive a HookContext for tenant isolation. All hooks follow a consistent pattern: ctx is ALWAYS first.
Filter Hooks - Transform and return data (ctx first, must return value):
def dvp_before_content_create(
self,
ctx: HookContext, # ctx ALWAYS first
content: dict[str, Any],
metadata: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""All hooks: ctx first. Filter hooks MUST return value."""
content['modified'] = True
return content # Filter hooks must return (never None!)
Action Hooks - React without returning (ctx first, no return):
def dvp_content_published(
self,
ctx: HookContext, # ctx ALWAYS first
content_id: str, # Note: str, not UUID
content: dict[str, Any],
metadata: dict[str, Any] | None = None,
) -> None:
"""All hooks: ctx first. Action hooks need no return."""
print(f"Content {content_id} was published!")
# No return needed
Step 1: Project Setup
Create Directory Structure
mkdir -p my-first-plugin/src/my_first_plugin my-first-plugin/tests
cd my-first-plugin
Required Files
You'll create these files:
pyproject.toml- Package metadata and entry pointsrc/my_first_plugin/__init__.py- Package exportssrc/my_first_plugin/plugin.py- Main plugin code
Step 2: Create Plugin Files
2.1 Create pyproject.toml
This defines your plugin package and entry point:
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "my-first-plugin"
version = "1.0.0"
description = "My first DVP CMS plugin that adds a timestamp to content"
readme = "README.md"
requires-python = ">=3.12"
authors = [
{ name = "Your Name", email = "you@example.com" },
]
dependencies = [
"dvp-cms>=1.0.0",
]
[project.entry-points."dvp_cms.plugins"]
my-first-plugin = "my_first_plugin:MyFirstPlugin"
[tool.hatch.build.targets.wheel]
packages = ["src/my_first_plugin"]
Key sections:
[project]: Package name, version, Python 3.12+ requirement[project.entry-points."dvp_cms.plugins"]: How DVP CMS discovers your plugin
2.2 Create src/my_first_plugin/__init__.py
This exports your plugin class:
"""My First Plugin for DVP CMS."""
from my_first_plugin.plugin import MyFirstPlugin
__all__ = ["MyFirstPlugin"]
__version__ = "1.0.0"
Step 3: Implement Your Plugin
3.1 Create src/my_first_plugin/plugin.py
Here's the full plugin:
"""
My First Plugin for DVP CMS
Adds a timestamp to all content before saving.
"""
from __future__ import annotations
from datetime import datetime, timezone
from typing import TYPE_CHECKING, Any
from uuid import UUID
from dvp_cms.plugins.base import Plugin
from dvp_cms.plugins.hookspec import ContentLifecycleHooks
if TYPE_CHECKING:
from dvp_cms.plugins.context import HookContext
class MyFirstPlugin(Plugin, ContentLifecycleHooks):
"""
Add timestamps to content.
This plugin demonstrates the basics of DVP CMS plugin development.
Example:
>>> from dvp_cms.plugins.registry import PluginRegistry
>>> from my_first_plugin import MyFirstPlugin
>>>
>>> registry = PluginRegistry()
>>> plugin = MyFirstPlugin()
>>> registry.register(plugin)
>>> registry.enable("my-first-plugin")
"""
# Plugin metadata
name = "my-first-plugin"
version = "1.0.0"
description = "Add timestamps to content"
author = "Your Name"
def __init__(self) -> None:
"""Initialize the plugin."""
super().__init__()
self.content_count = 0
def dvp_before_content_create(
self,
ctx: HookContext,
content: dict[str, Any],
metadata: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Add timestamp before content is created.
This is a FILTER hook:
- ctx is ALWAYS first (consistent across all hooks)
- MUST return the modified content (never None!)
Args:
ctx: Hook context with tenant information (always first)
content: Content data being created
metadata: Optional metadata
Returns:
Modified content with timestamp added
"""
# Add timestamp (UTC)
content['created_at_plugin'] = datetime.now(timezone.utc).isoformat()
content['processed_by'] = self.name
# Increment counter
self.content_count += 1
# IMPORTANT: Filter hooks MUST return the modified content
return content
def dvp_content_published(
self,
ctx: HookContext,
content_id: str,
content: dict[str, Any],
metadata: dict[str, Any] | None = None,
) -> None:
"""React when content is published.
This is an ACTION hook:
- ctx is ALWAYS first (consistent across all hooks)
- No return needed
Args:
ctx: Hook context with tenant information (always first)
content_id: ID of the published content (str, not UUID)
content: The published content
metadata: Optional metadata
"""
print(f"[{ctx.tenant_slug}] Content {content_id} published!")
3.2 Understanding the Code
Key Patterns:
from dvp_cms.plugins.base import Plugin
from dvp_cms.plugins.hookspec import ContentLifecycleHooks
- Import from
dvp_cms.plugins.base(notdvp_cms.plugins) ContentLifecycleHooksdefines the hook interface
class MyFirstPlugin(Plugin, ContentLifecycleHooks):
- Inherit from
Pluginbase class - Implement
ContentLifecycleHooksprotocol
name = "my-first-plugin"
version = "1.0.0"
description = "Add timestamps to content"
author = "Your Name"
- Required metadata attributes
namemust match entry point inpyproject.toml
def __init__(self) -> None:
super().__init__()
self.content_count = 0
- Simple initialization - no parameters required
- Always call
super().__init__()first!
def dvp_before_content_create(
self,
ctx: HookContext, # ctx ALWAYS first
content: dict[str, Any],
metadata: dict[str, Any] | None = None,
) -> dict[str, Any]:
- All hooks: ctx is ALWAYS the first parameter
- Filter hooks must return the modified content (never None!)
- Hooks can be sync or async - the system handles both
Step 4: Test Your Plugin
4.1 Create Test File
Create tests/test_plugin.py:
"""Tests for My First Plugin."""
from datetime import datetime
from uuid import UUID
import pytest
from dvp_cms.plugins.context import HookContext
from my_first_plugin import MyFirstPlugin
# Canonical test tenant
TENANT_ID = UUID("11111111-1111-1111-1111-111111111111")
@pytest.fixture
def hook_context() -> HookContext:
"""Create a test hook context."""
return HookContext(
tenant_id=TENANT_ID,
tenant_slug="test-tenant",
tenant_plan="enterprise",
)
class TestMyFirstPlugin:
"""Test suite for MyFirstPlugin."""
def test_plugin_metadata(self) -> None:
"""Test plugin has correct metadata."""
plugin = MyFirstPlugin()
assert plugin.name == "my-first-plugin"
assert plugin.version == "1.0.0"
assert plugin.content_count == 0
def test_adds_timestamp(self, hook_context: HookContext) -> None:
"""Test filter hook adds timestamp to content.
All hooks: ctx is always the first parameter.
"""
plugin = MyFirstPlugin()
content = {
'title': 'Test Article',
'body': 'This is a test.'
}
# All hooks: ctx first
result = plugin.dvp_before_content_create(hook_context, content)
assert result is not None # Filter hooks must return
assert 'created_at_plugin' in result
assert 'processed_by' in result
assert result['processed_by'] == 'my-first-plugin'
assert plugin.content_count == 1
def test_preserves_existing_fields(
self, hook_context: HookContext
) -> None:
"""Test plugin doesn't delete existing fields."""
plugin = MyFirstPlugin()
content = {
'title': 'Test',
'body': 'Content',
'custom_field': 'value'
}
# All hooks: ctx first
result = plugin.dvp_before_content_create(hook_context, content)
assert result is not None
assert result['title'] == 'Test'
assert result['custom_field'] == 'value'
assert 'created_at_plugin' in result
4.2 Run Tests
pytest tests/ -v
Expected output:
tests/test_plugin.py::TestMyFirstPlugin::test_plugin_metadata PASSED
tests/test_plugin.py::TestMyFirstPlugin::test_adds_timestamp PASSED
tests/test_plugin.py::TestMyFirstPlugin::test_preserves_existing_fields PASSED
3 passed in 0.05s
Step 5: Install and Use
5.1 Install Your Plugin
Install in development mode:
# From your plugin directory
uv pip install -e .
DVP CMS automatically discovers plugins via entry points. Once installed, your plugin is available to the registry.
5.2 Example Usage
Here's how your plugin would be used in DVP CMS:
"""Example of using My First Plugin."""
import asyncio
from uuid import UUID
from dvp_cms.plugins.context import HookContext
from dvp_cms.plugins.registry import PluginRegistry
from my_first_plugin import MyFirstPlugin
async def main() -> None:
# Create registry and register plugin
registry = PluginRegistry()
plugin = MyFirstPlugin()
registry.register(plugin)
registry.enable("my-first-plugin")
print("Plugin registered and enabled")
# Create test context (in production, this comes from DVP CMS)
ctx = HookContext(
tenant_id=UUID("11111111-1111-1111-1111-111111111111"),
tenant_slug="demo-tenant",
tenant_plan="pro",
)
# Create some content
content = {
'title': 'My First Article',
'body': 'This is my first article using my plugin!'
}
print(f"\nBefore plugin: {content}")
# All hooks: ctx first
modified_content = plugin.dvp_before_content_create(ctx, content)
print(f"\nAfter plugin: {modified_content}")
print(f"\nTimestamp added: {modified_content['created_at_plugin']}")
if __name__ == "__main__":
asyncio.run(main())
5.3 Run the Example
python example_usage.py
Congratulations!
You've created your first DVP CMS plugin!
What You Learned
- Plugin structure (3 required files)
- How to inherit from Plugin base class
- How to implement hooks
- Filter vs Action hooks
- Plugin lifecycle (initialize/shutdown)
- Testing your plugin
- Registering and enabling plugins
Next Steps
1. Explore More Hooks
Try other hooks from ContentLifecycleHooks:
# ACTION hook (ctx first, no return)
def dvp_content_created(
self,
ctx: HookContext,
content_id: str,
content: dict[str, Any],
metadata: dict[str, Any] | None = None,
) -> None:
"""Called AFTER content is created."""
print(f"[{ctx.tenant_slug}] Content {content_id} created!")
# ACTION hook (ctx first, no return)
def dvp_content_unpublished(
self,
ctx: HookContext,
content_id: str,
reason: str | None = None,
metadata: dict[str, Any] | None = None,
) -> None:
"""Called when content is unpublished."""
print(f"[{ctx.tenant_slug}] Content {content_id} unpublished")
2. Add Tenant-Scoped Configuration
Make your plugin configurable per tenant:
def dvp_before_content_create(
self,
ctx: HookContext,
content: dict[str, Any],
metadata: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""All hooks: ctx first. Filter hooks must return."""
# Get tenant-specific config
config = self.get_config(str(ctx.tenant_id))
timestamp_format = config.get("timestamp_format", "%Y-%m-%d %H:%M:%S")
content['created_at_plugin'] = datetime.now(timezone.utc).strftime(
timestamp_format
)
return content # Must return!
3. Study Example Plugins
DVP CMS includes example plugins demonstrating various patterns:
- hello-world - Simplest example
- markdown-transformer - Content transformation
- seo-optimizer - SEO automation
- content-audit-plugin - Production patterns
Check your DVP CMS installation's examples directory for reference implementations.
4. Read More Guides
- Plugin Best Practices - Production standards
- Testing Plugins - Testing with pytest
- Plugin Deployment - Publishing your plugin
5. Build Something Useful
Ideas for your next plugin:
- Word Counter - Count words and estimate reading time
- Tag Extractor - Extract hashtags from content
- Spell Checker - Validate spelling before creation
- Image Optimizer - Compress images automatically
- Social Sharer - Auto-post to social media
Common Mistakes to Avoid
Mistake 1: Forgetting to Return Content
def dvp_before_content_create(self, ctx, content, metadata=None):
content['field'] = 'value'
# Forgot to return - will raise HookError!
def dvp_before_content_create(self, ctx, content, metadata=None):
content['field'] = 'value'
return content # Filter hooks MUST return!
Mistake 2: Not Calling super().__init__()
def __init__(self) -> None:
self.my_var = 123
# Forgot super().__init__()!
def __init__(self) -> None:
super().__init__() # Always call first!
self.my_var = 123
Mistake 3: Filter Hook Returning None
def dvp_before_content_create(self, ctx, content, metadata=None) -> dict | None:
# Wrong! Filter hooks must ALWAYS return a value, never None
if not content.get('title'):
return None # This will raise HookError!
return content
def dvp_before_content_create(
self,
ctx: HookContext, # ctx ALWAYS first
content: dict[str, Any],
metadata: dict[str, Any] | None = None,
) -> dict[str, Any]: # Never dict | None!
if not content.get('title'):
raise ValueError("Title required")
return content
Mistake 4: Missing metadata Parameter
def dvp_content_published(self, ctx, content_id, content):
# Missing metadata parameter!
pass
def dvp_content_published(
self,
ctx: HookContext,
content_id: str,
content: dict[str, Any],
metadata: dict[str, Any] | None = None, # Include optional params!
) -> None:
pass
Note: Hooks can be sync (def) or async (async def) - the system handles both automatically.
Tip: Check the Plugin Development Guide for correct hook signatures.
Troubleshooting
Plugin Not Loading?
- Check
pyproject.tomlentry point is correct - Verify
nameattribute matches the entry point name - Ensure the plugin is installed:
uv pip install -e . - Check Python version is 3.12+
Tests Failing?
- Run
pytest -vto see detailed errors - Check you're returning content from filter hooks (never None!)
- For filter hooks: ensure content is first parameter, ctx second
- Verify HookContext is being passed correctly
Hook Not Running?
- Check hook name exactly matches
hookspec.py - Ensure plugin is enabled:
registry.enable("plugin-name") - Check parameter order: all hooks have ctx as first parameter
- Verify hook signature matches protocol
Resources
- Example Plugins: Check your DVP CMS installation's examples directory for reference implementations
- Hook Reference: See
hookspec.pyin your DVP CMS installation for full hook documentation - Plugin Documentation: Visit the DVP CMS documentation for more guides