From ca1569b7425bdb454b879ed8604cbbee631a4aa1 Mon Sep 17 00:00:00 2001 From: Dhanvee Ivaturi Date: Thu, 5 Jan 2023 11:46:00 -0800 Subject: [PATCH 01/21] Add more operators (#1) --- business_rules/operators.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/business_rules/operators.py b/business_rules/operators.py index d43e5b1f..f965bb1e 100644 --- a/business_rules/operators.py +++ b/business_rules/operators.py @@ -87,6 +87,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 +99,10 @@ def matches_regex(self, regex): def non_empty(self): return bool(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): @@ -119,6 +127,10 @@ def _assert_valid_value_and_cast(value): def equal_to(self, other_numeric): return abs(self.value - other_numeric) <= self.EPSILON + @type_operator(FIELD_NUMERIC) + def not_equal_to(self, other_numeric): + 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 From 65329e41d7602c041e3d9e581edef313bb7e8efd Mon Sep 17 00:00:00 2001 From: Dhanvee Ivaturi Date: Sun, 8 Jan 2023 22:49:34 -0800 Subject: [PATCH 02/21] Bump version to 1.1.athelas --- business_rules/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/business_rules/__init__.py b/business_rules/__init__.py index 348801bb..99db537f 100644 --- a/business_rules/__init__.py +++ b/business_rules/__init__.py @@ -1,4 +1,4 @@ -__version__ = '1.1.1' +__version__ = '1.1.athelas' from .engine import run_all from .utils import export_rule_data From aa44d0ec13e918449161a5ddc2fd3a2d5dc2ced8 Mon Sep 17 00:00:00 2001 From: pransu-ath <118312980+pransu-ath@users.noreply.github.com> Date: Sun, 8 Jan 2023 23:39:51 -0800 Subject: [PATCH 03/21] bump version to test --- business_rules/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/business_rules/__init__.py b/business_rules/__init__.py index 99db537f..3ee76471 100644 --- a/business_rules/__init__.py +++ b/business_rules/__init__.py @@ -1,4 +1,4 @@ -__version__ = '1.1.athelas' +__version__ = '1.1.1+athelas' from .engine import run_all from .utils import export_rule_data From 726360c22c9b7de1b2c02fa89276fc0b55660fd1 Mon Sep 17 00:00:00 2001 From: Dhanvee Ivaturi Date: Fri, 13 Jan 2023 17:17:02 -0800 Subject: [PATCH 04/21] Add more operators for string types --- business_rules/operators.py | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/business_rules/operators.py b/business_rules/operators.py index f965bb1e..00c4a2dc 100644 --- a/business_rules/operators.py +++ b/business_rules/operators.py @@ -99,9 +99,37 @@ 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() == "" + return (self.value is None) or (self.value.strip() == "") @export_type From f3546c952713cd82c126d27e2dc6e19b96bbb241 Mon Sep 17 00:00:00 2001 From: Dhanvee Ivaturi Date: Wed, 4 Oct 2023 00:14:23 -0700 Subject: [PATCH 05/21] Add support for returning documentation info --- business_rules/__init__.py | 2 +- business_rules/actions.py | 3 ++- business_rules/operators.py | 3 ++- business_rules/variables.py | 1 + setup.py | 5 +---- tests/test_actions_class.py | 2 ++ tests/test_integration.py | 6 ++++++ tests/test_variables_class.py | 2 ++ 8 files changed, 17 insertions(+), 7 deletions(-) diff --git a/business_rules/__init__.py b/business_rules/__init__.py index 3ee76471..c78318b3 100644 --- a/business_rules/__init__.py +++ b/business_rules/__init__.py @@ -1,4 +1,4 @@ -__version__ = '1.1.1+athelas' +__version__ = '1.1.1+athelas2' 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..15e0aa6c 100644 --- a/business_rules/actions.py +++ b/business_rules/actions.py @@ -13,7 +13,8 @@ def get_all_actions(cls): methods = inspect.getmembers(cls) return [{'name': m[0], 'label': m[1].label, - 'params': m[1].params + '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): diff --git a/business_rules/operators.py b/business_rules/operators.py index 00c4a2dc..f5f1f723 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)] diff --git a/business_rules/variables.py b/business_rules/variables.py index b7863f60..96a550b1 100644 --- a/business_rules/variables.py +++ b/business_rules/variables.py @@ -19,6 +19,7 @@ def get_all_variables(cls): 'label': m[1].label, 'field_type': m[1].field_type.name, 'options': m[1].options, + 'docs': inspect.getdoc(m[1]) } for m in methods if getattr(m[1], 'is_rule_variable', False)] 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..341a9ac5 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -95,12 +95,15 @@ def test_export_rule_data(self): self.assertEqual(all_data.get("actions"), [{"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', @@ -114,14 +117,17 @@ def test_export_rule_data(self): self.assertEqual(all_data.get("variables"), [{"name": "foo", "label": "Foo", + "docs": None, "field_type": "string", "options": []}, {"name": "ten", "label": "Diez", + "docs": None, "field_type": "numeric", "options": []}, {'name': 'true_bool', 'label': 'True Bool', + "docs": None, 'field_type': 'boolean', 'options': []}]) diff --git a/tests/test_variables_class.py b/tests/test_variables_class.py index 23628dc9..9d46df5f 100644 --- a/tests/test_variables_class.py +++ b/tests/test_variables_class.py @@ -16,6 +16,7 @@ class SomeVariables(BaseVariables): @rule_variable(StringType) def this_is_rule_1(self): + """some docs""" return "blah" def non_rule(self): @@ -26,6 +27,7 @@ def non_rule(self): 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]['docs'], 'some docs') self.assertEqual(vars[0]['options'], []) # should work on an instance of the class too From f02b1bb02eb4a17b2eb1469c9b05f45ff0c89140 Mon Sep 17 00:00:00 2001 From: elliotberdy-athelas <164943162+elliotberdy-athelas@users.noreply.github.com> Date: Mon, 29 Jul 2024 18:14:14 -0700 Subject: [PATCH 06/21] Elliot/add posting rule operator (#3) * add operator for comparison in the posting rule engine * get rid of max diff * small fix * wip * small changes including name * added label specifying posting rule engine only * remove print statements --- business_rules/operators.py | 10 ++++ tests/test_integration.py | 94 +++++++++++++++++++------------------ tests/test_operators.py | 18 +++++++ 3 files changed, 76 insertions(+), 46 deletions(-) diff --git a/business_rules/operators.py b/business_rules/operators.py index f5f1f723..d69d1a1c 100644 --- a/business_rules/operators.py +++ b/business_rules/operators.py @@ -276,3 +276,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/tests/test_integration.py b/tests/test_integration.py index 341a9ac5..b59e6109 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -131,49 +131,51 @@ def test_export_rule_data(self): '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'}]}) + 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'}, + {'input_type': 'numeric', 'label': 'Not Equal To', 'name': 'not_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': '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..fb080c76 100644 --- a/tests/test_operators.py +++ b/tests/test_operators.py @@ -190,3 +190,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 From 3de3687c59142a620294e5794176a2f696e69357 Mon Sep 17 00:00:00 2001 From: Elliot Berdy Date: Mon, 29 Jul 2024 18:24:35 -0700 Subject: [PATCH 07/21] update version --- business_rules/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/business_rules/__init__.py b/business_rules/__init__.py index c78318b3..8e6ad609 100644 --- a/business_rules/__init__.py +++ b/business_rules/__init__.py @@ -1,4 +1,4 @@ -__version__ = '1.1.1+athelas2' +__version__ = '1.1.1+athelas3' from .engine import run_all from .utils import export_rule_data From 820cbedbe57a8ec4132d8266a382fe2efe483603 Mon Sep 17 00:00:00 2001 From: Sid Pillai Date: Tue, 13 Aug 2024 13:08:51 -0700 Subject: [PATCH 08/21] feat: added null-handling functionality to numeric type. --- business_rules/operators.py | 31 ++++++++++++++++++++++++------- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/business_rules/operators.py b/business_rules/operators.py index d69d1a1c..dfc2aab4 100644 --- a/business_rules/operators.py +++ b/business_rules/operators.py @@ -141,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) @@ -154,28 +156,43 @@ def _assert_valid_value_and_cast(value): @type_operator(FIELD_NUMERIC) def equal_to(self, other_numeric): - return abs(self.value - other_numeric) <= self.EPSILON + if self.exists(): + return abs(self.value - other_numeric) <= self.EPSILON + return False @type_operator(FIELD_NUMERIC) def not_equal_to(self, other_numeric): - return abs(self.value - other_numeric) > self.EPSILON + if self.exists(): + return abs(self.value - other_numeric) > self.EPSILON + return False @type_operator(FIELD_NUMERIC) def greater_than(self, other_numeric): - return (self.value - other_numeric) > self.EPSILON + if self.exists(): + return (self.value - other_numeric) > self.EPSILON + return False @type_operator(FIELD_NUMERIC) def greater_than_or_equal_to(self, other_numeric): - return self.greater_than(other_numeric) or self.equal_to(other_numeric) + if self.exists(): + return self.greater_than(other_numeric) or self.equal_to(other_numeric) + return False @type_operator(FIELD_NUMERIC) def less_than(self, other_numeric): - return (other_numeric - self.value) > self.EPSILON + if not self.exists(): + return (other_numeric - self.value) > self.EPSILON + return False @type_operator(FIELD_NUMERIC) def less_than_or_equal_to(self, other_numeric): - return self.less_than(other_numeric) or self.equal_to(other_numeric) - + if not self.exists(): + return self.less_than(other_numeric) or self.equal_to(other_numeric) + return False + + @type_operator(FIELD_NO_INPUT) + def exists(self): + return self.value != None @export_type class BooleanType(BaseType): From 904d5e0b78939ef302cb784b8824d253b9e6b6c3 Mon Sep 17 00:00:00 2001 From: Sid Pillai Date: Tue, 13 Aug 2024 13:37:40 -0700 Subject: [PATCH 09/21] test: tested behavior when numeric type gets null input --- business_rules/operators.py | 4 ++-- tests/test_operators.py | 11 +++++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/business_rules/operators.py b/business_rules/operators.py index dfc2aab4..6a71a060 100644 --- a/business_rules/operators.py +++ b/business_rules/operators.py @@ -180,13 +180,13 @@ def greater_than_or_equal_to(self, other_numeric): @type_operator(FIELD_NUMERIC) def less_than(self, other_numeric): - if not self.exists(): + if self.exists(): return (other_numeric - self.value) > self.EPSILON return False @type_operator(FIELD_NUMERIC) def less_than_or_equal_to(self, other_numeric): - if not self.exists(): + if self.exists(): return self.less_than(other_numeric) or self.equal_to(other_numeric) return False diff --git a/tests/test_operators.py b/tests/test_operators.py index fb080c76..9830e84e 100644 --- a/tests/test_operators.py +++ b/tests/test_operators.py @@ -80,6 +80,8 @@ 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)) + def test_other_value_not_numeric(self): error_string = "10 is not a valid numeric type" @@ -92,6 +94,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 +103,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 +111,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 +121,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_exists(self): + self.assertTrue(NumericType(1).exists()) + self.assertFalse(NumericType(None).exists()) class BooleanOperatorTests(TestCase): From 36b01c4453f994f1abfcaad9863a1d4dee31039a Mon Sep 17 00:00:00 2001 From: Sid Pillai Date: Tue, 13 Aug 2024 14:07:32 -0700 Subject: [PATCH 10/21] update: integration test with new operator --- tests/test_integration.py | 16 +++++++++------- tests/test_operators.py | 3 +++ 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/tests/test_integration.py b/tests/test_integration.py index b59e6109..845483c6 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -3,7 +3,7 @@ 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 pprint import pprint from unittest import TestCase class SomeVariables(BaseVariables): @@ -130,16 +130,20 @@ def test_export_rule_data(self): "docs": None, 'field_type': 'boolean', 'options': []}]) + + variable_type_operators = all_data.get("variable_type_operators") + self.assertEqual(variable_type_operators, expected_variable_type_operators) + - self.assertEqual( - all_data.get("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': 'numeric', 'label': 'Equal To', 'name': 'equal_to'}, + {'input_type': 'none', 'label': 'Exists', 'name': 'exists'}, {'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'}, @@ -176,6 +180,4 @@ def test_export_rule_data(self): {'input_type': 'text', 'label': 'Not equal To (case insensitive)', 'name': 'not_equal_to_case_insensitive'}, {'input_type': 'text', 'label': 'Starts With', 'name': 'starts_with'} ] - } - ) - + } \ No newline at end of file diff --git a/tests/test_operators.py b/tests/test_operators.py index 9830e84e..2d52cf05 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) From 9440889950ee660c7c03da81ac7774175f9299f1 Mon Sep 17 00:00:00 2001 From: Sid Pillai Date: Tue, 13 Aug 2024 14:17:42 -0700 Subject: [PATCH 11/21] test: reached 95% coverage by testing not equals --- tests/test_operators.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/test_operators.py b/tests/test_operators.py index 2d52cf05..57546c73 100644 --- a/tests/test_operators.py +++ b/tests/test_operators.py @@ -84,6 +84,11 @@ def test_numeric_equal_to(self): self.assertFalse(NumericType(10).equal_to(10.00001)) self.assertFalse(NumericType(10).equal_to(11)) self.assertFalse(NumericType(None).equal_to(10)) + + 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)) def test_other_value_not_numeric(self): From 8593e5ebca022d6324d786f5ad8aa0e524db9450 Mon Sep 17 00:00:00 2001 From: Sid Pillai Date: Tue, 13 Aug 2024 14:19:21 -0700 Subject: [PATCH 12/21] rm: useless import --- tests/test_integration.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_integration.py b/tests/test_integration.py index 845483c6..606fff5a 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -3,7 +3,6 @@ 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 pprint import pprint from unittest import TestCase class SomeVariables(BaseVariables): From 02a50b42fb6bc15a04c96888d54f3ceb7b127e8d Mon Sep 17 00:00:00 2001 From: Sid Pillai Date: Tue, 13 Aug 2024 14:34:50 -0700 Subject: [PATCH 13/21] test: tested that None != 0 --- tests/test_operators.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_operators.py b/tests/test_operators.py index 57546c73..005e3b60 100644 --- a/tests/test_operators.py +++ b/tests/test_operators.py @@ -84,11 +84,13 @@ def test_numeric_equal_to(self): 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): From d554881a3a5f77cc37f78dca14480bb7ac317bfd Mon Sep 17 00:00:00 2001 From: Sid Pillai Date: Tue, 13 Aug 2024 16:42:29 -0700 Subject: [PATCH 14/21] refactor: return whether does not exist --- business_rules/operators.py | 46 ++++++++++++++++++------------------- tests/test_integration.py | 2 +- tests/test_operators.py | 6 ++--- 3 files changed, 27 insertions(+), 27 deletions(-) diff --git a/business_rules/operators.py b/business_rules/operators.py index 6a71a060..12f0f522 100644 --- a/business_rules/operators.py +++ b/business_rules/operators.py @@ -156,43 +156,43 @@ def _assert_valid_value_and_cast(value): @type_operator(FIELD_NUMERIC) def equal_to(self, other_numeric): - if self.exists(): - return abs(self.value - other_numeric) <= self.EPSILON - return False - + 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.exists(): - return abs(self.value - other_numeric) > self.EPSILON - return False + 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): - if self.exists(): - return (self.value - other_numeric) > self.EPSILON - return False + 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.exists(): - return self.greater_than(other_numeric) or self.equal_to(other_numeric) - return False + 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): - if self.exists(): - return (other_numeric - self.value) > self.EPSILON - return False - + 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.exists(): - return self.less_than(other_numeric) or self.equal_to(other_numeric) - return False - + 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 exists(self): - return self.value != None + def does_not_exist(self): + return self.value == None @export_type class BooleanType(BaseType): diff --git a/tests/test_integration.py b/tests/test_integration.py index 606fff5a..a683fe30 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -141,8 +141,8 @@ def test_export_rule_data(self): {'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': 'none', 'label': 'Exists', 'name': 'exists'}, {'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'}, diff --git a/tests/test_operators.py b/tests/test_operators.py index 005e3b60..b76453f2 100644 --- a/tests/test_operators.py +++ b/tests/test_operators.py @@ -133,9 +133,9 @@ def test_numeric_less_than_or_equal_to(self): self.assertTrue(NumericType(10).less_than_or_equal_to(10)) self.assertFalse(NumericType(None).less_than_or_equal_to(10)) - def test_exists(self): - self.assertTrue(NumericType(1).exists()) - self.assertFalse(NumericType(None).exists()) + def test_does_not_exist(self): + self.assertFalse(NumericType(1).does_not_exist()) + self.assertTrue(NumericType(None).does_not_exist()) class BooleanOperatorTests(TestCase): From a4a176bbf3a760877da533a9b840f8a6e68f3d0d Mon Sep 17 00:00:00 2001 From: adithya-soma-athelas Date: Wed, 16 Oct 2024 12:48:17 -0700 Subject: [PATCH 15/21] [feat]: Add contains_any and contains_all operators for select type (#5) * contains any and contains all * tests * updated logic * tests * clean up * clean up * spicy set update --- business_rules/operators.py | 24 ++++++++++++++++++++++++ tests/test_integration.py | 2 ++ tests/test_operators.py | 16 ++++++++++++++++ 3 files changed, 42 insertions(+) diff --git a/business_rules/operators.py b/business_rules/operators.py index 12f0f522..44f6b61d 100644 --- a/business_rules/operators.py +++ b/business_rules/operators.py @@ -238,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): diff --git a/tests/test_integration.py b/tests/test_integration.py index a683fe30..f12b02ba 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -151,6 +151,8 @@ def test_export_rule_data(self): ], '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': [ diff --git a/tests/test_operators.py b/tests/test_operators.py index b76453f2..55be42d0 100644 --- a/tests/test_operators.py +++ b/tests/test_operators.py @@ -167,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): From da93dfc55d0572196c635166d1be899b3d9fa946 Mon Sep 17 00:00:00 2001 From: adithya-soma-athelas Date: Thu, 17 Oct 2024 10:05:18 -0700 Subject: [PATCH 16/21] Update version number (#6) --- business_rules/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/business_rules/__init__.py b/business_rules/__init__.py index 8e6ad609..78833e6c 100644 --- a/business_rules/__init__.py +++ b/business_rules/__init__.py @@ -1,4 +1,4 @@ -__version__ = '1.1.1+athelas3' +__version__ = '1.1.1+athelas5' from .engine import run_all from .utils import export_rule_data From c5499cb874d9a3c213d3d9f075c6d4aa658e2eb2 Mon Sep 17 00:00:00 2001 From: ram-janapala Date: Tue, 21 Jan 2025 21:37:43 +0530 Subject: [PATCH 17/21] Adding param suupport for variables --- .gitignore | 1 + business_rules/actions.py | 29 +++-- business_rules/engine.py | 25 +++-- business_rules/variables.py | 83 ++++++++++---- tests/test_integration.py | 201 +++++++++++++++++++++------------- tests/test_variables_class.py | 34 ++++-- 6 files changed, 250 insertions(+), 123 deletions(-) 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/actions.py b/business_rules/actions.py index 15e0aa6c..59644e02 100644 --- a/business_rules/actions.py +++ b/business_rules/actions.py @@ -8,6 +8,7 @@ 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) @@ -15,7 +16,8 @@ def get_all_actions(cls): 'label': m[1].label, 'params': m[1].params, 'docs': inspect.getdoc(m[1]), - } for m in methods if getattr(m[1], 'is_rule_action', False)] + } 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 @@ -24,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..b76f06bb 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 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() + 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/variables.py b/business_rules/variables.py index 96a550b1..e1da49e8 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,44 +22,84 @@ def get_all_variables(cls): 'label': m[1].label, 'field_type': m[1].field_type.name, 'options': m[1].options, - 'docs': inspect.getdoc(m[1]) - } 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): +def select_multiple_rule_variable(label=None, options=None, params=None): return rule_variable(SelectMultipleType, label=label, options=options) diff --git a/tests/test_integration.py b/tests/test_integration.py index f12b02ba..e9fd603d 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -5,6 +5,7 @@ from business_rules.fields import FIELD_TEXT, FIELD_NUMERIC, FIELD_SELECT from unittest import TestCase + class SomeVariables(BaseVariables): @string_rule_variable() @@ -19,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}) @@ -31,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', @@ -85,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_check_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 @@ -92,93 +122,112 @@ 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", - "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'} - ]}] - } - ]) - + [{"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'} + ]}] + } + ]) + print(all_data.get("variables")) 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': []}]) - + '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'} - ] - } \ No newline at end of file + '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_variables_class.py b/tests/test_variables_class.py index 9d46df5f..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 @@ -22,14 +24,30 @@ def this_is_rule_1(self): 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]['docs'], 'some docs') - 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) From da37440e7575a26d3bf9bc9265c94ade605dcbe8 Mon Sep 17 00:00:00 2001 From: ram-janapala Date: Tue, 21 Jan 2025 21:41:22 +0530 Subject: [PATCH 18/21] Adding param suupport for variables --- tests/test_integration.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_integration.py b/tests/test_integration.py index e9fd603d..e7a0d009 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -105,7 +105,7 @@ def test_numeric_variable_with_params(self): condition_result = check_condition(condition, SomeVariables()) self.assertTrue(condition_result) - def test_check_missing_params(self): + def test_variable_missing_params(self): condition = { "name": "addition", "operator": "equal_to", From 25c18a82bbc94b5f7b057539228de05978e80424 Mon Sep 17 00:00:00 2001 From: ram-janapala Date: Fri, 24 Jan 2025 09:15:17 +0530 Subject: [PATCH 19/21] Adding docs and update the error messages --- business_rules/engine.py | 6 +++--- tests/test_integration.py | 1 - 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/business_rules/engine.py b/business_rules/engine.py index b76f06bb..aeb68279 100644 --- a/business_rules/engine.py +++ b/business_rules/engine.py @@ -60,15 +60,15 @@ def check_condition(condition, defined_variables): 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(**params) diff --git a/tests/test_integration.py b/tests/test_integration.py index e7a0d009..9b5c1927 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -142,7 +142,6 @@ def test_export_rule_data(self): ]}] } ]) - print(all_data.get("variables")) self.assertEqual(all_data.get("variables"), [{"name": "addition", "label": "Addition", From 264c38f7662be5c068baad3142344d377de164c7 Mon Sep 17 00:00:00 2001 From: ram-janapala Date: Fri, 24 Jan 2025 09:17:47 +0530 Subject: [PATCH 20/21] Adding docs and update the error messages --- business_rules/variables.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/business_rules/variables.py b/business_rules/variables.py index e1da49e8..7de47185 100644 --- a/business_rules/variables.py +++ b/business_rules/variables.py @@ -102,4 +102,4 @@ def select_rule_variable(label=None, options=None, params=None): def select_multiple_rule_variable(label=None, options=None, params=None): - return rule_variable(SelectMultipleType, label=label, options=options) + return rule_variable(SelectMultipleType, label=label, options=options, params=params) From 19f02f33b0fba6fdc609f4032b81063d879e1834 Mon Sep 17 00:00:00 2001 From: ram-janapala Date: Fri, 24 Jan 2025 09:19:10 +0530 Subject: [PATCH 21/21] Adding docs and update the error messages --- tests/test_integration.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_integration.py b/tests/test_integration.py index 9b5c1927..796bfeb5 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -84,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())