From 5a92c0ac3b979e418d114cccafda339984b1ac25 Mon Sep 17 00:00:00 2001
From: Fabio Manganiello <blacklight86@gmail.com>
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 f244e9976..9a0440ab3 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 63c688c6b..ed04ab43d 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 ea096245d..f2254c9ef 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 bca088d2f..a1547989f 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 81fd04917..59d5367c0 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 fb580e39b..f506bf37f 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 0f62aabaf..2eb174bf2 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 821d14ae2..439be420a 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 = [