Skip to content

Commit

Permalink
Merge pull request #68 from stuartmaxwell:plugin_system_mvp
Browse files Browse the repository at this point in the history
Plugin_system_mvp
  • Loading branch information
stuartmaxwell authored Nov 20, 2024
2 parents a6bdbaa + e487fe1 commit 06b9493
Show file tree
Hide file tree
Showing 18 changed files with 936 additions and 5 deletions.
1 change: 1 addition & 0 deletions djpress-example-plugin/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Example Plugin for DJ Press
15 changes: 15 additions & 0 deletions djpress-example-plugin/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
[project]
name = "djpress-example-plugin"
version = "0.1.0"
description = "Example plugin for DJ Press"
readme = "README.md"
authors = [{ name = "Stuart Maxwell", email = "[email protected]" }]
requires-python = ">=3.10"
dependencies = ["django>=4.2.0", "djpress"]

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.uv.sources]
djpress = { workspace = true }
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""DJ Press example plugin."""
46 changes: 46 additions & 0 deletions djpress-example-plugin/src/djpress_example_plugin/plugin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
"""An example DJ Press plugin."""

from djpress.plugins import DJPressPlugin, PluginRegistry


class Plugin(DJPressPlugin):
"""An example DJ Press plugin."""

name = "djpress_example_plugin"

def setup(self, registry: PluginRegistry) -> None:
"""Set up the plugin.
Args:
registry (Hooks): The plugin registry.
"""
registry.register_hook("pre_render_content", self.add_greeting)
registry.register_hook("post_render_content", self.add_goodbye)

def add_greeting(self, content: str) -> str:
"""Add a greeting to the content.
This is a pre-render hook, so the content is still in Markdown format.
Args:
content (str): The content to modify.
Returns:
str: The modified content.
"""
return f"{self.config.get("pre_text")} This was added by `djpress_example_plugin`!\n\n---\n\n{content}"

def add_goodbye(self, content: str) -> str:
"""Add a goodbye message to the content.
This is a post-render hook, so the content has already been rendered from Markdown to HTML.
Args:
content (str): The content to modify.
Returns:
str: The modified content.
"""
return (
f"{content}<hr><p>{self.config.get("pre_text")} This was added by <code>djpress_example_plugin</code>!</p>"
)
2 changes: 2 additions & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ complete:
- [URL Structure](url_structure.md)
- [Template Tags](templatetags.md)
- [Themes](themes.md)
- [Plugins](plugins.md)

## Table of Contents

Expand All @@ -20,4 +21,5 @@ configuration
url_structure
templatetags
themes
plugins
```
127 changes: 127 additions & 0 deletions docs/plugins.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
# Plugins

DJ Press includes a plugin system that allows you to extend its functionality. Plugins can modify content before and
after rendering, and future versions will include more hook points for customization.

## Creating a Plugin

To create a plugin, create a new Python package with the following structure:

```text
djpress_my_plugin/
__init__.py
plugin.py
```

In `plugin.py`, create a class called `Plugin` that inherits from `DJPressPlugin`:

```python
from djpress.plugins import DJPressPlugin

class Plugin(DJPressPlugin):
name = "djpress_my_plugin" # Required - recommended to be the same as the package name

def setup(self, registry):
# Register your hook callbacks
registry.register_hook("pre_render_content", self.modify_content)
registry.register_hook("post_render_content", self.modify_html)

def modify_content(self, content: str) -> str:
"""Modify the markdown content before rendering."""
# Create your code here...
return content

def modify_html(self, content: str) -> str:
"""Modify the HTML after rendering."""
# Create your code here...
return content
```

## Available Hooks

Currently available hooks:

- `pre_render_content`: Called before markdown content is rendered to HTML
- `post_render_content`: Called after markdown content is rendered to HTML

**Note** that you can also import the `Hooks` enum class, and reference the hook names specifically, e.g.
`from djpress.plugins import Hooks` and then you can refer to the above two hooks as follows:

- `Hooks.PRE_RENDER_CONTENT`
- `Hooks.POST_RENDER_CONTENT`

Each hook receives the content as its first argument and must return the modified content.

## Installing Plugins

- Install your plugin package:

```bash
pip install djpress-my-plugin
```

- Add the plugin to your DJ Press settings by adding the package name of your plugin to the `PLUGINS` key in `DJPRESS_SETTINGS`.
If you use the recommended file structure for your plugin as described above, you only need the package name,
i.e. this assumes your plugin code resides in a class called `Plugin` in a module called `plugins.py`

```python
DJPRESS_SETTINGS = {
"PLUGINS": [
"djpress_my_plugin"
],
}
```

- If you have a more complex plugin or you prefer a different style of packaging your plugin, you must use the full
path to your plugin class. For example, if your package name is `djpress_my_plugin` and the module with your plugin
class is `custom.py` and the plugin class is called `MyPlugin`, you'd need to use the following format:

```python
DJPRESS_SETTINGS = {
"PLUGINS": [
"djpress_my_plugin.custom.MyPlugin"
],
}
```

## Plugin Configuration

Plugins can receive configuration through the `PLUGIN_SETTINGS` dictionary. Access settings in your plugin using `self.config`.

For example, here is the `PLUGIN_SETTINGS` from the example plugin in this repository. **Note** that the dictionary key
is the `name` of the plugin and not the package name. It's recommended to keep the `name` of the plugin the same as the
package name, otherwise it will get confusing.

```python
DJPRESS_SETTINGS = {
"PLUGINS": ["djpress_example_plugin"], # this is the package name!
"PLUGIN_SETTINGS": {
"djpress_example_plugin": { # this is the plugin name!
"pre_text": "Hello, this text is configurable!",
"post_text": "Goodbye, this text is configurable!",
},
},
}
```

In your plugin, you can access these settings using `self.config.get("pre_text")` or `self.config.get("post_text")`.

## Plugin Development Guidelines

1. You must define a unique `name` for your plugin and strongly recommend this is the same as the package name.
2. Handle errors gracefully - don't let your plugin break the site.
3. Use type hints for better code maintainability.
4. Include tests for your plugin's functionality.
5. Document any settings your plugin uses.

## System Checks

DJ Press includes system checks that will warn about:

- Unknown hooks (might indicate deprecated hooks or version mismatches)

Run Django's check framework to see any warnings:

```bash
python manage.py check
```
10 changes: 10 additions & 0 deletions example/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,3 +89,13 @@

# Required for django-debug-toolbar
INTERNAL_IPS = ["127.0.0.1"]

DJPRESS_SETTINGS = {
"PLUGINS": ["djpress_example_plugin"],
"PLUGIN_SETTINGS": {
"djpress_example_plugin": {
"pre_text": "Hello, this text is configurable!",
"post_text": "Goodbye, this text is configurable!",
},
},
}
11 changes: 9 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "djpress"
version = "0.11.4"
version = "0.12.0"
description = "A blog application for Django sites, inspired by classic WordPress."
readme = "README.md"
requires-python = ">=3.10"
Expand Down Expand Up @@ -50,6 +50,7 @@ test = [
"pytest-coverage>=0.0",
"django-debug-toolbar>=4.4.0",
"nox>=2024.4.15",
"djpress-example-plugin",
]
docs = [
"cogapp>=3.4.1",
Expand Down Expand Up @@ -95,7 +96,7 @@ include = ["src/djpress/*"]
omit = ["*/tests/*", "*/migrations/*"]

[tool.bumpver]
current_version = "0.11.4"
current_version = "0.12.0"
version_pattern = "MAJOR.MINOR.PATCH"
commit_message = "👍 bump version {old_version} -> {new_version}"
commit = true
Expand All @@ -105,3 +106,9 @@ tag = true
[tool.bumpver.file_patterns]
"pyproject.toml" = ['version = "{version}"']
"src/djpress/__init__.py" = ['^__version__ = "{version}"$']

[tool.uv.workspace]
members = ["djpress-example-plugin"]

[tool.uv.sources]
djpress-example-plugin = { workspace = true }
2 changes: 1 addition & 1 deletion src/djpress/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""djpress module."""

__version__ = "0.11.4"
__version__ = "0.12.0"
2 changes: 2 additions & 0 deletions src/djpress/app_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
"MARKDOWN_EXTENSIONS": ([], list),
"MARKDOWN_RENDERER": ("djpress.markdown_renderer.default_renderer", str),
"MICROFORMATS_ENABLED": (True, bool),
"PLUGINS": ([], list),
"PLUGIN_SETTINGS": ({}, dict),
"POST_PREFIX": ("{{ year }}/{{ month }}/{{ day }}", str),
"POST_READ_MORE_TEXT": ("Read more...", str),
"RECENT_PUBLISHED_POSTS_COUNT": (20, int),
Expand Down
11 changes: 11 additions & 0 deletions src/djpress/apps.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Djpress app configuration."""

from django.apps import AppConfig
from django.core.checks import Tags, register


class DjpressConfig(AppConfig):
Expand All @@ -14,3 +15,13 @@ def ready(self: "DjpressConfig") -> None:
"""Run when the app is ready."""
# Import signals to ensure they are registered
import djpress.signals # noqa: F401

# Initialize plugin system
from djpress.plugins import registry

registry.load_plugins()

# Register check explicitly
from djpress.checks import check_plugin_hooks

register(check_plugin_hooks, Tags.compatibility)
26 changes: 26 additions & 0 deletions src/djpress/checks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
"""Custom checks for DJPress."""

from django.core.checks import Warning


def check_plugin_hooks(app_configs, **kwargs) -> list[Warning]: # noqa: ANN001, ANN003, ARG001
"""Check for unknown plugin hooks."""
from djpress.plugins import Hooks, registry

# Ensure plugins are loaded
if not registry._loaded: # noqa: SLF001
registry.load_plugins()

warnings = []

for hook_name in registry.hooks:
if not isinstance(hook_name, Hooks):
warning = Warning(
f"Plugin registering unknown hook '{hook_name}'.",
hint=("This might indicate use of a deprecated hook or a hook from a newer version of DJPress."),
obj=hook_name,
id="djpress.W001",
)
warnings.append(warning)

return warnings
13 changes: 12 additions & 1 deletion src/djpress/models/post.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from djpress.conf import settings as djpress_settings
from djpress.exceptions import PageNotFoundError, PostNotFoundError
from djpress.models import Category
from djpress.plugins import Hooks, registry
from djpress.utils import get_markdown_renderer

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -395,7 +396,17 @@ def _check_circular_reference(self) -> None:
@property
def content_markdown(self: "Post") -> str:
"""Return the content as HTML converted from Markdown."""
return render_markdown(self.content)
# Get the raw markdown content
content = self.content

# Let plugins modify the markdown before rendering
content = registry.run_hook(Hooks.PRE_RENDER_CONTENT, content)

# Render the markdown
html_content = render_markdown(content)

# Let the plugins modify the markdown after rendering and return the results
return registry.run_hook(Hooks.POST_RENDER_CONTENT, html_content)

@property
def truncated_content_markdown(self: "Post") -> str:
Expand Down
Loading

0 comments on commit 06b9493

Please sign in to comment.