From 1102a023a7758e22e74e504461d1e76874a09004 Mon Sep 17 00:00:00 2001 From: azharmunir-smf Date: Sun, 25 Jun 2023 11:28:21 +0300 Subject: [PATCH 1/3] Add run all with results (by slitayem) --- business_rules/__init__.py | 2 + business_rules/engine.py | 69 ++++++++++++++++++++++++++++--- tests/test_get_actions_results.py | 63 ++++++++++++++++++++++++++++ 3 files changed, 129 insertions(+), 5 deletions(-) create mode 100644 tests/test_get_actions_results.py diff --git a/business_rules/__init__.py b/business_rules/__init__.py index 348801bb..53df1147 100644 --- a/business_rules/__init__.py +++ b/business_rules/__init__.py @@ -1,8 +1,10 @@ __version__ = '1.1.1' from .engine import run_all +from .engine import run_all_with_results from .utils import export_rule_data # Appease pyflakes by "using" these exports assert run_all +assert run_all_with_results assert export_rule_data diff --git a/business_rules/engine.py b/business_rules/engine.py index eb3c00ad..a78b950f 100644 --- a/business_rules/engine.py +++ b/business_rules/engine.py @@ -1,10 +1,10 @@ from .fields import FIELD_NO_INPUT + def run_all(rule_list, defined_variables, defined_actions, stop_on_first_trigger=False): - rule_was_triggered = False for rule in rule_list: result = run(rule, defined_variables, defined_actions) @@ -14,6 +14,41 @@ def run_all(rule_list, return True return rule_was_triggered + +def run_all_with_results(rule_list, defined_variables, defined_actions, + stop_on_first_trigger=False): + """Run all Rules and return the rules actions results + Returns: + rule_results(list): list of dictionaries. Each dictionary is a rule + actions' results + """ + rule_results = [] + for rule in rule_list: + actions_results = run_and_get_results(rule, defined_variables, + defined_actions) + if actions_results: + rule_results.append(actions_results) + if stop_on_first_trigger: + return rule_results + return rule_results + + +def run_and_get_results(rule, defined_variables, defined_actions): + """Run the rule and get the action returned result + Attributes: + rule(dict): the rule dictionary + defined_variables(BaseVariables): the defined set of variables object + defined_actions(BaseActions): the actions object + """ + actions_results = {} + conditions, actions = rule.get('conditions'), rule.get('actions') + if conditions and actions: + rule_triggered = check_conditions_recursively(conditions, defined_variables) + if rule_triggered: + actions_results = do_actions(actions, defined_actions) + return actions_results + + def run(rule, defined_variables, defined_actions): conditions, actions = rule['conditions'], rule['actions'] rule_triggered = check_conditions_recursively(conditions, defined_variables) @@ -45,6 +80,7 @@ def check_conditions_recursively(conditions, defined_variables): assert not ('any' in keys or 'all' in keys) return check_condition(conditions, defined_variables) + def check_condition(condition, defined_variables): """ Checks a single rule condition - the condition will be made up of variables, values, and the comparison operator. The defined_variables @@ -54,6 +90,7 @@ def check_condition(condition, defined_variables): operator_type = _get_variable_value(defined_variables, name) return _do_operator_comparison(operator_type, op, value) + def _get_variable_value(defined_variables, name): """ Call the function provided on the defined_variables object with the given name (raise exception if that doesn't exist) and casts it to the @@ -61,13 +98,16 @@ def _get_variable_value(defined_variables, name): Returns an instance of operators.BaseType """ + def fallback(*args, **kwargs): raise AssertionError("Variable {0} is not defined in class {1}".format( - name, defined_variables.__class__.__name__)) + name, defined_variables.__class__.__name__)) + method = getattr(defined_variables, name, fallback) val = method() return method.field_type(val) + def _do_operator_comparison(operator_type, operator_name, comparison_value): """ Finds the method on the given operator_type and compares it to the given comparison_value. @@ -76,9 +116,11 @@ def _do_operator_comparison(operator_type, operator_name, comparison_value): comparison_value is whatever python type to compare to returns a bool """ + def fallback(*args, **kwargs): raise AssertionError("Operator {0} does not exist for type {1}".format( operator_name, operator_type.__class__.__name__)) + method = getattr(operator_type, operator_name, fallback) if getattr(method, 'input_type', '') == FIELD_NO_INPUT: return method() @@ -86,11 +128,28 @@ def fallback(*args, **kwargs): def do_actions(actions, defined_actions): + """ Run the actions + Attributes: + actions(list): list of dictionaries of actions. e.g: [ + { "name": "put_on_sale", + "params": {"sale_percentage": 0.25}, + } + ] + Returns: + actionsResults(dict): Dictionary of actions results + e.g: {"put_on_sale: [product1, product2, ...]} + """ + actions_results = {} for action in actions: method_name = action['name'] + def fallback(*args, **kwargs): - raise AssertionError("Action {0} is not defined in class {1}"\ - .format(method_name, defined_actions.__class__.__name__)) + raise AssertionError( + "Action {0} is not defined in class {1}".format(method_name, + defined_actions.__class__.__name__)) + params = action.get('params') or {} method = getattr(defined_actions, method_name, fallback) - method(**params) + actions_results[method_name] = method(**params) + + return actions_results diff --git a/tests/test_get_actions_results.py b/tests/test_get_actions_results.py new file mode 100644 index 00000000..0da21714 --- /dev/null +++ b/tests/test_get_actions_results.py @@ -0,0 +1,63 @@ +from business_rules.actions import BaseActions, rule_action +from business_rules.fields import FIELD_TEXT +from business_rules.variables import BaseVariables, boolean_rule_variable +from business_rules.engine import run_all_with_results +from . import TestCase + + +class ActionsResultsClassTests(TestCase): + """ Test methods on getting fired rules actions results + """ + + def test_get_actions_results(self): + class SomeVariables(BaseVariables): + @boolean_rule_variable + def this_is_rule_1(self): + return True + + @boolean_rule_variable + def this_is_rule_2(self): + return False + + class SomeActions(BaseActions): + + @rule_action(params={'foo': FIELD_TEXT}) + def some_action_1(self, foo): + return foo + + @rule_action(params={'foobar': FIELD_TEXT}) + def some_action_2(self, foobar): + return foobar + + @rule_action() + def some_action_3(self): + pass + + rule1 = {'conditions': {'all': [ + { + 'name': 'this_is_rule_1', + 'value': True, + 'operator': 'is_true' + }]}, + 'actions': [ + {'name': 'some_action_1', + 'params': {'foo': 'fooValue'} + }]} + rule2 = {'conditions': {'all': [ + { + 'name': 'this_is_rule_2', + 'value': True, + 'operator': 'is_false' + }]}, + 'actions': [ + {'name': 'some_action_2', + 'params': {'foobar': 'foobarValue'} + }, + {'name': 'some_action_3' + }]} + + variables = SomeVariables() + actions = SomeActions() + result = run_all_with_results([rule1, rule2], variables, actions) + self.assertCountEqual(result, + [{'some_action_1': 'fooValue'}, {'some_action_2': 'foobarValue', 'some_action_3': None}]) From 1b23e5a3f598af6ee4eab60c7288ddd562fba77d Mon Sep 17 00:00:00 2001 From: azharmunir-smf Date: Sun, 25 Jun 2023 12:00:44 +0300 Subject: [PATCH 2/3] Updated README.md --- HISTORY.md | 6 ++++++ README.md | 4 +++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/HISTORY.md b/HISTORY.md index 13d32dfb..adb95008 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,6 +1,12 @@ History ========= +## 1.1.2 + +Release date: 2023-6-25 + +- Added support for returning the results for applied actions of triggered rules + ## 1.1.1 Release date: 2022-3-18 diff --git a/README.md b/README.md index f0e95427..379b9f51 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ class ProductVariables(BaseVariables): ### 2. Define your set of actions -These are the actions that are available to be taken when a condition is triggered. +These are the actions that are available to be taken when a condition is triggered. Actions can either persist the changes directly, or can return an updated state back for any downstream use cases, or do both. For example: @@ -62,6 +62,7 @@ class ProductActions(BaseActions): def put_on_sale(self, sale_percentage): self.product.price = (1.0 - sale_percentage) * self.product.price self.product.save() + return self.product # in case of any downstream use case @rule_action(params={"number_to_order": FIELD_NUMERIC}) def order_more(self, number_to_order): @@ -213,6 +214,7 @@ for product in Products.objects.all(): ) ``` +Alternatively, `run_all_with_results` can be used to get the updated state for applied actions of triggered rules. ## API #### Variable Types and Decorators: From a4d533ca00d1ee3108a8012c284540ad2d2aa8f4 Mon Sep 17 00:00:00 2001 From: azharmunir-smf Date: Sun, 25 Jun 2023 16:07:05 +0300 Subject: [PATCH 3/3] Added rule name --- README.md | 113 ++++++++++++++++++------------ business_rules/engine.py | 37 +++++----- tests/test_engine_logic.py | 45 +++++++++++- tests/test_get_actions_results.py | 60 +++++++++------- 4 files changed, 166 insertions(+), 89 deletions(-) diff --git a/README.md b/README.md index 379b9f51..d19ea12d 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ class ProductActions(BaseActions): def put_on_sale(self, sale_percentage): self.product.price = (1.0 - sale_percentage) * self.product.price self.product.save() - return self.product # in case of any downstream use case + return self.product.price # optionally return some state @rule_action(params={"number_to_order": FIELD_NUMERIC}) def order_more(self, number_to_order): @@ -101,48 +101,59 @@ An example of the resulting python lists/dicts is: ```python rules = [ -# expiration_days < 5 AND current_inventory > 20 -{ "conditions": { "all": [ - { "name": "expiration_days", - "operator": "less_than", - "value": 5, - }, - { "name": "current_inventory", - "operator": "greater_than", - "value": 20, - }, - ]}, - "actions": [ - { "name": "put_on_sale", - "params": {"sale_percentage": 0.25}, - }, - ], -}, - -# current_inventory < 5 OR (current_month = "December" AND current_inventory < 20) -{ "conditions": { "any": [ - { "name": "current_inventory", - "operator": "less_than", - "value": 5, - }, - ]}, - { "all": [ - { "name": "current_month", - "operator": "equal_to", - "value": "December", + # expiration_days < 5 AND current_inventory > 20 + { + "name": "Rule for Putting Product on Sale", # name is optional + "conditions": { + "all": [ + { + "name": "expiration_days", + "operator": "less_than", + "value": 5, + }, + { + "name": "current_inventory", + "operator": "greater_than", + "value": 20, + }, + ] }, - { "name": "current_inventory", - "operator": "less_than", - "value": 20, - } - ]}, - }, - "actions": [ - { "name": "order_more", - "params":{"number_to_order": 40}, + "actions": [ + {"name": "put_on_sale", "params": {"sale_percentage": 0.25}}, + ], }, - ], -}] + + # current_inventory < 5 OR (current_month = "December" AND current_inventory < 20) + { + "name": "Rule for restocking", # name is optional + "conditions": { + "any": [ + { + "name": "current_inventory", + "operator": "less_than", + "value": 5, + }, + { + "all": [ + { + "name": "current_month", + "operator": "equal_to", + "value": "December", + }, + { + "name": "current_inventory", + "operator": "less_than", + "value": 20, + } + ] + } + ], + "actions": [ + {"name": "order_more", "params": {"number_to_order": 40}}, + ], + } + } +] ``` ### Export the available variables, operators and actions @@ -215,6 +226,22 @@ for product in Products.objects.all(): ``` Alternatively, `run_all_with_results` can be used to get the updated state for applied actions of triggered rules. + +```python +from business_rules import run_all_with_results + +rules = _some_function_to_receive_from_client() + +for product in Products.objects.all(): + rules_action_results = run_all_with_results( + rule_list=rules, + defined_variables=ProductVariables(product), + defined_actions=ProductActions(product), + stop_on_first_trigger=True + ) + +``` + ## API #### Variable Types and Decorators: @@ -273,10 +300,6 @@ Note: to compare floating point equality we just check that the difference is le * `shares_exactly_one_element_with` * `shares_no_elements_with` -### Returning data to your client - - - ## Contributing Open up a pull request, making sure to add tests for any new functionality. To set up the dev environment (assuming you're using [virtualenvwrapper](http://docs.python-guide.org/en/latest/dev/virtualenvs/#virtualenvwrapper)): diff --git a/business_rules/engine.py b/business_rules/engine.py index a78b950f..e59e40d8 100644 --- a/business_rules/engine.py +++ b/business_rules/engine.py @@ -15,19 +15,21 @@ def run_all(rule_list, return rule_was_triggered -def run_all_with_results(rule_list, defined_variables, defined_actions, - stop_on_first_trigger=False): - """Run all Rules and return the rules actions results - Returns: - rule_results(list): list of dictionaries. Each dictionary is a rule - actions' results +def run_all_with_results(rule_list, defined_variables, defined_actions, stop_on_first_trigger=False): """ - rule_results = [] - for rule in rule_list: - actions_results = run_and_get_results(rule, defined_variables, - defined_actions) - if actions_results: - rule_results.append(actions_results) + Runs all the rules and returns the results returned by actions of triggered rule(s). + + Returns: + rule_results(dict): dictionary with results of actions for every triggered rule. {} if no rule triggered. + Uses rule's `name` as key if available, otherwise rule's index is used as key. + """ + rule_results = {} + for ix, rule in enumerate(rule_list): + triggered, actions_results = run_and_get_results(rule, defined_variables, + defined_actions) + if triggered: + rule_name = rule.get('name', ix) + rule_results[rule_name] = actions_results if stop_on_first_trigger: return rule_results return rule_results @@ -40,13 +42,12 @@ def run_and_get_results(rule, defined_variables, defined_actions): defined_variables(BaseVariables): the defined set of variables object defined_actions(BaseActions): the actions object """ - actions_results = {} + actions_results = None conditions, actions = rule.get('conditions'), rule.get('actions') - if conditions and actions: - rule_triggered = check_conditions_recursively(conditions, defined_variables) - if rule_triggered: - actions_results = do_actions(actions, defined_actions) - return actions_results + rule_triggered = check_conditions_recursively(conditions, defined_variables) + if rule_triggered: + actions_results = do_actions(actions, defined_actions) + return rule_triggered, actions_results def run(rule, defined_variables, defined_actions): diff --git a/tests/test_engine_logic.py b/tests/test_engine_logic.py index 70642fe5..0cf5930c 100644 --- a/tests/test_engine_logic.py +++ b/tests/test_engine_logic.py @@ -46,11 +46,53 @@ def test_run_all_stop_on_first(self, *args): actions = BaseActions() result = engine.run_all([rule1, rule2], variables, actions, - stop_on_first_trigger=True) + stop_on_first_trigger=True) self.assertEqual(result, True) self.assertEqual(engine.run.call_count, 1) engine.run.assert_called_once_with(rule1, variables, actions) + @patch.object(engine, 'run_and_get_results') + def test_run_all_with_results_some_rule_triggered(self, *args): + """ By default, does not stop on first triggered rule. Returns True if + any rule was triggered, otherwise False + """ + rule1 = {'name': 'rule 1', 'conditions': 'condition1', 'actions': 'action name 1'} + rule2 = {'name': 'rule 2', 'conditions': 'condition2', 'actions': 'action name 2'} + variables = BaseVariables() + actions = BaseActions() + + def return_action1(rule, *args, **kwargs): + if rule['name'] == 'rule 1': + return True, {'action name 1': None} + return False, None + + engine.run_and_get_results.side_effect = return_action1 + + result = engine.run_all_with_results([rule1, rule2], variables, actions) + + self.assertDictEqual(result, {'rule 1': {'action name 1': None}}) + self.assertEqual(engine.run_and_get_results.call_count, 2) + + # switch order and try again + engine.run_and_get_results.reset_mock() + + result = engine.run_all_with_results([rule2, rule1], variables, actions) + self.assertDictEqual(result, {'rule 1': {'action name 1': None}}) + self.assertEqual(engine.run_and_get_results.call_count, 2) + + @patch.object(engine, 'run_and_get_results', return_value=(True, {'action name 1': None})) + def test_run_all_with_results_stop_on_first(self, *args): + rule1 = {'name': 'rule 1', 'conditions': 'condition1', 'actions': 'action name 1'} + rule2 = {'name': 'rule 2', 'conditions': 'condition2', 'actions': 'action name 2'} + variables = BaseVariables() + actions = BaseActions() + + result = engine.run_all_with_results([rule1, rule2], variables, actions, + stop_on_first_trigger=True) + self.assertDictEqual(result, {'rule 1': {'action name 1': None}}) + self.assertEqual(engine.run_and_get_results.call_count, 1) + engine.run_and_get_results.assert_called_once_with(rule1, variables, actions) + @patch.object(engine, 'check_conditions_recursively', return_value=True) @patch.object(engine, 'do_actions') def test_run_that_triggers_rule(self, *args): @@ -78,7 +120,6 @@ def test_run_that_doesnt_trigger_rule(self, *args): rule['conditions'], variables) self.assertEqual(engine.do_actions.call_count, 0) - @patch.object(engine, 'check_condition', return_value=True) def test_check_all_conditions_with_all_true(self, *args): conditions = {'all': [{'thing1': ''}, {'thing2': ''}]} diff --git a/tests/test_get_actions_results.py b/tests/test_get_actions_results.py index 0da21714..2b936948 100644 --- a/tests/test_get_actions_results.py +++ b/tests/test_get_actions_results.py @@ -20,7 +20,6 @@ def this_is_rule_2(self): return False class SomeActions(BaseActions): - @rule_action(params={'foo': FIELD_TEXT}) def some_action_1(self, foo): return foo @@ -33,31 +32,44 @@ def some_action_2(self, foobar): def some_action_3(self): pass - rule1 = {'conditions': {'all': [ - { - 'name': 'this_is_rule_1', - 'value': True, - 'operator': 'is_true' - }]}, - 'actions': [ - {'name': 'some_action_1', - 'params': {'foo': 'fooValue'} - }]} - rule2 = {'conditions': {'all': [ - { - 'name': 'this_is_rule_2', - 'value': True, - 'operator': 'is_false' - }]}, + rule1 = { + 'conditions': { + 'all': [ + {'name': 'this_is_rule_1', 'value': True, 'operator': 'is_true'} + ] + }, + 'actions': [{'name': 'some_action_1', 'params': {'foo': 'fooValue'}}], + } + rule2 = { + 'conditions': { + 'all': [ + {'name': 'this_is_rule_2', 'value': True, 'operator': 'is_false'} + ] + }, 'actions': [ - {'name': 'some_action_2', - 'params': {'foobar': 'foobarValue'} - }, - {'name': 'some_action_3' - }]} + {'name': 'some_action_2', 'params': {'foobar': 'foobarValue'}}, + {'name': 'some_action_3'}, + ], + } variables = SomeVariables() actions = SomeActions() result = run_all_with_results([rule1, rule2], variables, actions) - self.assertCountEqual(result, - [{'some_action_1': 'fooValue'}, {'some_action_2': 'foobarValue', 'some_action_3': None}]) + self.assertDictEqual( + result, + { + 0: {'some_action_1': 'fooValue'}, + 1: {'some_action_2': 'foobarValue', 'some_action_3': None}, + }, + ) + + # with rule name + rule1['name'] = 'rule 1' + result = run_all_with_results([rule1, rule2], variables, actions) + self.assertDictEqual( + result, + { + 'rule 1': {'some_action_1': 'fooValue'}, + 1: {'some_action_2': 'foobarValue', 'some_action_3': None}, + }, + )