diff --git a/.gitignore b/.gitignore index 8bc7b869..4c756d01 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,4 @@ pip-log.txt # Virtual environments venv*/ +.idea/ \ No newline at end of file diff --git a/business_rules/__init__.py b/business_rules/__init__.py index 348801bb..78833e6c 100644 --- a/business_rules/__init__.py +++ b/business_rules/__init__.py @@ -1,4 +1,4 @@ -__version__ = '1.1.1' +__version__ = '1.1.1+athelas5' from .engine import run_all from .utils import export_rule_data diff --git a/business_rules/actions.py b/business_rules/actions.py index c37acbce..59644e02 100644 --- a/business_rules/actions.py +++ b/business_rules/actions.py @@ -8,13 +8,16 @@ class BaseActions(object): """ Classes that hold a collection of actions to use with the rules engine should inherit from this. """ + @classmethod def get_all_actions(cls): methods = inspect.getmembers(cls) return [{'name': m[0], 'label': m[1].label, - 'params': m[1].params - } for m in methods if getattr(m[1], 'is_rule_action', False)] + 'params': m[1].params, + 'docs': inspect.getdoc(m[1]), + } for m in methods if getattr(m[1], 'is_rule_action', False)] + def _validate_action_parameters(func, params): """ Verifies that the parameters specified are actual parameters for the @@ -23,33 +26,36 @@ def _validate_action_parameters(func, params): if params is not None: # Verify field name is valid valid_fields = [getattr(fields, f) for f in dir(fields) \ - if f.startswith("FIELD_")] + 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__)) + 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)) + raise AssertionError("Unknown field type {0} specified for" \ + " action {1} param {2}".format( + field_type, func.__name__, param_name)) + def rule_action(label=None, params=None): """ Decorator to make a function into a rule action """ + def wrapper(func): params_ = params if isinstance(params, dict): params_ = [dict(label=fn_name_to_pretty_label(name), - name=name, - fieldType=field_type) \ - for name, field_type in params.items()] + name=name, + fieldType=field_type) \ + for name, field_type in params.items()] _validate_action_parameters(func, params_) func.is_rule_action = True func.label = label \ - or fn_name_to_pretty_label(func.__name__) + or fn_name_to_pretty_label(func.__name__) func.params = params_ return func + return wrapper diff --git a/business_rules/engine.py b/business_rules/engine.py index eb3c00ad..aeb68279 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,7 @@ def run_all(rule_list, return True return rule_was_triggered + def run(rule, defined_variables, defined_actions): conditions, actions = rule['conditions'], rule['actions'] rule_triggered = check_conditions_recursively(conditions, defined_variables) @@ -45,29 +46,35 @@ 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 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", {}) + 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 + given name and params (raise exception if name or params on the definition 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__)) + raise AssertionError("Variable {0} or params {2} is not defined in class {1}".format( + name, defined_variables.__class__.__name__, params)) + 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): """ Finds the method on the given operator_type and compares it to the given comparison_value. @@ -76,9 +83,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() @@ -88,9 +97,11 @@ def fallback(*args, **kwargs): def do_actions(actions, defined_actions): 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) diff --git a/business_rules/operators.py b/business_rules/operators.py index d43e5b1f..44f6b61d 100644 --- a/business_rules/operators.py +++ b/business_rules/operators.py @@ -20,7 +20,8 @@ def get_all_operators(cls): methods = inspect.getmembers(cls) return [{'name': m[0], 'label': m[1].label, - 'input_type': m[1].input_type} + 'input_type': m[1].input_type, + } for m in methods if getattr(m[1], 'is_operator', False)] @@ -87,6 +88,10 @@ def ends_with(self, other_string): def contains(self, other_string): return other_string in self.value + @type_operator(FIELD_TEXT, label="Contains (case insensitive)") + def contains_case_insensitive(self, other_string): + return other_string.lower() in self.value.lower() + @type_operator(FIELD_TEXT) def matches_regex(self, regex): return re.search(regex, self.value) @@ -95,6 +100,38 @@ def matches_regex(self, regex): def non_empty(self): return bool(self.value) + @type_operator(FIELD_TEXT) + def not_equal_to(self, other_string): + return not (self.value == other_string) + + @type_operator(FIELD_TEXT, label="Not equal To (case insensitive)") + def not_equal_to_case_insensitive(self, other_string): + return not (self.value.lower() == other_string.lower()) + + @type_operator(FIELD_TEXT) + def does_not_start_with(self, other_string): + return not (self.value.startswith(other_string)) + + @type_operator(FIELD_TEXT) + def does_not_end_with(self, other_string): + return not (self.value.endswith(other_string)) + + @type_operator(FIELD_TEXT) + def does_not_contain(self, other_string): + return not (other_string in self.value) + + @type_operator(FIELD_TEXT, label="Does not contain (case insensitive)") + def does_not_contain_case_insensitive(self, other_string): + return not (other_string.lower() in self.value.lower()) + + @type_operator(FIELD_TEXT) + def does_not_match_regex(self, regex): + return not re.search(regex, self.value) + + @type_operator(FIELD_NO_INPUT) + def is_empty(self): + return (self.value is None) or (self.value.strip() == "") + @export_type class NumericType(BaseType): @@ -104,6 +141,8 @@ class NumericType(BaseType): @staticmethod def _assert_valid_value_and_cast(value): + if value == None: + return None if isinstance(value, float): # In python 2.6, casting float to Decimal doesn't work return float_to_decimal(value) @@ -117,24 +156,43 @@ def _assert_valid_value_and_cast(value): @type_operator(FIELD_NUMERIC) def equal_to(self, other_numeric): + if self.does_not_exist(): + return False return abs(self.value - other_numeric) <= self.EPSILON + + @type_operator(FIELD_NUMERIC) + def not_equal_to(self, other_numeric): + if self.does_not_exist(): + return False + return abs(self.value - other_numeric) > self.EPSILON @type_operator(FIELD_NUMERIC) def greater_than(self, other_numeric): - return (self.value - other_numeric) > self.EPSILON + if self.does_not_exist(): + return False + return (self.value - other_numeric) > self.EPSILON @type_operator(FIELD_NUMERIC) def greater_than_or_equal_to(self, other_numeric): + if self.does_not_exist(): + return False return self.greater_than(other_numeric) or self.equal_to(other_numeric) @type_operator(FIELD_NUMERIC) def less_than(self, other_numeric): - return (other_numeric - self.value) > self.EPSILON - + if self.does_not_exist(): + return False + return (other_numeric - self.value) > self.EPSILON + @type_operator(FIELD_NUMERIC) def less_than_or_equal_to(self, other_numeric): + if self.does_not_exist(): + return False return self.less_than(other_numeric) or self.equal_to(other_numeric) - + + @type_operator(FIELD_NO_INPUT) + def does_not_exist(self): + return self.value == None @export_type class BooleanType(BaseType): @@ -180,6 +238,30 @@ def contains(self, other_value): if self._case_insensitive_equal_to(val, other_value): return True return False + + @type_operator(FIELD_SELECT, assert_type_for_arguments=False) + def contains_any(self, other_value): + if not other_value or len(other_value) == 0: + return False + if not self.value or len(self.value) == 0: + return False + + self_set = set([val.lower() if isinstance(val, string_types) else val for val in self.value]) + other_set = set([val.lower() if isinstance(val, string_types) else val for val in other_value]) + + return len(self_set.intersection(other_set)) > 0 + + @type_operator(FIELD_SELECT, assert_type_for_arguments=False) + def contains_all(self, other_value): + if not other_value or len(other_value) == 0: + return False + if not self.value or len(self.value) == 0: + return False + + self_set = set([val.lower() if isinstance(val, string_types) else val for val in self.value]) + other_set = set([val.lower() if isinstance(val, string_types) else val for val in other_value]) + + return len(self_set.intersection(other_set)) == len(other_set) @type_operator(FIELD_SELECT, assert_type_for_arguments=False) def does_not_contain(self, other_value): @@ -235,3 +317,13 @@ def shares_exactly_one_element_with(self, other_value): @type_operator(FIELD_SELECT_MULTIPLE) def shares_no_elements_with(self, other_value): return not self.shares_at_least_one_element_with(other_value) + + + # This operator is only implemented for the posting rule engine at the moment + @type_operator(FIELD_SELECT_MULTIPLE, label="Compare State With Item (Only for Posting Rule Engine)") + def compare_state_with_item(self, other_value): + other_value = set(other_value) + for state in self.value: + if other_value.issubset(state): + return True + return False \ No newline at end of file diff --git a/business_rules/variables.py b/business_rules/variables.py index b7863f60..7de47185 100644 --- a/business_rules/variables.py +++ b/business_rules/variables.py @@ -7,11 +7,14 @@ BooleanType, SelectType, SelectMultipleType) +from . import fields + class BaseVariables(object): """ Classes that hold a collection of variables to use with the rules engine should inherit from this. """ + @classmethod def get_all_variables(cls): methods = inspect.getmembers(cls) @@ -19,43 +22,84 @@ def get_all_variables(cls): 'label': m[1].label, 'field_type': m[1].field_type.name, 'options': m[1].options, - } for m in methods if getattr(m[1], 'is_rule_variable', False)] + 'docs': inspect.getdoc(m[1]), + 'params': m[1].params, + } for m in methods if getattr(m[1], 'is_rule_variable', False)] + + +def _validate_variable_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" \ + " variable {1}".format( + param_name, func.__name__)) + if field_type not in valid_fields: + raise AssertionError("Unknown field type {0} specified for" \ + " variable {1} param {2}".format( + field_type, func.__name__, param_name)) -def rule_variable(field_type, label=None, options=None): + +def rule_variable(var_field_type, label=None, options=None, params=None): """ Decorator to make a function into a rule variable """ options = options or [] + params = params or [] + def wrapper(func): - if not (type(field_type) == type and issubclass(field_type, BaseType)): - raise AssertionError("{0} is not instance of BaseType in"\ - " rule_variable field_type".format(field_type)) - func.field_type = field_type + params_ = params + if not (type(var_field_type) == type and issubclass(var_field_type, BaseType)): + raise AssertionError("{0} is not instance of BaseType in" \ + " rule_variable field_type".format(var_field_type)) + + if isinstance(params, dict): + params_ = [dict(label=fn_name_to_pretty_label(name), + name=name, + fieldType=field_type) \ + for name, field_type in params.items()] + _validate_variable_parameters(func, params_) + func.field_type = var_field_type func.is_rule_variable = True func.label = label \ - or fn_name_to_pretty_label(func.__name__) + 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=None): 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, params=params)(label) + return rule_variable(field_type, label=label, params=params) + + +def numeric_rule_variable(label=None, params=None): + return _rule_variable_wrapper(NumericType, label, params=params) + + +def string_rule_variable(label=None, params=None): + return _rule_variable_wrapper(StringType, label, params=params) + -def numeric_rule_variable(label=None): - return _rule_variable_wrapper(NumericType, label) +def boolean_rule_variable(label=None, params=None): + return _rule_variable_wrapper(BooleanType, label, params=params) -def string_rule_variable(label=None): - return _rule_variable_wrapper(StringType, label) -def boolean_rule_variable(label=None): - return _rule_variable_wrapper(BooleanType, label) +def select_rule_variable(label=None, options=None, params=None): + return rule_variable(SelectType, label=label, options=options, params=params) -def select_rule_variable(label=None, options=None): - return rule_variable(SelectType, label=label, options=options) -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/setup.py b/setup.py index 68dde442..82430e77 100644 --- a/setup.py +++ b/setup.py @@ -6,14 +6,11 @@ with open('README.md') as f: readme = f.read() -with open('HISTORY.md') as f: - history = f.read() - setup( name='business-rules', version=version, description='Python DSL for setting up business intelligence rules that can be configured without code', - long_description='{}\n\n{}'.format(readme, history), + long_description='{}\n\n{}'.format(readme, ""), long_description_content_type='text/markdown', author='Venmo', author_email='open-source@venmo.com', diff --git a/tests/test_actions_class.py b/tests/test_actions_class.py index d57a427b..bcec807e 100644 --- a/tests/test_actions_class.py +++ b/tests/test_actions_class.py @@ -16,6 +16,7 @@ class SomeActions(BaseActions): @rule_action(params={'foo':FIELD_TEXT}) def some_action(self, foo): + """some docs""" return "blah" def non_action(self): @@ -25,6 +26,7 @@ def non_action(self): self.assertEqual(len(actions), 1) self.assertEqual(actions[0]['name'], 'some_action') self.assertEqual(actions[0]['label'], 'Some Action') + self.assertEqual(actions[0]['docs'], 'some docs') self.assertEqual(actions[0]['params'], [{'fieldType': FIELD_TEXT, 'name': 'foo', 'label': 'Foo'}]) # should work on an instance of the class too diff --git a/tests/test_integration.py b/tests/test_integration.py index 008228cf..796bfeb5 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -3,9 +3,9 @@ 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 unittest import TestCase + class SomeVariables(BaseVariables): @string_rule_variable() @@ -20,6 +20,14 @@ def ten(self): def true_bool(self): return True + @numeric_rule_variable( + params=[{"fieldType": FIELD_NUMERIC, "name": "x", "label": "X"}, + {"fieldType": FIELD_NUMERIC, "name": "y", "label": "Y"}] + ) + def addition(self, x, y): + return x + y + + class SomeActions(BaseActions): @rule_action(params={"foo": FIELD_NUMERIC}) @@ -32,15 +40,16 @@ def some_other_action(self, bar): pass 'name': 'baz', 'label': 'Baz', 'options': [ - {'label': 'Chose Me', 'name': 'chose_me'}, - {'label': 'Or Me', 'name': 'or_me'} - ]}]) + {'label': 'Chose Me', 'name': 'chose_me'}, + {'label': 'Or Me', 'name': 'or_me'} + ]}]) def some_select_action(self, baz): pass class IntegrationTests(TestCase): """ Integration test, using the library like a user would. """ + def test_true_boolean_variable(self): condition = { 'name': 'true_bool', @@ -75,7 +84,7 @@ def test_check_incorrect_method_name(self): condition = {'name': 'food', 'operator': 'equal_to', 'value': 'm'} - err_string = 'Variable food is not defined in class SomeVariables' + err_string = 'Variable food or params {} is not defined in class SomeVariables' with self.assertRaisesRegex(AssertionError, err_string): check_condition(condition, SomeVariables()) @@ -86,6 +95,26 @@ def test_check_incorrect_operator_name(self): with self.assertRaises(AssertionError): check_condition(condition, SomeVariables()) + def test_numeric_variable_with_params(self): + condition = { + "name": "addition", + "operator": "equal_to", + "value": 15, + "params": {"x": 9, "y": 6}, + } + condition_result = check_condition(condition, SomeVariables()) + self.assertTrue(condition_result) + + def test_variable_missing_params(self): + condition = { + "name": "addition", + "operator": "equal_to", + "value": 10, + "params": {}, + } + err_string = "addition\(\) missing 2 required positional arguments: 'x' and 'y'" + with self.assertRaisesRegex(TypeError, err_string): + check_condition(condition, SomeVariables()) def test_export_rule_data(self): """ Tests that export_rule_data has the three expected keys @@ -93,81 +122,111 @@ def test_export_rule_data(self): """ all_data = export_rule_data(SomeVariables(), SomeActions()) self.assertEqual(all_data.get("actions"), - [{"name": "some_action", - "label": "Some Action", - "params": [{'fieldType': 'numeric', 'label': 'Foo', 'name': 'foo'}]}, - {"name": "some_other_action", - "label": "woohoo", - "params": [{'fieldType': 'text', 'label': 'Bar', 'name': 'bar'}]}, - {"name": "some_select_action", - "label": "Some Select Action", - "params":[{'fieldType': FIELD_SELECT, - 'name': 'baz', - 'label': 'Baz', - 'options': [ - {'label': 'Chose Me', 'name': 'chose_me'}, - {'label': 'Or Me', 'name': 'or_me'} - ]}] - } - ]) - + [{"name": "some_action", + "label": "Some Action", + "docs": None, + "params": [{'fieldType': 'numeric', 'label': 'Foo', 'name': 'foo'}]}, + {"name": "some_other_action", + "label": "woohoo", + "docs": None, + "params": [{'fieldType': 'text', 'label': 'Bar', 'name': 'bar'}]}, + {"name": "some_select_action", + "label": "Some Select Action", + "docs": None, + "params": [{'fieldType': FIELD_SELECT, + 'name': 'baz', + 'label': 'Baz', + 'options': [ + {'label': 'Chose Me', 'name': 'chose_me'}, + {'label': 'Or Me', 'name': 'or_me'} + ]}] + } + ]) self.assertEqual(all_data.get("variables"), - [{"name": "foo", + [{"name": "addition", + "label": "Addition", + "field_type": "numeric", + "options": [], + "docs": None, + "params": [{'fieldType': 'numeric', 'name': 'x', 'label': 'X'}, + {'fieldType': 'numeric', 'name': 'y', 'label': 'Y'} + ] + }, + {"name": "foo", "label": "Foo", + "docs": None, "field_type": "string", - "options": []}, + "options": [], + "params": [], + }, {"name": "ten", "label": "Diez", + "docs": None, "field_type": "numeric", - "options": []}, + "options": [], + "params": [], + }, {'name': 'true_bool', 'label': 'True Bool', + "docs": None, 'field_type': 'boolean', - 'options': []}]) - - self.assertEqual(all_data.get("variable_type_operators"), - {'boolean': [{'input_type': 'none', - 'label': 'Is False', - 'name': 'is_false'}, - {'input_type': 'none', - 'label': 'Is True', - 'name': 'is_true'}], - 'numeric': [{'input_type': 'numeric', - 'label': 'Equal To', - 'name': 'equal_to'}, - {'input_type': 'numeric', 'label': 'Greater Than', 'name': 'greater_than'}, - {'input_type': 'numeric', - 'label': 'Greater Than Or Equal To', - 'name': 'greater_than_or_equal_to'}, - {'input_type': 'numeric', 'label': 'Less Than', 'name': 'less_than'}, - {'input_type': 'numeric', - 'label': 'Less Than Or Equal To', - 'name': 'less_than_or_equal_to'}], - 'select': [{'input_type': 'select', 'label': 'Contains', 'name': 'contains'}, - {'input_type': 'select', - 'label': 'Does Not Contain', - 'name': 'does_not_contain'}], - 'select_multiple': [{'input_type': 'select_multiple', - 'label': 'Contains All', - 'name': 'contains_all'}, - {'input_type': 'select_multiple', - 'label': 'Is Contained By', - 'name': 'is_contained_by'}, - {'input_type': 'select_multiple', - 'label': 'Shares At Least One Element With', - 'name': 'shares_at_least_one_element_with'}, - {'input_type': 'select_multiple', - 'label': 'Shares Exactly One Element With', - 'name': 'shares_exactly_one_element_with'}, - {'input_type': 'select_multiple', - 'label': 'Shares No Elements With', - 'name': 'shares_no_elements_with'}], - 'string': [{'input_type': 'text', 'label': 'Contains', 'name': 'contains'}, - {'input_type': 'text', 'label': 'Ends With', 'name': 'ends_with'}, - {'input_type': 'text', 'label': 'Equal To', 'name': 'equal_to'}, - {'input_type': 'text', - 'label': 'Equal To (case insensitive)', - 'name': 'equal_to_case_insensitive'}, - {'input_type': 'text', 'label': 'Matches Regex', 'name': 'matches_regex'}, - {'input_type': 'none', 'label': 'Non Empty', 'name': 'non_empty'}, - {'input_type': 'text', 'label': 'Starts With', 'name': 'starts_with'}]}) + 'options': [], + "params": [], + }, + ]) + + variable_type_operators = all_data.get("variable_type_operators") + self.assertEqual(variable_type_operators, expected_variable_type_operators) + + +expected_variable_type_operators = { + 'boolean': [ + {'input_type': 'none', 'label': 'Is False', 'name': 'is_false'}, + {'input_type': 'none', 'label': 'Is True', 'name': 'is_true'} + ], + 'numeric': [ + {'input_type': 'none', 'label': 'Does Not Exist', 'name': 'does_not_exist'}, + {'input_type': 'numeric', 'label': 'Equal To', 'name': 'equal_to'}, + {'input_type': 'numeric', 'label': 'Greater Than', 'name': 'greater_than'}, + {'input_type': 'numeric', 'label': 'Greater Than Or Equal To', 'name': 'greater_than_or_equal_to'}, + {'input_type': 'numeric', 'label': 'Less Than', 'name': 'less_than'}, + {'input_type': 'numeric', 'label': 'Less Than Or Equal To', 'name': 'less_than_or_equal_to'}, + {'input_type': 'numeric', 'label': 'Not Equal To', 'name': 'not_equal_to'} + ], + 'select': [ + {'input_type': 'select', 'label': 'Contains', 'name': 'contains'}, + {'input_type': 'select', 'label': 'Contains All', 'name': 'contains_all'}, + {'input_type': 'select', 'label': 'Contains Any', 'name': 'contains_any'}, + {'input_type': 'select', 'label': 'Does Not Contain', 'name': 'does_not_contain'} + ], + 'select_multiple': [ + {'input_type': 'select_multiple', 'label': 'Compare State With Item (Only for Posting Rule Engine)', + 'name': 'compare_state_with_item'}, + {'input_type': 'select_multiple', 'label': 'Contains All', 'name': 'contains_all'}, + {'input_type': 'select_multiple', 'label': 'Is Contained By', 'name': 'is_contained_by'}, + {'input_type': 'select_multiple', 'label': 'Shares At Least One Element With', + 'name': 'shares_at_least_one_element_with'}, + {'input_type': 'select_multiple', 'label': 'Shares Exactly One Element With', + 'name': 'shares_exactly_one_element_with'}, + {'input_type': 'select_multiple', 'label': 'Shares No Elements With', 'name': 'shares_no_elements_with'} + ], + 'string': [ + {'input_type': 'text', 'label': 'Contains', 'name': 'contains'}, + {'input_type': 'text', 'label': 'Contains (case insensitive)', 'name': 'contains_case_insensitive'}, + {'input_type': 'text', 'label': 'Does Not Contain', 'name': 'does_not_contain'}, + {'input_type': 'text', 'label': 'Does not contain (case insensitive)', + 'name': 'does_not_contain_case_insensitive'}, + {'input_type': 'text', 'label': 'Does Not End With', 'name': 'does_not_end_with'}, + {'input_type': 'text', 'label': 'Does Not Match Regex', 'name': 'does_not_match_regex'}, + {'input_type': 'text', 'label': 'Does Not Start With', 'name': 'does_not_start_with'}, + {'input_type': 'text', 'label': 'Ends With', 'name': 'ends_with'}, + {'input_type': 'text', 'label': 'Equal To', 'name': 'equal_to'}, + {'input_type': 'text', 'label': 'Equal To (case insensitive)', 'name': 'equal_to_case_insensitive'}, + {'input_type': 'none', 'label': 'Is Empty', 'name': 'is_empty'}, + {'input_type': 'text', 'label': 'Matches Regex', 'name': 'matches_regex'}, + {'input_type': 'none', 'label': 'Non Empty', 'name': 'non_empty'}, + {'input_type': 'text', 'label': 'Not Equal To', 'name': 'not_equal_to'}, + {'input_type': 'text', 'label': 'Not equal To (case insensitive)', 'name': 'not_equal_to_case_insensitive'}, + {'input_type': 'text', 'label': 'Starts With', 'name': 'starts_with'} + ] +} diff --git a/tests/test_operators.py b/tests/test_operators.py index 824d4a69..55be42d0 100644 --- a/tests/test_operators.py +++ b/tests/test_operators.py @@ -53,6 +53,9 @@ def test_instantiate(self): err_string = "foo is not a valid numeric type" with self.assertRaisesRegex(AssertionError, err_string): NumericType("foo") + + num = NumericType(None) + self.assertEqual(num.value, None ) def test_numeric_type_validates_and_casts_decimal(self): ten_dec = Decimal(10) @@ -80,6 +83,15 @@ def test_numeric_equal_to(self): self.assertTrue(NumericType(10).equal_to(Decimal('10.0'))) self.assertFalse(NumericType(10).equal_to(10.00001)) self.assertFalse(NumericType(10).equal_to(11)) + self.assertFalse(NumericType(None).equal_to(10)) + self.assertFalse(NumericType(None).equal_to(0)) + + def test_numeric_not_equal_to(self): + self.assertTrue(NumericType(1).not_equal_to(10)) + self.assertTrue(NumericType(10.1).not_equal_to(10)) + self.assertFalse(NumericType(None).not_equal_to(10)) + self.assertFalse(NumericType(None).not_equal_to(0)) + def test_other_value_not_numeric(self): error_string = "10 is not a valid numeric type" @@ -92,6 +104,7 @@ def test_numeric_greater_than(self): self.assertTrue(NumericType(10.1).greater_than(10)) self.assertFalse(NumericType(10.000001).greater_than(10)) self.assertTrue(NumericType(10.000002).greater_than(10)) + self.assertFalse(NumericType(None).greater_than(10)) def test_numeric_greater_than_or_equal_to(self): self.assertTrue(NumericType(10).greater_than_or_equal_to(1)) @@ -100,6 +113,7 @@ def test_numeric_greater_than_or_equal_to(self): self.assertTrue(NumericType(10.000001).greater_than_or_equal_to(10)) self.assertTrue(NumericType(10.000002).greater_than_or_equal_to(10)) self.assertTrue(NumericType(10).greater_than_or_equal_to(10)) + self.assertFalse(NumericType(None).greater_than_or_equal_to(10)) def test_numeric_less_than(self): self.assertTrue(NumericType(1).less_than(10)) @@ -107,6 +121,8 @@ def test_numeric_less_than(self): self.assertTrue(NumericType(10).less_than(10.1)) self.assertFalse(NumericType(10).less_than(10.000001)) self.assertTrue(NumericType(10).less_than(10.000002)) + self.assertFalse(NumericType(None).less_than(10)) + def test_numeric_less_than_or_equal_to(self): self.assertTrue(NumericType(1).less_than_or_equal_to(10)) @@ -115,6 +131,11 @@ def test_numeric_less_than_or_equal_to(self): self.assertTrue(NumericType(10).less_than_or_equal_to(10.000001)) self.assertTrue(NumericType(10).less_than_or_equal_to(10.000002)) self.assertTrue(NumericType(10).less_than_or_equal_to(10)) + self.assertFalse(NumericType(None).less_than_or_equal_to(10)) + + def test_does_not_exist(self): + self.assertFalse(NumericType(1).does_not_exist()) + self.assertTrue(NumericType(None).does_not_exist()) class BooleanOperatorTests(TestCase): @@ -146,6 +167,22 @@ def test_does_not_contain(self): self.assertFalse(SelectType([1, 2]).does_not_contain(2)) self.assertFalse(SelectType([1, 2, "a"]).does_not_contain("A")) + def test_contains_all(self): + self.assertTrue(SelectType([1, 2]).contains_all([2])) + self.assertTrue(SelectType([1, 2, "a"]).contains_all(["A"])) + self.assertTrue(SelectType([1, 2]).contains_all([1, 2, 1])) + self.assertFalse(SelectType([1, 2]).contains_all([])) + self.assertFalse(SelectType([1, 2]).contains_all([3])) + self.assertFalse(SelectType([1, 2]).contains_all([1, 2, 3])) + + def test_contains_any(self): + self.assertTrue(SelectType([1, 2]).contains_any([2])) + self.assertTrue(SelectType([1, 2, "a"]).contains_any(["A"])) + self.assertTrue(SelectType([1, 2]).contains_any([1, 2, 1])) + self.assertFalse(SelectType([1, 2]).contains_any([3])) + self.assertFalse(SelectType([]).contains_any([3])) + self.assertFalse(SelectType([1, 2]).contains_any([])) + class SelectMultipleOperatorTests(TestCase): @@ -190,3 +227,21 @@ def test_shares_no_elements_with(self): shares_no_elements_with([2, 3])) self.assertFalse(SelectMultipleType([1, 2, "a"]). shares_no_elements_with([4, "A"])) + + + def test_contains_all(self): + self.assertTrue(SelectMultipleType([1, 2]). + contains_all([2, 1])) + self.assertFalse(SelectMultipleType([1, 2]). + contains_all([2, 3])) + self.assertTrue(SelectMultipleType([1, 2, "a"]). + contains_all([2, 1, "A"])) + + + def test_compare_state_with_item(self): + self.assertTrue(SelectMultipleType([["same_check_number", "same_check_date"], ["different_check_number", "different_check_date"]]). + compare_state_with_item(["same_check_number", "same_check_date"])) + self.assertFalse(SelectMultipleType([["same_check_number", "same_check_date"], ["different_check_number", "different_check_date"]]). + compare_state_with_item(["same_check_number", "different_check_date"])) + self.assertTrue(SelectMultipleType([["same_check_number", "same_check_date", "different_era_id"], ["different_check_number", "different_check_date, same_era_id"]]). + compare_state_with_item(["same_check_number", "different_era_id"])) \ No newline at end of file diff --git a/tests/test_variables_class.py b/tests/test_variables_class.py index 23628dc9..f47e1f11 100644 --- a/tests/test_variables_class.py +++ b/tests/test_variables_class.py @@ -1,4 +1,6 @@ -from business_rules.variables import BaseVariables, rule_variable +from business_rules.engine import check_condition +from business_rules.fields import FIELD_NUMERIC +from business_rules.variables import BaseVariables, rule_variable, boolean_rule_variable from business_rules.operators import StringType from unittest import TestCase @@ -16,18 +18,36 @@ class SomeVariables(BaseVariables): @rule_variable(StringType) def this_is_rule_1(self): + """some docs""" return "blah" def non_rule(self): return "baz" + @boolean_rule_variable( + params=[{"fieldType": FIELD_NUMERIC, "name": "x", "label": "X"}, + {"fieldType": FIELD_NUMERIC, "name": "y", "label": "Y"}] + ) + def compare(self, x, y): + return x > y + 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(len(vars), 2) + self.assertEqual(vars[1]['name'], 'this_is_rule_1') + self.assertEqual(vars[1]['label'], 'This Is Rule 1') + self.assertEqual(vars[1]['field_type'], 'string') + self.assertEqual(vars[1]['docs'], 'some docs') + self.assertEqual(vars[1]['options'], []) # should work on an instance of the class too - self.assertEqual(len(SomeVariables().get_all_variables()), 1) + self.assertEqual(len(SomeVariables().get_all_variables()), 2) + + condition = { + "name": "compare", + "operator": "is_true", + "value": '', + "params": {"x": 9, "y": 6}, + } + condition_result = check_condition(condition, SomeVariables()) + self.assertTrue(condition_result)