Skip to content

Commit

Permalink
Return Fired rules actions results. venmo#34 (Update)
Browse files Browse the repository at this point in the history
  • Loading branch information
azharmunir43 committed Jun 25, 2023
1 parent b5c1a3c commit 64e8df4
Show file tree
Hide file tree
Showing 6 changed files with 262 additions and 53 deletions.
6 changes: 6 additions & 0 deletions HISTORY.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
115 changes: 70 additions & 45 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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.price # optionally return some state

@rule_action(params={"number_to_order": FIELD_NUMERIC})
def order_more(self, number_to_order):
Expand Down Expand Up @@ -100,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
Expand Down Expand Up @@ -213,6 +225,23 @@ 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:
Expand Down Expand Up @@ -271,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)):
Expand Down
4 changes: 3 additions & 1 deletion business_rules/__init__.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
__version__ = '1.1.1'
__version__ = '1.1.2'

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
70 changes: 65 additions & 5 deletions business_rules/engine.py
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -14,6 +14,42 @@ 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):
"""
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


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 = None
conditions, actions = rule.get('conditions'), rule.get('actions')
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):
conditions, actions = rule['conditions'], rule['actions']
rule_triggered = check_conditions_recursively(conditions, defined_variables)
Expand Down Expand Up @@ -45,6 +81,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
Expand All @@ -54,20 +91,24 @@ 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
specified type.
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.
Expand All @@ -76,21 +117,40 @@ 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()
return method(comparison_value)


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:
actions_results(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
45 changes: 43 additions & 2 deletions tests/test_engine_logic.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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': ''}]}
Expand Down
Loading

0 comments on commit 64e8df4

Please sign in to comment.