Skip to content

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.

Contributing guidelines

Development Setup ​

Prerequisites ​

  • Python 3.11+
  • uv for dependency management
  • pre-commit for Git hooks

Initial Setup ​

sh
# Clone the repository
git clone https://github.com/fdev31/pyprland.git
cd pyprland

# Install dev and lint dependencies
uv sync --all-groups

# Install pre-commit hooks
uv run pre-commit install
uv run pre-commit install --hook-type pre-push

Quick Start ​

Debugging ​

To get detailed logs when an error occurs, use:

sh
pypr --debug

This displays logs in the console. To also save logs to a file:

sh
pypr --debug $HOME/pypr.log

Quick 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:

toml
[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:

toml
plugins = ["external:myplugin"]

Prefer namespaced packages like johns_pyprland.super_feature instead.

Plugin Attributes ​

Your Extension class has access to these attributes:

AttributeTypeDescription
self.namestrPlugin identifier
self.configConfigurationPlugin's TOML config section
self.stateSharedStateShared application state (active workspace, monitor, etc.)
self.backendEnvironmentBackendWM interaction: commands, queries, notifications
self.logLoggerPlugin-specific logger

Creating Your First Plugin ​

python
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."""
        pass

Adding Commands ​

Add run_<commandname> methods to handle pypr <commandname> calls.

The first line of the docstring appears in pypr help:

python
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.zoomed

Reacting to Events ​

Add event_<eventname> methods to react to Hyprland events:

python
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:

python
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 exist

Using Menus ​

For plugins that need menu interaction (rofi, wofi, tofi, etc.), use MenuMixin:

python
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:

python
# 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
    pass

See Architecture - Shared State for all available fields.

Mixins ​

Use mixins for common functionality:

python
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:

sh
pypr exit ; pypr --debug

API Documentation ​

Generate and browse the full API documentation:

sh
tox run -e doc
# Then visit http://localhost:8080

Testing & Quality Assurance ​

Running All Checks ​

Before submitting a PR, run the full test suite:

sh
tox

This runs unit tests across Python versions and linting checks.

Tox Environments ​

EnvironmentCommandDescription
py314-unittox run -e py314-unitUnit tests (Python 3.14)
py311-unittox run -e py311-unitUnit tests (Python 3.11)
py312-unittox run -e py312-unitUnit tests (Python 3.12)
py314-lintingtox run -e py314-lintingFull linting suite (mypy, ruff, pylint, flake8)
py314-wikitox run -e py314-wikiCheck plugin documentation coverage
doctox run -e docGenerate API docs with pdoc
coveragetox run -e coverageRun tests with coverage report
deadcodetox run -e deadcodeDetect dead code with vulture

Quick Test Commands ​

sh
# 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 coverage

Pre-commit Hooks ​

Pre-commit hooks ensure code quality before commits and pushes.

Installation ​

sh
pip install pre-commit
pre-commit install
pre-commit install --hook-type pre-push

What Runs Automatically ​

On every commit:

HookPurpose
versionMgmtAuto-increment version number
wikiDocGenRegenerate plugin documentation JSON
wikiDocCheckVerify documentation coverage
ruff-checkLint Python code
ruff-formatFormat Python code
flake8Additional Python linting
check-yamlValidate YAML files
check-jsonValidate JSON files
pretty-format-jsonAuto-format JSON files
beautyshFormat shell scripts
yamllintLint YAML files

On push:

HookPurpose
runtestsRun full pytest suite

Manual Execution ​

Run all hooks manually:

sh
pre-commit run --all-files

Run a specific hook:

sh
pre-commit run ruff-check --all-files

Packaging & Distribution ​

Creating an External Plugin Package ​

See the sample extension for a complete example with:

  • Proper package structure
  • pyproject.toml configuration
  • Example plugin code: focus_counter.py

Development Installation ​

Install your package in editable mode for testing:

sh
cd your-plugin-package/
pip install -e .

Publishing ​

When ready to distribute:

sh
uv publish

Don't forget to update the details in your pyproject.toml file first.

Example Usage ​

Add your plugin to the config:

toml
[pyprland]
plugins = ["pypr_examples.focus_counter"]

["pypr_examples.focus_counter"]
multiplier = 2

IMPORTANT

Contact the maintainer to get your extension listed on the home page.

Further Reading ​