Plugin Development Guide¶
Overview¶
Create custom plugins to extend File Organizer functionality through a hook-based system.
Getting Started¶
Create a Plugin¶
# my_plugin.py
from file_organizer.plugins import Plugin, register_hook
class MyPlugin(Plugin):
"""Custom plugin for File Organizer"""
def __init__(self):
super().__init__()
self.name = "my-plugin"
self.version = "1.0.0"
def initialize(self):
"""Called when plugin is loaded"""
register_hook("on_file_uploaded", self.on_upload)
register_hook("on_organize_complete", self.on_complete)
async def on_upload(self, file):
"""Handle file upload"""
print(f"File uploaded: {file.name}")
async def on_complete(self, result):
"""Handle organization completion"""
print(f"Organization complete: {result}")
Complete Example¶
Here's a production-ready plugin that automatically tags images based on EXIF metadata:
"""EXIF-based image tagger plugin."""
from __future__ import annotations
from datetime import datetime
from pathlib import Path
from typing import Any
from file_organizer.plugins import Plugin, PluginMetadata
from file_organizer.plugins.sdk import hook
class ExifImageTaggerPlugin(Plugin):
"""Automatically tags images with EXIF-derived metadata."""
name = "exif_image_tagger"
version = "1.0.0"
allowed_paths: list = []
def on_load(self) -> None:
"""Handle plugin load event."""
return None
def on_enable(self) -> None:
"""Handle plugin enable event and configure settings."""
self.include_camera_model = self.config.get("include_camera_model", True)
self.include_location = self.config.get("include_location", True)
self.date_format = self.config.get("date_format", "%Y-%m-%d")
def on_disable(self) -> None:
"""Handle plugin disable event."""
return None
def on_unload(self) -> None:
"""Handle plugin unload event."""
return None
def get_metadata(self) -> PluginMetadata:
"""Return plugin metadata."""
return PluginMetadata(
name="exif_image_tagger",
version="1.0.0",
author="File Organizer Team",
description="Automatically tags images based on EXIF metadata.",
dependencies=("pillow>=10.0.0",),
)
@hook("file.organized", priority=10)
def on_file_organized(self, payload: dict[str, Any]) -> dict[str, object]:
"""Extract EXIF data and add tags to organized image files."""
destination = payload.get("destination_path")
if not isinstance(destination, str) or not destination:
return {"tagged": False, "reason": "missing destination_path"}
target = Path(destination)
if not target.exists():
return {"tagged": False, "reason": "destination file missing"}
# Only process image files
if target.suffix.lower() not in {".jpg", ".jpeg", ".tiff", ".png"}:
return {"tagged": False, "reason": "not an image file"}
tags = self._extract_exif_tags(target)
if not tags:
return {"tagged": False, "reason": "no EXIF data found"}
# Store tags in payload for downstream plugins/processing
payload["tags"] = tags
return {"tagged": True, "tags": tags, "tag_count": len(tags)}
def _extract_exif_tags(self, image_path: Path) -> list[str]:
"""Extract relevant tags from image EXIF data."""
try:
from PIL import Image
from PIL.ExifTags import TAGS
except ImportError:
return []
tags: list[str] = []
try:
with Image.open(image_path) as img:
exif_data = img.getexif()
if not exif_data:
return tags
# Extract camera model
if self.include_camera_model:
model = exif_data.get(272) # Model tag
if model:
tags.append(f"camera:{model.strip()}")
# Extract date taken
date_taken = exif_data.get(36867) # DateTimeOriginal
if date_taken:
try:
dt = datetime.strptime(date_taken, "%Y:%m:%d %H:%M:%S")
tags.append(f"date:{dt.strftime(self.date_format)}")
tags.append(f"year:{dt.year}")
except ValueError:
pass
# Extract location (GPS data)
if self.include_location:
gps_info = exif_data.get(34853) # GPSInfo
if gps_info:
tags.append("location:geotagged")
except Exception:
# Silently handle any PIL errors
pass
return tags
Plugin Configuration¶
Create config/plugins.yaml to configure the plugin:
plugins:
exif_image_tagger:
enabled: true
config:
include_camera_model: true
include_location: true
date_format: "%Y-%m-%d"
Key Features¶
This example demonstrates:
- Lifecycle Methods: Proper implementation of
on_load,on_enable,on_disable, andon_unload - Hook Registration: Using
@hookdecorator with priority for event handling - Configuration: Reading plugin config with sensible defaults
- Error Handling: Graceful handling of missing EXIF data and import errors
- Metadata: Complete
PluginMetadatawith dependencies - Type Safety: Type hints and validation for payload data
- Real-world Logic: Extracting and processing EXIF data from images
Plugin Directory Structure¶
Every plugin must follow a standard directory structure with a plugin.json manifest file:
my_plugin/
├── plugin.json # Required: Plugin manifest
├── plugin.py # Plugin implementation (entry_point)
├── __init__.py # Optional: Package initialization
├── requirements.txt # Optional: Python dependencies
├── README.md # Optional: Documentation
└── tests/ # Optional: Test files
└── test_plugin.py
Minimal Example¶
The simplest plugin requires only two files:
Complete Example Structure¶
For production plugins, use this structure:
exif_image_tagger/
├── plugin.json
├── plugin.py
├── __init__.py
├── requirements.txt
├── README.md
├── config/
│ └── defaults.yaml
└── tests/
├── __init__.py
└── test_exif_tagger.py
Plugin Manifest (plugin.json)¶
The plugin.json file is required and defines plugin metadata, dependencies, and entry point.
Required Fields¶
| Field | Type | Description |
|---|---|---|
name | string | Unique plugin identifier (lowercase, underscores) |
version | string | Semantic version (e.g., "1.0.0") |
author | string | Plugin author name or organization |
description | string | Brief description of plugin functionality |
entry_point | string | Python file containing plugin class (e.g., "plugin.py") |
Optional Fields¶
| Field | Type | Default | Description |
|---|---|---|---|
license | string | "MIT" | Plugin license identifier |
homepage | string | null | URL to plugin homepage or repository |
dependencies | array | [] | List of Python package dependencies |
min_organizer_version | string | "2.0.0" | Minimum File Organizer version required |
max_organizer_version | string | null | Maximum compatible File Organizer version |
allowed_paths | array | [] | List of filesystem paths plugin can access |
Minimal Manifest Example¶
{
"name": "hello_world",
"version": "1.0.0",
"author": "Your Name",
"description": "A simple hello world plugin.",
"entry_point": "plugin.py"
}
Complete Manifest Example¶
{
"name": "exif_image_tagger",
"version": "1.2.0",
"author": "File Organizer Team",
"description": "Automatically tags images based on EXIF metadata.",
"entry_point": "plugin.py",
"license": "MIT",
"homepage": "https://github.com/yourorg/exif-tagger",
"dependencies": [
"pillow>=10.0.0",
"piexif>=1.1.3"
],
"min_organizer_version": "2.0.0",
"max_organizer_version": "3.0.0",
"allowed_paths": [
"/Users/shared/photos",
"/mnt/nas/media"
]
}
Naming Conventions¶
- Plugin name: Use lowercase with underscores (e.g.,
exif_image_tagger, notExifImageTagger) - Entry point: Typically
plugin.py, but can be any Python file - Dependencies: Use pip-style version specifiers (e.g.,
"pillow>=10.0.0,<11.0.0")
Version Compatibility¶
Specify version constraints to ensure compatibility:
{
"min_organizer_version": "2.1.0",
"max_organizer_version": "2.9.99",
"dependencies": [
"requests>=2.28.0,<3.0.0",
"pyyaml~=6.0"
]
}
Version specifiers: - >=2.0.0 - Minimum version - <3.0.0 - Maximum version (exclusive) - ~=6.0 - Compatible release (>= 6.0, < 7.0) - ==1.2.3 - Exact version (not recommended)
Security: Allowed Paths¶
Restrict plugin filesystem access using allowed_paths:
The plugin sandbox will enforce these restrictions, preventing access to other directories.
Local Installation and Registration¶
This section covers installing and testing plugins locally during development, before publishing them.
Development Installation Methods¶
Method 1: Direct Directory Installation (Recommended for Development)¶
Install your plugin directly from its source directory when it includes standard packaging metadata such as pyproject.toml or setup.py. If you only have the minimal plugin.json + plugin.py layout shown above, use Method 3 (Manual Registration) instead.
# Navigate to your plugin directory
cd ~/projects/my_plugin
# Install in development mode (editable) when pyproject.toml/setup.py is present
pip install -e .
# Changes to plugin code are immediately reflected
Advantages: - Code changes take effect immediately without reinstalling - Easy to debug and iterate quickly - Preserves your development environment
Method 2: Install from Local Path¶
Install from a specific directory path:
# Install from absolute path
pip install /path/to/my_plugin
# Install from relative path
pip install ../plugins/my_plugin
# Install with dependencies
pip install -e /path/to/my_plugin[dev]
Method 3: Manual Registration¶
Register a plugin without pip installation by adding it to the plugin path:
Step 1: Create or edit config/plugins.yaml:
plugin_paths:
- /Users/yourname/projects/my_plugin
- ./local_plugins
plugins:
my_plugin:
enabled: true
config:
debug_mode: true
Step 2: Ensure your plugin directory has a valid plugin.json:
{
"name": "my_plugin",
"version": "1.0.0",
"author": "Your Name",
"description": "My development plugin.",
"entry_point": "plugin.py"
}
Step 3: Restart File Organizer to load the plugin:
Plugin Registration Process¶
File Organizer automatically discovers and registers plugins during startup:
- Discovery: Scans configured plugin paths and installed packages
- Validation: Checks
plugin.jsonfor required fields and compatibility - Loading: Imports the entry point and instantiates the plugin class
- Registration: Calls
on_load()andon_enable()lifecycle methods - Hook Binding: Registers all
@hookdecorated methods
Verifying Plugin Installation¶
Check that your plugin was installed and registered successfully:
# List all installed plugins
file-organizer plugins list
# Show detailed plugin information
file-organizer plugins info my_plugin
# Check plugin status
file-organizer plugins status
Expected output:
Installed Plugins:
✓ my_plugin (v1.0.0) - Enabled
Location: /Users/yourname/projects/my_plugin
Entry Point: plugin.py
Hooks: 2 registered
Testing Locally¶
Running Plugin Tests¶
# Run plugin tests with pytest
cd ~/projects/my_plugin
pytest tests/
# Run with coverage
pytest --cov=my_plugin tests/
# Run specific test
pytest tests/test_plugin.py::test_on_file_organized
Manual Testing with File Organizer¶
Test your plugin with actual file operations:
# Enable debug logging
export FILE_ORGANIZER_LOG_LEVEL=DEBUG
# Run File Organizer with test files
file-organizer organize ~/test-files/ --dry-run
# Check plugin output in logs
tail -f ~/.file-organizer/logs/plugins.log
Interactive Testing¶
Use the File Organizer Python API to test your plugin interactively:
# test_plugin_interactive.py
from plugin import ExifImageTaggerPlugin
# Instantiate your plugin class directly from the source tree
plugin = ExifImageTaggerPlugin()
plugin.on_enable()
# Test hook manually
payload = {
"destination_path": "/tmp/test-image.jpg",
"source_path": "/tmp/uploads/photo.jpg"
}
result = plugin.on_file_organized(payload)
print(f"Result: {result}")
Run the test:
Hot-Reloading During Development¶
Enable hot-reloading to see code changes without restarting:
Step 1: Enable development mode in config/plugins.yaml:
development:
hot_reload: true
watch_paths:
- /Users/yourname/projects/my_plugin
plugins:
my_plugin:
enabled: true
Step 2: Start File Organizer in watch mode:
Code changes are now automatically detected and the plugin is reloaded.
Debugging Plugins¶
Using Python Debugger¶
Add breakpoints in your plugin code:
class MyPlugin(Plugin):
@hook("file.organized")
def on_file_organized(self, payload):
import pdb; pdb.set_trace() # Debugger breakpoint
# Your plugin logic
return {"status": "processed"}
Run File Organizer with debugging enabled:
Logging for Debugging¶
Add detailed logging to your plugin:
import logging
logger = logging.getLogger(__name__)
class MyPlugin(Plugin):
@hook("file.organized")
def on_file_organized(self, payload):
logger.debug(f"Processing file: {payload}")
logger.info(f"Destination: {payload.get('destination_path')}")
try:
result = self.process(payload)
logger.info(f"Successfully processed: {result}")
return result
except Exception as e:
logger.error(f"Error processing file: {e}", exc_info=True)
raise
View logs in real-time:
Common Installation Issues¶
Issue: Plugin Not Found¶
Error: Plugin 'my_plugin' not found in registered plugins
Solution: 1. Verify plugin.json exists and has correct name field 2. Check that plugin path is in config/plugins.yaml 3. Ensure entry point file exists and is named correctly 4. Restart File Organizer to trigger re-discovery
Issue: Import Errors¶
Error: ModuleNotFoundError: No module named 'my_dependency'
Solution: 1. Install dependencies: pip install -r requirements.txt 2. Add dependencies to plugin.json:
- Reinstall plugin:
pip install -e .
Issue: Hook Not Triggering¶
Error: Plugin loads but hooks don't execute
Solution: 1. Verify hook name is correct: @hook("file.organized") 2. Check that on_enable() is called (plugin must be enabled) 3. Ensure hook priority doesn't conflict with other plugins 4. Add logging to confirm hook registration:
Uninstalling Plugins¶
Remove a locally installed plugin:
# Uninstall with pip
pip uninstall my_plugin
# Remove from plugin paths
# Edit config/plugins.yaml and remove plugin entry
# Clear plugin cache
file-organizer plugins clear-cache
# Restart to apply changes
file-organizer restart
Best Practices for Local Development¶
- Use Editable Installs: Always use
pip install -e .during development - Version Control: Keep
plugin.jsonand code in git, exclude__pycache__and.pycfiles - Isolated Testing: Use
PluginTestCasewith temporary directories for tests - Logging Over Print: Use proper logging instead of print statements
- Graceful Errors: Handle all exceptions and return meaningful error messages
- Document Config: Provide clear documentation for all config options
- Test Edge Cases: Test with missing files, invalid data, and permission errors
Example Development Workflow¶
Here's a typical workflow for developing and testing a plugin locally:
# 1. Create plugin directory
mkdir -p ~/projects/my_plugin
cd ~/projects/my_plugin
# 2. Create plugin structure
cat > plugin.json <<EOF
{
"name": "my_plugin",
"version": "0.1.0",
"author": "Your Name",
"description": "Development plugin",
"entry_point": "plugin.py"
}
EOF
# 3. Write plugin code
cat > plugin.py <<EOF
from file_organizer.plugins import Plugin
from file_organizer.plugins.sdk import hook
class MyPlugin(Plugin):
def on_enable(self):
print(f"Plugin {self.name} enabled")
@hook("file.organized")
def on_file_organized(self, payload):
return {"processed": True}
EOF
# 4. Register using Method 3 (Manual Registration)
# This minimal example only creates plugin.json and plugin.py, so it does not
# include the pyproject.toml/setup.py packaging metadata required by pip install -e .
# Add ~/projects/my_plugin to config/plugins.yaml under plugin_paths and enable my_plugin.
# 5. Test the plugin
pytest tests/ -v
# 6. Run with File Organizer
file-organizer organize ~/test-files/ --dry-run
# 7. Check logs
tail -f ~/.file-organizer/logs/plugins.log
# 8. Make changes and retest (no reinstall needed with -e flag)
# Edit plugin.py...
file-organizer organize ~/test-files/ --dry-run
Plugin Hooks¶
Available Hooks¶
| Hook | Triggered | Parameters |
|---|---|---|
on_file_uploaded | File uploaded | file: UploadedFile |
on_organize_start | Organization begins | job_id: str |
on_organize_complete | Organization finishes | result: OrganizeResult |
on_duplicate_detected | Duplicates found | duplicates: List[File] |
on_file_processed | File processed | file: File, metadata: Dict |
on_error | Error occurs | error: Exception, context: Dict |
Hook Implementation¶
from file_organizer.plugins import register_hook
@register_hook("on_organize_complete")
async def handle_completion(result):
# Send notification
send_notification(f"Organized {result.file_count} files")
@register_hook("on_duplicate_detected")
async def handle_duplicates(duplicates):
# Log duplicates
for dup in duplicates:
logger.info(f"Duplicate: {dup.path}")
Custom Methodologies¶
Create custom file organization methodologies:
from file_organizer.methodologies import BaseMethodology
class CustomMethodology(BaseMethodology):
"""Custom organization methodology"""
name = "custom"
description = "My custom methodology"
def organize(self, file, metadata):
"""Return suggested folder and filename"""
folder = self.determine_folder(metadata)
filename = self.generate_filename(file, metadata)
return {
"folder": folder,
"filename": filename,
"confidence": 0.95
}
def determine_folder(self, metadata):
# Custom logic to determine folder
pass
def generate_filename(self, file, metadata):
# Custom logic to generate filename
pass
Configuration¶
Plugin Configuration File¶
Create config/plugins.yaml:
plugins:
my-plugin:
enabled: true
module: my_plugin
class: MyPlugin
config:
option1: value1
option2: value2
another-plugin:
enabled: false
module: another_plugin
class: AnotherPlugin
Plugin Settings¶
class MyPlugin(Plugin):
def __init__(self, config=None):
super().__init__()
self.config = config or {}
self.timeout = self.config.get("timeout", 30)
self.enabled = self.config.get("enabled", True)
Plugin Structure¶
Directory Layout¶
my_plugin/
├── __init__.py
├── plugin.py
├── config.yaml
├── templates/
│ └── settings.html
├── static/
│ ├── css/
│ └── js/
└── tests/
└── test_plugin.py
Plugin Metadata¶
from file_organizer.plugins import Plugin
class MyPlugin(Plugin):
name = "my-plugin"
version = "1.0.0"
author = "Your Name"
description = "Plugin description"
dependencies = ["requests>=2.28.0"]
def get_metadata(self):
return {
"name": self.name,
"version": self.version,
"author": self.author
}
API Access¶
Access Core Services¶
from file_organizer.core import FileOrganizer
class MyPlugin:
def __init__(self):
self.core = FileOrganizer()
async def process_file(self, file_path):
result = await self.core.organize_file(file_path)
return result
Database Access¶
from file_organizer.models import File, FileMetadata
async def list_recent_files(self, limit=10):
files = self.db.query(File)\
.order_by(File.created_at.desc())\
.limit(limit)\
.all()
return files
Testing¶
Unit Tests¶
import pytest
from my_plugin import MyPlugin
@pytest.fixture
def plugin():
return MyPlugin()
def test_plugin_initialization(plugin):
assert plugin.name == "my-plugin"
@pytest.mark.asyncio
async def test_on_upload(plugin):
class MockFile:
name = "test.txt"
path = "/tmp/test.txt"
await plugin.on_upload(MockFile())
Using PluginTestCase¶
The SDK provides PluginTestCase for testing plugins with isolated filesystem helpers:
"""Tests for EXIF Image Tagger Plugin."""
from __future__ import annotations
from pathlib import Path
from file_organizer.plugins.sdk.testing import PluginTestCase
# ExifImageTaggerPlugin is defined in plugin.py.
# When running tests inside the plugin directory, import it directly:
from plugin import ExifImageTaggerPlugin
# When testing an installed package, use:
# from exif_image_tagger.plugin import ExifImageTaggerPlugin
class TestExifImageTaggerPlugin(PluginTestCase):
"""Test suite for ExifImageTaggerPlugin using SDK test utilities."""
def setUp(self) -> None:
"""Set up test fixtures with isolated filesystem."""
super().setUp()
self.plugin = ExifImageTaggerPlugin()
self.plugin.on_enable()
def test_handles_non_image_files(self) -> None:
"""Test that plugin skips non-image files."""
# Create test text file using SDK helper
test_file = self.create_test_file("document.txt", "Hello, world!")
self.assert_file_exists(test_file)
payload = {"destination_path": str(test_file)}
result = self.plugin.on_file_organized(payload)
self.assertFalse(result["tagged"])
self.assertEqual(result["reason"], "not an image file")
def test_handles_missing_destination(self) -> None:
"""Test that plugin handles missing destination_path gracefully."""
payload = {}
result = self.plugin.on_file_organized(payload)
self.assertFalse(result["tagged"])
self.assertEqual(result["reason"], "missing destination_path")
def test_handles_nonexistent_file(self) -> None:
"""Test that plugin handles nonexistent files."""
nonexistent = self.test_dir / "missing.jpg"
self.assert_file_not_exists(nonexistent)
payload = {"destination_path": str(nonexistent)}
result = self.plugin.on_file_organized(payload)
self.assertFalse(result["tagged"])
self.assertEqual(result["reason"], "destination file missing")
def test_processes_image_without_exif(self) -> None:
"""Test that plugin handles images without EXIF data."""
# Create minimal PNG file without EXIF data
image_file = self.create_test_file("test_images/photo.png", "")
# Write minimal valid PNG header
with open(image_file, "wb") as f:
# PNG signature
f.write(b"\x89PNG\r\n\x1a\n")
# Minimal IHDR chunk for 1x1 image
f.write(b"\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01")
f.write(b"\x08\x02\x00\x00\x00\x90wS\xde")
# IEND chunk
f.write(b"\x00\x00\x00\x00IEND\xaeB`\x82")
self.assert_file_exists(image_file)
payload = {"destination_path": str(image_file)}
result = self.plugin.on_file_organized(payload)
# Should not tag images without EXIF data
self.assertFalse(result["tagged"])
self.assertEqual(result["reason"], "no EXIF data found")
Key Features:
- Isolated Testing: Each test gets a fresh temporary directory via
self.test_dir - File Fixtures: Use
create_test_file()to create test files with proper paths - Path Assertions: Use
assert_file_exists()andassert_file_not_exists()for verification - Automatic Cleanup: Temporary directories are cleaned up after each test
- Real Filesystem: Tests run against actual files, not mocks
Integration Tests¶
@pytest.mark.asyncio
async def test_plugin_integration(app_client):
# List files (path= is optional; omit to list home directory)
response = await app_client.get(
"/api/v1/files",
params={"path": "/test-dir"}
)
# Check plugin was called
assert response.status_code == 200
Distribution¶
Package Plugin¶
Install Plugin¶
Best Practices¶
Performance¶
- Use async/await for I/O operations
- Cache expensive computations
- Avoid blocking operations
- Set reasonable timeouts
Error Handling¶
try:
result = await self.process_file(file)
except Exception as e:
logger.error(f"Plugin error: {e}")
raise
Logging¶
import logging
logger = logging.getLogger(__name__)
logger.info("Plugin initialized")
logger.debug("Processing file: %s", filename)
logger.error("Error processing file: %s", error)