Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Plugin_system_mvp #68

Merged
merged 18 commits into from
Nov 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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