From 5a92c0ac3b979e418d114cccafda339984b1ac25 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Tue, 2 Jan 2018 00:35:55 +0100 Subject: [PATCH] - Proper support for event arguments - Better algorithm for event matching - Added send_event support to pusher --- platypush/__init__.py | 8 -- platypush/event/hook.py | 29 +++- platypush/event/processor/__init__.py | 5 +- platypush/message/event/__init__.py | 136 ++++++++++++++---- platypush/message/event/assistant/__init__.py | 32 +---- platypush/message/request/__init__.py | 4 +- platypush/pusher/__init__.py | 57 +++++--- setup.py | 4 +- 8 files changed, 179 insertions(+), 96 deletions(-) diff --git a/platypush/__init__.py b/platypush/__init__.py index f244e99766..9a0440ab39 100644 --- a/platypush/__init__.py +++ b/platypush/__init__.py @@ -127,13 +127,5 @@ class Daemon(object): self.stop_app() -def main(args=sys.argv[1:]): - print('Starting platypush v.{}'.format(__version__)) - app = Daemon.build_from_cmdline(args) - app.start() - -if __name__ == '__main__': - main() - # vim:sw=4:ts=4:et: diff --git a/platypush/event/hook.py b/platypush/event/hook.py index 63c688c6bb..ed04ab43d8 100644 --- a/platypush/event/hook.py +++ b/platypush/event/hook.py @@ -1,8 +1,9 @@ import json import logging +import re from platypush.config import Config -from platypush.message.event import Event +from platypush.message.event import Event, EventMatchResult from platypush.message.request import Request from platypush.utils import get_event_class_by_type @@ -73,11 +74,31 @@ class EventAction(Request): def execute(self, **context): - for (key, value) in context.items(): - self.args[key] = value + event_args = context.pop('event').args if 'event' in context else {} + + for (argname, value) in self.args.items(): + if isinstance(value, str): + parsed_value = '' + while value: + m = re.match('([^\\\]*)\$([\w\d_-]+)(.*)', value) + if m: + context_argname = m.group(2) + value = m.group(3) + if context_argname in context: + parsed_value += m.group(1) + context[context_argname] + else: + parsed_value += m.group(1) + '$' + m.group(2) + else: + parsed_value += value + value = '' + + value = parsed_value + + self.args[argname] = value super().execute() + @classmethod def build(cls, action): action = super().parse(action) @@ -137,7 +158,7 @@ class EventHook(object): logging.info('Running hook {} triggered by an event'.format(self.name)) for action in self.actions: - action.execute(**result.parsed_args) + action.execute(event=event, **result.parsed_args) # vim:sw=4:ts=4:et: diff --git a/platypush/event/processor/__init__.py b/platypush/event/processor/__init__.py index ea096245d3..f2254c9efb 100644 --- a/platypush/event/processor/__init__.py +++ b/platypush/event/processor/__init__.py @@ -1,4 +1,5 @@ import logging +import sys from ..hook import EventHook @@ -22,10 +23,10 @@ class EventProcessor(object): def process_event(self, event): - """ Processes an event and runs any matched hooks """ + """ Processes an event and runs the matched hooks with the highest score """ matched_hooks = [] - max_score = 0 + max_score = -sys.maxsize for hook in self.hooks: match = hook.matches_event(event) diff --git a/platypush/message/event/__init__.py b/platypush/message/event/__init__.py index bca088d2f6..a1547989f8 100644 --- a/platypush/message/event/__init__.py +++ b/platypush/message/event/__init__.py @@ -1,5 +1,6 @@ import json import random +import re import threading from platypush.config import Config @@ -34,36 +35,12 @@ class Event(Message): event_type = msg['args'].pop('type') event_class = get_event_class_by_type(event_type) - args = { - 'target' : msg['target'], - 'origin' : msg['origin'], - **(msg['args'] if 'args' in msg else {}), - } - + args = msg['args'] if 'args' in msg else {} args['id'] = msg['id'] if 'id' in msg else cls._generate_id() + args['target'] = msg['target'] if 'target' in msg else Config.get('device_id') + args['origin'] = msg['origin'] if 'origin' in msg else Config.get('device_id') return event_class(**args) - def matches_condition(self, condition): - """ - If the event matches an event condition, it will return an EventMatchResult - Params: - -- condition -- The platypush.event.hook.EventCondition object - """ - - result = EventMatchResult(is_match=False) - if not isinstance(self, condition.type): return result - - for (attr, value) in condition.args.items(): - if not hasattr(self.args, attr): - return result - if isinstance(self.args[attr], str) and not value in self.args[attr]: - return result - elif self.args[attr] != value: - return result - - result.is_match = True - return result - @staticmethod def _generate_id(): @@ -73,6 +50,107 @@ class Event(Message): id += '%.2x' % random.randint(0, 255) return id + + def matches_condition(self, condition): + """ + If the event matches an event condition, it will return an EventMatchResult + Params: + -- condition -- The platypush.event.hook.EventCondition object + """ + + result = EventMatchResult(is_match=False, parsed_args=self.args) + match_scores = [] + + if not isinstance(self, condition.type): return result + + for (attr, value) in condition.args.items(): + if attr not in self.args: + return result + + if isinstance(self.args[attr], str): + arg_result = self._matches_argument(argname=attr, condition_value=value) + + if arg_result.is_match: + match_scores.append(arg_result.score) + for (parsed_arg, parsed_value) in arg_result.parsed_args.items(): + result.parsed_args[parsed_arg] = parsed_value + else: + return result + elif self.args[attr] != value: + # TODO proper support for list and dictionary matches + return result + + result.is_match = True + if match_scores: + result.score = sum(match_scores) / float(len(match_scores)) + + return result + + + def _matches_argument(self, argname, condition_value): + """ + Returns an EventMatchResult if the event argument [argname] matches + [condition_value]. + + - Example: + - self.args = { + 'phrase': 'Hey dude turn on the living room lights' + } + + - self._matches_argument(argname='phrase', condition_value='Turn on the $lights lights') + will return EventMatchResult(is_match=True, parsed_args={ 'lights': 'living room' }) + + - self._matches_argument(argname='phrase', condition_value='Turn off the $lights lights') + will return EventMatchResult(is_match=False, parsed_args={}) + """ + + result = EventMatchResult(is_match=False) + event_tokens = re.split('\s+', self.args[argname].strip().lower()) + condition_tokens = re.split('\s+', condition_value.strip().lower()) + + while event_tokens and condition_tokens: + event_token = event_tokens[0] + condition_token = condition_tokens[0] + + if event_token == condition_token: + event_tokens.pop(0) + condition_tokens.pop(0) + result.score += 1 + elif re.search(condition_token, event_token): + # The only supported regex-match as of now is the equivalent of + # the maybe operator. + # e.g. "turn on (the)? lights" would match both "turn on the lights" + # and "turn on lights". In such a case, we just consume the + # condition token and proceed forward. TODO add a more + # sophisticated regex-match handling + condition_tokens.pop(0) + else: + m = re.match('[^\\\]*\$([\w\d_-]+)', condition_token) + if m: + argname = m.group(1) + if argname not in result.parsed_args: + result.parsed_args[argname] = event_token + result.score += 1 + else: + result.parsed_args[argname] += ' ' + event_token + + + if len(event_tokens) > 1 and len(condition_tokens) > 1 \ + and event_tokens[1] == condition_tokens[1]: + # Stop appending tokens to this argument, as the next + # condition will be satisfied as well + condition_tokens.pop(0) + + event_tokens.pop(0) + else: + result.score -= 1 + event_tokens.pop(0) + + # It's a match if all the tokens in the condition string have been satisfied + result.is_match = len(condition_tokens) == 0 + return result + + def __str__(self): """ Overrides the str() operator and converts @@ -98,10 +176,10 @@ class EventMatchResult(object): the match is - in case of multiple event matches, the ones with the highest score will win """ - def __init__(self, is_match, score=1, parsed_args = {}): + def __init__(self, is_match, score=0, parsed_args=None): self.is_match = is_match self.score = score - self.parsed_args = parsed_args + self.parsed_args = {} if not parsed_args else parsed_args # XXX Should be a stop Request, not an Event diff --git a/platypush/message/event/assistant/__init__.py b/platypush/message/event/assistant/__init__.py index 81fd049170..59d5367c04 100644 --- a/platypush/message/event/assistant/__init__.py +++ b/platypush/message/event/assistant/__init__.py @@ -27,35 +27,9 @@ class SpeechRecognizedEvent(AssistantEvent): self.recognized_phrase = phrase.strip().lower() def matches_condition(self, condition): - result = EventMatchResult(is_match=False, score=0) - - if not isinstance(self, condition.type): return result - - recognized_tokens = re.split('\s+', self.recognized_phrase.strip().lower()) - condition_tokens = re.split('\s+', condition.args['phrase'].strip().lower()) - - while recognized_tokens and condition_tokens: - rec_token = recognized_tokens[0] - cond_token = condition_tokens[0] - - if rec_token == cond_token: - recognized_tokens.pop(0) - condition_tokens.pop(0) - result.score += 1 - elif re.search(cond_token, rec_token): - condition_tokens.pop(0) - else: - m = re.match('^\$([\w\d_])', cond_token) - if m: - result.parsed_args[cond_token[1:]] = rec_token - recognized_tokens.pop(0) - condition_tokens.pop(0) - result.score += 1 - else: - recognized_tokens.pop(0) - - result.is_match = len(condition_tokens) == 0 - if result.is_match and self._assistant: self._assistant.stop_conversation() + result = super().matches_condition(condition) + if result.is_match and self._assistant: + self._assistant.stop_conversation() return result diff --git a/platypush/message/request/__init__.py b/platypush/message/request/__init__.py index fb580e39b5..f506bf37f3 100644 --- a/platypush/message/request/__init__.py +++ b/platypush/message/request/__init__.py @@ -13,7 +13,7 @@ from platypush.utils import get_module_and_method_from_action class Request(Message): """ Request message class """ - def __init__(self, target, action, origin=None, id=None, backend=None, args={}): + def __init__(self, target, action, origin=None, id=None, backend=None, args=None): """ Params: target -- Target node [String] @@ -28,7 +28,7 @@ class Request(Message): self.target = target self.action = action self.origin = origin - self.args = args + self.args = args if args else {} self.backend = backend @classmethod diff --git a/platypush/pusher/__init__.py b/platypush/pusher/__init__.py index 0f62aabaf7..2eb174bf25 100644 --- a/platypush/pusher/__init__.py +++ b/platypush/pusher/__init__.py @@ -65,11 +65,21 @@ class Pusher(object): "~/.config/platypush/config.yaml or " + "/etc/platypush/config.yaml") - parser.add_argument('--target', '-t', dest='target', required=True, + parser.add_argument('--type', '-p', dest='type', required=False, + default='request', help="Type of message to send, request or event") + + parser.add_argument('--target', '-t', dest='target', required=False, + default=Config.get('device_id'), help="Destination of the command") - parser.add_argument('--action', '-a', dest='action', required=True, - help="Action to execute, as package.method") + parser.add_argument('--action', '-a', dest='action', required=False, + default=None, help="Action to execute, as " + + "package.method (e.g. music.mpd.play), if this is a request") + + parser.add_argument('--event', '-e', dest='event', required=False, + default=None, help="Event type, as full " + + "package.class (e.g. " + + "platypush.message.event.ping.PingEvent), if this is an event") parser.add_argument('--backend', '-b', dest='backend', required=False, default=None, help="Backend to deliver the message " + @@ -86,8 +96,17 @@ class Pusher(object): opts, args = parser.parse_known_args(args) if len(args) % 2 != 0: + parser.print_help() raise RuntimeError('Odd number of key-value options passed: {}'.format(args)) + if opts.type == 'request' and not opts.action: + parser.print_help() + raise RuntimeError('No action provided for the request'.format(args)) + + if opts.type == 'event' and not opts.event: + parser.print_help() + raise RuntimeError('No type provided for the event'.format(args)) + opts.args = {} for i in range(0, len(args), 2): opts.args[re.sub('^-+', '', args[i])] = args[i+1] @@ -109,7 +128,21 @@ class Pusher(object): # self.backend_instance.stop() return _f - def push(self, target, action, backend=None, config_file=None, + def send_event(self, target=Config.get('device_id'), + type='platypush.message.event.Event', backend=None, **kwargs): + if not backend: backend = self.backend + + self.backend_instance = self.get_backend(backend) + self.backend_instance.send_event({ + 'target': target, + 'args': { + 'type': type, + **kwargs + } + }) + + + def push(self, target, action, backend=None, timeout=default_response_wait_timeout, **kwargs): """ Sends a message on a backend and optionally waits for an answer. @@ -123,9 +156,6 @@ class Pusher(object): 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']") """ @@ -146,18 +176,5 @@ class Pusher(object): response_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__': - main() - - # vim:sw=4:ts=4:et: diff --git a/setup.py b/setup.py index 821d14ae2b..439be420a6 100755 --- a/setup.py +++ b/setup.py @@ -43,8 +43,8 @@ setup( packages = find_packages(), entry_points = { 'console_scripts': [ - 'platypush=platypush:main', - 'pusher=platypush.pusher:main', + 'platypush=platypush.__main__:main', + 'pusher=platypush.pusher.__main__:main', ], }, data_files = [