Skip to content

Commit 3cd684b

Browse files
committed
business-rules extension updates
1 parent b5c1a3c commit 3cd684b

File tree

2 files changed

+164
-118
lines changed

2 files changed

+164
-118
lines changed

business_rules/engine.py

+6-2
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ def run(rule, defined_variables, defined_actions):
2525

2626
def check_conditions_recursively(conditions, defined_variables):
2727
keys = list(conditions.keys())
28+
if keys == ['not']:
29+
return not check_conditions_recursively(conditions['not'], defined_variables)
30+
2831
if keys == ['all']:
2932
assert len(conditions['all']) >= 1
3033
for condition in conditions['all']:
@@ -40,9 +43,10 @@ def check_conditions_recursively(conditions, defined_variables):
4043
return False
4144

4245
else:
43-
# help prevent errors - any and all can only be in the condition dict
46+
# help prevent errors - not, any, and all can only be in the condition dict
4447
# if they're the only item
45-
assert not ('any' in keys or 'all' in keys)
48+
if ('not' in keys or 'any' in keys or 'all' in keys):
49+
raise ValueError("Only one of 'not', 'any', or 'all' can be at the same level in the conditions dict")
4650
return check_condition(conditions, defined_variables)
4751

4852
def check_condition(condition, defined_variables):

tests/test_engine_logic.py

+158-116
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,32 @@
1+
from unittest import TestCase
2+
from unittest.mock import patch, MagicMock
3+
4+
import pytest
5+
16
from business_rules import engine
27
from business_rules.variables import BaseVariables
38
from business_rules.operators import StringType
49
from business_rules.actions import BaseActions
510

6-
from mock import patch, MagicMock
7-
from unittest import TestCase
8-
911

1012
class EngineTests(TestCase):
11-
1213
###
1314
### Run
1415
###
1516

16-
@patch.object(engine, 'run')
17+
@patch.object(engine, "run")
1718
def test_run_all_some_rule_triggered(self, *args):
18-
""" By default, does not stop on first triggered rule. Returns True if
19+
"""By default, does not stop on first triggered rule. Returns True if
1920
any rule was triggered, otherwise False
2021
"""
21-
rule1 = {'conditions': 'condition1', 'actions': 'action name 1'}
22-
rule2 = {'conditions': 'condition2', 'actions': 'action name 2'}
22+
rule1 = {"conditions": "condition1", "actions": "action name 1"}
23+
rule2 = {"conditions": "condition2", "actions": "action name 2"}
2324
variables = BaseVariables()
2425
actions = BaseActions()
2526

2627
def return_action1(rule, *args, **kwargs):
27-
return rule['actions'] == 'action name 1'
28+
return rule["actions"] == "action name 1"
29+
2830
engine.run.side_effect = return_action1
2931

3032
result = engine.run_all([rule1, rule2], variables, actions)
@@ -38,158 +40,198 @@ def return_action1(rule, *args, **kwargs):
3840
self.assertTrue(result)
3941
self.assertEqual(engine.run.call_count, 2)
4042

41-
@patch.object(engine, 'run', return_value=True)
43+
@patch.object(engine, "run", return_value=True)
4244
def test_run_all_stop_on_first(self, *args):
43-
rule1 = {'conditions': 'condition1', 'actions': 'action name 1'}
44-
rule2 = {'conditions': 'condition2', 'actions': 'action name 2'}
45+
rule1 = {"conditions": "condition1", "actions": "action name 1"}
46+
rule2 = {"conditions": "condition2", "actions": "action name 2"}
4547
variables = BaseVariables()
4648
actions = BaseActions()
4749

48-
result = engine.run_all([rule1, rule2], variables, actions,
49-
stop_on_first_trigger=True)
50+
result = engine.run_all(
51+
[rule1, rule2], variables, actions, stop_on_first_trigger=True
52+
)
5053
self.assertEqual(result, True)
5154
self.assertEqual(engine.run.call_count, 1)
5255
engine.run.assert_called_once_with(rule1, variables, actions)
5356

54-
@patch.object(engine, 'check_conditions_recursively', return_value=True)
55-
@patch.object(engine, 'do_actions')
57+
@patch.object(engine, "check_conditions_recursively", return_value=True)
58+
@patch.object(engine, "do_actions")
5659
def test_run_that_triggers_rule(self, *args):
57-
rule = {'conditions': 'blah', 'actions': 'blah2'}
60+
rule = {"conditions": "blah", "actions": "blah2"}
5861
variables = BaseVariables()
5962
actions = BaseActions()
6063

6164
result = engine.run(rule, variables, actions)
6265
self.assertEqual(result, True)
6366
engine.check_conditions_recursively.assert_called_once_with(
64-
rule['conditions'], variables)
65-
engine.do_actions.assert_called_once_with(rule['actions'], actions)
67+
rule["conditions"], variables
68+
)
69+
engine.do_actions.assert_called_once_with(rule["actions"], actions)
6670

67-
68-
@patch.object(engine, 'check_conditions_recursively', return_value=False)
69-
@patch.object(engine, 'do_actions')
71+
@patch.object(engine, "check_conditions_recursively", return_value=False)
72+
@patch.object(engine, "do_actions")
7073
def test_run_that_doesnt_trigger_rule(self, *args):
71-
rule = {'conditions': 'blah', 'actions': 'blah2'}
74+
rule = {"conditions": "blah", "actions": "blah2"}
7275
variables = BaseVariables()
7376
actions = BaseActions()
7477

7578
result = engine.run(rule, variables, actions)
7679
self.assertEqual(result, False)
7780
engine.check_conditions_recursively.assert_called_once_with(
78-
rule['conditions'], variables)
81+
rule["conditions"], variables
82+
)
7983
self.assertEqual(engine.do_actions.call_count, 0)
8084

81-
82-
@patch.object(engine, 'check_condition', return_value=True)
83-
def test_check_all_conditions_with_all_true(self, *args):
84-
conditions = {'all': [{'thing1': ''}, {'thing2': ''}]}
85-
variables = BaseVariables()
86-
87-
result = engine.check_conditions_recursively(conditions, variables)
88-
self.assertEqual(result, True)
89-
# assert call count and most recent call are as expected
90-
self.assertEqual(engine.check_condition.call_count, 2)
91-
engine.check_condition.assert_called_with({'thing2': ''}, variables)
92-
93-
94-
###
95-
### Check conditions
96-
###
97-
@patch.object(engine, 'check_condition', return_value=False)
98-
def test_check_all_conditions_with_all_false(self, *args):
99-
conditions = {'all': [{'thing1': ''}, {'thing2': ''}]}
100-
variables = BaseVariables()
101-
102-
result = engine.check_conditions_recursively(conditions, variables)
103-
self.assertEqual(result, False)
104-
engine.check_condition.assert_called_once_with({'thing1': ''}, variables)
105-
106-
107-
def test_check_all_condition_with_no_items_fails(self):
108-
with self.assertRaises(AssertionError):
109-
engine.check_conditions_recursively({'all': []}, BaseVariables())
110-
111-
112-
@patch.object(engine, 'check_condition', return_value=True)
113-
def test_check_any_conditions_with_all_true(self, *args):
114-
conditions = {'any': [{'thing1': ''}, {'thing2': ''}]}
115-
variables = BaseVariables()
116-
117-
result = engine.check_conditions_recursively(conditions, variables)
118-
self.assertEqual(result, True)
119-
engine.check_condition.assert_called_once_with({'thing1': ''}, variables)
120-
121-
122-
@patch.object(engine, 'check_condition', return_value=False)
123-
def test_check_any_conditions_with_all_false(self, *args):
124-
conditions = {'any': [{'thing1': ''}, {'thing2': ''}]}
125-
variables = BaseVariables()
126-
127-
result = engine.check_conditions_recursively(conditions, variables)
128-
self.assertEqual(result, False)
129-
# assert call count and most recent call are as expected
130-
self.assertEqual(engine.check_condition.call_count, 2)
131-
engine.check_condition.assert_called_with({'thing2': ''}, variables)
132-
133-
134-
def test_check_any_condition_with_no_items_fails(self):
135-
with self.assertRaises(AssertionError):
136-
engine.check_conditions_recursively({'any': []}, BaseVariables())
137-
138-
139-
def test_check_all_and_any_together(self):
140-
conditions = {'any': [], 'all': []}
141-
variables = BaseVariables()
142-
with self.assertRaises(AssertionError):
143-
engine.check_conditions_recursively(conditions, variables)
144-
145-
@patch.object(engine, 'check_condition')
146-
def test_nested_all_and_any(self, *args):
147-
conditions = {'all': [
148-
{'any': [{'name': 1}, {'name': 2}]},
149-
{'name': 3}]}
150-
bv = BaseVariables()
151-
152-
def side_effect(condition, _):
153-
return condition['name'] in [2,3]
154-
engine.check_condition.side_effect = side_effect
155-
156-
engine.check_conditions_recursively(conditions, bv)
157-
self.assertEqual(engine.check_condition.call_count, 3)
158-
engine.check_condition.assert_any_call({'name': 1}, bv)
159-
engine.check_condition.assert_any_call({'name': 2}, bv)
160-
engine.check_condition.assert_any_call({'name': 3}, bv)
161-
162-
16385
###
16486
### Operator comparisons
16587
###
16688
def test_check_operator_comparison(self):
167-
string_type = StringType('yo yo')
168-
with patch.object(string_type, 'contains', return_value=True):
89+
string_type = StringType("yo yo")
90+
with patch.object(string_type, "contains", return_value=True):
16991
result = engine._do_operator_comparison(
170-
string_type, 'contains', 'its mocked')
92+
string_type, "contains", "its mocked"
93+
)
17194
self.assertTrue(result)
172-
string_type.contains.assert_called_once_with('its mocked')
173-
95+
string_type.contains.assert_called_once_with("its mocked")
17496

17597
###
17698
### Actions
17799
###
178100
def test_do_actions(self):
179-
actions = [ {'name': 'action1'},
180-
{'name': 'action2',
181-
'params': {'param1': 'foo', 'param2': 10}}]
101+
actions = [
102+
{"name": "action1"},
103+
{"name": "action2", "params": {"param1": "foo", "param2": 10}},
104+
]
182105
defined_actions = BaseActions()
183106
defined_actions.action1 = MagicMock()
184107
defined_actions.action2 = MagicMock()
185108

186109
engine.do_actions(actions, defined_actions)
187110

188111
defined_actions.action1.assert_called_once_with()
189-
defined_actions.action2.assert_called_once_with(param1='foo', param2=10)
112+
defined_actions.action2.assert_called_once_with(param1="foo", param2=10)
190113

191114
def test_do_with_invalid_action(self):
192-
actions = [{'name': 'fakeone'}]
115+
actions = [{"name": "fakeone"}]
193116
err_string = "Action fakeone is not defined in class BaseActions"
194117
with self.assertRaisesRegex(AssertionError, err_string):
195118
engine.do_actions(actions, BaseActions())
119+
120+
121+
@patch.object(engine, "check_condition", return_value=True)
122+
def test_check_all_conditions_with_all_true(_mock_check_condition: MagicMock) -> None:
123+
# set up
124+
conditions = {"all": [{"thing1": ""}, {"thing2": ""}]}
125+
variables = BaseVariables()
126+
# test
127+
result = engine.check_conditions_recursively(conditions, variables)
128+
assert result is True
129+
# assert call count and most recent call are as expected
130+
assert engine.check_condition.call_count == 2
131+
engine.check_condition.assert_called_with({"thing2": ""}, variables)
132+
133+
134+
@patch.object(engine, "check_condition", return_value=False)
135+
def test_check_all_conditions_with_all_false(_mock_check_condition: MagicMock) -> None:
136+
# set up
137+
conditions = {"all": [{"thing1": ""}, {"thing2": ""}]}
138+
variables = BaseVariables()
139+
# test
140+
result = engine.check_conditions_recursively(conditions, variables)
141+
# self.assertEqual(result, False)
142+
assert result is False
143+
engine.check_condition.assert_called_once_with({"thing1": ""}, variables)
144+
145+
146+
def test_check_all_and_any_together() -> None:
147+
conditions = {"any": [], "all": []}
148+
variables = BaseVariables()
149+
with pytest.raises(
150+
ValueError,
151+
match="Only one of 'not', 'any', or 'all' can be at the same level in the conditions dict",
152+
):
153+
engine.check_conditions_recursively(conditions, variables)
154+
155+
156+
def test_check_all_conditions_with_no_items_fails() -> None:
157+
with pytest.raises(AssertionError):
158+
engine.check_conditions_recursively({"all": []}, BaseVariables())
159+
160+
161+
@patch.object(engine, "check_condition", return_value=True)
162+
def test_check_any_conditions_with_all_true(_mock_check_condition: MagicMock) -> None:
163+
conditions = {"any": [{"thing1": ""}, {"thing2": ""}]}
164+
variables = BaseVariables()
165+
166+
result = engine.check_conditions_recursively(conditions, variables)
167+
assert result is True
168+
engine.check_condition.assert_called_once_with({"thing1": ""}, variables)
169+
170+
171+
@patch.object(engine, "check_condition", return_value=False)
172+
def test_check_any_conditions_with_all_false(_mock_check_condition: MagicMock) -> None:
173+
conditions = {"any": [{"thing1": ""}, {"thing2": ""}]}
174+
variables = BaseVariables()
175+
176+
result = engine.check_conditions_recursively(conditions, variables)
177+
assert result is False
178+
# assert call count and most recent call are as expected
179+
assert engine.check_condition.call_count == 2
180+
engine.check_condition.assert_called_with({"thing2": ""}, variables)
181+
182+
183+
def test_check_any_condition_with_no_items_fails() -> None:
184+
with pytest.raises(AssertionError):
185+
engine.check_conditions_recursively({"any": []}, BaseVariables())
186+
187+
188+
@patch.object(engine, "check_condition")
189+
def test_nested_all_and_any(_mock_check_condition: MagicMock) -> None:
190+
conditions = {"all": [{"any": [{"name": 1}, {"name": 2}]}, {"name": 3}]}
191+
bv = BaseVariables()
192+
193+
def side_effect(condition, _):
194+
return condition["name"] in [2, 3]
195+
196+
engine.check_condition.side_effect = side_effect
197+
198+
engine.check_conditions_recursively(conditions, bv)
199+
# self.assertEqual(engine.check_condition.call_count, 3)
200+
assert engine.check_condition.call_count == 3
201+
engine.check_condition.assert_any_call({"name": 1}, bv)
202+
engine.check_condition.assert_any_call({"name": 2}, bv)
203+
engine.check_condition.assert_any_call({"name": 3}, bv)
204+
205+
206+
@pytest.mark.parametrize("other_key", ["all", "any"])
207+
def test_check_not_with_all_any(other_key: str) -> None:
208+
"""
209+
DOCUMENT ME.
210+
"""
211+
conditions = {"not": [], other_key: []}
212+
variables = BaseVariables()
213+
with pytest.raises(
214+
ValueError,
215+
match="Only one of 'not', 'any', or 'all' can be at the same level in the conditions dict",
216+
):
217+
engine.check_conditions_recursively(conditions, variables)
218+
219+
220+
@pytest.mark.parametrize(
221+
"check_condition_result, expected_result", [(True, False), (False, True)]
222+
)
223+
def test_check_not_negates_result(
224+
check_condition_result: bool, expected_result: bool
225+
) -> None:
226+
"""
227+
DOCUMENT ME.
228+
"""
229+
conditions = {"not": {"thing1": ""}}
230+
variables = BaseVariables()
231+
232+
with patch.object(
233+
engine, "check_condition", return_value=check_condition_result
234+
) as mock_check_condition:
235+
result = engine.check_conditions_recursively(conditions, variables)
236+
assert result is expected_result
237+
mock_check_condition.assert_called_once_with({"thing1": ""}, variables)

0 commit comments

Comments
 (0)