diff --git a/README.md b/README.md index ef69af88..f2f0fb6c 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,8 @@ Variables represent values in your system, usually the value of some particular You define all the available variables for a certain kind of object in your code, and then later dynamically set the conditions and thresholds for those. +**Update 9/2018 EF:** Rule variables now allow for function parameters. This allows for passing a parameter from the JSON object to the functions. Example of usage in `month_equals()` and JSON in section 3. Parameter use is the same as for actions, described in section 2. + For example: ```python @@ -50,6 +52,11 @@ class ProductVariables(BaseVariables): @select_rule_variable(options=Products.top_holiday_items()) def goes_well_with(self): return products.related_products + + @boolean_rule_variable(params={"month": FIELD_STRING}) + def month_equals(self, month): + return datetime.datetime.now().strftime("%B") == month + ``` ### 2. Define your set of actions @@ -124,12 +131,18 @@ rules = [ ], }, -# current_inventory < 5 OR (current_month = "December" AND current_inventory < 20) +# current_inventory < 5 OR current_month = "September" OR (current_month = "December" AND current_inventory < 20) +# i.e. Order more if inventory < 5, if September, or if both December and inventory < 20 { "conditions": { "any": [ { "name": "current_inventory", "operator": "less_than", "value": 5, }, + { "name": "month_equals", + "params":{"month": 'September'}, + "operator": "is_true", + "value": 1, + }, ]}, { "all": [ { "name": "current_month", diff --git a/business_rules/engine.py b/business_rules/engine.py index eb3c00ad..d39bec94 100644 --- a/business_rules/engine.py +++ b/business_rules/engine.py @@ -51,10 +51,11 @@ def check_condition(condition, defined_variables): object must have a variable defined for any variables in this condition. """ name, op, value = condition['name'], condition['operator'], condition['value'] - operator_type = _get_variable_value(defined_variables, name) + params = condition.get('params') or {} + operator_type = _get_variable_value(defined_variables, name, params) return _do_operator_comparison(operator_type, op, value) -def _get_variable_value(defined_variables, name): +def _get_variable_value(defined_variables, name, params): """ 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. @@ -65,7 +66,7 @@ def fallback(*args, **kwargs): raise AssertionError("Variable {0} is not defined in class {1}".format( name, defined_variables.__class__.__name__)) method = getattr(defined_variables, name, fallback) - val = method() + val = method(**params) return method.field_type(val) def _do_operator_comparison(operator_type, operator_name, comparison_value): diff --git a/business_rules/variables.py b/business_rules/variables.py index b7863f60..c2c84896 100644 --- a/business_rules/variables.py +++ b/business_rules/variables.py @@ -1,5 +1,6 @@ import inspect from functools import wraps +from . import fields from .utils import fn_name_to_pretty_label from .operators import (BaseType, NumericType, @@ -19,10 +20,30 @@ def get_all_variables(cls): 'label': m[1].label, 'field_type': m[1].field_type.name, 'options': m[1].options, + 'params': m[1].params, } for m in methods if getattr(m[1], 'is_rule_variable', False)] +def _validate_action_parameters(func, params): + """ Verifies that the parameters specified are actual parameters for the + function `func`, and that the field types are FIELD_* types in fields. + """ + if params is not None: + # Verify field name is valid + valid_fields = [getattr(fields, f) for f in dir(fields) \ + if f.startswith("FIELD_")] + for param in params: + param_name, field_type = param['name'], param['fieldType'] + if param_name not in func.__code__.co_varnames: + raise AssertionError("Unknown parameter name {0} specified for"\ + " action {1}".format( + param_name, func.__name__)) + + if field_type not in valid_fields: + raise AssertionError("Unknown field type {0} specified for"\ + " action {1} param {2}".format( + field_type, func.__name__, param_name)) -def rule_variable(field_type, label=None, options=None): +def rule_variable(field_type, label=None, options=None, params=None): """ Decorator to make a function into a rule variable """ options = options or [] @@ -31,31 +52,39 @@ def wrapper(func): raise AssertionError("{0} is not instance of BaseType in"\ " rule_variable field_type".format(field_type)) func.field_type = field_type + params_ = params + if isinstance(params, dict): + params_ = [dict(label=fn_name_to_pretty_label(name), + name=name, + fieldType=param_field_type) \ + for name, param_field_type in params.items()] + _validate_action_parameters(func, params_) func.is_rule_variable = True func.label = label \ or fn_name_to_pretty_label(func.__name__) func.options = options + func.params = params_ return func return wrapper -def _rule_variable_wrapper(field_type, label): +def _rule_variable_wrapper(field_type, label, params): if callable(label): # Decorator is being called with no args, label is actually the decorated func return rule_variable(field_type)(label) - return rule_variable(field_type, label=label) + return rule_variable(field_type, label=label, params=params) -def numeric_rule_variable(label=None): - return _rule_variable_wrapper(NumericType, label) +def numeric_rule_variable(label=None, params=None): + return _rule_variable_wrapper(NumericType, label, params) -def string_rule_variable(label=None): - return _rule_variable_wrapper(StringType, label) +def string_rule_variable(label=None, params=None): + return _rule_variable_wrapper(StringType, label, params) -def boolean_rule_variable(label=None): - return _rule_variable_wrapper(BooleanType, label) +def boolean_rule_variable(label=None, params=None): + return _rule_variable_wrapper(BooleanType, label, params) -def select_rule_variable(label=None, options=None): - return rule_variable(SelectType, label=label, options=options) +def select_rule_variable(label=None, options=None, params=None): + return rule_variable(SelectType, label=label, options=options, params=params) -def select_multiple_rule_variable(label=None, options=None): - return rule_variable(SelectMultipleType, label=label, options=options) +def select_multiple_rule_variable(label=None, options=None, params=None): + return rule_variable(SelectMultipleType, label=label, options=options, params=params) diff --git a/tests/test_integration.py b/tests/test_integration.py index 6dbf1ace..be996a01 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -16,6 +16,10 @@ def foo(self): def ten(self): return 10 + @numeric_rule_variable(label="num_param", params={'number': FIELD_NUMERIC}) + def num_param(self, number=5): + return number + @boolean_rule_variable() def true_bool(self): return True @@ -115,15 +119,23 @@ def test_export_rule_data(self): [{"name": "foo", "label": "Foo", "field_type": "string", - "options": []}, + "options": [], + "params": None}, + {'field_type': 'numeric', + 'label': 'num_param', + 'name': 'num_param', + 'options': [], + 'params': [{'fieldType': 'numeric', 'label': 'Number', 'name': 'number'}]}, {"name": "ten", "label": "Diez", "field_type": "numeric", - "options": []}, + "options": [], + "params": None}, {'name': 'true_bool', 'label': 'True Bool', 'field_type': 'boolean', - 'options': []}]) + 'options': [], + "params": None}]) self.assertEqual(all_data.get("variable_type_operators"), {'boolean': [{'input_type': 'none', diff --git a/tests/test_rules.py b/tests/test_rules.py new file mode 100644 index 00000000..a929b7cd --- /dev/null +++ b/tests/test_rules.py @@ -0,0 +1,91 @@ +from business_rules.engine import check_condition +from business_rules import export_rule_data +from business_rules import run_all +from business_rules.actions import rule_action, BaseActions +from business_rules.variables import BaseVariables, string_rule_variable, numeric_rule_variable, boolean_rule_variable +from business_rules.fields import FIELD_TEXT, FIELD_NUMERIC, FIELD_SELECT + +from . import TestCase + + +class SomeVariables(BaseVariables): + + @string_rule_variable() + def foo(self): + return "foo" + + @numeric_rule_variable(label="Diez") + def ten(self): + return 10 + + @numeric_rule_variable(label="num") + def num(self): + return 5 + + @numeric_rule_variable(label="num_param", params={'number': FIELD_NUMERIC}) + def num_param(self, number): + return number + 1 + + @boolean_rule_variable() + def true_bool(self): + return True + + +class SomeActions(BaseActions): + + def __init__(self): + self._baz = 'Not chosen' + + @rule_action(params={"foo": FIELD_NUMERIC}) + def some_action(self, foo): pass + + @rule_action(label="woohoo", params={"bar": FIELD_TEXT}) + def some_other_action(self, bar): + return bar + + @rule_action(params=[{'fieldType': FIELD_SELECT, + 'name': 'baz', + 'label': 'Baz', + 'options': [ + {'label': 'Chose Me', 'name': 'chose_me'}, + {'label': 'Or Me', 'name': 'or_me'} + ]}]) + def some_select_action(self, baz): + self._baz = baz + + +class RulesTests(TestCase): + """ Integration test, using the library like a user would. + """ + def test_rules(self): + + rules = [ + { + "conditions": {"all": [{ + "name": "num_param", + "operator": "equal_to", + "params": {"number": 4}, + "value": 5, + }, + { + "name": "num", + "operator": "equal_to", + "value": 5, + }, + ]}, + "actions": [{ + "name": "some_select_action", + "params": {"baz": 'chose_me'}, + },], + } + ] + + actions = SomeActions() + + run_all(rule_list=rules, + defined_variables=SomeVariables(), + defined_actions=actions, + stop_on_first_trigger=False + ) + + self.assertEqual(actions._baz, 'chose_me') diff --git a/tests/test_variables_class.py b/tests/test_variables_class.py index 1a4bf7da..e635297e 100644 --- a/tests/test_variables_class.py +++ b/tests/test_variables_class.py @@ -1,5 +1,6 @@ from business_rules.variables import BaseVariables, rule_variable from business_rules.operators import StringType +from business_rules.fields import FIELD_TEXT from . import TestCase class VariablesClassTests(TestCase): @@ -31,3 +32,26 @@ def non_rule(self): # should work on an instance of the class too self.assertEqual(len(SomeVariables().get_all_variables()), 1) + def test_get_all_param_variables(self): + """ Returns a dictionary listing all the functions on the class that + have been decorated as variables, with some of the data about them. + """ + class SomeVariables(BaseVariables): + + @rule_variable(StringType, params={'foo':FIELD_TEXT}) + def this_is_rule_1(self, foo): + return "blah" + + def non_rule(self): + return "baz" + + vars = SomeVariables.get_all_variables() + self.assertEqual(len(vars), 1) + self.assertEqual(vars[0]['name'], 'this_is_rule_1') + self.assertEqual(vars[0]['label'], 'This Is Rule 1') + self.assertEqual(vars[0]['field_type'], 'string') + self.assertEqual(vars[0]['options'], []) + self.assertEqual(vars[0]['params'], [{'fieldType': FIELD_TEXT, 'name': 'foo', 'label': 'Foo'}]) + + # should work on an instance of the class too + self.assertEqual(len(SomeVariables().get_all_variables()), 1)