Skip to content

Protocol Scheme

The protocol scheme is similar with the subclass one, except for one point: instead of using and inheriting the base class, we define the Protocol.

Python typing protocols are the class signatures. Instead of defining the base class and inherit from it, we define the functions and their signatures.

The major advantage of the protocol scheme is the fact we need no to import something from the core in our plugins.

Plugin Protocol

First, lets define the plugin's protocol

base.py
from typing import Protocol, runtime_checkable


@runtime_checkable
class PluginProtocol(Protocol):
    """Protocol for our plugin."""

    def __init__(self) -> None:
        ...

    def execute(self, x: int, y: int) -> int:
        """Plugin performs operation on two integers and returns integer."""
        ...

More complicated, then subclass, but still simple.

base.py
from typing import Protocol, runtime_checkable


@runtime_checkable
class PluginProtocol(Protocol):
    """Protocol for our plugin."""

    def __init__(self) -> None:
        ...

    def execute(self, x: int, y: int) -> int:
        """Plugin performs operation on two integers and returns integer."""
        ...

We need Protocol and runtime_checkable from the Python typing module.

base.py
from typing import Protocol, runtime_checkable


@runtime_checkable
class PluginProtocol(Protocol):
    """Protocol for our plugin."""

    def __init__(self) -> None:
        ...

    def execute(self, x: int, y: int) -> int:
        """Plugin performs operation on two integers and returns integer."""
        ...
@runtime_checkable decorator is necessary for Loader to be able to check plugins signatures. Do not forget it.

base.py
from typing import Protocol, runtime_checkable


@runtime_checkable
class PluginProtocol(Protocol):
    """Protocol for our plugin."""

    def __init__(self) -> None:
        ...

    def execute(self, x: int, y: int) -> int:
        """Plugin performs operation on two integers and returns integer."""
        ...
Protocols are derived from Protocol generic class.

base.py
from typing import Protocol, runtime_checkable


@runtime_checkable
class PluginProtocol(Protocol):
    """Protocol for our plugin."""

    def __init__(self) -> None:
        ...

    def execute(self, x: int, y: int) -> int:
        """Plugin performs operation on two integers and returns integer."""
        ...

Though docstrings are advisory it will help to navigate our code, so describe plugin tasks and features.

base.py
from typing import Protocol, runtime_checkable


@runtime_checkable
class PluginProtocol(Protocol):
    """Protocol for our plugin."""

    def __init__(self) -> None:
        ...

    def execute(self, x: int, y: int) -> int:
        """Plugin performs operation on two integers and returns integer."""
        ...

Our plugins has no initialization parameters, so we declare plain __init__ constructor.

base.py
from typing import Protocol, runtime_checkable


@runtime_checkable
class PluginProtocol(Protocol):
    """Protocol for our plugin."""

    def __init__(self) -> None:
        ...

    def execute(self, x: int, y: int) -> int:
        """Plugin performs operation on two integers and returns integer."""
        ...

All protocol functions are abstract, so we add ... operator to skip the implementation.

base.py
from typing import Protocol, runtime_checkable


@runtime_checkable
class PluginProtocol(Protocol):
    """Protocol for our plugin."""

    def __init__(self) -> None:
        ...

    def execute(self, x: int, y: int) -> int:
        """Plugin performs operation on two integers and returns integer."""
        ...

Our main function of the plugin, do not to forget to place the proper type hints to allow static type checking.

base.py
from typing import Protocol, runtime_checkable


@runtime_checkable
class PluginProtocol(Protocol):
    """Protocol for our plugin."""

    def __init__(self) -> None:
        ...

    def execute(self, x: int, y: int) -> int:
        """Plugin performs operation on two integers and returns integer."""
        ...

The main function of the plugin already should be documented.

base.py
from typing import Protocol, runtime_checkable


@runtime_checkable
class PluginProtocol(Protocol):
    """Protocol for our plugin."""

    def __init__(self) -> None:
        ...

    def execute(self, x: int, y: int) -> int:
        """Plugin performs operation on two integers and returns integer."""
        ...

Ellipses operator (...) shows the implementation is abstract. If missed, mypy will complain the function doesn't return an integer value.

Application Core

Let's define the application's core.

__main__.py
import sys

from gufo.loader import Loader

from .base import PluginProtocol

loader = Loader[PluginProtocol](base="myapp.plugins")


def main(op: str, x: int, y: int) -> None:
    kls = loader[op]
    item = kls()
    r = item.execute(x, y)
    print(r)


main(sys.argv[1], int(sys.argv[2]), int(sys.argv[3]))

Almost similar to the subclass core, except for the Loader type.

__main__.py
import sys

from gufo.loader import Loader

from .base import PluginProtocol

loader = Loader[PluginProtocol](base="myapp.plugins")


def main(op: str, x: int, y: int) -> None:
    kls = loader[op]
    item = kls()
    r = item.execute(x, y)
    print(r)


main(sys.argv[1], int(sys.argv[2]), int(sys.argv[3]))

Import sys module to parse the CLI argument.

Warning

We use sys.argv only for demonstration purposes. Use argsparse or alternatives in real-world applications.

__main__.py
import sys

from gufo.loader import Loader

from .base import PluginProtocol

loader = Loader[PluginProtocol](base="myapp.plugins")


def main(op: str, x: int, y: int) -> None:
    kls = loader[op]
    item = kls()
    r = item.execute(x, y)
    print(r)


main(sys.argv[1], int(sys.argv[2]), int(sys.argv[3]))

Import Loader class.

__main__.py
import sys

from gufo.loader import Loader

from .base import PluginProtocol

loader = Loader[PluginProtocol](base="myapp.plugins")


def main(op: str, x: int, y: int) -> None:
    kls = loader[op]
    item = kls()
    r = item.execute(x, y)
    print(r)


main(sys.argv[1], int(sys.argv[2]), int(sys.argv[3]))

Import PluginBase class to define Loader type.

__main__.py
import sys

from gufo.loader import Loader

from .base import PluginProtocol

loader = Loader[PluginProtocol](base="myapp.plugins")


def main(op: str, x: int, y: int) -> None:
    kls = loader[op]
    item = kls()
    r = item.execute(x, y)
    print(r)


main(sys.argv[1], int(sys.argv[2]), int(sys.argv[3]))

Then let's create a loader instance. You need only one loader instance per each type for your application. So loaders are usually singletons.

Loader is the generic type, so we must pass the exact plugin type. In the protocol scheme plugins are classes, following from the PluginProtocol protocol. In Python's typing terms, the protocol type is the PluginProtocol. We'd placed the type into the brackets just after the Loader.

After defining the plugin's type, we need to initialize the loader itself. Loader has several initialization parameters, see Reference for details. Here we consider our plugins will be in plugins folder of our applications.

__main__.py
import sys

from gufo.loader import Loader

from .base import PluginProtocol

loader = Loader[PluginProtocol](base="myapp.plugins")


def main(op: str, x: int, y: int) -> None:
    kls = loader[op]
    item = kls()
    r = item.execute(x, y)
    print(r)


main(sys.argv[1], int(sys.argv[2]), int(sys.argv[3]))

Our main function accepts the operation's name and two integer arguments. Then it prints the result.

__main__.py
import sys

from gufo.loader import Loader

from .base import PluginProtocol

loader = Loader[PluginProtocol](base="myapp.plugins")


def main(op: str, x: int, y: int) -> None:
    kls = loader[op]
    item = kls()
    r = item.execute(x, y)
    print(r)


main(sys.argv[1], int(sys.argv[2]), int(sys.argv[3]))

Loader supports dict-like interface to access the modules. For this example, we will use bracket notation. We use op parameter as the plugin name.

__main__.py
import sys

from gufo.loader import Loader

from .base import PluginProtocol

loader = Loader[PluginProtocol](base="myapp.plugins")


def main(op: str, x: int, y: int) -> None:
    kls = loader[op]
    item = kls()
    r = item.execute(x, y)
    print(r)


main(sys.argv[1], int(sys.argv[2]), int(sys.argv[3]))

Loader returns the class. We create the instance to show we can use some plugin initialization tasks. We can also define the execute method as a @classmethod to skip the initialization step.

__main__.py
import sys

from gufo.loader import Loader

from .base import PluginProtocol

loader = Loader[PluginProtocol](base="myapp.plugins")


def main(op: str, x: int, y: int) -> None:
    kls = loader[op]
    item = kls()
    r = item.execute(x, y)
    print(r)


main(sys.argv[1], int(sys.argv[2]), int(sys.argv[3]))

Then we call execute method of the plugin. Your editor must show the r variable has the type of int

__main__.py
import sys

from gufo.loader import Loader

from .base import PluginProtocol

loader = Loader[PluginProtocol](base="myapp.plugins")


def main(op: str, x: int, y: int) -> None:
    kls = loader[op]
    item = kls()
    r = item.execute(x, y)
    print(r)


main(sys.argv[1], int(sys.argv[2]), int(sys.argv[3]))

Then we print the result, and our core function is finally complete.

__main__.py
import sys

from gufo.loader import Loader

from .base import PluginProtocol

loader = Loader[PluginProtocol](base="myapp.plugins")


def main(op: str, x: int, y: int) -> None:
    kls = loader[op]
    item = kls()
    r = item.execute(x, y)
    print(r)


main(sys.argv[1], int(sys.argv[2]), int(sys.argv[3]))
We're extracting our arguments directly from sys.argv. Then we call our core function. The core is complete.

Plugins

Next we need to implement plugins itself. First, create directory plugins for our plugins packages. Then add empty __init__.py file.

We're ready to write our plugins.

add Plugin

Lets implement the plugin for adding numbers. Our plugin has the name add, so we're placing it into add.py file.

plugins/add.py
1
2
3
class AddPlugin(object):
    def execute(self, x: int, y: int) -> int:
        return x + y

The code is pretty and clean and ever simple than subclass scheme.

plugins/add.py
1
2
3
class AddPlugin(object):
    def execute(self, x: int, y: int) -> int:
        return x + y
Create the new plugin class. Class must follow PluginProtocol signature but we need no the point it explicitly. So we may derive our plugin from any class. Let's start from object.

plugins/add.py
1
2
3
class AddPlugin(object):
    def execute(self, x: int, y: int) -> int:
        return x + y
Then override the execute function.

plugins/add.py
1
2
3
class AddPlugin(object):
    def execute(self, x: int, y: int) -> int:
        return x + y
All we need is to add two numbers and return the result. Our plugin is complete.

sub Plugin

Let's create another plugin for subtraction. Our plugin has the name sub, so we're placing it into sub.py file.

plugins/sub.py
1
2
3
class SubPlugin(object):
    def execute(self, x: int, y: int) -> int:
        return x - y

Pretty like the add plugin, only the class name and implementation differ.

plugins/sub.py
1
2
3
class SubPlugin(object):
    def execute(self, x: int, y: int) -> int:
        return x - y
All we need is to subtract two numbers and return the result. Our plugin is complete.

Testing

$ python3 -m myapp add 1 2
3
$ python3 -m myapp sub 2 1
1

Summary

We have learned how to create simple and extendable applications using protocol-based approach.