Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added variable parameters #1

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
15 changes: 14 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ Variables represent values in your system, usually the value of some particular

You define all the available variables for a certain kind of object in your code, and then later dynamically set the conditions and thresholds for those.

**Update 9/2018 EF:** Rule variables now allow for function parameters. This allows for passing a parameter from the JSON object to the functions. Example of usage in `month_equals()` and JSON in section 3. Parameter use is the same as for actions, described in section 2.

For example:

```python
Expand All @@ -50,6 +52,11 @@ class ProductVariables(BaseVariables):
@select_rule_variable(options=Products.top_holiday_items())
def goes_well_with(self):
return products.related_products

@boolean_rule_variable(params={"month": FIELD_STRING})
def month_equals(self, month):
return datetime.datetime.now().strftime("%B") == month

```

### 2. Define your set of actions
Expand Down Expand Up @@ -124,12 +131,18 @@ rules = [
],
},

# current_inventory < 5 OR (current_month = "December" AND current_inventory < 20)
# current_inventory < 5 OR current_month = "September" OR (current_month = "December" AND current_inventory < 20)
# i.e. Order more if inventory < 5, if September, or if both December and inventory < 20
{ "conditions": { "any": [
{ "name": "current_inventory",
"operator": "less_than",
"value": 5,
},
{ "name": "month_equals",
"params":{"month": 'September'},
"operator": "is_true",
"value": 1,
},
]},
{ "all": [
{ "name": "current_month",
Expand Down
7 changes: 4 additions & 3 deletions business_rules/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,11 @@ def check_condition(condition, defined_variables):
object must have a variable defined for any variables in this condition.
"""
name, op, value = condition['name'], condition['operator'], condition['value']
operator_type = _get_variable_value(defined_variables, name)
params = condition.get('params') or {}
operator_type = _get_variable_value(defined_variables, name, params)
return _do_operator_comparison(operator_type, op, value)

def _get_variable_value(defined_variables, name):
def _get_variable_value(defined_variables, name, params):
""" Call the function provided on the defined_variables object with the
given name (raise exception if that doesn't exist) and casts it to the
specified type.
Expand All @@ -65,7 +66,7 @@ def fallback(*args, **kwargs):
raise AssertionError("Variable {0} is not defined in class {1}".format(
name, defined_variables.__class__.__name__))
method = getattr(defined_variables, name, fallback)
val = method()
val = method(**params)
return method.field_type(val)

def _do_operator_comparison(operator_type, operator_name, comparison_value):
Expand Down
55 changes: 42 additions & 13 deletions business_rules/variables.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import inspect
from functools import wraps
from . import fields
from .utils import fn_name_to_pretty_label
from .operators import (BaseType,
NumericType,
Expand All @@ -19,10 +20,30 @@ def get_all_variables(cls):
'label': m[1].label,
'field_type': m[1].field_type.name,
'options': m[1].options,
'params': m[1].params,
} for m in methods if getattr(m[1], 'is_rule_variable', False)]

def _validate_action_parameters(func, params):
""" Verifies that the parameters specified are actual parameters for the
function `func`, and that the field types are FIELD_* types in fields.
"""
if params is not None:
# Verify field name is valid
valid_fields = [getattr(fields, f) for f in dir(fields) \
if f.startswith("FIELD_")]
for param in params:
param_name, field_type = param['name'], param['fieldType']
if param_name not in func.__code__.co_varnames:
raise AssertionError("Unknown parameter name {0} specified for"\
" action {1}".format(
param_name, func.__name__))

if field_type not in valid_fields:
raise AssertionError("Unknown field type {0} specified for"\
" action {1} param {2}".format(
field_type, func.__name__, param_name))

def rule_variable(field_type, label=None, options=None):
def rule_variable(field_type, label=None, options=None, params=None):
""" Decorator to make a function into a rule variable
"""
options = options or []
Expand All @@ -31,31 +52,39 @@ def wrapper(func):
raise AssertionError("{0} is not instance of BaseType in"\
" rule_variable field_type".format(field_type))
func.field_type = field_type
params_ = params
if isinstance(params, dict):
params_ = [dict(label=fn_name_to_pretty_label(name),
name=name,
fieldType=param_field_type) \
for name, param_field_type in params.items()]
_validate_action_parameters(func, params_)
func.is_rule_variable = True
func.label = label \
or fn_name_to_pretty_label(func.__name__)
func.options = options
func.params = params_
return func
return wrapper


def _rule_variable_wrapper(field_type, label):
def _rule_variable_wrapper(field_type, label, params):
if callable(label):
# Decorator is being called with no args, label is actually the decorated func
return rule_variable(field_type)(label)
return rule_variable(field_type, label=label)
return rule_variable(field_type, label=label, params=params)

def numeric_rule_variable(label=None):
return _rule_variable_wrapper(NumericType, label)
def numeric_rule_variable(label=None, params=None):
return _rule_variable_wrapper(NumericType, label, params)

def string_rule_variable(label=None):
return _rule_variable_wrapper(StringType, label)
def string_rule_variable(label=None, params=None):
return _rule_variable_wrapper(StringType, label, params)

def boolean_rule_variable(label=None):
return _rule_variable_wrapper(BooleanType, label)
def boolean_rule_variable(label=None, params=None):
return _rule_variable_wrapper(BooleanType, label, params)

def select_rule_variable(label=None, options=None):
return rule_variable(SelectType, label=label, options=options)
def select_rule_variable(label=None, options=None, params=None):
return rule_variable(SelectType, label=label, options=options, params=params)

def select_multiple_rule_variable(label=None, options=None):
return rule_variable(SelectMultipleType, label=label, options=options)
def select_multiple_rule_variable(label=None, options=None, params=None):
return rule_variable(SelectMultipleType, label=label, options=options, params=params)
18 changes: 15 additions & 3 deletions tests/test_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ def foo(self):
def ten(self):
return 10

@numeric_rule_variable(label="num_param", params={'number': FIELD_NUMERIC})
def num_param(self, number=5):
return number

@boolean_rule_variable()
def true_bool(self):
return True
Expand Down Expand Up @@ -115,15 +119,23 @@ def test_export_rule_data(self):
[{"name": "foo",
"label": "Foo",
"field_type": "string",
"options": []},
"options": [],
"params": None},
{'field_type': 'numeric',
'label': 'num_param',
'name': 'num_param',
'options': [],
'params': [{'fieldType': 'numeric', 'label': 'Number', 'name': 'number'}]},
{"name": "ten",
"label": "Diez",
"field_type": "numeric",
"options": []},
"options": [],
"params": None},
{'name': 'true_bool',
'label': 'True Bool',
'field_type': 'boolean',
'options': []}])
'options': [],
"params": None}])

self.assertEqual(all_data.get("variable_type_operators"),
{'boolean': [{'input_type': 'none',
Expand Down
91 changes: 91 additions & 0 deletions tests/test_rules.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
from business_rules.engine import check_condition
from business_rules import export_rule_data
from business_rules import run_all
from business_rules.actions import rule_action, BaseActions
from business_rules.variables import BaseVariables, string_rule_variable, numeric_rule_variable, boolean_rule_variable
from business_rules.fields import FIELD_TEXT, FIELD_NUMERIC, FIELD_SELECT

from . import TestCase


class SomeVariables(BaseVariables):

@string_rule_variable()
def foo(self):
return "foo"

@numeric_rule_variable(label="Diez")
def ten(self):
return 10

@numeric_rule_variable(label="num")
def num(self):
return 5

@numeric_rule_variable(label="num_param", params={'number': FIELD_NUMERIC})
def num_param(self, number):
return number + 1

@boolean_rule_variable()
def true_bool(self):
return True


class SomeActions(BaseActions):

def __init__(self):
self._baz = 'Not chosen'

@rule_action(params={"foo": FIELD_NUMERIC})
def some_action(self, foo): pass

@rule_action(label="woohoo", params={"bar": FIELD_TEXT})
def some_other_action(self, bar):
return bar

@rule_action(params=[{'fieldType': FIELD_SELECT,
'name': 'baz',
'label': 'Baz',
'options': [
{'label': 'Chose Me', 'name': 'chose_me'},
{'label': 'Or Me', 'name': 'or_me'}
]}])
def some_select_action(self, baz):
self._baz = baz


class RulesTests(TestCase):
""" Integration test, using the library like a user would.
"""
def test_rules(self):

rules = [
{
"conditions": {"all": [{
"name": "num_param",
"operator": "equal_to",
"params": {"number": 4},
"value": 5,
},
{
"name": "num",
"operator": "equal_to",
"value": 5,
},
]},
"actions": [{
"name": "some_select_action",
"params": {"baz": 'chose_me'},
},],
}
]

actions = SomeActions()

run_all(rule_list=rules,
defined_variables=SomeVariables(),
defined_actions=actions,
stop_on_first_trigger=False
)

self.assertEqual(actions._baz, 'chose_me')
24 changes: 24 additions & 0 deletions tests/test_variables_class.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from business_rules.variables import BaseVariables, rule_variable
from business_rules.operators import StringType
from business_rules.fields import FIELD_TEXT
from . import TestCase

class VariablesClassTests(TestCase):
Expand Down Expand Up @@ -31,3 +32,26 @@ def non_rule(self):
# should work on an instance of the class too
self.assertEqual(len(SomeVariables().get_all_variables()), 1)

def test_get_all_param_variables(self):
""" Returns a dictionary listing all the functions on the class that
have been decorated as variables, with some of the data about them.
"""
class SomeVariables(BaseVariables):

@rule_variable(StringType, params={'foo':FIELD_TEXT})
def this_is_rule_1(self, foo):
return "blah"

def non_rule(self):
return "baz"

vars = SomeVariables.get_all_variables()
self.assertEqual(len(vars), 1)
self.assertEqual(vars[0]['name'], 'this_is_rule_1')
self.assertEqual(vars[0]['label'], 'This Is Rule 1')
self.assertEqual(vars[0]['field_type'], 'string')
self.assertEqual(vars[0]['options'], [])
self.assertEqual(vars[0]['params'], [{'fieldType': FIELD_TEXT, 'name': 'foo', 'label': 'Foo'}])

# should work on an instance of the class too
self.assertEqual(len(SomeVariables().get_all_variables()), 1)