From: Benjamin Braatz Date: Sun, 21 Mar 2021 20:28:25 +0000 (+0100) Subject: Add pdoc3-generated API documentation. X-Git-Tag: v0.3.0~42 X-Git-Url: http://git.graph-it.com/?a=commitdiff_plain;h=8d6104d2dc95e782bfb4b51f0a9762f1ad5cb4ce;p=graphit%2Fcontrolpi.git Add pdoc3-generated API documentation. --- diff --git a/controlpi/__init__.py b/controlpi/__init__.py index 227672b..fae63ec 100644 --- a/controlpi/__init__.py +++ b/controlpi/__init__.py @@ -133,7 +133,7 @@ async def test(conf: Dict[str, PluginConf], Similar functionality could be reached by using the Log and Init plugins to print messages and send some messages on the bus, but these would - clutter the test configuration and code to stop the idefinitely running + clutter the test configuration and code to stop the indefinitely running bus would have to be added to each and every test. Incorrect plugin configurations can also be tested by this: diff --git a/controlpi/baseplugin.py b/controlpi/baseplugin.py index fa03223..589e1c9 100644 --- a/controlpi/baseplugin.py +++ b/controlpi/baseplugin.py @@ -79,6 +79,8 @@ might register separate clients for all devices connected to the bus, or a network socket plugin might register separate clients for all connections to the socket (and unregister them when the connection is closed). """ +__pdoc__ = {'BasePlugin.CONF_SCHEMA': False} + from abc import ABC, abstractmethod import asyncio import jsonschema # type: ignore @@ -108,6 +110,50 @@ class BasePlugin(ABC): ... print(f"Processing '{self.conf['key']}'.") ... async def run(self): ... print("Doing something else.") + + Initialisation sets the instance variables bus to the given message bus, + name to the given name, and conf to the given configuration: + >>> class TestPlugin(BasePlugin): + ... CONF_SCHEMA = {'properties': {'key': {'type': 'string'}}, + ... 'required': ['key']} + ... def process_conf(self): + ... if 'key' in self.conf: + ... print(f"Processing '{self.conf['key']}'.") + ... async def run(self): + ... print("Doing something else.") + >>> async def test(): + ... p = TestPlugin(MessageBus(), 'Test Instance', + ... {'key': 'Something'}) + ... print(p.bus) + ... print(p.name) + ... print(p.conf) + >>> asyncio.run(test()) # doctest: +ELLIPSIS + Processing 'Something'. + + Test Instance + {'key': 'Something'} + + It also validates the configuration against the schema in CONF_SCHEMA + and raises ConfException if is not validated. + >>> async def test(): + ... p = TestPlugin(MessageBus(), 'Test Instance', + ... {'key': 42}) + >>> asyncio.run(test()) # doctest: +NORMALIZE_WHITESPACE + Traceback (most recent call last): + ... + baseplugin.ConfException: Configuration for 'Test Instance' + is not valid. + >>> async def test(): + ... p = TestPlugin(MessageBus(), 'Test Instance', + ... {'key 2': 'Something'}) + >>> asyncio.run(test()) # doctest: +NORMALIZE_WHITESPACE + Traceback (most recent call last): + ... + baseplugin.ConfException: Configuration for 'Test Instance' + is not valid. + + Finally, it calls process_conf, which is the function that should be + overridden by concrete plugins. """ @property @@ -122,52 +168,7 @@ class BasePlugin(ABC): raise NotImplementedError def __init__(self, bus: MessageBus, name: str, conf: PluginConf) -> None: - """Initialise the plugin. - - Set the instance variables bus to the given message bus, name to - the given name, and conf to the given configuration: - >>> class TestPlugin(BasePlugin): - ... CONF_SCHEMA = {'properties': {'key': {'type': 'string'}}, - ... 'required': ['key']} - ... def process_conf(self): - ... if 'key' in self.conf: - ... print(f"Processing '{self.conf['key']}'.") - ... async def run(self): - ... print("Doing something else.") - >>> async def test(): - ... p = TestPlugin(MessageBus(), 'Test Instance', - ... {'key': 'Something'}) - ... print(p.bus) - ... print(p.name) - ... print(p.conf) - >>> asyncio.run(test()) # doctest: +ELLIPSIS - Processing 'Something'. - - Test Instance - {'key': 'Something'} - - Validate the configuration against the schema in CONF_SCHEMA and - raise ConfException if is not validated. - >>> async def test(): - ... p = TestPlugin(MessageBus(), 'Test Instance', - ... {'key': 42}) - >>> asyncio.run(test()) # doctest: +NORMALIZE_WHITESPACE - Traceback (most recent call last): - ... - baseplugin.ConfException: Configuration for 'Test Instance' - is not valid. - >>> async def test(): - ... p = TestPlugin(MessageBus(), 'Test Instance', - ... {'key 2': 'Something'}) - >>> asyncio.run(test()) # doctest: +NORMALIZE_WHITESPACE - Traceback (most recent call last): - ... - baseplugin.ConfException: Configuration for 'Test Instance' - is not valid. - - Finally, call process_conf, which is the function that should be - overridden by concrete plugins. - """ + # noqa: D107 assert isinstance(bus, MessageBus) self.bus = bus assert isinstance(name, str) diff --git a/controlpi_plugins/state.py b/controlpi_plugins/state.py index a60233d..ae4ab59 100644 --- a/controlpi_plugins/state.py +++ b/controlpi_plugins/state.py @@ -8,14 +8,14 @@ All these plugins use the following conventions: - If their state changes they send a message containing "event": "changed" - and "state": . + and "state": NEW STATE. - If their state is reported due to a message, but did not change they send - a message containing just "state": . -- If they receive a message containing "target": and + a message containing just "state": CURRENT STATE. +- If they receive a message containing "target": NAME and "command": "get state" they report their current state. - If State (or any other settable state using these conventions) receives - a message containing "target": , "command": "set state" and - "new state": it changes the state accordingly. If this + a message containing "target": NAME, "command": "set state" and + "new state": STATE TO SET it changes the state accordingly. If this was really a change the corresponding event is sent. If it was already in this state a report message without "event": "changed" is sent. - StateAlias can alias any message bus client using these conventions, not diff --git a/controlpi_plugins/util.py b/controlpi_plugins/util.py index 313e50b..cf6e869 100644 --- a/controlpi_plugins/util.py +++ b/controlpi_plugins/util.py @@ -2,6 +2,7 @@ - Log logs messages on stdout. - Init sends list of messages on startup and on demand. +- Execute sends configurable list of messages on demand. - Alias translates messages to an alias. >>> import controlpi @@ -125,7 +126,7 @@ class Init(BasePlugin): The "messages" configuration key gets a list of messages to be sent on startup. The same list is sent in reaction to a message with - "target": and "command": "execute". + "target": NAME and "command": "execute". In the example, the two configured messages are sent twice, once at startup and a second time in reaction to the "execute" command sent by diff --git a/doc/controlpi/baseplugin.html b/doc/controlpi/baseplugin.html new file mode 100644 index 0000000..7f2f42f --- /dev/null +++ b/doc/controlpi/baseplugin.html @@ -0,0 +1,621 @@ + + + + + + +controlpi.baseplugin API documentation + + + + + + + + + + + +
+
+
+

Module controlpi.baseplugin

+
+
+

Define base class for all ControlPi plugins.

+

The class BasePlugin provides the abstract base class for concrete plugins +running on the ControlPi system.

+

It has three abstract methods that have to be implemented by all concrete +plugins: +- The class property CONF_SCHEMA is the JSON schema of the configuration of +the plugin. The configuration read from the global configuration file is +checked against this schema during initialisation. +- The method process_conf is called at the end of initialisation and is used +to initialise the plugin. It can be assumed that self.bus is the message +bus of the system, self.name the instance name, and self.conf the +configuration already validated against the schema. +- The run coroutines of all plugins are executed concurrently by the main +system.

+
>>> class TestPlugin(BasePlugin):
+...     CONF_SCHEMA = {'properties': {'key': {'type': 'string'}},
+...                    'required': ['key']}
+...     def process_conf(self):
+...         if 'key' in self.conf:
+...             print(f"Processing '{self.conf['key']}'.")
+...     async def run(self):
+...         print("Doing something else.")
+
+

Plugins are configured and run based on the information in the global +configuration. Here, we test this manually:

+
>>> async def test():
+...     p = TestPlugin(MessageBus(), 'Test Instance', {'key': 'Something'})
+...     await p.run()
+>>> asyncio.run(test())
+Processing 'Something'.
+Doing something else.
+
+

Each plugin gets a reference to the system message bus during +initialisation, which can be accessed as self.bus in the functions of the +plugin class. This can be used to register and unregister message bus +clients:

+
>>> class BusPlugin(BasePlugin):
+...     CONF_SCHEMA = True
+...     async def receive(self, message):
+...         print(f"{self.name} received {message}.")
+...         await self.bus.send({'sender': self.name, 'event': 'Receive'})
+...     def process_conf(self):
+...         self.bus.register(self.name, 'BusPlugin',
+...                           [{'event': {'type': 'string'}}],
+...                           [{'target': {'const': self.name}}],
+...                           self.receive)
+...     async def run(self):
+...         await self.bus.send({'sender': self.name, 'event': 'Run'})
+
+

Again, we run this manually here, but this is done by the main coroutine +when using the system in production:

+
>>> async def log(message):
+...     print(f"Log: {message}")
+>>> async def test_bus_plugin():
+...     bus = MessageBus()
+...     p = BusPlugin(bus, 'Bus Test', {})
+...     bus.register('Test', 'TestPlugin',
+...                  [{}], [{'sender': {'const': 'Bus Test'}}], log)
+...     bus_task = asyncio.create_task(bus.run())
+...     asyncio.create_task(p.run())
+...     await bus.send({'sender': 'Test', 'target': 'Bus Test', 'key': 'v'})
+...     await asyncio.sleep(0)
+...     await asyncio.sleep(0)
+...     bus_task.cancel()
+>>> asyncio.run(test_bus_plugin())
+Bus Test received {'sender': 'Test', 'target': 'Bus Test', 'key': 'v'}.
+Log: {'sender': 'Bus Test', 'event': 'Receive'}
+Log: {'sender': 'Bus Test', 'event': 'Run'}
+
+

Often, there will be a one-to-one correspondence between plugin +instances and message bus clients, a plugin instance will be a message bus +client. But there are also cases, where one plugin instance might register +and unregister a lot of message bus clients, maybe even dynamically through +its lifetime. A plugin for an input/output card might register separate +clients for each pin of the card, a plugin for some kind of hardware bus +might register separate clients for all devices connected to the bus, or a +network socket plugin might register separate clients for all connections +to the socket (and unregister them when the connection is closed).

+
+ +Expand source code + +
"""Define base class for all ControlPi plugins.
+
+The class BasePlugin provides the abstract base class for concrete plugins
+running on the ControlPi system.
+
+It has three abstract methods that have to be implemented by all concrete
+plugins:
+- The class property CONF_SCHEMA is the JSON schema of the configuration of
+  the plugin. The configuration read from the global configuration file is
+  checked against this schema during initialisation.
+- The method process_conf is called at the end of initialisation and is used
+  to initialise the plugin. It can be assumed that self.bus is the message
+  bus of the system, self.name the instance name, and self.conf the
+  configuration already validated against the schema.
+- The run coroutines of all plugins are executed concurrently by the main
+  system.
+>>> class TestPlugin(BasePlugin):
+...     CONF_SCHEMA = {'properties': {'key': {'type': 'string'}},
+...                    'required': ['key']}
+...     def process_conf(self):
+...         if 'key' in self.conf:
+...             print(f"Processing '{self.conf['key']}'.")
+...     async def run(self):
+...         print("Doing something else.")
+
+Plugins are configured and run based on the information in the global
+configuration. Here, we test this manually:
+>>> async def test():
+...     p = TestPlugin(MessageBus(), 'Test Instance', {'key': 'Something'})
+...     await p.run()
+>>> asyncio.run(test())
+Processing 'Something'.
+Doing something else.
+
+Each plugin gets a reference to the system message bus during
+initialisation, which can be accessed as self.bus in the functions of the
+plugin class. This can be used to register and unregister message bus
+clients:
+>>> class BusPlugin(BasePlugin):
+...     CONF_SCHEMA = True
+...     async def receive(self, message):
+...         print(f"{self.name} received {message}.")
+...         await self.bus.send({'sender': self.name, 'event': 'Receive'})
+...     def process_conf(self):
+...         self.bus.register(self.name, 'BusPlugin',
+...                           [{'event': {'type': 'string'}}],
+...                           [{'target': {'const': self.name}}],
+...                           self.receive)
+...     async def run(self):
+...         await self.bus.send({'sender': self.name, 'event': 'Run'})
+
+Again, we run this manually here, but this is done by the main coroutine
+when using the system in production:
+>>> async def log(message):
+...     print(f"Log: {message}")
+>>> async def test_bus_plugin():
+...     bus = MessageBus()
+...     p = BusPlugin(bus, 'Bus Test', {})
+...     bus.register('Test', 'TestPlugin',
+...                  [{}], [{'sender': {'const': 'Bus Test'}}], log)
+...     bus_task = asyncio.create_task(bus.run())
+...     asyncio.create_task(p.run())
+...     await bus.send({'sender': 'Test', 'target': 'Bus Test', 'key': 'v'})
+...     await asyncio.sleep(0)
+...     await asyncio.sleep(0)
+...     bus_task.cancel()
+>>> asyncio.run(test_bus_plugin())
+Bus Test received {'sender': 'Test', 'target': 'Bus Test', 'key': 'v'}.
+Log: {'sender': 'Bus Test', 'event': 'Receive'}
+Log: {'sender': 'Bus Test', 'event': 'Run'}
+
+Often, there will be a one-to-one correspondence between plugin
+instances and message bus clients, a plugin instance will be a message bus
+client. But there are also cases, where one plugin instance might register
+and unregister a lot of message bus clients, maybe even dynamically through
+its lifetime. A plugin for an input/output card might register separate
+clients for each pin of the card, a plugin for some kind of hardware bus
+might register separate clients for all devices connected to the bus, or a
+network socket plugin might register separate clients for all connections
+to the socket (and unregister them when the connection is closed).
+"""
+__pdoc__ = {'BasePlugin.CONF_SCHEMA': False}
+
+from abc import ABC, abstractmethod
+import asyncio
+import jsonschema  # type: ignore
+
+from controlpi.messagebus import MessageBus
+
+from typing import Union, Dict, List, Any
+JSONSchema = Union[bool, Dict[str, Union[None, str, int, float, bool,
+                                         Dict[str, Any], List[Any]]]]
+# Could be more specific.
+PluginConf = Dict[str, Any]
+# Could be more specific.
+
+
+class ConfException(Exception):
+    """Raise for errors in plugin configurations."""
+
+
+class BasePlugin(ABC):
+    """Base class for all ControlPi plugins.
+
+    >>> class TestPlugin(BasePlugin):
+    ...     CONF_SCHEMA = {'properties': {'key': {'type': 'string'}},
+    ...                    'required': ['key']}
+    ...     def process_conf(self):
+    ...         if 'key' in self.conf:
+    ...             print(f"Processing '{self.conf['key']}'.")
+    ...     async def run(self):
+    ...         print("Doing something else.")
+
+    Initialisation sets the instance variables bus to the given message bus,
+    name to the given name, and conf to the given configuration:
+    >>> class TestPlugin(BasePlugin):
+    ...     CONF_SCHEMA = {'properties': {'key': {'type': 'string'}},
+    ...                    'required': ['key']}
+    ...     def process_conf(self):
+    ...         if 'key' in self.conf:
+    ...             print(f"Processing '{self.conf['key']}'.")
+    ...     async def run(self):
+    ...         print("Doing something else.")
+    >>> async def test():
+    ...     p = TestPlugin(MessageBus(), 'Test Instance',
+    ...                    {'key': 'Something'})
+    ...     print(p.bus)
+    ...     print(p.name)
+    ...     print(p.conf)
+    >>> asyncio.run(test())  # doctest: +ELLIPSIS
+    Processing 'Something'.
+    <controlpi.messagebus.MessageBus object at 0x...>
+    Test Instance
+    {'key': 'Something'}
+
+    It also validates the configuration against the schema in CONF_SCHEMA
+    and raises ConfException if is not validated.
+    >>> async def test():
+    ...     p = TestPlugin(MessageBus(), 'Test Instance',
+    ...                    {'key': 42})
+    >>> asyncio.run(test())  # doctest: +NORMALIZE_WHITESPACE
+    Traceback (most recent call last):
+      ...
+    baseplugin.ConfException: Configuration for 'Test Instance'
+    is not valid.
+    >>> async def test():
+    ...     p = TestPlugin(MessageBus(), 'Test Instance',
+    ...                    {'key 2': 'Something'})
+    >>> asyncio.run(test())  # doctest: +NORMALIZE_WHITESPACE
+    Traceback (most recent call last):
+      ...
+    baseplugin.ConfException: Configuration for 'Test Instance'
+    is not valid.
+
+    Finally, it calls process_conf, which is the function that should be
+    overridden by concrete plugins.
+    """
+
+    @property
+    @classmethod
+    @abstractmethod
+    def CONF_SCHEMA(cls) -> JSONSchema:
+        """JSON schema for configuration of plugin.
+
+        Given configurations are validated against this schema in __init__.
+        process_conf and run can assume a valid configuration in self.conf.
+        """
+        raise NotImplementedError
+
+    def __init__(self, bus: MessageBus, name: str, conf: PluginConf) -> None:
+        assert isinstance(bus, MessageBus)
+        self.bus = bus
+        assert isinstance(name, str)
+        self.name = name
+        jsonschema.Draft7Validator.check_schema(type(self).CONF_SCHEMA)
+        validator = jsonschema.Draft7Validator(type(self).CONF_SCHEMA)
+        assert isinstance(conf, dict)
+        valid = True
+        for error in validator.iter_errors(conf):
+            print(error)
+            valid = False
+        if not valid:
+            raise ConfException(f"Configuration for '{self.name}'"
+                                " is not valid.")
+        self.conf = conf
+        self.process_conf()
+
+    @abstractmethod
+    def process_conf(self) -> None:
+        """Process the configuration.
+
+        Abstract method has to be overridden by concrete plugins.
+        process_conf is called at the end of initialisation after the bus
+        and the configuration are available as self.bus and self.conf, but
+        before any of the run coroutines are executed.
+        """
+        raise NotImplementedError
+
+    @abstractmethod
+    async def run(self) -> None:
+        """Run the plugin.
+
+        The coroutine is run concurrently with the message bus and all
+        other plugins. Initial messages and other tasks can be done here.
+        It is also okay to run a plugin-specific infinite loop concurrently
+        with the rest of the system.
+        """
+        raise NotImplementedError
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class ConfException +(*args, **kwargs) +
+
+

Raise for errors in plugin configurations.

+
+ +Expand source code + +
class ConfException(Exception):
+    """Raise for errors in plugin configurations."""
+
+

Ancestors

+
    +
  • builtins.Exception
  • +
  • builtins.BaseException
  • +
+
+
+class BasePlugin +(bus: MessageBus, name: str, conf: Dict[str, Any]) +
+
+

Base class for all ControlPi plugins.

+
>>> class TestPlugin(BasePlugin):
+...     CONF_SCHEMA = {'properties': {'key': {'type': 'string'}},
+...                    'required': ['key']}
+...     def process_conf(self):
+...         if 'key' in self.conf:
+...             print(f"Processing '{self.conf['key']}'.")
+...     async def run(self):
+...         print("Doing something else.")
+
+

Initialisation sets the instance variables bus to the given message bus, +name to the given name, and conf to the given configuration:

+
>>> class TestPlugin(BasePlugin):
+...     CONF_SCHEMA = {'properties': {'key': {'type': 'string'}},
+...                    'required': ['key']}
+...     def process_conf(self):
+...         if 'key' in self.conf:
+...             print(f"Processing '{self.conf['key']}'.")
+...     async def run(self):
+...         print("Doing something else.")
+>>> async def test():
+...     p = TestPlugin(MessageBus(), 'Test Instance',
+...                    {'key': 'Something'})
+...     print(p.bus)
+...     print(p.name)
+...     print(p.conf)
+>>> asyncio.run(test())  # doctest: +ELLIPSIS
+Processing 'Something'.
+<controlpi.messagebus.MessageBus object at 0x...>
+Test Instance
+{'key': 'Something'}
+
+

It also validates the configuration against the schema in CONF_SCHEMA +and raises ConfException if is not validated.

+
>>> async def test():
+...     p = TestPlugin(MessageBus(), 'Test Instance',
+...                    {'key': 42})
+>>> asyncio.run(test())  # doctest: +NORMALIZE_WHITESPACE
+Traceback (most recent call last):
+  ...
+baseplugin.ConfException: Configuration for 'Test Instance'
+is not valid.
+>>> async def test():
+...     p = TestPlugin(MessageBus(), 'Test Instance',
+...                    {'key 2': 'Something'})
+>>> asyncio.run(test())  # doctest: +NORMALIZE_WHITESPACE
+Traceback (most recent call last):
+  ...
+baseplugin.ConfException: Configuration for 'Test Instance'
+is not valid.
+
+

Finally, it calls process_conf, which is the function that should be +overridden by concrete plugins.

+
+ +Expand source code + +
class BasePlugin(ABC):
+    """Base class for all ControlPi plugins.
+
+    >>> class TestPlugin(BasePlugin):
+    ...     CONF_SCHEMA = {'properties': {'key': {'type': 'string'}},
+    ...                    'required': ['key']}
+    ...     def process_conf(self):
+    ...         if 'key' in self.conf:
+    ...             print(f"Processing '{self.conf['key']}'.")
+    ...     async def run(self):
+    ...         print("Doing something else.")
+
+    Initialisation sets the instance variables bus to the given message bus,
+    name to the given name, and conf to the given configuration:
+    >>> class TestPlugin(BasePlugin):
+    ...     CONF_SCHEMA = {'properties': {'key': {'type': 'string'}},
+    ...                    'required': ['key']}
+    ...     def process_conf(self):
+    ...         if 'key' in self.conf:
+    ...             print(f"Processing '{self.conf['key']}'.")
+    ...     async def run(self):
+    ...         print("Doing something else.")
+    >>> async def test():
+    ...     p = TestPlugin(MessageBus(), 'Test Instance',
+    ...                    {'key': 'Something'})
+    ...     print(p.bus)
+    ...     print(p.name)
+    ...     print(p.conf)
+    >>> asyncio.run(test())  # doctest: +ELLIPSIS
+    Processing 'Something'.
+    <controlpi.messagebus.MessageBus object at 0x...>
+    Test Instance
+    {'key': 'Something'}
+
+    It also validates the configuration against the schema in CONF_SCHEMA
+    and raises ConfException if is not validated.
+    >>> async def test():
+    ...     p = TestPlugin(MessageBus(), 'Test Instance',
+    ...                    {'key': 42})
+    >>> asyncio.run(test())  # doctest: +NORMALIZE_WHITESPACE
+    Traceback (most recent call last):
+      ...
+    baseplugin.ConfException: Configuration for 'Test Instance'
+    is not valid.
+    >>> async def test():
+    ...     p = TestPlugin(MessageBus(), 'Test Instance',
+    ...                    {'key 2': 'Something'})
+    >>> asyncio.run(test())  # doctest: +NORMALIZE_WHITESPACE
+    Traceback (most recent call last):
+      ...
+    baseplugin.ConfException: Configuration for 'Test Instance'
+    is not valid.
+
+    Finally, it calls process_conf, which is the function that should be
+    overridden by concrete plugins.
+    """
+
+    @property
+    @classmethod
+    @abstractmethod
+    def CONF_SCHEMA(cls) -> JSONSchema:
+        """JSON schema for configuration of plugin.
+
+        Given configurations are validated against this schema in __init__.
+        process_conf and run can assume a valid configuration in self.conf.
+        """
+        raise NotImplementedError
+
+    def __init__(self, bus: MessageBus, name: str, conf: PluginConf) -> None:
+        assert isinstance(bus, MessageBus)
+        self.bus = bus
+        assert isinstance(name, str)
+        self.name = name
+        jsonschema.Draft7Validator.check_schema(type(self).CONF_SCHEMA)
+        validator = jsonschema.Draft7Validator(type(self).CONF_SCHEMA)
+        assert isinstance(conf, dict)
+        valid = True
+        for error in validator.iter_errors(conf):
+            print(error)
+            valid = False
+        if not valid:
+            raise ConfException(f"Configuration for '{self.name}'"
+                                " is not valid.")
+        self.conf = conf
+        self.process_conf()
+
+    @abstractmethod
+    def process_conf(self) -> None:
+        """Process the configuration.
+
+        Abstract method has to be overridden by concrete plugins.
+        process_conf is called at the end of initialisation after the bus
+        and the configuration are available as self.bus and self.conf, but
+        before any of the run coroutines are executed.
+        """
+        raise NotImplementedError
+
+    @abstractmethod
+    async def run(self) -> None:
+        """Run the plugin.
+
+        The coroutine is run concurrently with the message bus and all
+        other plugins. Initial messages and other tasks can be done here.
+        It is also okay to run a plugin-specific infinite loop concurrently
+        with the rest of the system.
+        """
+        raise NotImplementedError
+
+

Ancestors

+
    +
  • abc.ABC
  • +
+

Subclasses

+ +

Methods

+
+
+def process_conf(self) ‑> NoneType +
+
+

Process the configuration.

+

Abstract method has to be overridden by concrete plugins. +process_conf is called at the end of initialisation after the bus +and the configuration are available as self.bus and self.conf, but +before any of the run coroutines are executed.

+
+ +Expand source code + +
@abstractmethod
+def process_conf(self) -> None:
+    """Process the configuration.
+
+    Abstract method has to be overridden by concrete plugins.
+    process_conf is called at the end of initialisation after the bus
+    and the configuration are available as self.bus and self.conf, but
+    before any of the run coroutines are executed.
+    """
+    raise NotImplementedError
+
+
+
+async def run(self) ‑> NoneType +
+
+

Run the plugin.

+

The coroutine is run concurrently with the message bus and all +other plugins. Initial messages and other tasks can be done here. +It is also okay to run a plugin-specific infinite loop concurrently +with the rest of the system.

+
+ +Expand source code + +
@abstractmethod
+async def run(self) -> None:
+    """Run the plugin.
+
+    The coroutine is run concurrently with the message bus and all
+    other plugins. Initial messages and other tasks can be done here.
+    It is also okay to run a plugin-specific infinite loop concurrently
+    with the rest of the system.
+    """
+    raise NotImplementedError
+
+
+
+
+
+
+
+ +
+ + + \ No newline at end of file diff --git a/doc/controlpi/index.html b/doc/controlpi/index.html new file mode 100644 index 0000000..d42fb6c --- /dev/null +++ b/doc/controlpi/index.html @@ -0,0 +1,476 @@ + + + + + + +controlpi API documentation + + + + + + + + + + + +
+
+
+

Package controlpi

+
+
+

Provide the infrastructure for the ControlPi system.

+

The infrastructure consists of the message bus from module messagebus, the +plugin registry from module pluginregistry and the abstract base plugin from +module baseplugin.

+

The package combines them in its run function, which is used by main.py +to run a ControlPi system based on a configuration file indefinitely.

+

The test function is a utility function to test plugins with minimal +boilerplate code.

+
+ +Expand source code + +
"""Provide the infrastructure for the ControlPi system.
+
+The infrastructure consists of the message bus from module messagebus, the
+plugin registry from module pluginregistry and the abstract base plugin from
+module baseplugin.
+
+The package combines them in its run function, which is used by __main__.py
+to run a ControlPi system based on a configuration file indefinitely.
+
+The test function is a utility function to test plugins with minimal
+boilerplate code.
+"""
+import asyncio
+import jsonschema  # type: ignore
+
+from controlpi.messagebus import MessageBus, Message, MessageTemplate
+from controlpi.pluginregistry import PluginRegistry
+from controlpi.baseplugin import BasePlugin, PluginConf, ConfException
+
+from typing import Dict, List, Coroutine, Any
+
+
+CONF_SCHEMA = {'type': 'object',
+               'patternProperties': {'.*': {'type': 'object'}}}
+
+
+def _process_conf(message_bus: MessageBus,
+                  conf: Dict[str, PluginConf]) -> List[Coroutine]:
+    jsonschema.Draft7Validator.check_schema(CONF_SCHEMA)
+    validator = jsonschema.Draft7Validator(CONF_SCHEMA)
+    valid = True
+    for error in validator.iter_errors(conf):
+        print(error)
+        valid = False
+    if not valid:
+        return []
+    plugins = PluginRegistry('controlpi_plugins', BasePlugin)
+    coroutines = [message_bus.run()]
+    for instance_name in conf:
+        instance_conf = conf[instance_name]
+        if 'plugin' not in instance_conf:
+            print("No plugin implementation specified for instance"
+                  f" '{instance_name}'.")
+            continue
+        plugin_name = instance_conf['plugin']
+        if plugin_name not in plugins:
+            print(f"No implementation found for plugin '{plugin_name}'"
+                  f" (specified for instance '{instance_name}').")
+            continue
+        plugin = plugins[plugin_name]
+        try:
+            instance = plugin(message_bus, instance_name, instance_conf)
+            coroutines.append(instance.run())
+        except ConfException as e:
+            print(e)
+            continue
+    return coroutines
+
+
+async def run(conf: Dict[str, PluginConf]) -> None:
+    """Run the ControlPi system based on a configuration.
+
+    Setup message bus, process given configuration, and run message bus and
+    plugins concurrently and indefinitely.
+
+    This function is mainly used by __main__.py to run a ControlPi system
+    based on a configuration loaded from a configuration JSON file on disk.
+
+    >>> async def test_coroutine():
+    ...     conf = {"Example Init":
+    ...             {"plugin": "Init",
+    ...              "messages": [{"id": 42,
+    ...                            "content": "Test Message"},
+    ...                           {"id": 42.42,
+    ...                            "content": "Second Message"}]},
+    ...             "Example Log":
+    ...             {"plugin": "Log",
+    ...              "filter": [{"sender": {"const": "Example Init"}}]}}
+    ...     run_task = asyncio.create_task(run(conf))
+    ...     await asyncio.sleep(0.1)
+    ...     run_task.cancel()
+    >>> asyncio.run(test_coroutine())  # doctest: +NORMALIZE_WHITESPACE
+    Example Log: {'sender': 'Example Init',
+                  'id': 42, 'content': 'Test Message'}
+    Example Log: {'sender': 'Example Init',
+                  'id': 42.42, 'content': 'Second Message'}
+    """
+    message_bus = MessageBus()
+    coroutines = _process_conf(message_bus, conf)
+    try:
+        await asyncio.gather(*coroutines)
+    except asyncio.exceptions.CancelledError:
+        pass
+
+
+async def test(conf: Dict[str, PluginConf],
+               messages: List[Dict[str, Any]],
+               wait: float = 0.0) -> None:
+    """Test configuration of ControlPi system.
+
+    Setup message bus, process given configuration, run message bus and
+    plugins concurrently, send given messages on message bus and print all
+    messages on message bus. Terminate when queue of message bus is empty.
+
+    This function allows to test single plugins or small plugin
+    configurations with minimal boilerplate code:
+    >>> asyncio.run(test(
+    ...     {"Example Init": {"plugin": "Init",
+    ...                       "messages": [{"id": 42,
+    ...                                     "content": "Test Message"},
+    ...                                    {"id": 42.42,
+    ...                                     "content": "Second Message"}]}},
+    ...     [{"target": "Example Init",
+    ...       "command": "execute"}]))  # doctest: +NORMALIZE_WHITESPACE
+    test(): {'sender': '', 'event': 'registered',
+             'client': 'Example Init', 'plugin': 'Init',
+             'sends': [{'id': {'const': 42},
+                        'content': {'const': 'Test Message'}},
+                       {'id': {'const': 42.42},
+                        'content': {'const': 'Second Message'}}],
+             'receives': [{'target': {'const': 'Example Init'},
+                           'command': {'const': 'execute'}}]}
+    test(): {'sender': 'Example Init',
+             'id': 42, 'content': 'Test Message'}
+    test(): {'sender': 'Example Init',
+             'id': 42.42, 'content': 'Second Message'}
+    test(): {'sender': 'test()', 'target': 'Example Init',
+             'command': 'execute'}
+    test(): {'sender': 'Example Init',
+             'id': 42, 'content': 'Test Message'}
+    test(): {'sender': 'Example Init',
+             'id': 42.42, 'content': 'Second Message'}
+
+    Similar functionality could be reached by using the Log and Init plugins
+    to print messages and send some messages on the bus, but these would
+    clutter the test configuration and code to stop the indefinitely running
+    bus would have to be added to each and every test.
+
+    Incorrect plugin configurations can also be tested by this:
+    >>> asyncio.run(test(
+    ...     {"Example Init": {"plugin": "Init"}}, []))
+    'messages' is a required property
+    <BLANKLINE>
+    Failed validating 'required' in schema:
+        {'properties': {'messages': {'items': {'type': 'object'},
+                                     'type': 'array'}},
+         'required': ['messages']}
+    <BLANKLINE>
+    On instance:
+        {'plugin': 'Init'}
+    Configuration for 'Example Init' is not valid.
+    """
+    message_bus = MessageBus()
+
+    async def log(message):
+        if ('sender' in message and message['sender'] == '' and
+                'event' in message and message['event'] == 'registered' and
+                'client' in message and message['client'] == 'test()'):
+            # Do not log own registration of 'test()':
+            return
+        print(f"test(): {message}")
+    message_bus.register('test()', 'Test',
+                         [MessageTemplate()], [MessageTemplate()], log)
+
+    coroutines = _process_conf(message_bus, conf)
+    for coroutine in coroutines:
+        asyncio.create_task(coroutine)
+        # Give the created task opportunity to run:
+        await asyncio.sleep(0)
+    for message in messages:
+        await message_bus.send(Message('test()', message))
+        # Give immediate reactions to messages opportunity to happen:
+        await asyncio.sleep(0)
+    await asyncio.sleep(wait)
+    await message_bus._queue.join()
+
+
+
+

Sub-modules

+
+
controlpi.baseplugin
+
+

Define base class for all ControlPi plugins …

+
+
controlpi.messagebus
+
+

Provide an asynchronous message bus …

+
+
controlpi.pluginregistry
+
+

Provide a generic plugin system …

+
+
+
+
+
+
+

Functions

+
+
+async def run(conf: Dict[str, Dict[str, Any]]) ‑> NoneType +
+
+

Run the ControlPi system based on a configuration.

+

Setup message bus, process given configuration, and run message bus and +plugins concurrently and indefinitely.

+

This function is mainly used by main.py to run a ControlPi system +based on a configuration loaded from a configuration JSON file on disk.

+
>>> async def test_coroutine():
+...     conf = {"Example Init":
+...             {"plugin": "Init",
+...              "messages": [{"id": 42,
+...                            "content": "Test Message"},
+...                           {"id": 42.42,
+...                            "content": "Second Message"}]},
+...             "Example Log":
+...             {"plugin": "Log",
+...              "filter": [{"sender": {"const": "Example Init"}}]}}
+...     run_task = asyncio.create_task(run(conf))
+...     await asyncio.sleep(0.1)
+...     run_task.cancel()
+>>> asyncio.run(test_coroutine())  # doctest: +NORMALIZE_WHITESPACE
+Example Log: {'sender': 'Example Init',
+              'id': 42, 'content': 'Test Message'}
+Example Log: {'sender': 'Example Init',
+              'id': 42.42, 'content': 'Second Message'}
+
+
+ +Expand source code + +
async def run(conf: Dict[str, PluginConf]) -> None:
+    """Run the ControlPi system based on a configuration.
+
+    Setup message bus, process given configuration, and run message bus and
+    plugins concurrently and indefinitely.
+
+    This function is mainly used by __main__.py to run a ControlPi system
+    based on a configuration loaded from a configuration JSON file on disk.
+
+    >>> async def test_coroutine():
+    ...     conf = {"Example Init":
+    ...             {"plugin": "Init",
+    ...              "messages": [{"id": 42,
+    ...                            "content": "Test Message"},
+    ...                           {"id": 42.42,
+    ...                            "content": "Second Message"}]},
+    ...             "Example Log":
+    ...             {"plugin": "Log",
+    ...              "filter": [{"sender": {"const": "Example Init"}}]}}
+    ...     run_task = asyncio.create_task(run(conf))
+    ...     await asyncio.sleep(0.1)
+    ...     run_task.cancel()
+    >>> asyncio.run(test_coroutine())  # doctest: +NORMALIZE_WHITESPACE
+    Example Log: {'sender': 'Example Init',
+                  'id': 42, 'content': 'Test Message'}
+    Example Log: {'sender': 'Example Init',
+                  'id': 42.42, 'content': 'Second Message'}
+    """
+    message_bus = MessageBus()
+    coroutines = _process_conf(message_bus, conf)
+    try:
+        await asyncio.gather(*coroutines)
+    except asyncio.exceptions.CancelledError:
+        pass
+
+
+
+async def test(conf: Dict[str, Dict[str, Any]], messages: List[Dict[str, Any]], wait: float = 0.0) ‑> NoneType +
+
+

Test configuration of ControlPi system.

+

Setup message bus, process given configuration, run message bus and +plugins concurrently, send given messages on message bus and print all +messages on message bus. Terminate when queue of message bus is empty.

+

This function allows to test single plugins or small plugin +configurations with minimal boilerplate code:

+
>>> asyncio.run(test(
+...     {"Example Init": {"plugin": "Init",
+...                       "messages": [{"id": 42,
+...                                     "content": "Test Message"},
+...                                    {"id": 42.42,
+...                                     "content": "Second Message"}]}},
+...     [{"target": "Example Init",
+...       "command": "execute"}]))  # doctest: +NORMALIZE_WHITESPACE
+test(): {'sender': '', 'event': 'registered',
+         'client': 'Example Init', 'plugin': 'Init',
+         'sends': [{'id': {'const': 42},
+                    'content': {'const': 'Test Message'}},
+                   {'id': {'const': 42.42},
+                    'content': {'const': 'Second Message'}}],
+         'receives': [{'target': {'const': 'Example Init'},
+                       'command': {'const': 'execute'}}]}
+test(): {'sender': 'Example Init',
+         'id': 42, 'content': 'Test Message'}
+test(): {'sender': 'Example Init',
+         'id': 42.42, 'content': 'Second Message'}
+test(): {'sender': 'test()', 'target': 'Example Init',
+         'command': 'execute'}
+test(): {'sender': 'Example Init',
+         'id': 42, 'content': 'Test Message'}
+test(): {'sender': 'Example Init',
+         'id': 42.42, 'content': 'Second Message'}
+
+

Similar functionality could be reached by using the Log and Init plugins +to print messages and send some messages on the bus, but these would +clutter the test configuration and code to stop the indefinitely running +bus would have to be added to each and every test.

+

Incorrect plugin configurations can also be tested by this:

+
>>> asyncio.run(test(
+...     {"Example Init": {"plugin": "Init"}}, []))
+'messages' is a required property
+<BLANKLINE>
+Failed validating 'required' in schema:
+    {'properties': {'messages': {'items': {'type': 'object'},
+                                 'type': 'array'}},
+     'required': ['messages']}
+<BLANKLINE>
+On instance:
+    {'plugin': 'Init'}
+Configuration for 'Example Init' is not valid.
+
+
+ +Expand source code + +
async def test(conf: Dict[str, PluginConf],
+               messages: List[Dict[str, Any]],
+               wait: float = 0.0) -> None:
+    """Test configuration of ControlPi system.
+
+    Setup message bus, process given configuration, run message bus and
+    plugins concurrently, send given messages on message bus and print all
+    messages on message bus. Terminate when queue of message bus is empty.
+
+    This function allows to test single plugins or small plugin
+    configurations with minimal boilerplate code:
+    >>> asyncio.run(test(
+    ...     {"Example Init": {"plugin": "Init",
+    ...                       "messages": [{"id": 42,
+    ...                                     "content": "Test Message"},
+    ...                                    {"id": 42.42,
+    ...                                     "content": "Second Message"}]}},
+    ...     [{"target": "Example Init",
+    ...       "command": "execute"}]))  # doctest: +NORMALIZE_WHITESPACE
+    test(): {'sender': '', 'event': 'registered',
+             'client': 'Example Init', 'plugin': 'Init',
+             'sends': [{'id': {'const': 42},
+                        'content': {'const': 'Test Message'}},
+                       {'id': {'const': 42.42},
+                        'content': {'const': 'Second Message'}}],
+             'receives': [{'target': {'const': 'Example Init'},
+                           'command': {'const': 'execute'}}]}
+    test(): {'sender': 'Example Init',
+             'id': 42, 'content': 'Test Message'}
+    test(): {'sender': 'Example Init',
+             'id': 42.42, 'content': 'Second Message'}
+    test(): {'sender': 'test()', 'target': 'Example Init',
+             'command': 'execute'}
+    test(): {'sender': 'Example Init',
+             'id': 42, 'content': 'Test Message'}
+    test(): {'sender': 'Example Init',
+             'id': 42.42, 'content': 'Second Message'}
+
+    Similar functionality could be reached by using the Log and Init plugins
+    to print messages and send some messages on the bus, but these would
+    clutter the test configuration and code to stop the indefinitely running
+    bus would have to be added to each and every test.
+
+    Incorrect plugin configurations can also be tested by this:
+    >>> asyncio.run(test(
+    ...     {"Example Init": {"plugin": "Init"}}, []))
+    'messages' is a required property
+    <BLANKLINE>
+    Failed validating 'required' in schema:
+        {'properties': {'messages': {'items': {'type': 'object'},
+                                     'type': 'array'}},
+         'required': ['messages']}
+    <BLANKLINE>
+    On instance:
+        {'plugin': 'Init'}
+    Configuration for 'Example Init' is not valid.
+    """
+    message_bus = MessageBus()
+
+    async def log(message):
+        if ('sender' in message and message['sender'] == '' and
+                'event' in message and message['event'] == 'registered' and
+                'client' in message and message['client'] == 'test()'):
+            # Do not log own registration of 'test()':
+            return
+        print(f"test(): {message}")
+    message_bus.register('test()', 'Test',
+                         [MessageTemplate()], [MessageTemplate()], log)
+
+    coroutines = _process_conf(message_bus, conf)
+    for coroutine in coroutines:
+        asyncio.create_task(coroutine)
+        # Give the created task opportunity to run:
+        await asyncio.sleep(0)
+    for message in messages:
+        await message_bus.send(Message('test()', message))
+        # Give immediate reactions to messages opportunity to happen:
+        await asyncio.sleep(0)
+    await asyncio.sleep(wait)
+    await message_bus._queue.join()
+
+
+
+
+
+
+
+ +
+ + + \ No newline at end of file diff --git a/doc/controlpi/messagebus.html b/doc/controlpi/messagebus.html new file mode 100644 index 0000000..46bea91 --- /dev/null +++ b/doc/controlpi/messagebus.html @@ -0,0 +1,4244 @@ + + + + + + +controlpi.messagebus API documentation + + + + + + + + + + + +
+
+
+

Module controlpi.messagebus

+
+
+

Provide an asynchronous message bus.

+

A message is a dictionary with string keys and string, integer, float, +Boolean, dictionary, or list values, where the inner dictionaries again +have string keys and these values and the inner lists also have elements of +these types. All messages have a special key 'sender' with the name of the +sending client as string value, which is set by the constructor:

+
>>> m = Message('Example sender', {'key 1': 'value 1'})
+>>> m['key 2'] = 'value 2'
+>>> print(m)
+{'sender': 'Example sender', 'key 1': 'value 1', 'key 2': 'value 2'}
+
+

A message template is a mapping from string keys to JSON schemas as values. +A message template matches a message if all keys of the template are +contained in the message and the values in the message validate against the +respective schemas. An empty mapping therefore matches all messages.

+

The bus executes asynchronous callbacks for all messages to be received by +a client. We use a simple callback printing the message in all examples:

+
>>> def callback_for_receiver(receiver):
+...     async def callback(message):
+...         print(f"{receiver}: {message}")
+...     return callback
+
+

Clients can be registered at the bus with a name, lists of message templates +they want to use for sending and receiving and a callback function for +receiving. An empty list of templates means that the client does not want to +send or receive any messages, respectively. A list with an empty template +means that it wants to send arbitrary or receive all messages, respectively:

+
>>> async def setup(bus):
+...     bus.register('Logger', 'Test Plugin',
+...                  [],
+...                  [{}],
+...                  callback_for_receiver('Logger'))
+...     bus.register('Client 1', 'Test Plugin',
+...                  [{'k1': {'type': 'string'}}],
+...                  [{'target': {'const': 'Client 1'}}],
+...                  callback_for_receiver('Client 1'))
+
+

While most clients should always use their own name for sending, this is not +enforced and debugging or management clients could send messages on behalf +of arbitrary client names.

+

The name of a client has to be unique and is not allowed to be empty +(otherwise registration fails).

+

The empty name is used to refer to the bus itself. The bus sends messages +for registrations and deregistrations of clients containing their complete +interface of send and receive templates. This can be used to allow dynamic +(debug) clients to deal with arbitrary configurations of clients. The bus +also reacts to 'get clients' command messages by sending the complete +information of all currently registered clients.

+

Clients can send to the bus with the send function. Each message has to +declare a sender. The send templates of that sender are checked for a +template matching the message:

+
>>> async def send(bus):
+...     print("Sending messages.")
+...     await bus.send({'sender': 'Client 1', 'k1': 'Test'})
+...     await bus.send({'sender': '', 'target': 'Client 1'})
+
+

The run function executes the message bus forever. If we want to stop it, we +have to explicitly cancel the task:

+
>>> async def main():
+...     bus = MessageBus()
+...     await setup(bus)
+...     bus_task = asyncio.create_task(bus.run())
+...     await send(bus)
+...     await asyncio.sleep(0)
+...     bus_task.cancel()
+>>> asyncio.run(main())  # doctest: +NORMALIZE_WHITESPACE
+Sending messages.
+Logger: {'sender': '', 'event': 'registered',
+         'client': 'Logger', 'plugin': 'Test Plugin',
+         'sends': [], 'receives': [{}]}
+Logger: {'sender': '', 'event': 'registered',
+         'client': 'Client 1', 'plugin': 'Test Plugin',
+         'sends': [{'k1': {'type': 'string'}}],
+         'receives': [{'target': {'const': 'Client 1'}}]}
+Logger: {'sender': 'Client 1', 'k1': 'Test'}
+Logger: {'sender': '', 'target': 'Client 1'}
+Client 1: {'sender': '', 'target': 'Client 1'}
+
+
+ +Expand source code + +
"""Provide an asynchronous message bus.
+
+A message is a dictionary with string keys and string, integer, float,
+Boolean, dictionary, or list values, where the inner dictionaries again
+have string keys and these values and the inner lists also have elements of
+these types. All messages have a special key 'sender' with the name of the
+sending client as string value, which is set by the constructor:
+>>> m = Message('Example sender', {'key 1': 'value 1'})
+>>> m['key 2'] = 'value 2'
+>>> print(m)
+{'sender': 'Example sender', 'key 1': 'value 1', 'key 2': 'value 2'}
+
+A message template is a mapping from string keys to JSON schemas as values.
+A message template matches a message if all keys of the template are
+contained in the message and the values in the message validate against the
+respective schemas. An empty mapping therefore matches all messages.
+
+The bus executes asynchronous callbacks for all messages to be received by
+a client. We use a simple callback printing the message in all examples:
+>>> def callback_for_receiver(receiver):
+...     async def callback(message):
+...         print(f"{receiver}: {message}")
+...     return callback
+
+Clients can be registered at the bus with a name, lists of message templates
+they want to use for sending and receiving and a callback function for
+receiving. An empty list of templates means that the client does not want to
+send or receive any messages, respectively. A list with an empty template
+means that it wants to send arbitrary or receive all messages, respectively:
+>>> async def setup(bus):
+...     bus.register('Logger', 'Test Plugin',
+...                  [],
+...                  [{}],
+...                  callback_for_receiver('Logger'))
+...     bus.register('Client 1', 'Test Plugin',
+...                  [{'k1': {'type': 'string'}}],
+...                  [{'target': {'const': 'Client 1'}}],
+...                  callback_for_receiver('Client 1'))
+
+While most clients should always use their own name for sending, this is not
+enforced and debugging or management clients could send messages on behalf
+of arbitrary client names.
+
+The name of a client has to be unique and is not allowed to be empty
+(otherwise registration fails).
+
+The empty name is used to refer to the bus itself. The bus sends messages
+for registrations and deregistrations of clients containing their complete
+interface of send and receive templates. This can be used to allow dynamic
+(debug) clients to deal with arbitrary configurations of clients. The bus
+also reacts to 'get clients' command messages by sending the complete
+information of all currently registered clients.
+
+Clients can send to the bus with the send function. Each message has to
+declare a sender. The send templates of that sender are checked for a
+template matching the message:
+>>> async def send(bus):
+...     print("Sending messages.")
+...     await bus.send({'sender': 'Client 1', 'k1': 'Test'})
+...     await bus.send({'sender': '', 'target': 'Client 1'})
+
+The run function executes the message bus forever. If we want to stop it, we
+have to explicitly cancel the task:
+>>> async def main():
+...     bus = MessageBus()
+...     await setup(bus)
+...     bus_task = asyncio.create_task(bus.run())
+...     await send(bus)
+...     await asyncio.sleep(0)
+...     bus_task.cancel()
+>>> asyncio.run(main())  # doctest: +NORMALIZE_WHITESPACE
+Sending messages.
+Logger: {'sender': '', 'event': 'registered',
+         'client': 'Logger', 'plugin': 'Test Plugin',
+         'sends': [], 'receives': [{}]}
+Logger: {'sender': '', 'event': 'registered',
+         'client': 'Client 1', 'plugin': 'Test Plugin',
+         'sends': [{'k1': {'type': 'string'}}],
+         'receives': [{'target': {'const': 'Client 1'}}]}
+Logger: {'sender': 'Client 1', 'k1': 'Test'}
+Logger: {'sender': '', 'target': 'Client 1'}
+Client 1: {'sender': '', 'target': 'Client 1'}
+"""
+import asyncio
+import json
+import jsonschema  # type: ignore
+
+from typing import Union, Dict, List, Any, Iterable, Callable, Coroutine
+MessageValue = Union[None, str, int, float, bool, Dict[str, Any], List[Any]]
+# Should really be:
+# MessageValue = Union[None, str, int, float, bool,
+#                      Dict[str, 'MessageValue'], List['MessageValue']]
+# But mypy does not support recursion by now:
+# https://github.com/python/mypy/issues/731
+JSONSchema = Union[bool, Dict[str, MessageValue]]
+# Could be even more specific.
+MessageCallback = Callable[['Message'], Coroutine[Any, Any, None]]
+
+
+class Message(Dict[str, MessageValue]):
+    """Define arbitrary message.
+
+    Messages are dictionaries with string keys and values that are strings,
+    integers, floats, Booleans, dictionaries that recursively have string
+    keys and values of any of these types, or lists with elements that have
+    any of these types. These constraints are checked when setting key-value
+    pairs of the message.
+
+    A message has to have a sender, which is set by the constructor:
+    >>> m = Message('Example sender')
+    >>> print(m)
+    {'sender': 'Example sender'}
+
+    A dictionary can be given to the constructor:
+    >>> m = Message('Example sender', {'key 1': 'value 1', 'key 2': 'value 2'})
+    >>> print(m)
+    {'sender': 'Example sender', 'key 1': 'value 1', 'key 2': 'value 2'}
+
+    Or the message can be modified after construction:
+    >>> m = Message('Example sender', {'key 1': 'value 1'})
+    >>> m['key 2'] = 'value 2'
+    >>> print(m)
+    {'sender': 'Example sender', 'key 1': 'value 1', 'key 2': 'value 2'}
+    """
+
+    def __init__(self, sender: str,
+                 init: Dict[str, MessageValue] = None) -> None:
+        """Initialise message.
+
+        Message is initialised with given sender and possibly given
+        key-value pairs:
+        >>> m = Message('Example sender')
+        >>> print(m)
+        {'sender': 'Example sender'}
+        >>> m = Message('Example sender', {'key 1': 'value 1'})
+        >>> print(m)
+        {'sender': 'Example sender', 'key 1': 'value 1'}
+
+        The sender can be overwritten by the key-value pairs:
+        >>> m = Message('Example sender', {'sender': 'Another sender'})
+        >>> print(m)
+        {'sender': 'Another sender'}
+        """
+        if not isinstance(sender, str):
+            raise TypeError(f"'{sender}' is not a valid sender name"
+                            " (not a string).")
+        self['sender'] = sender
+        if init is not None:
+            self.update(init)
+
+    @staticmethod
+    def check_value(value: MessageValue) -> bool:
+        """Check recursively if a given value is valid.
+
+        None, strings, integers, floats and Booleans are valid:
+        >>> Message.check_value(None)
+        True
+        >>> Message.check_value('Spam')
+        True
+        >>> Message.check_value(42)
+        True
+        >>> Message.check_value(42.42)
+        True
+        >>> Message.check_value(False)
+        True
+
+        Other basic types are not valid:
+        >>> Message.check_value(b'bytes')
+        False
+        >>> Message.check_value(1j)
+        False
+
+        Dictionaries with string keys and recursively valid values are valid:
+        >>> Message.check_value({'str value': 'Spam', 'int value': 42,
+        ...                      'float value': 42.42, 'bool value': False})
+        True
+
+        Empty dictionaries are valid:
+        >>> Message.check_value({})
+        True
+
+        Dictionaries with other keys are not valid:
+        >>> Message.check_value({42: 'int key'})
+        False
+
+        Dictionaries with invalid values are not valid:
+        >>> Message.check_value({'complex value': 1j})
+        False
+
+        Lists with valid elements are valid:
+        >>> Message.check_value(['Spam', 42, 42.42, False])
+        True
+
+        Empty lists are valid:
+        >>> Message.check_value([])
+        True
+
+        Lists with invalid elements are not valid:
+        >>> Message.check_value([1j])
+        False
+        """
+        if value is None:
+            return True
+        elif (isinstance(value, str) or isinstance(value, int) or
+                isinstance(value, float) or isinstance(value, bool)):
+            return True
+        elif isinstance(value, dict):
+            for key in value:
+                if not isinstance(key, str):
+                    return False
+                if not Message.check_value(value[key]):
+                    return False
+            return True
+        elif isinstance(value, list):
+            for element in value:
+                if not Message.check_value(element):
+                    return False
+            return True
+        return False
+
+    def __setitem__(self, key: str, value: MessageValue) -> None:
+        """Check key and value before putting pair into dict.
+
+        >>> m = Message('Example sender')
+        >>> m['key'] = 'value'
+        >>> m['dict'] = {'k1': 'v1', 'k2': 2}
+        >>> print(m)  # doctest: +NORMALIZE_WHITESPACE
+        {'sender': 'Example sender', 'key': 'value',
+         'dict': {'k1': 'v1', 'k2': 2}}
+        >>> m = Message('Example sender')
+        >>> m[42] = 'int key'
+        Traceback (most recent call last):
+          ...
+        TypeError: '42' is not a valid key in Message (not a string).
+        >>> m['complex value'] = 1j
+        Traceback (most recent call last):
+          ...
+        TypeError: '1j' is not a valid value in Message.
+        """
+        if not isinstance(key, str):
+            raise TypeError(f"'{key}' is not a valid key in Message"
+                            " (not a string).")
+        if not self.check_value(value):
+            raise TypeError(f"'{value}' is not a valid value in Message.")
+        super().__setitem__(key, value)
+
+    def update(self, *args, **kwargs) -> None:
+        """Override update to use validity checks.
+
+        >>> m = Message('Example sender')
+        >>> m.update({'key 1': 'value 1', 'key 2': 'value 2'})
+        >>> print(m)
+        {'sender': 'Example sender', 'key 1': 'value 1', 'key 2': 'value 2'}
+        >>> m.update({42: 'int key'})
+        Traceback (most recent call last):
+          ...
+        TypeError: '42' is not a valid key in Message (not a string).
+        >>> m.update({'complex value': 1j})
+        Traceback (most recent call last):
+          ...
+        TypeError: '1j' is not a valid value in Message.
+
+        This is also used in __init__:
+        >>> m = Message('Example sender', {'key': 'value'})
+        >>> print(m)
+        {'sender': 'Example sender', 'key': 'value'}
+        >>> m = Message('Example sender', {42: 'int key'})
+        Traceback (most recent call last):
+          ...
+        TypeError: '42' is not a valid key in Message (not a string).
+        >>> m = Message('Example sender', {'complex value': 1j})
+        Traceback (most recent call last):
+          ...
+        TypeError: '1j' is not a valid value in Message.
+        """
+        if args:
+            if len(args) > 1:
+                raise TypeError("update expected at most 1 argument,"
+                                f" got {len(args)}")
+            other = dict(args[0])
+            for key in other:
+                self[key] = other[key]
+        for key in kwargs:
+            self[key] = kwargs[key]
+
+    def setdefault(self, key: str, value: MessageValue = None) -> MessageValue:
+        """Override setdefault to use validity checks.
+
+        >>> m = Message('Example sender')
+        >>> m.setdefault('key', 'value 1')
+        'value 1'
+        >>> m.setdefault('key', 'value 2')
+        'value 1'
+        >>> m.setdefault(42, 'int key')
+        Traceback (most recent call last):
+          ...
+        TypeError: '42' is not a valid key in Message (not a string).
+        >>> m.setdefault('complex value', 1j)
+        Traceback (most recent call last):
+          ...
+        TypeError: '1j' is not a valid value in Message.
+
+        But __setitem__ is not called if the key is already present:
+        >>> m.setdefault('key', 1j)
+        'value 1'
+        """
+        if key not in self:
+            self[key] = value
+        return self[key]
+
+
+class MessageTemplate(Dict[str, JSONSchema]):
+    """Define a message template.
+
+    A message template is a mapping from string keys to JSON schemas as
+    values:
+    >>> t = MessageTemplate({'key 1': {'const': 'value'},
+    ...                      'key 2': {'type': 'string'}})
+    >>> t['key 3'] = {'type': 'object',
+    ...               'properties': {'key 1': {'type': 'number'},
+    ...                              'key 2': True}}
+
+    A message template matches a message if all keys of the template are
+    contained in the message and the values in the message validate against
+    the respective schemas:
+    >>> t.check(Message('Example Sender',
+    ...                 {'key 1': 'value', 'key 2': 'some string',
+    ...                  'key 3': {'key 1': 42, 'key 2': None}}))
+    True
+
+    An empty mapping therefore matches all messages:
+    >>> t = MessageTemplate()
+    >>> t.check(Message('Example Sender', {'arbitrary': 'content'}))
+    True
+    """
+
+    def __init__(self, init: Dict[str, JSONSchema] = None) -> None:
+        """Initialise message.
+
+        Template is initialised empty or with given key-value pairs:
+        >>> t = MessageTemplate()
+        >>> print(t)
+        {}
+        >>> t = MessageTemplate({'key': {'const': 'value'}})
+        >>> print(t)
+        {'key': {'const': 'value'}}
+        """
+        if init is not None:
+            self.update(init)
+
+    @staticmethod
+    def from_message(message: Message) -> 'MessageTemplate':
+        """Create template from message.
+
+        Template witch constant schemas is created from message:
+        >>> m = Message('Example Sender', {'key': 'value'})
+        >>> t = MessageTemplate.from_message(m)
+        >>> print(t)
+        {'sender': {'const': 'Example Sender'}, 'key': {'const': 'value'}}
+        >>> m = Message('Example Sender', {'dict': {'int': 42, 'float': 42.42},
+        ...                                'list': [None, True, 'string']})
+        >>> t = MessageTemplate.from_message(m)
+        >>> print(t)  # doctest: +NORMALIZE_WHITESPACE
+        {'sender': {'const': 'Example Sender'},
+         'dict': {'type': 'object',
+                  'properties': {'int': {'const': 42},
+                                 'float': {'const': 42.42}}},
+         'list': {'type': 'array',
+                  'items': [{'const': None},
+                            {'const': True},
+                            {'const': 'string'}]}}
+
+        This is especially useful for clients that send certain fully
+        predefined messages, where the message is given in the configuration
+        and the template for the registration can be constructed by this
+        method.
+        """
+        def schema_from_value(value: MessageValue) -> JSONSchema:
+            schema: JSONSchema = False
+            if value is None:
+                schema = {'const': None}
+            elif (isinstance(value, str) or isinstance(value, int) or
+                    isinstance(value, float) or isinstance(value, bool)):
+                schema = {'const': value}
+            elif isinstance(value, dict):
+                properties = {}
+                for inner_key in value:
+                    inner_value: Message = value[inner_key]
+                    properties[inner_key] = schema_from_value(inner_value)
+                schema = {'type': 'object',
+                          'properties': properties}
+            elif isinstance(value, list):
+                schema = {'type': 'array',
+                          'items': [schema_from_value(element)
+                                    for element in value]}
+            return schema
+        template = MessageTemplate()
+        for key in message:
+            template[key] = schema_from_value(message[key])
+        return template
+
+    def __setitem__(self, key: str, value: JSONSchema) -> None:
+        """Check key and value before putting pair into dict.
+
+        >>> t = MessageTemplate()
+        >>> t['key 1'] = {'const': 'value'}
+        >>> t['key 2'] = {'type': 'string'}
+        >>> t['key 3'] = {'type': 'object',
+        ...               'properties': {'key 1': {'type': 'number'},
+        ...                              'key 2': True}}
+        >>> print(t)  # doctest: +NORMALIZE_WHITESPACE
+        {'key 1': {'const': 'value'}, 'key 2': {'type': 'string'},
+         'key 3': {'type': 'object',
+                   'properties': {'key 1': {'type': 'number'},
+                                  'key 2': True}}}
+        >>> t[42] = {'const': 'int key'}
+        Traceback (most recent call last):
+          ...
+        TypeError: '42' is not a valid key in MessageTemplate (not a string).
+        >>> t['key'] = 'schema'  # doctest: +NORMALIZE_WHITESPACE
+        Traceback (most recent call last):
+          ...
+        TypeError: 'schema' is not a valid value in MessageTemplate
+        (not a valid JSON schema).
+        """
+        if not isinstance(key, str):
+            raise TypeError(f"'{key}' is not a valid key in MessageTemplate"
+                            " (not a string).")
+        try:
+            jsonschema.Draft7Validator.check_schema(value)
+            # Draft7Validator is hardcoded, because _LATEST_VERSION is
+            # non-public in jsonschema and we also perhaps do not want to
+            # upgrade automatically.
+        except jsonschema.exceptions.SchemaError:
+            raise TypeError(f"'{value}' is not a valid value in"
+                            " MessageTemplate (not a valid JSON schema).")
+        super().__setitem__(key, value)
+
+    def update(self, *args, **kwargs) -> None:
+        """Override update to use validity checks.
+
+        >>> t = MessageTemplate()
+        >>> t.update({'key 1': {'const': 'value'},
+        ...           'key 2': {'type': 'string'},
+        ...           'key 3': {'type': 'object',
+        ...                     'properties': {'key 1': {'type': 'number'},
+        ...                                    'key 2': True}}})
+        >>> print(t)  # doctest: +NORMALIZE_WHITESPACE
+        {'key 1': {'const': 'value'}, 'key 2': {'type': 'string'},
+         'key 3': {'type': 'object',
+                   'properties': {'key 1': {'type': 'number'},
+                                  'key 2': True}}}
+        >>> t.update({42: {'const': 'int key'}})
+        Traceback (most recent call last):
+          ...
+        TypeError: '42' is not a valid key in MessageTemplate (not a string).
+        >>> t.update({'key': 'schema'})  # doctest: +NORMALIZE_WHITESPACE
+        Traceback (most recent call last):
+          ...
+        TypeError: 'schema' is not a valid value in MessageTemplate
+        (not a valid JSON schema).
+
+        This is also used in __init__:
+        >>> t = MessageTemplate({'key 1': {'const': 'value'},
+        ...                      'key 2': {'type': 'string'},
+        ...                      'key 3': {'type': 'object',
+        ...                                'properties': {
+        ...                                    'key 1': {'type': 'number'},
+        ...                                    'key 2': True}}})
+        >>> print(t)  # doctest: +NORMALIZE_WHITESPACE
+        {'key 1': {'const': 'value'}, 'key 2': {'type': 'string'},
+         'key 3': {'type': 'object',
+                   'properties': {'key 1': {'type': 'number'},
+                                  'key 2': True}}}
+        >>> t = MessageTemplate({42: {'const': 'int key'}})
+        Traceback (most recent call last):
+          ...
+        TypeError: '42' is not a valid key in MessageTemplate (not a string).
+        >>> t = MessageTemplate({'key': 'schema'})
+        ... # doctest: +NORMALIZE_WHITESPACE
+        Traceback (most recent call last):
+          ...
+        TypeError: 'schema' is not a valid value in MessageTemplate
+        (not a valid JSON schema).
+        """
+        if args:
+            if len(args) > 1:
+                raise TypeError("update expected at most 1 argument,"
+                                f" got {len(args)}")
+            other = dict(args[0])
+            for key in other:
+                self[key] = other[key]
+        for key in kwargs:
+            self[key] = kwargs[key]
+
+    def setdefault(self, key: str, value: JSONSchema = None) -> JSONSchema:
+        """Override setdefault to use validity checks.
+
+        >>> t = MessageTemplate()
+        >>> t.setdefault('key 1', {'const': 'value'})
+        {'const': 'value'}
+        >>> t.setdefault('key 2', {'type': 'string'})
+        {'type': 'string'}
+        >>> t.setdefault('key 3', {'type': 'object',
+        ...                        'properties': {'key 1': {'type': 'number'},
+        ...                                       'key 2': True}})
+        ... # doctest: +NORMALIZE_WHITESPACE
+        {'type': 'object',
+                   'properties': {'key 1': {'type': 'number'},
+                                  'key 2': True}}
+        >>> t.setdefault(42, {'const': 'int key'})
+        Traceback (most recent call last):
+          ...
+        TypeError: '42' is not a valid key in MessageTemplate (not a string).
+        >>> t.setdefault('key', 'schema')  # doctest: +NORMALIZE_WHITESPACE
+        Traceback (most recent call last):
+          ...
+        TypeError: 'schema' is not a valid value in MessageTemplate
+        (not a valid JSON schema).
+
+        But __setitem__ is not called if the key is already present:
+        >>> t.setdefault('key 1', 'schema')
+        {'const': 'value'}
+        """
+        if key not in self:
+            if value is not None:
+                self[key] = value
+            else:
+                self[key] = True
+        return self[key]
+
+    def check(self, message: Message) -> bool:
+        """Check message against this template.
+
+        Constant values have to match exactly:
+        >>> t = MessageTemplate({'key': {'const': 'value'}})
+        >>> t.check(Message('Example Sender', {'key': 'value'}))
+        True
+        >>> t.check(Message('Example Sender', {'key': 'other value'}))
+        False
+
+        But for integers, floats with the same value are also valid:
+        >>> t = MessageTemplate({'key': {'const': 42}})
+        >>> t.check(Message('Example Sender', {'key': 42}))
+        True
+        >>> t.check(Message('Example Sender', {'key': 42.0}))
+        True
+
+        Type integer is valid for floats with zero fractional part, but
+        not by floats with non-zero fractional part:
+        >>> t = MessageTemplate({'key': {'type': 'integer'}})
+        >>> t.check(Message('Example Sender', {'key': 42}))
+        True
+        >>> t.check(Message('Example Sender', {'key': 42.0}))
+        True
+        >>> t.check(Message('Example Sender', {'key': 42.42}))
+        False
+
+        Type number is valid for arbitrary ints or floats:
+        >>> t = MessageTemplate({'key': {'type': 'number'}})
+        >>> t.check(Message('Example Sender', {'key': 42}))
+        True
+        >>> t.check(Message('Example Sender', {'key': 42.42}))
+        True
+
+        All keys in template have to be present in message:
+        >>> t = MessageTemplate({'key 1': {'const': 'value'},
+        ...                      'key 2': {'type': 'string'},
+        ...                      'key 3': {'type': 'object',
+        ...                                'properties': {
+        ...                                    'key 1': {'type': 'number'},
+        ...                                    'key 2': True,
+        ...                                    'key 3': False}}})
+        >>> t.check(Message('Example Sender',
+        ...                 {'key 1': 'value', 'key 2': 'some string'}))
+        False
+
+        But for nested objects their properties do not necessarily have
+        to be present:
+        >>> t.check(Message('Example Sender',
+        ...                 {'key 1': 'value', 'key 2': 'some string',
+        ...                  'key 3': {'key 1': 42}}))
+        True
+
+        Schema True matches everything (even None):
+        >>> t.check(Message('Example Sender',
+        ...                 {'key 1': 'value', 'key 2': 'some string',
+        ...                  'key 3': {'key 2': None}}))
+        True
+
+        Schema False matches nothing:
+        >>> t.check(Message('Example Sender',
+        ...                 {'key 1': 'value', 'key 2': 'some string',
+        ...                  'key 3': {'key 3': True}}))
+        False
+
+        Message is valid for the constant template created from it:
+        >>> m = Message('Example Sender', {'dict': {'int': 42, 'float': 42.42},
+        ...                                'list': [None, True, 'string']})
+        >>> t = MessageTemplate.from_message(m)
+        >>> t.check(m)
+        True
+        """
+        for key in self:
+            if key not in message:
+                return False
+            else:
+                validator = jsonschema.Draft7Validator(self[key])
+                for error in validator.iter_errors(message[key]):
+                    return False
+        return True
+
+
+class MessageTemplateRegistry:
+    """Manage a collection of message templates with registered clients.
+
+    A new MessageTemplateRegistry is created by:
+    >>> r = MessageTemplateRegistry()
+
+    Client names (strings) can be registered for message templates, which
+    are mappings from keys to JSON schemas:
+    >>> r.insert({'k1': {'const': 'v1'}}, 'C 1')
+
+    The check function checks if the templates registered for a client
+    match a given message:
+    >>> for m in [{'k1': 'v1', 'k2': 'v1'}, {'k1': 'v1', 'k2': 2},
+    ...           {'k1': 'v2', 'k2': 'v1'}, {'k1': 'v2', 'k2': 2}]:
+    ...     print(f"{m}: {r.check('C 1', m)}")
+    {'k1': 'v1', 'k2': 'v1'}: True
+    {'k1': 'v1', 'k2': 2}: True
+    {'k1': 'v2', 'k2': 'v1'}: False
+    {'k1': 'v2', 'k2': 2}: False
+
+    Clients can be registered for values validating against arbitrary JSON
+    schemas, e.g. all values of a certain type:
+    >>> r.insert({'k1': {'const': 'v2'}, 'k2': {'type': 'string'}}, 'C 2')
+    >>> for m in [{'k1': 'v1', 'k2': 'v1'}, {'k1': 'v1', 'k2': 2},
+    ...           {'k1': 'v2', 'k2': 'v1'}, {'k1': 'v2', 'k2': 2}]:
+    ...     print(f"{m}: {r.check('C 2', m)}")
+    {'k1': 'v1', 'k2': 'v1'}: False
+    {'k1': 'v1', 'k2': 2}: False
+    {'k1': 'v2', 'k2': 'v1'}: True
+    {'k1': 'v2', 'k2': 2}: False
+    >>> r.insert({'k1': {'const': 'v2'}, 'k2': {'type': 'integer'}}, 'C 3')
+    >>> for m in [{'k1': 'v1', 'k2': 'v1'}, {'k1': 'v1', 'k2': 2},
+    ...           {'k1': 'v2', 'k2': 'v1'}, {'k1': 'v2', 'k2': 2}]:
+    ...     print(f"{m}: {r.check('C 3', m)}")
+    {'k1': 'v1', 'k2': 'v1'}: False
+    {'k1': 'v1', 'k2': 2}: False
+    {'k1': 'v2', 'k2': 'v1'}: False
+    {'k1': 'v2', 'k2': 2}: True
+
+    The order of key-value pairs does not have to match the order in the
+    messages and keys can be left out:
+    >>> r.insert({'k2': {'const': 2}}, 'C 4')
+    >>> for m in [{'k1': 'v1', 'k2': 'v1'}, {'k1': 'v1', 'k2': 2},
+    ...           {'k1': 'v2', 'k2': 'v1'}, {'k1': 'v2', 'k2': 2}]:
+    ...     print(f"{m}: {r.check('C 4', m)}")
+    {'k1': 'v1', 'k2': 'v1'}: False
+    {'k1': 'v1', 'k2': 2}: True
+    {'k1': 'v2', 'k2': 'v1'}: False
+    {'k1': 'v2', 'k2': 2}: True
+
+    A registration for an empty template matches all messages:
+    >>> r.insert({}, 'C 5')
+    >>> for m in [{'k1': 'v1', 'k2': 'v1'}, {'k1': 'v1', 'k2': 2},
+    ...           {'k1': 'v2', 'k2': 'v1'}, {'k1': 'v2', 'k2': 2}]:
+    ...     print(f"{m}: {r.check('C 5', m)}")
+    {'k1': 'v1', 'k2': 'v1'}: True
+    {'k1': 'v1', 'k2': 2}: True
+    {'k1': 'v2', 'k2': 'v1'}: True
+    {'k1': 'v2', 'k2': 2}: True
+
+    A client can be registered for multiple templates:
+    >>> r.insert({'k1': {'const': 'v1'}}, 'C 6')
+    >>> r.insert({'k2': {'const': 'v1'}}, 'C 6')
+    >>> for m in [{'k1': 'v1', 'k2': 'v1'}, {'k1': 'v1', 'k2': 2},
+    ...           {'k1': 'v2', 'k2': 'v1'}, {'k1': 'v2', 'k2': 2}]:
+    ...     print(f"{m}: {r.check('C 6', m)}")
+    {'k1': 'v1', 'k2': 'v1'}: True
+    {'k1': 'v1', 'k2': 2}: True
+    {'k1': 'v2', 'k2': 'v1'}: True
+    {'k1': 'v2', 'k2': 2}: False
+
+    Clients can be deregistered again (the result is False if the registry
+    is empty after the deletion):
+    >>> r.insert({'k1': {'const': 'v1'}}, 'C 7')
+    >>> r.delete('C 7')
+    True
+    >>> for m in [{'k1': 'v1', 'k2': 'v1'}, {'k1': 'v1', 'k2': 2},
+    ...           {'k1': 'v2', 'k2': 'v1'}, {'k1': 'v2', 'k2': 2}]:
+    ...     print(f"{m}: {r.check('C 7', m)}")
+    {'k1': 'v1', 'k2': 'v1'}: False
+    {'k1': 'v1', 'k2': 2}: False
+    {'k1': 'v2', 'k2': 'v1'}: False
+    {'k1': 'v2', 'k2': 2}: False
+
+    The get function returns all clients with registered templates matching
+    a given message:
+    >>> for m in [{'k1': 'v1', 'k2': 'v1'}, {'k1': 'v1', 'k2': 2},
+    ...           {'k1': 'v2', 'k2': 'v1'}, {'k1': 'v2', 'k2': 2}]:
+    ...     print(f"{m}: {r.get(m)}")
+    {'k1': 'v1', 'k2': 'v1'}: ['C 5', 'C 1', 'C 6']
+    {'k1': 'v1', 'k2': 2}: ['C 5', 'C 1', 'C 6', 'C 4']
+    {'k1': 'v2', 'k2': 'v1'}: ['C 5', 'C 2', 'C 6']
+    {'k1': 'v2', 'k2': 2}: ['C 5', 'C 3', 'C 4']
+
+    The get_templates function returns all templates for a given client:
+    >>> for c in ['C 1', 'C 2', 'C 3', 'C 4', 'C 5', 'C 6']:
+    ...     print(f"{c}: {r.get_templates(c)}")
+    C 1: [{'k1': {'const': 'v1'}}]
+    C 2: [{'k1': {'const': 'v2'}, 'k2': {'type': 'string'}}]
+    C 3: [{'k1': {'const': 'v2'}, 'k2': {'type': 'integer'}}]
+    C 4: [{'k2': {'const': 2}}]
+    C 5: [{}]
+    C 6: [{'k1': {'const': 'v1'}}, {'k2': {'const': 'v1'}}]
+    """
+
+    def __init__(self) -> None:
+        """Initialise an empty registry.
+
+        >>> r = MessageTemplateRegistry()
+        """
+        self._clients: List[str] = []
+        self._children: Dict[str, Dict[str, MessageTemplateRegistry]] = {}
+
+    def insert(self, template: MessageTemplate, client: str) -> None:
+        """Register a client for a template.
+
+        >>> r = MessageTemplateRegistry()
+        >>> r.insert({'k1': {'const': 'v1'}, 'k2': {'const': 'v1'}}, 'C 1')
+        >>> r.insert({'k1': {'const': 'v1'}, 'k2': {'const': 'v2'}}, 'C 2')
+        >>> r.insert({'k1': {'const': 'v2'}, 'k2': {'const': 'v1'}}, 'C 3')
+        >>> r.insert({'k1': {'const': 'v2'}, 'k2': {'const': 'v2'}}, 'C 4')
+        >>> r.insert({}, 'C 5')
+
+        Implementation details:
+        -----------------------
+        The tree nodes on the way to a registered object are used/created
+        in the order given in the message template, which can be used to
+        design more efficient lookups (e.g., putting rarer key-value pairs
+        earlier in the template).
+        >>> r._clients
+        ['C 5']
+        >>> r._children.keys()
+        dict_keys(['k1'])
+        >>> r._children['k1'].keys()
+        dict_keys(['{"const": "v1"}', '{"const": "v2"}'])
+        >>> r._children['k1']['{"const": "v1"}']._clients
+        []
+        >>> r._children['k1']['{"const": "v1"}']._children.keys()
+        dict_keys(['k2'])
+        >>> r._children['k1']['{"const": "v1"}']._children['k2'].keys()
+        dict_keys(['{"const": "v1"}', '{"const": "v2"}'])
+        >>> (r._children['k1']['{"const": "v1"}']
+        ...   ._children['k2']['{"const": "v1"}'])._clients
+        ['C 1']
+        >>> (r._children['k1']['{"const": "v1"}']
+        ...   ._children['k2']['{"const": "v1"}'])._children.keys()
+        dict_keys([])
+        >>> (r._children['k1']['{"const": "v1"}']
+        ...   ._children['k2']['{"const": "v2"}'])._clients
+        ['C 2']
+        >>> (r._children['k1']['{"const": "v1"}']
+        ...   ._children['k2']['{"const": "v2"}'])._children.keys()
+        dict_keys([])
+        >>> r._children['k1']['{"const": "v2"}']._clients
+        []
+        >>> r._children['k1']['{"const": "v2"}']._children.keys()
+        dict_keys(['k2'])
+        >>> r._children['k1']['{"const": "v2"}']._children['k2'].keys()
+        dict_keys(['{"const": "v1"}', '{"const": "v2"}'])
+        >>> (r._children['k1']['{"const": "v2"}']
+        ...   ._children['k2']['{"const": "v1"}'])._clients
+        ['C 3']
+        >>> (r._children['k1']['{"const": "v2"}']
+        ...   ._children['k2']['{"const": "v1"}'])._children.keys()
+        dict_keys([])
+        >>> (r._children['k1']['{"const": "v2"}']
+        ...   ._children['k2']['{"const": "v2"}'])._clients
+        ['C 4']
+        >>> (r._children['k1']['{"const": "v2"}']
+        ...   ._children['k2']['{"const": "v2"}'])._children.keys()
+        dict_keys([])
+        """
+        if not template:
+            self._clients.append(client)
+        else:
+            key, schema = next(iter(template.items()))
+            schema_string = json.dumps(schema)
+            reduced_template = MessageTemplate({k: template[k]
+                                                for k in template
+                                                if k != key})
+            if key not in self._children:
+                self._children[key] = {}
+            if schema_string not in self._children[key]:
+                self._children[key][schema_string] = MessageTemplateRegistry()
+            self._children[key][schema_string].insert(reduced_template, client)
+
+    def delete(self, client: str) -> bool:
+        """Unregister a client from all templates.
+
+        >>> r = MessageTemplateRegistry()
+        >>> r.insert({'k1': {'const': 'v1'}, 'k2': {'const': 'v1'}}, 'C 1')
+        >>> r.insert({'k1': {'const': 'v1'}, 'k2': {'const': 'v2'}}, 'C 2')
+        >>> r.insert({'k1': {'const': 'v2'}, 'k2': {'const': 'v1'}}, 'C 3')
+        >>> r.insert({'k1': {'const': 'v2'}, 'k2': {'const': 'v2'}}, 'C 4')
+        >>> r.insert({}, 'C 5')
+        >>> r.delete('C 3')
+        True
+        >>> r.delete('C 4')
+        True
+
+        Implementation details:
+        -----------------------
+        If parts of the tree become superfluous by the deletion of the
+        client, they are also completely removed to reduce the lookup
+        effort and keep the tree clean.
+        >>> r._clients
+        ['C 5']
+        >>> r._children.keys()
+        dict_keys(['k1'])
+        >>> r._children['k1'].keys()
+        dict_keys(['{"const": "v1"}'])
+        >>> r._children['k1']['{"const": "v1"}']._clients
+        []
+        >>> r._children['k1']['{"const": "v1"}']._children.keys()
+        dict_keys(['k2'])
+        >>> r._children['k1']['{"const": "v1"}']._children['k2'].keys()
+        dict_keys(['{"const": "v1"}', '{"const": "v2"}'])
+        >>> (r._children['k1']['{"const": "v1"}']
+        ...   ._children['k2']['{"const": "v1"}'])._clients
+        ['C 1']
+        >>> (r._children['k1']['{"const": "v1"}']
+        ...   ._children['k2']['{"const": "v1"}'])._children.keys()
+        dict_keys([])
+        >>> (r._children['k1']['{"const": "v1"}']
+        ...   ._children['k2']['{"const": "v2"}'])._clients
+        ['C 2']
+        >>> (r._children['k1']['{"const": "v1"}']
+        ...   ._children['k2']['{"const": "v2"}'])._children.keys()
+        dict_keys([])
+        """
+        self._clients = [c for c in self._clients if c != client]
+        new_children: Dict[str, Dict[str, MessageTemplateRegistry]] = {}
+        for key in self._children:
+            new_children[key] = {}
+            for schema in self._children[key]:
+                if self._children[key][schema].delete(client):
+                    new_children[key][schema] = self._children[key][schema]
+            if not new_children[key]:
+                del new_children[key]
+        self._children = new_children
+        if self._clients or self._children:
+            return True
+        return False
+
+    def check(self, client: str, message: Message) -> bool:
+        """Get if a client has a registered template matching a message.
+
+        >>> r = MessageTemplateRegistry()
+        >>> r.insert({'k1': {'const': 'v1'}}, 'Client 1')
+        >>> for m in [{'k1': 'v1', 'k2': 'v1'}, {'k1': 'v1', 'k2': 'v2'},
+        ...           {'k1': 'v2', 'k2': 'v1'}, {'k1': 'v2', 'k2': 'v2'}]:
+        ...     print(f"{m}: {r.check('Client 1', m)}")
+        {'k1': 'v1', 'k2': 'v1'}: True
+        {'k1': 'v1', 'k2': 'v2'}: True
+        {'k1': 'v2', 'k2': 'v1'}: False
+        {'k1': 'v2', 'k2': 'v2'}: False
+        >>> r.insert({'k2': {'const': 'v2'}}, 'Client 2')
+        >>> for m in [{'k1': 'v1', 'k2': 'v1'}, {'k1': 'v1', 'k2': 'v2'},
+        ...           {'k1': 'v2', 'k2': 'v1'}, {'k1': 'v2', 'k2': 'v2'}]:
+        ...     print(f"{m}: {r.check('Client 2', m)}")
+        {'k1': 'v1', 'k2': 'v1'}: False
+        {'k1': 'v1', 'k2': 'v2'}: True
+        {'k1': 'v2', 'k2': 'v1'}: False
+        {'k1': 'v2', 'k2': 'v2'}: True
+        """
+        if client in self._clients:
+            return True
+        for key in self._children:
+            if key in message:
+                for schema_string in self._children[key]:
+                    schema = json.loads(schema_string)
+                    validator = jsonschema.Draft7Validator(schema)
+                    validated = True
+                    for error in validator.iter_errors(message[key]):
+                        validated = False
+                    if validated:
+                        child = self._children[key][schema_string]
+                        if child.check(client, message):
+                            return True
+        return False
+
+    def get(self, message: Message) -> List[str]:
+        """Get all clients registered for templates matching a message.
+
+        >>> r = MessageTemplateRegistry()
+        >>> r.insert({'k1': {'const': 'v1'}}, 'Client 1')
+        >>> r.insert({'k2': {'const': 'v2'}}, 'Client 2')
+        >>> for m in [{'k1': 'v1', 'k2': 'v1'}, {'k1': 'v1', 'k2': 'v2'},
+        ...           {'k1': 'v2', 'k2': 'v1'}, {'k1': 'v2', 'k2': 'v2'}]:
+        ...     print(f"{m}: {r.get(m)}")
+        {'k1': 'v1', 'k2': 'v1'}: ['Client 1']
+        {'k1': 'v1', 'k2': 'v2'}: ['Client 1', 'Client 2']
+        {'k1': 'v2', 'k2': 'v1'}: []
+        {'k1': 'v2', 'k2': 'v2'}: ['Client 2']
+        """
+        result = []
+        for client in self._clients:
+            if client not in result:
+                result.append(client)
+        for key in self._children:
+            if key in message:
+                for schema_string in self._children[key]:
+                    schema = json.loads(schema_string)
+                    validator = jsonschema.Draft7Validator(schema)
+                    validated = True
+                    for error in validator.iter_errors(message[key]):
+                        validated = False
+                    if validated:
+                        child = self._children[key][schema_string]
+                        for client in child.get(message):
+                            if client not in result:
+                                result.append(client)
+        return result
+
+    def get_templates(self, client: str) -> List[MessageTemplate]:
+        """Get all templates for a client.
+
+        >>> r = MessageTemplateRegistry()
+        >>> r.insert({'k1': {'const': 'v1'}}, 'Client 1')
+        >>> r.get_templates('Client 1')
+        [{'k1': {'const': 'v1'}}]
+        >>> r.insert({'k1': {'const': 'v2'},
+        ...           'k2': {'type': 'string'}}, 'Client 2')
+        >>> r.get_templates('Client 2')
+        [{'k1': {'const': 'v2'}, 'k2': {'type': 'string'}}]
+        >>> r.insert({'k1': {'const': 'v2'},
+        ...           'k2': {'type': 'integer'}}, 'Client 3')
+        >>> r.get_templates('Client 3')
+        [{'k1': {'const': 'v2'}, 'k2': {'type': 'integer'}}]
+        >>> r.insert({'k2': {'const': 2}}, 'Client 4')
+        >>> r.get_templates('Client 4')
+        [{'k2': {'const': 2}}]
+        >>> r.insert({}, 'Client 5')
+        >>> r.get_templates('Client 5')
+        [{}]
+        >>> r.insert({'k1': {'const': 'v1'}}, 'Client 6')
+        >>> r.insert({'k2': {'const': 'v1'}}, 'Client 6')
+        >>> r.get_templates('Client 6')
+        [{'k1': {'const': 'v1'}}, {'k2': {'const': 'v1'}}]
+        """
+        result = []
+        if client in self._clients:
+            result.append(MessageTemplate())
+        for key in self._children:
+            for schema_string in self._children[key]:
+                schema = json.loads(schema_string)
+                child = self._children[key][schema_string]
+                for template in child.get_templates(client):
+                    current = MessageTemplate({key: schema})
+                    current.update(template)
+                    result.append(current)
+        return result
+
+
+class BusException(Exception):
+    """Raise for errors in using message bus."""
+
+
+class MessageBus:
+    """Provide an asynchronous message bus.
+
+    The bus executes asynchronous callbacks for all messages to be received
+    by a client. We use a simple callback printing the message in all
+    examples:
+    >>> def callback_for_receiver(receiver):
+    ...     print(f"Creating callback for {receiver}.")
+    ...     async def callback(message):
+    ...         print(f"{receiver}: {message}")
+    ...     return callback
+
+    Clients can be registered at the bus with a name, lists of message
+    templates they want to use for sending and receiving and a callback
+    function for receiving. An empty list of templates means that the
+    client does not want to send or receive any messages, respectively.
+    A list with an empty template means that it wants to send arbitrary
+    or receive all messages, respectively:
+    >>> async def setup(bus):
+    ...     print("Setting up.")
+    ...     bus.register('Logger', 'Test Plugin',
+    ...                  [],
+    ...                  [{}],
+    ...                  callback_for_receiver('Logger'))
+    ...     bus.register('Client 1', 'Test Plugin',
+    ...                  [{'k1': {'type': 'string'}}],
+    ...                  [{'target': {'const': 'Client 1'}}],
+    ...                  callback_for_receiver('Client 1'))
+    ...     bus.register('Client 2', 'Test Plugin',
+    ...                  [{}],
+    ...                  [{'target': {'const': 'Client 2'}}],
+    ...                  callback_for_receiver('Client 2'))
+
+    The bus itself is addressed by the empty string. It sends messages for
+    each registration and deregestration of a client with a key 'event' and
+    a value of 'registered' or 'unregistered', a key 'client' with the
+    client's name as value and for registrations also keys 'sends' and
+    'receives' with all templates registered for the client for sending and
+    receiving.
+
+    Clients can send to the bus with the send function. Each message has to
+    declare a sender. The send templates of that sender are checked for a
+    template matching the message. We cannot prevent arbitrary code from
+    impersonating any sender, but this should only be done in debugging or
+    management situations.
+
+    Messages that are intended for a specific client by convention have a
+    key 'target' with the target client's name as value. Such messages are
+    often commands to the client to do something, which is by convention
+    indicated by a key 'command' with a value that indicates what should be
+    done.
+
+    The bus, for example, reacts to a message with 'target': '' and
+    'command': 'get clients' by sending one message for each currently
+    registered with complete information about its registered send and
+    receive templates.
+
+    >>> async def send(bus):
+    ...     print("Sending messages.")
+    ...     await bus.send({'sender': 'Client 1', 'k1': 'Test'})
+    ...     await bus.send({'sender': 'Client 2', 'target': 'Client 1'})
+    ...     await bus.send({'sender': '', 'target': '',
+    ...                     'command': 'get clients'})
+
+    The run function executes the message bus forever. If we want to stop
+    it, we have to explicitly cancel the task:
+    >>> async def main():
+    ...     bus = MessageBus()
+    ...     await setup(bus)
+    ...     bus_task = asyncio.create_task(bus.run())
+    ...     await send(bus)
+    ...     await asyncio.sleep(0)
+    ...     bus_task.cancel()
+    >>> asyncio.run(main())  # doctest: +NORMALIZE_WHITESPACE
+    Setting up.
+    Creating callback for Logger.
+    Creating callback for Client 1.
+    Creating callback for Client 2.
+    Sending messages.
+    Logger: {'sender': '', 'event': 'registered',
+             'client': 'Logger', 'plugin': 'Test Plugin',
+             'sends': [], 'receives': [{}]}
+    Logger: {'sender': '', 'event': 'registered',
+             'client': 'Client 1', 'plugin': 'Test Plugin',
+             'sends': [{'k1': {'type': 'string'}}],
+             'receives': [{'target': {'const': 'Client 1'}}]}
+    Logger: {'sender': '', 'event': 'registered',
+             'client': 'Client 2', 'plugin': 'Test Plugin',
+             'sends': [{}], 'receives': [{'target': {'const': 'Client 2'}}]}
+    Logger: {'sender': 'Client 1', 'k1': 'Test'}
+    Logger: {'sender': 'Client 2', 'target': 'Client 1'}
+    Client 1: {'sender': 'Client 2', 'target': 'Client 1'}
+    Logger: {'sender': '', 'target': '', 'command': 'get clients'}
+    Logger: {'sender': '', 'client': 'Logger', 'plugin': 'Test Plugin',
+             'sends': [], 'receives': [{}]}
+    Logger: {'sender': '', 'client': 'Client 1', 'plugin': 'Test Plugin',
+             'sends': [{'k1': {'type': 'string'}}],
+             'receives': [{'target': {'const': 'Client 1'}}]}
+    Logger: {'sender': '', 'client': 'Client 2', 'plugin': 'Test Plugin',
+             'sends': [{}], 'receives': [{'target': {'const': 'Client 2'}}]}
+    """
+
+    def __init__(self) -> None:
+        """Initialise a new bus without clients.
+
+        >>> async def main():
+        ...     bus = MessageBus()
+        >>> asyncio.run(main())
+        """
+        self._queue: asyncio.Queue = asyncio.Queue()
+        self._plugins: Dict[str, str] = {}
+        self._send_reg: MessageTemplateRegistry = MessageTemplateRegistry()
+        self._recv_reg: MessageTemplateRegistry = MessageTemplateRegistry()
+        self._callbacks: Dict[str, MessageCallback] = {}
+
+    def register(self, client: str, plugin: str,
+                 sends: Iterable[MessageTemplate],
+                 receives: Iterable[MessageTemplate],
+                 callback: MessageCallback) -> None:
+        """Register a client at the message bus.
+
+        >>> async def callback(message):
+        ...     print(message)
+        >>> async def main():
+        ...     bus = MessageBus()
+        ...     bus.register('Logger', 'Test Plugin',
+        ...                  [],    # send nothing
+        ...                  [{}],  # receive everything
+        ...                  callback)
+        ...     bus.register('Client 1', 'Test Plugin',
+        ...                  [{'k1': {'type': 'string'}}],
+        ...                      # send with key 'k1' and string value
+        ...                  [{'target': {'const': 'Client 1'}}],
+        ...                      # receive for this client
+        ...                  callback)
+        ...     bus.register('Client 2', 'Test Plugin',
+        ...                  [{}],  # send arbitrary
+        ...                  [{'target': {'const': 'Client 2'}}],
+        ...                      # receive for this client
+        ...                  callback)
+        >>> asyncio.run(main())
+        """
+        if not client:
+            raise BusException("Client name is not allowed to be empty.")
+        if client in self._plugins:
+            raise BusException(f"Client '{client}' already registered"
+                               " at message bus.")
+        event = Message('')
+        event['event'] = 'registered'
+        event['client'] = client
+        self._plugins[client] = plugin
+        event['plugin'] = plugin
+        for template in sends:
+            self._send_reg.insert(template, client)
+        event['sends'] = self._send_reg.get_templates(client)
+        for template in receives:
+            self._recv_reg.insert(template, client)
+        event['receives'] = self._recv_reg.get_templates(client)
+        self._callbacks[client] = callback
+        self._queue.put_nowait(event)
+
+    def unregister(self, client: str) -> None:
+        """Unregister a client from the message bus.
+
+        >>> async def callback(message):
+        ...     print(message)
+        >>> async def main():
+        ...     bus = MessageBus()
+        ...     bus.register('Client 1', 'Test Plugin',
+        ...                  [{'k1': {'type': 'string'}}],
+        ...                  [{'target': {'const': 'Client 1'}}],
+        ...                  callback)
+        ...     bus.unregister('Client 1')
+        >>> asyncio.run(main())
+        """
+        if client not in self._plugins:
+            return
+        event = Message('')
+        event['event'] = 'unregistered'
+        event['client'] = client
+        del self._plugins[client]
+        self._send_reg.delete(client)
+        self._recv_reg.delete(client)
+        if client in self._callbacks:
+            del self._callbacks[client]
+        self._queue.put_nowait(event)
+
+    async def run(self) -> None:
+        """Run the message bus forever.
+
+        >>> async def main():
+        ...     bus = MessageBus()
+        ...     bus_task = asyncio.create_task(bus.run())
+        ...     bus_task.cancel()
+        >>> asyncio.run(main())
+        """
+        while True:
+            message = await self._queue.get()
+            if ('target' in message and
+                    message['target'] == '' and
+                    'command' in message and
+                    message['command'] == 'get clients'):
+                for client in self._plugins:
+                    answer = Message('')
+                    answer['client'] = client
+                    answer['plugin'] = self._plugins[client]
+                    answer['sends'] = self._send_reg.get_templates(client)
+                    answer['receives'] = self._recv_reg.get_templates(client)
+                    await self._queue.put(answer)
+            for client in self._recv_reg.get(message):
+                await self._callbacks[client](message)
+            self._queue.task_done()
+
+    async def send(self, message: Message) -> None:
+        """Send a message to the message bus.
+
+        >>> async def callback(message):
+        ...     print(f"Got: {message}")
+        >>> async def main():
+        ...     bus = MessageBus()
+        ...     bus.register('Client 1', 'Test Plugin',
+        ...                  [{'k1': {'type': 'string'}}],
+        ...                  [{'target': {'const': 'Client 1'}}],
+        ...                  callback)
+        ...     bus.register('Client 2', 'Test Plugin',
+        ...                  [{}],
+        ...                  [{'target': {'const': 'Client 2'}}],
+        ...                  callback)
+        ...     bus_task = asyncio.create_task(bus.run())
+        ...     await bus.send({'sender': 'Client 1', 'target': 'Client 2',
+        ...                     'k1': 'Test'})
+        ...     await bus.send({'sender': 'Client 2', 'target': 'Client 1'})
+        ...     try:
+        ...         await bus.send({'sender': 'Client 1', 'target': 'Client 2',
+        ...                         'k1': 42})
+        ...     except BusException as e:
+        ...         print(e)
+        ...     await asyncio.sleep(0)
+        ...     bus_task.cancel()
+        >>> asyncio.run(main())  # doctest: +NORMALIZE_WHITESPACE
+        Message '{'sender': 'Client 1', 'target': 'Client 2', 'k1': 42}'
+        not allowed for sender 'Client 1'.
+        Got: {'sender': 'Client 1', 'target': 'Client 2', 'k1': 'Test'}
+        Got: {'sender': 'Client 2', 'target': 'Client 1'}
+        """
+        assert isinstance(message['sender'], str)
+        sender = message['sender']
+        if sender:
+            if not self._send_reg.check(sender, message):
+                raise BusException(f"Message '{message}' not allowed for"
+                                   f" sender '{sender}'.")
+        await self._queue.put(message)
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class Message +(sender: str, init: Dict[str, Union[NoneType, str, int, float, bool, Dict[str, Any], List[Any]]] = None) +
+
+

Define arbitrary message.

+

Messages are dictionaries with string keys and values that are strings, +integers, floats, Booleans, dictionaries that recursively have string +keys and values of any of these types, or lists with elements that have +any of these types. These constraints are checked when setting key-value +pairs of the message.

+

A message has to have a sender, which is set by the constructor:

+
>>> m = Message('Example sender')
+>>> print(m)
+{'sender': 'Example sender'}
+
+

A dictionary can be given to the constructor:

+
>>> m = Message('Example sender', {'key 1': 'value 1', 'key 2': 'value 2'})
+>>> print(m)
+{'sender': 'Example sender', 'key 1': 'value 1', 'key 2': 'value 2'}
+
+

Or the message can be modified after construction:

+
>>> m = Message('Example sender', {'key 1': 'value 1'})
+>>> m['key 2'] = 'value 2'
+>>> print(m)
+{'sender': 'Example sender', 'key 1': 'value 1', 'key 2': 'value 2'}
+
+

Initialise message.

+

Message is initialised with given sender and possibly given +key-value pairs:

+
>>> m = Message('Example sender')
+>>> print(m)
+{'sender': 'Example sender'}
+>>> m = Message('Example sender', {'key 1': 'value 1'})
+>>> print(m)
+{'sender': 'Example sender', 'key 1': 'value 1'}
+
+

The sender can be overwritten by the key-value pairs:

+
>>> m = Message('Example sender', {'sender': 'Another sender'})
+>>> print(m)
+{'sender': 'Another sender'}
+
+
+ +Expand source code + +
class Message(Dict[str, MessageValue]):
+    """Define arbitrary message.
+
+    Messages are dictionaries with string keys and values that are strings,
+    integers, floats, Booleans, dictionaries that recursively have string
+    keys and values of any of these types, or lists with elements that have
+    any of these types. These constraints are checked when setting key-value
+    pairs of the message.
+
+    A message has to have a sender, which is set by the constructor:
+    >>> m = Message('Example sender')
+    >>> print(m)
+    {'sender': 'Example sender'}
+
+    A dictionary can be given to the constructor:
+    >>> m = Message('Example sender', {'key 1': 'value 1', 'key 2': 'value 2'})
+    >>> print(m)
+    {'sender': 'Example sender', 'key 1': 'value 1', 'key 2': 'value 2'}
+
+    Or the message can be modified after construction:
+    >>> m = Message('Example sender', {'key 1': 'value 1'})
+    >>> m['key 2'] = 'value 2'
+    >>> print(m)
+    {'sender': 'Example sender', 'key 1': 'value 1', 'key 2': 'value 2'}
+    """
+
+    def __init__(self, sender: str,
+                 init: Dict[str, MessageValue] = None) -> None:
+        """Initialise message.
+
+        Message is initialised with given sender and possibly given
+        key-value pairs:
+        >>> m = Message('Example sender')
+        >>> print(m)
+        {'sender': 'Example sender'}
+        >>> m = Message('Example sender', {'key 1': 'value 1'})
+        >>> print(m)
+        {'sender': 'Example sender', 'key 1': 'value 1'}
+
+        The sender can be overwritten by the key-value pairs:
+        >>> m = Message('Example sender', {'sender': 'Another sender'})
+        >>> print(m)
+        {'sender': 'Another sender'}
+        """
+        if not isinstance(sender, str):
+            raise TypeError(f"'{sender}' is not a valid sender name"
+                            " (not a string).")
+        self['sender'] = sender
+        if init is not None:
+            self.update(init)
+
+    @staticmethod
+    def check_value(value: MessageValue) -> bool:
+        """Check recursively if a given value is valid.
+
+        None, strings, integers, floats and Booleans are valid:
+        >>> Message.check_value(None)
+        True
+        >>> Message.check_value('Spam')
+        True
+        >>> Message.check_value(42)
+        True
+        >>> Message.check_value(42.42)
+        True
+        >>> Message.check_value(False)
+        True
+
+        Other basic types are not valid:
+        >>> Message.check_value(b'bytes')
+        False
+        >>> Message.check_value(1j)
+        False
+
+        Dictionaries with string keys and recursively valid values are valid:
+        >>> Message.check_value({'str value': 'Spam', 'int value': 42,
+        ...                      'float value': 42.42, 'bool value': False})
+        True
+
+        Empty dictionaries are valid:
+        >>> Message.check_value({})
+        True
+
+        Dictionaries with other keys are not valid:
+        >>> Message.check_value({42: 'int key'})
+        False
+
+        Dictionaries with invalid values are not valid:
+        >>> Message.check_value({'complex value': 1j})
+        False
+
+        Lists with valid elements are valid:
+        >>> Message.check_value(['Spam', 42, 42.42, False])
+        True
+
+        Empty lists are valid:
+        >>> Message.check_value([])
+        True
+
+        Lists with invalid elements are not valid:
+        >>> Message.check_value([1j])
+        False
+        """
+        if value is None:
+            return True
+        elif (isinstance(value, str) or isinstance(value, int) or
+                isinstance(value, float) or isinstance(value, bool)):
+            return True
+        elif isinstance(value, dict):
+            for key in value:
+                if not isinstance(key, str):
+                    return False
+                if not Message.check_value(value[key]):
+                    return False
+            return True
+        elif isinstance(value, list):
+            for element in value:
+                if not Message.check_value(element):
+                    return False
+            return True
+        return False
+
+    def __setitem__(self, key: str, value: MessageValue) -> None:
+        """Check key and value before putting pair into dict.
+
+        >>> m = Message('Example sender')
+        >>> m['key'] = 'value'
+        >>> m['dict'] = {'k1': 'v1', 'k2': 2}
+        >>> print(m)  # doctest: +NORMALIZE_WHITESPACE
+        {'sender': 'Example sender', 'key': 'value',
+         'dict': {'k1': 'v1', 'k2': 2}}
+        >>> m = Message('Example sender')
+        >>> m[42] = 'int key'
+        Traceback (most recent call last):
+          ...
+        TypeError: '42' is not a valid key in Message (not a string).
+        >>> m['complex value'] = 1j
+        Traceback (most recent call last):
+          ...
+        TypeError: '1j' is not a valid value in Message.
+        """
+        if not isinstance(key, str):
+            raise TypeError(f"'{key}' is not a valid key in Message"
+                            " (not a string).")
+        if not self.check_value(value):
+            raise TypeError(f"'{value}' is not a valid value in Message.")
+        super().__setitem__(key, value)
+
+    def update(self, *args, **kwargs) -> None:
+        """Override update to use validity checks.
+
+        >>> m = Message('Example sender')
+        >>> m.update({'key 1': 'value 1', 'key 2': 'value 2'})
+        >>> print(m)
+        {'sender': 'Example sender', 'key 1': 'value 1', 'key 2': 'value 2'}
+        >>> m.update({42: 'int key'})
+        Traceback (most recent call last):
+          ...
+        TypeError: '42' is not a valid key in Message (not a string).
+        >>> m.update({'complex value': 1j})
+        Traceback (most recent call last):
+          ...
+        TypeError: '1j' is not a valid value in Message.
+
+        This is also used in __init__:
+        >>> m = Message('Example sender', {'key': 'value'})
+        >>> print(m)
+        {'sender': 'Example sender', 'key': 'value'}
+        >>> m = Message('Example sender', {42: 'int key'})
+        Traceback (most recent call last):
+          ...
+        TypeError: '42' is not a valid key in Message (not a string).
+        >>> m = Message('Example sender', {'complex value': 1j})
+        Traceback (most recent call last):
+          ...
+        TypeError: '1j' is not a valid value in Message.
+        """
+        if args:
+            if len(args) > 1:
+                raise TypeError("update expected at most 1 argument,"
+                                f" got {len(args)}")
+            other = dict(args[0])
+            for key in other:
+                self[key] = other[key]
+        for key in kwargs:
+            self[key] = kwargs[key]
+
+    def setdefault(self, key: str, value: MessageValue = None) -> MessageValue:
+        """Override setdefault to use validity checks.
+
+        >>> m = Message('Example sender')
+        >>> m.setdefault('key', 'value 1')
+        'value 1'
+        >>> m.setdefault('key', 'value 2')
+        'value 1'
+        >>> m.setdefault(42, 'int key')
+        Traceback (most recent call last):
+          ...
+        TypeError: '42' is not a valid key in Message (not a string).
+        >>> m.setdefault('complex value', 1j)
+        Traceback (most recent call last):
+          ...
+        TypeError: '1j' is not a valid value in Message.
+
+        But __setitem__ is not called if the key is already present:
+        >>> m.setdefault('key', 1j)
+        'value 1'
+        """
+        if key not in self:
+            self[key] = value
+        return self[key]
+
+

Ancestors

+
    +
  • builtins.dict
  • +
  • typing.Generic
  • +
+

Static methods

+
+
+def check_value(value: Union[NoneType, str, int, float, bool, Dict[str, Any], List[Any]]) ‑> bool +
+
+

Check recursively if a given value is valid.

+

None, strings, integers, floats and Booleans are valid:

+
>>> Message.check_value(None)
+True
+>>> Message.check_value('Spam')
+True
+>>> Message.check_value(42)
+True
+>>> Message.check_value(42.42)
+True
+>>> Message.check_value(False)
+True
+
+

Other basic types are not valid:

+
>>> Message.check_value(b'bytes')
+False
+>>> Message.check_value(1j)
+False
+
+

Dictionaries with string keys and recursively valid values are valid:

+
>>> Message.check_value({'str value': 'Spam', 'int value': 42,
+...                      'float value': 42.42, 'bool value': False})
+True
+
+

Empty dictionaries are valid:

+
>>> Message.check_value({})
+True
+
+

Dictionaries with other keys are not valid:

+
>>> Message.check_value({42: 'int key'})
+False
+
+

Dictionaries with invalid values are not valid:

+
>>> Message.check_value({'complex value': 1j})
+False
+
+

Lists with valid elements are valid:

+
>>> Message.check_value(['Spam', 42, 42.42, False])
+True
+
+

Empty lists are valid:

+
>>> Message.check_value([])
+True
+
+

Lists with invalid elements are not valid:

+
>>> Message.check_value([1j])
+False
+
+
+ +Expand source code + +
@staticmethod
+def check_value(value: MessageValue) -> bool:
+    """Check recursively if a given value is valid.
+
+    None, strings, integers, floats and Booleans are valid:
+    >>> Message.check_value(None)
+    True
+    >>> Message.check_value('Spam')
+    True
+    >>> Message.check_value(42)
+    True
+    >>> Message.check_value(42.42)
+    True
+    >>> Message.check_value(False)
+    True
+
+    Other basic types are not valid:
+    >>> Message.check_value(b'bytes')
+    False
+    >>> Message.check_value(1j)
+    False
+
+    Dictionaries with string keys and recursively valid values are valid:
+    >>> Message.check_value({'str value': 'Spam', 'int value': 42,
+    ...                      'float value': 42.42, 'bool value': False})
+    True
+
+    Empty dictionaries are valid:
+    >>> Message.check_value({})
+    True
+
+    Dictionaries with other keys are not valid:
+    >>> Message.check_value({42: 'int key'})
+    False
+
+    Dictionaries with invalid values are not valid:
+    >>> Message.check_value({'complex value': 1j})
+    False
+
+    Lists with valid elements are valid:
+    >>> Message.check_value(['Spam', 42, 42.42, False])
+    True
+
+    Empty lists are valid:
+    >>> Message.check_value([])
+    True
+
+    Lists with invalid elements are not valid:
+    >>> Message.check_value([1j])
+    False
+    """
+    if value is None:
+        return True
+    elif (isinstance(value, str) or isinstance(value, int) or
+            isinstance(value, float) or isinstance(value, bool)):
+        return True
+    elif isinstance(value, dict):
+        for key in value:
+            if not isinstance(key, str):
+                return False
+            if not Message.check_value(value[key]):
+                return False
+        return True
+    elif isinstance(value, list):
+        for element in value:
+            if not Message.check_value(element):
+                return False
+        return True
+    return False
+
+
+
+

Methods

+
+
+def update(self, *args, **kwargs) ‑> NoneType +
+
+

Override update to use validity checks.

+
>>> m = Message('Example sender')
+>>> m.update({'key 1': 'value 1', 'key 2': 'value 2'})
+>>> print(m)
+{'sender': 'Example sender', 'key 1': 'value 1', 'key 2': 'value 2'}
+>>> m.update({42: 'int key'})
+Traceback (most recent call last):
+  ...
+TypeError: '42' is not a valid key in Message (not a string).
+>>> m.update({'complex value': 1j})
+Traceback (most recent call last):
+  ...
+TypeError: '1j' is not a valid value in Message.
+
+

This is also used in init:

+
>>> m = Message('Example sender', {'key': 'value'})
+>>> print(m)
+{'sender': 'Example sender', 'key': 'value'}
+>>> m = Message('Example sender', {42: 'int key'})
+Traceback (most recent call last):
+  ...
+TypeError: '42' is not a valid key in Message (not a string).
+>>> m = Message('Example sender', {'complex value': 1j})
+Traceback (most recent call last):
+  ...
+TypeError: '1j' is not a valid value in Message.
+
+
+ +Expand source code + +
def update(self, *args, **kwargs) -> None:
+    """Override update to use validity checks.
+
+    >>> m = Message('Example sender')
+    >>> m.update({'key 1': 'value 1', 'key 2': 'value 2'})
+    >>> print(m)
+    {'sender': 'Example sender', 'key 1': 'value 1', 'key 2': 'value 2'}
+    >>> m.update({42: 'int key'})
+    Traceback (most recent call last):
+      ...
+    TypeError: '42' is not a valid key in Message (not a string).
+    >>> m.update({'complex value': 1j})
+    Traceback (most recent call last):
+      ...
+    TypeError: '1j' is not a valid value in Message.
+
+    This is also used in __init__:
+    >>> m = Message('Example sender', {'key': 'value'})
+    >>> print(m)
+    {'sender': 'Example sender', 'key': 'value'}
+    >>> m = Message('Example sender', {42: 'int key'})
+    Traceback (most recent call last):
+      ...
+    TypeError: '42' is not a valid key in Message (not a string).
+    >>> m = Message('Example sender', {'complex value': 1j})
+    Traceback (most recent call last):
+      ...
+    TypeError: '1j' is not a valid value in Message.
+    """
+    if args:
+        if len(args) > 1:
+            raise TypeError("update expected at most 1 argument,"
+                            f" got {len(args)}")
+        other = dict(args[0])
+        for key in other:
+            self[key] = other[key]
+    for key in kwargs:
+        self[key] = kwargs[key]
+
+
+
+def setdefault(self, key: str, value: Union[NoneType, str, int, float, bool, Dict[str, Any], List[Any]] = None) ‑> Union[NoneType, str, int, float, bool, Dict[str, Any], List[Any]] +
+
+

Override setdefault to use validity checks.

+
>>> m = Message('Example sender')
+>>> m.setdefault('key', 'value 1')
+'value 1'
+>>> m.setdefault('key', 'value 2')
+'value 1'
+>>> m.setdefault(42, 'int key')
+Traceback (most recent call last):
+  ...
+TypeError: '42' is not a valid key in Message (not a string).
+>>> m.setdefault('complex value', 1j)
+Traceback (most recent call last):
+  ...
+TypeError: '1j' is not a valid value in Message.
+
+

But setitem is not called if the key is already present:

+
>>> m.setdefault('key', 1j)
+'value 1'
+
+
+ +Expand source code + +
def setdefault(self, key: str, value: MessageValue = None) -> MessageValue:
+    """Override setdefault to use validity checks.
+
+    >>> m = Message('Example sender')
+    >>> m.setdefault('key', 'value 1')
+    'value 1'
+    >>> m.setdefault('key', 'value 2')
+    'value 1'
+    >>> m.setdefault(42, 'int key')
+    Traceback (most recent call last):
+      ...
+    TypeError: '42' is not a valid key in Message (not a string).
+    >>> m.setdefault('complex value', 1j)
+    Traceback (most recent call last):
+      ...
+    TypeError: '1j' is not a valid value in Message.
+
+    But __setitem__ is not called if the key is already present:
+    >>> m.setdefault('key', 1j)
+    'value 1'
+    """
+    if key not in self:
+        self[key] = value
+    return self[key]
+
+
+
+
+
+class MessageTemplate +(init: Dict[str, Union[bool, Dict[str, Union[NoneType, str, int, float, bool, Dict[str, Any], List[Any]]]]] = None) +
+
+

Define a message template.

+

A message template is a mapping from string keys to JSON schemas as +values:

+
>>> t = MessageTemplate({'key 1': {'const': 'value'},
+...                      'key 2': {'type': 'string'}})
+>>> t['key 3'] = {'type': 'object',
+...               'properties': {'key 1': {'type': 'number'},
+...                              'key 2': True}}
+
+

A message template matches a message if all keys of the template are +contained in the message and the values in the message validate against +the respective schemas:

+
>>> t.check(Message('Example Sender',
+...                 {'key 1': 'value', 'key 2': 'some string',
+...                  'key 3': {'key 1': 42, 'key 2': None}}))
+True
+
+

An empty mapping therefore matches all messages:

+
>>> t = MessageTemplate()
+>>> t.check(Message('Example Sender', {'arbitrary': 'content'}))
+True
+
+

Initialise message.

+

Template is initialised empty or with given key-value pairs:

+
>>> t = MessageTemplate()
+>>> print(t)
+{}
+>>> t = MessageTemplate({'key': {'const': 'value'}})
+>>> print(t)
+{'key': {'const': 'value'}}
+
+
+ +Expand source code + +
class MessageTemplate(Dict[str, JSONSchema]):
+    """Define a message template.
+
+    A message template is a mapping from string keys to JSON schemas as
+    values:
+    >>> t = MessageTemplate({'key 1': {'const': 'value'},
+    ...                      'key 2': {'type': 'string'}})
+    >>> t['key 3'] = {'type': 'object',
+    ...               'properties': {'key 1': {'type': 'number'},
+    ...                              'key 2': True}}
+
+    A message template matches a message if all keys of the template are
+    contained in the message and the values in the message validate against
+    the respective schemas:
+    >>> t.check(Message('Example Sender',
+    ...                 {'key 1': 'value', 'key 2': 'some string',
+    ...                  'key 3': {'key 1': 42, 'key 2': None}}))
+    True
+
+    An empty mapping therefore matches all messages:
+    >>> t = MessageTemplate()
+    >>> t.check(Message('Example Sender', {'arbitrary': 'content'}))
+    True
+    """
+
+    def __init__(self, init: Dict[str, JSONSchema] = None) -> None:
+        """Initialise message.
+
+        Template is initialised empty or with given key-value pairs:
+        >>> t = MessageTemplate()
+        >>> print(t)
+        {}
+        >>> t = MessageTemplate({'key': {'const': 'value'}})
+        >>> print(t)
+        {'key': {'const': 'value'}}
+        """
+        if init is not None:
+            self.update(init)
+
+    @staticmethod
+    def from_message(message: Message) -> 'MessageTemplate':
+        """Create template from message.
+
+        Template witch constant schemas is created from message:
+        >>> m = Message('Example Sender', {'key': 'value'})
+        >>> t = MessageTemplate.from_message(m)
+        >>> print(t)
+        {'sender': {'const': 'Example Sender'}, 'key': {'const': 'value'}}
+        >>> m = Message('Example Sender', {'dict': {'int': 42, 'float': 42.42},
+        ...                                'list': [None, True, 'string']})
+        >>> t = MessageTemplate.from_message(m)
+        >>> print(t)  # doctest: +NORMALIZE_WHITESPACE
+        {'sender': {'const': 'Example Sender'},
+         'dict': {'type': 'object',
+                  'properties': {'int': {'const': 42},
+                                 'float': {'const': 42.42}}},
+         'list': {'type': 'array',
+                  'items': [{'const': None},
+                            {'const': True},
+                            {'const': 'string'}]}}
+
+        This is especially useful for clients that send certain fully
+        predefined messages, where the message is given in the configuration
+        and the template for the registration can be constructed by this
+        method.
+        """
+        def schema_from_value(value: MessageValue) -> JSONSchema:
+            schema: JSONSchema = False
+            if value is None:
+                schema = {'const': None}
+            elif (isinstance(value, str) or isinstance(value, int) or
+                    isinstance(value, float) or isinstance(value, bool)):
+                schema = {'const': value}
+            elif isinstance(value, dict):
+                properties = {}
+                for inner_key in value:
+                    inner_value: Message = value[inner_key]
+                    properties[inner_key] = schema_from_value(inner_value)
+                schema = {'type': 'object',
+                          'properties': properties}
+            elif isinstance(value, list):
+                schema = {'type': 'array',
+                          'items': [schema_from_value(element)
+                                    for element in value]}
+            return schema
+        template = MessageTemplate()
+        for key in message:
+            template[key] = schema_from_value(message[key])
+        return template
+
+    def __setitem__(self, key: str, value: JSONSchema) -> None:
+        """Check key and value before putting pair into dict.
+
+        >>> t = MessageTemplate()
+        >>> t['key 1'] = {'const': 'value'}
+        >>> t['key 2'] = {'type': 'string'}
+        >>> t['key 3'] = {'type': 'object',
+        ...               'properties': {'key 1': {'type': 'number'},
+        ...                              'key 2': True}}
+        >>> print(t)  # doctest: +NORMALIZE_WHITESPACE
+        {'key 1': {'const': 'value'}, 'key 2': {'type': 'string'},
+         'key 3': {'type': 'object',
+                   'properties': {'key 1': {'type': 'number'},
+                                  'key 2': True}}}
+        >>> t[42] = {'const': 'int key'}
+        Traceback (most recent call last):
+          ...
+        TypeError: '42' is not a valid key in MessageTemplate (not a string).
+        >>> t['key'] = 'schema'  # doctest: +NORMALIZE_WHITESPACE
+        Traceback (most recent call last):
+          ...
+        TypeError: 'schema' is not a valid value in MessageTemplate
+        (not a valid JSON schema).
+        """
+        if not isinstance(key, str):
+            raise TypeError(f"'{key}' is not a valid key in MessageTemplate"
+                            " (not a string).")
+        try:
+            jsonschema.Draft7Validator.check_schema(value)
+            # Draft7Validator is hardcoded, because _LATEST_VERSION is
+            # non-public in jsonschema and we also perhaps do not want to
+            # upgrade automatically.
+        except jsonschema.exceptions.SchemaError:
+            raise TypeError(f"'{value}' is not a valid value in"
+                            " MessageTemplate (not a valid JSON schema).")
+        super().__setitem__(key, value)
+
+    def update(self, *args, **kwargs) -> None:
+        """Override update to use validity checks.
+
+        >>> t = MessageTemplate()
+        >>> t.update({'key 1': {'const': 'value'},
+        ...           'key 2': {'type': 'string'},
+        ...           'key 3': {'type': 'object',
+        ...                     'properties': {'key 1': {'type': 'number'},
+        ...                                    'key 2': True}}})
+        >>> print(t)  # doctest: +NORMALIZE_WHITESPACE
+        {'key 1': {'const': 'value'}, 'key 2': {'type': 'string'},
+         'key 3': {'type': 'object',
+                   'properties': {'key 1': {'type': 'number'},
+                                  'key 2': True}}}
+        >>> t.update({42: {'const': 'int key'}})
+        Traceback (most recent call last):
+          ...
+        TypeError: '42' is not a valid key in MessageTemplate (not a string).
+        >>> t.update({'key': 'schema'})  # doctest: +NORMALIZE_WHITESPACE
+        Traceback (most recent call last):
+          ...
+        TypeError: 'schema' is not a valid value in MessageTemplate
+        (not a valid JSON schema).
+
+        This is also used in __init__:
+        >>> t = MessageTemplate({'key 1': {'const': 'value'},
+        ...                      'key 2': {'type': 'string'},
+        ...                      'key 3': {'type': 'object',
+        ...                                'properties': {
+        ...                                    'key 1': {'type': 'number'},
+        ...                                    'key 2': True}}})
+        >>> print(t)  # doctest: +NORMALIZE_WHITESPACE
+        {'key 1': {'const': 'value'}, 'key 2': {'type': 'string'},
+         'key 3': {'type': 'object',
+                   'properties': {'key 1': {'type': 'number'},
+                                  'key 2': True}}}
+        >>> t = MessageTemplate({42: {'const': 'int key'}})
+        Traceback (most recent call last):
+          ...
+        TypeError: '42' is not a valid key in MessageTemplate (not a string).
+        >>> t = MessageTemplate({'key': 'schema'})
+        ... # doctest: +NORMALIZE_WHITESPACE
+        Traceback (most recent call last):
+          ...
+        TypeError: 'schema' is not a valid value in MessageTemplate
+        (not a valid JSON schema).
+        """
+        if args:
+            if len(args) > 1:
+                raise TypeError("update expected at most 1 argument,"
+                                f" got {len(args)}")
+            other = dict(args[0])
+            for key in other:
+                self[key] = other[key]
+        for key in kwargs:
+            self[key] = kwargs[key]
+
+    def setdefault(self, key: str, value: JSONSchema = None) -> JSONSchema:
+        """Override setdefault to use validity checks.
+
+        >>> t = MessageTemplate()
+        >>> t.setdefault('key 1', {'const': 'value'})
+        {'const': 'value'}
+        >>> t.setdefault('key 2', {'type': 'string'})
+        {'type': 'string'}
+        >>> t.setdefault('key 3', {'type': 'object',
+        ...                        'properties': {'key 1': {'type': 'number'},
+        ...                                       'key 2': True}})
+        ... # doctest: +NORMALIZE_WHITESPACE
+        {'type': 'object',
+                   'properties': {'key 1': {'type': 'number'},
+                                  'key 2': True}}
+        >>> t.setdefault(42, {'const': 'int key'})
+        Traceback (most recent call last):
+          ...
+        TypeError: '42' is not a valid key in MessageTemplate (not a string).
+        >>> t.setdefault('key', 'schema')  # doctest: +NORMALIZE_WHITESPACE
+        Traceback (most recent call last):
+          ...
+        TypeError: 'schema' is not a valid value in MessageTemplate
+        (not a valid JSON schema).
+
+        But __setitem__ is not called if the key is already present:
+        >>> t.setdefault('key 1', 'schema')
+        {'const': 'value'}
+        """
+        if key not in self:
+            if value is not None:
+                self[key] = value
+            else:
+                self[key] = True
+        return self[key]
+
+    def check(self, message: Message) -> bool:
+        """Check message against this template.
+
+        Constant values have to match exactly:
+        >>> t = MessageTemplate({'key': {'const': 'value'}})
+        >>> t.check(Message('Example Sender', {'key': 'value'}))
+        True
+        >>> t.check(Message('Example Sender', {'key': 'other value'}))
+        False
+
+        But for integers, floats with the same value are also valid:
+        >>> t = MessageTemplate({'key': {'const': 42}})
+        >>> t.check(Message('Example Sender', {'key': 42}))
+        True
+        >>> t.check(Message('Example Sender', {'key': 42.0}))
+        True
+
+        Type integer is valid for floats with zero fractional part, but
+        not by floats with non-zero fractional part:
+        >>> t = MessageTemplate({'key': {'type': 'integer'}})
+        >>> t.check(Message('Example Sender', {'key': 42}))
+        True
+        >>> t.check(Message('Example Sender', {'key': 42.0}))
+        True
+        >>> t.check(Message('Example Sender', {'key': 42.42}))
+        False
+
+        Type number is valid for arbitrary ints or floats:
+        >>> t = MessageTemplate({'key': {'type': 'number'}})
+        >>> t.check(Message('Example Sender', {'key': 42}))
+        True
+        >>> t.check(Message('Example Sender', {'key': 42.42}))
+        True
+
+        All keys in template have to be present in message:
+        >>> t = MessageTemplate({'key 1': {'const': 'value'},
+        ...                      'key 2': {'type': 'string'},
+        ...                      'key 3': {'type': 'object',
+        ...                                'properties': {
+        ...                                    'key 1': {'type': 'number'},
+        ...                                    'key 2': True,
+        ...                                    'key 3': False}}})
+        >>> t.check(Message('Example Sender',
+        ...                 {'key 1': 'value', 'key 2': 'some string'}))
+        False
+
+        But for nested objects their properties do not necessarily have
+        to be present:
+        >>> t.check(Message('Example Sender',
+        ...                 {'key 1': 'value', 'key 2': 'some string',
+        ...                  'key 3': {'key 1': 42}}))
+        True
+
+        Schema True matches everything (even None):
+        >>> t.check(Message('Example Sender',
+        ...                 {'key 1': 'value', 'key 2': 'some string',
+        ...                  'key 3': {'key 2': None}}))
+        True
+
+        Schema False matches nothing:
+        >>> t.check(Message('Example Sender',
+        ...                 {'key 1': 'value', 'key 2': 'some string',
+        ...                  'key 3': {'key 3': True}}))
+        False
+
+        Message is valid for the constant template created from it:
+        >>> m = Message('Example Sender', {'dict': {'int': 42, 'float': 42.42},
+        ...                                'list': [None, True, 'string']})
+        >>> t = MessageTemplate.from_message(m)
+        >>> t.check(m)
+        True
+        """
+        for key in self:
+            if key not in message:
+                return False
+            else:
+                validator = jsonschema.Draft7Validator(self[key])
+                for error in validator.iter_errors(message[key]):
+                    return False
+        return True
+
+

Ancestors

+
    +
  • builtins.dict
  • +
  • typing.Generic
  • +
+

Static methods

+
+
+def from_message(message: Message) ‑> MessageTemplate +
+
+

Create template from message.

+

Template witch constant schemas is created from message:

+
>>> m = Message('Example Sender', {'key': 'value'})
+>>> t = MessageTemplate.from_message(m)
+>>> print(t)
+{'sender': {'const': 'Example Sender'}, 'key': {'const': 'value'}}
+>>> m = Message('Example Sender', {'dict': {'int': 42, 'float': 42.42},
+...                                'list': [None, True, 'string']})
+>>> t = MessageTemplate.from_message(m)
+>>> print(t)  # doctest: +NORMALIZE_WHITESPACE
+{'sender': {'const': 'Example Sender'},
+ 'dict': {'type': 'object',
+          'properties': {'int': {'const': 42},
+                         'float': {'const': 42.42}}},
+ 'list': {'type': 'array',
+          'items': [{'const': None},
+                    {'const': True},
+                    {'const': 'string'}]}}
+
+

This is especially useful for clients that send certain fully +predefined messages, where the message is given in the configuration +and the template for the registration can be constructed by this +method.

+
+ +Expand source code + +
@staticmethod
+def from_message(message: Message) -> 'MessageTemplate':
+    """Create template from message.
+
+    Template witch constant schemas is created from message:
+    >>> m = Message('Example Sender', {'key': 'value'})
+    >>> t = MessageTemplate.from_message(m)
+    >>> print(t)
+    {'sender': {'const': 'Example Sender'}, 'key': {'const': 'value'}}
+    >>> m = Message('Example Sender', {'dict': {'int': 42, 'float': 42.42},
+    ...                                'list': [None, True, 'string']})
+    >>> t = MessageTemplate.from_message(m)
+    >>> print(t)  # doctest: +NORMALIZE_WHITESPACE
+    {'sender': {'const': 'Example Sender'},
+     'dict': {'type': 'object',
+              'properties': {'int': {'const': 42},
+                             'float': {'const': 42.42}}},
+     'list': {'type': 'array',
+              'items': [{'const': None},
+                        {'const': True},
+                        {'const': 'string'}]}}
+
+    This is especially useful for clients that send certain fully
+    predefined messages, where the message is given in the configuration
+    and the template for the registration can be constructed by this
+    method.
+    """
+    def schema_from_value(value: MessageValue) -> JSONSchema:
+        schema: JSONSchema = False
+        if value is None:
+            schema = {'const': None}
+        elif (isinstance(value, str) or isinstance(value, int) or
+                isinstance(value, float) or isinstance(value, bool)):
+            schema = {'const': value}
+        elif isinstance(value, dict):
+            properties = {}
+            for inner_key in value:
+                inner_value: Message = value[inner_key]
+                properties[inner_key] = schema_from_value(inner_value)
+            schema = {'type': 'object',
+                      'properties': properties}
+        elif isinstance(value, list):
+            schema = {'type': 'array',
+                      'items': [schema_from_value(element)
+                                for element in value]}
+        return schema
+    template = MessageTemplate()
+    for key in message:
+        template[key] = schema_from_value(message[key])
+    return template
+
+
+
+

Methods

+
+
+def update(self, *args, **kwargs) ‑> NoneType +
+
+

Override update to use validity checks.

+
>>> t = MessageTemplate()
+>>> t.update({'key 1': {'const': 'value'},
+...           'key 2': {'type': 'string'},
+...           'key 3': {'type': 'object',
+...                     'properties': {'key 1': {'type': 'number'},
+...                                    'key 2': True}}})
+>>> print(t)  # doctest: +NORMALIZE_WHITESPACE
+{'key 1': {'const': 'value'}, 'key 2': {'type': 'string'},
+ 'key 3': {'type': 'object',
+           'properties': {'key 1': {'type': 'number'},
+                          'key 2': True}}}
+>>> t.update({42: {'const': 'int key'}})
+Traceback (most recent call last):
+  ...
+TypeError: '42' is not a valid key in MessageTemplate (not a string).
+>>> t.update({'key': 'schema'})  # doctest: +NORMALIZE_WHITESPACE
+Traceback (most recent call last):
+  ...
+TypeError: 'schema' is not a valid value in MessageTemplate
+(not a valid JSON schema).
+
+

This is also used in init:

+
>>> t = MessageTemplate({'key 1': {'const': 'value'},
+...                      'key 2': {'type': 'string'},
+...                      'key 3': {'type': 'object',
+...                                'properties': {
+...                                    'key 1': {'type': 'number'},
+...                                    'key 2': True}}})
+>>> print(t)  # doctest: +NORMALIZE_WHITESPACE
+{'key 1': {'const': 'value'}, 'key 2': {'type': 'string'},
+ 'key 3': {'type': 'object',
+           'properties': {'key 1': {'type': 'number'},
+                          'key 2': True}}}
+>>> t = MessageTemplate({42: {'const': 'int key'}})
+Traceback (most recent call last):
+  ...
+TypeError: '42' is not a valid key in MessageTemplate (not a string).
+>>> t = MessageTemplate({'key': 'schema'})
+... # doctest: +NORMALIZE_WHITESPACE
+Traceback (most recent call last):
+  ...
+TypeError: 'schema' is not a valid value in MessageTemplate
+(not a valid JSON schema).
+
+
+ +Expand source code + +
def update(self, *args, **kwargs) -> None:
+    """Override update to use validity checks.
+
+    >>> t = MessageTemplate()
+    >>> t.update({'key 1': {'const': 'value'},
+    ...           'key 2': {'type': 'string'},
+    ...           'key 3': {'type': 'object',
+    ...                     'properties': {'key 1': {'type': 'number'},
+    ...                                    'key 2': True}}})
+    >>> print(t)  # doctest: +NORMALIZE_WHITESPACE
+    {'key 1': {'const': 'value'}, 'key 2': {'type': 'string'},
+     'key 3': {'type': 'object',
+               'properties': {'key 1': {'type': 'number'},
+                              'key 2': True}}}
+    >>> t.update({42: {'const': 'int key'}})
+    Traceback (most recent call last):
+      ...
+    TypeError: '42' is not a valid key in MessageTemplate (not a string).
+    >>> t.update({'key': 'schema'})  # doctest: +NORMALIZE_WHITESPACE
+    Traceback (most recent call last):
+      ...
+    TypeError: 'schema' is not a valid value in MessageTemplate
+    (not a valid JSON schema).
+
+    This is also used in __init__:
+    >>> t = MessageTemplate({'key 1': {'const': 'value'},
+    ...                      'key 2': {'type': 'string'},
+    ...                      'key 3': {'type': 'object',
+    ...                                'properties': {
+    ...                                    'key 1': {'type': 'number'},
+    ...                                    'key 2': True}}})
+    >>> print(t)  # doctest: +NORMALIZE_WHITESPACE
+    {'key 1': {'const': 'value'}, 'key 2': {'type': 'string'},
+     'key 3': {'type': 'object',
+               'properties': {'key 1': {'type': 'number'},
+                              'key 2': True}}}
+    >>> t = MessageTemplate({42: {'const': 'int key'}})
+    Traceback (most recent call last):
+      ...
+    TypeError: '42' is not a valid key in MessageTemplate (not a string).
+    >>> t = MessageTemplate({'key': 'schema'})
+    ... # doctest: +NORMALIZE_WHITESPACE
+    Traceback (most recent call last):
+      ...
+    TypeError: 'schema' is not a valid value in MessageTemplate
+    (not a valid JSON schema).
+    """
+    if args:
+        if len(args) > 1:
+            raise TypeError("update expected at most 1 argument,"
+                            f" got {len(args)}")
+        other = dict(args[0])
+        for key in other:
+            self[key] = other[key]
+    for key in kwargs:
+        self[key] = kwargs[key]
+
+
+
+def setdefault(self, key: str, value: Union[bool, Dict[str, Union[NoneType, str, int, float, bool, Dict[str, Any], List[Any]]]] = None) ‑> Union[bool, Dict[str, Union[NoneType, str, int, float, bool, Dict[str, Any], List[Any]]]] +
+
+

Override setdefault to use validity checks.

+
>>> t = MessageTemplate()
+>>> t.setdefault('key 1', {'const': 'value'})
+{'const': 'value'}
+>>> t.setdefault('key 2', {'type': 'string'})
+{'type': 'string'}
+>>> t.setdefault('key 3', {'type': 'object',
+...                        'properties': {'key 1': {'type': 'number'},
+...                                       'key 2': True}})
+... # doctest: +NORMALIZE_WHITESPACE
+{'type': 'object',
+           'properties': {'key 1': {'type': 'number'},
+                          'key 2': True}}
+>>> t.setdefault(42, {'const': 'int key'})
+Traceback (most recent call last):
+  ...
+TypeError: '42' is not a valid key in MessageTemplate (not a string).
+>>> t.setdefault('key', 'schema')  # doctest: +NORMALIZE_WHITESPACE
+Traceback (most recent call last):
+  ...
+TypeError: 'schema' is not a valid value in MessageTemplate
+(not a valid JSON schema).
+
+

But setitem is not called if the key is already present:

+
>>> t.setdefault('key 1', 'schema')
+{'const': 'value'}
+
+
+ +Expand source code + +
def setdefault(self, key: str, value: JSONSchema = None) -> JSONSchema:
+    """Override setdefault to use validity checks.
+
+    >>> t = MessageTemplate()
+    >>> t.setdefault('key 1', {'const': 'value'})
+    {'const': 'value'}
+    >>> t.setdefault('key 2', {'type': 'string'})
+    {'type': 'string'}
+    >>> t.setdefault('key 3', {'type': 'object',
+    ...                        'properties': {'key 1': {'type': 'number'},
+    ...                                       'key 2': True}})
+    ... # doctest: +NORMALIZE_WHITESPACE
+    {'type': 'object',
+               'properties': {'key 1': {'type': 'number'},
+                              'key 2': True}}
+    >>> t.setdefault(42, {'const': 'int key'})
+    Traceback (most recent call last):
+      ...
+    TypeError: '42' is not a valid key in MessageTemplate (not a string).
+    >>> t.setdefault('key', 'schema')  # doctest: +NORMALIZE_WHITESPACE
+    Traceback (most recent call last):
+      ...
+    TypeError: 'schema' is not a valid value in MessageTemplate
+    (not a valid JSON schema).
+
+    But __setitem__ is not called if the key is already present:
+    >>> t.setdefault('key 1', 'schema')
+    {'const': 'value'}
+    """
+    if key not in self:
+        if value is not None:
+            self[key] = value
+        else:
+            self[key] = True
+    return self[key]
+
+
+
+def check(self, message: Message) ‑> bool +
+
+

Check message against this template.

+

Constant values have to match exactly:

+
>>> t = MessageTemplate({'key': {'const': 'value'}})
+>>> t.check(Message('Example Sender', {'key': 'value'}))
+True
+>>> t.check(Message('Example Sender', {'key': 'other value'}))
+False
+
+

But for integers, floats with the same value are also valid:

+
>>> t = MessageTemplate({'key': {'const': 42}})
+>>> t.check(Message('Example Sender', {'key': 42}))
+True
+>>> t.check(Message('Example Sender', {'key': 42.0}))
+True
+
+

Type integer is valid for floats with zero fractional part, but +not by floats with non-zero fractional part:

+
>>> t = MessageTemplate({'key': {'type': 'integer'}})
+>>> t.check(Message('Example Sender', {'key': 42}))
+True
+>>> t.check(Message('Example Sender', {'key': 42.0}))
+True
+>>> t.check(Message('Example Sender', {'key': 42.42}))
+False
+
+

Type number is valid for arbitrary ints or floats:

+
>>> t = MessageTemplate({'key': {'type': 'number'}})
+>>> t.check(Message('Example Sender', {'key': 42}))
+True
+>>> t.check(Message('Example Sender', {'key': 42.42}))
+True
+
+

All keys in template have to be present in message:

+
>>> t = MessageTemplate({'key 1': {'const': 'value'},
+...                      'key 2': {'type': 'string'},
+...                      'key 3': {'type': 'object',
+...                                'properties': {
+...                                    'key 1': {'type': 'number'},
+...                                    'key 2': True,
+...                                    'key 3': False}}})
+>>> t.check(Message('Example Sender',
+...                 {'key 1': 'value', 'key 2': 'some string'}))
+False
+
+

But for nested objects their properties do not necessarily have +to be present:

+
>>> t.check(Message('Example Sender',
+...                 {'key 1': 'value', 'key 2': 'some string',
+...                  'key 3': {'key 1': 42}}))
+True
+
+

Schema True matches everything (even None):

+
>>> t.check(Message('Example Sender',
+...                 {'key 1': 'value', 'key 2': 'some string',
+...                  'key 3': {'key 2': None}}))
+True
+
+

Schema False matches nothing:

+
>>> t.check(Message('Example Sender',
+...                 {'key 1': 'value', 'key 2': 'some string',
+...                  'key 3': {'key 3': True}}))
+False
+
+

Message is valid for the constant template created from it:

+
>>> m = Message('Example Sender', {'dict': {'int': 42, 'float': 42.42},
+...                                'list': [None, True, 'string']})
+>>> t = MessageTemplate.from_message(m)
+>>> t.check(m)
+True
+
+
+ +Expand source code + +
def check(self, message: Message) -> bool:
+    """Check message against this template.
+
+    Constant values have to match exactly:
+    >>> t = MessageTemplate({'key': {'const': 'value'}})
+    >>> t.check(Message('Example Sender', {'key': 'value'}))
+    True
+    >>> t.check(Message('Example Sender', {'key': 'other value'}))
+    False
+
+    But for integers, floats with the same value are also valid:
+    >>> t = MessageTemplate({'key': {'const': 42}})
+    >>> t.check(Message('Example Sender', {'key': 42}))
+    True
+    >>> t.check(Message('Example Sender', {'key': 42.0}))
+    True
+
+    Type integer is valid for floats with zero fractional part, but
+    not by floats with non-zero fractional part:
+    >>> t = MessageTemplate({'key': {'type': 'integer'}})
+    >>> t.check(Message('Example Sender', {'key': 42}))
+    True
+    >>> t.check(Message('Example Sender', {'key': 42.0}))
+    True
+    >>> t.check(Message('Example Sender', {'key': 42.42}))
+    False
+
+    Type number is valid for arbitrary ints or floats:
+    >>> t = MessageTemplate({'key': {'type': 'number'}})
+    >>> t.check(Message('Example Sender', {'key': 42}))
+    True
+    >>> t.check(Message('Example Sender', {'key': 42.42}))
+    True
+
+    All keys in template have to be present in message:
+    >>> t = MessageTemplate({'key 1': {'const': 'value'},
+    ...                      'key 2': {'type': 'string'},
+    ...                      'key 3': {'type': 'object',
+    ...                                'properties': {
+    ...                                    'key 1': {'type': 'number'},
+    ...                                    'key 2': True,
+    ...                                    'key 3': False}}})
+    >>> t.check(Message('Example Sender',
+    ...                 {'key 1': 'value', 'key 2': 'some string'}))
+    False
+
+    But for nested objects their properties do not necessarily have
+    to be present:
+    >>> t.check(Message('Example Sender',
+    ...                 {'key 1': 'value', 'key 2': 'some string',
+    ...                  'key 3': {'key 1': 42}}))
+    True
+
+    Schema True matches everything (even None):
+    >>> t.check(Message('Example Sender',
+    ...                 {'key 1': 'value', 'key 2': 'some string',
+    ...                  'key 3': {'key 2': None}}))
+    True
+
+    Schema False matches nothing:
+    >>> t.check(Message('Example Sender',
+    ...                 {'key 1': 'value', 'key 2': 'some string',
+    ...                  'key 3': {'key 3': True}}))
+    False
+
+    Message is valid for the constant template created from it:
+    >>> m = Message('Example Sender', {'dict': {'int': 42, 'float': 42.42},
+    ...                                'list': [None, True, 'string']})
+    >>> t = MessageTemplate.from_message(m)
+    >>> t.check(m)
+    True
+    """
+    for key in self:
+        if key not in message:
+            return False
+        else:
+            validator = jsonschema.Draft7Validator(self[key])
+            for error in validator.iter_errors(message[key]):
+                return False
+    return True
+
+
+
+
+
+class MessageTemplateRegistry +
+
+

Manage a collection of message templates with registered clients.

+

A new MessageTemplateRegistry is created by:

+
>>> r = MessageTemplateRegistry()
+
+

Client names (strings) can be registered for message templates, which +are mappings from keys to JSON schemas:

+
>>> r.insert({'k1': {'const': 'v1'}}, 'C 1')
+
+

The check function checks if the templates registered for a client +match a given message:

+
>>> for m in [{'k1': 'v1', 'k2': 'v1'}, {'k1': 'v1', 'k2': 2},
+...           {'k1': 'v2', 'k2': 'v1'}, {'k1': 'v2', 'k2': 2}]:
+...     print(f"{m}: {r.check('C 1', m)}")
+{'k1': 'v1', 'k2': 'v1'}: True
+{'k1': 'v1', 'k2': 2}: True
+{'k1': 'v2', 'k2': 'v1'}: False
+{'k1': 'v2', 'k2': 2}: False
+
+

Clients can be registered for values validating against arbitrary JSON +schemas, e.g. all values of a certain type:

+
>>> r.insert({'k1': {'const': 'v2'}, 'k2': {'type': 'string'}}, 'C 2')
+>>> for m in [{'k1': 'v1', 'k2': 'v1'}, {'k1': 'v1', 'k2': 2},
+...           {'k1': 'v2', 'k2': 'v1'}, {'k1': 'v2', 'k2': 2}]:
+...     print(f"{m}: {r.check('C 2', m)}")
+{'k1': 'v1', 'k2': 'v1'}: False
+{'k1': 'v1', 'k2': 2}: False
+{'k1': 'v2', 'k2': 'v1'}: True
+{'k1': 'v2', 'k2': 2}: False
+>>> r.insert({'k1': {'const': 'v2'}, 'k2': {'type': 'integer'}}, 'C 3')
+>>> for m in [{'k1': 'v1', 'k2': 'v1'}, {'k1': 'v1', 'k2': 2},
+...           {'k1': 'v2', 'k2': 'v1'}, {'k1': 'v2', 'k2': 2}]:
+...     print(f"{m}: {r.check('C 3', m)}")
+{'k1': 'v1', 'k2': 'v1'}: False
+{'k1': 'v1', 'k2': 2}: False
+{'k1': 'v2', 'k2': 'v1'}: False
+{'k1': 'v2', 'k2': 2}: True
+
+

The order of key-value pairs does not have to match the order in the +messages and keys can be left out:

+
>>> r.insert({'k2': {'const': 2}}, 'C 4')
+>>> for m in [{'k1': 'v1', 'k2': 'v1'}, {'k1': 'v1', 'k2': 2},
+...           {'k1': 'v2', 'k2': 'v1'}, {'k1': 'v2', 'k2': 2}]:
+...     print(f"{m}: {r.check('C 4', m)}")
+{'k1': 'v1', 'k2': 'v1'}: False
+{'k1': 'v1', 'k2': 2}: True
+{'k1': 'v2', 'k2': 'v1'}: False
+{'k1': 'v2', 'k2': 2}: True
+
+

A registration for an empty template matches all messages:

+
>>> r.insert({}, 'C 5')
+>>> for m in [{'k1': 'v1', 'k2': 'v1'}, {'k1': 'v1', 'k2': 2},
+...           {'k1': 'v2', 'k2': 'v1'}, {'k1': 'v2', 'k2': 2}]:
+...     print(f"{m}: {r.check('C 5', m)}")
+{'k1': 'v1', 'k2': 'v1'}: True
+{'k1': 'v1', 'k2': 2}: True
+{'k1': 'v2', 'k2': 'v1'}: True
+{'k1': 'v2', 'k2': 2}: True
+
+

A client can be registered for multiple templates:

+
>>> r.insert({'k1': {'const': 'v1'}}, 'C 6')
+>>> r.insert({'k2': {'const': 'v1'}}, 'C 6')
+>>> for m in [{'k1': 'v1', 'k2': 'v1'}, {'k1': 'v1', 'k2': 2},
+...           {'k1': 'v2', 'k2': 'v1'}, {'k1': 'v2', 'k2': 2}]:
+...     print(f"{m}: {r.check('C 6', m)}")
+{'k1': 'v1', 'k2': 'v1'}: True
+{'k1': 'v1', 'k2': 2}: True
+{'k1': 'v2', 'k2': 'v1'}: True
+{'k1': 'v2', 'k2': 2}: False
+
+

Clients can be deregistered again (the result is False if the registry +is empty after the deletion):

+
>>> r.insert({'k1': {'const': 'v1'}}, 'C 7')
+>>> r.delete('C 7')
+True
+>>> for m in [{'k1': 'v1', 'k2': 'v1'}, {'k1': 'v1', 'k2': 2},
+...           {'k1': 'v2', 'k2': 'v1'}, {'k1': 'v2', 'k2': 2}]:
+...     print(f"{m}: {r.check('C 7', m)}")
+{'k1': 'v1', 'k2': 'v1'}: False
+{'k1': 'v1', 'k2': 2}: False
+{'k1': 'v2', 'k2': 'v1'}: False
+{'k1': 'v2', 'k2': 2}: False
+
+

The get function returns all clients with registered templates matching +a given message:

+
>>> for m in [{'k1': 'v1', 'k2': 'v1'}, {'k1': 'v1', 'k2': 2},
+...           {'k1': 'v2', 'k2': 'v1'}, {'k1': 'v2', 'k2': 2}]:
+...     print(f"{m}: {r.get(m)}")
+{'k1': 'v1', 'k2': 'v1'}: ['C 5', 'C 1', 'C 6']
+{'k1': 'v1', 'k2': 2}: ['C 5', 'C 1', 'C 6', 'C 4']
+{'k1': 'v2', 'k2': 'v1'}: ['C 5', 'C 2', 'C 6']
+{'k1': 'v2', 'k2': 2}: ['C 5', 'C 3', 'C 4']
+
+

The get_templates function returns all templates for a given client:

+
>>> for c in ['C 1', 'C 2', 'C 3', 'C 4', 'C 5', 'C 6']:
+...     print(f"{c}: {r.get_templates(c)}")
+C 1: [{'k1': {'const': 'v1'}}]
+C 2: [{'k1': {'const': 'v2'}, 'k2': {'type': 'string'}}]
+C 3: [{'k1': {'const': 'v2'}, 'k2': {'type': 'integer'}}]
+C 4: [{'k2': {'const': 2}}]
+C 5: [{}]
+C 6: [{'k1': {'const': 'v1'}}, {'k2': {'const': 'v1'}}]
+
+

Initialise an empty registry.

+
>>> r = MessageTemplateRegistry()
+
+
+ +Expand source code + +
class MessageTemplateRegistry:
+    """Manage a collection of message templates with registered clients.
+
+    A new MessageTemplateRegistry is created by:
+    >>> r = MessageTemplateRegistry()
+
+    Client names (strings) can be registered for message templates, which
+    are mappings from keys to JSON schemas:
+    >>> r.insert({'k1': {'const': 'v1'}}, 'C 1')
+
+    The check function checks if the templates registered for a client
+    match a given message:
+    >>> for m in [{'k1': 'v1', 'k2': 'v1'}, {'k1': 'v1', 'k2': 2},
+    ...           {'k1': 'v2', 'k2': 'v1'}, {'k1': 'v2', 'k2': 2}]:
+    ...     print(f"{m}: {r.check('C 1', m)}")
+    {'k1': 'v1', 'k2': 'v1'}: True
+    {'k1': 'v1', 'k2': 2}: True
+    {'k1': 'v2', 'k2': 'v1'}: False
+    {'k1': 'v2', 'k2': 2}: False
+
+    Clients can be registered for values validating against arbitrary JSON
+    schemas, e.g. all values of a certain type:
+    >>> r.insert({'k1': {'const': 'v2'}, 'k2': {'type': 'string'}}, 'C 2')
+    >>> for m in [{'k1': 'v1', 'k2': 'v1'}, {'k1': 'v1', 'k2': 2},
+    ...           {'k1': 'v2', 'k2': 'v1'}, {'k1': 'v2', 'k2': 2}]:
+    ...     print(f"{m}: {r.check('C 2', m)}")
+    {'k1': 'v1', 'k2': 'v1'}: False
+    {'k1': 'v1', 'k2': 2}: False
+    {'k1': 'v2', 'k2': 'v1'}: True
+    {'k1': 'v2', 'k2': 2}: False
+    >>> r.insert({'k1': {'const': 'v2'}, 'k2': {'type': 'integer'}}, 'C 3')
+    >>> for m in [{'k1': 'v1', 'k2': 'v1'}, {'k1': 'v1', 'k2': 2},
+    ...           {'k1': 'v2', 'k2': 'v1'}, {'k1': 'v2', 'k2': 2}]:
+    ...     print(f"{m}: {r.check('C 3', m)}")
+    {'k1': 'v1', 'k2': 'v1'}: False
+    {'k1': 'v1', 'k2': 2}: False
+    {'k1': 'v2', 'k2': 'v1'}: False
+    {'k1': 'v2', 'k2': 2}: True
+
+    The order of key-value pairs does not have to match the order in the
+    messages and keys can be left out:
+    >>> r.insert({'k2': {'const': 2}}, 'C 4')
+    >>> for m in [{'k1': 'v1', 'k2': 'v1'}, {'k1': 'v1', 'k2': 2},
+    ...           {'k1': 'v2', 'k2': 'v1'}, {'k1': 'v2', 'k2': 2}]:
+    ...     print(f"{m}: {r.check('C 4', m)}")
+    {'k1': 'v1', 'k2': 'v1'}: False
+    {'k1': 'v1', 'k2': 2}: True
+    {'k1': 'v2', 'k2': 'v1'}: False
+    {'k1': 'v2', 'k2': 2}: True
+
+    A registration for an empty template matches all messages:
+    >>> r.insert({}, 'C 5')
+    >>> for m in [{'k1': 'v1', 'k2': 'v1'}, {'k1': 'v1', 'k2': 2},
+    ...           {'k1': 'v2', 'k2': 'v1'}, {'k1': 'v2', 'k2': 2}]:
+    ...     print(f"{m}: {r.check('C 5', m)}")
+    {'k1': 'v1', 'k2': 'v1'}: True
+    {'k1': 'v1', 'k2': 2}: True
+    {'k1': 'v2', 'k2': 'v1'}: True
+    {'k1': 'v2', 'k2': 2}: True
+
+    A client can be registered for multiple templates:
+    >>> r.insert({'k1': {'const': 'v1'}}, 'C 6')
+    >>> r.insert({'k2': {'const': 'v1'}}, 'C 6')
+    >>> for m in [{'k1': 'v1', 'k2': 'v1'}, {'k1': 'v1', 'k2': 2},
+    ...           {'k1': 'v2', 'k2': 'v1'}, {'k1': 'v2', 'k2': 2}]:
+    ...     print(f"{m}: {r.check('C 6', m)}")
+    {'k1': 'v1', 'k2': 'v1'}: True
+    {'k1': 'v1', 'k2': 2}: True
+    {'k1': 'v2', 'k2': 'v1'}: True
+    {'k1': 'v2', 'k2': 2}: False
+
+    Clients can be deregistered again (the result is False if the registry
+    is empty after the deletion):
+    >>> r.insert({'k1': {'const': 'v1'}}, 'C 7')
+    >>> r.delete('C 7')
+    True
+    >>> for m in [{'k1': 'v1', 'k2': 'v1'}, {'k1': 'v1', 'k2': 2},
+    ...           {'k1': 'v2', 'k2': 'v1'}, {'k1': 'v2', 'k2': 2}]:
+    ...     print(f"{m}: {r.check('C 7', m)}")
+    {'k1': 'v1', 'k2': 'v1'}: False
+    {'k1': 'v1', 'k2': 2}: False
+    {'k1': 'v2', 'k2': 'v1'}: False
+    {'k1': 'v2', 'k2': 2}: False
+
+    The get function returns all clients with registered templates matching
+    a given message:
+    >>> for m in [{'k1': 'v1', 'k2': 'v1'}, {'k1': 'v1', 'k2': 2},
+    ...           {'k1': 'v2', 'k2': 'v1'}, {'k1': 'v2', 'k2': 2}]:
+    ...     print(f"{m}: {r.get(m)}")
+    {'k1': 'v1', 'k2': 'v1'}: ['C 5', 'C 1', 'C 6']
+    {'k1': 'v1', 'k2': 2}: ['C 5', 'C 1', 'C 6', 'C 4']
+    {'k1': 'v2', 'k2': 'v1'}: ['C 5', 'C 2', 'C 6']
+    {'k1': 'v2', 'k2': 2}: ['C 5', 'C 3', 'C 4']
+
+    The get_templates function returns all templates for a given client:
+    >>> for c in ['C 1', 'C 2', 'C 3', 'C 4', 'C 5', 'C 6']:
+    ...     print(f"{c}: {r.get_templates(c)}")
+    C 1: [{'k1': {'const': 'v1'}}]
+    C 2: [{'k1': {'const': 'v2'}, 'k2': {'type': 'string'}}]
+    C 3: [{'k1': {'const': 'v2'}, 'k2': {'type': 'integer'}}]
+    C 4: [{'k2': {'const': 2}}]
+    C 5: [{}]
+    C 6: [{'k1': {'const': 'v1'}}, {'k2': {'const': 'v1'}}]
+    """
+
+    def __init__(self) -> None:
+        """Initialise an empty registry.
+
+        >>> r = MessageTemplateRegistry()
+        """
+        self._clients: List[str] = []
+        self._children: Dict[str, Dict[str, MessageTemplateRegistry]] = {}
+
+    def insert(self, template: MessageTemplate, client: str) -> None:
+        """Register a client for a template.
+
+        >>> r = MessageTemplateRegistry()
+        >>> r.insert({'k1': {'const': 'v1'}, 'k2': {'const': 'v1'}}, 'C 1')
+        >>> r.insert({'k1': {'const': 'v1'}, 'k2': {'const': 'v2'}}, 'C 2')
+        >>> r.insert({'k1': {'const': 'v2'}, 'k2': {'const': 'v1'}}, 'C 3')
+        >>> r.insert({'k1': {'const': 'v2'}, 'k2': {'const': 'v2'}}, 'C 4')
+        >>> r.insert({}, 'C 5')
+
+        Implementation details:
+        -----------------------
+        The tree nodes on the way to a registered object are used/created
+        in the order given in the message template, which can be used to
+        design more efficient lookups (e.g., putting rarer key-value pairs
+        earlier in the template).
+        >>> r._clients
+        ['C 5']
+        >>> r._children.keys()
+        dict_keys(['k1'])
+        >>> r._children['k1'].keys()
+        dict_keys(['{"const": "v1"}', '{"const": "v2"}'])
+        >>> r._children['k1']['{"const": "v1"}']._clients
+        []
+        >>> r._children['k1']['{"const": "v1"}']._children.keys()
+        dict_keys(['k2'])
+        >>> r._children['k1']['{"const": "v1"}']._children['k2'].keys()
+        dict_keys(['{"const": "v1"}', '{"const": "v2"}'])
+        >>> (r._children['k1']['{"const": "v1"}']
+        ...   ._children['k2']['{"const": "v1"}'])._clients
+        ['C 1']
+        >>> (r._children['k1']['{"const": "v1"}']
+        ...   ._children['k2']['{"const": "v1"}'])._children.keys()
+        dict_keys([])
+        >>> (r._children['k1']['{"const": "v1"}']
+        ...   ._children['k2']['{"const": "v2"}'])._clients
+        ['C 2']
+        >>> (r._children['k1']['{"const": "v1"}']
+        ...   ._children['k2']['{"const": "v2"}'])._children.keys()
+        dict_keys([])
+        >>> r._children['k1']['{"const": "v2"}']._clients
+        []
+        >>> r._children['k1']['{"const": "v2"}']._children.keys()
+        dict_keys(['k2'])
+        >>> r._children['k1']['{"const": "v2"}']._children['k2'].keys()
+        dict_keys(['{"const": "v1"}', '{"const": "v2"}'])
+        >>> (r._children['k1']['{"const": "v2"}']
+        ...   ._children['k2']['{"const": "v1"}'])._clients
+        ['C 3']
+        >>> (r._children['k1']['{"const": "v2"}']
+        ...   ._children['k2']['{"const": "v1"}'])._children.keys()
+        dict_keys([])
+        >>> (r._children['k1']['{"const": "v2"}']
+        ...   ._children['k2']['{"const": "v2"}'])._clients
+        ['C 4']
+        >>> (r._children['k1']['{"const": "v2"}']
+        ...   ._children['k2']['{"const": "v2"}'])._children.keys()
+        dict_keys([])
+        """
+        if not template:
+            self._clients.append(client)
+        else:
+            key, schema = next(iter(template.items()))
+            schema_string = json.dumps(schema)
+            reduced_template = MessageTemplate({k: template[k]
+                                                for k in template
+                                                if k != key})
+            if key not in self._children:
+                self._children[key] = {}
+            if schema_string not in self._children[key]:
+                self._children[key][schema_string] = MessageTemplateRegistry()
+            self._children[key][schema_string].insert(reduced_template, client)
+
+    def delete(self, client: str) -> bool:
+        """Unregister a client from all templates.
+
+        >>> r = MessageTemplateRegistry()
+        >>> r.insert({'k1': {'const': 'v1'}, 'k2': {'const': 'v1'}}, 'C 1')
+        >>> r.insert({'k1': {'const': 'v1'}, 'k2': {'const': 'v2'}}, 'C 2')
+        >>> r.insert({'k1': {'const': 'v2'}, 'k2': {'const': 'v1'}}, 'C 3')
+        >>> r.insert({'k1': {'const': 'v2'}, 'k2': {'const': 'v2'}}, 'C 4')
+        >>> r.insert({}, 'C 5')
+        >>> r.delete('C 3')
+        True
+        >>> r.delete('C 4')
+        True
+
+        Implementation details:
+        -----------------------
+        If parts of the tree become superfluous by the deletion of the
+        client, they are also completely removed to reduce the lookup
+        effort and keep the tree clean.
+        >>> r._clients
+        ['C 5']
+        >>> r._children.keys()
+        dict_keys(['k1'])
+        >>> r._children['k1'].keys()
+        dict_keys(['{"const": "v1"}'])
+        >>> r._children['k1']['{"const": "v1"}']._clients
+        []
+        >>> r._children['k1']['{"const": "v1"}']._children.keys()
+        dict_keys(['k2'])
+        >>> r._children['k1']['{"const": "v1"}']._children['k2'].keys()
+        dict_keys(['{"const": "v1"}', '{"const": "v2"}'])
+        >>> (r._children['k1']['{"const": "v1"}']
+        ...   ._children['k2']['{"const": "v1"}'])._clients
+        ['C 1']
+        >>> (r._children['k1']['{"const": "v1"}']
+        ...   ._children['k2']['{"const": "v1"}'])._children.keys()
+        dict_keys([])
+        >>> (r._children['k1']['{"const": "v1"}']
+        ...   ._children['k2']['{"const": "v2"}'])._clients
+        ['C 2']
+        >>> (r._children['k1']['{"const": "v1"}']
+        ...   ._children['k2']['{"const": "v2"}'])._children.keys()
+        dict_keys([])
+        """
+        self._clients = [c for c in self._clients if c != client]
+        new_children: Dict[str, Dict[str, MessageTemplateRegistry]] = {}
+        for key in self._children:
+            new_children[key] = {}
+            for schema in self._children[key]:
+                if self._children[key][schema].delete(client):
+                    new_children[key][schema] = self._children[key][schema]
+            if not new_children[key]:
+                del new_children[key]
+        self._children = new_children
+        if self._clients or self._children:
+            return True
+        return False
+
+    def check(self, client: str, message: Message) -> bool:
+        """Get if a client has a registered template matching a message.
+
+        >>> r = MessageTemplateRegistry()
+        >>> r.insert({'k1': {'const': 'v1'}}, 'Client 1')
+        >>> for m in [{'k1': 'v1', 'k2': 'v1'}, {'k1': 'v1', 'k2': 'v2'},
+        ...           {'k1': 'v2', 'k2': 'v1'}, {'k1': 'v2', 'k2': 'v2'}]:
+        ...     print(f"{m}: {r.check('Client 1', m)}")
+        {'k1': 'v1', 'k2': 'v1'}: True
+        {'k1': 'v1', 'k2': 'v2'}: True
+        {'k1': 'v2', 'k2': 'v1'}: False
+        {'k1': 'v2', 'k2': 'v2'}: False
+        >>> r.insert({'k2': {'const': 'v2'}}, 'Client 2')
+        >>> for m in [{'k1': 'v1', 'k2': 'v1'}, {'k1': 'v1', 'k2': 'v2'},
+        ...           {'k1': 'v2', 'k2': 'v1'}, {'k1': 'v2', 'k2': 'v2'}]:
+        ...     print(f"{m}: {r.check('Client 2', m)}")
+        {'k1': 'v1', 'k2': 'v1'}: False
+        {'k1': 'v1', 'k2': 'v2'}: True
+        {'k1': 'v2', 'k2': 'v1'}: False
+        {'k1': 'v2', 'k2': 'v2'}: True
+        """
+        if client in self._clients:
+            return True
+        for key in self._children:
+            if key in message:
+                for schema_string in self._children[key]:
+                    schema = json.loads(schema_string)
+                    validator = jsonschema.Draft7Validator(schema)
+                    validated = True
+                    for error in validator.iter_errors(message[key]):
+                        validated = False
+                    if validated:
+                        child = self._children[key][schema_string]
+                        if child.check(client, message):
+                            return True
+        return False
+
+    def get(self, message: Message) -> List[str]:
+        """Get all clients registered for templates matching a message.
+
+        >>> r = MessageTemplateRegistry()
+        >>> r.insert({'k1': {'const': 'v1'}}, 'Client 1')
+        >>> r.insert({'k2': {'const': 'v2'}}, 'Client 2')
+        >>> for m in [{'k1': 'v1', 'k2': 'v1'}, {'k1': 'v1', 'k2': 'v2'},
+        ...           {'k1': 'v2', 'k2': 'v1'}, {'k1': 'v2', 'k2': 'v2'}]:
+        ...     print(f"{m}: {r.get(m)}")
+        {'k1': 'v1', 'k2': 'v1'}: ['Client 1']
+        {'k1': 'v1', 'k2': 'v2'}: ['Client 1', 'Client 2']
+        {'k1': 'v2', 'k2': 'v1'}: []
+        {'k1': 'v2', 'k2': 'v2'}: ['Client 2']
+        """
+        result = []
+        for client in self._clients:
+            if client not in result:
+                result.append(client)
+        for key in self._children:
+            if key in message:
+                for schema_string in self._children[key]:
+                    schema = json.loads(schema_string)
+                    validator = jsonschema.Draft7Validator(schema)
+                    validated = True
+                    for error in validator.iter_errors(message[key]):
+                        validated = False
+                    if validated:
+                        child = self._children[key][schema_string]
+                        for client in child.get(message):
+                            if client not in result:
+                                result.append(client)
+        return result
+
+    def get_templates(self, client: str) -> List[MessageTemplate]:
+        """Get all templates for a client.
+
+        >>> r = MessageTemplateRegistry()
+        >>> r.insert({'k1': {'const': 'v1'}}, 'Client 1')
+        >>> r.get_templates('Client 1')
+        [{'k1': {'const': 'v1'}}]
+        >>> r.insert({'k1': {'const': 'v2'},
+        ...           'k2': {'type': 'string'}}, 'Client 2')
+        >>> r.get_templates('Client 2')
+        [{'k1': {'const': 'v2'}, 'k2': {'type': 'string'}}]
+        >>> r.insert({'k1': {'const': 'v2'},
+        ...           'k2': {'type': 'integer'}}, 'Client 3')
+        >>> r.get_templates('Client 3')
+        [{'k1': {'const': 'v2'}, 'k2': {'type': 'integer'}}]
+        >>> r.insert({'k2': {'const': 2}}, 'Client 4')
+        >>> r.get_templates('Client 4')
+        [{'k2': {'const': 2}}]
+        >>> r.insert({}, 'Client 5')
+        >>> r.get_templates('Client 5')
+        [{}]
+        >>> r.insert({'k1': {'const': 'v1'}}, 'Client 6')
+        >>> r.insert({'k2': {'const': 'v1'}}, 'Client 6')
+        >>> r.get_templates('Client 6')
+        [{'k1': {'const': 'v1'}}, {'k2': {'const': 'v1'}}]
+        """
+        result = []
+        if client in self._clients:
+            result.append(MessageTemplate())
+        for key in self._children:
+            for schema_string in self._children[key]:
+                schema = json.loads(schema_string)
+                child = self._children[key][schema_string]
+                for template in child.get_templates(client):
+                    current = MessageTemplate({key: schema})
+                    current.update(template)
+                    result.append(current)
+        return result
+
+

Methods

+
+
+def insert(self, template: MessageTemplate, client: str) ‑> NoneType +
+
+

Register a client for a template.

+
>>> r = MessageTemplateRegistry()
+>>> r.insert({'k1': {'const': 'v1'}, 'k2': {'const': 'v1'}}, 'C 1')
+>>> r.insert({'k1': {'const': 'v1'}, 'k2': {'const': 'v2'}}, 'C 2')
+>>> r.insert({'k1': {'const': 'v2'}, 'k2': {'const': 'v1'}}, 'C 3')
+>>> r.insert({'k1': {'const': 'v2'}, 'k2': {'const': 'v2'}}, 'C 4')
+>>> r.insert({}, 'C 5')
+
+

Implementation details:

+

The tree nodes on the way to a registered object are used/created +in the order given in the message template, which can be used to +design more efficient lookups (e.g., putting rarer key-value pairs +earlier in the template).

+
>>> r._clients
+['C 5']
+>>> r._children.keys()
+dict_keys(['k1'])
+>>> r._children['k1'].keys()
+dict_keys(['{"const": "v1"}', '{"const": "v2"}'])
+>>> r._children['k1']['{"const": "v1"}']._clients
+[]
+>>> r._children['k1']['{"const": "v1"}']._children.keys()
+dict_keys(['k2'])
+>>> r._children['k1']['{"const": "v1"}']._children['k2'].keys()
+dict_keys(['{"const": "v1"}', '{"const": "v2"}'])
+>>> (r._children['k1']['{"const": "v1"}']
+...   ._children['k2']['{"const": "v1"}'])._clients
+['C 1']
+>>> (r._children['k1']['{"const": "v1"}']
+...   ._children['k2']['{"const": "v1"}'])._children.keys()
+dict_keys([])
+>>> (r._children['k1']['{"const": "v1"}']
+...   ._children['k2']['{"const": "v2"}'])._clients
+['C 2']
+>>> (r._children['k1']['{"const": "v1"}']
+...   ._children['k2']['{"const": "v2"}'])._children.keys()
+dict_keys([])
+>>> r._children['k1']['{"const": "v2"}']._clients
+[]
+>>> r._children['k1']['{"const": "v2"}']._children.keys()
+dict_keys(['k2'])
+>>> r._children['k1']['{"const": "v2"}']._children['k2'].keys()
+dict_keys(['{"const": "v1"}', '{"const": "v2"}'])
+>>> (r._children['k1']['{"const": "v2"}']
+...   ._children['k2']['{"const": "v1"}'])._clients
+['C 3']
+>>> (r._children['k1']['{"const": "v2"}']
+...   ._children['k2']['{"const": "v1"}'])._children.keys()
+dict_keys([])
+>>> (r._children['k1']['{"const": "v2"}']
+...   ._children['k2']['{"const": "v2"}'])._clients
+['C 4']
+>>> (r._children['k1']['{"const": "v2"}']
+...   ._children['k2']['{"const": "v2"}'])._children.keys()
+dict_keys([])
+
+
+ +Expand source code + +
def insert(self, template: MessageTemplate, client: str) -> None:
+    """Register a client for a template.
+
+    >>> r = MessageTemplateRegistry()
+    >>> r.insert({'k1': {'const': 'v1'}, 'k2': {'const': 'v1'}}, 'C 1')
+    >>> r.insert({'k1': {'const': 'v1'}, 'k2': {'const': 'v2'}}, 'C 2')
+    >>> r.insert({'k1': {'const': 'v2'}, 'k2': {'const': 'v1'}}, 'C 3')
+    >>> r.insert({'k1': {'const': 'v2'}, 'k2': {'const': 'v2'}}, 'C 4')
+    >>> r.insert({}, 'C 5')
+
+    Implementation details:
+    -----------------------
+    The tree nodes on the way to a registered object are used/created
+    in the order given in the message template, which can be used to
+    design more efficient lookups (e.g., putting rarer key-value pairs
+    earlier in the template).
+    >>> r._clients
+    ['C 5']
+    >>> r._children.keys()
+    dict_keys(['k1'])
+    >>> r._children['k1'].keys()
+    dict_keys(['{"const": "v1"}', '{"const": "v2"}'])
+    >>> r._children['k1']['{"const": "v1"}']._clients
+    []
+    >>> r._children['k1']['{"const": "v1"}']._children.keys()
+    dict_keys(['k2'])
+    >>> r._children['k1']['{"const": "v1"}']._children['k2'].keys()
+    dict_keys(['{"const": "v1"}', '{"const": "v2"}'])
+    >>> (r._children['k1']['{"const": "v1"}']
+    ...   ._children['k2']['{"const": "v1"}'])._clients
+    ['C 1']
+    >>> (r._children['k1']['{"const": "v1"}']
+    ...   ._children['k2']['{"const": "v1"}'])._children.keys()
+    dict_keys([])
+    >>> (r._children['k1']['{"const": "v1"}']
+    ...   ._children['k2']['{"const": "v2"}'])._clients
+    ['C 2']
+    >>> (r._children['k1']['{"const": "v1"}']
+    ...   ._children['k2']['{"const": "v2"}'])._children.keys()
+    dict_keys([])
+    >>> r._children['k1']['{"const": "v2"}']._clients
+    []
+    >>> r._children['k1']['{"const": "v2"}']._children.keys()
+    dict_keys(['k2'])
+    >>> r._children['k1']['{"const": "v2"}']._children['k2'].keys()
+    dict_keys(['{"const": "v1"}', '{"const": "v2"}'])
+    >>> (r._children['k1']['{"const": "v2"}']
+    ...   ._children['k2']['{"const": "v1"}'])._clients
+    ['C 3']
+    >>> (r._children['k1']['{"const": "v2"}']
+    ...   ._children['k2']['{"const": "v1"}'])._children.keys()
+    dict_keys([])
+    >>> (r._children['k1']['{"const": "v2"}']
+    ...   ._children['k2']['{"const": "v2"}'])._clients
+    ['C 4']
+    >>> (r._children['k1']['{"const": "v2"}']
+    ...   ._children['k2']['{"const": "v2"}'])._children.keys()
+    dict_keys([])
+    """
+    if not template:
+        self._clients.append(client)
+    else:
+        key, schema = next(iter(template.items()))
+        schema_string = json.dumps(schema)
+        reduced_template = MessageTemplate({k: template[k]
+                                            for k in template
+                                            if k != key})
+        if key not in self._children:
+            self._children[key] = {}
+        if schema_string not in self._children[key]:
+            self._children[key][schema_string] = MessageTemplateRegistry()
+        self._children[key][schema_string].insert(reduced_template, client)
+
+
+
+def delete(self, client: str) ‑> bool +
+
+

Unregister a client from all templates.

+
>>> r = MessageTemplateRegistry()
+>>> r.insert({'k1': {'const': 'v1'}, 'k2': {'const': 'v1'}}, 'C 1')
+>>> r.insert({'k1': {'const': 'v1'}, 'k2': {'const': 'v2'}}, 'C 2')
+>>> r.insert({'k1': {'const': 'v2'}, 'k2': {'const': 'v1'}}, 'C 3')
+>>> r.insert({'k1': {'const': 'v2'}, 'k2': {'const': 'v2'}}, 'C 4')
+>>> r.insert({}, 'C 5')
+>>> r.delete('C 3')
+True
+>>> r.delete('C 4')
+True
+
+

Implementation details:

+

If parts of the tree become superfluous by the deletion of the +client, they are also completely removed to reduce the lookup +effort and keep the tree clean.

+
>>> r._clients
+['C 5']
+>>> r._children.keys()
+dict_keys(['k1'])
+>>> r._children['k1'].keys()
+dict_keys(['{"const": "v1"}'])
+>>> r._children['k1']['{"const": "v1"}']._clients
+[]
+>>> r._children['k1']['{"const": "v1"}']._children.keys()
+dict_keys(['k2'])
+>>> r._children['k1']['{"const": "v1"}']._children['k2'].keys()
+dict_keys(['{"const": "v1"}', '{"const": "v2"}'])
+>>> (r._children['k1']['{"const": "v1"}']
+...   ._children['k2']['{"const": "v1"}'])._clients
+['C 1']
+>>> (r._children['k1']['{"const": "v1"}']
+...   ._children['k2']['{"const": "v1"}'])._children.keys()
+dict_keys([])
+>>> (r._children['k1']['{"const": "v1"}']
+...   ._children['k2']['{"const": "v2"}'])._clients
+['C 2']
+>>> (r._children['k1']['{"const": "v1"}']
+...   ._children['k2']['{"const": "v2"}'])._children.keys()
+dict_keys([])
+
+
+ +Expand source code + +
def delete(self, client: str) -> bool:
+    """Unregister a client from all templates.
+
+    >>> r = MessageTemplateRegistry()
+    >>> r.insert({'k1': {'const': 'v1'}, 'k2': {'const': 'v1'}}, 'C 1')
+    >>> r.insert({'k1': {'const': 'v1'}, 'k2': {'const': 'v2'}}, 'C 2')
+    >>> r.insert({'k1': {'const': 'v2'}, 'k2': {'const': 'v1'}}, 'C 3')
+    >>> r.insert({'k1': {'const': 'v2'}, 'k2': {'const': 'v2'}}, 'C 4')
+    >>> r.insert({}, 'C 5')
+    >>> r.delete('C 3')
+    True
+    >>> r.delete('C 4')
+    True
+
+    Implementation details:
+    -----------------------
+    If parts of the tree become superfluous by the deletion of the
+    client, they are also completely removed to reduce the lookup
+    effort and keep the tree clean.
+    >>> r._clients
+    ['C 5']
+    >>> r._children.keys()
+    dict_keys(['k1'])
+    >>> r._children['k1'].keys()
+    dict_keys(['{"const": "v1"}'])
+    >>> r._children['k1']['{"const": "v1"}']._clients
+    []
+    >>> r._children['k1']['{"const": "v1"}']._children.keys()
+    dict_keys(['k2'])
+    >>> r._children['k1']['{"const": "v1"}']._children['k2'].keys()
+    dict_keys(['{"const": "v1"}', '{"const": "v2"}'])
+    >>> (r._children['k1']['{"const": "v1"}']
+    ...   ._children['k2']['{"const": "v1"}'])._clients
+    ['C 1']
+    >>> (r._children['k1']['{"const": "v1"}']
+    ...   ._children['k2']['{"const": "v1"}'])._children.keys()
+    dict_keys([])
+    >>> (r._children['k1']['{"const": "v1"}']
+    ...   ._children['k2']['{"const": "v2"}'])._clients
+    ['C 2']
+    >>> (r._children['k1']['{"const": "v1"}']
+    ...   ._children['k2']['{"const": "v2"}'])._children.keys()
+    dict_keys([])
+    """
+    self._clients = [c for c in self._clients if c != client]
+    new_children: Dict[str, Dict[str, MessageTemplateRegistry]] = {}
+    for key in self._children:
+        new_children[key] = {}
+        for schema in self._children[key]:
+            if self._children[key][schema].delete(client):
+                new_children[key][schema] = self._children[key][schema]
+        if not new_children[key]:
+            del new_children[key]
+    self._children = new_children
+    if self._clients or self._children:
+        return True
+    return False
+
+
+
+def check(self, client: str, message: Message) ‑> bool +
+
+

Get if a client has a registered template matching a message.

+
>>> r = MessageTemplateRegistry()
+>>> r.insert({'k1': {'const': 'v1'}}, 'Client 1')
+>>> for m in [{'k1': 'v1', 'k2': 'v1'}, {'k1': 'v1', 'k2': 'v2'},
+...           {'k1': 'v2', 'k2': 'v1'}, {'k1': 'v2', 'k2': 'v2'}]:
+...     print(f"{m}: {r.check('Client 1', m)}")
+{'k1': 'v1', 'k2': 'v1'}: True
+{'k1': 'v1', 'k2': 'v2'}: True
+{'k1': 'v2', 'k2': 'v1'}: False
+{'k1': 'v2', 'k2': 'v2'}: False
+>>> r.insert({'k2': {'const': 'v2'}}, 'Client 2')
+>>> for m in [{'k1': 'v1', 'k2': 'v1'}, {'k1': 'v1', 'k2': 'v2'},
+...           {'k1': 'v2', 'k2': 'v1'}, {'k1': 'v2', 'k2': 'v2'}]:
+...     print(f"{m}: {r.check('Client 2', m)}")
+{'k1': 'v1', 'k2': 'v1'}: False
+{'k1': 'v1', 'k2': 'v2'}: True
+{'k1': 'v2', 'k2': 'v1'}: False
+{'k1': 'v2', 'k2': 'v2'}: True
+
+
+ +Expand source code + +
def check(self, client: str, message: Message) -> bool:
+    """Get if a client has a registered template matching a message.
+
+    >>> r = MessageTemplateRegistry()
+    >>> r.insert({'k1': {'const': 'v1'}}, 'Client 1')
+    >>> for m in [{'k1': 'v1', 'k2': 'v1'}, {'k1': 'v1', 'k2': 'v2'},
+    ...           {'k1': 'v2', 'k2': 'v1'}, {'k1': 'v2', 'k2': 'v2'}]:
+    ...     print(f"{m}: {r.check('Client 1', m)}")
+    {'k1': 'v1', 'k2': 'v1'}: True
+    {'k1': 'v1', 'k2': 'v2'}: True
+    {'k1': 'v2', 'k2': 'v1'}: False
+    {'k1': 'v2', 'k2': 'v2'}: False
+    >>> r.insert({'k2': {'const': 'v2'}}, 'Client 2')
+    >>> for m in [{'k1': 'v1', 'k2': 'v1'}, {'k1': 'v1', 'k2': 'v2'},
+    ...           {'k1': 'v2', 'k2': 'v1'}, {'k1': 'v2', 'k2': 'v2'}]:
+    ...     print(f"{m}: {r.check('Client 2', m)}")
+    {'k1': 'v1', 'k2': 'v1'}: False
+    {'k1': 'v1', 'k2': 'v2'}: True
+    {'k1': 'v2', 'k2': 'v1'}: False
+    {'k1': 'v2', 'k2': 'v2'}: True
+    """
+    if client in self._clients:
+        return True
+    for key in self._children:
+        if key in message:
+            for schema_string in self._children[key]:
+                schema = json.loads(schema_string)
+                validator = jsonschema.Draft7Validator(schema)
+                validated = True
+                for error in validator.iter_errors(message[key]):
+                    validated = False
+                if validated:
+                    child = self._children[key][schema_string]
+                    if child.check(client, message):
+                        return True
+    return False
+
+
+
+def get(self, message: Message) ‑> List[str] +
+
+

Get all clients registered for templates matching a message.

+
>>> r = MessageTemplateRegistry()
+>>> r.insert({'k1': {'const': 'v1'}}, 'Client 1')
+>>> r.insert({'k2': {'const': 'v2'}}, 'Client 2')
+>>> for m in [{'k1': 'v1', 'k2': 'v1'}, {'k1': 'v1', 'k2': 'v2'},
+...           {'k1': 'v2', 'k2': 'v1'}, {'k1': 'v2', 'k2': 'v2'}]:
+...     print(f"{m}: {r.get(m)}")
+{'k1': 'v1', 'k2': 'v1'}: ['Client 1']
+{'k1': 'v1', 'k2': 'v2'}: ['Client 1', 'Client 2']
+{'k1': 'v2', 'k2': 'v1'}: []
+{'k1': 'v2', 'k2': 'v2'}: ['Client 2']
+
+
+ +Expand source code + +
def get(self, message: Message) -> List[str]:
+    """Get all clients registered for templates matching a message.
+
+    >>> r = MessageTemplateRegistry()
+    >>> r.insert({'k1': {'const': 'v1'}}, 'Client 1')
+    >>> r.insert({'k2': {'const': 'v2'}}, 'Client 2')
+    >>> for m in [{'k1': 'v1', 'k2': 'v1'}, {'k1': 'v1', 'k2': 'v2'},
+    ...           {'k1': 'v2', 'k2': 'v1'}, {'k1': 'v2', 'k2': 'v2'}]:
+    ...     print(f"{m}: {r.get(m)}")
+    {'k1': 'v1', 'k2': 'v1'}: ['Client 1']
+    {'k1': 'v1', 'k2': 'v2'}: ['Client 1', 'Client 2']
+    {'k1': 'v2', 'k2': 'v1'}: []
+    {'k1': 'v2', 'k2': 'v2'}: ['Client 2']
+    """
+    result = []
+    for client in self._clients:
+        if client not in result:
+            result.append(client)
+    for key in self._children:
+        if key in message:
+            for schema_string in self._children[key]:
+                schema = json.loads(schema_string)
+                validator = jsonschema.Draft7Validator(schema)
+                validated = True
+                for error in validator.iter_errors(message[key]):
+                    validated = False
+                if validated:
+                    child = self._children[key][schema_string]
+                    for client in child.get(message):
+                        if client not in result:
+                            result.append(client)
+    return result
+
+
+
+def get_templates(self, client: str) ‑> List[MessageTemplate] +
+
+

Get all templates for a client.

+
>>> r = MessageTemplateRegistry()
+>>> r.insert({'k1': {'const': 'v1'}}, 'Client 1')
+>>> r.get_templates('Client 1')
+[{'k1': {'const': 'v1'}}]
+>>> r.insert({'k1': {'const': 'v2'},
+...           'k2': {'type': 'string'}}, 'Client 2')
+>>> r.get_templates('Client 2')
+[{'k1': {'const': 'v2'}, 'k2': {'type': 'string'}}]
+>>> r.insert({'k1': {'const': 'v2'},
+...           'k2': {'type': 'integer'}}, 'Client 3')
+>>> r.get_templates('Client 3')
+[{'k1': {'const': 'v2'}, 'k2': {'type': 'integer'}}]
+>>> r.insert({'k2': {'const': 2}}, 'Client 4')
+>>> r.get_templates('Client 4')
+[{'k2': {'const': 2}}]
+>>> r.insert({}, 'Client 5')
+>>> r.get_templates('Client 5')
+[{}]
+>>> r.insert({'k1': {'const': 'v1'}}, 'Client 6')
+>>> r.insert({'k2': {'const': 'v1'}}, 'Client 6')
+>>> r.get_templates('Client 6')
+[{'k1': {'const': 'v1'}}, {'k2': {'const': 'v1'}}]
+
+
+ +Expand source code + +
def get_templates(self, client: str) -> List[MessageTemplate]:
+    """Get all templates for a client.
+
+    >>> r = MessageTemplateRegistry()
+    >>> r.insert({'k1': {'const': 'v1'}}, 'Client 1')
+    >>> r.get_templates('Client 1')
+    [{'k1': {'const': 'v1'}}]
+    >>> r.insert({'k1': {'const': 'v2'},
+    ...           'k2': {'type': 'string'}}, 'Client 2')
+    >>> r.get_templates('Client 2')
+    [{'k1': {'const': 'v2'}, 'k2': {'type': 'string'}}]
+    >>> r.insert({'k1': {'const': 'v2'},
+    ...           'k2': {'type': 'integer'}}, 'Client 3')
+    >>> r.get_templates('Client 3')
+    [{'k1': {'const': 'v2'}, 'k2': {'type': 'integer'}}]
+    >>> r.insert({'k2': {'const': 2}}, 'Client 4')
+    >>> r.get_templates('Client 4')
+    [{'k2': {'const': 2}}]
+    >>> r.insert({}, 'Client 5')
+    >>> r.get_templates('Client 5')
+    [{}]
+    >>> r.insert({'k1': {'const': 'v1'}}, 'Client 6')
+    >>> r.insert({'k2': {'const': 'v1'}}, 'Client 6')
+    >>> r.get_templates('Client 6')
+    [{'k1': {'const': 'v1'}}, {'k2': {'const': 'v1'}}]
+    """
+    result = []
+    if client in self._clients:
+        result.append(MessageTemplate())
+    for key in self._children:
+        for schema_string in self._children[key]:
+            schema = json.loads(schema_string)
+            child = self._children[key][schema_string]
+            for template in child.get_templates(client):
+                current = MessageTemplate({key: schema})
+                current.update(template)
+                result.append(current)
+    return result
+
+
+
+
+
+class BusException +(*args, **kwargs) +
+
+

Raise for errors in using message bus.

+
+ +Expand source code + +
class BusException(Exception):
+    """Raise for errors in using message bus."""
+
+

Ancestors

+
    +
  • builtins.Exception
  • +
  • builtins.BaseException
  • +
+
+
+class MessageBus +
+
+

Provide an asynchronous message bus.

+

The bus executes asynchronous callbacks for all messages to be received +by a client. We use a simple callback printing the message in all +examples:

+
>>> def callback_for_receiver(receiver):
+...     print(f"Creating callback for {receiver}.")
+...     async def callback(message):
+...         print(f"{receiver}: {message}")
+...     return callback
+
+

Clients can be registered at the bus with a name, lists of message +templates they want to use for sending and receiving and a callback +function for receiving. An empty list of templates means that the +client does not want to send or receive any messages, respectively. +A list with an empty template means that it wants to send arbitrary +or receive all messages, respectively:

+
>>> async def setup(bus):
+...     print("Setting up.")
+...     bus.register('Logger', 'Test Plugin',
+...                  [],
+...                  [{}],
+...                  callback_for_receiver('Logger'))
+...     bus.register('Client 1', 'Test Plugin',
+...                  [{'k1': {'type': 'string'}}],
+...                  [{'target': {'const': 'Client 1'}}],
+...                  callback_for_receiver('Client 1'))
+...     bus.register('Client 2', 'Test Plugin',
+...                  [{}],
+...                  [{'target': {'const': 'Client 2'}}],
+...                  callback_for_receiver('Client 2'))
+
+

The bus itself is addressed by the empty string. It sends messages for +each registration and deregestration of a client with a key 'event' and +a value of 'registered' or 'unregistered', a key 'client' with the +client's name as value and for registrations also keys 'sends' and +'receives' with all templates registered for the client for sending and +receiving.

+

Clients can send to the bus with the send function. Each message has to +declare a sender. The send templates of that sender are checked for a +template matching the message. We cannot prevent arbitrary code from +impersonating any sender, but this should only be done in debugging or +management situations.

+

Messages that are intended for a specific client by convention have a +key 'target' with the target client's name as value. Such messages are +often commands to the client to do something, which is by convention +indicated by a key 'command' with a value that indicates what should be +done.

+

The bus, for example, reacts to a message with 'target': '' and +'command': 'get clients' by sending one message for each currently +registered with complete information about its registered send and +receive templates.

+
>>> async def send(bus):
+...     print("Sending messages.")
+...     await bus.send({'sender': 'Client 1', 'k1': 'Test'})
+...     await bus.send({'sender': 'Client 2', 'target': 'Client 1'})
+...     await bus.send({'sender': '', 'target': '',
+...                     'command': 'get clients'})
+
+

The run function executes the message bus forever. If we want to stop +it, we have to explicitly cancel the task:

+
>>> async def main():
+...     bus = MessageBus()
+...     await setup(bus)
+...     bus_task = asyncio.create_task(bus.run())
+...     await send(bus)
+...     await asyncio.sleep(0)
+...     bus_task.cancel()
+>>> asyncio.run(main())  # doctest: +NORMALIZE_WHITESPACE
+Setting up.
+Creating callback for Logger.
+Creating callback for Client 1.
+Creating callback for Client 2.
+Sending messages.
+Logger: {'sender': '', 'event': 'registered',
+         'client': 'Logger', 'plugin': 'Test Plugin',
+         'sends': [], 'receives': [{}]}
+Logger: {'sender': '', 'event': 'registered',
+         'client': 'Client 1', 'plugin': 'Test Plugin',
+         'sends': [{'k1': {'type': 'string'}}],
+         'receives': [{'target': {'const': 'Client 1'}}]}
+Logger: {'sender': '', 'event': 'registered',
+         'client': 'Client 2', 'plugin': 'Test Plugin',
+         'sends': [{}], 'receives': [{'target': {'const': 'Client 2'}}]}
+Logger: {'sender': 'Client 1', 'k1': 'Test'}
+Logger: {'sender': 'Client 2', 'target': 'Client 1'}
+Client 1: {'sender': 'Client 2', 'target': 'Client 1'}
+Logger: {'sender': '', 'target': '', 'command': 'get clients'}
+Logger: {'sender': '', 'client': 'Logger', 'plugin': 'Test Plugin',
+         'sends': [], 'receives': [{}]}
+Logger: {'sender': '', 'client': 'Client 1', 'plugin': 'Test Plugin',
+         'sends': [{'k1': {'type': 'string'}}],
+         'receives': [{'target': {'const': 'Client 1'}}]}
+Logger: {'sender': '', 'client': 'Client 2', 'plugin': 'Test Plugin',
+         'sends': [{}], 'receives': [{'target': {'const': 'Client 2'}}]}
+
+

Initialise a new bus without clients.

+
>>> async def main():
+...     bus = MessageBus()
+>>> asyncio.run(main())
+
+
+ +Expand source code + +
class MessageBus:
+    """Provide an asynchronous message bus.
+
+    The bus executes asynchronous callbacks for all messages to be received
+    by a client. We use a simple callback printing the message in all
+    examples:
+    >>> def callback_for_receiver(receiver):
+    ...     print(f"Creating callback for {receiver}.")
+    ...     async def callback(message):
+    ...         print(f"{receiver}: {message}")
+    ...     return callback
+
+    Clients can be registered at the bus with a name, lists of message
+    templates they want to use for sending and receiving and a callback
+    function for receiving. An empty list of templates means that the
+    client does not want to send or receive any messages, respectively.
+    A list with an empty template means that it wants to send arbitrary
+    or receive all messages, respectively:
+    >>> async def setup(bus):
+    ...     print("Setting up.")
+    ...     bus.register('Logger', 'Test Plugin',
+    ...                  [],
+    ...                  [{}],
+    ...                  callback_for_receiver('Logger'))
+    ...     bus.register('Client 1', 'Test Plugin',
+    ...                  [{'k1': {'type': 'string'}}],
+    ...                  [{'target': {'const': 'Client 1'}}],
+    ...                  callback_for_receiver('Client 1'))
+    ...     bus.register('Client 2', 'Test Plugin',
+    ...                  [{}],
+    ...                  [{'target': {'const': 'Client 2'}}],
+    ...                  callback_for_receiver('Client 2'))
+
+    The bus itself is addressed by the empty string. It sends messages for
+    each registration and deregestration of a client with a key 'event' and
+    a value of 'registered' or 'unregistered', a key 'client' with the
+    client's name as value and for registrations also keys 'sends' and
+    'receives' with all templates registered for the client for sending and
+    receiving.
+
+    Clients can send to the bus with the send function. Each message has to
+    declare a sender. The send templates of that sender are checked for a
+    template matching the message. We cannot prevent arbitrary code from
+    impersonating any sender, but this should only be done in debugging or
+    management situations.
+
+    Messages that are intended for a specific client by convention have a
+    key 'target' with the target client's name as value. Such messages are
+    often commands to the client to do something, which is by convention
+    indicated by a key 'command' with a value that indicates what should be
+    done.
+
+    The bus, for example, reacts to a message with 'target': '' and
+    'command': 'get clients' by sending one message for each currently
+    registered with complete information about its registered send and
+    receive templates.
+
+    >>> async def send(bus):
+    ...     print("Sending messages.")
+    ...     await bus.send({'sender': 'Client 1', 'k1': 'Test'})
+    ...     await bus.send({'sender': 'Client 2', 'target': 'Client 1'})
+    ...     await bus.send({'sender': '', 'target': '',
+    ...                     'command': 'get clients'})
+
+    The run function executes the message bus forever. If we want to stop
+    it, we have to explicitly cancel the task:
+    >>> async def main():
+    ...     bus = MessageBus()
+    ...     await setup(bus)
+    ...     bus_task = asyncio.create_task(bus.run())
+    ...     await send(bus)
+    ...     await asyncio.sleep(0)
+    ...     bus_task.cancel()
+    >>> asyncio.run(main())  # doctest: +NORMALIZE_WHITESPACE
+    Setting up.
+    Creating callback for Logger.
+    Creating callback for Client 1.
+    Creating callback for Client 2.
+    Sending messages.
+    Logger: {'sender': '', 'event': 'registered',
+             'client': 'Logger', 'plugin': 'Test Plugin',
+             'sends': [], 'receives': [{}]}
+    Logger: {'sender': '', 'event': 'registered',
+             'client': 'Client 1', 'plugin': 'Test Plugin',
+             'sends': [{'k1': {'type': 'string'}}],
+             'receives': [{'target': {'const': 'Client 1'}}]}
+    Logger: {'sender': '', 'event': 'registered',
+             'client': 'Client 2', 'plugin': 'Test Plugin',
+             'sends': [{}], 'receives': [{'target': {'const': 'Client 2'}}]}
+    Logger: {'sender': 'Client 1', 'k1': 'Test'}
+    Logger: {'sender': 'Client 2', 'target': 'Client 1'}
+    Client 1: {'sender': 'Client 2', 'target': 'Client 1'}
+    Logger: {'sender': '', 'target': '', 'command': 'get clients'}
+    Logger: {'sender': '', 'client': 'Logger', 'plugin': 'Test Plugin',
+             'sends': [], 'receives': [{}]}
+    Logger: {'sender': '', 'client': 'Client 1', 'plugin': 'Test Plugin',
+             'sends': [{'k1': {'type': 'string'}}],
+             'receives': [{'target': {'const': 'Client 1'}}]}
+    Logger: {'sender': '', 'client': 'Client 2', 'plugin': 'Test Plugin',
+             'sends': [{}], 'receives': [{'target': {'const': 'Client 2'}}]}
+    """
+
+    def __init__(self) -> None:
+        """Initialise a new bus without clients.
+
+        >>> async def main():
+        ...     bus = MessageBus()
+        >>> asyncio.run(main())
+        """
+        self._queue: asyncio.Queue = asyncio.Queue()
+        self._plugins: Dict[str, str] = {}
+        self._send_reg: MessageTemplateRegistry = MessageTemplateRegistry()
+        self._recv_reg: MessageTemplateRegistry = MessageTemplateRegistry()
+        self._callbacks: Dict[str, MessageCallback] = {}
+
+    def register(self, client: str, plugin: str,
+                 sends: Iterable[MessageTemplate],
+                 receives: Iterable[MessageTemplate],
+                 callback: MessageCallback) -> None:
+        """Register a client at the message bus.
+
+        >>> async def callback(message):
+        ...     print(message)
+        >>> async def main():
+        ...     bus = MessageBus()
+        ...     bus.register('Logger', 'Test Plugin',
+        ...                  [],    # send nothing
+        ...                  [{}],  # receive everything
+        ...                  callback)
+        ...     bus.register('Client 1', 'Test Plugin',
+        ...                  [{'k1': {'type': 'string'}}],
+        ...                      # send with key 'k1' and string value
+        ...                  [{'target': {'const': 'Client 1'}}],
+        ...                      # receive for this client
+        ...                  callback)
+        ...     bus.register('Client 2', 'Test Plugin',
+        ...                  [{}],  # send arbitrary
+        ...                  [{'target': {'const': 'Client 2'}}],
+        ...                      # receive for this client
+        ...                  callback)
+        >>> asyncio.run(main())
+        """
+        if not client:
+            raise BusException("Client name is not allowed to be empty.")
+        if client in self._plugins:
+            raise BusException(f"Client '{client}' already registered"
+                               " at message bus.")
+        event = Message('')
+        event['event'] = 'registered'
+        event['client'] = client
+        self._plugins[client] = plugin
+        event['plugin'] = plugin
+        for template in sends:
+            self._send_reg.insert(template, client)
+        event['sends'] = self._send_reg.get_templates(client)
+        for template in receives:
+            self._recv_reg.insert(template, client)
+        event['receives'] = self._recv_reg.get_templates(client)
+        self._callbacks[client] = callback
+        self._queue.put_nowait(event)
+
+    def unregister(self, client: str) -> None:
+        """Unregister a client from the message bus.
+
+        >>> async def callback(message):
+        ...     print(message)
+        >>> async def main():
+        ...     bus = MessageBus()
+        ...     bus.register('Client 1', 'Test Plugin',
+        ...                  [{'k1': {'type': 'string'}}],
+        ...                  [{'target': {'const': 'Client 1'}}],
+        ...                  callback)
+        ...     bus.unregister('Client 1')
+        >>> asyncio.run(main())
+        """
+        if client not in self._plugins:
+            return
+        event = Message('')
+        event['event'] = 'unregistered'
+        event['client'] = client
+        del self._plugins[client]
+        self._send_reg.delete(client)
+        self._recv_reg.delete(client)
+        if client in self._callbacks:
+            del self._callbacks[client]
+        self._queue.put_nowait(event)
+
+    async def run(self) -> None:
+        """Run the message bus forever.
+
+        >>> async def main():
+        ...     bus = MessageBus()
+        ...     bus_task = asyncio.create_task(bus.run())
+        ...     bus_task.cancel()
+        >>> asyncio.run(main())
+        """
+        while True:
+            message = await self._queue.get()
+            if ('target' in message and
+                    message['target'] == '' and
+                    'command' in message and
+                    message['command'] == 'get clients'):
+                for client in self._plugins:
+                    answer = Message('')
+                    answer['client'] = client
+                    answer['plugin'] = self._plugins[client]
+                    answer['sends'] = self._send_reg.get_templates(client)
+                    answer['receives'] = self._recv_reg.get_templates(client)
+                    await self._queue.put(answer)
+            for client in self._recv_reg.get(message):
+                await self._callbacks[client](message)
+            self._queue.task_done()
+
+    async def send(self, message: Message) -> None:
+        """Send a message to the message bus.
+
+        >>> async def callback(message):
+        ...     print(f"Got: {message}")
+        >>> async def main():
+        ...     bus = MessageBus()
+        ...     bus.register('Client 1', 'Test Plugin',
+        ...                  [{'k1': {'type': 'string'}}],
+        ...                  [{'target': {'const': 'Client 1'}}],
+        ...                  callback)
+        ...     bus.register('Client 2', 'Test Plugin',
+        ...                  [{}],
+        ...                  [{'target': {'const': 'Client 2'}}],
+        ...                  callback)
+        ...     bus_task = asyncio.create_task(bus.run())
+        ...     await bus.send({'sender': 'Client 1', 'target': 'Client 2',
+        ...                     'k1': 'Test'})
+        ...     await bus.send({'sender': 'Client 2', 'target': 'Client 1'})
+        ...     try:
+        ...         await bus.send({'sender': 'Client 1', 'target': 'Client 2',
+        ...                         'k1': 42})
+        ...     except BusException as e:
+        ...         print(e)
+        ...     await asyncio.sleep(0)
+        ...     bus_task.cancel()
+        >>> asyncio.run(main())  # doctest: +NORMALIZE_WHITESPACE
+        Message '{'sender': 'Client 1', 'target': 'Client 2', 'k1': 42}'
+        not allowed for sender 'Client 1'.
+        Got: {'sender': 'Client 1', 'target': 'Client 2', 'k1': 'Test'}
+        Got: {'sender': 'Client 2', 'target': 'Client 1'}
+        """
+        assert isinstance(message['sender'], str)
+        sender = message['sender']
+        if sender:
+            if not self._send_reg.check(sender, message):
+                raise BusException(f"Message '{message}' not allowed for"
+                                   f" sender '{sender}'.")
+        await self._queue.put(message)
+
+

Methods

+
+
+def register(self, client: str, plugin: str, sends: Iterable[MessageTemplate], receives: Iterable[MessageTemplate], callback: Callable[[ForwardRef('Message')], Coroutine[Any, Any, NoneType]]) ‑> NoneType +
+
+

Register a client at the message bus.

+
>>> async def callback(message):
+...     print(message)
+>>> async def main():
+...     bus = MessageBus()
+...     bus.register('Logger', 'Test Plugin',
+...                  [],    # send nothing
+...                  [{}],  # receive everything
+...                  callback)
+...     bus.register('Client 1', 'Test Plugin',
+...                  [{'k1': {'type': 'string'}}],
+...                      # send with key 'k1' and string value
+...                  [{'target': {'const': 'Client 1'}}],
+...                      # receive for this client
+...                  callback)
+...     bus.register('Client 2', 'Test Plugin',
+...                  [{}],  # send arbitrary
+...                  [{'target': {'const': 'Client 2'}}],
+...                      # receive for this client
+...                  callback)
+>>> asyncio.run(main())
+
+
+ +Expand source code + +
def register(self, client: str, plugin: str,
+             sends: Iterable[MessageTemplate],
+             receives: Iterable[MessageTemplate],
+             callback: MessageCallback) -> None:
+    """Register a client at the message bus.
+
+    >>> async def callback(message):
+    ...     print(message)
+    >>> async def main():
+    ...     bus = MessageBus()
+    ...     bus.register('Logger', 'Test Plugin',
+    ...                  [],    # send nothing
+    ...                  [{}],  # receive everything
+    ...                  callback)
+    ...     bus.register('Client 1', 'Test Plugin',
+    ...                  [{'k1': {'type': 'string'}}],
+    ...                      # send with key 'k1' and string value
+    ...                  [{'target': {'const': 'Client 1'}}],
+    ...                      # receive for this client
+    ...                  callback)
+    ...     bus.register('Client 2', 'Test Plugin',
+    ...                  [{}],  # send arbitrary
+    ...                  [{'target': {'const': 'Client 2'}}],
+    ...                      # receive for this client
+    ...                  callback)
+    >>> asyncio.run(main())
+    """
+    if not client:
+        raise BusException("Client name is not allowed to be empty.")
+    if client in self._plugins:
+        raise BusException(f"Client '{client}' already registered"
+                           " at message bus.")
+    event = Message('')
+    event['event'] = 'registered'
+    event['client'] = client
+    self._plugins[client] = plugin
+    event['plugin'] = plugin
+    for template in sends:
+        self._send_reg.insert(template, client)
+    event['sends'] = self._send_reg.get_templates(client)
+    for template in receives:
+        self._recv_reg.insert(template, client)
+    event['receives'] = self._recv_reg.get_templates(client)
+    self._callbacks[client] = callback
+    self._queue.put_nowait(event)
+
+
+
+def unregister(self, client: str) ‑> NoneType +
+
+

Unregister a client from the message bus.

+
>>> async def callback(message):
+...     print(message)
+>>> async def main():
+...     bus = MessageBus()
+...     bus.register('Client 1', 'Test Plugin',
+...                  [{'k1': {'type': 'string'}}],
+...                  [{'target': {'const': 'Client 1'}}],
+...                  callback)
+...     bus.unregister('Client 1')
+>>> asyncio.run(main())
+
+
+ +Expand source code + +
def unregister(self, client: str) -> None:
+    """Unregister a client from the message bus.
+
+    >>> async def callback(message):
+    ...     print(message)
+    >>> async def main():
+    ...     bus = MessageBus()
+    ...     bus.register('Client 1', 'Test Plugin',
+    ...                  [{'k1': {'type': 'string'}}],
+    ...                  [{'target': {'const': 'Client 1'}}],
+    ...                  callback)
+    ...     bus.unregister('Client 1')
+    >>> asyncio.run(main())
+    """
+    if client not in self._plugins:
+        return
+    event = Message('')
+    event['event'] = 'unregistered'
+    event['client'] = client
+    del self._plugins[client]
+    self._send_reg.delete(client)
+    self._recv_reg.delete(client)
+    if client in self._callbacks:
+        del self._callbacks[client]
+    self._queue.put_nowait(event)
+
+
+
+async def run(self) ‑> NoneType +
+
+

Run the message bus forever.

+
>>> async def main():
+...     bus = MessageBus()
+...     bus_task = asyncio.create_task(bus.run())
+...     bus_task.cancel()
+>>> asyncio.run(main())
+
+
+ +Expand source code + +
async def run(self) -> None:
+    """Run the message bus forever.
+
+    >>> async def main():
+    ...     bus = MessageBus()
+    ...     bus_task = asyncio.create_task(bus.run())
+    ...     bus_task.cancel()
+    >>> asyncio.run(main())
+    """
+    while True:
+        message = await self._queue.get()
+        if ('target' in message and
+                message['target'] == '' and
+                'command' in message and
+                message['command'] == 'get clients'):
+            for client in self._plugins:
+                answer = Message('')
+                answer['client'] = client
+                answer['plugin'] = self._plugins[client]
+                answer['sends'] = self._send_reg.get_templates(client)
+                answer['receives'] = self._recv_reg.get_templates(client)
+                await self._queue.put(answer)
+        for client in self._recv_reg.get(message):
+            await self._callbacks[client](message)
+        self._queue.task_done()
+
+
+
+async def send(self, message: Message) ‑> NoneType +
+
+

Send a message to the message bus.

+
>>> async def callback(message):
+...     print(f"Got: {message}")
+>>> async def main():
+...     bus = MessageBus()
+...     bus.register('Client 1', 'Test Plugin',
+...                  [{'k1': {'type': 'string'}}],
+...                  [{'target': {'const': 'Client 1'}}],
+...                  callback)
+...     bus.register('Client 2', 'Test Plugin',
+...                  [{}],
+...                  [{'target': {'const': 'Client 2'}}],
+...                  callback)
+...     bus_task = asyncio.create_task(bus.run())
+...     await bus.send({'sender': 'Client 1', 'target': 'Client 2',
+...                     'k1': 'Test'})
+...     await bus.send({'sender': 'Client 2', 'target': 'Client 1'})
+...     try:
+...         await bus.send({'sender': 'Client 1', 'target': 'Client 2',
+...                         'k1': 42})
+...     except BusException as e:
+...         print(e)
+...     await asyncio.sleep(0)
+...     bus_task.cancel()
+>>> asyncio.run(main())  # doctest: +NORMALIZE_WHITESPACE
+Message '{'sender': 'Client 1', 'target': 'Client 2', 'k1': 42}'
+not allowed for sender 'Client 1'.
+Got: {'sender': 'Client 1', 'target': 'Client 2', 'k1': 'Test'}
+Got: {'sender': 'Client 2', 'target': 'Client 1'}
+
+
+ +Expand source code + +
async def send(self, message: Message) -> None:
+    """Send a message to the message bus.
+
+    >>> async def callback(message):
+    ...     print(f"Got: {message}")
+    >>> async def main():
+    ...     bus = MessageBus()
+    ...     bus.register('Client 1', 'Test Plugin',
+    ...                  [{'k1': {'type': 'string'}}],
+    ...                  [{'target': {'const': 'Client 1'}}],
+    ...                  callback)
+    ...     bus.register('Client 2', 'Test Plugin',
+    ...                  [{}],
+    ...                  [{'target': {'const': 'Client 2'}}],
+    ...                  callback)
+    ...     bus_task = asyncio.create_task(bus.run())
+    ...     await bus.send({'sender': 'Client 1', 'target': 'Client 2',
+    ...                     'k1': 'Test'})
+    ...     await bus.send({'sender': 'Client 2', 'target': 'Client 1'})
+    ...     try:
+    ...         await bus.send({'sender': 'Client 1', 'target': 'Client 2',
+    ...                         'k1': 42})
+    ...     except BusException as e:
+    ...         print(e)
+    ...     await asyncio.sleep(0)
+    ...     bus_task.cancel()
+    >>> asyncio.run(main())  # doctest: +NORMALIZE_WHITESPACE
+    Message '{'sender': 'Client 1', 'target': 'Client 2', 'k1': 42}'
+    not allowed for sender 'Client 1'.
+    Got: {'sender': 'Client 1', 'target': 'Client 2', 'k1': 'Test'}
+    Got: {'sender': 'Client 2', 'target': 'Client 1'}
+    """
+    assert isinstance(message['sender'], str)
+    sender = message['sender']
+    if sender:
+        if not self._send_reg.check(sender, message):
+            raise BusException(f"Message '{message}' not allowed for"
+                               f" sender '{sender}'.")
+    await self._queue.put(message)
+
+
+
+
+
+
+
+ +
+ + + \ No newline at end of file diff --git a/doc/controlpi/pluginregistry.html b/doc/controlpi/pluginregistry.html new file mode 100644 index 0000000..ad9a464 --- /dev/null +++ b/doc/controlpi/pluginregistry.html @@ -0,0 +1,429 @@ + + + + + + +controlpi.pluginregistry API documentation + + + + + + + + + + + +
+
+
+

Module controlpi.pluginregistry

+
+
+

Provide a generic plugin system.

+

The class PluginRegistry is initialised with the name of a namespace +package and a base class.

+

All modules in the namespace package are loaded. These modules can be +included in different distribution packages, which allows to dynamically +add plugins to the system without changing any code.

+

Afterwards, all (direct and indirect) subclasses of the base class are +registered as plugins under their class name. Class names should be unique, +which cannot be programmatically enforced.

+
>>> class BasePlugin:
+...     pass
+>>> class Plugin1(BasePlugin):
+...     pass
+>>> class Plugin2(BasePlugin):
+...     pass
+>>> registry = PluginRegistry('importlib', BasePlugin)
+
+

The registry provides a generic mapping interface with the class names as +keys and the classes as values.

+
>>> print(len(registry))
+2
+>>> for name in registry:
+...     print(f"{name}: {registry[name]}")
+Plugin1: <class 'pluginregistry.Plugin1'>
+Plugin2: <class 'pluginregistry.Plugin2'>
+>>> if 'Plugin1' in registry:
+...     print(f"'Plugin1' is in registry.")
+'Plugin1' is in registry.
+>>> p1 = registry['Plugin1']
+>>> i1 = p1()
+
+
+ +Expand source code + +
"""Provide a generic plugin system.
+
+The class PluginRegistry is initialised with the name of a namespace
+package and a base class.
+
+All modules in the namespace package are loaded. These modules can be
+included in different distribution packages, which allows to dynamically
+add plugins to the system without changing any code.
+
+Afterwards, all (direct and indirect) subclasses of the base class are
+registered as plugins under their class name. Class names should be unique,
+which cannot be programmatically enforced.
+
+>>> class BasePlugin:
+...     pass
+>>> class Plugin1(BasePlugin):
+...     pass
+>>> class Plugin2(BasePlugin):
+...     pass
+>>> registry = PluginRegistry('importlib', BasePlugin)
+
+The registry provides a generic mapping interface with the class names as
+keys and the classes as values.
+
+>>> print(len(registry))
+2
+>>> for name in registry:
+...     print(f"{name}: {registry[name]}")
+Plugin1: <class 'pluginregistry.Plugin1'>
+Plugin2: <class 'pluginregistry.Plugin2'>
+>>> if 'Plugin1' in registry:
+...     print(f"'Plugin1' is in registry.")
+'Plugin1' is in registry.
+>>> p1 = registry['Plugin1']
+>>> i1 = p1()
+"""
+import importlib
+import pkgutil
+import collections.abc
+
+
+class PluginRegistry(collections.abc.Mapping):
+    """Provide a registry for plugins.
+
+    Initialise the registry by loading all modules in the given namespace
+    package and then registering all subclasses of the given base class as
+    plugins (only simulated here – the code for Plugin1 and Plugin2 should
+    be in modules in the given namespace package in real applications):
+    >>> class BasePlugin:
+    ...     pass
+    >>> class Plugin1(BasePlugin):
+    ...     pass
+    >>> class Plugin2(BasePlugin):
+    ...     pass
+    >>> registry = PluginRegistry('importlib', BasePlugin)
+
+    After initialisation, provide a mapping interface to the plugins:
+    >>> print(len(registry))
+    2
+    >>> for name in registry:
+    ...     print(f"{name}: {registry[name]}")
+    Plugin1: <class 'pluginregistry.Plugin1'>
+    Plugin2: <class 'pluginregistry.Plugin2'>
+    >>> if 'Plugin1' in registry:
+    ...     print(f"'Plugin1' is in registry.")
+    'Plugin1' is in registry.
+    """
+
+    def __init__(self, namespace_package: str, base_class: type) -> None:
+        """Initialise registry.
+
+        Import all modules defined in the given namespace package (in any
+        distribution package currently installed in the path). Then register
+        all subclasses of the given base class as plugins.
+
+        >>> class BasePlugin:
+        ...     pass
+        >>> class Plugin1(BasePlugin):
+        ...     pass
+        >>> class Plugin2(BasePlugin):
+        ...     pass
+        >>> registry = PluginRegistry('importlib', BasePlugin)
+        >>> for name in registry._plugins:
+        ...     print(f"{name}: {registry._plugins[name]}")
+        Plugin1: <class 'pluginregistry.Plugin1'>
+        Plugin2: <class 'pluginregistry.Plugin2'>
+        """
+        ns_mod = importlib.import_module(namespace_package)
+        ns_path = ns_mod.__path__  # type: ignore  # mypy issue #1422
+        ns_name = ns_mod.__name__
+        for _, mod_name, _ in pkgutil.iter_modules(ns_path):
+            importlib.import_module(f"{ns_name}.{mod_name}")
+
+        def all_subclasses(cls):
+            result = []
+            for subcls in cls.__subclasses__():
+                result.append(subcls)
+                result.extend(all_subclasses(subcls))
+            return result
+        self._plugins = {cls.__name__: cls
+                         for cls in all_subclasses(base_class)}
+
+    def __len__(self) -> int:
+        """Get number of registered plugins.
+
+        >>> class BasePlugin:
+        ...     pass
+        >>> class Plugin1(BasePlugin):
+        ...     pass
+        >>> class Plugin2(BasePlugin):
+        ...     pass
+        >>> registry = PluginRegistry('importlib', BasePlugin)
+        >>> print(registry.__len__())
+        2
+        """
+        return len(self._plugins)
+
+    def __iter__(self):
+        """Get an iterator of the registered plugins.
+
+        >>> class BasePlugin:
+        ...     pass
+        >>> class Plugin1(BasePlugin):
+        ...     pass
+        >>> class Plugin2(BasePlugin):
+        ...     pass
+        >>> registry = PluginRegistry('importlib', BasePlugin)
+        >>> print(type(registry.__iter__()))
+        <class 'dict_keyiterator'>
+        >>> for name in registry:
+        ...     print(name)
+        Plugin1
+        Plugin2
+        """
+        return iter(self._plugins)
+
+    def __getitem__(self, plugin_name: str) -> type:
+        """Get a registered plugin given its name.
+
+        >>> class BasePlugin:
+        ...     pass
+        >>> class Plugin1(BasePlugin):
+        ...     pass
+        >>> class Plugin2(BasePlugin):
+        ...     pass
+        >>> registry = PluginRegistry('importlib', BasePlugin)
+        >>> print(registry.__getitem__('Plugin1'))
+        <class 'pluginregistry.Plugin1'>
+        >>> print(registry.__getitem__('Plugin2'))
+        <class 'pluginregistry.Plugin2'>
+        >>> for name in registry:
+        ...     print(registry[name])
+        <class 'pluginregistry.Plugin1'>
+        <class 'pluginregistry.Plugin2'>
+        """
+        return self._plugins[plugin_name]
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class PluginRegistry +(namespace_package: str, base_class: type) +
+
+

Provide a registry for plugins.

+

Initialise the registry by loading all modules in the given namespace +package and then registering all subclasses of the given base class as +plugins (only simulated here – the code for Plugin1 and Plugin2 should +be in modules in the given namespace package in real applications):

+
>>> class BasePlugin:
+...     pass
+>>> class Plugin1(BasePlugin):
+...     pass
+>>> class Plugin2(BasePlugin):
+...     pass
+>>> registry = PluginRegistry('importlib', BasePlugin)
+
+

After initialisation, provide a mapping interface to the plugins:

+
>>> print(len(registry))
+2
+>>> for name in registry:
+...     print(f"{name}: {registry[name]}")
+Plugin1: <class 'pluginregistry.Plugin1'>
+Plugin2: <class 'pluginregistry.Plugin2'>
+>>> if 'Plugin1' in registry:
+...     print(f"'Plugin1' is in registry.")
+'Plugin1' is in registry.
+
+

Initialise registry.

+

Import all modules defined in the given namespace package (in any +distribution package currently installed in the path). Then register +all subclasses of the given base class as plugins.

+
>>> class BasePlugin:
+...     pass
+>>> class Plugin1(BasePlugin):
+...     pass
+>>> class Plugin2(BasePlugin):
+...     pass
+>>> registry = PluginRegistry('importlib', BasePlugin)
+>>> for name in registry._plugins:
+...     print(f"{name}: {registry._plugins[name]}")
+Plugin1: <class 'pluginregistry.Plugin1'>
+Plugin2: <class 'pluginregistry.Plugin2'>
+
+
+ +Expand source code + +
class PluginRegistry(collections.abc.Mapping):
+    """Provide a registry for plugins.
+
+    Initialise the registry by loading all modules in the given namespace
+    package and then registering all subclasses of the given base class as
+    plugins (only simulated here – the code for Plugin1 and Plugin2 should
+    be in modules in the given namespace package in real applications):
+    >>> class BasePlugin:
+    ...     pass
+    >>> class Plugin1(BasePlugin):
+    ...     pass
+    >>> class Plugin2(BasePlugin):
+    ...     pass
+    >>> registry = PluginRegistry('importlib', BasePlugin)
+
+    After initialisation, provide a mapping interface to the plugins:
+    >>> print(len(registry))
+    2
+    >>> for name in registry:
+    ...     print(f"{name}: {registry[name]}")
+    Plugin1: <class 'pluginregistry.Plugin1'>
+    Plugin2: <class 'pluginregistry.Plugin2'>
+    >>> if 'Plugin1' in registry:
+    ...     print(f"'Plugin1' is in registry.")
+    'Plugin1' is in registry.
+    """
+
+    def __init__(self, namespace_package: str, base_class: type) -> None:
+        """Initialise registry.
+
+        Import all modules defined in the given namespace package (in any
+        distribution package currently installed in the path). Then register
+        all subclasses of the given base class as plugins.
+
+        >>> class BasePlugin:
+        ...     pass
+        >>> class Plugin1(BasePlugin):
+        ...     pass
+        >>> class Plugin2(BasePlugin):
+        ...     pass
+        >>> registry = PluginRegistry('importlib', BasePlugin)
+        >>> for name in registry._plugins:
+        ...     print(f"{name}: {registry._plugins[name]}")
+        Plugin1: <class 'pluginregistry.Plugin1'>
+        Plugin2: <class 'pluginregistry.Plugin2'>
+        """
+        ns_mod = importlib.import_module(namespace_package)
+        ns_path = ns_mod.__path__  # type: ignore  # mypy issue #1422
+        ns_name = ns_mod.__name__
+        for _, mod_name, _ in pkgutil.iter_modules(ns_path):
+            importlib.import_module(f"{ns_name}.{mod_name}")
+
+        def all_subclasses(cls):
+            result = []
+            for subcls in cls.__subclasses__():
+                result.append(subcls)
+                result.extend(all_subclasses(subcls))
+            return result
+        self._plugins = {cls.__name__: cls
+                         for cls in all_subclasses(base_class)}
+
+    def __len__(self) -> int:
+        """Get number of registered plugins.
+
+        >>> class BasePlugin:
+        ...     pass
+        >>> class Plugin1(BasePlugin):
+        ...     pass
+        >>> class Plugin2(BasePlugin):
+        ...     pass
+        >>> registry = PluginRegistry('importlib', BasePlugin)
+        >>> print(registry.__len__())
+        2
+        """
+        return len(self._plugins)
+
+    def __iter__(self):
+        """Get an iterator of the registered plugins.
+
+        >>> class BasePlugin:
+        ...     pass
+        >>> class Plugin1(BasePlugin):
+        ...     pass
+        >>> class Plugin2(BasePlugin):
+        ...     pass
+        >>> registry = PluginRegistry('importlib', BasePlugin)
+        >>> print(type(registry.__iter__()))
+        <class 'dict_keyiterator'>
+        >>> for name in registry:
+        ...     print(name)
+        Plugin1
+        Plugin2
+        """
+        return iter(self._plugins)
+
+    def __getitem__(self, plugin_name: str) -> type:
+        """Get a registered plugin given its name.
+
+        >>> class BasePlugin:
+        ...     pass
+        >>> class Plugin1(BasePlugin):
+        ...     pass
+        >>> class Plugin2(BasePlugin):
+        ...     pass
+        >>> registry = PluginRegistry('importlib', BasePlugin)
+        >>> print(registry.__getitem__('Plugin1'))
+        <class 'pluginregistry.Plugin1'>
+        >>> print(registry.__getitem__('Plugin2'))
+        <class 'pluginregistry.Plugin2'>
+        >>> for name in registry:
+        ...     print(registry[name])
+        <class 'pluginregistry.Plugin1'>
+        <class 'pluginregistry.Plugin2'>
+        """
+        return self._plugins[plugin_name]
+
+

Ancestors

+
    +
  • collections.abc.Mapping
  • +
  • collections.abc.Collection
  • +
  • collections.abc.Sized
  • +
  • collections.abc.Iterable
  • +
  • collections.abc.Container
  • +
+
+
+
+
+ +
+ + + \ No newline at end of file diff --git a/doc/controlpi_plugins/index.html b/doc/controlpi_plugins/index.html new file mode 100644 index 0000000..6775c5d --- /dev/null +++ b/doc/controlpi_plugins/index.html @@ -0,0 +1,70 @@ + + + + + + +controlpi_plugins API documentation + + + + + + + + + + + +
+
+
+

Namespace controlpi_plugins

+
+
+
+
+

Sub-modules

+
+
controlpi_plugins.state
+
+

Provide state plugins for all kinds of systems …

+
+
controlpi_plugins.util
+
+

Provide utility plugins for all kinds of systems …

+
+
controlpi_plugins.wait
+
+

Provide waiting/sleeping plugins for all kinds of systems …

+
+
+
+
+
+
+
+
+
+
+ +
+ + + \ No newline at end of file diff --git a/doc/controlpi_plugins/state.html b/doc/controlpi_plugins/state.html new file mode 100644 index 0000000..8f33480 --- /dev/null +++ b/doc/controlpi_plugins/state.html @@ -0,0 +1,1884 @@ + + + + + + +controlpi_plugins.state API documentation + + + + + + + + + + + +
+
+
+

Module controlpi_plugins.state

+
+
+

Provide state plugins for all kinds of systems.

+
    +
  • State represents a Boolean state.
  • +
  • StateAlias translates to another state-like client.
  • +
  • AndState combines several state-like clients by conjunction.
  • +
  • OrState combines several state-like clients by disjunction.
  • +
+

All these plugins use the following conventions:

+
    +
  • If their state changes they send a message containing "event": "changed" +and "state": .
  • +
  • If their state is reported due to a message, but did not change they send +a message containing just "state": .
  • +
  • If they receive a message containing "target": and +"command": "get state" they report their current state.
  • +
  • If State (or any other settable state using these conventions) receives +a message containing "target": , "command": "set state" and +"new state": it changes the state accordingly. If this +was really a change the corresponding event is sent. If it was already in +this state a report message without "event": "changed" is sent.
  • +
  • StateAlias can alias any message bus client using these conventions, not +just State instances. It translates all messages described here in both +directions.
  • +
  • AndState and OrState instances cannot be set.
  • +
  • AndState and OrState can combine any message bus clients using these +conventions, not just State instances. They only react to messages +containing "state" information.
  • +
+
>>> import asyncio
+>>> import controlpi
+>>> asyncio.run(controlpi.test(
+...     {"Test State": {"plugin": "State"},
+...      "Test State 2": {"plugin": "State"},
+...      "Test StateAlias": {"plugin": "StateAlias",
+...                          "alias for": "Test State 2"},
+...      "Test AndState": {"plugin": "AndState",
+...                        "states": ["Test State", "Test StateAlias"]},
+...      "Test OrState": {"plugin": "OrState",
+...                       "states": ["Test State", "Test StateAlias"]}},
+...     [{"target": "Test AndState",
+...       "command": "get state"},
+...      {"target": "Test OrState",
+...       "command": "get state"},
+...      {"target": "Test State",
+...       "command": "set state", "new state": True},
+...      {"target": "Test StateAlias",
+...       "command": "set state", "new state": True},
+...      {"target": "Test State",
+...       "command": "set state", "new state": False}]))
+... # doctest: +NORMALIZE_WHITESPACE
+test(): {'sender': '', 'event': 'registered',
+         'client': 'Test State', 'plugin': 'State',
+         'sends': [{'event': {'const': 'changed'},
+                    'state': {'type': 'boolean'}},
+                   {'state': {'type': 'boolean'}}],
+         'receives': [{'target': {'const': 'Test State'},
+                       'command': {'const': 'get state'}},
+                      {'target': {'const': 'Test State'},
+                       'command': {'const': 'set state'},
+                       'new state': {'type': 'boolean'}}]}
+test(): {'sender': '', 'event': 'registered',
+         'client': 'Test State 2', 'plugin': 'State',
+         'sends': [{'event': {'const': 'changed'},
+                    'state': {'type': 'boolean'}},
+                   {'state': {'type': 'boolean'}}],
+         'receives': [{'target': {'const': 'Test State 2'},
+                       'command': {'const': 'get state'}},
+                      {'target': {'const': 'Test State 2'},
+                       'command': {'const': 'set state'},
+                       'new state': {'type': 'boolean'}}]}
+test(): {'sender': '', 'event': 'registered',
+         'client': 'Test StateAlias', 'plugin': 'StateAlias',
+         'sends': [{'event': {'const': 'changed'},
+                    'state': {'type': 'boolean'}},
+                   {'state': {'type': 'boolean'}},
+                   {'target': {'const': 'Test State 2'},
+                    'command': {'const': 'get state'}},
+                   {'target': {'const': 'Test State 2'},
+                    'command': {'const': 'set state'},
+                    'new state': {'type': 'boolean'}}],
+         'receives': [{'target': {'const': 'Test StateAlias'},
+                       'command': {'const': 'get state'}},
+                      {'target': {'const': 'Test StateAlias'},
+                       'command': {'const': 'set state'},
+                       'new state': {'type': 'boolean'}},
+                      {'sender': {'const': 'Test State 2'},
+                       'event': {'const': 'changed'},
+                       'state': {'type': 'boolean'}},
+                      {'sender': {'const': 'Test State 2'},
+                       'state': {'type': 'boolean'}}]}
+test(): {'sender': '', 'event': 'registered',
+         'client': 'Test AndState', 'plugin': 'AndState',
+         'sends': [{'event': {'const': 'changed'},
+                    'state': {'type': 'boolean'}},
+                   {'state': {'type': 'boolean'}}],
+         'receives': [{'target': {'const': 'Test AndState'},
+                       'command': {'const': 'get state'}},
+                      {'sender': {'const': 'Test State'},
+                       'state': {'type': 'boolean'}},
+                      {'sender': {'const': 'Test StateAlias'},
+                       'state': {'type': 'boolean'}}]}
+test(): {'sender': '', 'event': 'registered',
+         'client': 'Test OrState', 'plugin': 'OrState',
+         'sends': [{'event': {'const': 'changed'},
+                    'state': {'type': 'boolean'}},
+                   {'state': {'type': 'boolean'}}],
+         'receives': [{'target': {'const': 'Test OrState'},
+                       'command': {'const': 'get state'}},
+                      {'sender': {'const': 'Test State'},
+                       'state': {'type': 'boolean'}},
+                      {'sender': {'const': 'Test StateAlias'},
+                       'state': {'type': 'boolean'}}]}
+test(): {'sender': 'test()', 'target': 'Test AndState',
+         'command': 'get state'}
+test(): {'sender': 'Test AndState', 'state': False}
+test(): {'sender': 'test()', 'target': 'Test OrState',
+         'command': 'get state'}
+test(): {'sender': 'Test OrState', 'state': False}
+test(): {'sender': 'test()', 'target': 'Test State',
+         'command': 'set state', 'new state': True}
+test(): {'sender': 'Test State', 'event': 'changed', 'state': True}
+test(): {'sender': 'Test OrState', 'event': 'changed', 'state': True}
+test(): {'sender': 'test()', 'target': 'Test StateAlias',
+         'command': 'set state', 'new state': True}
+test(): {'sender': 'Test StateAlias', 'target': 'Test State 2',
+         'command': 'set state', 'new state': True}
+test(): {'sender': 'Test State 2', 'event': 'changed', 'state': True}
+test(): {'sender': 'Test StateAlias', 'event': 'changed', 'state': True}
+test(): {'sender': 'Test AndState', 'event': 'changed', 'state': True}
+test(): {'sender': 'test()', 'target': 'Test State',
+         'command': 'set state', 'new state': False}
+test(): {'sender': 'Test State', 'event': 'changed', 'state': False}
+test(): {'sender': 'Test AndState', 'event': 'changed', 'state': False}
+
+
+ +Expand source code + +
"""Provide state plugins for all kinds of systems.
+
+- State represents a Boolean state.
+- StateAlias translates to another state-like client.
+- AndState combines several state-like clients by conjunction.
+- OrState combines several state-like clients by disjunction.
+
+All these plugins use the following conventions:
+
+- If their state changes they send a message containing "event": "changed"
+  and "state": <new state>.
+- If their state is reported due to a message, but did not change they send
+  a message containing just "state": <current state>.
+- If they receive a message containing "target": <name> and
+  "command": "get state" they report their current state.
+- If State (or any other settable state using these conventions) receives
+  a message containing "target": <name>, "command": "set state" and
+  "new state": <state to be set> it changes the state accordingly. If this
+  was really a change the corresponding event is sent. If it was already in
+  this state a report message without "event": "changed" is sent.
+- StateAlias can alias any message bus client using these conventions, not
+  just State instances. It translates all messages described here in both
+  directions.
+- AndState and OrState instances cannot be set.
+- AndState and OrState can combine any message bus clients using these
+  conventions, not just State instances. They only react to messages
+  containing "state" information.
+
+>>> import asyncio
+>>> import controlpi
+>>> asyncio.run(controlpi.test(
+...     {"Test State": {"plugin": "State"},
+...      "Test State 2": {"plugin": "State"},
+...      "Test StateAlias": {"plugin": "StateAlias",
+...                          "alias for": "Test State 2"},
+...      "Test AndState": {"plugin": "AndState",
+...                        "states": ["Test State", "Test StateAlias"]},
+...      "Test OrState": {"plugin": "OrState",
+...                       "states": ["Test State", "Test StateAlias"]}},
+...     [{"target": "Test AndState",
+...       "command": "get state"},
+...      {"target": "Test OrState",
+...       "command": "get state"},
+...      {"target": "Test State",
+...       "command": "set state", "new state": True},
+...      {"target": "Test StateAlias",
+...       "command": "set state", "new state": True},
+...      {"target": "Test State",
+...       "command": "set state", "new state": False}]))
+... # doctest: +NORMALIZE_WHITESPACE
+test(): {'sender': '', 'event': 'registered',
+         'client': 'Test State', 'plugin': 'State',
+         'sends': [{'event': {'const': 'changed'},
+                    'state': {'type': 'boolean'}},
+                   {'state': {'type': 'boolean'}}],
+         'receives': [{'target': {'const': 'Test State'},
+                       'command': {'const': 'get state'}},
+                      {'target': {'const': 'Test State'},
+                       'command': {'const': 'set state'},
+                       'new state': {'type': 'boolean'}}]}
+test(): {'sender': '', 'event': 'registered',
+         'client': 'Test State 2', 'plugin': 'State',
+         'sends': [{'event': {'const': 'changed'},
+                    'state': {'type': 'boolean'}},
+                   {'state': {'type': 'boolean'}}],
+         'receives': [{'target': {'const': 'Test State 2'},
+                       'command': {'const': 'get state'}},
+                      {'target': {'const': 'Test State 2'},
+                       'command': {'const': 'set state'},
+                       'new state': {'type': 'boolean'}}]}
+test(): {'sender': '', 'event': 'registered',
+         'client': 'Test StateAlias', 'plugin': 'StateAlias',
+         'sends': [{'event': {'const': 'changed'},
+                    'state': {'type': 'boolean'}},
+                   {'state': {'type': 'boolean'}},
+                   {'target': {'const': 'Test State 2'},
+                    'command': {'const': 'get state'}},
+                   {'target': {'const': 'Test State 2'},
+                    'command': {'const': 'set state'},
+                    'new state': {'type': 'boolean'}}],
+         'receives': [{'target': {'const': 'Test StateAlias'},
+                       'command': {'const': 'get state'}},
+                      {'target': {'const': 'Test StateAlias'},
+                       'command': {'const': 'set state'},
+                       'new state': {'type': 'boolean'}},
+                      {'sender': {'const': 'Test State 2'},
+                       'event': {'const': 'changed'},
+                       'state': {'type': 'boolean'}},
+                      {'sender': {'const': 'Test State 2'},
+                       'state': {'type': 'boolean'}}]}
+test(): {'sender': '', 'event': 'registered',
+         'client': 'Test AndState', 'plugin': 'AndState',
+         'sends': [{'event': {'const': 'changed'},
+                    'state': {'type': 'boolean'}},
+                   {'state': {'type': 'boolean'}}],
+         'receives': [{'target': {'const': 'Test AndState'},
+                       'command': {'const': 'get state'}},
+                      {'sender': {'const': 'Test State'},
+                       'state': {'type': 'boolean'}},
+                      {'sender': {'const': 'Test StateAlias'},
+                       'state': {'type': 'boolean'}}]}
+test(): {'sender': '', 'event': 'registered',
+         'client': 'Test OrState', 'plugin': 'OrState',
+         'sends': [{'event': {'const': 'changed'},
+                    'state': {'type': 'boolean'}},
+                   {'state': {'type': 'boolean'}}],
+         'receives': [{'target': {'const': 'Test OrState'},
+                       'command': {'const': 'get state'}},
+                      {'sender': {'const': 'Test State'},
+                       'state': {'type': 'boolean'}},
+                      {'sender': {'const': 'Test StateAlias'},
+                       'state': {'type': 'boolean'}}]}
+test(): {'sender': 'test()', 'target': 'Test AndState',
+         'command': 'get state'}
+test(): {'sender': 'Test AndState', 'state': False}
+test(): {'sender': 'test()', 'target': 'Test OrState',
+         'command': 'get state'}
+test(): {'sender': 'Test OrState', 'state': False}
+test(): {'sender': 'test()', 'target': 'Test State',
+         'command': 'set state', 'new state': True}
+test(): {'sender': 'Test State', 'event': 'changed', 'state': True}
+test(): {'sender': 'Test OrState', 'event': 'changed', 'state': True}
+test(): {'sender': 'test()', 'target': 'Test StateAlias',
+         'command': 'set state', 'new state': True}
+test(): {'sender': 'Test StateAlias', 'target': 'Test State 2',
+         'command': 'set state', 'new state': True}
+test(): {'sender': 'Test State 2', 'event': 'changed', 'state': True}
+test(): {'sender': 'Test StateAlias', 'event': 'changed', 'state': True}
+test(): {'sender': 'Test AndState', 'event': 'changed', 'state': True}
+test(): {'sender': 'test()', 'target': 'Test State',
+         'command': 'set state', 'new state': False}
+test(): {'sender': 'Test State', 'event': 'changed', 'state': False}
+test(): {'sender': 'Test AndState', 'event': 'changed', 'state': False}
+"""
+from controlpi import BasePlugin, Message, MessageTemplate
+
+from typing import Dict
+
+
+class State(BasePlugin):
+    """Provide a Boolean state.
+
+    The state of a State plugin instance can be queried with the "get state"
+    command and set with the "set state" command to the new state given by
+    the "new state" key:
+    >>> import asyncio
+    >>> import controlpi
+    >>> asyncio.run(controlpi.test(
+    ...     {"Test State": {"plugin": "State"}},
+    ...     [{"target": "Test State", "command": "get state"},
+    ...      {"target": "Test State", "command": "set state",
+    ...       "new state": True},
+    ...      {"target": "Test State", "command": "set state",
+    ...       "new state": True},
+    ...      {"target": "Test State", "command": "get state"}]))
+    ... # doctest: +NORMALIZE_WHITESPACE
+    test(): {'sender': '', 'event': 'registered',
+             'client': 'Test State', 'plugin': 'State',
+             'sends': [{'event': {'const': 'changed'},
+                        'state': {'type': 'boolean'}},
+                       {'state': {'type': 'boolean'}}],
+             'receives': [{'target': {'const': 'Test State'},
+                           'command': {'const': 'get state'}},
+                          {'target': {'const': 'Test State'},
+                           'command': {'const': 'set state'},
+                           'new state': {'type': 'boolean'}}]}
+    test(): {'sender': 'test()', 'target': 'Test State',
+             'command': 'get state'}
+    test(): {'sender': 'Test State', 'state': False}
+    test(): {'sender': 'test()', 'target': 'Test State',
+             'command': 'set state', 'new state': True}
+    test(): {'sender': 'Test State', 'event': 'changed', 'state': True}
+    test(): {'sender': 'test()', 'target': 'Test State',
+             'command': 'set state', 'new state': True}
+    test(): {'sender': 'Test State', 'state': True}
+    test(): {'sender': 'test()', 'target': 'Test State',
+             'command': 'get state'}
+    test(): {'sender': 'Test State', 'state': True}
+    """
+
+    CONF_SCHEMA = True
+    """Schema for State plugin configuration.
+
+    There are no required or optional configuration keys.
+    """
+
+    async def receive(self, message: Message) -> None:
+        """Process commands to get/set state."""
+        if message['command'] == 'get state':
+            await self.bus.send(Message(self.name, {'state': self.state}))
+        elif message['command'] == 'set state':
+            if self.state != message['new state']:
+                assert isinstance(message['new state'], bool)
+                self.state: bool = message['new state']
+                await self.bus.send(Message(self.name,
+                                            {'event': 'changed',
+                                             'state': self.state}))
+            else:
+                await self.bus.send(Message(self.name,
+                                            {'state': self.state}))
+
+    def process_conf(self) -> None:
+        """Register plugin as bus client."""
+        self.state = False
+        sends = [MessageTemplate({'event': {'const': 'changed'},
+                                  'state': {'type': 'boolean'}}),
+                 MessageTemplate({'state': {'type': 'boolean'}})]
+        receives = [MessageTemplate({'target': {'const': self.name},
+                                     'command': {'const': 'get state'}}),
+                    MessageTemplate({'target': {'const': self.name},
+                                     'command': {'const': 'set state'},
+                                     'new state': {'type': 'boolean'}})]
+        self.bus.register(self.name, 'State', sends, receives, self.receive)
+
+    async def run(self) -> None:
+        """Run no code proactively."""
+        pass
+
+
+class StateAlias(BasePlugin):
+    """Define an alias for another state.
+
+    The "alias for" configuration key gets the name for the other state that
+    is aliased by the StateAlias plugin instance.
+
+    The "get state" and "set state" commands are forwarded to and the
+    "changed" events and "state" messages are forwarded from this other
+    state:
+    >>> import asyncio
+    >>> import controlpi
+    >>> asyncio.run(controlpi.test(
+    ...     {"Test State": {"plugin": "State"},
+    ...      "Test StateAlias": {"plugin": "StateAlias",
+    ...                          "alias for": "Test State"}},
+    ...     [{"target": "Test State", "command": "get state"},
+    ...      {"target": "Test StateAlias", "command": "set state",
+    ...       "new state": True},
+    ...      {"target": "Test State", "command": "set state",
+    ...       "new state": True},
+    ...      {"target": "Test StateAlias", "command": "get state"}]))
+    ... # doctest: +NORMALIZE_WHITESPACE
+    test(): {'sender': '', 'event': 'registered',
+             'client': 'Test State', 'plugin': 'State',
+             'sends': [{'event': {'const': 'changed'},
+                        'state': {'type': 'boolean'}},
+                       {'state': {'type': 'boolean'}}],
+             'receives': [{'target': {'const': 'Test State'},
+                           'command': {'const': 'get state'}},
+                          {'target': {'const': 'Test State'},
+                           'command': {'const': 'set state'},
+                           'new state': {'type': 'boolean'}}]}
+    test(): {'sender': '', 'event': 'registered',
+             'client': 'Test StateAlias', 'plugin': 'StateAlias',
+             'sends': [{'event': {'const': 'changed'},
+                        'state': {'type': 'boolean'}},
+                       {'state': {'type': 'boolean'}},
+                       {'target': {'const': 'Test State'},
+                        'command': {'const': 'get state'}},
+                       {'target': {'const': 'Test State'},
+                        'command': {'const': 'set state'},
+                        'new state': {'type': 'boolean'}}],
+             'receives': [{'target': {'const': 'Test StateAlias'},
+                           'command': {'const': 'get state'}},
+                          {'target': {'const': 'Test StateAlias'},
+                           'command': {'const': 'set state'},
+                           'new state': {'type': 'boolean'}},
+                          {'sender': {'const': 'Test State'},
+                           'event': {'const': 'changed'},
+                           'state': {'type': 'boolean'}},
+                          {'sender': {'const': 'Test State'},
+                           'state': {'type': 'boolean'}}]}
+    test(): {'sender': 'test()', 'target': 'Test State',
+             'command': 'get state'}
+    test(): {'sender': 'Test State', 'state': False}
+    test(): {'sender': 'Test StateAlias', 'state': False}
+    test(): {'sender': 'test()', 'target': 'Test StateAlias',
+             'command': 'set state', 'new state': True}
+    test(): {'sender': 'Test StateAlias', 'target': 'Test State',
+             'command': 'set state', 'new state': True}
+    test(): {'sender': 'Test State', 'event': 'changed', 'state': True}
+    test(): {'sender': 'Test StateAlias', 'event': 'changed', 'state': True}
+    test(): {'sender': 'test()', 'target': 'Test State',
+             'command': 'set state', 'new state': True}
+    test(): {'sender': 'Test State', 'state': True}
+    test(): {'sender': 'Test StateAlias', 'state': True}
+    test(): {'sender': 'test()', 'target': 'Test StateAlias',
+             'command': 'get state'}
+    test(): {'sender': 'Test StateAlias', 'target': 'Test State',
+             'command': 'get state'}
+    test(): {'sender': 'Test State', 'state': True}
+    test(): {'sender': 'Test StateAlias', 'state': True}
+    """
+
+    CONF_SCHEMA = {'properties': {'alias for': {'type': 'string'}},
+                   'required': ['alias for']}
+    """Schema for StateAlias plugin configuration.
+
+    Required configuration key:
+
+    - 'alias for': name of aliased state.
+    """
+
+    async def receive(self, message: Message) -> None:
+        """Translate states from and commands to aliased state."""
+        alias_message = Message(self.name)
+        if ('target' in message and message['target'] == self.name and
+                'command' in message):
+            alias_message['target'] = self.conf['alias for']
+            if message['command'] == 'get state':
+                alias_message['command'] = 'get state'
+                await self.bus.send(alias_message)
+            elif (message['command'] == 'set state' and
+                    'new state' in message):
+                alias_message['command'] = 'set state'
+                alias_message['new state'] = message['new state']
+                await self.bus.send(alias_message)
+        if (message['sender'] == self.conf['alias for'] and
+                'state' in message):
+            if 'event' in message and message['event'] == 'changed':
+                alias_message['event'] = 'changed'
+            alias_message['state'] = message['state']
+            await self.bus.send(alias_message)
+
+    def process_conf(self) -> None:
+        """Register plugin as bus client."""
+        sends = [MessageTemplate({'target': {'const': self.conf['alias for']},
+                                  'command': {'const': 'get state'}}),
+                 MessageTemplate({'target': {'const': self.conf['alias for']},
+                                  'command': {'const': 'set state'},
+                                  'new state': {'type': 'boolean'}}),
+                 MessageTemplate({'event': {'const': 'changed'},
+                                  'state': {'type': 'boolean'}}),
+                 MessageTemplate({'state': {'type': 'boolean'}})]
+        receives = [MessageTemplate({'target': {'const': self.name},
+                                     'command': {'const': 'get state'}}),
+                    MessageTemplate({'target': {'const': self.name},
+                                     'command': {'const': 'set state'},
+                                     'new state': {'type': 'boolean'}}),
+                    MessageTemplate({'sender':
+                                     {'const': self.conf['alias for']},
+                                     'event': {'const': 'changed'},
+                                     'state': {'type': 'boolean'}}),
+                    MessageTemplate({'sender':
+                                     {'const': self.conf['alias for']},
+                                     'state': {'type': 'boolean'}})]
+        self.bus.register(self.name, 'StateAlias',
+                          sends, receives, self.receive)
+
+    async def run(self) -> None:
+        """Run no code proactively."""
+        pass
+
+
+class AndState(BasePlugin):
+    """Implement conjunction of states.
+
+    The "states" configuration key gets an array of states to be combined.
+    An AndState plugin client reacts to "get state" commands and sends
+    "changed" events when a change in one of the combined states leads to
+    a change for the conjunction:
+    >>> import asyncio
+    >>> import controlpi
+    >>> asyncio.run(controlpi.test(
+    ...     {"Test State 1": {"plugin": "State"},
+    ...      "Test State 2": {"plugin": "State"},
+    ...      "Test AndState": {"plugin": "AndState",
+    ...                        "states": ["Test State 1", "Test State 2"]}},
+    ...     [{"target": "Test State 1", "command": "set state",
+    ...       "new state": True},
+    ...      {"target": "Test State 2", "command": "set state",
+    ...       "new state": True},
+    ...      {"target": "Test State 1", "command": "set state",
+    ...       "new state": False},
+    ...      {"target": "Test AndState", "command": "get state"}]))
+    ... # doctest: +NORMALIZE_WHITESPACE
+    test(): {'sender': '', 'event': 'registered',
+             'client': 'Test State 1', 'plugin': 'State',
+             'sends': [{'event': {'const': 'changed'},
+                        'state': {'type': 'boolean'}},
+                       {'state': {'type': 'boolean'}}],
+             'receives': [{'target': {'const': 'Test State 1'},
+                           'command': {'const': 'get state'}},
+                          {'target': {'const': 'Test State 1'},
+                           'command': {'const': 'set state'},
+                           'new state': {'type': 'boolean'}}]}
+    test(): {'sender': '', 'event': 'registered',
+             'client': 'Test State 2', 'plugin': 'State',
+             'sends': [{'event': {'const': 'changed'},
+                        'state': {'type': 'boolean'}},
+                       {'state': {'type': 'boolean'}}],
+             'receives': [{'target': {'const': 'Test State 2'},
+                           'command': {'const': 'get state'}},
+                          {'target': {'const': 'Test State 2'},
+                           'command': {'const': 'set state'},
+                           'new state': {'type': 'boolean'}}]}
+    test(): {'sender': '', 'event': 'registered',
+             'client': 'Test AndState', 'plugin': 'AndState',
+             'sends': [{'event': {'const': 'changed'},
+                        'state': {'type': 'boolean'}},
+                       {'state': {'type': 'boolean'}}],
+             'receives': [{'target': {'const': 'Test AndState'},
+                           'command': {'const': 'get state'}},
+                          {'sender': {'const': 'Test State 1'},
+                           'state': {'type': 'boolean'}},
+                          {'sender': {'const': 'Test State 2'},
+                           'state': {'type': 'boolean'}}]}
+    test(): {'sender': 'test()', 'target': 'Test State 1',
+             'command': 'set state', 'new state': True}
+    test(): {'sender': 'Test State 1', 'event': 'changed', 'state': True}
+    test(): {'sender': 'test()', 'target': 'Test State 2',
+             'command': 'set state', 'new state': True}
+    test(): {'sender': 'Test State 2', 'event': 'changed', 'state': True}
+    test(): {'sender': 'Test AndState', 'event': 'changed', 'state': True}
+    test(): {'sender': 'test()', 'target': 'Test State 1',
+             'command': 'set state', 'new state': False}
+    test(): {'sender': 'Test State 1', 'event': 'changed', 'state': False}
+    test(): {'sender': 'Test AndState', 'event': 'changed', 'state': False}
+    test(): {'sender': 'test()', 'target': 'Test AndState',
+             'command': 'get state'}
+    test(): {'sender': 'Test AndState', 'state': False}
+    """
+
+    CONF_SCHEMA = {'properties': {'states': {'type': 'array',
+                                             'items': {'type': 'string'}}},
+                   'required': ['states']}
+    """Schema for AndState plugin configuration.
+
+    Required configuration key:
+
+    - 'states': list of names of combined states.
+    """
+
+    async def receive(self, message: Message) -> None:
+        """Process "get state" command and messages of combined states."""
+        if ('target' in message and message['target'] == self.name and
+                'command' in message and message['command'] == 'get state'):
+            await self.bus.send(Message(self.name, {'state': self.state}))
+        if 'state' in message and message['sender'] in self.conf['states']:
+            assert isinstance(message['sender'], str)
+            assert isinstance(message['state'], bool)
+            self.states[message['sender']] = message['state']
+            new_state = all(self.states.values())
+            if self.state != new_state:
+                self.state: bool = new_state
+                await self.bus.send(Message(self.name,
+                                            {'event': 'changed',
+                                             'state': self.state}))
+
+    def process_conf(self) -> None:
+        """Register plugin as bus client."""
+        sends = [MessageTemplate({'event': {'const': 'changed'},
+                                  'state': {'type': 'boolean'}}),
+                 MessageTemplate({'state': {'type': 'boolean'}})]
+        receives = [MessageTemplate({'target': {'const': self.name},
+                                     'command': {'const': 'get state'}})]
+        self.states: Dict[str, bool] = {}
+        for state in self.conf['states']:
+            receives.append(MessageTemplate({'sender': {'const': state},
+                                             'state': {'type': 'boolean'}}))
+            self.states[state] = False
+        self.state = all(self.states.values())
+        self.bus.register(self.name, 'AndState',
+                          sends, receives, self.receive)
+
+    async def run(self) -> None:
+        """Run no code proactively."""
+        pass
+
+
+class OrState(BasePlugin):
+    """Implement disjunction of states.
+
+    The "states" configuration key gets an array of states to be combined.
+    An OrState plugin client reacts to "get state" commands and sends
+    "changed" events when a change in one of the combined states leads to
+    a change for the disjunction:
+    >>> import asyncio
+    >>> import controlpi
+    >>> asyncio.run(controlpi.test(
+    ...     {"Test State 1": {"plugin": "State"},
+    ...      "Test State 2": {"plugin": "State"},
+    ...      "Test OrState": {"plugin": "OrState",
+    ...                       "states": ["Test State 1", "Test State 2"]}},
+    ...     [{"target": "Test State 1", "command": "set state",
+    ...       "new state": True},
+    ...      {"target": "Test State 2", "command": "set state",
+    ...       "new state": True},
+    ...      {"target": "Test State 1", "command": "set state",
+    ...       "new state": False},
+    ...      {"target": "Test OrState", "command": "get state"}]))
+    ... # doctest: +NORMALIZE_WHITESPACE
+    test(): {'sender': '', 'event': 'registered',
+             'client': 'Test State 1', 'plugin': 'State',
+             'sends': [{'event': {'const': 'changed'},
+                        'state': {'type': 'boolean'}},
+                       {'state': {'type': 'boolean'}}],
+             'receives': [{'target': {'const': 'Test State 1'},
+                           'command': {'const': 'get state'}},
+                          {'target': {'const': 'Test State 1'},
+                           'command': {'const': 'set state'},
+                           'new state': {'type': 'boolean'}}]}
+    test(): {'sender': '', 'event': 'registered',
+             'client': 'Test State 2', 'plugin': 'State',
+             'sends': [{'event': {'const': 'changed'},
+                        'state': {'type': 'boolean'}},
+                       {'state': {'type': 'boolean'}}],
+             'receives': [{'target': {'const': 'Test State 2'},
+                           'command': {'const': 'get state'}},
+                          {'target': {'const': 'Test State 2'},
+                           'command': {'const': 'set state'},
+                           'new state': {'type': 'boolean'}}]}
+    test(): {'sender': '', 'event': 'registered',
+             'client': 'Test OrState', 'plugin': 'OrState',
+             'sends': [{'event': {'const': 'changed'},
+                        'state': {'type': 'boolean'}},
+                       {'state': {'type': 'boolean'}}],
+             'receives': [{'target': {'const': 'Test OrState'},
+                           'command': {'const': 'get state'}},
+                          {'sender': {'const': 'Test State 1'},
+                           'state': {'type': 'boolean'}},
+                          {'sender': {'const': 'Test State 2'},
+                           'state': {'type': 'boolean'}}]}
+    test(): {'sender': 'test()', 'target': 'Test State 1',
+             'command': 'set state', 'new state': True}
+    test(): {'sender': 'Test State 1', 'event': 'changed', 'state': True}
+    test(): {'sender': 'Test OrState', 'event': 'changed', 'state': True}
+    test(): {'sender': 'test()', 'target': 'Test State 2',
+             'command': 'set state', 'new state': True}
+    test(): {'sender': 'Test State 2', 'event': 'changed', 'state': True}
+    test(): {'sender': 'test()', 'target': 'Test State 1',
+             'command': 'set state', 'new state': False}
+    test(): {'sender': 'Test State 1', 'event': 'changed', 'state': False}
+    test(): {'sender': 'test()', 'target': 'Test OrState',
+             'command': 'get state'}
+    test(): {'sender': 'Test OrState', 'state': True}
+    """
+
+    CONF_SCHEMA = {'properties': {'states': {'type': 'array',
+                                             'items': {'type': 'string'}}},
+                   'required': ['states']}
+    """Schema for OrState plugin configuration.
+
+    Required configuration key:
+
+    - 'states': list of names of combined states.
+    """
+
+    async def receive(self, message: Message) -> None:
+        """Process "get state" command and messages of combined states."""
+        if ('target' in message and message['target'] == self.name and
+                'command' in message and message['command'] == 'get state'):
+            await self.bus.send(Message(self.name, {'state': self.state}))
+        if 'state' in message and message['sender'] in self.conf['states']:
+            assert isinstance(message['sender'], str)
+            assert isinstance(message['state'], bool)
+            self.states[message['sender']] = message['state']
+            new_state = any(self.states.values())
+            if self.state != new_state:
+                self.state: bool = new_state
+                await self.bus.send(Message(self.name,
+                                            {'event': 'changed',
+                                             'state': self.state}))
+
+    def process_conf(self) -> None:
+        """Register plugin as bus client."""
+        sends = [MessageTemplate({'event': {'const': 'changed'},
+                                  'state': {'type': 'boolean'}}),
+                 MessageTemplate({'state': {'type': 'boolean'}})]
+        receives = [MessageTemplate({'target': {'const': self.name},
+                                     'command': {'const': 'get state'}})]
+        self.states: Dict[str, bool] = {}
+        for state in self.conf['states']:
+            receives.append(MessageTemplate({'sender': {'const': state},
+                                             'state': {'type': 'boolean'}}))
+            self.states[state] = False
+        self.state = any(self.states.values())
+        self.bus.register(self.name, 'OrState',
+                          sends, receives, self.receive)
+
+    async def run(self) -> None:
+        """Run no code proactively."""
+        pass
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class State +(bus: MessageBus, name: str, conf: Dict[str, Any]) +
+
+

Provide a Boolean state.

+

The state of a State plugin instance can be queried with the "get state" +command and set with the "set state" command to the new state given by +the "new state" key:

+
>>> import asyncio
+>>> import controlpi
+>>> asyncio.run(controlpi.test(
+...     {"Test State": {"plugin": "State"}},
+...     [{"target": "Test State", "command": "get state"},
+...      {"target": "Test State", "command": "set state",
+...       "new state": True},
+...      {"target": "Test State", "command": "set state",
+...       "new state": True},
+...      {"target": "Test State", "command": "get state"}]))
+... # doctest: +NORMALIZE_WHITESPACE
+test(): {'sender': '', 'event': 'registered',
+         'client': 'Test State', 'plugin': 'State',
+         'sends': [{'event': {'const': 'changed'},
+                    'state': {'type': 'boolean'}},
+                   {'state': {'type': 'boolean'}}],
+         'receives': [{'target': {'const': 'Test State'},
+                       'command': {'const': 'get state'}},
+                      {'target': {'const': 'Test State'},
+                       'command': {'const': 'set state'},
+                       'new state': {'type': 'boolean'}}]}
+test(): {'sender': 'test()', 'target': 'Test State',
+         'command': 'get state'}
+test(): {'sender': 'Test State', 'state': False}
+test(): {'sender': 'test()', 'target': 'Test State',
+         'command': 'set state', 'new state': True}
+test(): {'sender': 'Test State', 'event': 'changed', 'state': True}
+test(): {'sender': 'test()', 'target': 'Test State',
+         'command': 'set state', 'new state': True}
+test(): {'sender': 'Test State', 'state': True}
+test(): {'sender': 'test()', 'target': 'Test State',
+         'command': 'get state'}
+test(): {'sender': 'Test State', 'state': True}
+
+
+ +Expand source code + +
class State(BasePlugin):
+    """Provide a Boolean state.
+
+    The state of a State plugin instance can be queried with the "get state"
+    command and set with the "set state" command to the new state given by
+    the "new state" key:
+    >>> import asyncio
+    >>> import controlpi
+    >>> asyncio.run(controlpi.test(
+    ...     {"Test State": {"plugin": "State"}},
+    ...     [{"target": "Test State", "command": "get state"},
+    ...      {"target": "Test State", "command": "set state",
+    ...       "new state": True},
+    ...      {"target": "Test State", "command": "set state",
+    ...       "new state": True},
+    ...      {"target": "Test State", "command": "get state"}]))
+    ... # doctest: +NORMALIZE_WHITESPACE
+    test(): {'sender': '', 'event': 'registered',
+             'client': 'Test State', 'plugin': 'State',
+             'sends': [{'event': {'const': 'changed'},
+                        'state': {'type': 'boolean'}},
+                       {'state': {'type': 'boolean'}}],
+             'receives': [{'target': {'const': 'Test State'},
+                           'command': {'const': 'get state'}},
+                          {'target': {'const': 'Test State'},
+                           'command': {'const': 'set state'},
+                           'new state': {'type': 'boolean'}}]}
+    test(): {'sender': 'test()', 'target': 'Test State',
+             'command': 'get state'}
+    test(): {'sender': 'Test State', 'state': False}
+    test(): {'sender': 'test()', 'target': 'Test State',
+             'command': 'set state', 'new state': True}
+    test(): {'sender': 'Test State', 'event': 'changed', 'state': True}
+    test(): {'sender': 'test()', 'target': 'Test State',
+             'command': 'set state', 'new state': True}
+    test(): {'sender': 'Test State', 'state': True}
+    test(): {'sender': 'test()', 'target': 'Test State',
+             'command': 'get state'}
+    test(): {'sender': 'Test State', 'state': True}
+    """
+
+    CONF_SCHEMA = True
+    """Schema for State plugin configuration.
+
+    There are no required or optional configuration keys.
+    """
+
+    async def receive(self, message: Message) -> None:
+        """Process commands to get/set state."""
+        if message['command'] == 'get state':
+            await self.bus.send(Message(self.name, {'state': self.state}))
+        elif message['command'] == 'set state':
+            if self.state != message['new state']:
+                assert isinstance(message['new state'], bool)
+                self.state: bool = message['new state']
+                await self.bus.send(Message(self.name,
+                                            {'event': 'changed',
+                                             'state': self.state}))
+            else:
+                await self.bus.send(Message(self.name,
+                                            {'state': self.state}))
+
+    def process_conf(self) -> None:
+        """Register plugin as bus client."""
+        self.state = False
+        sends = [MessageTemplate({'event': {'const': 'changed'},
+                                  'state': {'type': 'boolean'}}),
+                 MessageTemplate({'state': {'type': 'boolean'}})]
+        receives = [MessageTemplate({'target': {'const': self.name},
+                                     'command': {'const': 'get state'}}),
+                    MessageTemplate({'target': {'const': self.name},
+                                     'command': {'const': 'set state'},
+                                     'new state': {'type': 'boolean'}})]
+        self.bus.register(self.name, 'State', sends, receives, self.receive)
+
+    async def run(self) -> None:
+        """Run no code proactively."""
+        pass
+
+

Ancestors

+ +

Class variables

+
+
var CONF_SCHEMA
+
+

Schema for State plugin configuration.

+

There are no required or optional configuration keys.

+
+
+

Methods

+
+
+async def receive(self, message: Message) ‑> NoneType +
+
+

Process commands to get/set state.

+
+ +Expand source code + +
async def receive(self, message: Message) -> None:
+    """Process commands to get/set state."""
+    if message['command'] == 'get state':
+        await self.bus.send(Message(self.name, {'state': self.state}))
+    elif message['command'] == 'set state':
+        if self.state != message['new state']:
+            assert isinstance(message['new state'], bool)
+            self.state: bool = message['new state']
+            await self.bus.send(Message(self.name,
+                                        {'event': 'changed',
+                                         'state': self.state}))
+        else:
+            await self.bus.send(Message(self.name,
+                                        {'state': self.state}))
+
+
+
+def process_conf(self) ‑> NoneType +
+
+

Register plugin as bus client.

+
+ +Expand source code + +
def process_conf(self) -> None:
+    """Register plugin as bus client."""
+    self.state = False
+    sends = [MessageTemplate({'event': {'const': 'changed'},
+                              'state': {'type': 'boolean'}}),
+             MessageTemplate({'state': {'type': 'boolean'}})]
+    receives = [MessageTemplate({'target': {'const': self.name},
+                                 'command': {'const': 'get state'}}),
+                MessageTemplate({'target': {'const': self.name},
+                                 'command': {'const': 'set state'},
+                                 'new state': {'type': 'boolean'}})]
+    self.bus.register(self.name, 'State', sends, receives, self.receive)
+
+
+
+async def run(self) ‑> NoneType +
+
+

Run no code proactively.

+
+ +Expand source code + +
async def run(self) -> None:
+    """Run no code proactively."""
+    pass
+
+
+
+
+
+class StateAlias +(bus: MessageBus, name: str, conf: Dict[str, Any]) +
+
+

Define an alias for another state.

+

The "alias for" configuration key gets the name for the other state that +is aliased by the StateAlias plugin instance.

+

The "get state" and "set state" commands are forwarded to and the +"changed" events and "state" messages are forwarded from this other +state:

+
>>> import asyncio
+>>> import controlpi
+>>> asyncio.run(controlpi.test(
+...     {"Test State": {"plugin": "State"},
+...      "Test StateAlias": {"plugin": "StateAlias",
+...                          "alias for": "Test State"}},
+...     [{"target": "Test State", "command": "get state"},
+...      {"target": "Test StateAlias", "command": "set state",
+...       "new state": True},
+...      {"target": "Test State", "command": "set state",
+...       "new state": True},
+...      {"target": "Test StateAlias", "command": "get state"}]))
+... # doctest: +NORMALIZE_WHITESPACE
+test(): {'sender': '', 'event': 'registered',
+         'client': 'Test State', 'plugin': 'State',
+         'sends': [{'event': {'const': 'changed'},
+                    'state': {'type': 'boolean'}},
+                   {'state': {'type': 'boolean'}}],
+         'receives': [{'target': {'const': 'Test State'},
+                       'command': {'const': 'get state'}},
+                      {'target': {'const': 'Test State'},
+                       'command': {'const': 'set state'},
+                       'new state': {'type': 'boolean'}}]}
+test(): {'sender': '', 'event': 'registered',
+         'client': 'Test StateAlias', 'plugin': 'StateAlias',
+         'sends': [{'event': {'const': 'changed'},
+                    'state': {'type': 'boolean'}},
+                   {'state': {'type': 'boolean'}},
+                   {'target': {'const': 'Test State'},
+                    'command': {'const': 'get state'}},
+                   {'target': {'const': 'Test State'},
+                    'command': {'const': 'set state'},
+                    'new state': {'type': 'boolean'}}],
+         'receives': [{'target': {'const': 'Test StateAlias'},
+                       'command': {'const': 'get state'}},
+                      {'target': {'const': 'Test StateAlias'},
+                       'command': {'const': 'set state'},
+                       'new state': {'type': 'boolean'}},
+                      {'sender': {'const': 'Test State'},
+                       'event': {'const': 'changed'},
+                       'state': {'type': 'boolean'}},
+                      {'sender': {'const': 'Test State'},
+                       'state': {'type': 'boolean'}}]}
+test(): {'sender': 'test()', 'target': 'Test State',
+         'command': 'get state'}
+test(): {'sender': 'Test State', 'state': False}
+test(): {'sender': 'Test StateAlias', 'state': False}
+test(): {'sender': 'test()', 'target': 'Test StateAlias',
+         'command': 'set state', 'new state': True}
+test(): {'sender': 'Test StateAlias', 'target': 'Test State',
+         'command': 'set state', 'new state': True}
+test(): {'sender': 'Test State', 'event': 'changed', 'state': True}
+test(): {'sender': 'Test StateAlias', 'event': 'changed', 'state': True}
+test(): {'sender': 'test()', 'target': 'Test State',
+         'command': 'set state', 'new state': True}
+test(): {'sender': 'Test State', 'state': True}
+test(): {'sender': 'Test StateAlias', 'state': True}
+test(): {'sender': 'test()', 'target': 'Test StateAlias',
+         'command': 'get state'}
+test(): {'sender': 'Test StateAlias', 'target': 'Test State',
+         'command': 'get state'}
+test(): {'sender': 'Test State', 'state': True}
+test(): {'sender': 'Test StateAlias', 'state': True}
+
+
+ +Expand source code + +
class StateAlias(BasePlugin):
+    """Define an alias for another state.
+
+    The "alias for" configuration key gets the name for the other state that
+    is aliased by the StateAlias plugin instance.
+
+    The "get state" and "set state" commands are forwarded to and the
+    "changed" events and "state" messages are forwarded from this other
+    state:
+    >>> import asyncio
+    >>> import controlpi
+    >>> asyncio.run(controlpi.test(
+    ...     {"Test State": {"plugin": "State"},
+    ...      "Test StateAlias": {"plugin": "StateAlias",
+    ...                          "alias for": "Test State"}},
+    ...     [{"target": "Test State", "command": "get state"},
+    ...      {"target": "Test StateAlias", "command": "set state",
+    ...       "new state": True},
+    ...      {"target": "Test State", "command": "set state",
+    ...       "new state": True},
+    ...      {"target": "Test StateAlias", "command": "get state"}]))
+    ... # doctest: +NORMALIZE_WHITESPACE
+    test(): {'sender': '', 'event': 'registered',
+             'client': 'Test State', 'plugin': 'State',
+             'sends': [{'event': {'const': 'changed'},
+                        'state': {'type': 'boolean'}},
+                       {'state': {'type': 'boolean'}}],
+             'receives': [{'target': {'const': 'Test State'},
+                           'command': {'const': 'get state'}},
+                          {'target': {'const': 'Test State'},
+                           'command': {'const': 'set state'},
+                           'new state': {'type': 'boolean'}}]}
+    test(): {'sender': '', 'event': 'registered',
+             'client': 'Test StateAlias', 'plugin': 'StateAlias',
+             'sends': [{'event': {'const': 'changed'},
+                        'state': {'type': 'boolean'}},
+                       {'state': {'type': 'boolean'}},
+                       {'target': {'const': 'Test State'},
+                        'command': {'const': 'get state'}},
+                       {'target': {'const': 'Test State'},
+                        'command': {'const': 'set state'},
+                        'new state': {'type': 'boolean'}}],
+             'receives': [{'target': {'const': 'Test StateAlias'},
+                           'command': {'const': 'get state'}},
+                          {'target': {'const': 'Test StateAlias'},
+                           'command': {'const': 'set state'},
+                           'new state': {'type': 'boolean'}},
+                          {'sender': {'const': 'Test State'},
+                           'event': {'const': 'changed'},
+                           'state': {'type': 'boolean'}},
+                          {'sender': {'const': 'Test State'},
+                           'state': {'type': 'boolean'}}]}
+    test(): {'sender': 'test()', 'target': 'Test State',
+             'command': 'get state'}
+    test(): {'sender': 'Test State', 'state': False}
+    test(): {'sender': 'Test StateAlias', 'state': False}
+    test(): {'sender': 'test()', 'target': 'Test StateAlias',
+             'command': 'set state', 'new state': True}
+    test(): {'sender': 'Test StateAlias', 'target': 'Test State',
+             'command': 'set state', 'new state': True}
+    test(): {'sender': 'Test State', 'event': 'changed', 'state': True}
+    test(): {'sender': 'Test StateAlias', 'event': 'changed', 'state': True}
+    test(): {'sender': 'test()', 'target': 'Test State',
+             'command': 'set state', 'new state': True}
+    test(): {'sender': 'Test State', 'state': True}
+    test(): {'sender': 'Test StateAlias', 'state': True}
+    test(): {'sender': 'test()', 'target': 'Test StateAlias',
+             'command': 'get state'}
+    test(): {'sender': 'Test StateAlias', 'target': 'Test State',
+             'command': 'get state'}
+    test(): {'sender': 'Test State', 'state': True}
+    test(): {'sender': 'Test StateAlias', 'state': True}
+    """
+
+    CONF_SCHEMA = {'properties': {'alias for': {'type': 'string'}},
+                   'required': ['alias for']}
+    """Schema for StateAlias plugin configuration.
+
+    Required configuration key:
+
+    - 'alias for': name of aliased state.
+    """
+
+    async def receive(self, message: Message) -> None:
+        """Translate states from and commands to aliased state."""
+        alias_message = Message(self.name)
+        if ('target' in message and message['target'] == self.name and
+                'command' in message):
+            alias_message['target'] = self.conf['alias for']
+            if message['command'] == 'get state':
+                alias_message['command'] = 'get state'
+                await self.bus.send(alias_message)
+            elif (message['command'] == 'set state' and
+                    'new state' in message):
+                alias_message['command'] = 'set state'
+                alias_message['new state'] = message['new state']
+                await self.bus.send(alias_message)
+        if (message['sender'] == self.conf['alias for'] and
+                'state' in message):
+            if 'event' in message and message['event'] == 'changed':
+                alias_message['event'] = 'changed'
+            alias_message['state'] = message['state']
+            await self.bus.send(alias_message)
+
+    def process_conf(self) -> None:
+        """Register plugin as bus client."""
+        sends = [MessageTemplate({'target': {'const': self.conf['alias for']},
+                                  'command': {'const': 'get state'}}),
+                 MessageTemplate({'target': {'const': self.conf['alias for']},
+                                  'command': {'const': 'set state'},
+                                  'new state': {'type': 'boolean'}}),
+                 MessageTemplate({'event': {'const': 'changed'},
+                                  'state': {'type': 'boolean'}}),
+                 MessageTemplate({'state': {'type': 'boolean'}})]
+        receives = [MessageTemplate({'target': {'const': self.name},
+                                     'command': {'const': 'get state'}}),
+                    MessageTemplate({'target': {'const': self.name},
+                                     'command': {'const': 'set state'},
+                                     'new state': {'type': 'boolean'}}),
+                    MessageTemplate({'sender':
+                                     {'const': self.conf['alias for']},
+                                     'event': {'const': 'changed'},
+                                     'state': {'type': 'boolean'}}),
+                    MessageTemplate({'sender':
+                                     {'const': self.conf['alias for']},
+                                     'state': {'type': 'boolean'}})]
+        self.bus.register(self.name, 'StateAlias',
+                          sends, receives, self.receive)
+
+    async def run(self) -> None:
+        """Run no code proactively."""
+        pass
+
+

Ancestors

+ +

Class variables

+
+
var CONF_SCHEMA
+
+

Schema for StateAlias plugin configuration.

+

Required configuration key:

+
    +
  • 'alias for': name of aliased state.
  • +
+
+
+

Methods

+
+
+async def receive(self, message: Message) ‑> NoneType +
+
+

Translate states from and commands to aliased state.

+
+ +Expand source code + +
async def receive(self, message: Message) -> None:
+    """Translate states from and commands to aliased state."""
+    alias_message = Message(self.name)
+    if ('target' in message and message['target'] == self.name and
+            'command' in message):
+        alias_message['target'] = self.conf['alias for']
+        if message['command'] == 'get state':
+            alias_message['command'] = 'get state'
+            await self.bus.send(alias_message)
+        elif (message['command'] == 'set state' and
+                'new state' in message):
+            alias_message['command'] = 'set state'
+            alias_message['new state'] = message['new state']
+            await self.bus.send(alias_message)
+    if (message['sender'] == self.conf['alias for'] and
+            'state' in message):
+        if 'event' in message and message['event'] == 'changed':
+            alias_message['event'] = 'changed'
+        alias_message['state'] = message['state']
+        await self.bus.send(alias_message)
+
+
+
+def process_conf(self) ‑> NoneType +
+
+

Register plugin as bus client.

+
+ +Expand source code + +
def process_conf(self) -> None:
+    """Register plugin as bus client."""
+    sends = [MessageTemplate({'target': {'const': self.conf['alias for']},
+                              'command': {'const': 'get state'}}),
+             MessageTemplate({'target': {'const': self.conf['alias for']},
+                              'command': {'const': 'set state'},
+                              'new state': {'type': 'boolean'}}),
+             MessageTemplate({'event': {'const': 'changed'},
+                              'state': {'type': 'boolean'}}),
+             MessageTemplate({'state': {'type': 'boolean'}})]
+    receives = [MessageTemplate({'target': {'const': self.name},
+                                 'command': {'const': 'get state'}}),
+                MessageTemplate({'target': {'const': self.name},
+                                 'command': {'const': 'set state'},
+                                 'new state': {'type': 'boolean'}}),
+                MessageTemplate({'sender':
+                                 {'const': self.conf['alias for']},
+                                 'event': {'const': 'changed'},
+                                 'state': {'type': 'boolean'}}),
+                MessageTemplate({'sender':
+                                 {'const': self.conf['alias for']},
+                                 'state': {'type': 'boolean'}})]
+    self.bus.register(self.name, 'StateAlias',
+                      sends, receives, self.receive)
+
+
+
+async def run(self) ‑> NoneType +
+
+

Run no code proactively.

+
+ +Expand source code + +
async def run(self) -> None:
+    """Run no code proactively."""
+    pass
+
+
+
+
+
+class AndState +(bus: MessageBus, name: str, conf: Dict[str, Any]) +
+
+

Implement conjunction of states.

+

The "states" configuration key gets an array of states to be combined. +An AndState plugin client reacts to "get state" commands and sends +"changed" events when a change in one of the combined states leads to +a change for the conjunction:

+
>>> import asyncio
+>>> import controlpi
+>>> asyncio.run(controlpi.test(
+...     {"Test State 1": {"plugin": "State"},
+...      "Test State 2": {"plugin": "State"},
+...      "Test AndState": {"plugin": "AndState",
+...                        "states": ["Test State 1", "Test State 2"]}},
+...     [{"target": "Test State 1", "command": "set state",
+...       "new state": True},
+...      {"target": "Test State 2", "command": "set state",
+...       "new state": True},
+...      {"target": "Test State 1", "command": "set state",
+...       "new state": False},
+...      {"target": "Test AndState", "command": "get state"}]))
+... # doctest: +NORMALIZE_WHITESPACE
+test(): {'sender': '', 'event': 'registered',
+         'client': 'Test State 1', 'plugin': 'State',
+         'sends': [{'event': {'const': 'changed'},
+                    'state': {'type': 'boolean'}},
+                   {'state': {'type': 'boolean'}}],
+         'receives': [{'target': {'const': 'Test State 1'},
+                       'command': {'const': 'get state'}},
+                      {'target': {'const': 'Test State 1'},
+                       'command': {'const': 'set state'},
+                       'new state': {'type': 'boolean'}}]}
+test(): {'sender': '', 'event': 'registered',
+         'client': 'Test State 2', 'plugin': 'State',
+         'sends': [{'event': {'const': 'changed'},
+                    'state': {'type': 'boolean'}},
+                   {'state': {'type': 'boolean'}}],
+         'receives': [{'target': {'const': 'Test State 2'},
+                       'command': {'const': 'get state'}},
+                      {'target': {'const': 'Test State 2'},
+                       'command': {'const': 'set state'},
+                       'new state': {'type': 'boolean'}}]}
+test(): {'sender': '', 'event': 'registered',
+         'client': 'Test AndState', 'plugin': 'AndState',
+         'sends': [{'event': {'const': 'changed'},
+                    'state': {'type': 'boolean'}},
+                   {'state': {'type': 'boolean'}}],
+         'receives': [{'target': {'const': 'Test AndState'},
+                       'command': {'const': 'get state'}},
+                      {'sender': {'const': 'Test State 1'},
+                       'state': {'type': 'boolean'}},
+                      {'sender': {'const': 'Test State 2'},
+                       'state': {'type': 'boolean'}}]}
+test(): {'sender': 'test()', 'target': 'Test State 1',
+         'command': 'set state', 'new state': True}
+test(): {'sender': 'Test State 1', 'event': 'changed', 'state': True}
+test(): {'sender': 'test()', 'target': 'Test State 2',
+         'command': 'set state', 'new state': True}
+test(): {'sender': 'Test State 2', 'event': 'changed', 'state': True}
+test(): {'sender': 'Test AndState', 'event': 'changed', 'state': True}
+test(): {'sender': 'test()', 'target': 'Test State 1',
+         'command': 'set state', 'new state': False}
+test(): {'sender': 'Test State 1', 'event': 'changed', 'state': False}
+test(): {'sender': 'Test AndState', 'event': 'changed', 'state': False}
+test(): {'sender': 'test()', 'target': 'Test AndState',
+         'command': 'get state'}
+test(): {'sender': 'Test AndState', 'state': False}
+
+
+ +Expand source code + +
class AndState(BasePlugin):
+    """Implement conjunction of states.
+
+    The "states" configuration key gets an array of states to be combined.
+    An AndState plugin client reacts to "get state" commands and sends
+    "changed" events when a change in one of the combined states leads to
+    a change for the conjunction:
+    >>> import asyncio
+    >>> import controlpi
+    >>> asyncio.run(controlpi.test(
+    ...     {"Test State 1": {"plugin": "State"},
+    ...      "Test State 2": {"plugin": "State"},
+    ...      "Test AndState": {"plugin": "AndState",
+    ...                        "states": ["Test State 1", "Test State 2"]}},
+    ...     [{"target": "Test State 1", "command": "set state",
+    ...       "new state": True},
+    ...      {"target": "Test State 2", "command": "set state",
+    ...       "new state": True},
+    ...      {"target": "Test State 1", "command": "set state",
+    ...       "new state": False},
+    ...      {"target": "Test AndState", "command": "get state"}]))
+    ... # doctest: +NORMALIZE_WHITESPACE
+    test(): {'sender': '', 'event': 'registered',
+             'client': 'Test State 1', 'plugin': 'State',
+             'sends': [{'event': {'const': 'changed'},
+                        'state': {'type': 'boolean'}},
+                       {'state': {'type': 'boolean'}}],
+             'receives': [{'target': {'const': 'Test State 1'},
+                           'command': {'const': 'get state'}},
+                          {'target': {'const': 'Test State 1'},
+                           'command': {'const': 'set state'},
+                           'new state': {'type': 'boolean'}}]}
+    test(): {'sender': '', 'event': 'registered',
+             'client': 'Test State 2', 'plugin': 'State',
+             'sends': [{'event': {'const': 'changed'},
+                        'state': {'type': 'boolean'}},
+                       {'state': {'type': 'boolean'}}],
+             'receives': [{'target': {'const': 'Test State 2'},
+                           'command': {'const': 'get state'}},
+                          {'target': {'const': 'Test State 2'},
+                           'command': {'const': 'set state'},
+                           'new state': {'type': 'boolean'}}]}
+    test(): {'sender': '', 'event': 'registered',
+             'client': 'Test AndState', 'plugin': 'AndState',
+             'sends': [{'event': {'const': 'changed'},
+                        'state': {'type': 'boolean'}},
+                       {'state': {'type': 'boolean'}}],
+             'receives': [{'target': {'const': 'Test AndState'},
+                           'command': {'const': 'get state'}},
+                          {'sender': {'const': 'Test State 1'},
+                           'state': {'type': 'boolean'}},
+                          {'sender': {'const': 'Test State 2'},
+                           'state': {'type': 'boolean'}}]}
+    test(): {'sender': 'test()', 'target': 'Test State 1',
+             'command': 'set state', 'new state': True}
+    test(): {'sender': 'Test State 1', 'event': 'changed', 'state': True}
+    test(): {'sender': 'test()', 'target': 'Test State 2',
+             'command': 'set state', 'new state': True}
+    test(): {'sender': 'Test State 2', 'event': 'changed', 'state': True}
+    test(): {'sender': 'Test AndState', 'event': 'changed', 'state': True}
+    test(): {'sender': 'test()', 'target': 'Test State 1',
+             'command': 'set state', 'new state': False}
+    test(): {'sender': 'Test State 1', 'event': 'changed', 'state': False}
+    test(): {'sender': 'Test AndState', 'event': 'changed', 'state': False}
+    test(): {'sender': 'test()', 'target': 'Test AndState',
+             'command': 'get state'}
+    test(): {'sender': 'Test AndState', 'state': False}
+    """
+
+    CONF_SCHEMA = {'properties': {'states': {'type': 'array',
+                                             'items': {'type': 'string'}}},
+                   'required': ['states']}
+    """Schema for AndState plugin configuration.
+
+    Required configuration key:
+
+    - 'states': list of names of combined states.
+    """
+
+    async def receive(self, message: Message) -> None:
+        """Process "get state" command and messages of combined states."""
+        if ('target' in message and message['target'] == self.name and
+                'command' in message and message['command'] == 'get state'):
+            await self.bus.send(Message(self.name, {'state': self.state}))
+        if 'state' in message and message['sender'] in self.conf['states']:
+            assert isinstance(message['sender'], str)
+            assert isinstance(message['state'], bool)
+            self.states[message['sender']] = message['state']
+            new_state = all(self.states.values())
+            if self.state != new_state:
+                self.state: bool = new_state
+                await self.bus.send(Message(self.name,
+                                            {'event': 'changed',
+                                             'state': self.state}))
+
+    def process_conf(self) -> None:
+        """Register plugin as bus client."""
+        sends = [MessageTemplate({'event': {'const': 'changed'},
+                                  'state': {'type': 'boolean'}}),
+                 MessageTemplate({'state': {'type': 'boolean'}})]
+        receives = [MessageTemplate({'target': {'const': self.name},
+                                     'command': {'const': 'get state'}})]
+        self.states: Dict[str, bool] = {}
+        for state in self.conf['states']:
+            receives.append(MessageTemplate({'sender': {'const': state},
+                                             'state': {'type': 'boolean'}}))
+            self.states[state] = False
+        self.state = all(self.states.values())
+        self.bus.register(self.name, 'AndState',
+                          sends, receives, self.receive)
+
+    async def run(self) -> None:
+        """Run no code proactively."""
+        pass
+
+

Ancestors

+ +

Class variables

+
+
var CONF_SCHEMA
+
+

Schema for AndState plugin configuration.

+

Required configuration key:

+
    +
  • 'states': list of names of combined states.
  • +
+
+
+

Methods

+
+
+async def receive(self, message: Message) ‑> NoneType +
+
+

Process "get state" command and messages of combined states.

+
+ +Expand source code + +
async def receive(self, message: Message) -> None:
+    """Process "get state" command and messages of combined states."""
+    if ('target' in message and message['target'] == self.name and
+            'command' in message and message['command'] == 'get state'):
+        await self.bus.send(Message(self.name, {'state': self.state}))
+    if 'state' in message and message['sender'] in self.conf['states']:
+        assert isinstance(message['sender'], str)
+        assert isinstance(message['state'], bool)
+        self.states[message['sender']] = message['state']
+        new_state = all(self.states.values())
+        if self.state != new_state:
+            self.state: bool = new_state
+            await self.bus.send(Message(self.name,
+                                        {'event': 'changed',
+                                         'state': self.state}))
+
+
+
+def process_conf(self) ‑> NoneType +
+
+

Register plugin as bus client.

+
+ +Expand source code + +
def process_conf(self) -> None:
+    """Register plugin as bus client."""
+    sends = [MessageTemplate({'event': {'const': 'changed'},
+                              'state': {'type': 'boolean'}}),
+             MessageTemplate({'state': {'type': 'boolean'}})]
+    receives = [MessageTemplate({'target': {'const': self.name},
+                                 'command': {'const': 'get state'}})]
+    self.states: Dict[str, bool] = {}
+    for state in self.conf['states']:
+        receives.append(MessageTemplate({'sender': {'const': state},
+                                         'state': {'type': 'boolean'}}))
+        self.states[state] = False
+    self.state = all(self.states.values())
+    self.bus.register(self.name, 'AndState',
+                      sends, receives, self.receive)
+
+
+
+async def run(self) ‑> NoneType +
+
+

Run no code proactively.

+
+ +Expand source code + +
async def run(self) -> None:
+    """Run no code proactively."""
+    pass
+
+
+
+
+
+class OrState +(bus: MessageBus, name: str, conf: Dict[str, Any]) +
+
+

Implement disjunction of states.

+

The "states" configuration key gets an array of states to be combined. +An OrState plugin client reacts to "get state" commands and sends +"changed" events when a change in one of the combined states leads to +a change for the disjunction:

+
>>> import asyncio
+>>> import controlpi
+>>> asyncio.run(controlpi.test(
+...     {"Test State 1": {"plugin": "State"},
+...      "Test State 2": {"plugin": "State"},
+...      "Test OrState": {"plugin": "OrState",
+...                       "states": ["Test State 1", "Test State 2"]}},
+...     [{"target": "Test State 1", "command": "set state",
+...       "new state": True},
+...      {"target": "Test State 2", "command": "set state",
+...       "new state": True},
+...      {"target": "Test State 1", "command": "set state",
+...       "new state": False},
+...      {"target": "Test OrState", "command": "get state"}]))
+... # doctest: +NORMALIZE_WHITESPACE
+test(): {'sender': '', 'event': 'registered',
+         'client': 'Test State 1', 'plugin': 'State',
+         'sends': [{'event': {'const': 'changed'},
+                    'state': {'type': 'boolean'}},
+                   {'state': {'type': 'boolean'}}],
+         'receives': [{'target': {'const': 'Test State 1'},
+                       'command': {'const': 'get state'}},
+                      {'target': {'const': 'Test State 1'},
+                       'command': {'const': 'set state'},
+                       'new state': {'type': 'boolean'}}]}
+test(): {'sender': '', 'event': 'registered',
+         'client': 'Test State 2', 'plugin': 'State',
+         'sends': [{'event': {'const': 'changed'},
+                    'state': {'type': 'boolean'}},
+                   {'state': {'type': 'boolean'}}],
+         'receives': [{'target': {'const': 'Test State 2'},
+                       'command': {'const': 'get state'}},
+                      {'target': {'const': 'Test State 2'},
+                       'command': {'const': 'set state'},
+                       'new state': {'type': 'boolean'}}]}
+test(): {'sender': '', 'event': 'registered',
+         'client': 'Test OrState', 'plugin': 'OrState',
+         'sends': [{'event': {'const': 'changed'},
+                    'state': {'type': 'boolean'}},
+                   {'state': {'type': 'boolean'}}],
+         'receives': [{'target': {'const': 'Test OrState'},
+                       'command': {'const': 'get state'}},
+                      {'sender': {'const': 'Test State 1'},
+                       'state': {'type': 'boolean'}},
+                      {'sender': {'const': 'Test State 2'},
+                       'state': {'type': 'boolean'}}]}
+test(): {'sender': 'test()', 'target': 'Test State 1',
+         'command': 'set state', 'new state': True}
+test(): {'sender': 'Test State 1', 'event': 'changed', 'state': True}
+test(): {'sender': 'Test OrState', 'event': 'changed', 'state': True}
+test(): {'sender': 'test()', 'target': 'Test State 2',
+         'command': 'set state', 'new state': True}
+test(): {'sender': 'Test State 2', 'event': 'changed', 'state': True}
+test(): {'sender': 'test()', 'target': 'Test State 1',
+         'command': 'set state', 'new state': False}
+test(): {'sender': 'Test State 1', 'event': 'changed', 'state': False}
+test(): {'sender': 'test()', 'target': 'Test OrState',
+         'command': 'get state'}
+test(): {'sender': 'Test OrState', 'state': True}
+
+
+ +Expand source code + +
class OrState(BasePlugin):
+    """Implement disjunction of states.
+
+    The "states" configuration key gets an array of states to be combined.
+    An OrState plugin client reacts to "get state" commands and sends
+    "changed" events when a change in one of the combined states leads to
+    a change for the disjunction:
+    >>> import asyncio
+    >>> import controlpi
+    >>> asyncio.run(controlpi.test(
+    ...     {"Test State 1": {"plugin": "State"},
+    ...      "Test State 2": {"plugin": "State"},
+    ...      "Test OrState": {"plugin": "OrState",
+    ...                       "states": ["Test State 1", "Test State 2"]}},
+    ...     [{"target": "Test State 1", "command": "set state",
+    ...       "new state": True},
+    ...      {"target": "Test State 2", "command": "set state",
+    ...       "new state": True},
+    ...      {"target": "Test State 1", "command": "set state",
+    ...       "new state": False},
+    ...      {"target": "Test OrState", "command": "get state"}]))
+    ... # doctest: +NORMALIZE_WHITESPACE
+    test(): {'sender': '', 'event': 'registered',
+             'client': 'Test State 1', 'plugin': 'State',
+             'sends': [{'event': {'const': 'changed'},
+                        'state': {'type': 'boolean'}},
+                       {'state': {'type': 'boolean'}}],
+             'receives': [{'target': {'const': 'Test State 1'},
+                           'command': {'const': 'get state'}},
+                          {'target': {'const': 'Test State 1'},
+                           'command': {'const': 'set state'},
+                           'new state': {'type': 'boolean'}}]}
+    test(): {'sender': '', 'event': 'registered',
+             'client': 'Test State 2', 'plugin': 'State',
+             'sends': [{'event': {'const': 'changed'},
+                        'state': {'type': 'boolean'}},
+                       {'state': {'type': 'boolean'}}],
+             'receives': [{'target': {'const': 'Test State 2'},
+                           'command': {'const': 'get state'}},
+                          {'target': {'const': 'Test State 2'},
+                           'command': {'const': 'set state'},
+                           'new state': {'type': 'boolean'}}]}
+    test(): {'sender': '', 'event': 'registered',
+             'client': 'Test OrState', 'plugin': 'OrState',
+             'sends': [{'event': {'const': 'changed'},
+                        'state': {'type': 'boolean'}},
+                       {'state': {'type': 'boolean'}}],
+             'receives': [{'target': {'const': 'Test OrState'},
+                           'command': {'const': 'get state'}},
+                          {'sender': {'const': 'Test State 1'},
+                           'state': {'type': 'boolean'}},
+                          {'sender': {'const': 'Test State 2'},
+                           'state': {'type': 'boolean'}}]}
+    test(): {'sender': 'test()', 'target': 'Test State 1',
+             'command': 'set state', 'new state': True}
+    test(): {'sender': 'Test State 1', 'event': 'changed', 'state': True}
+    test(): {'sender': 'Test OrState', 'event': 'changed', 'state': True}
+    test(): {'sender': 'test()', 'target': 'Test State 2',
+             'command': 'set state', 'new state': True}
+    test(): {'sender': 'Test State 2', 'event': 'changed', 'state': True}
+    test(): {'sender': 'test()', 'target': 'Test State 1',
+             'command': 'set state', 'new state': False}
+    test(): {'sender': 'Test State 1', 'event': 'changed', 'state': False}
+    test(): {'sender': 'test()', 'target': 'Test OrState',
+             'command': 'get state'}
+    test(): {'sender': 'Test OrState', 'state': True}
+    """
+
+    CONF_SCHEMA = {'properties': {'states': {'type': 'array',
+                                             'items': {'type': 'string'}}},
+                   'required': ['states']}
+    """Schema for OrState plugin configuration.
+
+    Required configuration key:
+
+    - 'states': list of names of combined states.
+    """
+
+    async def receive(self, message: Message) -> None:
+        """Process "get state" command and messages of combined states."""
+        if ('target' in message and message['target'] == self.name and
+                'command' in message and message['command'] == 'get state'):
+            await self.bus.send(Message(self.name, {'state': self.state}))
+        if 'state' in message and message['sender'] in self.conf['states']:
+            assert isinstance(message['sender'], str)
+            assert isinstance(message['state'], bool)
+            self.states[message['sender']] = message['state']
+            new_state = any(self.states.values())
+            if self.state != new_state:
+                self.state: bool = new_state
+                await self.bus.send(Message(self.name,
+                                            {'event': 'changed',
+                                             'state': self.state}))
+
+    def process_conf(self) -> None:
+        """Register plugin as bus client."""
+        sends = [MessageTemplate({'event': {'const': 'changed'},
+                                  'state': {'type': 'boolean'}}),
+                 MessageTemplate({'state': {'type': 'boolean'}})]
+        receives = [MessageTemplate({'target': {'const': self.name},
+                                     'command': {'const': 'get state'}})]
+        self.states: Dict[str, bool] = {}
+        for state in self.conf['states']:
+            receives.append(MessageTemplate({'sender': {'const': state},
+                                             'state': {'type': 'boolean'}}))
+            self.states[state] = False
+        self.state = any(self.states.values())
+        self.bus.register(self.name, 'OrState',
+                          sends, receives, self.receive)
+
+    async def run(self) -> None:
+        """Run no code proactively."""
+        pass
+
+

Ancestors

+ +

Class variables

+
+
var CONF_SCHEMA
+
+

Schema for OrState plugin configuration.

+

Required configuration key:

+
    +
  • 'states': list of names of combined states.
  • +
+
+
+

Methods

+
+
+async def receive(self, message: Message) ‑> NoneType +
+
+

Process "get state" command and messages of combined states.

+
+ +Expand source code + +
async def receive(self, message: Message) -> None:
+    """Process "get state" command and messages of combined states."""
+    if ('target' in message and message['target'] == self.name and
+            'command' in message and message['command'] == 'get state'):
+        await self.bus.send(Message(self.name, {'state': self.state}))
+    if 'state' in message and message['sender'] in self.conf['states']:
+        assert isinstance(message['sender'], str)
+        assert isinstance(message['state'], bool)
+        self.states[message['sender']] = message['state']
+        new_state = any(self.states.values())
+        if self.state != new_state:
+            self.state: bool = new_state
+            await self.bus.send(Message(self.name,
+                                        {'event': 'changed',
+                                         'state': self.state}))
+
+
+
+def process_conf(self) ‑> NoneType +
+
+

Register plugin as bus client.

+
+ +Expand source code + +
def process_conf(self) -> None:
+    """Register plugin as bus client."""
+    sends = [MessageTemplate({'event': {'const': 'changed'},
+                              'state': {'type': 'boolean'}}),
+             MessageTemplate({'state': {'type': 'boolean'}})]
+    receives = [MessageTemplate({'target': {'const': self.name},
+                                 'command': {'const': 'get state'}})]
+    self.states: Dict[str, bool] = {}
+    for state in self.conf['states']:
+        receives.append(MessageTemplate({'sender': {'const': state},
+                                         'state': {'type': 'boolean'}}))
+        self.states[state] = False
+    self.state = any(self.states.values())
+    self.bus.register(self.name, 'OrState',
+                      sends, receives, self.receive)
+
+
+
+async def run(self) ‑> NoneType +
+
+

Run no code proactively.

+
+ +Expand source code + +
async def run(self) -> None:
+    """Run no code proactively."""
+    pass
+
+
+
+
+
+
+
+ +
+ + + \ No newline at end of file diff --git a/doc/controlpi_plugins/util.html b/doc/controlpi_plugins/util.html new file mode 100644 index 0000000..ca720bb --- /dev/null +++ b/doc/controlpi_plugins/util.html @@ -0,0 +1,1421 @@ + + + + + + +controlpi_plugins.util API documentation + + + + + + + + + + + +
+
+
+

Module controlpi_plugins.util

+
+
+

Provide utility plugins for all kinds of systems.

+
    +
  • Log logs messages on stdout.
  • +
  • Init sends list of messages on startup and on demand.
  • +
  • Alias translates messages to an alias.
  • +
+
>>> import controlpi
+>>> asyncio.run(controlpi.test(
+...     {"Test Log": {"plugin": "Log",
+...                   "filter": [{"sender": {"const": "Test Alias"}}]},
+...      "Test Init": {"plugin": "Init",
+...                    "messages": [{"id": 42, "content": "Test Message"}]},
+...      "Test Alias": {"plugin": "Alias",
+...                     "from": {"sender": {"const": "Test Init"},
+...                              "id": {"const": 42}},
+...                     "to": {"id": "translated"}}}, []))
+... # doctest: +NORMALIZE_WHITESPACE
+test(): {'sender': '', 'event': 'registered',
+         'client': 'Test Log', 'plugin': 'Log',
+         'sends': [], 'receives': [{'sender': {'const': 'Test Alias'}}]}
+test(): {'sender': '', 'event': 'registered',
+         'client': 'Test Init', 'plugin': 'Init',
+         'sends': [{'id': {'const': 42},
+                    'content': {'const': 'Test Message'}}],
+         'receives': [{'target': {'const': 'Test Init'},
+                       'command': {'const': 'execute'}}]}
+test(): {'sender': '', 'event': 'registered',
+         'client': 'Test Alias', 'plugin': 'Alias',
+         'sends': [{'id': {'const': 'translated'}}],
+         'receives': [{'sender': {'const': 'Test Init'},
+                       'id': {'const': 42}}]}
+test(): {'sender': 'Test Init', 'id': 42,
+         'content': 'Test Message'}
+test(): {'sender': 'Test Alias', 'id': 'translated',
+         'content': 'Test Message'}
+Test Log: {'sender': 'Test Alias', 'id': 'translated',
+           'content': 'Test Message'}
+
+
+ +Expand source code + +
"""Provide utility plugins for all kinds of systems.
+
+- Log logs messages on stdout.
+- Init sends list of messages on startup and on demand.
+- Alias translates messages to an alias.
+
+>>> import controlpi
+>>> asyncio.run(controlpi.test(
+...     {"Test Log": {"plugin": "Log",
+...                   "filter": [{"sender": {"const": "Test Alias"}}]},
+...      "Test Init": {"plugin": "Init",
+...                    "messages": [{"id": 42, "content": "Test Message"}]},
+...      "Test Alias": {"plugin": "Alias",
+...                     "from": {"sender": {"const": "Test Init"},
+...                              "id": {"const": 42}},
+...                     "to": {"id": "translated"}}}, []))
+... # doctest: +NORMALIZE_WHITESPACE
+test(): {'sender': '', 'event': 'registered',
+         'client': 'Test Log', 'plugin': 'Log',
+         'sends': [], 'receives': [{'sender': {'const': 'Test Alias'}}]}
+test(): {'sender': '', 'event': 'registered',
+         'client': 'Test Init', 'plugin': 'Init',
+         'sends': [{'id': {'const': 42},
+                    'content': {'const': 'Test Message'}}],
+         'receives': [{'target': {'const': 'Test Init'},
+                       'command': {'const': 'execute'}}]}
+test(): {'sender': '', 'event': 'registered',
+         'client': 'Test Alias', 'plugin': 'Alias',
+         'sends': [{'id': {'const': 'translated'}}],
+         'receives': [{'sender': {'const': 'Test Init'},
+                       'id': {'const': 42}}]}
+test(): {'sender': 'Test Init', 'id': 42,
+         'content': 'Test Message'}
+test(): {'sender': 'Test Alias', 'id': 'translated',
+         'content': 'Test Message'}
+Test Log: {'sender': 'Test Alias', 'id': 'translated',
+           'content': 'Test Message'}
+"""
+import asyncio
+
+from controlpi import BasePlugin, Message, MessageTemplate
+
+
+class Log(BasePlugin):
+    """Log messages on stdout.
+
+    The "filter" configuration key gets a list of message templates defining
+    the messages that should be logged by the plugin instance.
+
+    In the following example the first and third message match the given
+    template and are logged by the instance "Test Log", while the second
+    message does not match and is only logged by the test, but not by the
+    Log instance:
+    >>> import controlpi
+    >>> asyncio.run(controlpi.test(
+    ...     {"Test Log": {"plugin": "Log",
+    ...                   "filter": [{"id": {"const": 42}}]}},
+    ...     [{"id": 42, "message": "Test Message"},
+    ...      {"id": 42.42, "message": "Second Message"},
+    ...      {"id": 42, "message": "Third Message"}]))
+    ... # doctest: +NORMALIZE_WHITESPACE
+    test(): {'sender': '', 'event': 'registered',
+             'client': 'Test Log', 'plugin': 'Log',
+             'sends': [], 'receives': [{'id': {'const': 42}}]}
+    test(): {'sender': 'test()', 'id': 42, 'message': 'Test Message'}
+    Test Log: {'sender': 'test()', 'id': 42, 'message': 'Test Message'}
+    test(): {'sender': 'test()', 'id': 42.42, 'message': 'Second Message'}
+    test(): {'sender': 'test()', 'id': 42, 'message': 'Third Message'}
+    Test Log: {'sender': 'test()', 'id': 42, 'message': 'Third Message'}
+
+    The "filter" key is required:
+    >>> asyncio.run(controlpi.test(
+    ...     {"Test Log": {"plugin": "Log"}}, []))
+    'filter' is a required property
+    <BLANKLINE>
+    Failed validating 'required' in schema:
+        {'properties': {'filter': {'items': {'type': 'object'},
+                                   'type': 'array'}},
+         'required': ['filter']}
+    <BLANKLINE>
+    On instance:
+        {'plugin': 'Log'}
+    Configuration for 'Test Log' is not valid.
+
+    The "filter" key has to contain a list of message templates, i.e.,
+    JSON objects:
+    >>> asyncio.run(controlpi.test(
+    ...     {"Test Log": {"plugin": "Log",
+    ...                   "filter": [42]}}, []))
+    42 is not of type 'object'
+    <BLANKLINE>
+    Failed validating 'type' in schema['properties']['filter']['items']:
+        {'type': 'object'}
+    <BLANKLINE>
+    On instance['filter'][0]:
+        42
+    Configuration for 'Test Log' is not valid.
+    """
+
+    CONF_SCHEMA = {'properties': {'filter': {'type': 'array',
+                                             'items': {'type': 'object'}}},
+                   'required': ['filter']}
+    """Schema for Log plugin configuration.
+
+    Required configuration key:
+
+    - 'filter': list of message templates to be logged.
+    """
+
+    async def log(self, message: Message) -> None:
+        """Log received message on stdout using own name as prefix."""
+        print(f"{self.name}: {message}")
+
+    def process_conf(self) -> None:
+        """Register plugin as bus client."""
+        self.bus.register(self.name, 'Log', [], self.conf['filter'], self.log)
+
+    async def run(self) -> None:
+        """Run no code proactively."""
+        pass
+
+
+class Init(BasePlugin):
+    """Send list of messages on startup and on demand.
+
+    The "messages" configuration key gets a list of messages to be sent on
+    startup. The same list is sent in reaction to a message with
+    "target": <name> and "command": "execute".
+
+    In the example, the two configured messages are sent twice, once at
+    startup and a second time in reaction to the "execute" command sent by
+    the test:
+    >>> import controlpi
+    >>> asyncio.run(controlpi.test(
+    ...     {"Test Init": {"plugin": "Init",
+    ...                    "messages": [{"id": 42,
+    ...                                  "content": "Test Message"},
+    ...                                 {"id": 42.42,
+    ...                                  "content": "Second Message"}]}},
+    ...     [{"target": "Test Init", "command": "execute"}]))
+    ... # doctest: +NORMALIZE_WHITESPACE
+    test(): {'sender': '', 'event': 'registered',
+             'client': 'Test Init', 'plugin': 'Init',
+             'sends': [{'id': {'const': 42},
+                        'content': {'const': 'Test Message'}},
+                       {'id': {'const': 42.42},
+                        'content': {'const': 'Second Message'}}],
+             'receives': [{'target': {'const': 'Test Init'},
+                           'command': {'const': 'execute'}}]}
+    test(): {'sender': 'Test Init', 'id': 42, 'content': 'Test Message'}
+    test(): {'sender': 'Test Init', 'id': 42.42, 'content': 'Second Message'}
+    test(): {'sender': 'test()', 'target': 'Test Init', 'command': 'execute'}
+    test(): {'sender': 'Test Init', 'id': 42, 'content': 'Test Message'}
+    test(): {'sender': 'Test Init', 'id': 42.42, 'content': 'Second Message'}
+
+    The "messages" key is required:
+    >>> asyncio.run(controlpi.test(
+    ...     {"Test Init": {"plugin": "Init"}}, []))
+    'messages' is a required property
+    <BLANKLINE>
+    Failed validating 'required' in schema:
+        {'properties': {'messages': {'items': {'type': 'object'},
+                                     'type': 'array'}},
+         'required': ['messages']}
+    <BLANKLINE>
+    On instance:
+        {'plugin': 'Init'}
+    Configuration for 'Test Init' is not valid.
+
+    The "messages" key has to contain a list of (partial) messages, i.e.,
+    JSON objects:
+    >>> asyncio.run(controlpi.test(
+    ...     {"Test Init": {"plugin": "Init",
+    ...                    "messages": [42]}}, []))
+    42 is not of type 'object'
+    <BLANKLINE>
+    Failed validating 'type' in schema['properties']['messages']['items']:
+        {'type': 'object'}
+    <BLANKLINE>
+    On instance['messages'][0]:
+        42
+    Configuration for 'Test Init' is not valid.
+    """
+
+    CONF_SCHEMA = {'properties': {'messages': {'type': 'array',
+                                               'items': {'type': 'object'}}},
+                   'required': ['messages']}
+    """Schema for Init plugin configuration.
+
+    Required configuration key:
+
+    - 'messages': list of messages to be sent.
+    """
+
+    async def execute(self, message: Message) -> None:
+        """Send configured messages."""
+        for message in self.conf['messages']:
+            await self.bus.send(Message(self.name, message))
+            # Give immediate reactions to messages opportunity to happen:
+            await asyncio.sleep(0)
+
+    def process_conf(self) -> None:
+        """Register plugin as bus client."""
+        receives = [MessageTemplate({'target': {'const': self.name},
+                                     'command': {'const': 'execute'}})]
+        sends = [MessageTemplate.from_message(message)
+                 for message in self.conf['messages']]
+        self.bus.register(self.name, 'Init', sends, receives, self.execute)
+
+    async def run(self) -> None:
+        """Send configured messages on startup."""
+        for message in self.conf['messages']:
+            await self.bus.send(Message(self.name, message))
+
+
+class Execute(BasePlugin):
+    """Send configurable list of messages on demand.
+
+    An Execute plugin instance receives two kinds of commands.
+    The "set messages" command has a "messages" key with a list of (partial)
+    messages, which are sent by the Execute instance in reaction to an
+    "execute" command.
+
+    In the example, the first command sent by the test sets two messages,
+    which are then sent in reaction to the second command sent by the test:
+    >>> import controlpi
+    >>> asyncio.run(controlpi.test(
+    ...     {"Test Execute": {"plugin": "Execute"}},
+    ...     [{"target": "Test Execute", "command": "set messages",
+    ...       "messages": [{"id": 42, "content": "Test Message"},
+    ...                    {"id": 42.42, "content": "Second Message"}]},
+    ...      {"target": "Test Execute", "command": "execute"}]))
+    ... # doctest: +NORMALIZE_WHITESPACE
+    test(): {'sender': '', 'event': 'registered',
+             'client': 'Test Execute', 'plugin': 'Execute',
+             'sends': [{}],
+             'receives': [{'target': {'const': 'Test Execute'},
+                           'command': {'const': 'set messages'},
+                           'messages': {'type': 'array',
+                                        'items': {'type': 'object'}}},
+                          {'target': {'const': 'Test Execute'},
+                           'command': {'const': 'execute'}}]}
+    test(): {'sender': 'test()', 'target': 'Test Execute',
+             'command': 'set messages',
+             'messages': [{'id': 42, 'content': 'Test Message'},
+                          {'id': 42.42, 'content': 'Second Message'}]}
+    test(): {'sender': 'test()', 'target': 'Test Execute',
+             'command': 'execute'}
+    test(): {'sender': 'Test Execute', 'id': 42,
+             'content': 'Test Message'}
+    test(): {'sender': 'Test Execute', 'id': 42.42,
+             'content': 'Second Message'}
+    """
+
+    CONF_SCHEMA = True
+    """Schema for Execute plugin configuration.
+
+    There are no required or optional configuration keys.
+    """
+
+    async def execute(self, message: Message) -> None:
+        """Set or send configured messages."""
+        if message['command'] == 'set messages':
+            assert isinstance(message['messages'], list)
+            self.messages = list(message['messages'])
+        elif message['command'] == 'execute':
+            for message in self.messages:
+                await self.bus.send(Message(self.name, message))
+                # Give immediate reactions to messages opportunity to happen:
+                await asyncio.sleep(0)
+
+    def process_conf(self) -> None:
+        """Register plugin as bus client."""
+        self.messages = []
+        receives = [MessageTemplate({'target': {'const': self.name},
+                                     'command': {'const': 'set messages'},
+                                     'messages':
+                                     {'type': 'array',
+                                      'items': {'type': 'object'}}}),
+                    MessageTemplate({'target': {'const': self.name},
+                                     'command': {'const': 'execute'}})]
+        sends = [MessageTemplate()]
+        self.bus.register(self.name, 'Execute', sends, receives, self.execute)
+
+    async def run(self) -> None:
+        """Run no code proactively."""
+        pass
+
+
+class Alias(BasePlugin):
+    """Translate messages to an alias.
+
+    The "from" configuration key gets a message template and the
+    configuration key "to" a (partial) message. All messages matching the
+    template are received by the Alias instance and a message translated by
+    removing the keys of the "from" template and adding the keys and values
+    of the "to" message is sent. Keys that are neither in "from" nor in "to"
+    are retained.
+
+    In the example, the two messages sent by the test are translated by the
+    Alias instance and the translated messages are sent by it preserving
+    the "content" keys:
+    >>> import controlpi
+    >>> asyncio.run(controlpi.test(
+    ...     {"Test Alias": {"plugin": "Alias",
+    ...                     "from": {"id": {"const": 42}},
+    ...                     "to": {"id": "translated"}}},
+    ...     [{"id": 42, "content": "Test Message"},
+    ...      {"id": 42, "content": "Second Message"}]))
+    ... # doctest: +NORMALIZE_WHITESPACE
+    test(): {'sender': '', 'event': 'registered',
+             'client': 'Test Alias', 'plugin': 'Alias',
+             'sends': [{'id': {'const': 'translated'}}],
+             'receives': [{'id': {'const': 42}}]}
+    test(): {'sender': 'test()', 'id': 42,
+             'content': 'Test Message'}
+    test(): {'sender': 'Test Alias', 'id': 'translated',
+             'content': 'Test Message'}
+    test(): {'sender': 'test()', 'id': 42,
+             'content': 'Second Message'}
+    test(): {'sender': 'Test Alias', 'id': 'translated',
+             'content': 'Second Message'}
+
+    The "from" and "to" keys are required:
+    >>> asyncio.run(controlpi.test(
+    ...     {"Test Alias": {"plugin": "Alias"}}, []))
+    'from' is a required property
+    <BLANKLINE>
+    Failed validating 'required' in schema:
+        {'properties': {'from': {'type': 'object'}, 'to': {'type': 'object'}},
+         'required': ['from', 'to']}
+    <BLANKLINE>
+    On instance:
+        {'plugin': 'Alias'}
+    'to' is a required property
+    <BLANKLINE>
+    Failed validating 'required' in schema:
+        {'properties': {'from': {'type': 'object'}, 'to': {'type': 'object'}},
+         'required': ['from', 'to']}
+    <BLANKLINE>
+    On instance:
+        {'plugin': 'Alias'}
+    Configuration for 'Test Alias' is not valid.
+
+    The "from" key has to contain a message template and the "to" key a
+    (partial) message, i.e., both have to be JSON objects:
+    >>> asyncio.run(controlpi.test(
+    ...     {"Test Alias": {"plugin": "Alias",
+    ...                     "from": 42,
+    ...                     "to": 42}}, []))
+    42 is not of type 'object'
+    <BLANKLINE>
+    Failed validating 'type' in schema['properties']['from']:
+        {'type': 'object'}
+    <BLANKLINE>
+    On instance['from']:
+        42
+    42 is not of type 'object'
+    <BLANKLINE>
+    Failed validating 'type' in schema['properties']['to']:
+        {'type': 'object'}
+    <BLANKLINE>
+    On instance['to']:
+        42
+    Configuration for 'Test Alias' is not valid.
+    """
+
+    CONF_SCHEMA = {'properties': {'from': {'type': 'object'},
+                                  'to': {'type': 'object'}},
+                   'required': ['from', 'to']}
+    """Schema for Alias plugin configuration.
+
+    Required configuration keys:
+
+    - 'from': template of messages to be translated.
+    - 'to': translated message to be sent.
+    """
+
+    async def alias(self, message: Message) -> None:
+        """Translate and send message."""
+        alias_message = Message(self.name)
+        alias_message.update(self.conf['to'])
+        for key in message:
+            if key != 'sender' and key not in self.conf['from']:
+                alias_message[key] = message[key]
+        await self.bus.send(alias_message)
+
+    def process_conf(self) -> None:
+        """Register plugin as bus client."""
+        self.bus.register(self.name, 'Alias',
+                          [MessageTemplate.from_message(self.conf['to'])],
+                          [self.conf['from']],
+                          self.alias)
+
+    async def run(self) -> None:
+        """Run no code proactively."""
+        pass
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class Log +(bus: MessageBus, name: str, conf: Dict[str, Any]) +
+
+

Log messages on stdout.

+

The "filter" configuration key gets a list of message templates defining +the messages that should be logged by the plugin instance.

+

In the following example the first and third message match the given +template and are logged by the instance "Test Log", while the second +message does not match and is only logged by the test, but not by the +Log instance:

+
>>> import controlpi
+>>> asyncio.run(controlpi.test(
+...     {"Test Log": {"plugin": "Log",
+...                   "filter": [{"id": {"const": 42}}]}},
+...     [{"id": 42, "message": "Test Message"},
+...      {"id": 42.42, "message": "Second Message"},
+...      {"id": 42, "message": "Third Message"}]))
+... # doctest: +NORMALIZE_WHITESPACE
+test(): {'sender': '', 'event': 'registered',
+         'client': 'Test Log', 'plugin': 'Log',
+         'sends': [], 'receives': [{'id': {'const': 42}}]}
+test(): {'sender': 'test()', 'id': 42, 'message': 'Test Message'}
+Test Log: {'sender': 'test()', 'id': 42, 'message': 'Test Message'}
+test(): {'sender': 'test()', 'id': 42.42, 'message': 'Second Message'}
+test(): {'sender': 'test()', 'id': 42, 'message': 'Third Message'}
+Test Log: {'sender': 'test()', 'id': 42, 'message': 'Third Message'}
+
+

The "filter" key is required:

+
>>> asyncio.run(controlpi.test(
+...     {"Test Log": {"plugin": "Log"}}, []))
+'filter' is a required property
+<BLANKLINE>
+Failed validating 'required' in schema:
+    {'properties': {'filter': {'items': {'type': 'object'},
+                               'type': 'array'}},
+     'required': ['filter']}
+<BLANKLINE>
+On instance:
+    {'plugin': 'Log'}
+Configuration for 'Test Log' is not valid.
+
+

The "filter" key has to contain a list of message templates, i.e., +JSON objects:

+
>>> asyncio.run(controlpi.test(
+...     {"Test Log": {"plugin": "Log",
+...                   "filter": [42]}}, []))
+42 is not of type 'object'
+<BLANKLINE>
+Failed validating 'type' in schema['properties']['filter']['items']:
+    {'type': 'object'}
+<BLANKLINE>
+On instance['filter'][0]:
+    42
+Configuration for 'Test Log' is not valid.
+
+
+ +Expand source code + +
class Log(BasePlugin):
+    """Log messages on stdout.
+
+    The "filter" configuration key gets a list of message templates defining
+    the messages that should be logged by the plugin instance.
+
+    In the following example the first and third message match the given
+    template and are logged by the instance "Test Log", while the second
+    message does not match and is only logged by the test, but not by the
+    Log instance:
+    >>> import controlpi
+    >>> asyncio.run(controlpi.test(
+    ...     {"Test Log": {"plugin": "Log",
+    ...                   "filter": [{"id": {"const": 42}}]}},
+    ...     [{"id": 42, "message": "Test Message"},
+    ...      {"id": 42.42, "message": "Second Message"},
+    ...      {"id": 42, "message": "Third Message"}]))
+    ... # doctest: +NORMALIZE_WHITESPACE
+    test(): {'sender': '', 'event': 'registered',
+             'client': 'Test Log', 'plugin': 'Log',
+             'sends': [], 'receives': [{'id': {'const': 42}}]}
+    test(): {'sender': 'test()', 'id': 42, 'message': 'Test Message'}
+    Test Log: {'sender': 'test()', 'id': 42, 'message': 'Test Message'}
+    test(): {'sender': 'test()', 'id': 42.42, 'message': 'Second Message'}
+    test(): {'sender': 'test()', 'id': 42, 'message': 'Third Message'}
+    Test Log: {'sender': 'test()', 'id': 42, 'message': 'Third Message'}
+
+    The "filter" key is required:
+    >>> asyncio.run(controlpi.test(
+    ...     {"Test Log": {"plugin": "Log"}}, []))
+    'filter' is a required property
+    <BLANKLINE>
+    Failed validating 'required' in schema:
+        {'properties': {'filter': {'items': {'type': 'object'},
+                                   'type': 'array'}},
+         'required': ['filter']}
+    <BLANKLINE>
+    On instance:
+        {'plugin': 'Log'}
+    Configuration for 'Test Log' is not valid.
+
+    The "filter" key has to contain a list of message templates, i.e.,
+    JSON objects:
+    >>> asyncio.run(controlpi.test(
+    ...     {"Test Log": {"plugin": "Log",
+    ...                   "filter": [42]}}, []))
+    42 is not of type 'object'
+    <BLANKLINE>
+    Failed validating 'type' in schema['properties']['filter']['items']:
+        {'type': 'object'}
+    <BLANKLINE>
+    On instance['filter'][0]:
+        42
+    Configuration for 'Test Log' is not valid.
+    """
+
+    CONF_SCHEMA = {'properties': {'filter': {'type': 'array',
+                                             'items': {'type': 'object'}}},
+                   'required': ['filter']}
+    """Schema for Log plugin configuration.
+
+    Required configuration key:
+
+    - 'filter': list of message templates to be logged.
+    """
+
+    async def log(self, message: Message) -> None:
+        """Log received message on stdout using own name as prefix."""
+        print(f"{self.name}: {message}")
+
+    def process_conf(self) -> None:
+        """Register plugin as bus client."""
+        self.bus.register(self.name, 'Log', [], self.conf['filter'], self.log)
+
+    async def run(self) -> None:
+        """Run no code proactively."""
+        pass
+
+

Ancestors

+ +

Class variables

+
+
var CONF_SCHEMA
+
+

Schema for Log plugin configuration.

+

Required configuration key:

+
    +
  • 'filter': list of message templates to be logged.
  • +
+
+
+

Methods

+
+
+async def log(self, message: Message) ‑> NoneType +
+
+

Log received message on stdout using own name as prefix.

+
+ +Expand source code + +
async def log(self, message: Message) -> None:
+    """Log received message on stdout using own name as prefix."""
+    print(f"{self.name}: {message}")
+
+
+
+def process_conf(self) ‑> NoneType +
+
+

Register plugin as bus client.

+
+ +Expand source code + +
def process_conf(self) -> None:
+    """Register plugin as bus client."""
+    self.bus.register(self.name, 'Log', [], self.conf['filter'], self.log)
+
+
+
+async def run(self) ‑> NoneType +
+
+

Run no code proactively.

+
+ +Expand source code + +
async def run(self) -> None:
+    """Run no code proactively."""
+    pass
+
+
+
+
+
+class Init +(bus: MessageBus, name: str, conf: Dict[str, Any]) +
+
+

Send list of messages on startup and on demand.

+

The "messages" configuration key gets a list of messages to be sent on +startup. The same list is sent in reaction to a message with +"target": and "command": "execute".

+

In the example, the two configured messages are sent twice, once at +startup and a second time in reaction to the "execute" command sent by +the test:

+
>>> import controlpi
+>>> asyncio.run(controlpi.test(
+...     {"Test Init": {"plugin": "Init",
+...                    "messages": [{"id": 42,
+...                                  "content": "Test Message"},
+...                                 {"id": 42.42,
+...                                  "content": "Second Message"}]}},
+...     [{"target": "Test Init", "command": "execute"}]))
+... # doctest: +NORMALIZE_WHITESPACE
+test(): {'sender': '', 'event': 'registered',
+         'client': 'Test Init', 'plugin': 'Init',
+         'sends': [{'id': {'const': 42},
+                    'content': {'const': 'Test Message'}},
+                   {'id': {'const': 42.42},
+                    'content': {'const': 'Second Message'}}],
+         'receives': [{'target': {'const': 'Test Init'},
+                       'command': {'const': 'execute'}}]}
+test(): {'sender': 'Test Init', 'id': 42, 'content': 'Test Message'}
+test(): {'sender': 'Test Init', 'id': 42.42, 'content': 'Second Message'}
+test(): {'sender': 'test()', 'target': 'Test Init', 'command': 'execute'}
+test(): {'sender': 'Test Init', 'id': 42, 'content': 'Test Message'}
+test(): {'sender': 'Test Init', 'id': 42.42, 'content': 'Second Message'}
+
+

The "messages" key is required:

+
>>> asyncio.run(controlpi.test(
+...     {"Test Init": {"plugin": "Init"}}, []))
+'messages' is a required property
+<BLANKLINE>
+Failed validating 'required' in schema:
+    {'properties': {'messages': {'items': {'type': 'object'},
+                                 'type': 'array'}},
+     'required': ['messages']}
+<BLANKLINE>
+On instance:
+    {'plugin': 'Init'}
+Configuration for 'Test Init' is not valid.
+
+

The "messages" key has to contain a list of (partial) messages, i.e., +JSON objects:

+
>>> asyncio.run(controlpi.test(
+...     {"Test Init": {"plugin": "Init",
+...                    "messages": [42]}}, []))
+42 is not of type 'object'
+<BLANKLINE>
+Failed validating 'type' in schema['properties']['messages']['items']:
+    {'type': 'object'}
+<BLANKLINE>
+On instance['messages'][0]:
+    42
+Configuration for 'Test Init' is not valid.
+
+
+ +Expand source code + +
class Init(BasePlugin):
+    """Send list of messages on startup and on demand.
+
+    The "messages" configuration key gets a list of messages to be sent on
+    startup. The same list is sent in reaction to a message with
+    "target": <name> and "command": "execute".
+
+    In the example, the two configured messages are sent twice, once at
+    startup and a second time in reaction to the "execute" command sent by
+    the test:
+    >>> import controlpi
+    >>> asyncio.run(controlpi.test(
+    ...     {"Test Init": {"plugin": "Init",
+    ...                    "messages": [{"id": 42,
+    ...                                  "content": "Test Message"},
+    ...                                 {"id": 42.42,
+    ...                                  "content": "Second Message"}]}},
+    ...     [{"target": "Test Init", "command": "execute"}]))
+    ... # doctest: +NORMALIZE_WHITESPACE
+    test(): {'sender': '', 'event': 'registered',
+             'client': 'Test Init', 'plugin': 'Init',
+             'sends': [{'id': {'const': 42},
+                        'content': {'const': 'Test Message'}},
+                       {'id': {'const': 42.42},
+                        'content': {'const': 'Second Message'}}],
+             'receives': [{'target': {'const': 'Test Init'},
+                           'command': {'const': 'execute'}}]}
+    test(): {'sender': 'Test Init', 'id': 42, 'content': 'Test Message'}
+    test(): {'sender': 'Test Init', 'id': 42.42, 'content': 'Second Message'}
+    test(): {'sender': 'test()', 'target': 'Test Init', 'command': 'execute'}
+    test(): {'sender': 'Test Init', 'id': 42, 'content': 'Test Message'}
+    test(): {'sender': 'Test Init', 'id': 42.42, 'content': 'Second Message'}
+
+    The "messages" key is required:
+    >>> asyncio.run(controlpi.test(
+    ...     {"Test Init": {"plugin": "Init"}}, []))
+    'messages' is a required property
+    <BLANKLINE>
+    Failed validating 'required' in schema:
+        {'properties': {'messages': {'items': {'type': 'object'},
+                                     'type': 'array'}},
+         'required': ['messages']}
+    <BLANKLINE>
+    On instance:
+        {'plugin': 'Init'}
+    Configuration for 'Test Init' is not valid.
+
+    The "messages" key has to contain a list of (partial) messages, i.e.,
+    JSON objects:
+    >>> asyncio.run(controlpi.test(
+    ...     {"Test Init": {"plugin": "Init",
+    ...                    "messages": [42]}}, []))
+    42 is not of type 'object'
+    <BLANKLINE>
+    Failed validating 'type' in schema['properties']['messages']['items']:
+        {'type': 'object'}
+    <BLANKLINE>
+    On instance['messages'][0]:
+        42
+    Configuration for 'Test Init' is not valid.
+    """
+
+    CONF_SCHEMA = {'properties': {'messages': {'type': 'array',
+                                               'items': {'type': 'object'}}},
+                   'required': ['messages']}
+    """Schema for Init plugin configuration.
+
+    Required configuration key:
+
+    - 'messages': list of messages to be sent.
+    """
+
+    async def execute(self, message: Message) -> None:
+        """Send configured messages."""
+        for message in self.conf['messages']:
+            await self.bus.send(Message(self.name, message))
+            # Give immediate reactions to messages opportunity to happen:
+            await asyncio.sleep(0)
+
+    def process_conf(self) -> None:
+        """Register plugin as bus client."""
+        receives = [MessageTemplate({'target': {'const': self.name},
+                                     'command': {'const': 'execute'}})]
+        sends = [MessageTemplate.from_message(message)
+                 for message in self.conf['messages']]
+        self.bus.register(self.name, 'Init', sends, receives, self.execute)
+
+    async def run(self) -> None:
+        """Send configured messages on startup."""
+        for message in self.conf['messages']:
+            await self.bus.send(Message(self.name, message))
+
+

Ancestors

+ +

Class variables

+
+
var CONF_SCHEMA
+
+

Schema for Init plugin configuration.

+

Required configuration key:

+
    +
  • 'messages': list of messages to be sent.
  • +
+
+
+

Methods

+
+
+async def execute(self, message: Message) ‑> NoneType +
+
+

Send configured messages.

+
+ +Expand source code + +
async def execute(self, message: Message) -> None:
+    """Send configured messages."""
+    for message in self.conf['messages']:
+        await self.bus.send(Message(self.name, message))
+        # Give immediate reactions to messages opportunity to happen:
+        await asyncio.sleep(0)
+
+
+
+def process_conf(self) ‑> NoneType +
+
+

Register plugin as bus client.

+
+ +Expand source code + +
def process_conf(self) -> None:
+    """Register plugin as bus client."""
+    receives = [MessageTemplate({'target': {'const': self.name},
+                                 'command': {'const': 'execute'}})]
+    sends = [MessageTemplate.from_message(message)
+             for message in self.conf['messages']]
+    self.bus.register(self.name, 'Init', sends, receives, self.execute)
+
+
+
+async def run(self) ‑> NoneType +
+
+

Send configured messages on startup.

+
+ +Expand source code + +
async def run(self) -> None:
+    """Send configured messages on startup."""
+    for message in self.conf['messages']:
+        await self.bus.send(Message(self.name, message))
+
+
+
+
+
+class Execute +(bus: MessageBus, name: str, conf: Dict[str, Any]) +
+
+

Send configurable list of messages on demand.

+

An Execute plugin instance receives two kinds of commands. +The "set messages" command has a "messages" key with a list of (partial) +messages, which are sent by the Execute instance in reaction to an +"execute" command.

+

In the example, the first command sent by the test sets two messages, +which are then sent in reaction to the second command sent by the test:

+
>>> import controlpi
+>>> asyncio.run(controlpi.test(
+...     {"Test Execute": {"plugin": "Execute"}},
+...     [{"target": "Test Execute", "command": "set messages",
+...       "messages": [{"id": 42, "content": "Test Message"},
+...                    {"id": 42.42, "content": "Second Message"}]},
+...      {"target": "Test Execute", "command": "execute"}]))
+... # doctest: +NORMALIZE_WHITESPACE
+test(): {'sender': '', 'event': 'registered',
+         'client': 'Test Execute', 'plugin': 'Execute',
+         'sends': [{}],
+         'receives': [{'target': {'const': 'Test Execute'},
+                       'command': {'const': 'set messages'},
+                       'messages': {'type': 'array',
+                                    'items': {'type': 'object'}}},
+                      {'target': {'const': 'Test Execute'},
+                       'command': {'const': 'execute'}}]}
+test(): {'sender': 'test()', 'target': 'Test Execute',
+         'command': 'set messages',
+         'messages': [{'id': 42, 'content': 'Test Message'},
+                      {'id': 42.42, 'content': 'Second Message'}]}
+test(): {'sender': 'test()', 'target': 'Test Execute',
+         'command': 'execute'}
+test(): {'sender': 'Test Execute', 'id': 42,
+         'content': 'Test Message'}
+test(): {'sender': 'Test Execute', 'id': 42.42,
+         'content': 'Second Message'}
+
+
+ +Expand source code + +
class Execute(BasePlugin):
+    """Send configurable list of messages on demand.
+
+    An Execute plugin instance receives two kinds of commands.
+    The "set messages" command has a "messages" key with a list of (partial)
+    messages, which are sent by the Execute instance in reaction to an
+    "execute" command.
+
+    In the example, the first command sent by the test sets two messages,
+    which are then sent in reaction to the second command sent by the test:
+    >>> import controlpi
+    >>> asyncio.run(controlpi.test(
+    ...     {"Test Execute": {"plugin": "Execute"}},
+    ...     [{"target": "Test Execute", "command": "set messages",
+    ...       "messages": [{"id": 42, "content": "Test Message"},
+    ...                    {"id": 42.42, "content": "Second Message"}]},
+    ...      {"target": "Test Execute", "command": "execute"}]))
+    ... # doctest: +NORMALIZE_WHITESPACE
+    test(): {'sender': '', 'event': 'registered',
+             'client': 'Test Execute', 'plugin': 'Execute',
+             'sends': [{}],
+             'receives': [{'target': {'const': 'Test Execute'},
+                           'command': {'const': 'set messages'},
+                           'messages': {'type': 'array',
+                                        'items': {'type': 'object'}}},
+                          {'target': {'const': 'Test Execute'},
+                           'command': {'const': 'execute'}}]}
+    test(): {'sender': 'test()', 'target': 'Test Execute',
+             'command': 'set messages',
+             'messages': [{'id': 42, 'content': 'Test Message'},
+                          {'id': 42.42, 'content': 'Second Message'}]}
+    test(): {'sender': 'test()', 'target': 'Test Execute',
+             'command': 'execute'}
+    test(): {'sender': 'Test Execute', 'id': 42,
+             'content': 'Test Message'}
+    test(): {'sender': 'Test Execute', 'id': 42.42,
+             'content': 'Second Message'}
+    """
+
+    CONF_SCHEMA = True
+    """Schema for Execute plugin configuration.
+
+    There are no required or optional configuration keys.
+    """
+
+    async def execute(self, message: Message) -> None:
+        """Set or send configured messages."""
+        if message['command'] == 'set messages':
+            assert isinstance(message['messages'], list)
+            self.messages = list(message['messages'])
+        elif message['command'] == 'execute':
+            for message in self.messages:
+                await self.bus.send(Message(self.name, message))
+                # Give immediate reactions to messages opportunity to happen:
+                await asyncio.sleep(0)
+
+    def process_conf(self) -> None:
+        """Register plugin as bus client."""
+        self.messages = []
+        receives = [MessageTemplate({'target': {'const': self.name},
+                                     'command': {'const': 'set messages'},
+                                     'messages':
+                                     {'type': 'array',
+                                      'items': {'type': 'object'}}}),
+                    MessageTemplate({'target': {'const': self.name},
+                                     'command': {'const': 'execute'}})]
+        sends = [MessageTemplate()]
+        self.bus.register(self.name, 'Execute', sends, receives, self.execute)
+
+    async def run(self) -> None:
+        """Run no code proactively."""
+        pass
+
+

Ancestors

+ +

Class variables

+
+
var CONF_SCHEMA
+
+

Schema for Execute plugin configuration.

+

There are no required or optional configuration keys.

+
+
+

Methods

+
+
+async def execute(self, message: Message) ‑> NoneType +
+
+

Set or send configured messages.

+
+ +Expand source code + +
async def execute(self, message: Message) -> None:
+    """Set or send configured messages."""
+    if message['command'] == 'set messages':
+        assert isinstance(message['messages'], list)
+        self.messages = list(message['messages'])
+    elif message['command'] == 'execute':
+        for message in self.messages:
+            await self.bus.send(Message(self.name, message))
+            # Give immediate reactions to messages opportunity to happen:
+            await asyncio.sleep(0)
+
+
+
+def process_conf(self) ‑> NoneType +
+
+

Register plugin as bus client.

+
+ +Expand source code + +
def process_conf(self) -> None:
+    """Register plugin as bus client."""
+    self.messages = []
+    receives = [MessageTemplate({'target': {'const': self.name},
+                                 'command': {'const': 'set messages'},
+                                 'messages':
+                                 {'type': 'array',
+                                  'items': {'type': 'object'}}}),
+                MessageTemplate({'target': {'const': self.name},
+                                 'command': {'const': 'execute'}})]
+    sends = [MessageTemplate()]
+    self.bus.register(self.name, 'Execute', sends, receives, self.execute)
+
+
+
+async def run(self) ‑> NoneType +
+
+

Run no code proactively.

+
+ +Expand source code + +
async def run(self) -> None:
+    """Run no code proactively."""
+    pass
+
+
+
+
+
+class Alias +(bus: MessageBus, name: str, conf: Dict[str, Any]) +
+
+

Translate messages to an alias.

+

The "from" configuration key gets a message template and the +configuration key "to" a (partial) message. All messages matching the +template are received by the Alias instance and a message translated by +removing the keys of the "from" template and adding the keys and values +of the "to" message is sent. Keys that are neither in "from" nor in "to" +are retained.

+

In the example, the two messages sent by the test are translated by the +Alias instance and the translated messages are sent by it preserving +the "content" keys:

+
>>> import controlpi
+>>> asyncio.run(controlpi.test(
+...     {"Test Alias": {"plugin": "Alias",
+...                     "from": {"id": {"const": 42}},
+...                     "to": {"id": "translated"}}},
+...     [{"id": 42, "content": "Test Message"},
+...      {"id": 42, "content": "Second Message"}]))
+... # doctest: +NORMALIZE_WHITESPACE
+test(): {'sender': '', 'event': 'registered',
+         'client': 'Test Alias', 'plugin': 'Alias',
+         'sends': [{'id': {'const': 'translated'}}],
+         'receives': [{'id': {'const': 42}}]}
+test(): {'sender': 'test()', 'id': 42,
+         'content': 'Test Message'}
+test(): {'sender': 'Test Alias', 'id': 'translated',
+         'content': 'Test Message'}
+test(): {'sender': 'test()', 'id': 42,
+         'content': 'Second Message'}
+test(): {'sender': 'Test Alias', 'id': 'translated',
+         'content': 'Second Message'}
+
+

The "from" and "to" keys are required:

+
>>> asyncio.run(controlpi.test(
+...     {"Test Alias": {"plugin": "Alias"}}, []))
+'from' is a required property
+<BLANKLINE>
+Failed validating 'required' in schema:
+    {'properties': {'from': {'type': 'object'}, 'to': {'type': 'object'}},
+     'required': ['from', 'to']}
+<BLANKLINE>
+On instance:
+    {'plugin': 'Alias'}
+'to' is a required property
+<BLANKLINE>
+Failed validating 'required' in schema:
+    {'properties': {'from': {'type': 'object'}, 'to': {'type': 'object'}},
+     'required': ['from', 'to']}
+<BLANKLINE>
+On instance:
+    {'plugin': 'Alias'}
+Configuration for 'Test Alias' is not valid.
+
+

The "from" key has to contain a message template and the "to" key a +(partial) message, i.e., both have to be JSON objects:

+
>>> asyncio.run(controlpi.test(
+...     {"Test Alias": {"plugin": "Alias",
+...                     "from": 42,
+...                     "to": 42}}, []))
+42 is not of type 'object'
+<BLANKLINE>
+Failed validating 'type' in schema['properties']['from']:
+    {'type': 'object'}
+<BLANKLINE>
+On instance['from']:
+    42
+42 is not of type 'object'
+<BLANKLINE>
+Failed validating 'type' in schema['properties']['to']:
+    {'type': 'object'}
+<BLANKLINE>
+On instance['to']:
+    42
+Configuration for 'Test Alias' is not valid.
+
+
+ +Expand source code + +
class Alias(BasePlugin):
+    """Translate messages to an alias.
+
+    The "from" configuration key gets a message template and the
+    configuration key "to" a (partial) message. All messages matching the
+    template are received by the Alias instance and a message translated by
+    removing the keys of the "from" template and adding the keys and values
+    of the "to" message is sent. Keys that are neither in "from" nor in "to"
+    are retained.
+
+    In the example, the two messages sent by the test are translated by the
+    Alias instance and the translated messages are sent by it preserving
+    the "content" keys:
+    >>> import controlpi
+    >>> asyncio.run(controlpi.test(
+    ...     {"Test Alias": {"plugin": "Alias",
+    ...                     "from": {"id": {"const": 42}},
+    ...                     "to": {"id": "translated"}}},
+    ...     [{"id": 42, "content": "Test Message"},
+    ...      {"id": 42, "content": "Second Message"}]))
+    ... # doctest: +NORMALIZE_WHITESPACE
+    test(): {'sender': '', 'event': 'registered',
+             'client': 'Test Alias', 'plugin': 'Alias',
+             'sends': [{'id': {'const': 'translated'}}],
+             'receives': [{'id': {'const': 42}}]}
+    test(): {'sender': 'test()', 'id': 42,
+             'content': 'Test Message'}
+    test(): {'sender': 'Test Alias', 'id': 'translated',
+             'content': 'Test Message'}
+    test(): {'sender': 'test()', 'id': 42,
+             'content': 'Second Message'}
+    test(): {'sender': 'Test Alias', 'id': 'translated',
+             'content': 'Second Message'}
+
+    The "from" and "to" keys are required:
+    >>> asyncio.run(controlpi.test(
+    ...     {"Test Alias": {"plugin": "Alias"}}, []))
+    'from' is a required property
+    <BLANKLINE>
+    Failed validating 'required' in schema:
+        {'properties': {'from': {'type': 'object'}, 'to': {'type': 'object'}},
+         'required': ['from', 'to']}
+    <BLANKLINE>
+    On instance:
+        {'plugin': 'Alias'}
+    'to' is a required property
+    <BLANKLINE>
+    Failed validating 'required' in schema:
+        {'properties': {'from': {'type': 'object'}, 'to': {'type': 'object'}},
+         'required': ['from', 'to']}
+    <BLANKLINE>
+    On instance:
+        {'plugin': 'Alias'}
+    Configuration for 'Test Alias' is not valid.
+
+    The "from" key has to contain a message template and the "to" key a
+    (partial) message, i.e., both have to be JSON objects:
+    >>> asyncio.run(controlpi.test(
+    ...     {"Test Alias": {"plugin": "Alias",
+    ...                     "from": 42,
+    ...                     "to": 42}}, []))
+    42 is not of type 'object'
+    <BLANKLINE>
+    Failed validating 'type' in schema['properties']['from']:
+        {'type': 'object'}
+    <BLANKLINE>
+    On instance['from']:
+        42
+    42 is not of type 'object'
+    <BLANKLINE>
+    Failed validating 'type' in schema['properties']['to']:
+        {'type': 'object'}
+    <BLANKLINE>
+    On instance['to']:
+        42
+    Configuration for 'Test Alias' is not valid.
+    """
+
+    CONF_SCHEMA = {'properties': {'from': {'type': 'object'},
+                                  'to': {'type': 'object'}},
+                   'required': ['from', 'to']}
+    """Schema for Alias plugin configuration.
+
+    Required configuration keys:
+
+    - 'from': template of messages to be translated.
+    - 'to': translated message to be sent.
+    """
+
+    async def alias(self, message: Message) -> None:
+        """Translate and send message."""
+        alias_message = Message(self.name)
+        alias_message.update(self.conf['to'])
+        for key in message:
+            if key != 'sender' and key not in self.conf['from']:
+                alias_message[key] = message[key]
+        await self.bus.send(alias_message)
+
+    def process_conf(self) -> None:
+        """Register plugin as bus client."""
+        self.bus.register(self.name, 'Alias',
+                          [MessageTemplate.from_message(self.conf['to'])],
+                          [self.conf['from']],
+                          self.alias)
+
+    async def run(self) -> None:
+        """Run no code proactively."""
+        pass
+
+

Ancestors

+ +

Class variables

+
+
var CONF_SCHEMA
+
+

Schema for Alias plugin configuration.

+

Required configuration keys:

+
    +
  • 'from': template of messages to be translated.
  • +
  • 'to': translated message to be sent.
  • +
+
+
+

Methods

+
+
+async def alias(self, message: Message) ‑> NoneType +
+
+

Translate and send message.

+
+ +Expand source code + +
async def alias(self, message: Message) -> None:
+    """Translate and send message."""
+    alias_message = Message(self.name)
+    alias_message.update(self.conf['to'])
+    for key in message:
+        if key != 'sender' and key not in self.conf['from']:
+            alias_message[key] = message[key]
+    await self.bus.send(alias_message)
+
+
+
+def process_conf(self) ‑> NoneType +
+
+

Register plugin as bus client.

+
+ +Expand source code + +
def process_conf(self) -> None:
+    """Register plugin as bus client."""
+    self.bus.register(self.name, 'Alias',
+                      [MessageTemplate.from_message(self.conf['to'])],
+                      [self.conf['from']],
+                      self.alias)
+
+
+
+async def run(self) ‑> NoneType +
+
+

Run no code proactively.

+
+ +Expand source code + +
async def run(self) -> None:
+    """Run no code proactively."""
+    pass
+
+
+
+
+
+
+
+ +
+ + + \ No newline at end of file diff --git a/doc/controlpi_plugins/wait.html b/doc/controlpi_plugins/wait.html new file mode 100644 index 0000000..a5230a5 --- /dev/null +++ b/doc/controlpi_plugins/wait.html @@ -0,0 +1,604 @@ + + + + + + +controlpi_plugins.wait API documentation + + + + + + + + + + + +
+
+
+

Module controlpi_plugins.wait

+
+
+

Provide waiting/sleeping plugins for all kinds of systems.

+
    +
  • Wait waits for time defined in configuration and sends "finished" event.
  • +
  • GenericWait waits for time defined in "wait" command and sends "finished" +event with "id" string defined in "wait" command.
  • +
+
>>> import controlpi
+>>> asyncio.run(controlpi.test(
+...     {"Test Wait": {"plugin": "Wait", "seconds": 0.01},
+...      "Test GenericWait": {"plugin": "GenericWait"}},
+...     [{"target": "Test GenericWait", "command": "wait",
+...       "seconds": 0.02, "id": "Long Wait"},
+...      {"target": "Test Wait", "command": "wait"}], 0.025))
+... # doctest: +NORMALIZE_WHITESPACE
+test(): {'sender': '', 'event': 'registered',
+         'client': 'Test Wait', 'plugin': 'Wait',
+         'sends': [{'event': {'const': 'finished'}}],
+         'receives': [{'target': {'const': 'Test Wait'},
+                       'command': {'const': 'wait'}}]}
+test(): {'sender': '', 'event': 'registered',
+         'client': 'Test GenericWait', 'plugin': 'GenericWait',
+         'sends': [{'event': {'const': 'finished'},
+                    'id': {'type': 'string'}}],
+         'receives': [{'target': {'const': 'Test GenericWait'},
+                       'command': {'const': 'wait'},
+                       'seconds': {'type': 'number'},
+                       'id': {'type': 'string'}}]}
+test(): {'sender': 'test()', 'target': 'Test GenericWait',
+         'command': 'wait', 'seconds': 0.02, 'id': 'Long Wait'}
+test(): {'sender': 'test()', 'target': 'Test Wait', 'command': 'wait'}
+test(): {'sender': 'Test Wait', 'event': 'finished'}
+test(): {'sender': 'Test GenericWait', 'event': 'finished',
+         'id': 'Long Wait'}
+
+
+ +Expand source code + +
"""Provide waiting/sleeping plugins for all kinds of systems.
+
+- Wait waits for time defined in configuration and sends "finished" event.
+- GenericWait waits for time defined in "wait" command and sends "finished"
+  event with "id" string defined in "wait" command.
+
+>>> import controlpi
+>>> asyncio.run(controlpi.test(
+...     {"Test Wait": {"plugin": "Wait", "seconds": 0.01},
+...      "Test GenericWait": {"plugin": "GenericWait"}},
+...     [{"target": "Test GenericWait", "command": "wait",
+...       "seconds": 0.02, "id": "Long Wait"},
+...      {"target": "Test Wait", "command": "wait"}], 0.025))
+... # doctest: +NORMALIZE_WHITESPACE
+test(): {'sender': '', 'event': 'registered',
+         'client': 'Test Wait', 'plugin': 'Wait',
+         'sends': [{'event': {'const': 'finished'}}],
+         'receives': [{'target': {'const': 'Test Wait'},
+                       'command': {'const': 'wait'}}]}
+test(): {'sender': '', 'event': 'registered',
+         'client': 'Test GenericWait', 'plugin': 'GenericWait',
+         'sends': [{'event': {'const': 'finished'},
+                    'id': {'type': 'string'}}],
+         'receives': [{'target': {'const': 'Test GenericWait'},
+                       'command': {'const': 'wait'},
+                       'seconds': {'type': 'number'},
+                       'id': {'type': 'string'}}]}
+test(): {'sender': 'test()', 'target': 'Test GenericWait',
+         'command': 'wait', 'seconds': 0.02, 'id': 'Long Wait'}
+test(): {'sender': 'test()', 'target': 'Test Wait', 'command': 'wait'}
+test(): {'sender': 'Test Wait', 'event': 'finished'}
+test(): {'sender': 'Test GenericWait', 'event': 'finished',
+         'id': 'Long Wait'}
+"""
+import asyncio
+
+from controlpi import BasePlugin, Message, MessageTemplate
+
+
+class Wait(BasePlugin):
+    """Wait for time defined in configuration.
+
+    The "seconds" configuration key gets the number of seconds to wait after
+    receiving a "wait" command before sending the "finished" event:
+    >>> import controlpi
+    >>> asyncio.run(controlpi.test(
+    ...     {"Long Wait": {"plugin": "Wait", "seconds": 0.02},
+    ...      "Short Wait": {"plugin": "Wait", "seconds": 0.01}},
+    ...     [{"target": "Long Wait", "command": "wait"},
+    ...      {"target": "Short Wait", "command": "wait"}], 0.025))
+    ... # doctest: +NORMALIZE_WHITESPACE
+    test(): {'sender': '', 'event': 'registered',
+             'client': 'Long Wait', 'plugin': 'Wait',
+             'sends': [{'event': {'const': 'finished'}}],
+             'receives': [{'target': {'const': 'Long Wait'},
+                           'command': {'const': 'wait'}}]}
+    test(): {'sender': '', 'event': 'registered',
+             'client': 'Short Wait', 'plugin': 'Wait',
+             'sends': [{'event': {'const': 'finished'}}],
+             'receives': [{'target': {'const': 'Short Wait'},
+                           'command': {'const': 'wait'}}]}
+    test(): {'sender': 'test()', 'target': 'Long Wait', 'command': 'wait'}
+    test(): {'sender': 'test()', 'target': 'Short Wait', 'command': 'wait'}
+    test(): {'sender': 'Short Wait', 'event': 'finished'}
+    test(): {'sender': 'Long Wait', 'event': 'finished'}
+    """
+
+    CONF_SCHEMA = {'properties': {'seconds': {'type': 'number'}},
+                   'required': ['seconds']}
+    """Schema for Wait plugin configuration.
+
+    Required configuration key:
+
+    - 'seconds': number of seconds to wait.
+    """
+
+    async def wait(self, message: Message) -> None:
+        """Wait configured time and send "finished" event."""
+        async def wait_coroutine():
+            await asyncio.sleep(self.conf['seconds'])
+            await self.bus.send(Message(self.name, {'event': 'finished'}))
+        # Done in separate task to not block queue awaiting this callback:
+        asyncio.create_task(wait_coroutine())
+
+    def process_conf(self) -> None:
+        """Register plugin as bus client."""
+        receives = [MessageTemplate({'target': {'const': self.name},
+                                     'command': {'const': 'wait'}})]
+        sends = [MessageTemplate({'event': {'const': 'finished'}})]
+        self.bus.register(self.name, 'Wait', sends, receives, self.wait)
+
+    async def run(self) -> None:
+        """Run no code proactively."""
+        pass
+
+
+class GenericWait(BasePlugin):
+    """Wait for time defined in "wait" command.
+
+    The "wait" command has message keys "seconds" defining the seconds to
+    wait and "id" defining a string to be sent back in the "finished" event
+    after the wait:
+    >>> import controlpi
+    >>> asyncio.run(controlpi.test(
+    ...     {"Test GenericWait": {"plugin": "GenericWait"}},
+    ...     [{"target": "Test GenericWait", "command": "wait",
+    ...       "seconds": 0.02, "id": "Long Wait"},
+    ...      {"target": "Test GenericWait", "command": "wait",
+    ...       "seconds": 0.01, "id": "Short Wait"}], 0.025))
+    ... # doctest: +NORMALIZE_WHITESPACE
+    test(): {'sender': '', 'event': 'registered',
+             'client': 'Test GenericWait', 'plugin': 'GenericWait',
+             'sends': [{'event': {'const': 'finished'},
+                        'id': {'type': 'string'}}],
+             'receives': [{'target': {'const': 'Test GenericWait'},
+                           'command': {'const': 'wait'},
+                           'seconds': {'type': 'number'},
+                           'id': {'type': 'string'}}]}
+    test(): {'sender': 'test()', 'target': 'Test GenericWait',
+             'command': 'wait', 'seconds': 0.02, 'id': 'Long Wait'}
+    test(): {'sender': 'test()', 'target': 'Test GenericWait',
+             'command': 'wait', 'seconds': 0.01, 'id': 'Short Wait'}
+    test(): {'sender': 'Test GenericWait', 'event': 'finished',
+             'id': 'Short Wait'}
+    test(): {'sender': 'Test GenericWait', 'event': 'finished',
+             'id': 'Long Wait'}
+    """
+
+    CONF_SCHEMA = True
+    """Schema for GenericWait plugin configuration.
+
+    There are no required or optional configuration keys.
+    """
+
+    async def wait(self, message: Message) -> None:
+        """Wait given time and send "finished" event with given "id"."""
+        async def wait_coroutine():
+            assert isinstance(message['seconds'], float)
+            await asyncio.sleep(message['seconds'])
+            await self.bus.send(Message(self.name, {'event': 'finished',
+                                                    'id': message['id']}))
+        # Done in separate task to not block queue awaiting this callback:
+        asyncio.create_task(wait_coroutine())
+
+    def process_conf(self) -> None:
+        """Register plugin as bus client."""
+        receives = [MessageTemplate({'target': {'const': self.name},
+                                     'command': {'const': 'wait'},
+                                     'seconds': {'type': 'number'},
+                                     'id': {'type': 'string'}})]
+        sends = [MessageTemplate({'event': {'const': 'finished'},
+                                  'id': {'type': 'string'}})]
+        self.bus.register(self.name, 'GenericWait', sends, receives, self.wait)
+
+    async def run(self) -> None:
+        """Run no code proactively."""
+        pass
+
+
+
+
+
+
+
+
+
+

Classes

+
+
+class Wait +(bus: MessageBus, name: str, conf: Dict[str, Any]) +
+
+

Wait for time defined in configuration.

+

The "seconds" configuration key gets the number of seconds to wait after +receiving a "wait" command before sending the "finished" event:

+
>>> import controlpi
+>>> asyncio.run(controlpi.test(
+...     {"Long Wait": {"plugin": "Wait", "seconds": 0.02},
+...      "Short Wait": {"plugin": "Wait", "seconds": 0.01}},
+...     [{"target": "Long Wait", "command": "wait"},
+...      {"target": "Short Wait", "command": "wait"}], 0.025))
+... # doctest: +NORMALIZE_WHITESPACE
+test(): {'sender': '', 'event': 'registered',
+         'client': 'Long Wait', 'plugin': 'Wait',
+         'sends': [{'event': {'const': 'finished'}}],
+         'receives': [{'target': {'const': 'Long Wait'},
+                       'command': {'const': 'wait'}}]}
+test(): {'sender': '', 'event': 'registered',
+         'client': 'Short Wait', 'plugin': 'Wait',
+         'sends': [{'event': {'const': 'finished'}}],
+         'receives': [{'target': {'const': 'Short Wait'},
+                       'command': {'const': 'wait'}}]}
+test(): {'sender': 'test()', 'target': 'Long Wait', 'command': 'wait'}
+test(): {'sender': 'test()', 'target': 'Short Wait', 'command': 'wait'}
+test(): {'sender': 'Short Wait', 'event': 'finished'}
+test(): {'sender': 'Long Wait', 'event': 'finished'}
+
+
+ +Expand source code + +
class Wait(BasePlugin):
+    """Wait for time defined in configuration.
+
+    The "seconds" configuration key gets the number of seconds to wait after
+    receiving a "wait" command before sending the "finished" event:
+    >>> import controlpi
+    >>> asyncio.run(controlpi.test(
+    ...     {"Long Wait": {"plugin": "Wait", "seconds": 0.02},
+    ...      "Short Wait": {"plugin": "Wait", "seconds": 0.01}},
+    ...     [{"target": "Long Wait", "command": "wait"},
+    ...      {"target": "Short Wait", "command": "wait"}], 0.025))
+    ... # doctest: +NORMALIZE_WHITESPACE
+    test(): {'sender': '', 'event': 'registered',
+             'client': 'Long Wait', 'plugin': 'Wait',
+             'sends': [{'event': {'const': 'finished'}}],
+             'receives': [{'target': {'const': 'Long Wait'},
+                           'command': {'const': 'wait'}}]}
+    test(): {'sender': '', 'event': 'registered',
+             'client': 'Short Wait', 'plugin': 'Wait',
+             'sends': [{'event': {'const': 'finished'}}],
+             'receives': [{'target': {'const': 'Short Wait'},
+                           'command': {'const': 'wait'}}]}
+    test(): {'sender': 'test()', 'target': 'Long Wait', 'command': 'wait'}
+    test(): {'sender': 'test()', 'target': 'Short Wait', 'command': 'wait'}
+    test(): {'sender': 'Short Wait', 'event': 'finished'}
+    test(): {'sender': 'Long Wait', 'event': 'finished'}
+    """
+
+    CONF_SCHEMA = {'properties': {'seconds': {'type': 'number'}},
+                   'required': ['seconds']}
+    """Schema for Wait plugin configuration.
+
+    Required configuration key:
+
+    - 'seconds': number of seconds to wait.
+    """
+
+    async def wait(self, message: Message) -> None:
+        """Wait configured time and send "finished" event."""
+        async def wait_coroutine():
+            await asyncio.sleep(self.conf['seconds'])
+            await self.bus.send(Message(self.name, {'event': 'finished'}))
+        # Done in separate task to not block queue awaiting this callback:
+        asyncio.create_task(wait_coroutine())
+
+    def process_conf(self) -> None:
+        """Register plugin as bus client."""
+        receives = [MessageTemplate({'target': {'const': self.name},
+                                     'command': {'const': 'wait'}})]
+        sends = [MessageTemplate({'event': {'const': 'finished'}})]
+        self.bus.register(self.name, 'Wait', sends, receives, self.wait)
+
+    async def run(self) -> None:
+        """Run no code proactively."""
+        pass
+
+

Ancestors

+ +

Class variables

+
+
var CONF_SCHEMA
+
+

Schema for Wait plugin configuration.

+

Required configuration key:

+
    +
  • 'seconds': number of seconds to wait.
  • +
+
+
+

Methods

+
+
+async def wait(self, message: Message) ‑> NoneType +
+
+

Wait configured time and send "finished" event.

+
+ +Expand source code + +
async def wait(self, message: Message) -> None:
+    """Wait configured time and send "finished" event."""
+    async def wait_coroutine():
+        await asyncio.sleep(self.conf['seconds'])
+        await self.bus.send(Message(self.name, {'event': 'finished'}))
+    # Done in separate task to not block queue awaiting this callback:
+    asyncio.create_task(wait_coroutine())
+
+
+
+def process_conf(self) ‑> NoneType +
+
+

Register plugin as bus client.

+
+ +Expand source code + +
def process_conf(self) -> None:
+    """Register plugin as bus client."""
+    receives = [MessageTemplate({'target': {'const': self.name},
+                                 'command': {'const': 'wait'}})]
+    sends = [MessageTemplate({'event': {'const': 'finished'}})]
+    self.bus.register(self.name, 'Wait', sends, receives, self.wait)
+
+
+
+async def run(self) ‑> NoneType +
+
+

Run no code proactively.

+
+ +Expand source code + +
async def run(self) -> None:
+    """Run no code proactively."""
+    pass
+
+
+
+
+
+class GenericWait +(bus: MessageBus, name: str, conf: Dict[str, Any]) +
+
+

Wait for time defined in "wait" command.

+

The "wait" command has message keys "seconds" defining the seconds to +wait and "id" defining a string to be sent back in the "finished" event +after the wait:

+
>>> import controlpi
+>>> asyncio.run(controlpi.test(
+...     {"Test GenericWait": {"plugin": "GenericWait"}},
+...     [{"target": "Test GenericWait", "command": "wait",
+...       "seconds": 0.02, "id": "Long Wait"},
+...      {"target": "Test GenericWait", "command": "wait",
+...       "seconds": 0.01, "id": "Short Wait"}], 0.025))
+... # doctest: +NORMALIZE_WHITESPACE
+test(): {'sender': '', 'event': 'registered',
+         'client': 'Test GenericWait', 'plugin': 'GenericWait',
+         'sends': [{'event': {'const': 'finished'},
+                    'id': {'type': 'string'}}],
+         'receives': [{'target': {'const': 'Test GenericWait'},
+                       'command': {'const': 'wait'},
+                       'seconds': {'type': 'number'},
+                       'id': {'type': 'string'}}]}
+test(): {'sender': 'test()', 'target': 'Test GenericWait',
+         'command': 'wait', 'seconds': 0.02, 'id': 'Long Wait'}
+test(): {'sender': 'test()', 'target': 'Test GenericWait',
+         'command': 'wait', 'seconds': 0.01, 'id': 'Short Wait'}
+test(): {'sender': 'Test GenericWait', 'event': 'finished',
+         'id': 'Short Wait'}
+test(): {'sender': 'Test GenericWait', 'event': 'finished',
+         'id': 'Long Wait'}
+
+
+ +Expand source code + +
class GenericWait(BasePlugin):
+    """Wait for time defined in "wait" command.
+
+    The "wait" command has message keys "seconds" defining the seconds to
+    wait and "id" defining a string to be sent back in the "finished" event
+    after the wait:
+    >>> import controlpi
+    >>> asyncio.run(controlpi.test(
+    ...     {"Test GenericWait": {"plugin": "GenericWait"}},
+    ...     [{"target": "Test GenericWait", "command": "wait",
+    ...       "seconds": 0.02, "id": "Long Wait"},
+    ...      {"target": "Test GenericWait", "command": "wait",
+    ...       "seconds": 0.01, "id": "Short Wait"}], 0.025))
+    ... # doctest: +NORMALIZE_WHITESPACE
+    test(): {'sender': '', 'event': 'registered',
+             'client': 'Test GenericWait', 'plugin': 'GenericWait',
+             'sends': [{'event': {'const': 'finished'},
+                        'id': {'type': 'string'}}],
+             'receives': [{'target': {'const': 'Test GenericWait'},
+                           'command': {'const': 'wait'},
+                           'seconds': {'type': 'number'},
+                           'id': {'type': 'string'}}]}
+    test(): {'sender': 'test()', 'target': 'Test GenericWait',
+             'command': 'wait', 'seconds': 0.02, 'id': 'Long Wait'}
+    test(): {'sender': 'test()', 'target': 'Test GenericWait',
+             'command': 'wait', 'seconds': 0.01, 'id': 'Short Wait'}
+    test(): {'sender': 'Test GenericWait', 'event': 'finished',
+             'id': 'Short Wait'}
+    test(): {'sender': 'Test GenericWait', 'event': 'finished',
+             'id': 'Long Wait'}
+    """
+
+    CONF_SCHEMA = True
+    """Schema for GenericWait plugin configuration.
+
+    There are no required or optional configuration keys.
+    """
+
+    async def wait(self, message: Message) -> None:
+        """Wait given time and send "finished" event with given "id"."""
+        async def wait_coroutine():
+            assert isinstance(message['seconds'], float)
+            await asyncio.sleep(message['seconds'])
+            await self.bus.send(Message(self.name, {'event': 'finished',
+                                                    'id': message['id']}))
+        # Done in separate task to not block queue awaiting this callback:
+        asyncio.create_task(wait_coroutine())
+
+    def process_conf(self) -> None:
+        """Register plugin as bus client."""
+        receives = [MessageTemplate({'target': {'const': self.name},
+                                     'command': {'const': 'wait'},
+                                     'seconds': {'type': 'number'},
+                                     'id': {'type': 'string'}})]
+        sends = [MessageTemplate({'event': {'const': 'finished'},
+                                  'id': {'type': 'string'}})]
+        self.bus.register(self.name, 'GenericWait', sends, receives, self.wait)
+
+    async def run(self) -> None:
+        """Run no code proactively."""
+        pass
+
+

Ancestors

+ +

Class variables

+
+
var CONF_SCHEMA
+
+

Schema for GenericWait plugin configuration.

+

There are no required or optional configuration keys.

+
+
+

Methods

+
+
+async def wait(self, message: Message) ‑> NoneType +
+
+

Wait given time and send "finished" event with given "id".

+
+ +Expand source code + +
async def wait(self, message: Message) -> None:
+    """Wait given time and send "finished" event with given "id"."""
+    async def wait_coroutine():
+        assert isinstance(message['seconds'], float)
+        await asyncio.sleep(message['seconds'])
+        await self.bus.send(Message(self.name, {'event': 'finished',
+                                                'id': message['id']}))
+    # Done in separate task to not block queue awaiting this callback:
+    asyncio.create_task(wait_coroutine())
+
+
+
+def process_conf(self) ‑> NoneType +
+
+

Register plugin as bus client.

+
+ +Expand source code + +
def process_conf(self) -> None:
+    """Register plugin as bus client."""
+    receives = [MessageTemplate({'target': {'const': self.name},
+                                 'command': {'const': 'wait'},
+                                 'seconds': {'type': 'number'},
+                                 'id': {'type': 'string'}})]
+    sends = [MessageTemplate({'event': {'const': 'finished'},
+                              'id': {'type': 'string'}})]
+    self.bus.register(self.name, 'GenericWait', sends, receives, self.wait)
+
+
+
+async def run(self) ‑> NoneType +
+
+

Run no code proactively.

+
+ +Expand source code + +
async def run(self) -> None:
+    """Run no code proactively."""
+    pass
+
+
+
+
+
+
+
+ +
+ + + \ No newline at end of file