Development
It's easy to write your own plugin by making a Python package and then indicating its name as the plugin name.
TIP
For details on internal architecture, data flows, and design patterns, see the Architecture document.
Development Setup
Prerequisites
- Python 3.11+
- Poetry for dependency management
- pre-commit for Git hooks
Initial Setup
# Clone the repository
git clone https://github.com/fdev31/pyprland.git
cd pyprland
# Install dependencies
poetry install
# Install dev and lint dependencies
poetry install --with dev,lint
# Install pre-commit hooks
pip install pre-commit
pre-commit install
pre-commit install --hook-type pre-pushQuick Start
Debugging
To get detailed logs when an error occurs, use:
pypr --debugThis displays logs in the console. To also save logs to a file:
pypr --debug $HOME/pypr.logQuick Experimentation
NOTE
To quickly get started, you can directly edit the built-in experimental plugin. To distribute your plugin, create your own Python package or submit a pull request.
Custom Plugin Paths
TIP
Set plugins_paths = ["/custom/path"] in the [pyprland] section of your config to add extra plugin search paths during development.
Writing Plugins
Plugin Loading
Plugins are loaded by their full Python module path:
[pyprland]
plugins = ["mypackage.myplugin"]The module must provide an Extension class inheriting from Plugin.
NOTE
If your extension is at the root level (not recommended), you can import it using the external: prefix:
plugins = ["external:myplugin"]Prefer namespaced packages like johns_pyprland.super_feature instead.
Plugin Attributes
Your Extension class has access to these attributes:
| Attribute | Type | Description |
|---|---|---|
self.name | str | Plugin identifier |
self.config | Configuration | Plugin's TOML config section |
self.state | SharedState | Shared application state (active workspace, monitor, etc.) |
self.backend | EnvironmentBackend | WM interaction: commands, queries, notifications |
self.log | Logger | Plugin-specific logger |
Creating Your First Plugin
from pyprland.plugins.interface import Plugin
class Extension(Plugin):
"""My custom plugin."""
async def init(self) -> None:
"""Called once at startup."""
self.log.info("My plugin initialized")
async def on_reload(self) -> None:
"""Called on init and config reload."""
self.log.info(f"Config: {self.config}")
async def exit(self) -> None:
"""Cleanup on shutdown."""
passAdding Commands
Add run_<commandname> methods to handle pypr <commandname> calls.
The first line of the docstring appears in pypr help:
class Extension(Plugin):
zoomed = False
async def run_togglezoom(self, args: str) -> str | None:
"""Toggle zoom level.
This second line won't appear in CLI help.
"""
if self.zoomed:
await self.backend.execute("keyword misc:cursor_zoom_factor 1")
else:
await self.backend.execute("keyword misc:cursor_zoom_factor 2")
self.zoomed = not self.zoomedReacting to Events
Add event_<eventname> methods to react to Hyprland events:
async def event_openwindow(self, params: str) -> None:
"""React to window open events."""
addr, workspace, cls, title = params.split(",", 3)
self.log.debug(f"Window opened: {title}")
async def event_workspace(self, workspace: str) -> None:
"""React to workspace changes."""
self.log.info(f"Switched to workspace: {workspace}")NOTE
Code Safety: Pypr ensures only one handler runs at a time per plugin, so you don't need concurrency handling. Each plugin runs independently in parallel. See Architecture - Manager for details.
Configuration Schema
Define expected config fields for automatic validation using ConfigField:
from pyprland.plugins.interface import Plugin
from pyprland.validation import ConfigField
class Extension(Plugin):
config_schema = [
ConfigField("enabled", bool, required=False, default=True),
ConfigField("timeout", int, required=False, default=5000),
ConfigField("command", str, required=True),
]
async def on_reload(self) -> None:
# Config is validated before on_reload is called
cmd = self.config["command"] # Guaranteed to existUsing Menus
For plugins that need menu interaction (rofi, wofi, tofi, etc.), use MenuMixin:
from pyprland.adapters.menus import MenuMixin
from pyprland.plugins.interface import Plugin
class Extension(MenuMixin, Plugin):
async def run_select(self, args: str) -> None:
"""Show a selection menu."""
await self.ensure_menu_configured()
options = ["Option 1", "Option 2", "Option 3"]
selected = await self.menu(options, "Choose an option:")
if selected:
await self.backend.notify_info(f"Selected: {selected}")Reusable Code
Shared State
Access commonly needed information without fetching it:
# Current workspace, monitor, window
workspace = self.state.active_workspace
monitor = self.state.active_monitor
window_addr = self.state.active_window
# Environment detection
if self.state.environment == "niri":
# Niri-specific logic
passSee Architecture - Shared State for all available fields.
Mixins
Use mixins for common functionality:
from pyprland.common import CastBoolMixin
from pyprland.plugins.interface import Plugin
class Extension(CastBoolMixin, Plugin):
async def on_reload(self) -> None:
# Safely cast config values to bool
enabled = self.cast_bool(self.config.get("enabled", True))Development Workflow
Restart the daemon after making changes:
pypr exit ; pypr --debugAPI Documentation
Generate and browse the full API documentation:
tox run -e doc
# Then visit http://localhost:8080Testing & Quality Assurance
Running All Checks
Before submitting a PR, run the full test suite:
toxThis runs unit tests across Python versions and linting checks.
Tox Environments
| Environment | Command | Description |
|---|---|---|
py314-unit | tox run -e py314-unit | Unit tests (Python 3.14) |
py311-unit | tox run -e py311-unit | Unit tests (Python 3.11) |
py312-unit | tox run -e py312-unit | Unit tests (Python 3.12) |
py314-linting | tox run -e py314-linting | Full linting suite (mypy, ruff, pylint, flake8) |
py314-wiki | tox run -e py314-wiki | Check plugin documentation coverage |
doc | tox run -e doc | Generate API docs with pdoc |
coverage | tox run -e coverage | Run tests with coverage report |
deadcode | tox run -e deadcode | Detect dead code with vulture |
Quick Test Commands
# Run unit tests only
tox run -e py314-unit
# Run linting only
tox run -e py314-linting
# Check documentation coverage
tox run -e py314-wiki
# Run tests with coverage
tox run -e coveragePre-commit Hooks
Pre-commit hooks ensure code quality before commits and pushes.
Installation
pip install pre-commit
pre-commit install
pre-commit install --hook-type pre-pushWhat Runs Automatically
On every commit:
| Hook | Purpose |
|---|---|
versionMgmt | Auto-increment version number |
wikiDocGen | Regenerate plugin documentation JSON |
wikiDocCheck | Verify documentation coverage |
ruff-check | Lint Python code |
ruff-format | Format Python code |
flake8 | Additional Python linting |
check-yaml | Validate YAML files |
check-json | Validate JSON files |
pretty-format-json | Auto-format JSON files |
beautysh | Format shell scripts |
yamllint | Lint YAML files |
On push:
| Hook | Purpose |
|---|---|
runtests | Run full pytest suite |
Manual Execution
Run all hooks manually:
pre-commit run --all-filesRun a specific hook:
pre-commit run ruff-check --all-filesPackaging & Distribution
Creating an External Plugin Package
See the sample extension for a complete example with:
- Proper package structure
pyproject.tomlconfiguration- Example plugin code:
focus_counter.py
Development Installation
Install your package in editable mode for testing:
cd your-plugin-package/
pip install -e .Publishing
When ready to distribute:
poetry publishDon't forget to update the details in your pyproject.toml file first.
Example Usage
Add your plugin to the config:
[pyprland]
plugins = ["pypr_examples.focus_counter"]
["pypr_examples.focus_counter"]
multiplier = 2IMPORTANT
Contact the maintainer to get your extension listed on the home page.
Further Reading
- Architecture - Internal system design, data flows, and design patterns
- Plugins - List of available built-in plugins
- Sample Extension - Complete example plugin package
- Hyprland IPC - Hyprland's IPC documentation