From 3ba56a809679fb0b50b0cdb0192a5246ed16e6bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krist=C3=B3f=20T=C3=B3th?= Date: Thu, 28 Jun 2018 17:31:55 +0200 Subject: [PATCH 01/27] Implement batch callback subscription in CallbackMixin --- lib/tfw/mixins/callback_mixin.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/lib/tfw/mixins/callback_mixin.py b/lib/tfw/mixins/callback_mixin.py index 3c94e71..54515f3 100644 --- a/lib/tfw/mixins/callback_mixin.py +++ b/lib/tfw/mixins/callback_mixin.py @@ -21,6 +21,14 @@ class CallbackMixin: fun = partial(callback, *args, **kwargs) self._callbacks.append(fun) + def subscribe_callbacks(self, *callbacks): + """ + Subscribe a list of callbacks to incoke once an event is triggered. + :param callbacks: callbacks to be subscribed + """ + for callback in callbacks: + self.subscribe_callback(callback) + def unsubscribe_callback(self, callback): self._callbacks.remove(callback) From f8233d51a9f8f0d9562daf306a018f5ac38c2b4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krist=C3=B3f=20T=C3=B3th?= Date: Thu, 28 Jun 2018 17:33:20 +0200 Subject: [PATCH 02/27] =?UTF-8?q?Completely=20rework=20TFWServer=20network?= =?UTF-8?q?ing=20=E2=9C=A8=F0=9F=8D=B0=E2=9C=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/tfw/networking/serialization.py | 3 +- lib/tfw/networking/server/tfw_server.py | 13 +- .../server/zmq_websocket_handler.py | 123 ++++++++++-------- 3 files changed, 76 insertions(+), 63 deletions(-) diff --git a/lib/tfw/networking/serialization.py b/lib/tfw/networking/serialization.py index 6a6a3e7..c21fc47 100644 --- a/lib/tfw/networking/serialization.py +++ b/lib/tfw/networking/serialization.py @@ -25,7 +25,8 @@ import json def validate_message(message): - return 'key' in message + if 'key' not in message: + raise ValueError('Invalid TFW message format!') def serialize_tfw_msg(message): diff --git a/lib/tfw/networking/server/tfw_server.py b/lib/tfw/networking/server/tfw_server.py index 78cc8ee..46c9006 100644 --- a/lib/tfw/networking/server/tfw_server.py +++ b/lib/tfw/networking/server/tfw_server.py @@ -32,10 +32,8 @@ class TFWServer: self.application = Application([( r'/ws', ZMQWebSocketProxy,{ - 'make_eventhandler_message': self.make_eventhandler_message, - 'proxy_filter': self.proxy_filter, - 'handle_trigger': self.handle_trigger, - 'event_handler_connector': self._event_handler_connector + 'event_handler_connector': self._event_handler_connector, + 'message_handlers': [self.append_fsm_data, self.handle_trigger] })] ) # self.controller_responder = ControllerResponder(self.fsm) @@ -49,8 +47,7 @@ class TFWServer: def fsm_manager(self): return self._fsm_manager - def make_eventhandler_message(self, message): - self.trigger_fsm(message) + def append_fsm_data(self, message): message['FSMUpdate'] = self._fsm_updater.get_fsm_state_and_transitions() return message @@ -65,10 +62,6 @@ class TFWServer: except AttributeError: LOG.debug('FSM failed to execute nonexistent trigger: "%s"', trigger) - def proxy_filter(self, message): - # pylint: disable=unused-argument,no-self-use - return True - def listen(self, port): self.application.listen(port) diff --git a/lib/tfw/networking/server/zmq_websocket_handler.py b/lib/tfw/networking/server/zmq_websocket_handler.py index c842205..7eafe53 100644 --- a/lib/tfw/networking/server/zmq_websocket_handler.py +++ b/lib/tfw/networking/server/zmq_websocket_handler.py @@ -2,90 +2,109 @@ # All Rights Reserved. See LICENSE file for details. import json -from abc import ABC, abstractmethod from tornado.websocket import WebSocketHandler from tfw.networking import deserialize_tfw_msg, validate_message +from tfw.mixins import CallbackMixin from tfw.config.logs import logging LOG = logging.getLogger(__name__) -class ZMQWebSocketHandler(WebSocketHandler, ABC): +class TFWProxy: + def __init__(self, to_source, to_destination): + self.to_source = to_source + self.to_destination = to_destination + + self.proxy_filters = CallbackMixin() + self.proxy_callbacks = CallbackMixin() + + self.proxy_filters.subscribe_callback(validate_message) + + self.keyhandlers = { + 'mirror': self.mirror + } + + def __call__(self, message): + try: + self.proxy_filters._execute_callbacks(message) + except ValueError: + LOG.exception('Invalid TFW message received!') + return + + self.proxy_callbacks._execute_callbacks(message) + + if message['key'] not in self.keyhandlers: + self.to_destination(message) + else: + try: + self.keyhandlers[message['key']](message) + except KeyError: + LOG.error('Invalid mirror message format! Ignoring.') + + def mirror(self, message): + message = message['data'] + LOG.debug('Mirroring message: %s', message) + self.to_source(message) + + +class ZMQWebSocketProxy(WebSocketHandler): instances = set() def initialize(self, **kwargs): # pylint: disable=arguments-differ self._event_handler_connector = kwargs['event_handler_connector'] + self._message_handlers = kwargs.get('message_handlers', []) + self._proxy_filters = kwargs.get('proxy_filters', []) + + self.proxy_eventhandler_to_websocket = TFWProxy( + self.send_eventhandler_message, + self.send_websocket_message + ) + self.proxy_websocket_to_eventhandler = TFWProxy( + self.send_websocket_message, + self.send_eventhandler_message + ) + + proxies = (self.proxy_eventhandler_to_websocket, self.proxy_websocket_to_eventhandler) + for proxy in proxies: + proxy.proxy_filters.subscribe_callbacks(*self._proxy_filters) + proxy.proxy_callbacks.subscribe_callbacks(*self._message_handlers) def prepare(self): - ZMQWebSocketHandler.instances.add(self) + ZMQWebSocketProxy.instances.add(self) def on_close(self): - ZMQWebSocketHandler.instances.remove(self) + ZMQWebSocketProxy.instances.remove(self) def open(self, *args, **kwargs): LOG.debug('WebSocket connection initiated') self._event_handler_connector.register_callback(self.zmq_callback) def zmq_callback(self, msg_parts): - keyhandlers = {'mirror': self.mirror} - + """ + Invoked on ZMQ message. + """ message = deserialize_tfw_msg(*msg_parts) LOG.debug('Received on pull socket: %s', message) - if not validate_message(message): - return - - self.handle_trigger(message) - if message['key'] not in keyhandlers: - for instance in ZMQWebSocketHandler.instances: - instance.write_message(message) - else: - try: - keyhandlers[message['key']](message) - except KeyError: - LOG.error('Invalid mirror message format! Ignoring.') - - def mirror(self, message): - message = message['data'] - self._event_handler_connector.send_message(message) + self.proxy_eventhandler_to_websocket(message) def on_message(self, message): + """ + Invoked on WS message. + """ + message = json.loads(message) LOG.debug('Received on WebSocket: %s', message) - if validate_message(message): - self.send_message(self.make_eventhandler_message(message)) + self.proxy_websocket_to_eventhandler(message) - @abstractmethod - def make_eventhandler_message(self, message): - raise NotImplementedError - - def send_message(self, message: dict): + def send_eventhandler_message(self, message): self._event_handler_connector.send_message(message) - @abstractmethod - def handle_trigger(self, message): - raise NotImplementedError + @staticmethod + def send_websocket_message(message): + for instance in ZMQWebSocketProxy.instances: + instance.write_message(message) # much secure, very cors, wow def check_origin(self, origin): return True - - -class ZMQWebSocketProxy(ZMQWebSocketHandler): - # pylint: disable=abstract-method - def initialize(self, **kwargs): # pylint: disable=arguments-differ - super(ZMQWebSocketProxy, self).initialize(**kwargs) - self._make_eventhandler_message = kwargs['make_eventhandler_message'] - self._proxy_filter = kwargs['proxy_filter'] - self._handle_trigger = kwargs['handle_trigger'] - - def on_message(self, message): - message = json.loads(message) - if self._proxy_filter(message): - super().on_message(message) - - def make_eventhandler_message(self, message): - return self._make_eventhandler_message(message) - - def handle_trigger(self, message): - self._handle_trigger(message) From 1b65bd4d3d3d22046fc344401c01055724c3cf20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krist=C3=B3f=20T=C3=B3th?= Date: Fri, 29 Jun 2018 10:54:08 +0200 Subject: [PATCH 03/27] Implement message broadcasting --- lib/tfw/networking/server/zmq_websocket_handler.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/lib/tfw/networking/server/zmq_websocket_handler.py b/lib/tfw/networking/server/zmq_websocket_handler.py index 7eafe53..9456ca4 100644 --- a/lib/tfw/networking/server/zmq_websocket_handler.py +++ b/lib/tfw/networking/server/zmq_websocket_handler.py @@ -23,7 +23,8 @@ class TFWProxy: self.proxy_filters.subscribe_callback(validate_message) self.keyhandlers = { - 'mirror': self.mirror + 'mirror': self.mirror, + 'broadcast': self.broadcast } def __call__(self, message): @@ -38,16 +39,23 @@ class TFWProxy: if message['key'] not in self.keyhandlers: self.to_destination(message) else: + handler = self.keyhandlers[message['key']] try: - self.keyhandlers[message['key']](message) + handler(message) except KeyError: - LOG.error('Invalid mirror message format! Ignoring.') + LOG.error('Invalid "%s" message format! Ignoring.', handler.__name__) def mirror(self, message): message = message['data'] LOG.debug('Mirroring message: %s', message) self.to_source(message) + def broadcast(self, message): + message = message['data'] + LOG.debug('Broadcasting message: %s', message) + self.to_source(message) + self.to_destination(message) + class ZMQWebSocketProxy(WebSocketHandler): instances = set() From 934f8ec74c73ef30b7f854b370b84a08dd5c6f03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krist=C3=B3f=20T=C3=B3th?= Date: Fri, 29 Jun 2018 11:50:36 +0200 Subject: [PATCH 04/27] Hide ZMQ serialization magic from ServerConnector clients --- lib/tfw/event_handler_base.py | 4 +--- lib/tfw/networking/__init__.py | 2 +- lib/tfw/networking/event_handlers/server_connector.py | 10 ++++++++-- lib/tfw/networking/serialization.py | 9 +++++++++ 4 files changed, 19 insertions(+), 6 deletions(-) diff --git a/lib/tfw/event_handler_base.py b/lib/tfw/event_handler_base.py index cca44b1..cfce6f7 100644 --- a/lib/tfw/event_handler_base.py +++ b/lib/tfw/event_handler_base.py @@ -3,7 +3,6 @@ from abc import ABC, abstractmethod -from tfw.networking import deserialize_tfw_msg from tfw.networking.event_handlers import ServerConnector @@ -20,14 +19,13 @@ class EventHandlerBase(ABC): self.subscribe(self.key, 'reset') self.server_connector.register_callback(self.event_handler_callback) - def event_handler_callback(self, msg_parts): + def event_handler_callback(self, message): """ Callback that is invoked when receiving a message. Dispatches messages to handler methods and sends a response back in case the handler returned something. This is subscribed in __init__(). """ - message = deserialize_tfw_msg(*msg_parts) response = self.dispatch_handling(message) if response: response['key'] = message['key'] diff --git a/lib/tfw/networking/__init__.py b/lib/tfw/networking/__init__.py index 6dd15f9..0958f0b 100644 --- a/lib/tfw/networking/__init__.py +++ b/lib/tfw/networking/__init__.py @@ -1,7 +1,7 @@ # Copyright (C) 2018 Avatao.com Innovative Learning Kft. # All Rights Reserved. See LICENSE file for details. -from .serialization import serialize_tfw_msg, deserialize_tfw_msg, validate_message +from .serialization import serialize_tfw_msg, deserialize_tfw_msg, validate_message, with_deserialize_tfw_msg from .zmq_connector_base import ZMQConnectorBase # from .controller_connector import ControllerConnector # TODO: readd once controller stuff is resolved from .message_sender import MessageSender diff --git a/lib/tfw/networking/event_handlers/server_connector.py b/lib/tfw/networking/event_handlers/server_connector.py index 6685ead..678bb3b 100644 --- a/lib/tfw/networking/event_handlers/server_connector.py +++ b/lib/tfw/networking/event_handlers/server_connector.py @@ -6,9 +6,12 @@ from functools import partial import zmq from zmq.eventloop.zmqstream import ZMQStream -from tfw.networking import serialize_tfw_msg +from tfw.networking import serialize_tfw_msg, with_deserialize_tfw_msg from tfw.networking import ZMQConnectorBase from tfw.config import TFWENV +from tfw.config.logs import logging + +LOG = logging.getLogger(__name__) class ServerDownlinkConnector(ZMQConnectorBase): @@ -20,7 +23,10 @@ class ServerDownlinkConnector(ZMQConnectorBase): self.subscribe = partial(self._zmq_sub_socket.setsockopt_string, zmq.SUBSCRIBE) self.unsubscribe = partial(self._zmq_sub_socket.setsockopt_string, zmq.UNSUBSCRIBE) - self.register_callback = self._zmq_sub_stream.on_recv + + def register_callback(self, callback): + callback = with_deserialize_tfw_msg(callback) + self._zmq_sub_stream.on_recv(callback) class ServerUplinkConnector(ZMQConnectorBase): diff --git a/lib/tfw/networking/serialization.py b/lib/tfw/networking/serialization.py index c21fc47..483b666 100644 --- a/lib/tfw/networking/serialization.py +++ b/lib/tfw/networking/serialization.py @@ -22,6 +22,7 @@ The purpose of this module is abstracting away this low level behaviour. """ import json +from functools import wraps def validate_message(message): @@ -36,6 +37,14 @@ def serialize_tfw_msg(message): return _serialize_all(message['key'], message) +def with_deserialize_tfw_msg(fun): + @wraps(fun) + def wrapper(message_parts): + message = deserialize_tfw_msg(*message_parts) + return fun(message) + return wrapper + + def deserialize_tfw_msg(*args): """ Return message from TFW multipart data From 36a86b04540fa83335f612abd0842c0a17cd7761 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krist=C3=B3f=20T=C3=B3th?= Date: Fri, 29 Jun 2018 11:58:05 +0200 Subject: [PATCH 05/27] Hide ZMQ serialization magic from EHConnector clients --- lib/tfw/networking/server/event_handler_connector.py | 3 ++- lib/tfw/networking/server/zmq_websocket_handler.py | 11 +++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/lib/tfw/networking/server/event_handler_connector.py b/lib/tfw/networking/server/event_handler_connector.py index 542bac1..c4c6338 100644 --- a/lib/tfw/networking/server/event_handler_connector.py +++ b/lib/tfw/networking/server/event_handler_connector.py @@ -4,7 +4,7 @@ import zmq from zmq.eventloop.zmqstream import ZMQStream -from tfw.networking import ZMQConnectorBase, serialize_tfw_msg +from tfw.networking import ZMQConnectorBase, serialize_tfw_msg, with_deserialize_tfw_msg from tfw.config import TFWENV from tfw.config.logs import logging @@ -32,6 +32,7 @@ class EventHandlerUplinkConnector(ZMQConnectorBase): class EventHandlerConnector(EventHandlerDownlinkConnector, EventHandlerUplinkConnector): def register_callback(self, callback): + callback = with_deserialize_tfw_msg(callback) self._zmq_pull_stream.on_recv(callback) def send_message(self, message: dict): diff --git a/lib/tfw/networking/server/zmq_websocket_handler.py b/lib/tfw/networking/server/zmq_websocket_handler.py index 9456ca4..0bf82fb 100644 --- a/lib/tfw/networking/server/zmq_websocket_handler.py +++ b/lib/tfw/networking/server/zmq_websocket_handler.py @@ -5,7 +5,7 @@ import json from tornado.websocket import WebSocketHandler -from tfw.networking import deserialize_tfw_msg, validate_message +from tfw.networking import validate_message from tfw.mixins import CallbackMixin from tfw.config.logs import logging @@ -87,19 +87,18 @@ class ZMQWebSocketProxy(WebSocketHandler): def open(self, *args, **kwargs): LOG.debug('WebSocket connection initiated') - self._event_handler_connector.register_callback(self.zmq_callback) + self._event_handler_connector.register_callback(self.eventhander_callback) - def zmq_callback(self, msg_parts): + def eventhander_callback(self, message): """ - Invoked on ZMQ message. + Invoked on ZMQ messages from event handlers. """ - message = deserialize_tfw_msg(*msg_parts) LOG.debug('Received on pull socket: %s', message) self.proxy_eventhandler_to_websocket(message) def on_message(self, message): """ - Invoked on WS message. + Invoked on WS messages from frontend. """ message = json.loads(message) LOG.debug('Received on WebSocket: %s', message) From b217ac59c82ca2b03d67c7e47579d5eca3dbc887 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krist=C3=B3f=20T=C3=B3th?= Date: Fri, 29 Jun 2018 12:06:08 +0200 Subject: [PATCH 06/27] Move message validation code to where it belongs --- lib/tfw/networking/__init__.py | 2 +- lib/tfw/networking/serialization.py | 5 ----- lib/tfw/networking/server/zmq_websocket_handler.py | 8 ++++++-- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/lib/tfw/networking/__init__.py b/lib/tfw/networking/__init__.py index 0958f0b..c5879b7 100644 --- a/lib/tfw/networking/__init__.py +++ b/lib/tfw/networking/__init__.py @@ -1,7 +1,7 @@ # Copyright (C) 2018 Avatao.com Innovative Learning Kft. # All Rights Reserved. See LICENSE file for details. -from .serialization import serialize_tfw_msg, deserialize_tfw_msg, validate_message, with_deserialize_tfw_msg +from .serialization import serialize_tfw_msg, deserialize_tfw_msg, with_deserialize_tfw_msg from .zmq_connector_base import ZMQConnectorBase # from .controller_connector import ControllerConnector # TODO: readd once controller stuff is resolved from .message_sender import MessageSender diff --git a/lib/tfw/networking/serialization.py b/lib/tfw/networking/serialization.py index 483b666..c28eec0 100644 --- a/lib/tfw/networking/serialization.py +++ b/lib/tfw/networking/serialization.py @@ -25,11 +25,6 @@ import json from functools import wraps -def validate_message(message): - if 'key' not in message: - raise ValueError('Invalid TFW message format!') - - def serialize_tfw_msg(message): """ Create TFW multipart data from message dict diff --git a/lib/tfw/networking/server/zmq_websocket_handler.py b/lib/tfw/networking/server/zmq_websocket_handler.py index 0bf82fb..508049f 100644 --- a/lib/tfw/networking/server/zmq_websocket_handler.py +++ b/lib/tfw/networking/server/zmq_websocket_handler.py @@ -5,7 +5,6 @@ import json from tornado.websocket import WebSocketHandler -from tfw.networking import validate_message from tfw.mixins import CallbackMixin from tfw.config.logs import logging @@ -20,13 +19,18 @@ class TFWProxy: self.proxy_filters = CallbackMixin() self.proxy_callbacks = CallbackMixin() - self.proxy_filters.subscribe_callback(validate_message) + self.proxy_filters.subscribe_callback(self.validate_message) self.keyhandlers = { 'mirror': self.mirror, 'broadcast': self.broadcast } + @staticmethod + def validate_message(message): + if 'key' not in message: + raise ValueError('Invalid TFW message format!') + def __call__(self, message): try: self.proxy_filters._execute_callbacks(message) From 7ed0715f4cef10b30b84c3b795b06705d46f1233 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krist=C3=B3f=20T=C3=B3th?= Date: Fri, 29 Jun 2018 15:33:45 +0200 Subject: [PATCH 07/27] Reorder stuff to follow the teachings of Uncle Bob --- .../server/zmq_websocket_handler.py | 100 +++++++++--------- 1 file changed, 50 insertions(+), 50 deletions(-) diff --git a/lib/tfw/networking/server/zmq_websocket_handler.py b/lib/tfw/networking/server/zmq_websocket_handler.py index 508049f..85d6c60 100644 --- a/lib/tfw/networking/server/zmq_websocket_handler.py +++ b/lib/tfw/networking/server/zmq_websocket_handler.py @@ -11,56 +11,6 @@ from tfw.config.logs import logging LOG = logging.getLogger(__name__) -class TFWProxy: - def __init__(self, to_source, to_destination): - self.to_source = to_source - self.to_destination = to_destination - - self.proxy_filters = CallbackMixin() - self.proxy_callbacks = CallbackMixin() - - self.proxy_filters.subscribe_callback(self.validate_message) - - self.keyhandlers = { - 'mirror': self.mirror, - 'broadcast': self.broadcast - } - - @staticmethod - def validate_message(message): - if 'key' not in message: - raise ValueError('Invalid TFW message format!') - - def __call__(self, message): - try: - self.proxy_filters._execute_callbacks(message) - except ValueError: - LOG.exception('Invalid TFW message received!') - return - - self.proxy_callbacks._execute_callbacks(message) - - if message['key'] not in self.keyhandlers: - self.to_destination(message) - else: - handler = self.keyhandlers[message['key']] - try: - handler(message) - except KeyError: - LOG.error('Invalid "%s" message format! Ignoring.', handler.__name__) - - def mirror(self, message): - message = message['data'] - LOG.debug('Mirroring message: %s', message) - self.to_source(message) - - def broadcast(self, message): - message = message['data'] - LOG.debug('Broadcasting message: %s', message) - self.to_source(message) - self.to_destination(message) - - class ZMQWebSocketProxy(WebSocketHandler): instances = set() @@ -119,3 +69,53 @@ class ZMQWebSocketProxy(WebSocketHandler): # much secure, very cors, wow def check_origin(self, origin): return True + + +class TFWProxy: + def __init__(self, to_source, to_destination): + self.to_source = to_source + self.to_destination = to_destination + + self.proxy_filters = CallbackMixin() + self.proxy_callbacks = CallbackMixin() + + self.proxy_filters.subscribe_callback(self.validate_message) + + self.keyhandlers = { + 'mirror': self.mirror, + 'broadcast': self.broadcast + } + + @staticmethod + def validate_message(message): + if 'key' not in message: + raise ValueError('Invalid TFW message format!') + + def __call__(self, message): + try: + self.proxy_filters._execute_callbacks(message) + except ValueError: + LOG.exception('Invalid TFW message received!') + return + + self.proxy_callbacks._execute_callbacks(message) + + if message['key'] not in self.keyhandlers: + self.to_destination(message) + else: + handler = self.keyhandlers[message['key']] + try: + handler(message) + except KeyError: + LOG.error('Invalid "%s" message format! Ignoring.', handler.__name__) + + def mirror(self, message): + message = message['data'] + LOG.debug('Mirroring message: %s', message) + self.to_source(message) + + def broadcast(self, message): + message = message['data'] + LOG.debug('Broadcasting message: %s', message) + self.to_source(message) + self.to_destination(message) From 427694623f849b6a173d343478c94ababee5552a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krist=C3=B3f=20T=C3=B3th?= Date: Fri, 29 Jun 2018 15:40:07 +0200 Subject: [PATCH 08/27] Rename file to harmonize with new networking code structure --- lib/tfw/networking/server/__init__.py | 1 - lib/tfw/networking/server/tfw_server.py | 2 +- .../server/{zmq_websocket_handler.py => zmq_websocket_proxy.py} | 0 3 files changed, 1 insertion(+), 2 deletions(-) rename lib/tfw/networking/server/{zmq_websocket_handler.py => zmq_websocket_proxy.py} (100%) diff --git a/lib/tfw/networking/server/__init__.py b/lib/tfw/networking/server/__init__.py index aea3e0c..e707fab 100644 --- a/lib/tfw/networking/server/__init__.py +++ b/lib/tfw/networking/server/__init__.py @@ -3,5 +3,4 @@ from .event_handler_connector import EventHandlerConnector, EventHandlerUplinkConnector, EventHandlerDownlinkConnector from .tfw_server import TFWServer -from .zmq_websocket_handler import ZMQWebSocketProxy # from .controller_responder import ControllerResponder # TODO: readd once controller stuff is resolved diff --git a/lib/tfw/networking/server/tfw_server.py b/lib/tfw/networking/server/tfw_server.py index 46c9006..7c34c05 100644 --- a/lib/tfw/networking/server/tfw_server.py +++ b/lib/tfw/networking/server/tfw_server.py @@ -9,7 +9,7 @@ from tfw.networking import MessageSender from tfw.networking.event_handlers import ServerUplinkConnector from tfw.networking.server import EventHandlerConnector from tfw.config.logs import logging -from .zmq_websocket_handler import ZMQWebSocketProxy +from .zmq_websocket_proxy import ZMQWebSocketProxy LOG = logging.getLogger(__name__) diff --git a/lib/tfw/networking/server/zmq_websocket_handler.py b/lib/tfw/networking/server/zmq_websocket_proxy.py similarity index 100% rename from lib/tfw/networking/server/zmq_websocket_handler.py rename to lib/tfw/networking/server/zmq_websocket_proxy.py From 196e753fb9cb402a3a5a17465f8e299e46ee4807 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krist=C3=B3f=20T=C3=B3th?= Date: Fri, 29 Jun 2018 15:59:03 +0200 Subject: [PATCH 09/27] Fix typo in comment --- lib/tfw/fsm_base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/tfw/fsm_base.py b/lib/tfw/fsm_base.py index 7296627..8b1edd3 100644 --- a/lib/tfw/fsm_base.py +++ b/lib/tfw/fsm_base.py @@ -12,7 +12,7 @@ class FSMBase(CallbackMixin): """ A general FSM base class you can inherit from to track user progress. See linear_fsm.py for an example use-case. - TFW the transitions library for state machines, please refer to their + TFW uses the transitions library for state machines, please refer to their documentation for more information on creating your own machines: https://github.com/pytransitions/transitions """ From a6563bcd89cd6ccc7de221460da2c407e3e7033c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krist=C3=B3f=20T=C3=B3th?= Date: Fri, 29 Jun 2018 22:02:26 +0200 Subject: [PATCH 10/27] Implement event handler base class that broadcasts everything --- lib/tfw/__init__.py | 2 +- lib/tfw/event_handler_base.py | 46 ++++++++++++++++++++++++++++++++++- 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/lib/tfw/__init__.py b/lib/tfw/__init__.py index d0e877b..9736ade 100644 --- a/lib/tfw/__init__.py +++ b/lib/tfw/__init__.py @@ -1,6 +1,6 @@ # Copyright (C) 2018 Avatao.com Innovative Learning Kft. # All Rights Reserved. See LICENSE file for details. -from .event_handler_base import EventHandlerBase, TriggeredEventHandler +from .event_handler_base import EventHandlerBase, TriggeredEventHandler, BroadcastingEventHandler from .fsm_base import FSMBase from .linear_fsm import LinearFSM diff --git a/lib/tfw/event_handler_base.py b/lib/tfw/event_handler_base.py index cfce6f7..8771f35 100644 --- a/lib/tfw/event_handler_base.py +++ b/lib/tfw/event_handler_base.py @@ -2,8 +2,13 @@ # All Rights Reserved. See LICENSE file for details. from abc import ABC, abstractmethod +from json import dumps +from hashlib import md5 from tfw.networking.event_handlers import ServerConnector +from tfw.config.logs import logging + +LOG = logging.getLogger(__name__) class EventHandlerBase(ABC): @@ -28,12 +33,14 @@ class EventHandlerBase(ABC): """ response = self.dispatch_handling(message) if response: - response['key'] = message['key'] self.server_connector.send(response) def dispatch_handling(self, message): """ Used to dispatch messages to their specific handlers. + + :param message: the message received + :returns: the message to send back """ if message['key'] != 'reset': return self.handle_event(message) @@ -45,6 +52,7 @@ class EventHandlerBase(ABC): Abstract method that implements the handling of messages. :param message: the message received + :returns: the message to send back """ raise NotImplementedError @@ -54,6 +62,7 @@ class EventHandlerBase(ABC): Usually 'reset' events receive some sort of special treatment. :param message: the message received + :returns: the message to send back """ return None @@ -100,3 +109,38 @@ class TriggeredEventHandler(EventHandlerBase, ABC): if message.get('trigger') == self.trigger: return super().dispatch_handling(message) return None + + +class BroadcastingEventHandler(EventHandlerBase, ABC): + # pylint: disable=abstract-method + """ + Abstract base class for EventHandlers which broadcast responses + and intelligently ignore their own broadcasted messages they receive. + """ + def __init__(self, key): + super().__init__(key) + self.own_message_hashes = [] + + def event_handler_callback(self, message): + message_hash = self.hash_message(message) + + if message_hash in self.own_message_hashes: + self.own_message_hashes.remove(message_hash) + return + + response = self.dispatch_handling(message) + if response: + self.own_message_hashes.append(self.hash_message(response)) + self.server_connector.send(self.make_broadcast_message(response)) + + @staticmethod + def hash_message(message): + message_bytes = dumps(message, sort_keys=True).encode() + return md5(message_bytes).hexdigest() + + @staticmethod + def make_broadcast_message(message): + return { + 'key': 'broadcast', + 'data': message + } From 708c920784c050685d859aa9679b43610fb18d23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krist=C3=B3f=20T=C3=B3th?= Date: Fri, 29 Jun 2018 22:03:19 +0200 Subject: [PATCH 11/27] Move FSM handling logic to an event handler --- lib/tfw/components/__init__.py | 1 + .../components/fsm_managing_event_handler.py | 88 ++++++++++++++ lib/tfw/networking/server/tfw_server.py | 110 +++--------------- 3 files changed, 102 insertions(+), 97 deletions(-) create mode 100644 lib/tfw/components/fsm_managing_event_handler.py diff --git a/lib/tfw/components/__init__.py b/lib/tfw/components/__init__.py index 12735f9..ad160f1 100644 --- a/lib/tfw/components/__init__.py +++ b/lib/tfw/components/__init__.py @@ -8,3 +8,4 @@ from .ide_event_handler import IdeEventHandler from .history_monitor import HistoryMonitor, BashMonitor, GDBMonitor from .terminal_commands import TerminalCommands from .log_monitoring_event_handler import LogMonitoringEventHandler +from .fsm_managing_event_handler import FSMManagingEventHandler diff --git a/lib/tfw/components/fsm_managing_event_handler.py b/lib/tfw/components/fsm_managing_event_handler.py new file mode 100644 index 0000000..69204b1 --- /dev/null +++ b/lib/tfw/components/fsm_managing_event_handler.py @@ -0,0 +1,88 @@ +# Copyright (C) 2018 Avatao.com Innovative Learning Kft. +# All Rights Reserved. See LICENSE file for details. + +from collections import defaultdict + +from tfw import BroadcastingEventHandler +from tfw.config.logs import logging + +LOG = logging.getLogger(__name__) + + +class FSMManagingEventHandler(BroadcastingEventHandler): + def __init__(self, key, fsm_type): + super().__init__(key) + self.fsm = fsm_type() + self.fsm_manager = FSMManager(self.fsm) + self._fsm_updater = FSMUpdater(self.fsm) + + self.command_handlers = { + 'trigger': self.handle_trigger, + 'update': self.handle_update + } + + def handle_event(self, message): + try: + data = message['data'] + message['data'] = self.command_handlers[data['command']](data) + return message + except KeyError: + LOG.error('IGNORING MESSAGE: Invalid message received: %s', message) + + def handle_trigger(self, data): + self.fsm_manager.trigger(data['value']) + return self.with_fsm_update(data) + + def with_fsm_update(self, data): + return { + **data, + **self._fsm_updater.get_fsm_state_and_transitions() + } + + def handle_update(self, data): + return self.with_fsm_update(data) + + +class FSMManager: + def __init__(self, fsm): + self.fsm = fsm + self.trigger_predicates = defaultdict(list) + + def trigger(self, trigger): + predicate_results = [ + predicate() + for predicate in self.trigger_predicates[trigger] + ] + + # TODO: think about what could we do when this prevents triggering + if all(predicate_results): + try: + self.fsm.trigger(trigger) + except AttributeError: + LOG.debug('FSM failed to execute nonexistent trigger: "%s"', trigger) + + def subscribe_predicate(self, trigger, *predicates): + self.trigger_predicates[trigger].extend(predicates) + + def unsubscribe_predicate(self, trigger, *predicates): + self.trigger_predicates[trigger] = [ + predicate + for predicate in self.trigger_predicates[trigger] + not in predicates + ] + + +class FSMUpdater: + def __init__(self, fsm): + self.fsm = fsm + + def get_fsm_state_and_transitions(self): + state = self.fsm.state + valid_transitions = [ + {'trigger': trigger} + for trigger in self.fsm.machine.get_triggers(self.fsm.state) + ] + return { + 'current_state': state, + 'valid_transitions': valid_transitions + } diff --git a/lib/tfw/networking/server/tfw_server.py b/lib/tfw/networking/server/tfw_server.py index 7c34c05..bdc5179 100644 --- a/lib/tfw/networking/server/tfw_server.py +++ b/lib/tfw/networking/server/tfw_server.py @@ -1,11 +1,8 @@ # Copyright (C) 2018 Avatao.com Innovative Learning Kft. # All Rights Reserved. See LICENSE file for details. -from collections import defaultdict - from tornado.web import Application -from tfw.networking import MessageSender from tfw.networking.event_handlers import ServerUplinkConnector from tfw.networking.server import EventHandlerConnector from tfw.config.logs import logging @@ -18,110 +15,29 @@ class TFWServer: """ This class handles the proxying of messages between the frontend and event handers. It proxies messages from the "/ws" route to all event handlers subscribed to a ZMQ - SUB socket. It also manages an FSM you can define as a constructor argument. + SUB socket. """ - def __init__(self, fsm_type): - """ - :param fsm_type: the type of FSM you want TFW to use - """ - self._fsm = fsm_type() - self._fsm_updater = FSMUpdater(self._fsm) - self._fsm_manager = FSMManager(self._fsm) - self._fsm.subscribe_callback(self._fsm_updater.update) + def __init__(self): self._event_handler_connector = EventHandlerConnector() + self._uplink_connector = ServerUplinkConnector() self.application = Application([( r'/ws', ZMQWebSocketProxy,{ 'event_handler_connector': self._event_handler_connector, - 'message_handlers': [self.append_fsm_data, self.handle_trigger] + 'message_handlers': [self.handle_trigger] })] ) - # self.controller_responder = ControllerResponder(self.fsm) - # TODO: add this once controller stuff is resolved - - @property - def fsm(self): - return self._fsm - - @property - def fsm_manager(self): - return self._fsm_manager - - def append_fsm_data(self, message): - message['FSMUpdate'] = self._fsm_updater.get_fsm_state_and_transitions() - return message def handle_trigger(self, message): - LOG.debug('Executing handler for trigger "%s"', message.get('trigger', '')) - self.trigger_fsm(message) - - def trigger_fsm(self, message): - trigger = message.get('trigger', '') - try: - self._fsm_manager.trigger(trigger, message) - except AttributeError: - LOG.debug('FSM failed to execute nonexistent trigger: "%s"', trigger) + if 'trigger' in message: + LOG.debug('Executing handler for trigger "%s"', message.get('trigger', '')) + self._uplink_connector.send_to_eventhandler({ + 'key': 'fsm', + 'data': { + 'command': 'trigger', + 'value': message.get('trigger', '') + } + }) def listen(self, port): self.application.listen(port) - - -class FSMManager: - def __init__(self, fsm): - self._fsm = fsm - self.trigger_predicates = defaultdict(list) - self.messenge_sender = MessageSender() - - @property - def fsm(self): - return self._fsm - - def trigger(self, trigger, message): - predicate_results = [] - for predicate in self.trigger_predicates[trigger]: - success, message = predicate(message) - predicate_results.append(success) - self.messenge_sender.send('FSM', message) - - if all(predicate_results): - try: - self.fsm.trigger(trigger, message=message) - except AttributeError: - LOG.debug('FSM failed to execute nonexistent trigger: "%s"', trigger) - - def subscribe_predicate(self, trigger, *predicates): - self.trigger_predicates[trigger].extend(predicates) - - def unsubscribe_predicate(self, trigger, *predicates): - self.trigger_predicates[trigger] = [ - predicate - for predicate in self.trigger_predicates[trigger] - not in predicates - ] - - -class FSMUpdater: - def __init__(self, fsm): - self.fsm = fsm - self.uplink = ServerUplinkConnector() - - def update(self, kwargs_dict): - # pylint: disable=unused-argument - self.uplink.send(self.generate_fsm_update()) - - def generate_fsm_update(self): - return { - 'key': 'FSMUpdate', - 'data': self.get_fsm_state_and_transitions() - } - - def get_fsm_state_and_transitions(self): - state = self.fsm.state - valid_transitions = [ - {'trigger': trigger} - for trigger in self.fsm.machine.get_triggers(self.fsm.state) - ] - return { - 'current_state': state, - 'valid_transitions': valid_transitions - } From 7c0e6d49bca5c2fda31537410df93f1dc8b0f522 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krist=C3=B3f=20T=C3=B3th?= Date: Fri, 29 Jun 2018 22:53:44 +0200 Subject: [PATCH 12/27] Handle starting TFWServer in baseimage --- Dockerfile | 5 ++++- supervisor/components/tfw_server.conf | 4 ++++ supervisor/tfw_server.py | 9 +++++++++ 3 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 supervisor/components/tfw_server.conf create mode 100644 supervisor/tfw_server.py diff --git a/Dockerfile b/Dockerfile index 95976be..85f00ee 100644 --- a/Dockerfile +++ b/Dockerfile @@ -37,6 +37,7 @@ ENV PYTHONPATH="/usr/local/lib" \ TFW_LIB_DIR="/usr/local/lib/" \ TFW_TERMINADO_DIR="/tmp/terminado_server" \ TFW_FRONTEND_DIR="/srv/frontend" \ + TFW_SERVER_DIR="/srv/.tfw" \ TFW_HISTFILE="/home/${AVATAO_USER}/.bash_history" \ PROMPT_COMMAND="history -a" @@ -45,13 +46,15 @@ RUN echo "export HISTFILE=${TFW_HISTFILE}" >> /tmp/bashrc &&\ cat /tmp/bashrc >> /home/${AVATAO_USER}/.bashrc COPY supervisor/supervisord.conf ${TFW_SUPERVISORD_CONF} +COPY supervisor/components/ ${TFW_SUPERVISORD_COMPONENTS} COPY nginx/nginx.conf ${TFW_NGINX_CONF} COPY nginx/default.conf ${TFW_NGINX_DEFAULT} COPY nginx/components/ ${TFW_NGINX_COMPONENTS} COPY lib LICENSE ${TFW_LIB_DIR} +COPY supervisor/tfw_server.py ${TFW_SERVER_DIR}/ RUN for dir in "${TFW_LIB_DIR}"/{tfw,tao,envvars} "/etc/nginx" "/etc/supervisor"; do \ - chown -R root:root "$dir" && chmod -R 700 "$dir"; \ + chown -R root:root "$dir" && chmod -R 700 "$dir"; \ done ONBUILD ARG BUILD_CONTEXT="solvable" diff --git a/supervisor/components/tfw_server.conf b/supervisor/components/tfw_server.conf new file mode 100644 index 0000000..f583be9 --- /dev/null +++ b/supervisor/components/tfw_server.conf @@ -0,0 +1,4 @@ +[program:tfwserver] +user=root +directory=%(ENV_TFW_SERVER_DIR)s +command=python3 tfw_server.py diff --git a/supervisor/tfw_server.py b/supervisor/tfw_server.py new file mode 100644 index 0000000..78766ac --- /dev/null +++ b/supervisor/tfw_server.py @@ -0,0 +1,9 @@ +from tornado.ioloop import IOLoop + +from tfw.networking import TFWServer +from tfw.config import TFWENV + + +if __name__ == '__main__': + TFWServer().listen(TFWENV.WEB_PORT) + IOLoop.instance().start() From 5e4303ac065fd619901adfb8583ac1c0f4718bf1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krist=C3=B3f=20T=C3=B3th?= Date: Tue, 3 Jul 2018 15:14:00 +0200 Subject: [PATCH 13/27] Add first version of YamlFSM --- lib/tfw/__init__.py | 1 + lib/tfw/yaml_fsm.py | 18 ++++++++++++++++++ requirements.txt | 1 + 3 files changed, 20 insertions(+) create mode 100644 lib/tfw/yaml_fsm.py diff --git a/lib/tfw/__init__.py b/lib/tfw/__init__.py index 9736ade..53e80d7 100644 --- a/lib/tfw/__init__.py +++ b/lib/tfw/__init__.py @@ -4,3 +4,4 @@ from .event_handler_base import EventHandlerBase, TriggeredEventHandler, BroadcastingEventHandler from .fsm_base import FSMBase from .linear_fsm import LinearFSM +from .yaml_fsm import YamlFSM diff --git a/lib/tfw/yaml_fsm.py b/lib/tfw/yaml_fsm.py new file mode 100644 index 0000000..fb54103 --- /dev/null +++ b/lib/tfw/yaml_fsm.py @@ -0,0 +1,18 @@ +import yaml +from transitions import State + +from tfw import FSMBase + + +class YamlFSM(FSMBase): + def __init__(self, config_file): + self.config = self.parse_config(config_file) + self.states = [State(**state) for state in self.config['states']] + super(YamlFSM, self).__init__() + for transition in self.config['transitions']: + self.machine.add_transition(**transition) + + @staticmethod + def parse_config(config_file): + with open(config_file, 'r') as ifile: + return yaml.load(ifile) diff --git a/requirements.txt b/requirements.txt index cdc46e3..a324ff0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,3 +3,4 @@ pyzmq==17.0.0 transitions==0.6.4 terminado==0.8.1 watchdog==0.8.3 +PyYAML==3.12 From 022a997dc296e7de57878cf01ac737a8410c2ebd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krist=C3=B3f=20T=C3=B3th?= Date: Tue, 3 Jul 2018 19:06:54 +0200 Subject: [PATCH 14/27] Implement monkey patching callbacks in YamlFSM config --- lib/tfw/yaml_fsm.py | 31 +++++++++++++++++++++++++++---- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/lib/tfw/yaml_fsm.py b/lib/tfw/yaml_fsm.py index fb54103..bb16b12 100644 --- a/lib/tfw/yaml_fsm.py +++ b/lib/tfw/yaml_fsm.py @@ -1,3 +1,6 @@ +from subprocess import Popen +from functools import partial + import yaml from transitions import State @@ -7,12 +10,32 @@ from tfw import FSMBase class YamlFSM(FSMBase): def __init__(self, config_file): self.config = self.parse_config(config_file) - self.states = [State(**state) for state in self.config['states']] - super(YamlFSM, self).__init__() - for transition in self.config['transitions']: - self.machine.add_transition(**transition) + self.patch_config_callbacks() + self.setup_states() + super(YamlFSM, self).__init__() # FSMBase.__init__() requires states + self.setup_transitions() @staticmethod def parse_config(config_file): with open(config_file, 'r') as ifile: return yaml.load(ifile) + + def setup_states(self): + self.states = [State(**state) for state in self.config['states']] + + def setup_transitions(self): + for transition in self.config['transitions']: + self.machine.add_transition(**transition) + + def patch_config_callbacks(self): + topatch = {'on_enter', 'on_exit', 'prepare', 'before', 'after'} + + for array in {'states', 'transitions'}: + for i, json_obj in enumerate(self.config[array]): + for attribute, value in json_obj.items(): + if attribute in topatch: + self.config[array][i][attribute] = partial(self.run, value) + + @staticmethod + def run(command, event): + Popen(command, shell=True) From 91c257554f65476d7293ded82980498c103a8066 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krist=C3=B3f=20T=C3=B3th?= Date: Tue, 3 Jul 2018 20:09:47 +0200 Subject: [PATCH 15/27] Simplify callback monkeypatching logic in YamlFSM --- lib/tfw/yaml_fsm.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/tfw/yaml_fsm.py b/lib/tfw/yaml_fsm.py index bb16b12..b4fd45a 100644 --- a/lib/tfw/yaml_fsm.py +++ b/lib/tfw/yaml_fsm.py @@ -31,10 +31,10 @@ class YamlFSM(FSMBase): topatch = {'on_enter', 'on_exit', 'prepare', 'before', 'after'} for array in {'states', 'transitions'}: - for i, json_obj in enumerate(self.config[array]): + for json_obj in self.config[array]: for attribute, value in json_obj.items(): if attribute in topatch: - self.config[array][i][attribute] = partial(self.run, value) + json_obj[attribute] = partial(self.run, value) @staticmethod def run(command, event): From 7a92d88b73a5fa4e1b9244df5a7a853aed52dec5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krist=C3=B3f=20T=C3=B3th?= Date: Wed, 4 Jul 2018 15:48:16 +0200 Subject: [PATCH 16/27] Refactor FSMBase to subclass transitions.Machine --- .../components/fsm_managing_event_handler.py | 36 +---------------- lib/tfw/fsm_base.py | 40 ++++++++++++++++--- 2 files changed, 36 insertions(+), 40 deletions(-) diff --git a/lib/tfw/components/fsm_managing_event_handler.py b/lib/tfw/components/fsm_managing_event_handler.py index 69204b1..78efa66 100644 --- a/lib/tfw/components/fsm_managing_event_handler.py +++ b/lib/tfw/components/fsm_managing_event_handler.py @@ -1,8 +1,6 @@ # Copyright (C) 2018 Avatao.com Innovative Learning Kft. # All Rights Reserved. See LICENSE file for details. -from collections import defaultdict - from tfw import BroadcastingEventHandler from tfw.config.logs import logging @@ -13,7 +11,6 @@ class FSMManagingEventHandler(BroadcastingEventHandler): def __init__(self, key, fsm_type): super().__init__(key) self.fsm = fsm_type() - self.fsm_manager = FSMManager(self.fsm) self._fsm_updater = FSMUpdater(self.fsm) self.command_handlers = { @@ -30,7 +27,7 @@ class FSMManagingEventHandler(BroadcastingEventHandler): LOG.error('IGNORING MESSAGE: Invalid message received: %s', message) def handle_trigger(self, data): - self.fsm_manager.trigger(data['value']) + self.fsm.step(data['value']) return self.with_fsm_update(data) def with_fsm_update(self, data): @@ -43,35 +40,6 @@ class FSMManagingEventHandler(BroadcastingEventHandler): return self.with_fsm_update(data) -class FSMManager: - def __init__(self, fsm): - self.fsm = fsm - self.trigger_predicates = defaultdict(list) - - def trigger(self, trigger): - predicate_results = [ - predicate() - for predicate in self.trigger_predicates[trigger] - ] - - # TODO: think about what could we do when this prevents triggering - if all(predicate_results): - try: - self.fsm.trigger(trigger) - except AttributeError: - LOG.debug('FSM failed to execute nonexistent trigger: "%s"', trigger) - - def subscribe_predicate(self, trigger, *predicates): - self.trigger_predicates[trigger].extend(predicates) - - def unsubscribe_predicate(self, trigger, *predicates): - self.trigger_predicates[trigger] = [ - predicate - for predicate in self.trigger_predicates[trigger] - not in predicates - ] - - class FSMUpdater: def __init__(self, fsm): self.fsm = fsm @@ -80,7 +48,7 @@ class FSMUpdater: state = self.fsm.state valid_transitions = [ {'trigger': trigger} - for trigger in self.fsm.machine.get_triggers(self.fsm.state) + for trigger in self.fsm.get_triggers(self.fsm.state) ] return { 'current_state': state, diff --git a/lib/tfw/fsm_base.py b/lib/tfw/fsm_base.py index 8b1edd3..155997e 100644 --- a/lib/tfw/fsm_base.py +++ b/lib/tfw/fsm_base.py @@ -1,14 +1,17 @@ # Copyright (C) 2018 Avatao.com Innovative Learning Kft. # All Rights Reserved. See LICENSE file for details. -from typing import List +from collections import defaultdict from transitions import Machine from tfw.mixins import CallbackMixin +from tfw.config.logs import logging + +LOG = logging.getLogger(__name__) -class FSMBase(CallbackMixin): +class FSMBase(Machine, CallbackMixin): """ A general FSM base class you can inherit from to track user progress. See linear_fsm.py for an example use-case. @@ -18,10 +21,12 @@ class FSMBase(CallbackMixin): """ states, transitions = [], [] - def __init__(self, initial: str = None, accepted_states: List[str] = None): + def __init__(self, initial=None, accepted_states=None): self.accepted_states = accepted_states or [self.states[-1]] - self.machine = Machine( - model=self, + self.trigger_predicates = defaultdict(list) + + Machine.__init__( + self, states=self.states, transitions=self.transitions, initial=initial or self.states[0], @@ -34,4 +39,27 @@ class FSMBase(CallbackMixin): self._execute_callbacks(event_data.kwargs) def is_solved(self): - return self.state in self.accepted_states # pylint: disable=no-member + return self.state in self.accepted_states # pylint: disable=no-member + + def subscribe_predicate(self, trigger, *predicates): + self.trigger_predicates[trigger].extend(predicates) + + def unsubscribe_predicate(self, trigger, *predicates): + self.trigger_predicates[trigger] = [ + predicate + for predicate in self.trigger_predicates[trigger] + not in predicates + ] + + def step(self, trigger): + predicate_results = [ + predicate() + for predicate in self.trigger_predicates[trigger] + ] + + # TODO: think about what could we do when this prevents triggering + if all(predicate_results): + try: + self.trigger(trigger) + except AttributeError: + LOG.debug('FSM failed to execute nonexistent trigger: "%s"', trigger) From bfa1bffbc51efb8a2e9a280ecbf67ab23ff25ffc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krist=C3=B3f=20T=C3=B3th?= Date: Wed, 4 Jul 2018 16:22:03 +0200 Subject: [PATCH 17/27] Refactor YamlFSM --- lib/tfw/yaml_fsm.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/lib/tfw/yaml_fsm.py b/lib/tfw/yaml_fsm.py index b4fd45a..9b5f331 100644 --- a/lib/tfw/yaml_fsm.py +++ b/lib/tfw/yaml_fsm.py @@ -12,29 +12,29 @@ class YamlFSM(FSMBase): self.config = self.parse_config(config_file) self.patch_config_callbacks() self.setup_states() - super(YamlFSM, self).__init__() # FSMBase.__init__() requires states + super().__init__() # FSMBase.__init__() requires states self.setup_transitions() @staticmethod def parse_config(config_file): with open(config_file, 'r') as ifile: - return yaml.load(ifile) + return yaml.safe_load(ifile) def setup_states(self): self.states = [State(**state) for state in self.config['states']] def setup_transitions(self): for transition in self.config['transitions']: - self.machine.add_transition(**transition) + self.add_transition(**transition) def patch_config_callbacks(self): - topatch = {'on_enter', 'on_exit', 'prepare', 'before', 'after'} + topatch = ('on_enter', 'on_exit', 'prepare', 'before', 'after') - for array in {'states', 'transitions'}: + for array in ('states', 'transitions'): for json_obj in self.config[array]: - for attribute, value in json_obj.items(): - if attribute in topatch: - json_obj[attribute] = partial(self.run, value) + for key in json_obj: + if key in topatch: + json_obj[key] = partial(self.run, json_obj[key]) @staticmethod def run(command, event): From ea76a195951f1f79168a6bfb094b836bead22923 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krist=C3=B3f=20T=C3=B3th?= Date: Wed, 4 Jul 2018 18:00:41 +0200 Subject: [PATCH 18/27] Refactor YamlFSM moar --- lib/tfw/yaml_fsm.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/lib/tfw/yaml_fsm.py b/lib/tfw/yaml_fsm.py index 9b5f331..fe854ba 100644 --- a/lib/tfw/yaml_fsm.py +++ b/lib/tfw/yaml_fsm.py @@ -10,7 +10,7 @@ from tfw import FSMBase class YamlFSM(FSMBase): def __init__(self, config_file): self.config = self.parse_config(config_file) - self.patch_config_callbacks() + self.for_config_states_and_transitions_do(self.patch_config_callbacks) self.setup_states() super().__init__() # FSMBase.__init__() requires states self.setup_transitions() @@ -27,14 +27,16 @@ class YamlFSM(FSMBase): for transition in self.config['transitions']: self.add_transition(**transition) - def patch_config_callbacks(self): - topatch = ('on_enter', 'on_exit', 'prepare', 'before', 'after') - + def for_config_states_and_transitions_do(self, what): for array in ('states', 'transitions'): for json_obj in self.config[array]: - for key in json_obj: - if key in topatch: - json_obj[key] = partial(self.run, json_obj[key]) + what(json_obj) + + def patch_config_callbacks(self, json_obj): + topatch = ('on_enter', 'on_exit', 'prepare', 'before', 'after') + for key in json_obj: + if key in topatch: + json_obj[key] = partial(self.run, json_obj[key]) @staticmethod def run(command, event): From d71a25e30a60d2beb5cf4e79dd2f52ead7ee9b8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krist=C3=B3f=20T=C3=B3th?= Date: Wed, 4 Jul 2018 18:11:42 +0200 Subject: [PATCH 19/27] Implement subscribing predicates found in yaml --- lib/tfw/yaml_fsm.py | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/lib/tfw/yaml_fsm.py b/lib/tfw/yaml_fsm.py index fe854ba..4b09d5c 100644 --- a/lib/tfw/yaml_fsm.py +++ b/lib/tfw/yaml_fsm.py @@ -1,5 +1,6 @@ -from subprocess import Popen +from subprocess import Popen, run from functools import partial +from contextlib import suppress import yaml from transitions import State @@ -13,6 +14,7 @@ class YamlFSM(FSMBase): self.for_config_states_and_transitions_do(self.patch_config_callbacks) self.setup_states() super().__init__() # FSMBase.__init__() requires states + self.for_config_states_and_transitions_do(self.subscribe_and_remove_predicates) self.setup_transitions() @staticmethod @@ -38,6 +40,24 @@ class YamlFSM(FSMBase): if key in topatch: json_obj[key] = partial(self.run, json_obj[key]) + def subscribe_and_remove_predicates(self, json_obj): + for key in json_obj: + if key == 'predicates': + for predicate in json_obj[key]: + self.subscribe_predicate( + json_obj['trigger'], + partial( + self.statuscode_is_zero, + predicate + ) + ) + with suppress(KeyError): + json_obj.pop('predicates') + @staticmethod def run(command, event): Popen(command, shell=True) + + @staticmethod + def statuscode_is_zero(command): + return run(command, shell=True).returncode == 0 From 1beb419b093b779f0e86ae128961a58316973dd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krist=C3=B3f=20T=C3=B3th?= Date: Wed, 4 Jul 2018 18:15:34 +0200 Subject: [PATCH 20/27] Remove subprocess spawning stuff from YamlFSM for SRP --- lib/tfw/yaml_fsm.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/lib/tfw/yaml_fsm.py b/lib/tfw/yaml_fsm.py index 4b09d5c..d868a70 100644 --- a/lib/tfw/yaml_fsm.py +++ b/lib/tfw/yaml_fsm.py @@ -34,11 +34,12 @@ class YamlFSM(FSMBase): for json_obj in self.config[array]: what(json_obj) - def patch_config_callbacks(self, json_obj): + @staticmethod + def patch_config_callbacks(json_obj): topatch = ('on_enter', 'on_exit', 'prepare', 'before', 'after') for key in json_obj: if key in topatch: - json_obj[key] = partial(self.run, json_obj[key]) + json_obj[key] = partial(run_command_async, json_obj[key]) def subscribe_and_remove_predicates(self, json_obj): for key in json_obj: @@ -47,17 +48,17 @@ class YamlFSM(FSMBase): self.subscribe_predicate( json_obj['trigger'], partial( - self.statuscode_is_zero, + command_statuscode_is_zero, predicate ) ) with suppress(KeyError): json_obj.pop('predicates') - @staticmethod - def run(command, event): - Popen(command, shell=True) - @staticmethod - def statuscode_is_zero(command): - return run(command, shell=True).returncode == 0 +def run_command_async(command, event): + Popen(command, shell=True) + + +def command_statuscode_is_zero(command): + return run(command, shell=True).returncode == 0 From c7ee97f0c689523b3bcdffc0953549c13f9825de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krist=C3=B3f=20T=C3=B3th?= Date: Wed, 4 Jul 2018 21:58:30 +0200 Subject: [PATCH 21/27] Simplify predicate finding logic YamlFSM --- lib/tfw/yaml_fsm.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/tfw/yaml_fsm.py b/lib/tfw/yaml_fsm.py index d868a70..1c2e1ec 100644 --- a/lib/tfw/yaml_fsm.py +++ b/lib/tfw/yaml_fsm.py @@ -42,9 +42,8 @@ class YamlFSM(FSMBase): json_obj[key] = partial(run_command_async, json_obj[key]) def subscribe_and_remove_predicates(self, json_obj): - for key in json_obj: - if key == 'predicates': - for predicate in json_obj[key]: + if 'predicates' in json_obj: + for predicate in json_obj['predicates']: self.subscribe_predicate( json_obj['trigger'], partial( @@ -52,6 +51,7 @@ class YamlFSM(FSMBase): predicate ) ) + with suppress(KeyError): json_obj.pop('predicates') From 7f583d8d1f6efa86cafa832bc06ddfa3ad3313c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krist=C3=B3f=20T=C3=B3th?= Date: Fri, 6 Jul 2018 12:27:26 +0100 Subject: [PATCH 22/27] Improve YamlFSM initialization logic --- lib/tfw/yaml_fsm.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/tfw/yaml_fsm.py b/lib/tfw/yaml_fsm.py index 1c2e1ec..65d8719 100644 --- a/lib/tfw/yaml_fsm.py +++ b/lib/tfw/yaml_fsm.py @@ -11,10 +11,8 @@ from tfw import FSMBase class YamlFSM(FSMBase): def __init__(self, config_file): self.config = self.parse_config(config_file) - self.for_config_states_and_transitions_do(self.patch_config_callbacks) self.setup_states() super().__init__() # FSMBase.__init__() requires states - self.for_config_states_and_transitions_do(self.subscribe_and_remove_predicates) self.setup_transitions() @staticmethod @@ -23,9 +21,11 @@ class YamlFSM(FSMBase): return yaml.safe_load(ifile) def setup_states(self): + self.for_config_states_and_transitions_do(self.patch_config_callbacks) self.states = [State(**state) for state in self.config['states']] def setup_transitions(self): + self.for_config_states_and_transitions_do(self.subscribe_and_remove_predicates) for transition in self.config['transitions']: self.add_transition(**transition) From 7cfa63bacfa4d1c2820902dbd6a541be49235e5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krist=C3=B3f=20T=C3=B3th?= Date: Fri, 6 Jul 2018 12:31:25 +0100 Subject: [PATCH 23/27] Rename config transformation method for easier understanding --- lib/tfw/yaml_fsm.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/tfw/yaml_fsm.py b/lib/tfw/yaml_fsm.py index 65d8719..b887a9c 100644 --- a/lib/tfw/yaml_fsm.py +++ b/lib/tfw/yaml_fsm.py @@ -21,7 +21,7 @@ class YamlFSM(FSMBase): return yaml.safe_load(ifile) def setup_states(self): - self.for_config_states_and_transitions_do(self.patch_config_callbacks) + self.for_config_states_and_transitions_do(self.wrap_callbacks_with_subprocess_call) self.states = [State(**state) for state in self.config['states']] def setup_transitions(self): @@ -35,7 +35,7 @@ class YamlFSM(FSMBase): what(json_obj) @staticmethod - def patch_config_callbacks(json_obj): + def wrap_callbacks_with_subprocess_call(json_obj): topatch = ('on_enter', 'on_exit', 'prepare', 'before', 'after') for key in json_obj: if key in topatch: From 57d2475ebc6152685c53c0652ade8ca5cdbb7e0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krist=C3=B3f=20T=C3=B3th?= Date: Fri, 6 Jul 2018 15:40:27 +0100 Subject: [PATCH 24/27] Fix invalid trigger killing FSMBase --- lib/tfw/fsm_base.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/tfw/fsm_base.py b/lib/tfw/fsm_base.py index 155997e..b7f270d 100644 --- a/lib/tfw/fsm_base.py +++ b/lib/tfw/fsm_base.py @@ -3,7 +3,7 @@ from collections import defaultdict -from transitions import Machine +from transitions import Machine, MachineError from tfw.mixins import CallbackMixin from tfw.config.logs import logging @@ -61,5 +61,5 @@ class FSMBase(Machine, CallbackMixin): if all(predicate_results): try: self.trigger(trigger) - except AttributeError: + except (AttributeError, MachineError): LOG.debug('FSM failed to execute nonexistent trigger: "%s"', trigger) From 5262401b18ea3902bec4cf857f59791414aa7dd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krist=C3=B3f=20T=C3=B3th?= Date: Sat, 7 Jul 2018 21:30:24 +0200 Subject: [PATCH 25/27] Update README and explain TFW server behaviour better --- README.md | 27 +++++++++++++++++++++++++++ docs/tfw_architecture.png | Bin 40724 -> 46604 bytes 2 files changed, 27 insertions(+) diff --git a/README.md b/README.md index 5eff814..1fa2eee 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,20 @@ Frontend components use websockets to connect to the TFW server, to which you ca ![TFW architecture](docs/tfw_architecture.png) +### Networking details + +Event handlers connect to the TFW server using ZMQ. +They receive messages on their `SUB`(scribe) sockets, which are connected to the `PUB`(lish) socket of the server. +Event handlers reply on their `PUSH` socket, then their messages are received on the `PULL` socket of the server. + +The TFW server is basically just a fancy proxy. +It's behaviour is quite simple: it proxies every message received from the fontend to the event handlers and vice versa. + +The server is also capable of "mirroring" messages back to their source. +This is useful for communication between event handlers or frontend components (event handler to event handler or frontend component to frontend component communication). + +Components can also broadcast messages (broadcasted messages are received both by event handlers and the frontend as well). + ### Event handlers Imagine event handlers as callbacks that are invoked when TFW receives a specific type of message. For instance, you could send a message to the framework when the user does something of note. @@ -75,6 +89,19 @@ The TFW message format: - The `data` object can contain anything you might want to send - The `trigger` key is an optional field that triggers an FSM action with that name from the current state (whatever that might be) +To mirror messages back to their sources you can use a special messaging format, in which the message to be mirrored is enveloped inside the `data` field of the outer message: + +```text + "key": "mirror", + "data": + { + ... + The message you want to mirror (with it's own "key" and "data" fields) + ... + } +``` + +Broadcasting messages is possible in a similar manner by using `"key": "broadcast"` in the outer message. ## Where to go next diff --git a/docs/tfw_architecture.png b/docs/tfw_architecture.png index fa53dd6ad2246a1e6acfbf3968a80e6c29466434..854448fa2979bc7fd197251cda458f257f47956e 100644 GIT binary patch literal 46604 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 NrF=uFP{HEq{{Uv;t>FLw literal 40724 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 From f78f01d6e1d7f60d3a31741169ed0db57eb79816 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krist=C3=B3f=20T=C3=B3th?= Date: Tue, 10 Jul 2018 14:37:20 +0200 Subject: [PATCH 26/27] Provide full event handler API documentation --- README.md | 158 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 157 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 1fa2eee..71e7294 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,162 @@ Broadcasting messages is possible in a similar manner by using `"key": "broadcas Most of the components you need have docstrings included (hang on tight, this is work in progress) – refer to them for usage info. -In the `docs` folder you can find our Sphinx-based API documentation, which you can build using the `hack/tfw.sh` script in the [test-tutorial-framework](https://github.com/avatao-content/test-tutorial-framework) repository. +In the `docs` folder you can find our Sphinx-based documentation, which you can build using the `hack/tfw.sh` script in the [test-tutorial-framework](https://github.com/avatao-content/test-tutorial-framework) repository. To get started you should take a look at [test-tutorial-framework](https://github.com/avatao-content/test-tutorial-framework), which serves as an example project as well. + +## API + +APIs exposed by our pre-witten event handlers are documented here. + +### IdeEventHandler + +You can read the content of the currently selected file like so: +``` +{ + "key": "ide", + "data": + { + "command": "read" + } +} +``` + +Use the following message to overwrite the content of the currently selected file: +``` +{ + "key": "ide", + "data": + { + "command": "write", + "content": ...string... + } +} +``` + +To select a file use the following message: +``` +{ + "key": "ide", + "data": + { + "command": "select", + "filename": ...string... + } +} +``` + +You can switch to a new working directory using this message (note that the directory must be in `allowed_directories`): +``` +{ + "key": "ide", + "data": + { + "command": "selectdir", + "directory": ...string... + } +} +``` + +Overwriting the current list of excluded file patterns is possible with this message: +``` +{ + "key": "ide", + "data": + { + "command": "exclude", + "exclude": ...array of strings... + } +} +``` + +### TerminalEventHandler + +Writing to the terminal: +``` +{ + "key": "shell", + "data": + { + "command": "write", + "value": ...string... + } +} +``` + +You can read terminal command history like so: +``` +{ + "key": "shell", + "data": + { + "command": "read", + "count": ...number... + } +} +``` + +### ProcessManagingEventHandler + +Starting, stopping and restarting supervisor processes can be done using similar messages (where `command` is `start`, `stop` or `restart`): +``` +{ + "key": "processmanager", + "data": + { + "command": ...string..., + "process_name": ...string... + } +} +``` + +### LogMonitoringEventHandler + +To change which supervisor process is monitored use this message: +``` +{ + "key": "logmonitor", + "data" : + { + "command": "process_name", + "value": ...string... + } +} +``` + +To set the tail length of logs (the monitor will send back the last `value` characters of the log): +``` +{ + "key": "logmonitor", + "data" : + { + "command": "log_tail", + "value": ...number... + } +} +``` + +### FSMManagingEventHandler + +To attempt executing a trigger on the FSM use: +``` +{ + "key": "fsm", + "data" : + { + "command": "trigger", + "value": ...string... + } +} +``` + +To force the broadcasting of an FSM update you can use this message: +``` +{ + "key": "fsm", + "data" : + { + "command": "update" + } +} +``` From 0e8f5297267f935a47062f9a5f7ccd4a54ccb668 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krist=C3=B3f=20T=C3=B3th?= Date: Tue, 10 Jul 2018 15:40:10 +0200 Subject: [PATCH 27/27] Optimize FSMBase by using generators for predicate logic --- lib/tfw/fsm_base.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/tfw/fsm_base.py b/lib/tfw/fsm_base.py index b7f270d..28b5ef6 100644 --- a/lib/tfw/fsm_base.py +++ b/lib/tfw/fsm_base.py @@ -52,10 +52,10 @@ class FSMBase(Machine, CallbackMixin): ] def step(self, trigger): - predicate_results = [ + predicate_results = ( predicate() for predicate in self.trigger_predicates[trigger] - ] + ) # TODO: think about what could we do when this prevents triggering if all(predicate_results):