Implement condition negation

This commit is contained in:
jeremystretch 2021-10-25 09:09:51 -04:00
parent b92de63245
commit 35c967e6f7
2 changed files with 33 additions and 21 deletions

View File

@ -6,17 +6,15 @@ __all__ = (
) )
LOGIC_TYPES = ( AND = 'and'
'and', OR = 'or'
'or'
)
def is_ruleset(data): def is_ruleset(data):
""" """
Determine whether the given dictionary looks like a rule set. Determine whether the given dictionary looks like a rule set.
""" """
return type(data) is dict and len(data) == 1 and list(data.keys())[0] in LOGIC_TYPES return type(data) is dict and len(data) == 1 and list(data.keys())[0] in (AND, OR)
class Condition: class Condition:
@ -28,7 +26,6 @@ class Condition:
:param op: The logical operation to use when evaluating the value (default: 'eq') :param op: The logical operation to use when evaluating the value (default: 'eq')
""" """
EQ = 'eq' EQ = 'eq'
NEQ = 'neq'
GT = 'gt' GT = 'gt'
GTE = 'gte' GTE = 'gte'
LT = 'lt' LT = 'lt'
@ -37,18 +34,18 @@ class Condition:
CONTAINS = 'contains' CONTAINS = 'contains'
OPERATORS = ( OPERATORS = (
EQ, NEQ, GT, GTE, LT, LTE, IN, CONTAINS EQ, GT, GTE, LT, LTE, IN, CONTAINS
) )
TYPES = { TYPES = {
str: (EQ, NEQ, CONTAINS), str: (EQ, CONTAINS),
bool: (EQ, NEQ, CONTAINS), bool: (EQ, CONTAINS),
int: (EQ, NEQ, GT, GTE, LT, LTE, CONTAINS), int: (EQ, GT, GTE, LT, LTE, CONTAINS),
float: (EQ, NEQ, GT, GTE, LT, LTE, CONTAINS), float: (EQ, GT, GTE, LT, LTE, CONTAINS),
list: (EQ, NEQ, IN, CONTAINS) list: (EQ, IN, CONTAINS)
} }
def __init__(self, attr, value, op=EQ): def __init__(self, attr, value, op=EQ, negate=False):
if op not in self.OPERATORS: if op not in self.OPERATORS:
raise ValueError(f"Unknown operator: {op}. Must be one of: {', '.join(self.OPERATORS)}") raise ValueError(f"Unknown operator: {op}. Must be one of: {', '.join(self.OPERATORS)}")
if type(value) not in self.TYPES: if type(value) not in self.TYPES:
@ -59,13 +56,18 @@ class Condition:
self.attr = attr self.attr = attr
self.value = value self.value = value
self.eval_func = getattr(self, f'eval_{op}') self.eval_func = getattr(self, f'eval_{op}')
self.negate = negate
def eval(self, data): def eval(self, data):
""" """
Evaluate the provided data to determine whether it matches the condition. Evaluate the provided data to determine whether it matches the condition.
""" """
value = functools.reduce(dict.get, self.attr.split('.'), data) value = functools.reduce(dict.get, self.attr.split('.'), data)
return self.eval_func(value) result = self.eval_func(value)
if self.negate:
return not result
return result
# Equivalency # Equivalency
@ -104,7 +106,7 @@ class ConditionSet:
{"and": [ {"and": [
{"attr": "foo", "op": "eq", "value": 1}, {"attr": "foo", "op": "eq", "value": 1},
{"attr": "bar", "op": "neq", "value": 2} {"attr": "bar", "op": "eq", "value": 2, "negate": true}
]} ]}
:param ruleset: A dictionary mapping a logical operator to a list of conditional rules :param ruleset: A dictionary mapping a logical operator to a list of conditional rules
@ -117,8 +119,8 @@ class ConditionSet:
# Determine the logic type # Determine the logic type
logic = list(ruleset.keys())[0] logic = list(ruleset.keys())[0]
if type(logic) is not str or logic.lower() not in LOGIC_TYPES: if type(logic) is not str or logic.lower() not in (AND, OR):
raise ValueError(f"Invalid logic type: {logic} (must be 'and' or 'or')") raise ValueError(f"Invalid logic type: {logic} (must be '{AND}' or '{OR}')")
self.logic = logic.lower() self.logic = logic.lower()
# Compile the set of Conditions # Compile the set of Conditions

View File

@ -48,8 +48,8 @@ class ConditionTestCase(TestCase):
self.assertTrue(c.eval({'x': 1})) self.assertTrue(c.eval({'x': 1}))
self.assertFalse(c.eval({'x': 2})) self.assertFalse(c.eval({'x': 2}))
def test_neq(self): def test_eq_negated(self):
c = Condition('x', 1, 'neq') c = Condition('x', 1, 'eq', negate=True)
self.assertFalse(c.eval({'x': 1})) self.assertFalse(c.eval({'x': 1}))
self.assertTrue(c.eval({'x': 2})) self.assertTrue(c.eval({'x': 2}))
@ -80,11 +80,21 @@ class ConditionTestCase(TestCase):
self.assertTrue(c.eval({'x': 1})) self.assertTrue(c.eval({'x': 1}))
self.assertFalse(c.eval({'x': 9})) self.assertFalse(c.eval({'x': 9}))
def test_in_negated(self):
c = Condition('x', [1, 2, 3], 'in', negate=True)
self.assertFalse(c.eval({'x': 1}))
self.assertTrue(c.eval({'x': 9}))
def test_contains(self): def test_contains(self):
c = Condition('x', 1, 'contains') c = Condition('x', 1, 'contains')
self.assertTrue(c.eval({'x': [1, 2, 3]})) self.assertTrue(c.eval({'x': [1, 2, 3]}))
self.assertFalse(c.eval({'x': [2, 3, 4]})) self.assertFalse(c.eval({'x': [2, 3, 4]}))
def test_contains_negated(self):
c = Condition('x', 1, 'contains', negate=True)
self.assertFalse(c.eval({'x': [1, 2, 3]}))
self.assertTrue(c.eval({'x': [2, 3, 4]}))
class ConditionSetTest(TestCase): class ConditionSetTest(TestCase):
@ -100,11 +110,11 @@ class ConditionSetTest(TestCase):
cs = ConditionSet({ cs = ConditionSet({
'and': [ 'and': [
{'attr': 'a', 'value': 1, 'op': 'eq'}, {'attr': 'a', 'value': 1, 'op': 'eq'},
{'attr': 'b', 'value': 2, 'op': 'eq'}, {'attr': 'b', 'value': 1, 'op': 'eq', 'negate': True},
] ]
}) })
self.assertTrue(cs.eval({'a': 1, 'b': 2})) self.assertTrue(cs.eval({'a': 1, 'b': 2}))
self.assertFalse(cs.eval({'a': 1, 'b': 3})) self.assertFalse(cs.eval({'a': 1, 'b': 1}))
def test_or_single_depth(self): def test_or_single_depth(self):
cs = ConditionSet({ cs = ConditionSet({