- Major refactoring.

- More consistent naming for many methods, plus added a more extensive doc.

- Refactored the entry points for the daemon and the pusher into two
  classes, easier to encapsulate and wrap into tests.

- Removed the local backend - managing the concurrency of two processes
  reading and writing on the same socket at the same time was too much,
  and its utility outside of the tests (which can have mock backends as
  well) is quite modest.

- Managing stop events on the bus. Still some work to do tho.

- Fixed several bugs.
This commit is contained in:
Fabio Manganiello 2017-12-20 20:25:08 +01:00
parent 84e36a13e9
commit bd5c80175f
15 changed files with 569 additions and 290 deletions

View file

@ -35,10 +35,6 @@ Edit the file to include:
* The host and port of the Kafka installation * The host and port of the Kafka installation
* The topic that will be used to deliver and process messages * The topic that will be used to deliver and process messages
### For the local socket backend
* The name of the local FIFO that will be used to deliver and process messages
### device_id ### device_id
Each target device is identified by a unique device_id in the messages sent over your account. The device_id is the hostname by default, unless changed in config.yaml. Each target device is identified by a unique device_id in the messages sent over your account. The device_id is the hostname by default, unless changed in config.yaml.
@ -161,9 +157,9 @@ You can also write your own backends, where a backend is nothing but a thread th
5. The configuration for your module will be read from a section named `backend.voicemail` from your `config.yaml`. Its values will be passed over the backend constructor arguments. 5. The configuration for your module will be read from a section named `backend.voicemail` from your `config.yaml`. Its values will be passed over the backend constructor arguments.
6. Implement the `run` method. Since a backend is a thread that polls for new messages on a channel, this will be the thread main method. `_send_msg` should call `self.on_msg` at the end to post a new message to the application. 6. Implement the `run` method. Since a backend is a thread that polls for new messages on a channel, this will be the thread main method. `send_message` should call `self.on_message` at the end to post a new message to the application.
7. Implement the `_send_msg` method. This method will be called whenever the application needs to send a new message through `send_request` and `send_response`. You should never call `_send_msg` directly. 7. Implement the `send_message` method. This method will be called whenever the application needs to send a new message through `send_request` and `send_response`. You should never call `send_message` directly.
The `__init__.py` will look like this: The `__init__.py` will look like this:
@ -176,12 +172,12 @@ class VoicemailBackend(Backend)
self.phone = phone self.phone = phone
self.voicemail = Voicemail(...) self.voicemail = Voicemail(...)
def _send_msg(self, msg): def send_message(self, msg):
self.voicemail.save_msg(msg) self.voicemail.save_msg(msg)
def run(self): def run(self):
while True: while True:
msg = self.voicemail.poll() msg = self.voicemail.poll()
self.on_msg(msg) self.on_message(msg)
``` ```

View file

@ -1,3 +1,4 @@
import argparse
import logging import logging
import sys import sys
import traceback import traceback
@ -7,7 +8,8 @@ from getopt import getopt
from .bus import Bus from .bus import Bus
from .config import Config from .config import Config
from .utils import get_or_load_plugin, init_backends from .utils import get_or_load_plugin, init_backends, get_module_and_name_from_action
from .message.event import Event, StopEvent
from .message.request import Request from .message.request import Request
from .message.response import Response from .message.response import Response
@ -16,10 +18,86 @@ __version__ = '0.4'
#-----------# #-----------#
def _execute_request(request, retry=True): class Daemon(object):
tokens = request.action.split('.') """ Main class for the Platypush daemon """
module_name = str.join('.', tokens[:-1])
method_name = tokens[-1:][0] """ Configuration file (default: either ~/.config/platypush/config.yaml or
/etc/platypush/config.yaml) """
config_file = None
""" Application bus. It's an internal queue where:
- backends will post the messages they receive
- plugins will post the responses they process """
bus = None
""" backend_name => backend_obj map """
backends = None
""" number of executions retries before a request fails """
n_tries = 2
def __init__(self, config_file=None):
""" Constructor
Params:
config_file -- Configuration file override (default: None)
"""
self.config_file = config_file
Config.init(self.config_file)
logging.basicConfig(level=Config.get('logging'), stream=sys.stdout)
@classmethod
def build_from_cmdline(cls, args):
""" Build the app from command line arguments.
Params:
args -- Your sys.argv[1:] [List of strings]
"""
parser = argparse.ArgumentParser()
parser.add_argument('--config', '-c', dest='config', required=False,
default=None, help=cls.config_file.__doc__)
opts, args = parser.parse_known_args(args)
return cls(config_file=opts.config)
def on_message(self):
""" Default message handler """
def _f(msg):
""" on_message closure
Params:
msg -- platypush.message.Message instance """
if isinstance(msg, Request):
logging.info('Processing request: {}'.format(msg))
Thread(target=self.run_request(), args=(msg,)).start()
elif isinstance(msg, Response):
logging.info('Received response: {}'.format(msg))
return _f
def send_response(self, request, response):
""" Sends a response back.
Params:
request -- The platypush.message.request.Request object
response -- The platypush.message.response.Response object """
if request.backend and request.origin:
if request.id: response.id = request.id
response.target = request.origin
logging.info('Processing response: {}'.format(response))
request.backend.send_response(response)
else:
logging.info('Ignoring response as the request has no backend: '
.format(request))
def run_request(self):
""" Runs a request and returns the response """
def _thread_func(request, n_tries=self.n_tries):
""" Thread closure method
Params:
request - platypush.message.request.Request object """
(module_name, method_name) = get_module_and_name_from_action(request.action)
try: try:
plugin = get_or_load_plugin(module_name) plugin = get_or_load_plugin(module_name)
@ -28,60 +106,48 @@ def _execute_request(request, retry=True):
return return
try: try:
# Run the action
response = plugin.run(method=method_name, **request.args) response = plugin.run(method=method_name, **request.args)
if response and response.is_error(): if response and response.is_error():
logging.warn('Response processed with errors: {}'.format(response)) logging.warning('Response processed with errors: {}'.format(response))
except Exception as e: except Exception as e: # Retry mechanism
response = Response(output=None, errors=[str(e), traceback.format_exc()]) response = Response(output=None, errors=[str(e), traceback.format_exc()])
logging.exception(e) logging.exception(e)
if retry: if n_tries:
logging.info('Reloading plugin {} and retrying'.format(module_name)) logging.info('Reloading plugin {} and retrying'.format(module_name))
get_or_load_plugin(module_name, reload=True) get_or_load_plugin(module_name, reload=True)
_execute_request(request, retry=False) _thread_func(request, n_tries=n_tries-1)
finally: finally:
# Send the response on the backend that received the request # Send the response on the backend that received the request
if request.backend and request.origin: self.send_response(request, response)
if request.id: response.id = request.id
response.target = request.origin
logging.info('Processing response: {}'.format(response)) return _thread_func
request.backend.send_response(response)
def start(self):
""" Start the daemon """
self.bus = Bus(on_message=self.on_message())
def on_msg(msg): # Initialize the backends and link them to the bus
if isinstance(msg, Request): self.backends = init_backends(self.bus)
logging.info('Processing request: {}'.format(msg))
Thread(target=_execute_request, args=(msg,)).start()
elif isinstance(msg, Response):
logging.info('Received response: {}'.format(msg))
# Start the backend threads
def main(): for backend in self.backends.values():
print('Starting platypush v.{}'.format(__version__))
config_file = None
optlist, args = getopt(sys.argv[1:], 'vh')
for opt, arg in optlist:
if opt == '-c':
config_file = arg
elif opt == '-h':
print('''
Usage: {} [-h] [-c <config_file>]
-h Show this help
-c Path to the configuration file (default: ./config.yaml)
'''.format(sys.argv[0]))
return
Config.init(config_file)
logging.basicConfig(level=Config.get('logging'), stream=sys.stdout)
bus = Bus(on_msg=on_msg)
backends = init_backends(bus)
for backend in backends.values():
backend.start() backend.start()
bus.loop_forever() # Poll for messages on the bus
try:
self.bus.poll()
except KeyboardInterrupt as e:
logging.info('SIGINT received, terminating application')
for backend in self.backends.values():
backend.stop()
def main(args=sys.argv[1:]):
print('Starting platypush v.{}'.format(__version__))
app = Daemon.build_from_cmdline(args)
app.start()
if __name__ == '__main__': if __name__ == '__main__':
main() main()

View file

@ -1,12 +1,15 @@
import importlib import importlib
import logging import logging
import sys import sys
import threading
from threading import Thread from threading import Thread
from platypush.bus import Bus from platypush.bus import Bus
from platypush.config import Config from platypush.config import Config
from platypush.utils import get_message_class_by_type
from platypush.message import Message from platypush.message import Message
from platypush.message.event import Event, StopEvent
from platypush.message.request import Request from platypush.message.request import Request
from platypush.message.response import Response from platypush.message.response import Response
@ -25,43 +28,16 @@ class Backend(Thread):
# the received messages will be pushed # the received messages will be pushed
self.bus = bus if bus else Bus() self.bus = bus if bus else Bus()
self.device_id = Config.get('device_id') self.device_id = Config.get('device_id')
self.msgtypes = {} self.thread_id = None
self._stop = False
Thread.__init__(self) Thread.__init__(self)
logging.basicConfig(stream=sys.stdout, level=Config.get('logging') logging.basicConfig(stream=sys.stdout, level=Config.get('logging')
if 'logging' not in kwargs if 'logging' not in kwargs
else getattr(logging, kwargs['logging'])) else getattr(logging, kwargs['logging']))
def is_local(self):
""" Returns true if this is a local backend """
from platypush.backend.local import LocalBackend
return isinstance(self, LocalBackend)
def _get_msgtype_class(self, msgtype): def on_message(self, msg):
""" Gets the class of a message type """
if msgtype in self.msgtypes: return self.msgtypes[msgtype]
try:
module = importlib.import_module('platypush.message.' + msgtype)
except ModuleNotFoundError as e:
logging.warn('Unsupported message type {}'.format(msgtype))
raise RuntimeError(e)
cls_name = msgtype[0].upper() + msgtype[1:]
try:
msgclass = getattr(module, cls_name)
self.msgtypes[msgtype] = msgclass
except AttributeError as e:
logging.warn('No such class in {}: {}'.format(
module.__name__, cls_name))
raise RuntimeError(e)
return msgclass
def on_msg(self, msg):
""" """
Callback when a message is received on the backend. Callback when a message is received on the backend.
It parses and posts the message on the main bus. It parses and posts the message on the main bus.
@ -76,17 +52,25 @@ class Backend(Thread):
msg = Message.parse(msg) msg = Message.parse(msg)
if 'type' not in msg: if 'type' not in msg:
logging.warn('Ignoring message with no type: {}'.format(msg)) logging.warning('Ignoring message with no type: {}'.format(msg))
return return
msgtype = self._get_msgtype_class(msg['type']) msgtype = get_message_class_by_type(msg['type'])
msg = msgtype.build(msg) msg = msgtype.build(msg)
if not getattr(msg, 'target') or (msg.target != self.device_id and not self.is_local()): if not getattr(msg, 'target') or msg.target != self.device_id:
return # Not for me return # Not for me
logging.debug('Message received on the backend: {}'.format(msg)) logging.info('Message received on the {} backend: {}'.format(
self.__class__.__name__, msg))
msg.backend = self # Augment message msg.backend = self # Augment message
if isinstance(msg, StopEvent) and msg.targets_me():
logging.info('Received STOP event on the {} backend: {}'.format(
self.__class__.__name__, msg))
self._stop = True
else:
self.bus.post(msg) self.bus.post(msg)
def send_request(self, request): def send_request(self, request):
@ -101,7 +85,7 @@ class Backend(Thread):
assert isinstance(request, Request) assert isinstance(request, Request)
request.origin = self.device_id request.origin = self.device_id
self._send_msg(request) self.send_message(request)
def send_response(self, response): def send_response(self, response):
""" """
@ -115,27 +99,39 @@ class Backend(Thread):
assert isinstance(response, Response) assert isinstance(response, Response)
response.origin = self.device_id response.origin = self.device_id
self._send_msg(response) self.send_message(response)
def _send_msg(self, msg):
def send_message(self, msg):
""" """
Sends a platypush.message.Message to a node. Sends a platypush.message.Message to a node.
To be implemented in the derived classes. To be implemented in the derived classes.
Always call send_request or send_response instead of _send_msg directly Always call send_request or send_response instead of send_message directly
Param: Param:
msg -- The message msg -- The message
""" """
raise NotImplementedError("_send_msg should be implemented in a derived class") raise NotImplementedError("send_message should be implemented in a derived class")
def run(self): def run(self):
""" Starts the backend thread. To be implemented in the derived classes """ """ Starts the backend thread. To be implemented in the derived classes """
raise NotImplementedError("run should be implemented in a derived class") self.thread_id = threading.get_ident()
def on_stop(self):
""" Callback invoked when the process stops """
pass
def stop(self): def stop(self):
""" Stops the backend thread (default: do nothing) """ """ Stops the backend thread by sending a STOP event on its bus """
pass evt = StopEvent(target=self.device_id, origin=self.device_id,
thread_id=self.thread_id)
self.send_message(evt)
self.on_stop()
def should_stop(self):
return self._stop
# vim:sw=4:ts=4:et: # vim:sw=4:ts=4:et:

View file

@ -1,11 +1,14 @@
import logging import logging
import json import json
import time
from kafka import KafkaConsumer, KafkaProducer from kafka import KafkaConsumer, KafkaProducer
from .. import Backend from .. import Backend
class KafkaBackend(Backend): class KafkaBackend(Backend):
_conn_retry_secs = 5
def __init__(self, server, topic, **kwargs): def __init__(self, server, topic, **kwargs):
super().__init__(**kwargs) super().__init__(**kwargs)
@ -23,7 +26,7 @@ class KafkaBackend(Backend):
logging.exception(e) logging.exception(e)
logging.debug('Received message: {}'.format(msg)) logging.debug('Received message: {}'.format(msg))
self.on_msg(msg) self.on_message(msg)
def _init_producer(self): def _init_producer(self):
if not self.producer: if not self.producer:
@ -32,7 +35,7 @@ class KafkaBackend(Backend):
def _topic_by_device_id(self, device_id): def _topic_by_device_id(self, device_id):
return '{}.{}'.format(self.topic_prefix, device_id) return '{}.{}'.format(self.topic_prefix, device_id)
def _send_msg(self, msg): def send_message(self, msg):
target = msg.target target = msg.target
msg = str(msg).encode('utf-8') msg = str(msg).encode('utf-8')
@ -40,13 +43,32 @@ class KafkaBackend(Backend):
self.producer.send(self._topic_by_device_id(target), msg) self.producer.send(self._topic_by_device_id(target), msg)
self.producer.flush() self.producer.flush()
def on_stop(self):
try:
if self.producer:
self.producer.flush()
self.producer.close()
if self.consumer:
self.consumer.close()
except: pass
def run(self): def run(self):
super().run()
self.consumer = KafkaConsumer(self.topic, bootstrap_servers=self.server) self.consumer = KafkaConsumer(self.topic, bootstrap_servers=self.server)
logging.info('Initialized kafka backend - server: {}, topic: {}' logging.info('Initialized kafka backend - server: {}, topic: {}'
.format(self.server, self.topic)) .format(self.server, self.topic))
try:
for msg in self.consumer: for msg in self.consumer:
self._on_record(msg) self._on_record(msg)
if self.should_stop():
break
except ConnectionError:
logging.warning('Kafka connection error, retrying in {} seconds'.
format(self._conn_retry_secs))
time.sleep(self._conn_retry_secs)
# vim:sw=4:ts=4:et: # vim:sw=4:ts=4:et:

View file

@ -1,48 +0,0 @@
import logging
import json
import os
import time
from .. import Backend
class LocalBackend(Backend):
def __init__(self, fifo, **kwargs):
super().__init__(**kwargs)
self.fifo = fifo
def _send_msg(self, msg):
msglen = len(str(msg))+1 # Include \n
# Message format: b"<length>\n<message>\n"
msg = bytearray((str(msglen) + '\n' + str(msg) + '\n').encode('utf-8'))
with open(self.fifo, 'wb') as f:
f.write(msg)
def run(self):
try: os.mkfifo(self.fifo)
except FileExistsError as e: pass
logging.info('Initialized local backend on fifo {}'.format(self.fifo))
with open(self.fifo, 'rb', 0) as f:
while True:
try:
msglen = int(f.readline())
except ValueError as e:
time.sleep(0.1)
continue
msg = f.read(msglen-1)
if not msg: continue
try:
msg = json.loads(msg.decode('utf-8'))
except Exception as e:
logging.exception(e)
continue
logging.debug('Received message: {}'.format(msg))
self.on_msg(msg)
# vim:sw=4:ts=4:et:

View file

@ -20,6 +20,7 @@ class PushbulletBackend(Backend):
self._last_received_msg = { self._last_received_msg = {
'request' : { 'body': None, 'time': None }, 'request' : { 'body': None, 'time': None },
'response' : { 'body': None, 'time': None }, 'response' : { 'body': None, 'time': None },
'event' : { 'body': None, 'time': None },
} }
def _get_latest_push(self): def _get_latest_push(self):
@ -64,9 +65,9 @@ class PushbulletBackend(Backend):
return is_duplicate return is_duplicate
@staticmethod def on_push(self):
def _on_msg(backend):
def _f(ws, data): def _f(ws, data):
try:
try: try:
data = json.loads(data) if isinstance(data, str) else push data = json.loads(data) if isinstance(data, str) else push
except Exception as e: except Exception as e:
@ -74,41 +75,39 @@ class PushbulletBackend(Backend):
return return
if data['type'] == 'tickle' and data['subtype'] == 'push': if data['type'] == 'tickle' and data['subtype'] == 'push':
push = backend._get_latest_push() push = self._get_latest_push()
elif data['type'] == 'push': elif data['type'] == 'push':
push = data['push'] push = data['push']
else: else: return # Not a push notification
return # Not a push notification
logging.debug('Received push: {}'.format(push)) logging.debug('Received push: {}'.format(push))
if 'body' not in push: return
body = push['body'] body = push['body']
try: body = json.loads(body) try: body = json.loads(body)
except ValueError as e: return except ValueError as e: return # Some other non-JSON push
if not backend._should_skip_last_received_msg(body): if not self._should_skip_last_received_msg(body):
backend.on_msg(body) self.on_message(body)
except Exception as e:
logging.exception(e)
return
return _f return _f
@staticmethod def on_error(self):
def _on_error(backend):
def _f(ws, e): def _f(ws, e):
logging.exception(e) logging.exception(e)
logging.info('Restarting PushBullet backend') logging.info('Restarting PushBullet backend')
ws.close() ws.close()
backend._init_socket() self._init_socket()
return _f return _f
def _init_socket(self): def _init_socket(self):
self.ws = websocket.WebSocketApp( self.ws = websocket.WebSocketApp(
'wss://stream.pushbullet.com/websocket/' + self.token, 'wss://stream.pushbullet.com/websocket/' + self.token,
# on_message = self._on_msg, on_message = self.on_push(),
on_message = self._on_msg(self), on_error = self.on_error())
on_error = self._on_error(self))
def get_device_id(self): def get_device_id(self):
response = requests.get( response = requests.get(
@ -125,7 +124,7 @@ class PushbulletBackend(Backend):
return devices[0]['iden'] return devices[0]['iden']
def _send_msg(self, msg): def send_message(self, msg):
requests.post( requests.post(
u'https://api.pushbullet.com/v2/pushes', u'https://api.pushbullet.com/v2/pushes',
headers = { 'Access-Token': self.token }, headers = { 'Access-Token': self.token },
@ -136,7 +135,12 @@ class PushbulletBackend(Backend):
} }
).json() ).json()
def on_stop(self):
self.ws.close()
def run(self): def run(self):
super().run()
self._init_socket() self._init_socket()
logging.info('Initialized Pushbullet backend - device_id: {}' logging.info('Initialized Pushbullet backend - device_id: {}'
.format(self.device_name)) .format(self.device_name))

View file

@ -3,18 +3,17 @@ import sys
import signal import signal
import logging import logging
from enum import Enum
from queue import Queue from queue import Queue
from platypush.message.event import Event, StopEvent
class Bus(object): class Bus(object):
""" Main local bus where the daemon will listen for new messages """ """ Main local bus where the daemon will listen for new messages """
""" Number of seconds to wait for any pending threads def __init__(self, on_message=None):
before the process returns to the OS """
_kill_sec_timeout = 5
def __init__(self, on_msg=None):
self.bus = Queue() self.bus = Queue()
self.on_msg = on_msg self.on_message = on_message
def post(self, msg): def post(self, msg):
""" Sends a message to the bus """ """ Sends a message to the bus """
@ -24,25 +23,24 @@ class Bus(object):
""" Reads one message from the bus """ """ Reads one message from the bus """
return self.bus.get() return self.bus.get()
def loop_forever(self): def poll(self):
""" Reads messages from the bus until KeyboardInterrupt """ """
def _on_stop_timeout(signum, frame): Reads messages from the bus until either stop event message or KeyboardInterrupt
logging.warn('Stopping all the active threads after waiting for ' + """
'{} seconds'.format(self._kill_sec_timeout))
os._exit(1)
if not self.on_msg: return if not self.on_message:
logging.warning('No message handlers installed, cannot poll')
return
while True: stop=False
try: while not stop:
self.on_msg(self.get()) msg = self.get()
except KeyboardInterrupt: self.on_message(msg)
logging.info('Received keyboard interrupt ' +
'- terminating application') if isinstance(msg, StopEvent) and msg.targets_me():
logging.info('Received STOP event')
stop=True
signal.signal(signal.SIGALRM, _on_stop_timeout)
signal.alarm(self._kill_sec_timeout)
sys.exit(0)
# vim:sw=4:ts=4:et: # vim:sw=4:ts=4:et:

View file

@ -11,9 +11,6 @@ backend.pushbullet:
token: your_pushbullet_token_here token: your_pushbullet_token_here
device: your_pushbullet_virtual_device_name device: your_pushbullet_virtual_device_name
backend.local:
fifo: /tmp/platypush.fifo
# device_id: <your_device_id> (default: current hostname) # device_id: <your_device_id> (default: current hostname)
# debug: True (default: False) # debug: True (default: False)

View file

@ -38,7 +38,7 @@ class Message(object):
if isinstance(msg, bytes) or isinstance(msg, bytearray): if isinstance(msg, bytes) or isinstance(msg, bytearray):
msg = msg.decode('utf-8') msg = msg.decode('utf-8')
if isinstance(msg, str): if isinstance(msg, str):
msg = json.loads(msg) msg = json.loads(msg.strip())
assert isinstance(msg, dict) assert isinstance(msg, dict)
return msg return msg

View file

@ -0,0 +1,112 @@
import json
import random
import threading
from enum import Enum
from platypush.message import Message
class Event(Message):
""" Event message class """
def __init__(self, target, type, origin, id=None, **kwargs):
"""
Params:
target -- Target node [String]
type -- Event type [EventType]
origin -- Origin node (default: current node) [String]
id -- Event ID (default: auto-generated)
kwargs -- Additional arguments for the event [kwDict]
"""
self.id = id if id else self._generate_id()
self.target = target
self.origin = origin
self.type = type
self.args = kwargs
@classmethod
def build(cls, msg):
""" Builds an event message from a JSON UTF-8 string/bytearray, a
dictionary, or another Event """
msg = super().parse(msg)
event_type = msg['args'].pop('type')
event_class = getattr(EventType, event_type).cls
args = {
'target' : msg['target'],
'origin' : msg['origin'],
**(msg['args'] if 'args' in msg else {}),
}
args['id'] = msg['id'] if 'id' in msg else cls._generate_id()
return event_class(**args)
@staticmethod
def _generate_id():
""" Generate a unique event ID """
id = ''
for i in range(0,16):
id += '%.2x' % random.randint(0, 255)
return id
def __str__(self):
"""
Overrides the str() operator and converts
the message into a UTF-8 JSON string
"""
return json.dumps({
'type' : 'event',
'target' : self.target,
'origin' : self.origin if hasattr(self, 'origin') else None,
'id' : self.id if hasattr(self, 'id') else None,
'args' : {
'type' : self.type.name,
**self.args,
},
})
class StopEvent(Event):
""" StopEvent message. When received on a Bus, it will terminate the
listening thread having the specified ID. Useful to keep listeners in
sync and make them quit when the application terminates """
def __init__(self, target, origin, thread_id, id=None, **kwargs):
""" Constructor.
Params:
target -- Target node
origin -- Origin node
thread_id -- thread_iden() to be terminated if listening on the bus
id -- Event ID (default: auto-generated)
kwargs -- Extra key-value arguments
"""
super().__init__(target=target, origin=origin, id=id,
type=EventType.STOP, thread_id=thread_id, **kwargs)
def targets_me(self):
""" Returns true if the stop event is for the current thread """
return self.args['thread_id'] == threading.get_ident()
class EventType(Enum):
""" Event types enum """
def __new__(cls, *args, **kwds):
value = len(cls.__members__) + 1
obj = object.__new__(cls)
obj._value_ = value
return obj
def __init__(self, label, cls):
self.label = label
self.cls = cls
STOP = 'STOP', StopEvent
# vim:sw=4:ts=4:et:

View file

@ -33,7 +33,7 @@ class Request(Message):
args['id'] = msg['id'] if 'id' in msg else cls._generate_id() args['id'] = msg['id'] if 'id' in msg else cls._generate_id()
if 'origin' in msg: args['origin'] = msg['origin'] if 'origin' in msg: args['origin'] = msg['origin']
return Request(**args) return cls(**args)
@staticmethod @staticmethod
def _generate_id(): def _generate_id():

View file

@ -36,7 +36,7 @@ class Response(Message):
if 'id' in msg: args['id'] = msg['id'] if 'id' in msg: args['id'] = msg['id']
if 'origin' in msg: args['origin'] = msg['origin'] if 'origin' in msg: args['origin'] = msg['origin']
return Response(**args) return cls(**args)
def __str__(self): def __str__(self):

View file

@ -7,15 +7,15 @@ from .. import Plugin
class ShellPlugin(Plugin): class ShellPlugin(Plugin):
def exec(self, cmd): def exec(self, cmd):
output = None output = None
error = None errors = []
try: try:
output = subprocess.check_output( output = subprocess.check_output(
cmd, stderr=subprocess.STDOUT, shell=True).decode('utf-8') cmd, stderr=subprocess.STDOUT, shell=True).decode('utf-8')
except subprocess.CalledProcessError as e: except subprocess.CalledProcessError as e:
error = e.output.decode('utf-8') errors = [e.output.decode('utf-8')]
return Response(output=output, errors=[error]) return Response(output=output, errors=errors)
# vim:sw=4:ts=4:et: # vim:sw=4:ts=4:et:

View file

@ -1,63 +1,63 @@
import argparse import argparse
import os import os
import re import re
import signal
import sys import sys
from platypush.bus import Bus from platypush.bus import Bus
from platypush.config import Config from platypush.config import Config
from platypush.message.request import Request from platypush.message.request import Request
from platypush.message.response import Response from platypush.message.response import Response
from platypush.utils import init_backends from platypush.utils import init_backends, set_timeout, clear_timeout
_DEFAULT_TIMEOUT_SEC=5 class Pusher(object):
"""
Main class to send messages and events to a node
"""
def pusher(target, action, backend=None, config=None, """ Configuration file path """
timeout=_DEFAULT_TIMEOUT_SEC, **kwargs): config_file = None
def on_timeout(signum, frame):
raise RuntimeError('Response timed out after {} seconds'.format(
timeout))
os._exit(0)
Config.init(config) """ Default backend name """
backend = None
if target == 'localhost': """ Pusher local bus. The response will be processed here """
backend = 'local' bus = None
elif not backend:
backend = Config.get_default_pusher_backend()
req = Request.build({ """ Configured backends as a name => object map """
'target' : target, backends = {}
'action' : action,
'args' : kwargs,
})
bus = Bus() """ Default response_wait timeout """
backends = init_backends(bus=bus) default_response_wait_timeout = 5
if backend not in backends:
raise RuntimeError('No such backend configured: {}'.format(backend))
b = backends[backend]
b.start()
b.send_request(req)
if timeout:
signal.signal(signal.SIGALRM, on_timeout)
signal.alarm(timeout)
response_received = False
while not response_received:
msg = bus.get()
response_received = isinstance(msg, Response) and (
hasattr(msg, 'id') and msg.id == req.id)
signal.alarm(0)
print(msg)
os._exit(0)
def main(): def __init__(self, config_file=None, backend=None, on_response=None):
"""
Constructor.
Params:
config_file -- Path to the configuration file - default:
~/.config/platypush/config.yaml or
/etc/platypush/config.yaml)
backend -- Name of the backend where pusher will send the
request and wait for the response (kafka
or pushbullet). Default: whatever is specified
with pusher=true in your configuration file
on_response -- Method that will be invoked upon response receipt.
Takes a platypush.message.response.Response as arg.
Default: print the response and exit.
"""
# Initialize the configuration
self.config_file = config_file
Config.init(config_file)
self.on_response = on_response or self.default_on_response()
self.backend = backend or Config.get_default_pusher_backend()
self.bus = Bus()
@classmethod
def parse_build_args(cls, args):
""" Parse the recognized options from a list of cmdline arguments """
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument('--config', '-c', dest='config', required=False, parser.add_argument('--config', '-c', dest='config', required=False,
default=None, help="Configuration file path (default: " + default=None, help="Configuration file path (default: " +
@ -72,29 +72,109 @@ def main():
parser.add_argument('--backend', '-b', dest='backend', required=False, parser.add_argument('--backend', '-b', dest='backend', required=False,
default=None, help="Backend to deliver the message " + default=None, help="Backend to deliver the message " +
"[pushbullet|kafka|local] (default: whatever " + "[pushbullet|kafka] (default: whatever " +
"specified in your config with pusher=True)") "specified in your config with pusher=True)")
parser.add_argument('--timeout', '-T', dest='timeout', required=False, parser.add_argument('--timeout', '-T', dest='timeout', required=False,
default=_DEFAULT_TIMEOUT_SEC, help="The application " + default=cls.default_response_wait_timeout, help="The application " +
"will wait for a response for this number of seconds " + "will wait for a response for this number of seconds " +
"(default: " + str(_DEFAULT_TIMEOUT_SEC) + " seconds. " "(default: " + str(cls.default_response_wait_timeout) + " seconds. "
"A zero value means that the application " + "A zero value means that the application " +
" will exit without waiting for a response)") " will exit without waiting for a response)")
opts, args = parser.parse_known_args(sys.argv[1:]) opts, args = parser.parse_known_args(args)
if len(args) % 2 != 0: if len(args) % 2 != 0:
raise RuntimeError('Odd number of key-value options passed: {}'. raise RuntimeError('Odd number of key-value options passed: {}'.format(args))
format(args))
payload = {} opts.args = {}
for i in range(0, len(args), 2): for i in range(0, len(args), 2):
payload[re.sub('^-+', '', args[i])] = args[i+1] opts.args[re.sub('^-+', '', args[i])] = args[i+1]
pusher(target=opts.target, action=opts.action, return opts
backend=opts.backend, config=opts.config, timeout=opts.timeout,
**payload) def get_backend(self, name):
# Lazy init
if not self.backends: self.backends = init_backends(bus=self.bus)
if name not in self.backends:
raise RuntimeError('No such backend configured: {}'.format(name))
return self.backends[name]
def on_timeout(self):
""" Default response timeout handle: raise RuntimeError """
def _f():
raise RuntimeError('Response timed out')
return _f
def default_on_response(self):
def _f(response):
print('Received response: {}'.format(response))
os._exit(0)
return _f
def response_wait(self, request, timeout):
# Install the timeout handler
set_timeout(seconds=timeout, on_timeout=self.on_timeout())
# Loop on the bus until you get a response for your request ID
response_received = False
while not response_received:
msg = self.bus.get()
response_received = (
isinstance(msg, Response) and
hasattr(msg, 'id') and
msg.id == request.id)
if timeout: clear_timeout()
self.on_response(msg)
def push(self, target, action, backend=None, config_file=None,
timeout=default_response_wait_timeout, **kwargs):
"""
Sends a message on a backend and optionally waits for an answer.
Params:
target -- Target node
action -- Action to be executed in the form plugin.path.method
(e.g. shell.exec or music.mpd.play)
backend -- Name of the backend that will process the request and get
the response (e.g. 'pushbullet' or 'kafka') (default: whichever
backend marked as pusher=true in your config.yaml)
timeout -- Response receive timeout in seconds
- Pusher Default: 5 seconds
- If timeout == 0 or None: Pusher exits without waiting for a response
config_file -- Path to the configuration file to be used (default:
~/.config/platypush/config.yaml or
/etc/platypush/config.yaml)
**kwargs -- Optional key-valued arguments for the action method
(e.g. cmd='echo ping' or groups="['Living Room']")
"""
def _timeout_hndl(signum, frame):
""" Default response timeout handle: raise RuntimeError and exit """
if not backend: backend = self.backend
req = Request.build({
'target' : target,
'action' : action,
'args' : kwargs,
})
b = self.get_backend(backend)
b.start()
b.send_request(req)
if timeout: self.response_wait(request=req, timeout=timeout)
def main(args=sys.argv[1:]):
opts = Pusher.parse_build_args(args)
pusher = Pusher(config_file=opts.config, backend=opts.backend)
pusher.push(opts.target, action=opts.action, timeout=opts.timeout,
**opts.args)
if __name__ == '__main__': if __name__ == '__main__':

View file

@ -1,8 +1,11 @@
import functools import functools
import importlib import importlib
import logging import logging
import signal
import time
from platypush.config import Config from platypush.config import Config
from platypush.message import Message
modules = {} modules = {}
@ -15,7 +18,7 @@ def get_or_load_plugin(plugin_name, reload=False):
try: try:
module = importlib.import_module('platypush.plugins.' + plugin_name) module = importlib.import_module('platypush.plugins.' + plugin_name)
except ModuleNotFoundError as e: except ModuleNotFoundError as e:
logging.warn('No such plugin: {}'.format(plugin_name)) logging.warning('No such plugin: {}'.format(plugin_name))
raise RuntimeError(e) raise RuntimeError(e)
# e.g. plugins.music.mpd main class: MusicMpdPlugin # e.g. plugins.music.mpd main class: MusicMpdPlugin
@ -28,10 +31,11 @@ def get_or_load_plugin(plugin_name, reload=False):
if plugin_name in Config.get_plugins() else {} if plugin_name in Config.get_plugins() else {}
try: try:
plugin = getattr(module, cls_name)(**plugin_conf) plugin_class = getattr(module, cls_name)
plugin = plugin_class(**plugin_conf)
modules[plugin_name] = plugin modules[plugin_name] = plugin
except AttributeError as e: except AttributeError as e:
logging.warn('No such class in {}: {}'.format( logging.warning('No such class in {}: {}'.format(
plugin_name, cls_name)) plugin_name, cls_name))
raise RuntimeError(e) raise RuntimeError(e)
@ -55,12 +59,64 @@ def init_backends(bus=None):
b = getattr(module, cls_name)(bus=bus, **cfg) b = getattr(module, cls_name)(bus=bus, **cfg)
backends[k] = b backends[k] = b
except AttributeError as e: except AttributeError as e:
logging.warn('No such class in {}: {}'.format( logging.warning('No such class in {}: {}'.format(
module.__name__, cls_name)) module.__name__, cls_name))
raise RuntimeError(e) raise RuntimeError(e)
return backends return backends
def get_module_and_name_from_action(action):
""" Input : action=music.mpd.play
Output : ('music.mpd', 'play') """
tokens = action.split('.')
module_name = str.join('.', tokens[:-1])
method_name = tokens[-1:][0]
return (module_name, method_name)
def get_message_class_by_type(msgtype):
""" Gets the class of a message type given as string """
try:
module = importlib.import_module('platypush.message.' + msgtype)
except ModuleNotFoundError as e:
logging.warning('Unsupported message type {}'.format(msgtype))
raise RuntimeError(e)
cls_name = msgtype[0].upper() + msgtype[1:]
try:
msgclass = getattr(module, cls_name)
except AttributeError as e:
logging.warning('No such class in {}: {}'.format(
module.__name__, cls_name))
raise RuntimeError(e)
return msgclass
def set_timeout(seconds, on_timeout):
"""
Set a function to be called if timeout expires without being cleared.
It only works on the main thread.
Params:
seconds -- Timeout in seconds
on_timeout -- Function invoked on timeout unless clear_timeout is called before
"""
def _sighandler(signum, frame):
on_timeout()
signal.signal(signal.SIGALRM, _sighandler)
signal.alarm(seconds)
def clear_timeout():
""" Clear any previously set timeout """
signal.alarm(0)
# vim:sw=4:ts=4:et: # vim:sw=4:ts=4:et: