Skip to content

User Guide

Grappler's purpose is to provide a simple interface that you can use to load "plugins" into your Python application. In its default configuration, it allows you to load objects from third-party code that has been installed into the same environment as your application.

This page explores how to use grappler to load plugins from third-party code. Furthermore, it will examine how to use customise grappler's loading behavior. It assumes knowledge of how to program in Python, including using type annotations, which are required for some mid-advanced features of grappler.

Hooks and Topics

When you need to load a third-party object (or plugin) into your application, it is usually in order to fit a specific purpose. As such, you would expect that each plugin you load to match a particular behaviour that you can use to invoke it for this predetermined purpose. Furthermore, you would expect plugins to advertise which specific behaviours they are capable of fitting, so that your application can load and use them correctly.

Hooks are grappler's abstraction of a particular behaviour that you wish to load plugins for. When you need to load plugins for a particular behaviour, you define a hook and use it to load plugins, which you can interact with to perform the functions you need. The only requirement on the application end, is that each hook must be given a static string identifier, which is called the hook's topic.

Topics are simply put, a way for plugins to advertise that they are capable of satisfying a hook's behaviour. The exact details of how this is advertised, relies on which exact strategies grappler is using to load plugins. By default, topics map directly to entry point names (from setuptools).

Loading Plugins with Hooks

This can be done be instantiating a hook and iterating over it like a Python iterable:

from grappler import Hook


plugins = Hook("some.topic")

for plugin in plugins:
    do_something_with(plugin)

When you instantiate a hook, you must give it a topic to use. With the default loading behaviour, topics map directly to entry point names. For this reason, it is a good idea to give the topics a unique sort of prefix, so that they do not conflict with other entry point names that may have been installed into the environment. The pattern pkg.module.purpose can be a good one to use, since your package name is already unique in the installed environment.

Specifying Expected Behavior

In the example above, the plugins loaded could be any arbitrary object. Provided that your every plugin that advertises to your topic is one that implements the behavior, then this duck-typing is not such an issue. However, you may wish to have have stronger typing for mypy, or, you may wish to load variants of the same plugins from the same hook. This can be accomplished by providing the hook with a type annotation.

from dataclasses import dataclass
from typing import Any, Protocol, runtime_checkable
from grappler import Hook


topic = "grappler-docs.example.shape"


# this hook will iterate any object on this topic
hook = Hook[Any](topic)


# this hook will iterate any object that is an instance of the Shape class or its
# subclasses from the topic, and ignore all others
@dataclass
class Shape:
    area: float

hook = Hook[Shape](topic)


# this hook will iterate only objects that implements the polygon protocol from
# the topic (runtime_checkable decoration required).
@runtime_checkable
class Polygon(Protocol):
    area: float
    sides: int

hook = Hook[Polygon](topic)


# this hook will iterate circles or squares, but nothing else
@dataclass
class Circle:
    radius: float

    @property
    def area(self) -> float: ...

@dataclass
class Square:
    size: float

    @property
    def area(self) -> float: ...

hook = Hook[Circle | Square](topic)

Of course, these examples are contrived for extreme simplicity, but you can define behaviors which are as complex as necessary, so long as they can be described by a protocol or an abstract class etc.

Grapplers

While hooks expose an interface to iterate plugins for a particular purpose, they do not expose much about how the plugins are loaded. Loading id done using the Grappler protocol. It describes an object that is able to find plugins and their metadata, as well as load them.

Each Grappler can either act as a source for plugins, or customise the finding or loading behavior of another grappler. For example, the EntryPointGrappler finds and loads plugins that are installed for entry points in the Python environment, while BouncerGrappler wraps another grappler and blocks the finding or loading certain plugins depending on its configuration.

By composing Grapplers, an application can describe a custom configuration of how to load plugins. Furthermore, new sources of plugins can be specified by implementing the protocol.

Customising Loading with Grapplers

The basic hook interface shown in this guide so far uses the default configuration. In this default config, entry points which have been installed into the Python environment are found as plugins. There is no caching or filtering applied.

Applications may however wish to customise this default behavior. The grappler.grapplers module provides a few Grapplers which can be used to do this. The CompositeGrappler is the starting point from which to do this. It can be used to compose the plugins and behaviors from several grapplers into a single grappler, which can then be passed to hook in order to load plugins from:

from grappler import Hook, Plugin
from grappler.grapplers import (
    BouncerGrappler,
    CompositeGrappler,
    EntryPointGrappler,
)

def block_grappler_plugins(plugin: Plugin) -> bool:
    return not plugin.package.name.startswith("grappler")

composite_grappler = (
    CompositeGrappler()
        .source(EntryPointGrappler())
        .source(YourCustomSourceGrappler())
        .wrap(BouncerGrappler())
        .configure(BouncerGrappler.checker, block_grappler_plugins)
)

plugins = Hook("some.topic", grappler=composite_grappler)

for plugin in plugins:
    do_something_with(plugin)

The remaining sections will explore the various Grapplers included with the library and what you can do with them.

Using BouncerGrappler to filter plugins

The BouncerGrappler provides a generic interface that wraps another Grappler in order to filter the plugins that it finds or loads. The filtering is done using checker functions, as many of which may be supplied using BouncerGrappler.checker. Checker functions should simply return truthy if the the plugin should be allowed to load.

For example, the following bouncer will block all plugins according to a blacklist:

from grappler import Package, Plugin
from grappler.grapplers import BouncerGrappler


bouncer = BouncerGrappler()
blacklisted_packages: list[Package] = [...]

@bouncer.checker
def block_plugins_in_blacklist(plugin: Plugin) -> bool:
    return plugin not in blacklisted_packages

Checker functions may only need to block a plugin during either finding, or loading, but not both. To do this, use the decorator with mode argument.

For example, the following checker can be used to deduplicate finding plugins (when composing in such a way that creates duplicates):

@bouncer.checker(mode=BouncerGrappler.Mode.Find)
def strip_duplicate_plugins(plugin: Plugin) -> bool:
    seen_plugins = context.setdefault("seen", set[Plugin]())

    if plugin in seen_plugins:
        return False

    else:
        seen_plugins.add(Plugin)
        return True

Blacklisting Plugins

The BlacklistingGrappler wraps BouncerGrapplerto perform more robust blacklisting than the earlier example.

Plugins can be blacklisted individually, or entire packages may be blacklisted.

from typing import Iterable

from grappler import Package, Plugin
from grappler.grapplers import BlacklistingGrappler

# blacklist may either be provided statically
blacklister = BlacklistingGrappler(blacklist=[*packages_or_plugins])

# ... or dynamically
blacklister = BlacklistingGrapper()
@blacklister.blacklist
def get_blacklisted_items() -> Iterable[Plugin | Package]:
    ...