Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
ca1569b
Add more operators (#1)
honDhan Jan 5, 2023
65329e4
Bump version to 1.1.athelas
honDhan Jan 9, 2023
aa44d0e
bump version to test
pransu-ath Jan 9, 2023
726360c
Add more operators for string types
honDhan Jan 14, 2023
f3546c9
Add support for returning documentation info
honDhan Oct 4, 2023
f02b1bb
Elliot/add posting rule operator (#3)
elliotberdy-athelas Jul 30, 2024
3de3687
update version
elliotberdy-athelas Jul 30, 2024
820cbed
feat: added null-handling functionality to numeric type.
siddharthp11 Aug 13, 2024
904d5e0
test: tested behavior when numeric type gets null input
siddharthp11 Aug 13, 2024
36b01c4
update: integration test with new operator
siddharthp11 Aug 13, 2024
9440889
test: reached 95% coverage by testing not equals
siddharthp11 Aug 13, 2024
8593e5e
rm: useless import
siddharthp11 Aug 13, 2024
02a50b4
test: tested that None != 0
siddharthp11 Aug 13, 2024
d554881
refactor: return whether does not exist
siddharthp11 Aug 13, 2024
cb0cd37
Merge pull request #4 from getathelas/sidp/numeric-operator
siddharthp11 Aug 14, 2024
a4a176b
[feat]: Add contains_any and contains_all operators for select type (#5)
adithya-soma-athelas Oct 16, 2024
da93dfc
Update version number (#6)
adithya-soma-athelas Oct 17, 2024
c5499cb
Adding param suupport for variables
ram-janapala Jan 21, 2025
da37440
Adding param suupport for variables
ram-janapala Jan 21, 2025
25c18a8
Adding docs and update the error messages
ram-janapala Jan 24, 2025
264c38f
Adding docs and update the error messages
ram-janapala Jan 24, 2025
19f02f3
Adding docs and update the error messages
ram-janapala Jan 24, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,4 @@ pip-log.txt

# Virtual environments
venv*/
.idea/
2 changes: 1 addition & 1 deletion business_rules/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
__version__ = '1.1.1'
__version__ = '1.1.1+athelas5'

from .engine import run_all
from .utils import export_rule_data
Expand Down
32 changes: 19 additions & 13 deletions business_rules/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
29 changes: 20 additions & 9 deletions business_rules/engine.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
from .fields import FIELD_NO_INPUT


def run_all(rule_list,
defined_variables,
defined_actions,
stop_on_first_trigger=False):

rule_was_triggered = False
for rule in rule_list:
result = run(rule, defined_variables, defined_actions)
Expand All @@ -14,6 +14,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)
Expand Down Expand Up @@ -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.
Expand All @@ -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()
Expand All @@ -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)
102 changes: 97 additions & 5 deletions business_rules/operators.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)]


Expand Down Expand Up @@ -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)
Expand All @@ -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):
Expand All @@ -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)
Expand All @@ -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):
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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
Loading