forked from platypush/platypush
Merge pull request '[#253] Support for relational filters on event hooks' (#254) from 253-support-for-relational-filters-on-event-hooks into master
Reviewed-on: platypush/platypush#254 Closes: #253
This commit is contained in:
commit
38262e245e
2 changed files with 161 additions and 20 deletions
|
@ -2,9 +2,12 @@ import copy
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import random
|
import random
|
||||||
|
import re
|
||||||
import time
|
import time
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
from datetime import date
|
from datetime import date
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from platypush.config import Config
|
from platypush.config import Config
|
||||||
from platypush.message import Message
|
from platypush.message import Message
|
||||||
|
@ -90,6 +93,70 @@ class Event(Message):
|
||||||
"""Generate a unique event ID"""
|
"""Generate a unique event ID"""
|
||||||
return ''.join([f'{random.randint(0, 255):02x}' for _ in range(16)])
|
return ''.join([f'{random.randint(0, 255):02x}' for _ in range(16)])
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_relational_filter(filter: dict) -> bool:
|
||||||
|
"""
|
||||||
|
Check if a condition is a relational filter.
|
||||||
|
|
||||||
|
For a condition to be a relational filter, it must have at least one
|
||||||
|
key starting with `$`.
|
||||||
|
"""
|
||||||
|
if not isinstance(filter, dict):
|
||||||
|
return False
|
||||||
|
return any(key.startswith('$') for key in filter)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def __relational_filter_matches(filter: dict, value: Any) -> bool:
|
||||||
|
"""
|
||||||
|
Return True if the conditions in the filter match the given event
|
||||||
|
arguments.
|
||||||
|
"""
|
||||||
|
for op, filter_val in filter.items():
|
||||||
|
comparator = _event_filter_operators.get(op)
|
||||||
|
assert comparator, f'Invalid operator: {op}'
|
||||||
|
|
||||||
|
# If this is a numeric or string filter, and one of the two values
|
||||||
|
# is null, return False - it doesn't make sense to run numeric or
|
||||||
|
# string comparison with null values.
|
||||||
|
if (op in _numeric_filter_operators or op in _string_filter_operators) and (
|
||||||
|
filter_val is None or value is None
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
|
||||||
|
# If this is a numeric-only or string-only filter, then the
|
||||||
|
# operands' types should be consistent with the operator.
|
||||||
|
if op in _numeric_filter_operators:
|
||||||
|
try:
|
||||||
|
value = float(value)
|
||||||
|
filter_val = float(filter_val)
|
||||||
|
except (ValueError, TypeError) as e:
|
||||||
|
raise AssertionError(
|
||||||
|
f'Could not convert either "{value}" nor "{filter_val} to a number'
|
||||||
|
) from e
|
||||||
|
elif op in _string_filter_operators:
|
||||||
|
assert isinstance(filter_val, str) and isinstance(value, str), (
|
||||||
|
f'Expected two strings, got "{filter_val}" '
|
||||||
|
f'({type(filter_val)}) and "{value}" ({type(value)})'
|
||||||
|
)
|
||||||
|
|
||||||
|
if not comparator(value, filter_val):
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _relational_filter_matches(cls, filter: dict, value: Any) -> bool:
|
||||||
|
is_match = False
|
||||||
|
try:
|
||||||
|
is_match = cls.__relational_filter_matches(filter, value)
|
||||||
|
except AssertionError as e:
|
||||||
|
logger.error('Invalid filter: %s', e)
|
||||||
|
|
||||||
|
if not is_match:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
def _matches_condition(
|
def _matches_condition(
|
||||||
self,
|
self,
|
||||||
condition: dict,
|
condition: dict,
|
||||||
|
@ -102,24 +169,33 @@ class Event(Message):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if isinstance(args[attr], str):
|
if isinstance(args[attr], str):
|
||||||
self._matches_argument(
|
if self._is_relational_filter(value):
|
||||||
argname=attr, condition_value=value, args=args, result=result
|
if not self._relational_filter_matches(value, args[attr]):
|
||||||
)
|
return False
|
||||||
|
|
||||||
if result.is_match:
|
|
||||||
match_scores.append(result.score)
|
|
||||||
else:
|
else:
|
||||||
return False
|
self._matches_argument(
|
||||||
elif isinstance(value, dict):
|
argname=attr, condition_value=value, args=args, result=result
|
||||||
if not isinstance(args[attr], dict):
|
)
|
||||||
return False
|
|
||||||
|
|
||||||
return self._matches_condition(
|
if result.is_match:
|
||||||
condition=value,
|
match_scores.append(result.score)
|
||||||
args=args[attr],
|
else:
|
||||||
result=result,
|
return False
|
||||||
match_scores=match_scores,
|
elif isinstance(value, dict):
|
||||||
)
|
if self._is_relational_filter(value):
|
||||||
|
if not self._relational_filter_matches(value, args[attr]):
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
if not isinstance(args[attr], dict):
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not self._matches_condition(
|
||||||
|
condition=value,
|
||||||
|
args=args[attr],
|
||||||
|
result=result,
|
||||||
|
match_scores=match_scores,
|
||||||
|
):
|
||||||
|
return False
|
||||||
elif args[attr] != value:
|
elif args[attr] != value:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
@ -188,6 +264,7 @@ class Event(Message):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
class EventMatchResult:
|
class EventMatchResult:
|
||||||
"""
|
"""
|
||||||
When comparing an event against an event condition, you want to
|
When comparing an event against an event condition, you want to
|
||||||
|
@ -197,10 +274,9 @@ class EventMatchResult:
|
||||||
highest score will win.
|
highest score will win.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, is_match, score=0.0, parsed_args=None):
|
is_match: bool
|
||||||
self.is_match = is_match
|
score: float = 0.0
|
||||||
self.score = score
|
parsed_args: dict = field(default_factory=dict)
|
||||||
self.parsed_args = parsed_args or {}
|
|
||||||
|
|
||||||
|
|
||||||
def flatten(args):
|
def flatten(args):
|
||||||
|
@ -221,4 +297,19 @@ def flatten(args):
|
||||||
flatten(args[i])
|
flatten(args[i])
|
||||||
|
|
||||||
|
|
||||||
|
_event_filter_operators = {
|
||||||
|
'$gt': lambda a, b: a > b,
|
||||||
|
'$gte': lambda a, b: a >= b,
|
||||||
|
'$lt': lambda a, b: a < b,
|
||||||
|
'$lte': lambda a, b: a <= b,
|
||||||
|
'$eq': lambda a, b: a == b,
|
||||||
|
'$ne': lambda a, b: a != b,
|
||||||
|
'$regex': lambda a, b: re.search(b, a),
|
||||||
|
}
|
||||||
|
|
||||||
|
_numeric_filter_operators = {'$gt', '$gte', '$lt', '$lte'}
|
||||||
|
|
||||||
|
_string_filter_operators = {'$regex'}
|
||||||
|
|
||||||
|
|
||||||
# vim:sw=4:ts=4:et:
|
# vim:sw=4:ts=4:et:
|
||||||
|
|
|
@ -87,6 +87,56 @@ def test_speech_recognized_event_parse():
|
||||||
assert not result.is_match
|
assert not result.is_match
|
||||||
|
|
||||||
|
|
||||||
|
def test_condition_with_relational_operators():
|
||||||
|
"""
|
||||||
|
Test relational operators used in event conditions.
|
||||||
|
"""
|
||||||
|
# Given: A condition with a relational operator.
|
||||||
|
condition = EventCondition.build(
|
||||||
|
{
|
||||||
|
'type': 'platypush.message.event.ping.PingEvent',
|
||||||
|
'message': {'foo': {'$gt': 25}},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# When: An event with a value greater than 25 is received.
|
||||||
|
event = PingEvent(message={'foo': 26})
|
||||||
|
|
||||||
|
# Then: The condition is matched.
|
||||||
|
assert event.matches_condition(condition).is_match
|
||||||
|
|
||||||
|
# When: An event with a value lower than 25 is received.
|
||||||
|
event = PingEvent(message={'foo': 24})
|
||||||
|
|
||||||
|
# Then: The condition is not matched.
|
||||||
|
assert not event.matches_condition(condition).is_match
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_with_regex_condition():
|
||||||
|
"""
|
||||||
|
Test an event matcher with a regex filter on an attribute.
|
||||||
|
"""
|
||||||
|
# Given: A condition with a regex filter.
|
||||||
|
condition = EventCondition.build(
|
||||||
|
{
|
||||||
|
'type': 'platypush.message.event.ping.PingEvent',
|
||||||
|
'message': {'foo': {'$regex': '^ba[rz]'}},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# When: An event with a matching string is received.
|
||||||
|
event = PingEvent(message={'foo': 'bart'})
|
||||||
|
|
||||||
|
# Then: The condition is matched.
|
||||||
|
assert event.matches_condition(condition).is_match
|
||||||
|
|
||||||
|
# When: An event with a non-matching string is received.
|
||||||
|
event = PingEvent(message={'foo': 'back'})
|
||||||
|
|
||||||
|
# Then: The condition is not matched.
|
||||||
|
assert not event.matches_condition(condition).is_match
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
pytest.main()
|
pytest.main()
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue