diff --git a/.circleci/config.yml b/.circleci/config.yml index 4b5db2b4..2dc85f37 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -2,19 +2,21 @@ version: 2 jobs: build: + resource_class: small docker: - - image: circleci/python:2.7 + - image: cimg/python:3.11 steps: - checkout - run: name: Install Dependencies command: | - sudo pip install virtualenv - virtualenv venv - . venv/bin/activate - make deps + make clean + poetry install --no-ansi --no-interaction - run: name: Run Tests command: | - . venv/bin/activate make coverage + - store_artifacts: + path: htmlcov + - store_test_results: + path: ./test-results diff --git a/.flake8 b/.flake8 new file mode 100644 index 00000000..81570acb --- /dev/null +++ b/.flake8 @@ -0,0 +1,12 @@ +[flake8] +# Taken from https://black.readthedocs.io/en/stable/compatible_configs.html#flake8 + +extend-ignore = E203,E501,W503,B904,B905,B907 +select = B,C,E,F,W,T4,B9 + +# Note on line length: +# We use flake8-bugbear's B950 instead of flake8's E501. It considers "max-line-length" +# but only triggers when the value has been exceeded by more than 10%. +max-line-length = 88 + +max-complexity = 18 diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml new file mode 100644 index 00000000..e1a70dfc --- /dev/null +++ b/.github/workflows/tests.yaml @@ -0,0 +1,32 @@ +name: Tests + +on: + push: + branches: [master] + pull_request: + branches: [master] + +jobs: + tests: + # https://help.github.com/articles/virtual-environments-for-github-actions + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # Python 3.12 is not working yet at time of writing this. + python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"] + steps: + - uses: actions/checkout@v3 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + curl -sSL https://install.python-poetry.org | python3 - + poetry install -v --no-root --with dev + + - name: Test with tox + run: poetry run tox -vv diff --git a/.gitignore b/.gitignore index d5f52038..0858199d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ *.py[cod] +.DS_Store # C extensions *.so @@ -24,7 +25,8 @@ pip-log.txt # Unit test / coverage reports .coverage .tox -nosetests.xml +.pytest_cache +test-results/ # Translations *.mo @@ -37,6 +39,8 @@ nosetests.xml # Vim *.sw[po] .idea/ + +# Virtual environments +venv*/ tests/__pycache__/ .cache/ -business-rules.iml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..7f3a0dcb --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,46 @@ +repos: + - repo: https://github.com/PyCQA/isort + rev: 5.12.0 + hooks: + - id: isort + + - repo: https://github.com/psf/black + rev: 22.3.0 + hooks: + - id: black + + - repo: https://github.com/PyCQA/flake8 + rev: 6.0.0 + hooks: + - id: flake8 + args: [--config, .flake8] + additional_dependencies: [flake8-bugbear] + + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.4.0 + hooks: + - id: check-added-large-files + - id: check-ast + - id: check-byte-order-marker + - id: check-case-conflict + - id: check-executables-have-shebangs + - id: check-json + - id: check-symlinks + - id: check-toml + - id: check-vcs-permalinks + - id: check-xml + - id: check-yaml + - id: debug-statements + - id: detect-private-key + - id: end-of-file-fixer + - id: fix-encoding-pragma + args: [--remove] + - id: forbid-new-submodules + - id: mixed-line-ending + args: [--fix=lf] + # Cannot use for now due to our factories.py files + # - id: name-tests-test + # args: [--django] + - id: no-commit-to-branch + args: [--branch=master] + - id: trailing-whitespace diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 2d254379..00000000 --- a/.travis.yml +++ /dev/null @@ -1,12 +0,0 @@ -language: python -python: - - "2.6" - - "2.7" - - "pypy" - - "3.2" - - "3.3" -install: - - if [[ $TRAVIS_PYTHON_VERSION == 2* ]]; then pip install unittest2; fi - - "pip install -e ." -script: - - "nosetests tests" diff --git a/HISTORY.md b/HISTORY.md new file mode 100644 index 00000000..13d32dfb --- /dev/null +++ b/HISTORY.md @@ -0,0 +1,26 @@ +History +========= + +## 1.1.1 + +Release date: 2022-3-18 + +- Fix package description and long description + +## 1.1.0 + +Release date: 2022-3-18 + +- Add support for Python 3.5-3.7 + +## 1.0.1 + +Release date: 2016-3-16 + +- Fixes a packaging bug preventing 1.0.0 from being installed on some platforms. + +## 1.0.0 + +Release date: 2016-3-16 + +- Removes caching layer on rule decorator diff --git a/HISTORY.rst b/HISTORY.rst deleted file mode 100644 index 9a682cb6..00000000 --- a/HISTORY.rst +++ /dev/null @@ -1,14 +0,0 @@ -History -------- - -1.0.1 -+++++ -released 2016-3-16 - -- Fixes a packaging bug preventing 1.0.0 from being installed on some platforms. - -1.0.0 -+++++ -released 2016-3-16 - -- Removes caching layer on rule decorator diff --git a/README.md b/README.md index 40369281..61b23b3d 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,6 @@ business-rules ============== -[![Build Status](https://travis-ci.org/venmo/business-rules.svg?branch=master)](https://travis-ci.org/venmo/business-rules) - As a software system grows in complexity and usage, it can become burdensome if every change to the logic/behavior of the system also requires you to write and deploy new code. The goal of this business rules engine is to provide a simple @@ -14,10 +12,6 @@ marketing logic around when certain customers or items are eligible for a discount or to automate emails after users enter a certain state or go through a particular sequence of events. -

- -

- ## Usage ### 1. Define Your set of variables @@ -443,7 +437,15 @@ A variable can return the following types of values: Open up a pull request, making sure to add tests for any new functionality. To set up the dev environment (assuming you're using [virtualenvwrapper](http://docs.python-guide.org/en/latest/dev/virtualenvs/#virtualenvwrapper)): ```bash -$ mkvirtualenv business-rules -$ pip install -r dev-requirements.txt -$ nosetests +$ python -m virtualenv venv +$ source ./venv/bin/activate +$ pip install -r dev-requirements.txt -e . +$ pytest +``` + +Alternatively, you can also use Tox: + +```bash +$ pip install "tox<4" +$ tox -p auto --skip-missing-interpreters ``` diff --git a/business_rules/__init__.py b/business_rules/__init__.py index e64d144d..059a129a 100644 --- a/business_rules/__init__.py +++ b/business_rules/__init__.py @@ -1,6 +1,6 @@ -__version__ = '1.5.0' +__version__ = "1.1.1" -from .engine import run_all, check_conditions_recursively +from .engine import check_conditions_recursively, run_all from .utils import export_rule_data, validate_rule_data # Appease pyflakes by "using" these exports diff --git a/business_rules/actions.py b/business_rules/actions.py index 647269b0..e717656e 100644 --- a/business_rules/actions.py +++ b/business_rules/actions.py @@ -1,22 +1,23 @@ -from __future__ import absolute_import import inspect +from dataclasses import dataclass +from typing import Optional from .utils import fn_name_to_pretty_label, get_valid_fields class BaseActions(object): - """ Classes that hold a collection of actions to use with the rules + """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)] + return [ + {'name': m[0], 'label': m[1].label, 'params': m[1].params} + for m in methods + if getattr(m[1], 'is_rule_action', False) + ] def _validate_action_parameters(func, params): @@ -40,12 +41,18 @@ def _validate_action_parameters(func, params): 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): @@ -76,8 +83,9 @@ def wrapper(func): label=fn_name_to_pretty_label(key), name=key, fieldType=getattr(value, "field_type", value), - defaultValue=getattr(value, "default_value", None) - ) for key, value in params.items() + defaultValue=getattr(value, "default_value", None), + ) + for key, value in params.items() ] _validate_action_parameters(func, params_) @@ -91,7 +99,7 @@ def wrapper(func): return wrapper +@dataclass class ActionParam: - def __init__(self, field_type, default_value=None): - self.field_type = field_type - self.default_value = default_value + field_type: type + default_value: Optional[int] diff --git a/business_rules/engine.py b/business_rules/engine.py index dc82c60a..90118ce8 100644 --- a/business_rules/engine.py +++ b/business_rules/engine.py @@ -1,18 +1,18 @@ -from __future__ import absolute_import -import inspect import logging +from inspect import getfullargspec +from typing import List from . import utils from .fields import FIELD_NO_INPUT from .models import ConditionResult from .util import method_type -from .util.compat import getfullargspec logger = logging.getLogger(__name__) -def run_all(rule_list, defined_variables, defined_actions, stop_on_first_trigger=False): - # type: (...) -> List[bool] +def run_all( + rule_list, defined_variables, defined_actions, stop_on_first_trigger=False +) -> List[bool]: results = [False] * len(rule_list) for i, rule in enumerate(rule_list): result = run(rule, defined_variables, defined_actions) @@ -24,10 +24,12 @@ def run_all(rule_list, defined_variables, defined_actions, stop_on_first_trigger def run(rule, defined_variables, defined_actions): - conditions, actions = rule.get('conditions'), rule['actions'] + conditions, actions = rule.get("conditions"), rule["actions"] if conditions is not None: - rule_triggered, checked_conditions_results = check_conditions_recursively(conditions, defined_variables, rule) + rule_triggered, checked_conditions_results = check_conditions_recursively( + conditions, defined_variables, rule + ) else: # If there are no conditions then trigger actions rule_triggered = True @@ -46,29 +48,36 @@ def check_conditions_recursively(conditions, defined_variables, rule): This method checks all conditions including embedded ones. :param conditions: Conditions to be checked - :param defined_variables: BaseVariables instance to get variables values to check Conditions + :param defined_variables: BaseVariables instance to get variables values to check + Conditions :param rule: Original rule where Conditions and Actions are defined - :return: tuple with result of condition check and list of checked conditions with each individual result. + :return: tuple with result of condition check and list of checked conditions with + each individual result. (condition_result, [(condition1_result), (condition2_result)] - condition1_result = (condition_result, variable name, condition operator, condition value, condition params) + condition1_result = (condition_result, variable name, condition operator, + condition value, condition params) """ keys = list(conditions.keys()) - if keys == ['all']: - assert len(conditions['all']) >= 1 + if keys == ["all"]: + assert len(conditions["all"]) >= 1 matches = [] - for condition in conditions['all']: - check_condition_result, matches_results = check_conditions_recursively(condition, defined_variables, rule) + for condition in conditions["all"]: + check_condition_result, matches_results = check_conditions_recursively( + condition, defined_variables, rule + ) matches.extend(matches_results) if not check_condition_result: return False, [] return True, matches - elif keys == ['any']: - assert len(conditions['any']) >= 1 - for condition in conditions['any']: - check_condition_result, matches_results = check_conditions_recursively(condition, defined_variables, rule) + elif keys == ["any"]: + assert len(conditions["any"]) >= 1 + for condition in conditions["any"]: + check_condition_result, matches_results = check_conditions_recursively( + condition, defined_variables, rule + ) if check_condition_result: return True, matches_results return False, [] @@ -76,7 +85,7 @@ def check_conditions_recursively(conditions, defined_variables, rule): else: # help prevent errors - any and all can only be in the condition dict # if they're the only item - assert not ('any' in keys or 'all' in keys) + assert not ("any" in keys or "all" in keys) result = check_condition(conditions, defined_variables, rule) return result[0], [result] @@ -101,11 +110,16 @@ def check_condition(condition, defined_variables, rule): condition params: {} ) """ - name, op, value = condition['name'], condition['operator'], condition['value'] - params = condition.get('params', {}) + name, op, value = condition["name"], condition["operator"], condition["value"] + params = condition.get("params", {}) operator_type = _get_variable_value(defined_variables, name, params, rule) - return ConditionResult(result=_do_operator_comparison(operator_type, op, value), name=name, operator=op, - value=value, parameters=params) + return ConditionResult( + result=_do_operator_comparison(operator_type, op, value), + name=name, + operator=op, + value=value, + parameters=params, + ) def _get_variable_value(defined_variables, name, params, rule): @@ -124,10 +138,15 @@ def _get_variable_value(defined_variables, name, params, rule): method = getattr(defined_variables, name, None) if method is None: - raise AssertionError("Variable {0} is not defined in class {1}".format( - name, defined_variables.__class__.__name__)) + raise AssertionError( + "Variable {0} is not defined in class {1}".format( + name, defined_variables.__class__.__name__ + ) + ) - utils.check_params_valid_for_method(method, params, method_type.METHOD_TYPE_VARIABLE) + utils.check_params_valid_for_method( + method, params, method_type.METHOD_TYPE_VARIABLE + ) method_params = _build_variable_parameters(method, params, rule) variable_value = method(**method_params) @@ -149,11 +168,14 @@ def _do_operator_comparison(operator_type, operator_name, comparison_value): """ def fallback(*args, **kwargs): - raise AssertionError("Operator {0} does not exist for type {1}".format( - operator_name, operator_type.__class__.__name__)) + 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: + if getattr(method, "input_type", "") == FIELD_NO_INPUT: return method() return method(comparison_value) @@ -172,8 +194,8 @@ def do_actions(actions, defined_actions, checked_conditions_results, rule): "param1": value } } - :param defined_actions: Class with function that implement the logic for each possible action defined in - 'actions' parameter + :param defined_actions: Class with function that implement the logic for each + possible action defined in 'actions' parameter :param checked_conditions_results: :param rule: Rule that is being executed :return: None @@ -183,41 +205,50 @@ def do_actions(actions, defined_actions, checked_conditions_results, rule): successful_conditions = [x for x in checked_conditions_results if x[0]] for action in actions: - method_name = action['name'] - action_params = action.get('params', {}) + method_name = action["name"] + action_params = action.get("params", {}) method = getattr(defined_actions, method_name, None) if not method: raise AssertionError( - "Action {0} is not defined in class {1}".format(method_name, defined_actions.__class__.__name__)) + "Action {0} is not defined in class {1}".format( + method_name, defined_actions.__class__.__name__ + ) + ) - missing_params_with_default_value = utils.check_params_valid_for_method(method, action_params, - method_type.METHOD_TYPE_ACTION) + missing_params_with_default_value = utils.check_params_valid_for_method( + method, action_params, method_type.METHOD_TYPE_ACTION + ) if missing_params_with_default_value: - action_params = _set_default_values_for_missing_action_params(method, - missing_params_with_default_value, - action_params) + action_params = _set_default_values_for_missing_action_params( + method, missing_params_with_default_value, action_params + ) - method_params = _build_action_parameters(method, action_params, rule, successful_conditions) + method_params = _build_action_parameters( + method, action_params, rule, successful_conditions + ) method(**method_params) -def _set_default_values_for_missing_action_params(method, missing_parameters_with_default_value, action_params): +def _set_default_values_for_missing_action_params( + method, missing_parameters_with_default_value, action_params +): """ Adds default parameter from method params to Action parameters. :param method: Action object. - :param parameters_with_default_value: set of parameters which have a default value for Action parameters. + :param parameters_with_default_value: set of parameters which have a default value + for Action parameters. :param action_params: Action parameters dict. :return: Modified action_params. """ modified_action_params = {} - if getattr(method, 'params', None): + if getattr(method, "params", None): for param in method.params: - param_name = param['name'] + param_name = param["name"] if param_name in missing_parameters_with_default_value: - default_value = param.get('defaultValue', None) + default_value = param.get("defaultValue", None) if default_value is not None: modified_action_params[param_name] = default_value continue @@ -234,10 +265,7 @@ def _build_action_parameters(method, parameters, rule, conditions): :param conditions: :return: """ - extra_parameters = { - 'rule': rule, - 'conditions': conditions - } + extra_parameters = {"rule": rule, "conditions": conditions} return _build_parameters(method, parameters, extra_parameters) @@ -251,7 +279,7 @@ def _build_variable_parameters(method, parameters, rule): :return: """ extra_parameters = { - 'rule': rule, + "rule": rule, } return _build_parameters(method, parameters, extra_parameters) diff --git a/business_rules/models.py b/business_rules/models.py index c576fea5..a5677a30 100644 --- a/business_rules/models.py +++ b/business_rules/models.py @@ -1,4 +1,7 @@ from __future__ import absolute_import + from collections import namedtuple -ConditionResult = namedtuple('ConditionResult', ['result', 'name', 'operator', 'value', 'parameters']) +ConditionResult = namedtuple( + 'ConditionResult', ['result', 'name', 'operator', 'value', 'parameters'] +) diff --git a/business_rules/operators.py b/business_rules/operators.py index 17de63b2..e344d6d1 100644 --- a/business_rules/operators.py +++ b/business_rules/operators.py @@ -1,16 +1,18 @@ -from __future__ import absolute_import -import calendar import inspect import re from datetime import date, datetime, time from decimal import Decimal from functools import wraps -from six import integer_types, string_types - -from .fields import (FIELD_DATETIME, FIELD_NO_INPUT, FIELD_NUMERIC, - FIELD_SELECT, FIELD_SELECT_MULTIPLE, FIELD_TEXT, - FIELD_TIME) +from .fields import ( + FIELD_DATETIME, + FIELD_NO_INPUT, + FIELD_NUMERIC, + FIELD_SELECT, + FIELD_SELECT_MULTIPLE, + FIELD_TEXT, + FIELD_TIME, +) from .utils import float_to_decimal, fn_name_to_pretty_label @@ -24,21 +26,21 @@ def _assert_valid_value_and_cast(self, value): @classmethod def get_all_operators(cls): methods = inspect.getmembers(cls) - return [{'name': m[0], - 'label': m[1].label, - 'input_type': m[1].input_type} - for m in methods if getattr(m[1], 'is_operator', False)] + return [ + {"name": m[0], "label": m[1].label, "input_type": m[1].input_type} + for m in methods + if getattr(m[1], "is_operator", False) + ] def export_type(cls): - """ Decorator to expose the given class to business_rules.export_rule_data. """ + """Decorator to expose the given class to business_rules.export_rule_data.""" cls.export_in_rule_data = True return cls -def type_operator(input_type, label=None, - assert_type_for_arguments=True): - """ Decorator to make a function into a type operator. +def type_operator(input_type, label=None, assert_type_for_arguments=True): + """Decorator to make a function into a type operator. - assert_type_for_arguments - if True this patches the operator function so that arguments passed to it will have _assert_valid_value_and_cast @@ -66,13 +68,13 @@ def inner(self, *args, **kwargs): @export_type class StringType(BaseType): + name = "string" def _assert_valid_value_and_cast(self, value): value = value or "" - if not isinstance(value, string_types): - raise AssertionError("{0} is not a valid string type.". - format(value)) + if not isinstance(value, str): + raise AssertionError("{0} is not a valid string type.".format(value)) return value @type_operator(FIELD_TEXT) @@ -106,7 +108,7 @@ def non_empty(self): @export_type class NumericType(BaseType): - EPSILON = Decimal('0.000001') + EPSILON = Decimal("0.000001") name = "numeric" @@ -115,13 +117,12 @@ def _assert_valid_value_and_cast(value): if isinstance(value, float): # In python 2.6, casting float to Decimal doesn't work return float_to_decimal(value) - if isinstance(value, integer_types): + if isinstance(value, int): return Decimal(value) if isinstance(value, Decimal): return value else: - raise AssertionError("{0} is not a valid numeric type.". - format(value)) + raise AssertionError("{0} is not a valid numeric type.".format(value)) @type_operator(FIELD_NUMERIC) def equal_to(self, other_numeric): @@ -146,12 +147,12 @@ def less_than_or_equal_to(self, other_numeric): @export_type class BooleanType(BaseType): + name = "boolean" def _assert_valid_value_and_cast(self, value): if type(value) != bool: - raise AssertionError("{0} is not a valid boolean type". - format(value)) + raise AssertionError("{0} is not a valid boolean type".format(value)) return value @type_operator(FIELD_NO_INPUT) @@ -165,18 +166,17 @@ def is_false(self): @export_type class SelectType(BaseType): + name = "select" def _assert_valid_value_and_cast(self, value): - if not hasattr(value, '__iter__'): - raise AssertionError("{0} is not a valid select type". - format(value)) + if not hasattr(value, "__iter__"): + raise AssertionError("{0} is not a valid select type".format(value)) return value @staticmethod def _case_insensitive_equal_to(value_from_list, other_value): - if isinstance(value_from_list, string_types) and \ - isinstance(other_value, string_types): + if isinstance(value_from_list, str) and isinstance(other_value, str): return value_from_list.lower() == other_value.lower() else: return value_from_list == other_value @@ -198,12 +198,14 @@ def does_not_contain(self, other_value): @export_type class SelectMultipleType(BaseType): + name = "select_multiple" def _assert_valid_value_and_cast(self, value): - if not hasattr(value, '__iter__'): - raise AssertionError("{0} is not a valid select multiple type". - format(value)) + if not hasattr(value, "__iter__"): + raise AssertionError( + "{0} is not a valid select multiple type".format(value) + ) return value @type_operator(FIELD_SELECT_MULTIPLE) @@ -246,12 +248,13 @@ def shares_no_elements_with(self, other_value): @export_type class DateTimeType(BaseType): name = "datetime" - DATETIME_FORMAT = '%Y-%m-%dT%H:%M:%S' - DATE_FORMAT = '%Y-%m-%d' + DATETIME_FORMAT = "%Y-%m-%dT%H:%M:%S" + DATE_FORMAT = "%Y-%m-%d" def _assert_valid_value_and_cast(self, value): """ - Parse string with formats '%Y-%m-%dT%H:%M:%S' or '%Y-%m-%d' into datetime.datetime instance. + Parse string with formats '%Y-%m-%dT%H:%M:%S' or '%Y-%m-%d' into + datetime.datetime instance. :param value: :return: @@ -315,8 +318,8 @@ def before_than_or_equal_to(self, other_datetime): @export_type class TimeType(BaseType): name = "time" - TIME_FORMAT = '%H:%M:%S' - TIME_FORMAT_NO_SECONDS = '%H:%M' + TIME_FORMAT = "%H:%M:%S" + TIME_FORMAT_NO_SECONDS = "%H:%M" def _assert_valid_value_and_cast(self, value): """ diff --git a/business_rules/util/compat.py b/business_rules/util/compat.py deleted file mode 100644 index 0caba58f..00000000 --- a/business_rules/util/compat.py +++ /dev/null @@ -1,33 +0,0 @@ -from __future__ import absolute_import, unicode_literals - -import sys -from collections import namedtuple - -PY2 = sys.version_info[0] == 2 - -# Taken from https://github.com/HypothesisWorks/hypothesis/pull/625/files#diff-e84a85b835af44101e1986c47ba39630R264 -if PY2: - FullArgSpec = namedtuple('FullArgSpec', 'args, varargs, varkw, defaults, kwonlyargs, kwonlydefaults, annotations') - - def getfullargspec(func): - import inspect - args, varargs, varkw, defaults = inspect.getargspec(func) - return FullArgSpec(args, varargs, varkw, defaults, [], None, {}) -else: - from inspect import getfullargspec - - if sys.version_info[:2] == (3, 5): - # silence deprecation warnings on Python 3.5 - # (un-deprecated in 3.6 to allow single-source 2/3 code like this) - def silence_warnings(func): - import warnings - import functools - - @functools.wraps(func) - def inner(*args, **kwargs): - with warnings.catch_warnings(): - warnings.simplefilter('ignore', DeprecationWarning) - return func(*args, **kwargs) - return inner - - getfullargspec = silence_warnings(getfullargspec) diff --git a/business_rules/utils.py b/business_rules/utils.py index e18eb385..7c6f02cb 100644 --- a/business_rules/utils.py +++ b/business_rules/utils.py @@ -1,4 +1,5 @@ from __future__ import absolute_import + import inspect from decimal import Context, Decimal, Inexact @@ -14,9 +15,11 @@ def export_rule_data(variables, actions): Export_rule_data is used to export all information about the variables, actions, and operators to the client. This will return a dictionary with three keys: - - variables: a list of all available variables along with their label, type, options and params + - variables: a list of all available variables along with their label, type, + options and params - actions: a list of all actions along with their label and params - - variable_type_operators: a dictionary of all field_types -> list of available operators + - variable_type_operators: a dictionary of all field_types -> list of available + operators :param variables: :param actions: :return: @@ -27,14 +30,16 @@ def export_rule_data(variables, actions): variables_data = variables.get_all_variables() variable_type_operators = {} - for variable_class in inspect.getmembers(operators, lambda x: getattr(x, 'export_in_rule_data', False)): + for variable_class in inspect.getmembers( + operators, lambda x: getattr(x, 'export_in_rule_data', False) + ): variable_type = variable_class[1] # getmembers returns (name, value) variable_type_operators[variable_type.name] = variable_type.get_all_operators() return { "variables": variables_data, "actions": actions_data, - "variable_type_operators": variable_type_operators + "variable_type_operators": variable_type_operators, } @@ -89,21 +94,23 @@ def params_dict_to_list(params): { 'label': fn_name_to_pretty_label(name), 'name': name, - 'field_type': param_field_type - } for name, param_field_type in params.items() + 'field_type': param_field_type, + } + for name, param_field_type in params.items() ] def check_params_valid_for_method(method, given_params, method_type_name): """ - Verifies that the given parameters (defined in the Rule) match the names of those defined in - the variable or action decorator. Raise an error if one of the sets contains a parameter that - the other does not. + Verifies that the given parameters (defined in the Rule) match the names of those + defined in the variable or action decorator. Raise an error if one of the sets + contains a parameter that the other does not. :param method: :param given_params: Parameters defined within the Rule (Action or Condition) :param method_type_name: A method type defined in util.method_type module - :return: Set of default values for params which are missing but have a default value. Raise exception if parameters + :return: Set of default values for params which are missing but have a default + value. Raise exception if parameters don't match (defined in method and Rule) @@ -112,21 +119,30 @@ def check_params_valid_for_method(method, given_params, method_type_name): defined_params = [param.get('name') for param in method_params] missing_params = set(defined_params).difference(given_params) - # check for default value in action parameters, if it is present, exclude param from missing params + # check for default value in action parameters, if it is present, exclude param + # from missing params params_with_default_value = set() if method_type_name == method_type.METHOD_TYPE_ACTION and missing_params: - params_with_default_value = check_for_default_value_for_missing_params(missing_params, method_params) + params_with_default_value = check_for_default_value_for_missing_params( + missing_params, method_params + ) missing_params -= params_with_default_value if missing_params: - raise AssertionError("Missing parameters {0} for {1} {2}".format( - ', '.join(missing_params), method_type_name, method.__name__)) + raise AssertionError( + "Missing parameters {0} for {1} {2}".format( + ', '.join(missing_params), method_type_name, method.__name__ + ) + ) invalid_params = set(given_params).difference(defined_params) if invalid_params: - raise AssertionError("Invalid parameters {0} for {1} {2}".format( - ', '.join(invalid_params), method_type_name, method.__name__)) + raise AssertionError( + "Invalid parameters {0} for {1} {2}".format( + ', '.join(invalid_params), method_type_name, method.__name__ + ) + ) return params_with_default_value @@ -134,7 +150,8 @@ def check_params_valid_for_method(method, given_params, method_type_name): def check_for_default_value_for_missing_params(missing_params, method_params): """ :param missing_params: Params missing from Rule - :param method_params: Params defined on method, which could have default value for missing param + :param method_params: Params defined on method, which could have default value for + missing param [{ 'label': 'action_label', 'name': 'action_parameter', @@ -143,17 +160,103 @@ def check_for_default_value_for_missing_params(missing_params, method_params): }, ... ] - :return Params that are missing from rule but have default params: {'action_parameter'} + :return Params that are missing from rule but have + default params: {'action_parameter'} """ missing_params_with_default_value = set() if method_params: for param in method_params: - if param['name'] in missing_params and param.get('defaultValue', None) is not None: + if ( + param['name'] in missing_params + and param.get('defaultValue', None) is not None + ): missing_params_with_default_value.add(param['name']) return missing_params_with_default_value +def validate_root_keys(rule): + """ + Check the root object contains both 'actions' & 'conditions' + """ + root_keys = list(rule.keys()) + if 'actions' not in root_keys: + raise AssertionError('Missing "{}" key'.format('actions')) + + +def validate_condition_operator(condition, rule_schema): + """ + Check provided condition contains a valid operator + """ + if "operator" not in condition: + raise AssertionError( + 'Missing "operator" key for condition {}'.format(condition.get('name')) + ) + for item in rule_schema.get('variables'): + if item.get('name') == condition.get('name'): + condition_field_type = item.get('field_type') + variable_operators = rule_schema.get('variable_type_operators', {}).get( + condition_field_type, [] + ) + for operators in variable_operators: + if operators['name'] == condition['operator']: + return True + raise AssertionError('Unknown operator "{}"'.format(condition['operator'])) + raise AssertionError('Name "{}" not supported'.format(condition.get('name'))) + + +def validate_condition_name(condition, variables): + """ + Check provided condition contains a 'name' key and the value is valid + """ + condition_name = condition.get('name') + if not condition_name: + raise AssertionError('Missing condition "name" key in {}'.format(condition)) + if not hasattr(variables, condition_name): + raise AssertionError('Unknown condition "{}"'.format(condition_name)) + + +def validate_condition(condition, variables, rule_schema): + validate_condition_name(condition, variables) + validate_condition_operator(condition, rule_schema) + method = getattr(variables, condition.get('name')) + params = condition.get('params', {}) + check_params_valid_for_method(method, params, method_type.METHOD_TYPE_VARIABLE) + + +def validate_conditions(input_conditions, rule_schema, variables): + """ + Recursively check all levels of input conditions + """ + if isinstance(input_conditions, list): + for condition in input_conditions: + validate_conditions(condition, rule_schema, variables) + if isinstance(input_conditions, dict): + keys = list(input_conditions.keys()) + if 'any' in keys or 'all' in keys: + if len(keys) > 1: + raise AssertionError( + 'Expected ONE of "any" or "all" but found {}'.format(keys) + ) + else: + for _, v in input_conditions.items(): + validate_conditions(v, rule_schema, variables) + else: + validate_condition(input_conditions, variables, rule_schema) + + +def validate_actions(input_actions, actions): + """ + Check all input actions contain valid names and parameters for defined actions + """ + if type(input_actions) is not list: + raise AssertionError('"actions" key must be a list') + for action in input_actions: + method = getattr(actions, action.get('name'), None) + params = action.get('params', {}) + check_params_valid_for_method(method, params, method_type.METHOD_TYPE_ACTION) + + def validate_rule_data(variables, actions, rule): """ validate_rule_data is used to check a generated rule against a set of variables and actions @@ -163,83 +266,11 @@ def validate_rule_data(variables, actions, rule): :return: bool :raises AssertionError: """ - def validate_root_keys(rule): - """ - Check the root object contains both 'actions' & 'conditions' - """ - root_keys = list(rule.keys()) - if 'actions' not in root_keys: - raise AssertionError('Missing "{}" key'.format('actions')) - - def validate_condition_operator(condition, rule_schema): - """ - Check provided condition contains a valid operator - """ - if "operator" not in condition: - raise AssertionError('Missing "operator" key for condition {}'.format(condition.get('name'))) - for item in rule_schema.get('variables'): - if item.get('name') == condition.get('name'): - condition_field_type = item.get('field_type') - variable_operators = rule_schema.get('variable_type_operators', {}).get(condition_field_type, []) - for operators in variable_operators: - if operators['name'] == condition['operator']: - return True - raise AssertionError('Unknown operator "{}"'.format(condition['operator'])) - raise AssertionError('Name "{}" not supported'.format(condition.get('name'))) - - def validate_condition_name(condition, variables): - """ - Check provided condition contains a 'name' key and the value is valid - """ - condition_name = condition.get('name') - if not condition_name: - raise AssertionError('Missing condition "name" key in {}'.format(condition)) - if not hasattr(variables, condition_name): - raise AssertionError('Unknown condition "{}"'.format(condition_name)) - - def validate_condition(condition, variables, rule_schema): - validate_condition_name(condition, variables) - validate_condition_operator(condition, rule_schema) - method = getattr(variables, condition.get('name')) - params = condition.get('params', {}) - check_params_valid_for_method(method, params, method_type.METHOD_TYPE_VARIABLE) - - def validate_conditions(input_conditions, rule_schema): - """ - Recursively check all levels of input conditions - """ - import six - - if isinstance(input_conditions, list): - for condition in input_conditions: - validate_conditions(condition, rule_schema) - if isinstance(input_conditions, dict): - keys = list(input_conditions.keys()) - if 'any' in keys or 'all' in keys: - if len(keys) > 1: - raise AssertionError('Expected ONE of "any" or "all" but found {}'.format(keys)) - else: - for _, v in six.iteritems(input_conditions): - validate_conditions(v, rule_schema) - else: - validate_condition(input_conditions, variables, rule_schema) - - def validate_actions(input_actions): - """ - Check all input actions contain valid names and parameters for defined actions - """ - if type(input_actions) is not list: - raise AssertionError('"actions" key must be a list') - for action in input_actions: - method = getattr(actions, action.get('name'), None) - params = action.get('params', {}) - check_params_valid_for_method(method, params, method_type.METHOD_TYPE_ACTION) - rule_schema = export_rule_data(variables, actions) validate_root_keys(rule) conditions = rule.get('conditions', None) if conditions is not None and type(conditions) is not dict: raise AssertionError('"conditions" must be a dictionary') - validate_conditions(conditions, rule_schema) - validate_actions(rule.get('actions')) + validate_conditions(conditions, rule_schema, variables) + validate_actions(rule.get('actions'), actions) return True diff --git a/business_rules/variables.py b/business_rules/variables.py index 6e44a2c9..9f567324 100644 --- a/business_rules/variables.py +++ b/business_rules/variables.py @@ -1,11 +1,19 @@ from __future__ import absolute_import -import inspect +import inspect from typing import Callable, List, Type # noqa: F401 from . import utils -from .operators import (BaseType, BooleanType, DateTimeType, NumericType, - SelectMultipleType, SelectType, StringType, TimeType) +from .operators import ( + BaseType, + BooleanType, + DateTimeType, + NumericType, + SelectMultipleType, + SelectType, + StringType, + TimeType, +) from .utils import fn_name_to_pretty_label @@ -26,7 +34,9 @@ def get_all_variables(cls): 'options': m[1].options, 'params': m[1].params, 'public': m[1].public, - } for m in methods if getattr(m[1], 'is_rule_variable', False) + } + for m in methods + if getattr(m[1], 'is_rule_variable', False) ] @@ -47,8 +57,10 @@ def rule_variable(field_type, label=None, options=None, params=None, public=True 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)) + raise AssertionError( + "{0} is not instance of BaseType in" + " rule_variable field_type".format(field_type) + ) params_wrapper = utils.params_dict_to_list(params) @@ -71,7 +83,9 @@ def _rule_variable_wrapper(field_type, label, params=None, options=None, public= # Decorator is being called with no args, label is actually the decorated func return rule_variable(field_type, params=params, public=public)(label) - return rule_variable(field_type, label=label, params=params, options=options, public=public) + return rule_variable( + field_type, label=label, params=params, options=options, public=public + ) def numeric_rule_variable(label=None, params=None, public=True): @@ -101,7 +115,9 @@ def string_rule_variable(label=None, params=None, options=None, public=True): :param public: Flag to identify if a variable is public or not :return: Decorator function wrapper """ - return _rule_variable_wrapper(StringType, label, params=params, options=options, public=public) + return _rule_variable_wrapper( + StringType, label, params=params, options=options, public=public + ) def boolean_rule_variable(label=None, params=None, public=True): @@ -130,7 +146,9 @@ def select_rule_variable(label=None, options=None, params=None, public=True): :param public: Flag to identify if a variable is public or not :return: Decorator function wrapper """ - return rule_variable(SelectType, label=label, options=options, params=params, public=public) + return rule_variable( + SelectType, label=label, options=options, params=params, public=public + ) def select_multiple_rule_variable(label=None, options=None, params=None, public=True): @@ -145,7 +163,9 @@ def select_multiple_rule_variable(label=None, options=None, params=None, public= :param public: Flag to identify if a variable is public or not :return: Decorator function wrapper """ - return rule_variable(SelectMultipleType, label=label, options=options, params=params, public=public) + return rule_variable( + SelectMultipleType, label=label, options=options, params=params, public=public + ) def datetime_rule_variable(label=None, params=None, public=True): @@ -160,7 +180,9 @@ def datetime_rule_variable(label=None, params=None, public=True): :return: Decorator function wrapper for DateTime values """ - return _rule_variable_wrapper(field_type=DateTimeType, label=label, params=params, public=public) + return _rule_variable_wrapper( + field_type=DateTimeType, label=label, params=params, public=public + ) def time_rule_variable(label=None, params=None, public=True): @@ -174,7 +196,9 @@ def time_rule_variable(label=None, params=None, public=True): :return: Decorator function wrapper for Time values """ - return _rule_variable_wrapper(field_type=TimeType, label=label, params=params, public=public) + return _rule_variable_wrapper( + field_type=TimeType, label=label, params=params, public=public + ) def _validate_variable_parameters(func, params): @@ -192,9 +216,15 @@ def _validate_variable_parameters(func, params): param_name, field_type = param['name'], param['field_type'] if param_name not in func.__code__.co_varnames: - raise AssertionError("Unknown parameter name {0} specified for variable {1}".format( - param_name, func.__name__)) + 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)) + raise AssertionError( + "Unknown field type {0} specified for variable {1} param {2}".format( + field_type, func.__name__, param_name + ) + ) diff --git a/makefile b/makefile index 0a3847ce..664bb8ff 100644 --- a/makefile +++ b/makefile @@ -2,12 +2,23 @@ clean: -find . -type f -name "*.pyc" -delete + poetry env remove 3.11 || true + poetry env use 3.11 deps: - pip install -r requirements-dev.txt + poetry install test: - py.test $(pytest_args) + poetry run py.test $(pytest_args) coverage: - py.test --cov-report term-missing --cov=./business_rules $(pytest_args) + mkdir -p test-results + poetry run py.test --junitxml=test-results/junit.xml --cov-report term-missing --cov=./business_rules $(pytest_args) + poetry run coverage html # open htmlcov/index.html in a browser + +merge-upstream: + # Merge the venmo/business-rules upstream master branch to our fork + # Once this command completes there will likely be conflicts so you will need to fix them and commit the changes. + git remote add upstream git@github.com:venmo/business-rules.git + git fetch upstream + git merge upstream/master diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 00000000..9c290cf0 --- /dev/null +++ b/poetry.lock @@ -0,0 +1,443 @@ +# This file is automatically @generated by Poetry 1.4.2 and should not be changed by hand. + +[[package]] +name = "cachetools" +version = "5.3.0" +description = "Extensible memoizing collections and decorators" +category = "dev" +optional = false +python-versions = "~=3.7" +files = [ + {file = "cachetools-5.3.0-py3-none-any.whl", hash = "sha256:429e1a1e845c008ea6c85aa35d4b98b65d6a9763eeef3e37e92728a12d1de9d4"}, + {file = "cachetools-5.3.0.tar.gz", hash = "sha256:13dfddc7b8df938c21a940dfa6557ce6e94a2f1cdfa58eb90c805721d58f2c14"}, +] + +[[package]] +name = "chardet" +version = "5.1.0" +description = "Universal encoding detector for Python 3" +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "chardet-5.1.0-py3-none-any.whl", hash = "sha256:362777fb014af596ad31334fde1e8c327dfdb076e1960d1694662d46a6917ab9"}, + {file = "chardet-5.1.0.tar.gz", hash = "sha256:0d62712b956bc154f85fb0a266e2a3c5913c2967e00348701b32411d6def31e5"}, +] + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +category = "dev" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "coverage" +version = "7.2.3" +description = "Code coverage measurement for Python" +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "coverage-7.2.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e58c0d41d336569d63d1b113bd573db8363bc4146f39444125b7f8060e4e04f5"}, + {file = "coverage-7.2.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:344e714bd0fe921fc72d97404ebbdbf9127bac0ca1ff66d7b79efc143cf7c0c4"}, + {file = "coverage-7.2.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:974bc90d6f6c1e59ceb1516ab00cf1cdfbb2e555795d49fa9571d611f449bcb2"}, + {file = "coverage-7.2.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0743b0035d4b0e32bc1df5de70fba3059662ace5b9a2a86a9f894cfe66569013"}, + {file = "coverage-7.2.3-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5d0391fb4cfc171ce40437f67eb050a340fdbd0f9f49d6353a387f1b7f9dd4fa"}, + {file = "coverage-7.2.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:4a42e1eff0ca9a7cb7dc9ecda41dfc7cbc17cb1d02117214be0561bd1134772b"}, + {file = "coverage-7.2.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:be19931a8dcbe6ab464f3339966856996b12a00f9fe53f346ab3be872d03e257"}, + {file = "coverage-7.2.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:72fcae5bcac3333a4cf3b8f34eec99cea1187acd55af723bcbd559adfdcb5535"}, + {file = "coverage-7.2.3-cp310-cp310-win32.whl", hash = "sha256:aeae2aa38395b18106e552833f2a50c27ea0000122bde421c31d11ed7e6f9c91"}, + {file = "coverage-7.2.3-cp310-cp310-win_amd64.whl", hash = "sha256:83957d349838a636e768251c7e9979e899a569794b44c3728eaebd11d848e58e"}, + {file = "coverage-7.2.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:dfd393094cd82ceb9b40df4c77976015a314b267d498268a076e940fe7be6b79"}, + {file = "coverage-7.2.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:182eb9ac3f2b4874a1f41b78b87db20b66da6b9cdc32737fbbf4fea0c35b23fc"}, + {file = "coverage-7.2.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1bb1e77a9a311346294621be905ea8a2c30d3ad371fc15bb72e98bfcfae532df"}, + {file = "coverage-7.2.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ca0f34363e2634deffd390a0fef1aa99168ae9ed2af01af4a1f5865e362f8623"}, + {file = "coverage-7.2.3-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:55416d7385774285b6e2a5feca0af9652f7f444a4fa3d29d8ab052fafef9d00d"}, + {file = "coverage-7.2.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:06ddd9c0249a0546997fdda5a30fbcb40f23926df0a874a60a8a185bc3a87d93"}, + {file = "coverage-7.2.3-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:fff5aaa6becf2c6a1699ae6a39e2e6fb0672c2d42eca8eb0cafa91cf2e9bd312"}, + {file = "coverage-7.2.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:ea53151d87c52e98133eb8ac78f1206498c015849662ca8dc246255265d9c3c4"}, + {file = "coverage-7.2.3-cp311-cp311-win32.whl", hash = "sha256:8f6c930fd70d91ddee53194e93029e3ef2aabe26725aa3c2753df057e296b925"}, + {file = "coverage-7.2.3-cp311-cp311-win_amd64.whl", hash = "sha256:fa546d66639d69aa967bf08156eb8c9d0cd6f6de84be9e8c9819f52ad499c910"}, + {file = "coverage-7.2.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b2317d5ed777bf5a033e83d4f1389fd4ef045763141d8f10eb09a7035cee774c"}, + {file = "coverage-7.2.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be9824c1c874b73b96288c6d3de793bf7f3a597770205068c6163ea1f326e8b9"}, + {file = "coverage-7.2.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2c3b2803e730dc2797a017335827e9da6da0e84c745ce0f552e66400abdfb9a1"}, + {file = "coverage-7.2.3-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f69770f5ca1994cb32c38965e95f57504d3aea96b6c024624fdd5bb1aa494a1"}, + {file = "coverage-7.2.3-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:1127b16220f7bfb3f1049ed4a62d26d81970a723544e8252db0efde853268e21"}, + {file = "coverage-7.2.3-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:aa784405f0c640940595fa0f14064d8e84aff0b0f762fa18393e2760a2cf5841"}, + {file = "coverage-7.2.3-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:3146b8e16fa60427e03884301bf8209221f5761ac754ee6b267642a2fd354c48"}, + {file = "coverage-7.2.3-cp37-cp37m-win32.whl", hash = "sha256:1fd78b911aea9cec3b7e1e2622c8018d51c0d2bbcf8faaf53c2497eb114911c1"}, + {file = "coverage-7.2.3-cp37-cp37m-win_amd64.whl", hash = "sha256:0f3736a5d34e091b0a611964c6262fd68ca4363df56185902528f0b75dbb9c1f"}, + {file = "coverage-7.2.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:981b4df72c93e3bc04478153df516d385317628bd9c10be699c93c26ddcca8ab"}, + {file = "coverage-7.2.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:c0045f8f23a5fb30b2eb3b8a83664d8dc4fb58faddf8155d7109166adb9f2040"}, + {file = "coverage-7.2.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f760073fcf8f3d6933178d67754f4f2d4e924e321f4bb0dcef0424ca0215eba1"}, + {file = "coverage-7.2.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c86bd45d1659b1ae3d0ba1909326b03598affbc9ed71520e0ff8c31a993ad911"}, + {file = "coverage-7.2.3-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:172db976ae6327ed4728e2507daf8a4de73c7cc89796483e0a9198fd2e47b462"}, + {file = "coverage-7.2.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:d2a3a6146fe9319926e1d477842ca2a63fe99af5ae690b1f5c11e6af074a6b5c"}, + {file = "coverage-7.2.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:f649dd53833b495c3ebd04d6eec58479454a1784987af8afb77540d6c1767abd"}, + {file = "coverage-7.2.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:7c4ed4e9f3b123aa403ab424430b426a1992e6f4c8fd3cb56ea520446e04d152"}, + {file = "coverage-7.2.3-cp38-cp38-win32.whl", hash = "sha256:eb0edc3ce9760d2f21637766c3aa04822030e7451981ce569a1b3456b7053f22"}, + {file = "coverage-7.2.3-cp38-cp38-win_amd64.whl", hash = "sha256:63cdeaac4ae85a179a8d6bc09b77b564c096250d759eed343a89d91bce8b6367"}, + {file = "coverage-7.2.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:20d1a2a76bb4eb00e4d36b9699f9b7aba93271c9c29220ad4c6a9581a0320235"}, + {file = "coverage-7.2.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4ea748802cc0de4de92ef8244dd84ffd793bd2e7be784cd8394d557a3c751e21"}, + {file = "coverage-7.2.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:21b154aba06df42e4b96fc915512ab39595105f6c483991287021ed95776d934"}, + {file = "coverage-7.2.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fd214917cabdd6f673a29d708574e9fbdb892cb77eb426d0eae3490d95ca7859"}, + {file = "coverage-7.2.3-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2c2e58e45fe53fab81f85474e5d4d226eeab0f27b45aa062856c89389da2f0d9"}, + {file = "coverage-7.2.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:87ecc7c9a1a9f912e306997ffee020297ccb5ea388421fe62a2a02747e4d5539"}, + {file = "coverage-7.2.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:387065e420aed3c71b61af7e82c7b6bc1c592f7e3c7a66e9f78dd178699da4fe"}, + {file = "coverage-7.2.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ea3f5bc91d7d457da7d48c7a732beaf79d0c8131df3ab278e6bba6297e23c6c4"}, + {file = "coverage-7.2.3-cp39-cp39-win32.whl", hash = "sha256:ae7863a1d8db6a014b6f2ff9c1582ab1aad55a6d25bac19710a8df68921b6e30"}, + {file = "coverage-7.2.3-cp39-cp39-win_amd64.whl", hash = "sha256:3f04becd4fcda03c0160d0da9c8f0c246bc78f2f7af0feea1ec0930e7c93fa4a"}, + {file = "coverage-7.2.3-pp37.pp38.pp39-none-any.whl", hash = "sha256:965ee3e782c7892befc25575fa171b521d33798132692df428a09efacaffe8d0"}, + {file = "coverage-7.2.3.tar.gz", hash = "sha256:d298c2815fa4891edd9abe5ad6e6cb4207104c7dd9fd13aea3fdebf6f9b91259"}, +] + +[package.dependencies] +tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} + +[package.extras] +toml = ["tomli"] + +[[package]] +name = "distlib" +version = "0.3.6" +description = "Distribution utilities" +category = "dev" +optional = false +python-versions = "*" +files = [ + {file = "distlib-0.3.6-py2.py3-none-any.whl", hash = "sha256:f35c4b692542ca110de7ef0bea44d73981caeb34ca0b9b6b2e6d7790dda8f80e"}, + {file = "distlib-0.3.6.tar.gz", hash = "sha256:14bad2d9b04d3a36127ac97f30b12a19268f211063d8f8ee4f47108896e11b46"}, +] + +[[package]] +name = "exceptiongroup" +version = "1.1.1" +description = "Backport of PEP 654 (exception groups)" +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "exceptiongroup-1.1.1-py3-none-any.whl", hash = "sha256:232c37c63e4f682982c8b6459f33a8981039e5fb8756b2074364e5055c498c9e"}, + {file = "exceptiongroup-1.1.1.tar.gz", hash = "sha256:d484c3090ba2889ae2928419117447a14daf3c1231d5e30d0aae34f354f01785"}, +] + +[package.extras] +test = ["pytest (>=6)"] + +[[package]] +name = "filelock" +version = "3.12.0" +description = "A platform independent file lock." +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "filelock-3.12.0-py3-none-any.whl", hash = "sha256:ad98852315c2ab702aeb628412cbf7e95b7ce8c3bf9565670b4eaecf1db370a9"}, + {file = "filelock-3.12.0.tar.gz", hash = "sha256:fc03ae43288c013d2ea83c8597001b1129db351aad9c57fe2409327916b8e718"}, +] + +[package.extras] +docs = ["furo (>=2023.3.27)", "sphinx (>=6.1.3)", "sphinx-autodoc-typehints (>=1.23,!=1.23.4)"] +testing = ["covdefaults (>=2.3)", "coverage (>=7.2.3)", "diff-cover (>=7.5)", "pytest (>=7.3.1)", "pytest-cov (>=4)", "pytest-mock (>=3.10)", "pytest-timeout (>=2.1)"] + +[[package]] +name = "importlib-metadata" +version = "6.6.0" +description = "Read metadata from Python packages" +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "importlib_metadata-6.6.0-py3-none-any.whl", hash = "sha256:43dd286a2cd8995d5eaef7fee2066340423b818ed3fd70adf0bad5f1fac53fed"}, + {file = "importlib_metadata-6.6.0.tar.gz", hash = "sha256:92501cdf9cc66ebd3e612f1b4f0c0765dfa42f0fa38ffb319b6bd84dd675d705"}, +] + +[package.dependencies] +typing-extensions = {version = ">=3.6.4", markers = "python_version < \"3.8\""} +zipp = ">=0.5" + +[package.extras] +docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +perf = ["ipython"] +testing = ["flake8 (<5)", "flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)"] + +[[package]] +name = "iniconfig" +version = "2.0.0" +description = "brain-dead simple config-ini parsing" +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, + {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, +] + +[[package]] +name = "mock" +version = "5.0.2" +description = "Rolling backport of unittest.mock for all Pythons" +category = "dev" +optional = false +python-versions = ">=3.6" +files = [ + {file = "mock-5.0.2-py3-none-any.whl", hash = "sha256:0e0bc5ba78b8db3667ad636d964eb963dc97a59f04c6f6214c5f0e4a8f726c56"}, + {file = "mock-5.0.2.tar.gz", hash = "sha256:06f18d7d65b44428202b145a9a36e99c2ee00d1eb992df0caf881d4664377891"}, +] + +[package.extras] +build = ["blurb", "twine", "wheel"] +docs = ["sphinx"] +test = ["pytest", "pytest-cov"] + +[[package]] +name = "nose" +version = "1.3.7" +description = "nose extends unittest to make testing easier" +category = "dev" +optional = false +python-versions = "*" +files = [ + {file = "nose-1.3.7-py2-none-any.whl", hash = "sha256:dadcddc0aefbf99eea214e0f1232b94f2fa9bd98fa8353711dacb112bfcbbb2a"}, + {file = "nose-1.3.7-py3-none-any.whl", hash = "sha256:9ff7c6cc443f8c51994b34a667bbcf45afd6d945be7477b52e97516fd17c53ac"}, + {file = "nose-1.3.7.tar.gz", hash = "sha256:f1bffef9cbc82628f6e7d7b40d7e255aefaa1adb6a1b1d26c69a8b79e6208a98"}, +] + +[[package]] +name = "nose-run-line-number" +version = "0.0.2" +description = "Nose plugin to run tests by line number" +category = "dev" +optional = false +python-versions = "*" +files = [ + {file = "nose-run-line-number-0.0.2.tar.gz", hash = "sha256:521ed2d1c4259d7cc0cab84225e63b1d6f7c7582b580263a2b7c19f2591cb1d4"}, +] + +[[package]] +name = "packaging" +version = "23.1" +description = "Core utilities for Python packages" +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "packaging-23.1-py3-none-any.whl", hash = "sha256:994793af429502c4ea2ebf6bf664629d07c1a9fe974af92966e4b8d2df7edc61"}, + {file = "packaging-23.1.tar.gz", hash = "sha256:a392980d2b6cffa644431898be54b0045151319d1e7ec34f0cfed48767dd334f"}, +] + +[[package]] +name = "platformdirs" +version = "3.4.0" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "platformdirs-3.4.0-py3-none-any.whl", hash = "sha256:01437886022decaf285d8972f9526397bfae2ac55480ed372ed6d9eca048870a"}, + {file = "platformdirs-3.4.0.tar.gz", hash = "sha256:a5e1536e5ea4b1c238a1364da17ff2993d5bd28e15600c2c8224008aff6bbcad"}, +] + +[package.dependencies] +typing-extensions = {version = ">=4.5", markers = "python_version < \"3.8\""} + +[package.extras] +docs = ["furo (>=2023.3.27)", "proselint (>=0.13)", "sphinx (>=6.1.3)", "sphinx-autodoc-typehints (>=1.23,!=1.23.4)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.3.1)", "pytest-cov (>=4)", "pytest-mock (>=3.10)"] + +[[package]] +name = "pluggy" +version = "1.0.0" +description = "plugin and hook calling mechanisms for python" +category = "dev" +optional = false +python-versions = ">=3.6" +files = [ + {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, + {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, +] + +[package.dependencies] +importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["pytest", "pytest-benchmark"] + +[[package]] +name = "pyproject-api" +version = "1.5.1" +description = "API to interact with the python pyproject.toml based projects" +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pyproject_api-1.5.1-py3-none-any.whl", hash = "sha256:4698a3777c2e0f6b624f8a4599131e2a25376d90fe8d146d7ac74c67c6f97c43"}, + {file = "pyproject_api-1.5.1.tar.gz", hash = "sha256:435f46547a9ff22cf4208ee274fca3e2869aeb062a4834adfc99a4dd64af3cf9"}, +] + +[package.dependencies] +packaging = ">=23" +tomli = {version = ">=2.0.1", markers = "python_version < \"3.11\""} + +[package.extras] +docs = ["furo (>=2022.12.7)", "sphinx (>=6.1.3)", "sphinx-autodoc-typehints (>=1.22,!=1.23.4)"] +testing = ["covdefaults (>=2.2.2)", "importlib-metadata (>=6)", "pytest (>=7.2.1)", "pytest-cov (>=4)", "pytest-mock (>=3.10)", "virtualenv (>=20.17.1)", "wheel (>=0.38.4)"] + +[[package]] +name = "pytest" +version = "7.3.1" +description = "pytest: simple powerful testing with Python" +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pytest-7.3.1-py3-none-any.whl", hash = "sha256:3799fa815351fea3a5e96ac7e503a96fa51cc9942c3753cda7651b93c1cfa362"}, + {file = "pytest-7.3.1.tar.gz", hash = "sha256:434afafd78b1d78ed0addf160ad2b77a30d35d4bdf8af234fe621919d9ed15e3"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "sys_platform == \"win32\""} +exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} +importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=0.12,<2.0" +tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} + +[package.extras] +testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"] + +[[package]] +name = "pytest-cov" +version = "4.0.0" +description = "Pytest plugin for measuring coverage." +category = "dev" +optional = false +python-versions = ">=3.6" +files = [ + {file = "pytest-cov-4.0.0.tar.gz", hash = "sha256:996b79efde6433cdbd0088872dbc5fb3ed7fe1578b68cdbba634f14bb8dd0470"}, + {file = "pytest_cov-4.0.0-py3-none-any.whl", hash = "sha256:2feb1b751d66a8bd934e5edfa2e961d11309dc37b73b0eabe73b5945fee20f6b"}, +] + +[package.dependencies] +coverage = {version = ">=5.2.1", extras = ["toml"]} +pytest = ">=4.6" + +[package.extras] +testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtualenv"] + +[[package]] +name = "pytz" +version = "2023.3" +description = "World timezone definitions, modern and historical" +category = "main" +optional = false +python-versions = "*" +files = [ + {file = "pytz-2023.3-py2.py3-none-any.whl", hash = "sha256:a151b3abb88eda1d4e34a9814df37de2a80e301e68ba0fd856fb9b46bfbbbffb"}, + {file = "pytz-2023.3.tar.gz", hash = "sha256:1d8ce29db189191fb55338ee6d0387d82ab59f3d00eac103412d64e0ebd0c588"}, +] + +[[package]] +name = "tomli" +version = "2.0.1" +description = "A lil' TOML parser" +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, + {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, +] + +[[package]] +name = "tox" +version = "4.5.1" +description = "tox is a generic virtualenv management and test command line tool" +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "tox-4.5.1-py3-none-any.whl", hash = "sha256:d25a2e6cb261adc489604fafd76cd689efeadfa79709965e965668d6d3f63046"}, + {file = "tox-4.5.1.tar.gz", hash = "sha256:5a2eac5fb816779dfdf5cb00fecbc27eb0524e4626626bb1de84747b24cacc56"}, +] + +[package.dependencies] +cachetools = ">=5.3" +chardet = ">=5.1" +colorama = ">=0.4.6" +filelock = ">=3.11" +importlib-metadata = {version = ">=6.4.1", markers = "python_version < \"3.8\""} +packaging = ">=23.1" +platformdirs = ">=3.2" +pluggy = ">=1" +pyproject-api = ">=1.5.1" +tomli = {version = ">=2.0.1", markers = "python_version < \"3.11\""} +typing-extensions = {version = ">=4.5", markers = "python_version < \"3.8\""} +virtualenv = ">=20.21" + +[package.extras] +docs = ["furo (>=2023.3.27)", "sphinx (>=6.1.3)", "sphinx-argparse-cli (>=1.11)", "sphinx-autodoc-typehints (>=1.23,!=1.23.4)", "sphinx-copybutton (>=0.5.2)", "sphinx-inline-tabs (>=2022.1.2b11)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=22.12)"] +testing = ["build[virtualenv] (>=0.10)", "covdefaults (>=2.3)", "devpi-process (>=0.3)", "diff-cover (>=7.5)", "distlib (>=0.3.6)", "flaky (>=3.7)", "hatch-vcs (>=0.3)", "hatchling (>=1.14)", "psutil (>=5.9.4)", "pytest (>=7.3.1)", "pytest-cov (>=4)", "pytest-mock (>=3.10)", "pytest-xdist (>=3.2.1)", "re-assert (>=1.1)", "time-machine (>=2.9)", "wheel (>=0.40)"] + +[[package]] +name = "typing-extensions" +version = "4.5.0" +description = "Backported and Experimental Type Hints for Python 3.7+" +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "typing_extensions-4.5.0-py3-none-any.whl", hash = "sha256:fb33085c39dd998ac16d1431ebc293a8b3eedd00fd4a32de0ff79002c19511b4"}, + {file = "typing_extensions-4.5.0.tar.gz", hash = "sha256:5cb5f4a79139d699607b3ef622a1dedafa84e115ab0024e0d9c044a9479ca7cb"}, +] + +[[package]] +name = "virtualenv" +version = "20.22.0" +description = "Virtual Python Environment builder" +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "virtualenv-20.22.0-py3-none-any.whl", hash = "sha256:48fd3b907b5149c5aab7c23d9790bea4cac6bc6b150af8635febc4cfeab1275a"}, + {file = "virtualenv-20.22.0.tar.gz", hash = "sha256:278753c47aaef1a0f14e6db8a4c5e1e040e90aea654d0fc1dc7e0d8a42616cc3"}, +] + +[package.dependencies] +distlib = ">=0.3.6,<1" +filelock = ">=3.11,<4" +importlib-metadata = {version = ">=6.4.1", markers = "python_version < \"3.8\""} +platformdirs = ">=3.2,<4" + +[package.extras] +docs = ["furo (>=2023.3.27)", "proselint (>=0.13)", "sphinx (>=6.1.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=22.12)"] +test = ["covdefaults (>=2.3)", "coverage (>=7.2.3)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.3.1)", "pytest-env (>=0.8.1)", "pytest-freezegun (>=0.4.2)", "pytest-mock (>=3.10)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)"] + +[[package]] +name = "zipp" +version = "3.15.0" +description = "Backport of pathlib-compatible object wrapper for zip files" +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "zipp-3.15.0-py3-none-any.whl", hash = "sha256:48904fc76a60e542af151aded95726c1a5c34ed43ab4134b597665c86d7ad556"}, + {file = "zipp-3.15.0.tar.gz", hash = "sha256:112929ad649da941c23de50f356a2b5570c954b65150642bccdd66bf194d224b"}, +] + +[package.extras] +docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +testing = ["big-O", "flake8 (<5)", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)"] + +[metadata] +lock-version = "2.0" +python-versions = ">=3.7,<4.0" +content-hash = "c36a1673d6d698c7ad45f256b4a254427861286c1a400d49f20b937c857f65f8" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..13e97655 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,43 @@ +[tool.black] +target-version = ['py310'] +include = '\.pyi?$' +skip-string-normalization = true + +[tool.isort] +profile = "black" +multi_line_output = 3 +include_trailing_comma = true +force_grid_wrap = 0 +use_parentheses = true +ensure_newline_before_comments = true +line_length = 88 +known_first_party = ["business_rules"] + +[tool.poetry] +name = "business-rules" +version = "2.0.0" +description = "Python DSL for setting up business intelligence rules that can be configured without code [https://github.com/venmo/business-rules]" +authors = ["venmo "] +readme = "README.md" +packages = [{include = "business_rules"}] +license='MIT' + +[tool.poetry.extras] +test = ["pytest", "pytest-cov"] + +[tool.poetry.dependencies] +python = ">=3.7,<4.0" +pytz = "^2023.3" + +[tool.poetry.group.dev.dependencies] +mock = "*" +nose = "*" +nose-run-line-number = "*" +pytest = "*" +pytest-cov = "*" +coverage = "^7.2.3" +tox = "^4.5.1" + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 00000000..1548117a --- /dev/null +++ b/pytest.ini @@ -0,0 +1,5 @@ +[pytest] +testpaths = tests +addopts = + --cov=business_rules + --cov-fail-under=95 diff --git a/requirements-dev.txt b/requirements-dev.txt deleted file mode 100644 index b20dfcff..00000000 --- a/requirements-dev.txt +++ /dev/null @@ -1,8 +0,0 @@ --r requirements.txt -ipdb==0.10.1 -ipython==5.1 -mock==1.0.1 -nose==1.3.1 -nose-run-line-number==0.0.1 -pytest>=3.0.5 -pytest-cov==2.4.0 diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 37161970..00000000 --- a/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -pytz>=2016.10 -typing>=3.6.1 -six diff --git a/setup.py b/setup.py deleted file mode 100644 index 029f3859..00000000 --- a/setup.py +++ /dev/null @@ -1,30 +0,0 @@ -#! /usr/bin/env python - -from __future__ import absolute_import -import setuptools -from setuptools import find_packages - -from business_rules import __version__ as version - -with open('HISTORY.rst') as f: - history = f.read() - -description = 'Python DSL for setting up business intelligence rules that can be configured without code' - -install_requires = [ - 'pytz>=2016.10', - 'typing', - 'six', -] - -setuptools.setup( - name='business-rules', - version=version, - description='{0}\n\n{1}'.format(description, history), - author='Venmo', - author_email='open-source@venmo.com', - url='https://github.com/venmo/business-rules', - packages=find_packages(exclude=['tests']), - license='MIT', - install_requires=install_requires -) diff --git a/tests/__init__.py b/tests/__init__.py index 20d2d6ba..2fce3bcf 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,7 +1,6 @@ -from __future__ import absolute_import -try: - from unittest2 import TestCase -except ImportError: - from unittest import TestCase +from unittest import TestCase -assert TestCase +# Allow us to use Python 3's `assertRaisesRegex` to avoid +# "DeprecationWarning: Please use assertRaisesRegex instead." +if not hasattr(TestCase, "assertRaisesRegex"): + TestCase.assertRaisesRegex = TestCase.assertRaisesRegexp diff --git a/tests/actions.py b/tests/actions.py index 1848858d..cb8663c3 100644 --- a/tests/actions.py +++ b/tests/actions.py @@ -1,9 +1,9 @@ from __future__ import absolute_import + from business_rules import actions, fields class TestActions(actions.BaseActions): - @actions.rule_action(params={"param": fields.FIELD_TEXT}) def example_action(self, param, **kargs): pass diff --git a/tests/example/actions.py b/tests/example/actions.py index 0aeb2566..f3c70b3f 100755 --- a/tests/example/actions.py +++ b/tests/example/actions.py @@ -1,8 +1,8 @@ -from __future__ import absolute_import +#! python3 import logging -from business_rules.actions import * -from business_rules.fields import * +from business_rules.actions import BaseActions, rule_action +from business_rules.fields import FIELD_TEXT logger = logging.getLogger(__name__) diff --git a/tests/example/main.py b/tests/example/main.py index 58f58053..c6b3b3b0 100755 --- a/tests/example/main.py +++ b/tests/example/main.py @@ -1,9 +1,9 @@ -from __future__ import absolute_import +#! python3 import logging from business_rules import run_all from tests.example.actions import ExampleActions -from tests.example.basket import Item, Basket +from tests.example.basket import Basket, Item from tests.example.variables import ExampleVariables logging.basicConfig(level=logging.DEBUG) @@ -36,7 +36,7 @@ "name": "today", "operator": "after_than_or_equal_to", "value": "2017-01-16", - } + }, ] }, "actions": [ @@ -44,9 +44,9 @@ "name": "log", "params": { "message": "All criteria met!", - } + }, } - ] + ], }, { "actions": [ @@ -54,10 +54,10 @@ "name": "log", "params": { "message": "Rule with no conditions triggered!", - } + }, } ] - } + }, ] hot_drink = Item(code=1, name='Hot Drink', line_number=1, quantity=1) diff --git a/tests/example/variables.py b/tests/example/variables.py index 8be6f4c1..551cc8aa 100755 --- a/tests/example/variables.py +++ b/tests/example/variables.py @@ -1,7 +1,14 @@ -from __future__ import absolute_import +#! python3 import datetime -from business_rules.variables import * +from business_rules.variables import ( + BaseVariables, + boolean_rule_variable, + datetime_rule_variable, + numeric_rule_variable, + select_rule_variable, + string_rule_variable, +) class ExampleVariables(BaseVariables): @@ -22,7 +29,7 @@ def item_count(self): @boolean_rule_variable() def rule_variable(self, **kwargs): - rule = kwargs.get('rule') + kwargs.get('rule') return True @datetime_rule_variable() diff --git a/tests/operators/test_operators.py b/tests/operators/test_operators.py index 0a9d914a..2deef059 100644 --- a/tests/operators/test_operators.py +++ b/tests/operators/test_operators.py @@ -1,18 +1,16 @@ -from __future__ import absolute_import -import sys -from datetime import datetime, timedelta, date, time +from datetime import date, datetime, time, timedelta from decimal import Decimal import pytz from business_rules.operators import ( - StringType, - NumericType, - BooleanType, - SelectType, - SelectMultipleType, BaseType, + BooleanType, DateTimeType, + NumericType, + SelectMultipleType, + SelectType, + StringType, TimeType, ) from tests import TestCase @@ -21,7 +19,7 @@ class BaseTypeOperatorTests(TestCase): def test_base_type_cannot_be_created(self): with self.assertRaises(NotImplementedError): - BaseType('test') + BaseType("test") class StringOperatorTests(TestCase): @@ -71,17 +69,14 @@ def test_non_empty(self): class NumericOperatorTests(TestCase): def test_instantiate(self): err_string = "foo is not a valid numeric type" - with self.assertRaisesRegexp(AssertionError, err_string): + with self.assertRaisesRegex(AssertionError, err_string): NumericType("foo") def test_numeric_type_validates_and_casts_decimal(self): ten_dec = Decimal(10) ten_int = 10 ten_float = 10.0 - if sys.version_info[0] == 2: - ten_long = int(10) - else: - ten_long = int(10) # long and int are same in python3 + ten_long = int(10) # long and int are same in python3 ten_var_dec = NumericType(ten_dec) # this should not throw an exception ten_var_int = NumericType(ten_int) ten_var_float = NumericType(ten_float) @@ -96,14 +91,14 @@ def test_numeric_equal_to(self): self.assertTrue(NumericType(10).equal_to(10.0)) self.assertTrue(NumericType(10).equal_to(10.000001)) self.assertTrue(NumericType(10.000001).equal_to(10)) - self.assertTrue(NumericType(Decimal('10.0')).equal_to(10)) - self.assertTrue(NumericType(10).equal_to(Decimal('10.0'))) + self.assertTrue(NumericType(Decimal("10.0")).equal_to(10)) + self.assertTrue(NumericType(10).equal_to(Decimal("10.0"))) self.assertFalse(NumericType(10).equal_to(10.00001)) self.assertFalse(NumericType(10).equal_to(11)) def test_other_value_not_numeric(self): error_string = "10 is not a valid numeric type" - with self.assertRaisesRegexp(AssertionError, error_string): + with self.assertRaisesRegex(AssertionError, error_string): NumericType(10).equal_to("10") def test_numeric_greater_than(self): @@ -140,10 +135,10 @@ def test_numeric_less_than_or_equal_to(self): class BooleanOperatorTests(TestCase): def test_instantiate(self): err_string = "foo is not a valid boolean type" - with self.assertRaisesRegexp(AssertionError, err_string): + with self.assertRaisesRegex(AssertionError, err_string): BooleanType("foo") err_string = "None is not a valid boolean type" - with self.assertRaisesRegexp(AssertionError, err_string): + with self.assertRaisesRegex(AssertionError, err_string): BooleanType(None) def test_boolean_is_true_and_is_false(self): @@ -175,46 +170,46 @@ def test_invalid_value(self): SelectMultipleType(123) 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"])) + 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_is_contained_by(self): - self.assertTrue(SelectMultipleType([1, 2]). - is_contained_by([2, 1, 3])) - self.assertFalse(SelectMultipleType([1, 2]). - is_contained_by([2, 3, 4])) - self.assertTrue(SelectMultipleType([1, 2, "a"]). - is_contained_by([2, 1, "A"])) + self.assertTrue(SelectMultipleType([1, 2]).is_contained_by([2, 1, 3])) + self.assertFalse(SelectMultipleType([1, 2]).is_contained_by([2, 3, 4])) + self.assertTrue(SelectMultipleType([1, 2, "a"]).is_contained_by([2, 1, "A"])) def test_shares_at_least_one_element_with(self): - self.assertTrue(SelectMultipleType([1, 2]). - shares_at_least_one_element_with([2, 3])) - self.assertFalse(SelectMultipleType([1, 2]). - shares_at_least_one_element_with([4, 3])) - self.assertTrue(SelectMultipleType([1, 2, "a"]). - shares_at_least_one_element_with([4, "A"])) + self.assertTrue( + SelectMultipleType([1, 2]).shares_at_least_one_element_with([2, 3]) + ) + self.assertFalse( + SelectMultipleType([1, 2]).shares_at_least_one_element_with([4, 3]) + ) + self.assertTrue( + SelectMultipleType([1, 2, "a"]).shares_at_least_one_element_with([4, "A"]) + ) def test_shares_exactly_one_element_with(self): - self.assertTrue(SelectMultipleType([1, 2]). - shares_exactly_one_element_with([2, 3])) - self.assertFalse(SelectMultipleType([1, 2]). - shares_exactly_one_element_with([4, 3])) - self.assertTrue(SelectMultipleType([1, 2, "a"]). - shares_exactly_one_element_with([4, "A"])) - self.assertFalse(SelectMultipleType([1, 2, 3]). - shares_exactly_one_element_with([2, 3, "a"])) + self.assertTrue( + SelectMultipleType([1, 2]).shares_exactly_one_element_with([2, 3]) + ) + self.assertFalse( + SelectMultipleType([1, 2]).shares_exactly_one_element_with([4, 3]) + ) + self.assertTrue( + SelectMultipleType([1, 2, "a"]).shares_exactly_one_element_with([4, "A"]) + ) + self.assertFalse( + SelectMultipleType([1, 2, 3]).shares_exactly_one_element_with([2, 3, "a"]) + ) def test_shares_no_elements_with(self): - self.assertTrue(SelectMultipleType([1, 2]). - shares_no_elements_with([4, 3])) - self.assertFalse(SelectMultipleType([1, 2]). - shares_no_elements_with([2, 3])) - self.assertFalse(SelectMultipleType([1, 2, "a"]). - shares_no_elements_with([4, "A"])) + self.assertTrue(SelectMultipleType([1, 2]).shares_no_elements_with([4, 3])) + self.assertFalse(SelectMultipleType([1, 2]).shares_no_elements_with([2, 3])) + self.assertFalse( + SelectMultipleType([1, 2, "a"]).shares_no_elements_with([4, "A"]) + ) class DateTimeOperatorTests(TestCase): @@ -226,19 +221,36 @@ def setUp(self): self.TEST_HOUR = 13 self.TEST_MINUTE = 55 self.TEST_SECOND = 25 - self.TEST_DATETIME = '{year}-0{month}-{day}T{hour}:{minute}:{second}'.format( - year=self.TEST_YEAR, month=self.TEST_MONTH, day=self.TEST_DAY, hour=self.TEST_HOUR, minute=self.TEST_MINUTE, - second=self.TEST_SECOND + self.TEST_DATETIME = "{year}-0{month}-{day}T{hour}:{minute}:{second}".format( + year=self.TEST_YEAR, + month=self.TEST_MONTH, + day=self.TEST_DAY, + hour=self.TEST_HOUR, + minute=self.TEST_MINUTE, + second=self.TEST_SECOND, ) - self.TEST_DATE = '{year}-0{month}-{day}'.format( + self.TEST_DATE = "{year}-0{month}-{day}".format( year=self.TEST_YEAR, month=self.TEST_MONTH, day=self.TEST_DAY ) - self.TEST_DATETIME_OBJ = datetime(self.TEST_YEAR, self.TEST_MONTH, self.TEST_DAY, self.TEST_HOUR, - self.TEST_MINUTE, self.TEST_SECOND) + self.TEST_DATETIME_OBJ = datetime( + self.TEST_YEAR, + self.TEST_MONTH, + self.TEST_DAY, + self.TEST_HOUR, + self.TEST_MINUTE, + self.TEST_SECOND, + ) self.TEST_DATE_OBJ = date(self.TEST_YEAR, self.TEST_MONTH, self.TEST_DAY) - self.TEST_DATETIME_UTC_OBJ = datetime(self.TEST_YEAR, self.TEST_MONTH, self.TEST_DAY, self.TEST_HOUR, - self.TEST_MINUTE, self.TEST_SECOND, tzinfo=pytz.UTC) + self.TEST_DATETIME_UTC_OBJ = datetime( + self.TEST_YEAR, + self.TEST_MONTH, + self.TEST_DAY, + self.TEST_HOUR, + self.TEST_MINUTE, + self.TEST_SECOND, + tzinfo=pytz.UTC, + ) self.datetime_type_date = DateTimeType(self.TEST_DATE) self.datetime_type_datetime = DateTimeType(self.TEST_DATETIME) @@ -247,7 +259,7 @@ def setUp(self): def test_instantiate(self): err_string = "foo is not a valid datetime type" - with self.assertRaisesRegexp(AssertionError, err_string): + with self.assertRaisesRegex(AssertionError, err_string): DateTimeType("foo") def test_datetime_type_validates_and_cast_datetime(self): @@ -266,22 +278,34 @@ def test_datetime_type_validates_and_cast_datetime(self): def test_datetime_equal_to(self): self.assertTrue(self.datetime_type_datetime.equal_to(self.TEST_DATETIME)) self.assertTrue(self.datetime_type_datetime.equal_to(self.TEST_DATETIME_OBJ)) - self.assertTrue(self.datetime_type_datetime.equal_to(self.TEST_DATETIME_UTC_OBJ)) + self.assertTrue( + self.datetime_type_datetime.equal_to(self.TEST_DATETIME_UTC_OBJ) + ) self.assertTrue(self.datetime_type_datetime_obj.equal_to(self.TEST_DATETIME)) - self.assertTrue(self.datetime_type_datetime_obj.equal_to(self.TEST_DATETIME_OBJ)) - self.assertTrue(self.datetime_type_datetime_obj.equal_to(self.TEST_DATETIME_UTC_OBJ)) + self.assertTrue( + self.datetime_type_datetime_obj.equal_to(self.TEST_DATETIME_OBJ) + ) + self.assertTrue( + self.datetime_type_datetime_obj.equal_to(self.TEST_DATETIME_UTC_OBJ) + ) - self.assertTrue(self.datetime_type_datetime_utc_obj.equal_to(self.TEST_DATETIME)) - self.assertTrue(self.datetime_type_datetime_utc_obj.equal_to(self.TEST_DATETIME_OBJ)) - self.assertTrue(self.datetime_type_datetime_utc_obj.equal_to(self.TEST_DATETIME_UTC_OBJ)) + self.assertTrue( + self.datetime_type_datetime_utc_obj.equal_to(self.TEST_DATETIME) + ) + self.assertTrue( + self.datetime_type_datetime_utc_obj.equal_to(self.TEST_DATETIME_OBJ) + ) + self.assertTrue( + self.datetime_type_datetime_utc_obj.equal_to(self.TEST_DATETIME_UTC_OBJ) + ) self.assertTrue(self.datetime_type_date.equal_to(self.TEST_DATE)) self.assertTrue(self.datetime_type_date.equal_to(self.TEST_DATE_OBJ)) def test_other_value_not_datetime(self): error_string = "2016-10 is not a valid datetime type" - with self.assertRaisesRegexp(AssertionError, error_string): + with self.assertRaisesRegex(AssertionError, error_string): DateTimeType(self.TEST_DATE).equal_to("2016-10") def datetime_after_than_asserts(self, datetime_type): @@ -289,10 +313,18 @@ def datetime_after_than_asserts(self, datetime_type): self.assertFalse(datetime_type.after_than(self.TEST_DATETIME)) self.assertFalse(datetime_type.after_than(self.TEST_DATETIME_OBJ)) self.assertFalse(datetime_type.after_than(self.TEST_DATETIME_UTC_OBJ)) - self.assertTrue(datetime_type.after_than(self.TEST_DATETIME_OBJ - timedelta(seconds=1))) - self.assertTrue(datetime_type.after_than(self.TEST_DATETIME_UTC_OBJ - timedelta(seconds=1))) - self.assertFalse(datetime_type.after_than(self.TEST_DATETIME_OBJ + timedelta(seconds=1))) - self.assertFalse(datetime_type.after_than(self.TEST_DATETIME_UTC_OBJ + timedelta(seconds=1))) + self.assertTrue( + datetime_type.after_than(self.TEST_DATETIME_OBJ - timedelta(seconds=1)) + ) + self.assertTrue( + datetime_type.after_than(self.TEST_DATETIME_UTC_OBJ - timedelta(seconds=1)) + ) + self.assertFalse( + datetime_type.after_than(self.TEST_DATETIME_OBJ + timedelta(seconds=1)) + ) + self.assertFalse( + datetime_type.after_than(self.TEST_DATETIME_UTC_OBJ + timedelta(seconds=1)) + ) def test_datetime_after_than(self): self.datetime_after_than_asserts(self.datetime_type_datetime) @@ -300,35 +332,71 @@ def test_datetime_after_than(self): self.datetime_after_than_asserts(self.datetime_type_datetime_utc_obj) self.assertFalse(self.datetime_type_date.after_than(self.TEST_DATE)) - self.assertFalse(self.datetime_type_date.after_than(self.TEST_DATETIME_OBJ + timedelta(seconds=1))) - self.assertFalse(self.datetime_type_date.after_than(self.TEST_DATETIME_UTC_OBJ + timedelta(seconds=1))) + self.assertFalse( + self.datetime_type_date.after_than( + self.TEST_DATETIME_OBJ + timedelta(seconds=1) + ) + ) + self.assertFalse( + self.datetime_type_date.after_than( + self.TEST_DATETIME_UTC_OBJ + timedelta(seconds=1) + ) + ) def datetime_after_than_or_equal_to_asserts(self, datetime_type): # type: (DateTimeType) -> None self.assertTrue(datetime_type.after_than_or_equal_to(self.TEST_DATETIME)) self.assertTrue(datetime_type.after_than_or_equal_to(self.TEST_DATETIME_OBJ)) - self.assertTrue(datetime_type.after_than_or_equal_to(self.TEST_DATETIME_UTC_OBJ)) - self.assertTrue(datetime_type.after_than_or_equal_to(self.TEST_DATETIME_OBJ - timedelta(seconds=1))) - self.assertFalse(datetime_type.after_than_or_equal_to(self.TEST_DATETIME_OBJ + timedelta(seconds=1))) - self.assertTrue(datetime_type.after_than_or_equal_to(self.TEST_DATETIME_UTC_OBJ - timedelta(seconds=1))) - self.assertFalse(datetime_type.after_than_or_equal_to(self.TEST_DATETIME_UTC_OBJ + timedelta(seconds=1))) + self.assertTrue( + datetime_type.after_than_or_equal_to(self.TEST_DATETIME_UTC_OBJ) + ) + self.assertTrue( + datetime_type.after_than_or_equal_to( + self.TEST_DATETIME_OBJ - timedelta(seconds=1) + ) + ) + self.assertFalse( + datetime_type.after_than_or_equal_to( + self.TEST_DATETIME_OBJ + timedelta(seconds=1) + ) + ) + self.assertTrue( + datetime_type.after_than_or_equal_to( + self.TEST_DATETIME_UTC_OBJ - timedelta(seconds=1) + ) + ) + self.assertFalse( + datetime_type.after_than_or_equal_to( + self.TEST_DATETIME_UTC_OBJ + timedelta(seconds=1) + ) + ) def test_datetime_after_than_or_equal_to(self): self.assertTrue(self.datetime_type_date.after_than_or_equal_to(self.TEST_DATE)) self.datetime_after_than_or_equal_to_asserts(self.datetime_type_datetime) self.datetime_after_than_or_equal_to_asserts(self.datetime_type_datetime_obj) - self.datetime_after_than_or_equal_to_asserts(self.datetime_type_datetime_utc_obj) + self.datetime_after_than_or_equal_to_asserts( + self.datetime_type_datetime_utc_obj + ) def datetime_before_than_asserts(self, datetime_type): # type: (DateTimeType) -> None self.assertFalse(datetime_type.before_than(self.TEST_DATETIME)) self.assertFalse(datetime_type.before_than(self.TEST_DATETIME_OBJ)) self.assertFalse(datetime_type.before_than(self.TEST_DATETIME_UTC_OBJ)) - self.assertFalse(datetime_type.before_than(self.TEST_DATETIME_OBJ - timedelta(seconds=1))) - self.assertFalse(datetime_type.before_than(self.TEST_DATETIME_UTC_OBJ - timedelta(seconds=1))) - self.assertTrue(datetime_type.before_than(self.TEST_DATETIME_OBJ + timedelta(seconds=1))) - self.assertTrue(datetime_type.before_than(self.TEST_DATETIME_UTC_OBJ + timedelta(seconds=1))) + self.assertFalse( + datetime_type.before_than(self.TEST_DATETIME_OBJ - timedelta(seconds=1)) + ) + self.assertFalse( + datetime_type.before_than(self.TEST_DATETIME_UTC_OBJ - timedelta(seconds=1)) + ) + self.assertTrue( + datetime_type.before_than(self.TEST_DATETIME_OBJ + timedelta(seconds=1)) + ) + self.assertTrue( + datetime_type.before_than(self.TEST_DATETIME_UTC_OBJ + timedelta(seconds=1)) + ) def test_datetime_before_than(self): self.datetime_before_than_asserts(self.datetime_type_datetime) @@ -336,28 +404,62 @@ def test_datetime_before_than(self): self.datetime_before_than_asserts(self.datetime_type_datetime_utc_obj) self.assertFalse(self.datetime_type_date.before_than(self.TEST_DATE)) - self.assertTrue(self.datetime_type_date.before_than(self.TEST_DATETIME_OBJ + timedelta(seconds=1))) - self.assertTrue(self.datetime_type_date.before_than(self.TEST_DATETIME_UTC_OBJ + timedelta(seconds=1))) + self.assertTrue( + self.datetime_type_date.before_than( + self.TEST_DATETIME_OBJ + timedelta(seconds=1) + ) + ) + self.assertTrue( + self.datetime_type_date.before_than( + self.TEST_DATETIME_UTC_OBJ + timedelta(seconds=1) + ) + ) def datetime_before_than_or_equal_to_asserts(self, datetime_type): # type: (DateTimeType) -> None self.assertTrue(datetime_type.before_than_or_equal_to(self.TEST_DATETIME)) self.assertTrue(datetime_type.before_than_or_equal_to(self.TEST_DATETIME_OBJ)) - self.assertTrue(datetime_type.before_than_or_equal_to(self.TEST_DATETIME_UTC_OBJ)) - self.assertFalse(datetime_type.before_than_or_equal_to(self.TEST_DATETIME_OBJ - timedelta(seconds=1))) - self.assertFalse(datetime_type.before_than_or_equal_to(self.TEST_DATETIME_UTC_OBJ - timedelta(seconds=1))) - self.assertTrue(datetime_type.before_than_or_equal_to(self.TEST_DATETIME_OBJ + timedelta(seconds=1))) - self.assertTrue(datetime_type.before_than_or_equal_to(self.TEST_DATETIME_UTC_OBJ + timedelta(seconds=1))) + self.assertTrue( + datetime_type.before_than_or_equal_to(self.TEST_DATETIME_UTC_OBJ) + ) + self.assertFalse( + datetime_type.before_than_or_equal_to( + self.TEST_DATETIME_OBJ - timedelta(seconds=1) + ) + ) + self.assertFalse( + datetime_type.before_than_or_equal_to( + self.TEST_DATETIME_UTC_OBJ - timedelta(seconds=1) + ) + ) + self.assertTrue( + datetime_type.before_than_or_equal_to( + self.TEST_DATETIME_OBJ + timedelta(seconds=1) + ) + ) + self.assertTrue( + datetime_type.before_than_or_equal_to( + self.TEST_DATETIME_UTC_OBJ + timedelta(seconds=1) + ) + ) def test_datetime_before_than_or_equal_to(self): self.datetime_before_than_or_equal_to_asserts(self.datetime_type_datetime) self.datetime_before_than_or_equal_to_asserts(self.datetime_type_datetime_obj) - self.datetime_before_than_or_equal_to_asserts(self.datetime_type_datetime_utc_obj) + self.datetime_before_than_or_equal_to_asserts( + self.datetime_type_datetime_utc_obj + ) self.assertTrue(self.datetime_type_date.before_than_or_equal_to(self.TEST_DATE)) - self.assertTrue(self.datetime_type_date.before_than_or_equal_to(self.TEST_DATETIME_OBJ + timedelta(seconds=1))) self.assertTrue( - self.datetime_type_date.before_than_or_equal_to(self.TEST_DATETIME_UTC_OBJ + timedelta(seconds=1)) + self.datetime_type_date.before_than_or_equal_to( + self.TEST_DATETIME_OBJ + timedelta(seconds=1) + ) + ) + self.assertTrue( + self.datetime_type_date.before_than_or_equal_to( + self.TEST_DATETIME_UTC_OBJ + timedelta(seconds=1) + ) ) @@ -367,10 +469,12 @@ def setUp(self): self.TEST_HOUR = 13 self.TEST_MINUTE = 55 self.TEST_SECOND = 00 - self.TEST_TIME = '{hour}:{minute}:{second}'.format( + self.TEST_TIME = "{hour}:{minute}:{second}".format( hour=self.TEST_HOUR, minute=self.TEST_MINUTE, second=self.TEST_SECOND ) - self.TEST_TIME_NO_SECONDS = '{hour}:{minute}'.format(hour=self.TEST_HOUR, minute=self.TEST_MINUTE) + self.TEST_TIME_NO_SECONDS = "{hour}:{minute}".format( + hour=self.TEST_HOUR, minute=self.TEST_MINUTE + ) self.TEST_TIME_OBJ = time(self.TEST_HOUR, self.TEST_MINUTE, self.TEST_SECOND) self.time_type_time = TimeType(self.TEST_TIME) @@ -379,7 +483,7 @@ def setUp(self): def test_instantiate(self): err_string = "foo is not a valid time type" - with self.assertRaisesRegexp(AssertionError, err_string): + with self.assertRaisesRegex(AssertionError, err_string): TimeType("foo") def test_time_type_validates_and_cast_time(self): @@ -403,7 +507,7 @@ def test_time_equal_to(self): def test_other_value_not_time(self): error_string = "2016-10 is not a valid time type" - with self.assertRaisesRegexp(AssertionError, error_string): + with self.assertRaisesRegex(AssertionError, error_string): TimeType(self.TEST_TIME_NO_SECONDS).equal_to("2016-10") def time_after_than_asserts(self, time_type): @@ -414,7 +518,11 @@ def time_after_than_asserts(self, time_type): test_time = time(self.TEST_TIME_OBJ.hour, self.TEST_TIME_OBJ.minute - 1, 59) self.assertTrue(time_type.after_than(test_time)) - test_time = time(self.TEST_TIME_OBJ.hour, self.TEST_TIME_OBJ.minute, self.TEST_TIME_OBJ.second + 1) + test_time = time( + self.TEST_TIME_OBJ.hour, + self.TEST_TIME_OBJ.minute, + self.TEST_TIME_OBJ.second + 1, + ) self.assertFalse(time_type.after_than(test_time)) def test_time_after_than(self): @@ -423,7 +531,11 @@ def test_time_after_than(self): self.assertFalse(self.time_type_time.after_than(self.TEST_TIME_NO_SECONDS)) - test_time = time(self.TEST_TIME_OBJ.hour, self.TEST_TIME_OBJ.minute, self.TEST_TIME_OBJ.second + 1) + test_time = time( + self.TEST_TIME_OBJ.hour, + self.TEST_TIME_OBJ.minute, + self.TEST_TIME_OBJ.second + 1, + ) self.assertFalse(self.time_type_time.after_than(test_time)) def time_after_than_or_equal_to_asserts(self, time_type): @@ -434,11 +546,17 @@ def time_after_than_or_equal_to_asserts(self, time_type): test_time = time(self.TEST_TIME_OBJ.hour, self.TEST_TIME_OBJ.minute - 1, 59) self.assertTrue(time_type.after_than_or_equal_to(test_time)) - test_time = time(self.TEST_TIME_OBJ.hour, self.TEST_TIME_OBJ.minute, self.TEST_TIME_OBJ.second + 1) + test_time = time( + self.TEST_TIME_OBJ.hour, + self.TEST_TIME_OBJ.minute, + self.TEST_TIME_OBJ.second + 1, + ) self.assertFalse(time_type.after_than_or_equal_to(test_time)) def test_time_after_than_or_equal_to(self): - self.assertTrue(self.time_type_time.after_than_or_equal_to(self.TEST_TIME_NO_SECONDS)) + self.assertTrue( + self.time_type_time.after_than_or_equal_to(self.TEST_TIME_NO_SECONDS) + ) self.time_after_than_or_equal_to_asserts(self.time_type_time_no_seconds) self.time_after_than_or_equal_to_asserts(self.time_type_time_obj) @@ -451,7 +569,11 @@ def time_before_than_asserts(self, time_type): test_time = time(self.TEST_TIME_OBJ.hour, self.TEST_TIME_OBJ.minute - 1, 59) self.assertFalse(time_type.before_than(test_time)) - test_time = time(self.TEST_TIME_OBJ.hour, self.TEST_TIME_OBJ.minute, self.TEST_TIME_OBJ.second + 1) + test_time = time( + self.TEST_TIME_OBJ.hour, + self.TEST_TIME_OBJ.minute, + self.TEST_TIME_OBJ.second + 1, + ) self.assertTrue(time_type.before_than(test_time)) def test_time_before_than(self): @@ -460,7 +582,11 @@ def test_time_before_than(self): self.assertFalse(self.time_type_time.before_than(self.TEST_TIME_NO_SECONDS)) - test_time = time(self.TEST_TIME_OBJ.hour, self.TEST_TIME_OBJ.minute, self.TEST_TIME_OBJ.second + 1) + test_time = time( + self.TEST_TIME_OBJ.hour, + self.TEST_TIME_OBJ.minute, + self.TEST_TIME_OBJ.second + 1, + ) self.assertTrue(self.time_type_time.before_than(test_time)) def time_before_than_or_equal_to_asserts(self, time_type): @@ -471,14 +597,24 @@ def time_before_than_or_equal_to_asserts(self, time_type): test_time = time(self.TEST_TIME_OBJ.hour, self.TEST_TIME_OBJ.minute - 1, 59) self.assertFalse(time_type.before_than_or_equal_to(test_time)) - test_time = time(self.TEST_TIME_OBJ.hour, self.TEST_TIME_OBJ.minute, self.TEST_TIME_OBJ.second + 1) + test_time = time( + self.TEST_TIME_OBJ.hour, + self.TEST_TIME_OBJ.minute, + self.TEST_TIME_OBJ.second + 1, + ) self.assertTrue(time_type.before_than_or_equal_to(test_time)) def test_time_before_than_or_equal_to(self): self.time_before_than_or_equal_to_asserts(self.time_type_time_no_seconds) self.time_before_than_or_equal_to_asserts(self.time_type_time_obj) - self.assertTrue(self.time_type_time.before_than_or_equal_to(self.TEST_TIME_NO_SECONDS)) + self.assertTrue( + self.time_type_time.before_than_or_equal_to(self.TEST_TIME_NO_SECONDS) + ) - test_time = time(self.TEST_TIME_OBJ.hour, self.TEST_TIME_OBJ.minute, self.TEST_TIME_OBJ.second + 1) + test_time = time( + self.TEST_TIME_OBJ.hour, + self.TEST_TIME_OBJ.minute, + self.TEST_TIME_OBJ.second + 1, + ) self.assertTrue(self.time_type_time.before_than_or_equal_to(test_time)) diff --git a/tests/operators/test_operators_class.py b/tests/operators/test_operators_class.py index 28bfa37f..3fc13a76 100644 --- a/tests/operators/test_operators_class.py +++ b/tests/operators/test_operators_class.py @@ -1,66 +1,67 @@ -from __future__ import absolute_import -from business_rules.operators import BaseType, type_operator -from tests import TestCase +from unittest import TestCase + from mock import MagicMock +from business_rules.operators import BaseType, type_operator + + class OperatorsClassTests(TestCase): - """ Test methods on classes that inherit from BaseType. - """ + """Test methods on classes that inherit from BaseType.""" def test_base_has_no_operators(self): self.assertEqual(len(BaseType.get_all_operators()), 0) def test_get_all_operators(self): - """ Returns a dictionary listing all the operators on the class + """Returns a dictionary listing all the operators on the class that can be called on that type, with some data about them. """ - class SomeType(BaseType): - @type_operator(input_type='text') + class SomeType(BaseType): + @type_operator(input_type="text") def some_operator(self): return True def not_an_operator(self): - return 'yo yo' + return "yo yo" operators = SomeType.get_all_operators() self.assertEqual(len(operators), 1) some_operator = operators[0] - self.assertEqual(some_operator['name'], 'some_operator') - self.assertEqual(some_operator['label'], 'Some Operator') - self.assertEqual(some_operator['input_type'], 'text') - + self.assertEqual(some_operator["name"], "some_operator") + self.assertEqual(some_operator["label"], "Some Operator") + self.assertEqual(some_operator["input_type"], "text") def test_operator_decorator_casts_argument(self): - """ Any operator that has the @type_operator decorator + """Any operator that has the @type_operator decorator should call _assert_valid_value_and_cast on the parameter. """ + class SomeType(BaseType): def __init__(self, value): self.value = value _assert_valid_value_and_cast = MagicMock() - @type_operator('text') + @type_operator("text") def some_operator(self, other_param): pass - @type_operator('text', assert_type_for_arguments=False) + @type_operator("text", assert_type_for_arguments=False) def other_operator(self, other_param): pass # casts with positional args - some_type = SomeType('val') - some_type.some_operator('foo') # positional - some_type._assert_valid_value_and_cast.assert_called_once_with('foo') + some_type = SomeType("val") + some_type.some_operator("foo") # positional + some_type._assert_valid_value_and_cast.assert_called_once_with("foo") # casts with keyword args some_type._assert_valid_value_and_cast.reset_mock() - some_type.some_operator(other_param='foo2') # keyword - some_type._assert_valid_value_and_cast.assert_called_once_with('foo2') + some_type.some_operator(other_param="foo2") # keyword + some_type._assert_valid_value_and_cast.assert_called_once_with("foo2") # does not cast if that argument is set some_type._assert_valid_value_and_cast.reset_mock() - some_type.other_operator('blah') - some_type.other_operator(other_param='blah') + some_type.other_operator("blah") + some_type.other_operator(other_param="blah") self.assertEqual(some_type._assert_valid_value_and_cast.call_count, 0) diff --git a/tests/operators/test_time_operator.py b/tests/operators/test_time_operator.py index 67193cce..a3207529 100644 --- a/tests/operators/test_time_operator.py +++ b/tests/operators/test_time_operator.py @@ -1,8 +1,7 @@ -from __future__ import absolute_import -from datetime import time, datetime, timedelta +from datetime import datetime, time +from unittest import TestCase from business_rules.operators import TimeType -from .. import TestCase class TimeOperatorTests(TestCase): @@ -11,16 +10,22 @@ def setUp(self): self.TEST_HOUR = 13 self.TEST_MINUTE = 55 self.TEST_SECOND = 25 - self.TEST_TIME = '{hour}:{minute}:{second}'.format( + self.TEST_TIME = "{hour}:{minute}:{second}".format( hour=self.TEST_HOUR, minute=self.TEST_MINUTE, second=self.TEST_SECOND ) self.TEST_TIME_OBJ = time(self.TEST_HOUR, self.TEST_MINUTE, self.TEST_SECOND) - self.TEST_DATETIME_OBJ = datetime(2017, 1, 1, hour=self.TEST_HOUR, minute=self.TEST_MINUTE, - second=self.TEST_SECOND) + self.TEST_DATETIME_OBJ = datetime( + 2017, + 1, + 1, + hour=self.TEST_HOUR, + minute=self.TEST_MINUTE, + second=self.TEST_SECOND, + ) def test_instantiate(self): err_string = "foo is not a valid time type" - with self.assertRaisesRegexp(AssertionError, err_string): + with self.assertRaisesRegex(AssertionError, err_string): TimeType("foo") def test_time_type_validates_and_cast_time(self): @@ -38,36 +43,67 @@ def test_time_equal_to(self): def test_other_value_not_time(self): error_string = "2016-10 is not a valid time type" - with self.assertRaisesRegexp(AssertionError, error_string): + with self.assertRaisesRegex(AssertionError, error_string): TimeType(self.TEST_TIME).equal_to("2016-10") def test_time_after_than(self): - self.assertTrue(TimeType(self.TEST_TIME).after_than(self._relative_time(self.TEST_TIME_OBJ, 0, 0, -1))) + self.assertTrue( + TimeType(self.TEST_TIME).after_than( + self._relative_time(self.TEST_TIME_OBJ, 0, 0, -1) + ) + ) self.assertFalse(TimeType(self.TEST_TIME).after_than(self.TEST_TIME)) - self.assertFalse(TimeType(self.TEST_TIME).after_than(self._relative_time(self.TEST_TIME_OBJ, 0, 0, 1))) + self.assertFalse( + TimeType(self.TEST_TIME).after_than( + self._relative_time(self.TEST_TIME_OBJ, 0, 0, 1) + ) + ) def test_time_after_than_or_equal_to(self): self.assertTrue(TimeType(self.TEST_TIME).after_than_or_equal_to(self.TEST_TIME)) self.assertTrue( - TimeType(self.TEST_TIME).after_than_or_equal_to(self._relative_time(self.TEST_TIME_OBJ, 0, 0, -1)) + TimeType(self.TEST_TIME).after_than_or_equal_to( + self._relative_time(self.TEST_TIME_OBJ, 0, 0, -1) + ) ) self.assertFalse( - TimeType(self.TEST_TIME).after_than_or_equal_to(self._relative_time(self.TEST_TIME_OBJ, 0, 0, 1)) + TimeType(self.TEST_TIME).after_than_or_equal_to( + self._relative_time(self.TEST_TIME_OBJ, 0, 0, 1) + ) ) def test_time_before_than(self): - self.assertFalse(TimeType(self.TEST_TIME).before_than(self._relative_time(self.TEST_TIME_OBJ, 0, 0, -1))) + self.assertFalse( + TimeType(self.TEST_TIME).before_than( + self._relative_time(self.TEST_TIME_OBJ, 0, 0, -1) + ) + ) self.assertFalse(TimeType(self.TEST_TIME).before_than(self.TEST_TIME)) - self.assertTrue(TimeType(self.TEST_TIME).before_than(self._relative_time(self.TEST_TIME_OBJ, 0, 0, 1))) + self.assertTrue( + TimeType(self.TEST_TIME).before_than( + self._relative_time(self.TEST_TIME_OBJ, 0, 0, 1) + ) + ) def test_time_before_than_or_equal_to(self): - self.assertTrue(TimeType(self.TEST_TIME).before_than_or_equal_to(self.TEST_TIME)) + self.assertTrue( + TimeType(self.TEST_TIME).before_than_or_equal_to(self.TEST_TIME) + ) self.assertFalse( - TimeType(self.TEST_TIME_OBJ).before_than_or_equal_to(self._relative_time(self.TEST_TIME_OBJ, 0, 0, -1)) + TimeType(self.TEST_TIME_OBJ).before_than_or_equal_to( + self._relative_time(self.TEST_TIME_OBJ, 0, 0, -1) + ) ) self.assertTrue( - TimeType(self.TEST_TIME).before_than_or_equal_to(self._relative_time(self.TEST_TIME_OBJ, 0, 0, 1))) + TimeType(self.TEST_TIME).before_than_or_equal_to( + self._relative_time(self.TEST_TIME_OBJ, 0, 0, 1) + ) + ) @staticmethod def _relative_time(base_time, hours, minutes, seconds): - return time(base_time.hour+hours, base_time.minute+minutes, base_time.second+seconds) + return time( + base_time.hour + hours, + base_time.minute + minutes, + base_time.second + seconds, + ) diff --git a/tests/test_actions_class.py b/tests/test_actions_class.py index a7a52941..b0bbb7e5 100644 --- a/tests/test_actions_class.py +++ b/tests/test_actions_class.py @@ -1,23 +1,22 @@ -from __future__ import absolute_import -from business_rules.actions import BaseActions, rule_action, ActionParam -from business_rules.fields import FIELD_TEXT, FIELD_NUMERIC -from . import TestCase +from unittest import TestCase + +from business_rules.actions import BaseActions, rule_action +from business_rules.fields import FIELD_TEXT class ActionsClassTests(TestCase): - """ Test methods on classes that inherit from BaseActions. - """ + """Test methods on classes that inherit from BaseActions.""" def test_base_has_no_actions(self): self.assertEqual(len(BaseActions.get_all_actions()), 0) def test_get_all_actions(self): - """ Returns a dictionary listing all the functions on the class that + """Returns a dictionary listing all the functions on the class that have been decorated as actions, with some of the data about them. """ class SomeActions(BaseActions): - @rule_action(params={'foo': FIELD_TEXT}) + @rule_action(params={"foo": FIELD_TEXT}) def some_action(self, foo): return "blah" @@ -26,34 +25,46 @@ def non_action(self): actions = SomeActions.get_all_actions() self.assertEqual(len(actions), 1) - self.assertEqual(actions[0]['name'], 'some_action') - self.assertEqual(actions[0]['label'], 'Some Action') - self.assertEqual(actions[0]['params'], [ - {'fieldType': FIELD_TEXT, 'name': 'foo', 'label': 'Foo', 'defaultValue': None}, - ]) + self.assertEqual(actions[0]["name"], "some_action") + self.assertEqual(actions[0]["label"], "Some Action") + self.assertEqual( + actions[0]["params"], + [ + { + "fieldType": FIELD_TEXT, + "name": "foo", + "label": "Foo", + "defaultValue": None, + }, + ], + ) # should work on an instance of the class too self.assertEqual(len(SomeActions().get_all_actions()), 1) def test_rule_action_doesnt_allow_unknown_field_types(self): - err_string = "Unknown field type blah specified for action some_action" \ - " param foo" - with self.assertRaisesRegexp(AssertionError, err_string): - @rule_action(params={'foo': 'blah'}) + err_string = ( + "Unknown field type blah specified for action some_action" " param foo" + ) + with self.assertRaisesRegex(AssertionError, err_string): + + @rule_action(params={"foo": "blah"}) def some_action(self, foo): pass def test_rule_action_doesnt_allow_unknown_parameter_name(self): err_string = "Unknown parameter name foo specified for action some_action" - with self.assertRaisesRegexp(AssertionError, err_string): - @rule_action(params={'foo': 'blah'}) + with self.assertRaisesRegex(AssertionError, err_string): + + @rule_action(params={"foo": "blah"}) def some_action(self): pass def test_rule_action_with_no_params_or_label(self): - """ A rule action should not have to specify paramers or label. """ + """A rule action should not have to specify paramers or label.""" @rule_action() - def some_action(self): pass + def some_action(self): + pass self.assertTrue(some_action.is_rule_action) diff --git a/tests/test_engine_logic.py b/tests/test_engine_logic.py index 627dc423..5996a163 100644 --- a/tests/test_engine_logic.py +++ b/tests/test_engine_logic.py @@ -1,34 +1,29 @@ -from __future__ import absolute_import +from unittest import TestCase -from mock import patch, MagicMock -from business_rules import engine -from business_rules import fields -from business_rules.actions import BaseActions, ActionParam, rule_action -from business_rules.fields import FIELD_TEXT, FIELD_NUMERIC +from mock import MagicMock, patch + +from business_rules import engine, fields +from business_rules.actions import ActionParam, BaseActions, rule_action +from business_rules.fields import FIELD_NUMERIC, FIELD_TEXT from business_rules.models import ConditionResult from business_rules.operators import StringType from business_rules.variables import BaseVariables -from . import TestCase class EngineTests(TestCase): - # ######### # - # ## Run ## # - # ######### # - - @patch.object(engine, 'run') + @patch.object(engine, "run") def test_run_all_some_rule_triggered(self, *args): """ By default, does not stop on first triggered rule. Returns a list of booleans indicating whether each rule was triggered. """ - rule1 = {'conditions': 'condition1', 'actions': 'action name 1'} - rule2 = {'conditions': 'condition2', 'actions': 'action name 2'} + rule1 = {"conditions": "condition1", "actions": "action name 1"} + rule2 = {"conditions": "condition2", "actions": "action name 2"} variables = BaseVariables() actions = BaseActions() def return_action1(rule, *args, **kwargs): - return rule['actions'] == 'action name 1' + return rule["actions"] == "action name 1" engine.run.side_effect = return_action1 @@ -43,153 +38,154 @@ def return_action1(rule, *args, **kwargs): self.assertEqual(results, [False, True]) self.assertEqual(engine.run.call_count, 2) - @patch.object(engine, 'run', return_value=True) + @patch.object(engine, "run", return_value=True) def test_run_all_stop_on_first(self, *args): - rule1 = {'conditions': 'condition1', 'actions': 'action name 1'} - rule2 = {'conditions': 'condition2', 'actions': 'action name 2'} - + rule1 = {"conditions": "condition1", "actions": "action name 1"} + rule2 = {"conditions": "condition2", "actions": "action name 2"} variables = BaseVariables() actions = BaseActions() - results = engine.run_all([rule1, rule2], variables, actions, stop_on_first_trigger=True) + results = engine.run_all( + [rule1, rule2], variables, actions, stop_on_first_trigger=True + ) self.assertEqual(results, [True, False]) self.assertEqual(engine.run.call_count, 1) engine.run.assert_called_once_with(rule1, variables, actions) - @patch.object(engine, 'check_conditions_recursively', return_value=(True, [])) - @patch.object(engine, 'do_actions') + @patch.object(engine, "check_conditions_recursively", return_value=(True, [])) + @patch.object(engine, "do_actions") def test_run_that_triggers_rule(self, *args): - rule = {'conditions': 'blah', 'actions': 'blah2'} - + rule = {"conditions": "blah", "actions": "blah2"} variables = BaseVariables() actions = BaseActions() result = engine.run(rule, variables, actions) - self.assertEqual(result, True) - engine.check_conditions_recursively.assert_called_once_with(rule['conditions'], variables, rule) - engine.do_actions.assert_called_once_with(rule['actions'], actions, [], rule) + engine.check_conditions_recursively.assert_called_once_with( + rule["conditions"], variables, rule + ) + engine.do_actions.assert_called_once_with(rule["actions"], actions, [], rule) - @patch.object(engine, 'check_conditions_recursively', return_value=(False, [])) - @patch.object(engine, 'do_actions') + @patch.object(engine, "check_conditions_recursively", return_value=(False, [])) + @patch.object(engine, "do_actions") def test_run_that_doesnt_trigger_rule(self, *args): - rule = {'conditions': 'blah', 'actions': 'blah2'} - + rule = {"conditions": "blah", "actions": "blah2"} variables = BaseVariables() actions = BaseActions() result = engine.run(rule, variables, actions) - self.assertEqual(result, False) - engine.check_conditions_recursively.assert_called_once_with(rule['conditions'], variables, rule) + engine.check_conditions_recursively.assert_called_once_with( + rule["conditions"], variables, rule + ) self.assertEqual(engine.do_actions.call_count, 0) - @patch.object(engine, 'check_condition', return_value=(True,)) + @patch.object(engine, "check_condition", return_value=(True,)) def test_check_all_conditions_with_all_true(self, *args): - conditions = {'all': [{'thing1': ''}, {'thing2': ''}]} + conditions = {"all": [{"thing1": ""}, {"thing2": ""}]} variables = BaseVariables() - rule = {'conditions': conditions, 'actions': []} + rule = {"conditions": conditions, "actions": []} result = engine.check_conditions_recursively(conditions, variables, rule) self.assertEqual(result, (True, [(True,), (True,)])) # assert call count and most recent call are as expected self.assertEqual(engine.check_condition.call_count, 2) - engine.check_condition.assert_called_with({'thing2': ''}, variables, rule) + engine.check_condition.assert_called_with({"thing2": ""}, variables, rule) # ########################################################## # # #################### Check conditions #################### # # ########################################################## # - @patch.object(engine, 'check_condition', return_value=(False,)) + @patch.object(engine, "check_condition", return_value=(False,)) def test_check_all_conditions_with_all_false(self, *args): - conditions = {'all': [{'thing1': ''}, {'thing2': ''}]} + conditions = {"all": [{"thing1": ""}, {"thing2": ""}]} variables = BaseVariables() rule = {"conditions": conditions, "actions": []} result = engine.check_conditions_recursively(conditions, variables, rule) self.assertEqual(result, (False, [])) - engine.check_condition.assert_called_once_with({'thing1': ''}, variables, rule) + engine.check_condition.assert_called_once_with({"thing1": ""}, variables, rule) def test_check_all_condition_with_no_items_fails(self): - conditions = {'all': []} + conditions = {"all": []} rule = {"conditions": conditions, "actions": []} variables = BaseVariables() with self.assertRaises(AssertionError): engine.check_conditions_recursively(conditions, variables, rule) - @patch.object(engine, 'check_condition', return_value=(True,)) + @patch.object(engine, "check_condition", return_value=(True,)) def test_check_any_conditions_with_all_true(self, *args): - conditions = {'any': [{'thing1': ''}, {'thing2': ''}]} + conditions = {"any": [{"thing1": ""}, {"thing2": ""}]} variables = BaseVariables() - rule = {'conditions': conditions, 'actions': []} + rule = {"conditions": conditions, "actions": []} result = engine.check_conditions_recursively(conditions, variables, rule) self.assertEqual(result, (True, [(True,)])) - engine.check_condition.assert_called_once_with({'thing1': ''}, variables, rule) + engine.check_condition.assert_called_once_with({"thing1": ""}, variables, rule) - @patch.object(engine, 'check_condition', return_value=(False,)) + @patch.object(engine, "check_condition", return_value=(False,)) def test_check_any_conditions_with_all_false(self, *args): - conditions = {'any': [{'thing1': ''}, {'thing2': ''}]} + conditions = {"any": [{"thing1": ""}, {"thing2": ""}]} variables = BaseVariables() - rule = {'conditions': conditions, 'actions': []} + rule = {"conditions": conditions, "actions": []} result = engine.check_conditions_recursively(conditions, variables, rule) self.assertEqual(result, (False, [])) # assert call count and most recent call are as expected self.assertEqual(engine.check_condition.call_count, 2) - engine.check_condition.assert_called_with(conditions['any'][1], variables, rule) + engine.check_condition.assert_called_with(conditions["any"][1], variables, rule) def test_check_any_condition_with_no_items_fails(self): - conditions = {'any': []} + conditions = {"any": []} variables = BaseVariables() - rule = {'conditions': conditions, 'actions': []} + rule = {"conditions": conditions, "actions": []} with self.assertRaises(AssertionError): engine.check_conditions_recursively(conditions, variables, rule) def test_check_all_and_any_together(self): - conditions = {'any': [], 'all': []} + conditions = {"any": [], "all": []} variables = BaseVariables() rule = {"conditions": conditions, "actions": []} with self.assertRaises(AssertionError): engine.check_conditions_recursively(conditions, variables, rule) - @patch.object(engine, 'check_condition') + @patch.object(engine, "check_condition") def test_nested_all_and_any(self, *args): - conditions = {'all': [ - {'any': [{'name': 1}, {'name': 2}]}, - {'name': 3} - ]} - - rule = { - 'conditions': conditions, - 'actions': {} - } + conditions = {"all": [{"any": [{"name": 1}, {"name": 2}]}, {"name": 3}]} + + rule = {"conditions": conditions, "actions": {}} bv = BaseVariables() def side_effect(condition, _, rule): - return ConditionResult(result=condition['name'] in [2, 3], name=condition['name'], operator='', value='', - parameters='') + return ConditionResult( + result=condition["name"] in [2, 3], + name=condition["name"], + operator="", + value="", + parameters="", + ) engine.check_condition.side_effect = side_effect engine.check_conditions_recursively(conditions, bv, rule) self.assertEqual(engine.check_condition.call_count, 3) - engine.check_condition.assert_any_call({'name': 1}, bv, rule) - engine.check_condition.assert_any_call({'name': 2}, bv, rule) - engine.check_condition.assert_any_call({'name': 3}, bv, rule) + engine.check_condition.assert_any_call({"name": 1}, bv, rule) + engine.check_condition.assert_any_call({"name": 2}, bv, rule) + engine.check_condition.assert_any_call({"name": 3}, bv, rule) # ##################################### # # ####### Operator comparisons ######## # # ##################################### # def test_check_operator_comparison(self): - string_type = StringType('yo yo') - with patch.object(string_type, 'contains', return_value=True): + string_type = StringType("yo yo") + with patch.object(string_type, "contains", return_value=True): result = engine._do_operator_comparison( - string_type, 'contains', 'its mocked') + string_type, "contains", "its mocked" + ) self.assertTrue(result) - string_type.contains.assert_called_once_with('its mocked') + string_type.contains.assert_called_once_with("its mocked") # ##################################### # # ############## Actions ############## # @@ -197,23 +193,15 @@ def test_check_operator_comparison(self): def test_do_actions(self): function_params_mock = MagicMock() function_params_mock.varkw = None - with patch('business_rules.engine.getfullargspec', return_value=function_params_mock): + with patch( + "business_rules.engine.getfullargspec", return_value=function_params_mock + ): rule_actions = [ - { - 'name': 'action1' - }, - { - 'name': 'action2', - 'params': {'param1': 'foo', 'param2': 10} - } + {"name": "action1"}, + {"name": "action2", "params": {"param1": "foo", "param2": 10}}, ] - rule = { - 'conditions': { - - }, - 'actions': rule_actions - } + rule = {"conditions": {}, "actions": rule_actions} action1_mock = MagicMock() action2_mock = MagicMock() @@ -223,103 +211,99 @@ class SomeActions(BaseActions): def action1(self): return action1_mock() - @rule_action(params={'param1': FIELD_TEXT, "param2": FIELD_NUMERIC}) + @rule_action(params={"param1": FIELD_TEXT, "param2": FIELD_NUMERIC}) def action2(self, param1, param2): return action2_mock(param1=param1, param2=param2) defined_actions = SomeActions() - payload = [(True, 'condition_name', 'operator_name', 'condition_value')] + payload = [(True, "condition_name", "operator_name", "condition_value")] engine.do_actions(rule_actions, defined_actions, payload, rule) action1_mock.assert_called_once_with() - action2_mock.assert_called_once_with(param1='foo', param2=10) + action2_mock.assert_called_once_with(param1="foo", param2=10) def test_do_actions_with_injected_parameters(self): function_params_mock = MagicMock() function_params_mock.varkw = True - with patch('business_rules.engine.getfullargspec', return_value=function_params_mock): + with patch( + "business_rules.engine.getfullargspec", return_value=function_params_mock + ): rule_actions = [ - { - 'name': 'action1' - }, - { - 'name': 'action2', - 'params': {'param1': 'foo', 'param2': 10} - } + {"name": "action1"}, + {"name": "action2", "params": {"param1": "foo", "param2": 10}}, ] - rule = { - 'conditions': { - - }, - 'actions': rule_actions - } + rule = {"conditions": {}, "actions": rule_actions} defined_actions = BaseActions() defined_actions.action1 = MagicMock() defined_actions.action1.params = [] defined_actions.action2 = MagicMock() - defined_actions.action2.params = [{ - 'label': 'action2', - 'name': 'param1', - 'fieldType': fields.FIELD_TEXT, - 'defaultValue': None - }, + defined_actions.action2.params = [ + { + "label": "action2", + "name": "param1", + "fieldType": fields.FIELD_TEXT, + "defaultValue": None, + }, { - 'label': 'action2', - 'name': 'param2', - 'fieldType': fields.FIELD_NUMERIC, - 'defaultValue': None - } + "label": "action2", + "name": "param2", + "fieldType": fields.FIELD_NUMERIC, + "defaultValue": None, + }, ] - payload = [(True, 'condition_name', 'operator_name', 'condition_value')] + payload = [(True, "condition_name", "operator_name", "condition_value")] engine.do_actions(rule_actions, defined_actions, payload, rule) - defined_actions.action1.assert_called_once_with(conditions=payload, rule=rule) - defined_actions.action2.assert_called_once_with(param1='foo', param2=10, conditions=payload, rule=rule) + defined_actions.action1.assert_called_once_with( + conditions=payload, rule=rule + ) + defined_actions.action2.assert_called_once_with( + param1="foo", param2=10, conditions=payload, rule=rule + ) def test_do_with_invalid_action(self): - actions = [{'name': 'fakeone'}] + actions = [{"name": "fakeone"}] err_string = "Action fakeone is not defined in class BaseActions" - rule = { - 'conditions': {}, - 'actions': {} - } + rule = {"conditions": {}, "actions": {}} - checked_conditions_results = [(True, 'condition_name', 'operator_name', 'condition_value')] + checked_conditions_results = [ + (True, "condition_name", "operator_name", "condition_value") + ] - with self.assertRaisesRegexp(AssertionError, err_string): + with self.assertRaisesRegex(AssertionError, err_string): engine.do_actions(actions, BaseActions(), checked_conditions_results, rule) def test_do_with_parameter_with_default_value(self): function_params_mock = MagicMock() function_params_mock.varkw = None - with patch('business_rules.engine.getfullargspec', return_value=function_params_mock): - # param2 is not set in rule, but there is a default parameter for it in action which will be used instead - rule_actions = [ - { - 'name': 'some_action', - 'params': {'param1': 'foo'} - } - ] + with patch( + "business_rules.engine.getfullargspec", return_value=function_params_mock + ): + # param2 is not set in rule, but there is a default parameter for it in + # action which will be used instead + rule_actions = [{"name": "some_action", "params": {"param1": "foo"}}] - rule = { - 'conditions': { - - }, - 'actions': rule_actions - } + rule = {"conditions": {}, "actions": rule_actions} - action_param_with_default_value = ActionParam(field_type=fields.FIELD_NUMERIC, default_value=42) + action_param_with_default_value = ActionParam( + field_type=fields.FIELD_NUMERIC, default_value=42 + ) action_mock = MagicMock() class SomeActions(BaseActions): - @rule_action(params={'param1': FIELD_TEXT, 'param2': action_param_with_default_value}) + @rule_action( + params={ + "param1": FIELD_TEXT, + "param2": action_param_with_default_value, + } + ) def some_action(self, param1, param2): return action_mock(param1=param1, param2=param2) @@ -327,41 +311,35 @@ def some_action(self, param1, param2): defined_actions.action = MagicMock() defined_actions.action.params = { - 'param1': fields.FIELD_TEXT, - 'param2': action_param_with_default_value + "param1": fields.FIELD_TEXT, + "param2": action_param_with_default_value, } - payload = [(True, 'condition_name', 'operator_name', 'condition_value')] + payload = [(True, "condition_name", "operator_name", "condition_value")] engine.do_actions(rule_actions, defined_actions, payload, rule) - action_mock.assert_called_once_with(param1='foo', param2=42) + action_mock.assert_called_once_with(param1="foo", param2=42) def test_default_param_overrides_action_param(self): function_params_mock = MagicMock() function_params_mock.varkw = None - with patch('business_rules.engine.getfullargspec', return_value=function_params_mock): - rule_actions = [ - { - 'name': 'some_action', - 'params': {'param1': False} - } - ] + with patch( + "business_rules.engine.getfullargspec", return_value=function_params_mock + ): + rule_actions = [{"name": "some_action", "params": {"param1": False}}] - rule = { - 'conditions': { + rule = {"conditions": {}, "actions": rule_actions} - }, - 'actions': rule_actions - } - - action_param_with_default_value = ActionParam(field_type=fields.FIELD_TEXT, default_value='bar') + action_param_with_default_value = ActionParam( + field_type=fields.FIELD_TEXT, default_value="bar" + ) action_mock = MagicMock() class SomeActions(BaseActions): - @rule_action(params={'param1': action_param_with_default_value}) + @rule_action(params={"param1": action_param_with_default_value}) def some_action(self, param1): return action_mock(param1=param1) @@ -369,10 +347,10 @@ def some_action(self, param1): defined_actions.action = MagicMock() defined_actions.action.params = { - 'param1': action_param_with_default_value, + "param1": action_param_with_default_value, } - payload = [(True, 'condition_name', 'operator_name', 'condition_value')] + payload = [(True, "condition_name", "operator_name", "condition_value")] engine.do_actions(rule_actions, defined_actions, payload, rule) @@ -381,15 +359,15 @@ def some_action(self, param1): class EngineCheckConditionsTests(TestCase): def test_case1(self): - """ cond1: true and cond2: false => [] """ + """cond1: true and cond2: false => []""" conditions = { - 'all': [ - {'name': 'true_variable', 'operator': 'is_true', 'value': ''}, - {'name': 'true_variable', 'operator': 'is_false', 'value': ''} + "all": [ + {"name": "true_variable", "operator": "is_true", "value": ""}, + {"name": "true_variable", "operator": "is_false", "value": ""}, ] } variables = TrueVariables() - rule = {'conditions': conditions, 'actions': []} + rule = {"conditions": conditions, "actions": []} result = engine.check_conditions_recursively(conditions, variables, rule) self.assertEqual(result, (False, [])) @@ -399,13 +377,13 @@ def test_case2(self): cond1: false and cond2: true => [] """ conditions = { - 'all': [ - {'name': 'true_variable', 'operator': 'is_false', 'value': ''}, - {'name': 'true_variable', 'operator': 'is_true', 'value': ''} + "all": [ + {"name": "true_variable", "operator": "is_false", "value": ""}, + {"name": "true_variable", "operator": "is_true", "value": ""}, ] } variables = TrueVariables() - rule = {'conditions': conditions, 'actions': []} + rule = {"conditions": conditions, "actions": []} result = engine.check_conditions_recursively(conditions, variables, rule) self.assertEqual(result, (False, [])) @@ -415,61 +393,97 @@ def test_case3(self): cond1: true and cond2: true => [cond1, cond2] """ conditions = { - 'all': [ - {'name': 'true_variable', 'operator': 'is_true', 'value': ''}, - {'name': 'true_variable', 'operator': 'is_true', 'value': ''} + "all": [ + {"name": "true_variable", "operator": "is_true", "value": ""}, + {"name": "true_variable", "operator": "is_true", "value": ""}, ] } variables = TrueVariables() - rule = {'conditions': conditions, 'actions': []} + rule = {"conditions": conditions, "actions": []} result = engine.check_conditions_recursively(conditions, variables, rule) - self.assertEqual(result, (True, [ - ConditionResult(result=True, name='true_variable', operator='is_true', value='', parameters={}), - ConditionResult(result=True, name='true_variable', operator='is_true', value='', parameters={}), - ])) + self.assertEqual( + result, + ( + True, + [ + ConditionResult( + result=True, + name="true_variable", + operator="is_true", + value="", + parameters={}, + ), + ConditionResult( + result=True, + name="true_variable", + operator="is_true", + value="", + parameters={}, + ), + ], + ), + ) def test_case4(self): """ cond1: true and (cond2: false or cond3: true) => [cond1, cond3] """ conditions = { - 'all': [ - {'name': 'true_variable', 'operator': 'is_true', 'value': '1'}, + "all": [ + {"name": "true_variable", "operator": "is_true", "value": "1"}, { - 'any': [ - {'name': 'true_variable', 'operator': 'is_false', 'value': '2'}, - {'name': 'true_variable', 'operator': 'is_true', 'value': '3'} + "any": [ + {"name": "true_variable", "operator": "is_false", "value": "2"}, + {"name": "true_variable", "operator": "is_true", "value": "3"}, ] - } + }, ] } variables = TrueVariables() - rule = {'conditions': conditions, 'actions': []} + rule = {"conditions": conditions, "actions": []} result = engine.check_conditions_recursively(conditions, variables, rule) - self.assertEqual(result, (True, [ - ConditionResult(result=True, name='true_variable', operator='is_true', value='1', parameters={}), - ConditionResult(result=True, name='true_variable', operator='is_true', value='3', parameters={}), - ])) + self.assertEqual( + result, + ( + True, + [ + ConditionResult( + result=True, + name="true_variable", + operator="is_true", + value="1", + parameters={}, + ), + ConditionResult( + result=True, + name="true_variable", + operator="is_true", + value="3", + parameters={}, + ), + ], + ), + ) def test_case5(self): """ cond1: false and (cond2: false or cond3: true) => [] """ conditions = { - 'all': [ - {'name': 'true_variable', 'operator': 'is_false', 'value': '1'}, + "all": [ + {"name": "true_variable", "operator": "is_false", "value": "1"}, { - 'any': [ - {'name': 'true_variable', 'operator': 'is_false', 'value': '2'}, - {'name': 'true_variable', 'operator': 'is_true', 'value': '3'} + "any": [ + {"name": "true_variable", "operator": "is_false", "value": "2"}, + {"name": "true_variable", "operator": "is_true", "value": "3"}, ] - } + }, ] } variables = TrueVariables() - rule = {'conditions': conditions, 'actions': []} + rule = {"conditions": conditions, "actions": []} result = engine.check_conditions_recursively(conditions, variables, rule) self.assertEqual(result, (False, [])) @@ -479,171 +493,279 @@ def test_case6(self): cond1: true or (cond2: false or cond3: true) => [cond1] """ conditions = { - 'any': [ - {'name': 'true_variable', 'operator': 'is_true', 'value': '1'}, + "any": [ + {"name": "true_variable", "operator": "is_true", "value": "1"}, { - 'any': [ - {'name': 'true_variable', 'operator': 'is_false', 'value': '2'}, - {'name': 'true_variable', 'operator': 'is_true', 'value': '3'} + "any": [ + {"name": "true_variable", "operator": "is_false", "value": "2"}, + {"name": "true_variable", "operator": "is_true", "value": "3"}, ] - } + }, ] } variables = TrueVariables() - rule = {'conditions': conditions, 'actions': []} + rule = {"conditions": conditions, "actions": []} result = engine.check_conditions_recursively(conditions, variables, rule) - self.assertEqual(result, (True, [ - ConditionResult(result=True, name='true_variable', operator='is_true', value='1', parameters={}), - ])) + self.assertEqual( + result, + ( + True, + [ + ConditionResult( + result=True, + name="true_variable", + operator="is_true", + value="1", + parameters={}, + ), + ], + ), + ) def test_case7(self): """ cond1: false or (cond2: false or cond3: true) => [cond3] """ conditions = { - 'any': [ - {'name': 'true_variable', 'operator': 'is_false', 'value': '1'}, + "any": [ + {"name": "true_variable", "operator": "is_false", "value": "1"}, { - 'any': [ - {'name': 'true_variable', 'operator': 'is_false', 'value': '2'}, - {'name': 'true_variable', 'operator': 'is_true', 'value': '3'} + "any": [ + {"name": "true_variable", "operator": "is_false", "value": "2"}, + {"name": "true_variable", "operator": "is_true", "value": "3"}, ] - } + }, ] } variables = TrueVariables() - rule = {'conditions': conditions, 'actions': []} + rule = {"conditions": conditions, "actions": []} result = engine.check_conditions_recursively(conditions, variables, rule) - self.assertEqual(result, (True, [ - ConditionResult(result=True, name='true_variable', operator='is_true', value='3', parameters={}), - ])) + self.assertEqual( + result, + ( + True, + [ + ConditionResult( + result=True, + name="true_variable", + operator="is_true", + value="3", + parameters={}, + ), + ], + ), + ) def test_case8(self): """ cond1: false or (cond2: true and cond3: true) => [cond2, cond3] """ conditions = { - 'any': [ - {'name': 'true_variable', 'operator': 'is_false', 'value': '1'}, + "any": [ + {"name": "true_variable", "operator": "is_false", "value": "1"}, { - 'all': [ - {'name': 'true_variable', 'operator': 'is_true', 'value': '2'}, - {'name': 'true_variable', 'operator': 'is_true', 'value': '3'} + "all": [ + {"name": "true_variable", "operator": "is_true", "value": "2"}, + {"name": "true_variable", "operator": "is_true", "value": "3"}, ] - } + }, ] } variables = TrueVariables() - rule = {'conditions': conditions, 'actions': []} + rule = {"conditions": conditions, "actions": []} result = engine.check_conditions_recursively(conditions, variables, rule) - self.assertEqual(result, (True, [ - ConditionResult(result=True, name='true_variable', operator='is_true', value='2', parameters={}), - ConditionResult(result=True, name='true_variable', operator='is_true', value='3', parameters={}), - ])) + self.assertEqual( + result, + ( + True, + [ + ConditionResult( + result=True, + name="true_variable", + operator="is_true", + value="2", + parameters={}, + ), + ConditionResult( + result=True, + name="true_variable", + operator="is_true", + value="3", + parameters={}, + ), + ], + ), + ) def test_case9(self): """ (cond2: true and cond3: true) or cond1: true => [cond2, cond3] """ conditions = { - 'any': [ + "any": [ { - 'all': [ - {'name': 'true_variable', 'operator': 'is_true', 'value': '2'}, - {'name': 'true_variable', 'operator': 'is_true', 'value': '3'} + "all": [ + {"name": "true_variable", "operator": "is_true", "value": "2"}, + {"name": "true_variable", "operator": "is_true", "value": "3"}, ] }, - {'name': 'true_variable', 'operator': 'is_false', 'value': '1'}, + {"name": "true_variable", "operator": "is_false", "value": "1"}, ] } variables = TrueVariables() - rule = {'conditions': conditions, 'actions': []} + rule = {"conditions": conditions, "actions": []} result = engine.check_conditions_recursively(conditions, variables, rule) - self.assertEqual(result, (True, [ - ConditionResult(result=True, name='true_variable', operator='is_true', value='2', parameters={}), - ConditionResult(result=True, name='true_variable', operator='is_true', value='3', parameters={}), - ])) + self.assertEqual( + result, + ( + True, + [ + ConditionResult( + result=True, + name="true_variable", + operator="is_true", + value="2", + parameters={}, + ), + ConditionResult( + result=True, + name="true_variable", + operator="is_true", + value="3", + parameters={}, + ), + ], + ), + ) def test_case10(self): """ cond1: true or cond2: false => [cond1] """ conditions = { - 'any': [ - {'name': 'true_variable', 'operator': 'is_true', 'value': '1'}, - {'name': 'true_variable', 'operator': 'is_false', 'value': '2'}, + "any": [ + {"name": "true_variable", "operator": "is_true", "value": "1"}, + {"name": "true_variable", "operator": "is_false", "value": "2"}, ] } variables = TrueVariables() - rule = {'conditions': conditions, 'actions': []} + rule = {"conditions": conditions, "actions": []} result = engine.check_conditions_recursively(conditions, variables, rule) - self.assertEqual(result, (True, [ - ConditionResult(result=True, name='true_variable', operator='is_true', value='1', parameters={}), - ])) + self.assertEqual( + result, + ( + True, + [ + ConditionResult( + result=True, + name="true_variable", + operator="is_true", + value="1", + parameters={}, + ), + ], + ), + ) def test_case11(self): """ cond1: false or cond2: true => [cond2] """ conditions = { - 'any': [ - {'name': 'true_variable', 'operator': 'is_false', 'value': '1'}, - {'name': 'true_variable', 'operator': 'is_true', 'value': '2'}, + "any": [ + {"name": "true_variable", "operator": "is_false", "value": "1"}, + {"name": "true_variable", "operator": "is_true", "value": "2"}, ] } variables = TrueVariables() - rule = {'conditions': conditions, 'actions': []} + rule = {"conditions": conditions, "actions": []} result = engine.check_conditions_recursively(conditions, variables, rule) - self.assertEqual(result, (True, [ - ConditionResult(result=True, name='true_variable', operator='is_true', value='2', parameters={}), - ])) + self.assertEqual( + result, + ( + True, + [ + ConditionResult( + result=True, + name="true_variable", + operator="is_true", + value="2", + parameters={}, + ), + ], + ), + ) def test_case12(self): """ cond1: true or cond2: true => [cond1] """ conditions = { - 'any': [ - {'name': 'true_variable', 'operator': 'is_true', 'value': '1'}, - {'name': 'true_variable', 'operator': 'is_true', 'value': '2'}, + "any": [ + {"name": "true_variable", "operator": "is_true", "value": "1"}, + {"name": "true_variable", "operator": "is_true", "value": "2"}, ] } variables = TrueVariables() - rule = {'conditions': conditions, 'actions': []} + rule = {"conditions": conditions, "actions": []} result = engine.check_conditions_recursively(conditions, variables, rule) - self.assertEqual(result, (True, [ - ConditionResult(result=True, name='true_variable', operator='is_true', value='1', parameters={}), - ])) + self.assertEqual( + result, + ( + True, + [ + ConditionResult( + result=True, + name="true_variable", + operator="is_true", + value="1", + parameters={}, + ), + ], + ), + ) def test_case13(self): """ (cond1: true and cond2: false) or cond3: true => [cond3] """ conditions = { - 'any': [ + "any": [ { - 'all': [ - {'name': 'true_variable', 'operator': 'is_true', 'value': '1'}, - {'name': 'true_variable', 'operator': 'is_false', 'value': '2'}, + "all": [ + {"name": "true_variable", "operator": "is_true", "value": "1"}, + {"name": "true_variable", "operator": "is_false", "value": "2"}, ] }, - {'name': 'true_variable', 'operator': 'is_true', 'value': '3'}, + {"name": "true_variable", "operator": "is_true", "value": "3"}, ] } variables = TrueVariables() - rule = {'conditions': conditions, 'actions': []} + rule = {"conditions": conditions, "actions": []} result = engine.check_conditions_recursively(conditions, variables, rule) - self.assertEqual(result, (True, [ - ConditionResult(result=True, name='true_variable', operator='is_true', value='3', parameters={}), - ])) + self.assertEqual( + result, + ( + True, + [ + ConditionResult( + result=True, + name="true_variable", + operator="is_true", + value="3", + parameters={}, + ), + ], + ), + ) class TrueVariables(BaseVariables): diff --git a/tests/test_integration.py b/tests/test_integration.py index 56aea91b..6b17052e 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -1,9 +1,16 @@ from __future__ import absolute_import -from business_rules.actions import rule_action, BaseActions + +from unittest import TestCase + +from business_rules.actions import BaseActions, rule_action from business_rules.engine import check_condition, run_all -from business_rules.fields import FIELD_TEXT, FIELD_NUMERIC, FIELD_SELECT -from business_rules.variables import BaseVariables, string_rule_variable, numeric_rule_variable, boolean_rule_variable -from . import TestCase +from business_rules.fields import FIELD_NUMERIC, FIELD_SELECT, FIELD_TEXT +from business_rules.variables import ( + BaseVariables, + boolean_rule_variable, + numeric_rule_variable, + string_rule_variable, +) class SomeVariables(BaseVariables): @@ -19,223 +26,186 @@ def ten(self): def true_bool(self): return True - @numeric_rule_variable(params=[{'field_type': FIELD_NUMERIC, 'name': 'x', 'label': 'X'}]) + @numeric_rule_variable( + params=[{"field_type": FIELD_NUMERIC, "name": "x", "label": "X"}] + ) def x_plus_one(self, x): return x + 1 @boolean_rule_variable() def rule_received(self, **kwargs): - rule = kwargs.get('rule') + rule = kwargs.get("rule") assert rule is not None return rule is not None - @string_rule_variable(label="StringLabel", options=['one', 'two', 'three']) + @string_rule_variable(label="StringLabel", options=["one", "two", "three"]) def string_variable_with_options(self): return "foo" @string_rule_variable(public=False) def private_string_variable(self): - return 'foo' + return "foo" class SomeActions(BaseActions): @rule_action(params={"foo": FIELD_NUMERIC}) - def some_action(self, foo): pass + def some_action(self, foo): + pass @rule_action(label="woohoo", params={"bar": FIELD_TEXT}) - def some_other_action(self, bar): pass - - @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): pass + def some_other_action(self, bar): + pass + + @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): + pass @rule_action() - def action_with_no_params(self): pass + def action_with_no_params(self): + pass class IntegrationTests(TestCase): - """ Integration test, using the library like a user would. - """ + """Integration test, using the library like a user would.""" def test_true_boolean_variable(self): - condition = { - 'name': 'true_bool', - 'operator': 'is_true', - 'value': '' - } + condition = {"name": "true_bool", "operator": "is_true", "value": ""} - rule = { - 'conditions': condition - } + rule = {"conditions": condition} condition_result = check_condition(condition, SomeVariables(), rule) self.assertTrue(condition_result.result) def test_false_boolean_variable(self): - condition = { - 'name': 'true_bool', - 'operator': 'is_false', - 'value': '' - } + condition = {"name": "true_bool", "operator": "is_false", "value": ""} - rule = { - 'conditions': condition - } + rule = {"conditions": condition} condition_result = check_condition(condition, SomeVariables(), rule) self.assertFalse(condition_result.result) def test_check_true_condition_happy_path(self): - condition = {'name': 'foo', - 'operator': 'contains', - 'value': 'o'} + condition = {"name": "foo", "operator": "contains", "value": "o"} - rule = { - 'conditions': condition - } + rule = {"conditions": condition} condition_result = check_condition(condition, SomeVariables(), rule) self.assertTrue(condition_result.result) def test_check_false_condition_happy_path(self): - condition = {'name': 'foo', - 'operator': 'contains', - 'value': 'm'} + condition = {"name": "foo", "operator": "contains", "value": "m"} - rule = { - 'conditions': condition - } + rule = {"conditions": condition} condition_result = check_condition(condition, SomeVariables(), rule) self.assertFalse(condition_result.result) def test_numeric_variable_with_params(self): condition = { - 'name': 'x_plus_one', - 'operator': 'equal_to', - 'value': 10, - 'params': {'x': 9} + "name": "x_plus_one", + "operator": "equal_to", + "value": 10, + "params": {"x": 9}, } - rule = { - 'conditions': condition - } + rule = {"conditions": condition} condition_result = check_condition(condition, SomeVariables(), rule) self.assertTrue(condition_result.result) def test_check_incorrect_method_name(self): - condition = { - 'name': 'food', - 'operator': 'equal_to', - 'value': 'm' - } - - rule = { - 'conditions': condition - } + condition = {"name": "food", "operator": "equal_to", "value": "m"} - err_string = 'Variable food is not defined in class SomeVariables' + rule = {"conditions": condition} - with self.assertRaisesRegexp(AssertionError, err_string): + err_string = "Variable food is not defined in class SomeVariables" + with self.assertRaisesRegex(AssertionError, err_string): check_condition(condition, SomeVariables(), rule) def test_check_incorrect_operator_name(self): - condition = { - 'name': 'foo', - 'operator': 'equal_tooooze', - 'value': 'foo' - } + condition = {"name": "foo", "operator": "equal_tooooze", "value": "foo"} - rule = { - 'conditions': condition - } + rule = {"conditions": condition} with self.assertRaises(AssertionError): check_condition(condition, SomeVariables(), rule) def test_check_missing_params(self): condition = { - 'name': 'x_plus_one', - 'operator': 'equal_to', - 'value': 10, - 'params': {} + "name": "x_plus_one", + "operator": "equal_to", + "value": 10, + "params": {}, } - rule = { - 'conditions': condition - } + rule = {"conditions": condition} - err_string = 'Missing parameters x for variable x_plus_one' + err_string = "Missing parameters x for variable x_plus_one" - with self.assertRaisesRegexp(AssertionError, err_string): + with self.assertRaisesRegex(AssertionError, err_string): check_condition(condition, SomeVariables(), rule) def test_check_invalid_params(self): condition = { - 'name': 'x_plus_one', - 'operator': 'equal_to', - 'value': 10, - 'params': {'x': 9, 'y': 9} + "name": "x_plus_one", + "operator": "equal_to", + "value": 10, + "params": {"x": 9, "y": 9}, } - rule = { - 'conditions': condition - } + rule = {"conditions": condition} - err_string = 'Invalid parameters y for variable x_plus_one' + err_string = "Invalid parameters y for variable x_plus_one" - with self.assertRaisesRegexp(AssertionError, err_string): + with self.assertRaisesRegex(AssertionError, err_string): check_condition(condition, SomeVariables(), rule) def test_variable_received_rules(self): condition = { - 'name': 'rule_received', - 'operator': 'is_true', - 'value': 'true', + "name": "rule_received", + "operator": "is_true", + "value": "true", } - rule = { - 'conditions': condition - } + rule = {"conditions": condition} condition_result = check_condition(condition, SomeVariables(), rule) self.assertTrue(condition_result) def test_string_variable_with_options_with_wrong_value(self): condition = { - 'name': 'string_variable_with_options', - 'operator': 'equal_to', - 'value': 'foo', + "name": "string_variable_with_options", + "operator": "equal_to", + "value": "foo", } - rule = { - 'conditions': condition - } + rule = {"conditions": condition} condition_result = check_condition(condition, SomeVariables(), rule) self.assertTrue(condition_result) def test_run_with_no_conditions(self): - actions = [ - { - 'name': 'action_with_no_params' - } - ] + actions = [{"name": "action_with_no_params"}] - rule = { - 'actions': actions - } + rule = {"actions": actions} - result = run_all(rule_list=[rule], defined_variables=SomeVariables(), defined_actions=SomeActions()) + result = run_all( + rule_list=[rule], + defined_variables=SomeVariables(), + defined_actions=SomeActions(), + ) self.assertTrue(result) diff --git a/tests/test_operators.py b/tests/test_operators.py new file mode 100644 index 00000000..ce61733b --- /dev/null +++ b/tests/test_operators.py @@ -0,0 +1,188 @@ +from decimal import Decimal +from unittest import TestCase + +from business_rules.operators import ( + BooleanType, + NumericType, + SelectMultipleType, + SelectType, + StringType, +) + + +class StringOperatorTests(TestCase): + def test_operator_decorator(self): + self.assertTrue(StringType("foo").equal_to.is_operator) + + def test_string_equal_to(self): + self.assertTrue(StringType("foo").equal_to("foo")) + self.assertFalse(StringType("foo").equal_to("Foo")) + + def test_string_equal_to_case_insensitive(self): + self.assertTrue(StringType("foo").equal_to_case_insensitive("FOo")) + self.assertTrue(StringType("foo").equal_to_case_insensitive("foo")) + self.assertFalse(StringType("foo").equal_to_case_insensitive("blah")) + + def test_string_starts_with(self): + self.assertTrue(StringType("hello").starts_with("he")) + self.assertFalse(StringType("hello").starts_with("hey")) + self.assertFalse(StringType("hello").starts_with("He")) + + def test_string_ends_with(self): + self.assertTrue(StringType("hello").ends_with("lo")) + self.assertFalse(StringType("hello").ends_with("boom")) + self.assertFalse(StringType("hello").ends_with("Lo")) + + def test_string_contains(self): + self.assertTrue(StringType("hello").contains("ell")) + self.assertTrue(StringType("hello").contains("he")) + self.assertTrue(StringType("hello").contains("lo")) + self.assertFalse(StringType("hello").contains("asdf")) + self.assertFalse(StringType("hello").contains("ElL")) + + def test_string_matches_regex(self): + self.assertTrue(StringType("hello").matches_regex(r"^h")) + self.assertFalse(StringType("hello").matches_regex(r"^sh")) + + def test_non_empty(self): + self.assertTrue(StringType("hello").non_empty()) + self.assertFalse(StringType("").non_empty()) + self.assertFalse(StringType(None).non_empty()) + + +class NumericOperatorTests(TestCase): + def test_instantiate(self): + err_string = "foo is not a valid numeric type" + with self.assertRaisesRegex(AssertionError, err_string): + NumericType("foo") + + def test_numeric_type_validates_and_casts_decimal(self): + ten_dec = Decimal(10) + ten_int = 10 + ten_float = 10.0 + ten_long = int(10) # long and int are same in python3 + ten_var_dec = NumericType(ten_dec) # this should not throw an exception + ten_var_int = NumericType(ten_int) + ten_var_float = NumericType(ten_float) + ten_var_long = NumericType(ten_long) + self.assertTrue(isinstance(ten_var_dec.value, Decimal)) + self.assertTrue(isinstance(ten_var_int.value, Decimal)) + self.assertTrue(isinstance(ten_var_float.value, Decimal)) + self.assertTrue(isinstance(ten_var_long.value, Decimal)) + + def test_numeric_equal_to(self): + self.assertTrue(NumericType(10).equal_to(10)) + self.assertTrue(NumericType(10).equal_to(10.0)) + self.assertTrue(NumericType(10).equal_to(10.000001)) + self.assertTrue(NumericType(10.000001).equal_to(10)) + self.assertTrue(NumericType(Decimal("10.0")).equal_to(10)) + self.assertTrue(NumericType(10).equal_to(Decimal("10.0"))) + self.assertFalse(NumericType(10).equal_to(10.00001)) + self.assertFalse(NumericType(10).equal_to(11)) + + def test_other_value_not_numeric(self): + error_string = "10 is not a valid numeric type" + with self.assertRaisesRegex(AssertionError, error_string): + NumericType(10).equal_to("10") + + def test_numeric_greater_than(self): + self.assertTrue(NumericType(10).greater_than(1)) + self.assertFalse(NumericType(10).greater_than(11)) + self.assertTrue(NumericType(10.1).greater_than(10)) + self.assertFalse(NumericType(10.000001).greater_than(10)) + self.assertTrue(NumericType(10.000002).greater_than(10)) + + def test_numeric_greater_than_or_equal_to(self): + self.assertTrue(NumericType(10).greater_than_or_equal_to(1)) + self.assertFalse(NumericType(10).greater_than_or_equal_to(11)) + self.assertTrue(NumericType(10.1).greater_than_or_equal_to(10)) + 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)) + + def test_numeric_less_than(self): + self.assertTrue(NumericType(1).less_than(10)) + self.assertFalse(NumericType(11).less_than(10)) + self.assertTrue(NumericType(10).less_than(10.1)) + self.assertFalse(NumericType(10).less_than(10.000001)) + self.assertTrue(NumericType(10).less_than(10.000002)) + + def test_numeric_less_than_or_equal_to(self): + self.assertTrue(NumericType(1).less_than_or_equal_to(10)) + self.assertFalse(NumericType(11).less_than_or_equal_to(10)) + self.assertTrue(NumericType(10).less_than_or_equal_to(10.1)) + 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)) + + +class BooleanOperatorTests(TestCase): + def test_instantiate(self): + err_string = "foo is not a valid boolean type" + with self.assertRaisesRegex(AssertionError, err_string): + BooleanType("foo") + err_string = "None is not a valid boolean type" + with self.assertRaisesRegex(AssertionError, err_string): + BooleanType(None) + + def test_boolean_is_true_and_is_false(self): + self.assertTrue(BooleanType(True).is_true()) + self.assertFalse(BooleanType(True).is_false()) + self.assertFalse(BooleanType(False).is_true()) + self.assertTrue(BooleanType(False).is_false()) + + +class SelectOperatorTests(TestCase): + def test_contains(self): + self.assertTrue(SelectType([1, 2]).contains(2)) + self.assertFalse(SelectType([1, 2]).contains(3)) + self.assertTrue(SelectType([1, 2, "a"]).contains("A")) + + def test_does_not_contain(self): + self.assertTrue(SelectType([1, 2]).does_not_contain(3)) + self.assertFalse(SelectType([1, 2]).does_not_contain(2)) + self.assertFalse(SelectType([1, 2, "a"]).does_not_contain("A")) + + +class SelectMultipleOperatorTests(TestCase): + 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_is_contained_by(self): + self.assertTrue(SelectMultipleType([1, 2]).is_contained_by([2, 1, 3])) + self.assertFalse(SelectMultipleType([1, 2]).is_contained_by([2, 3, 4])) + self.assertTrue(SelectMultipleType([1, 2, "a"]).is_contained_by([2, 1, "A"])) + + def test_shares_at_least_one_element_with(self): + self.assertTrue( + SelectMultipleType([1, 2]).shares_at_least_one_element_with([2, 3]) + ) + self.assertFalse( + SelectMultipleType([1, 2]).shares_at_least_one_element_with([4, 3]) + ) + self.assertTrue( + SelectMultipleType([1, 2, "a"]).shares_at_least_one_element_with([4, "A"]) + ) + + def test_shares_exactly_one_element_with(self): + self.assertTrue( + SelectMultipleType([1, 2]).shares_exactly_one_element_with([2, 3]) + ) + self.assertFalse( + SelectMultipleType([1, 2]).shares_exactly_one_element_with([4, 3]) + ) + self.assertTrue( + SelectMultipleType([1, 2, "a"]).shares_exactly_one_element_with([4, "A"]) + ) + self.assertFalse( + SelectMultipleType([1, 2, 3]).shares_exactly_one_element_with([2, 3, "a"]) + ) + + def test_shares_no_elements_with(self): + self.assertTrue(SelectMultipleType([1, 2]).shares_no_elements_with([4, 3])) + self.assertFalse(SelectMultipleType([1, 2]).shares_no_elements_with([2, 3])) + self.assertFalse( + SelectMultipleType([1, 2, "a"]).shares_no_elements_with([4, "A"]) + ) diff --git a/tests/test_utils.py b/tests/test_utils.py index 5cdddefa..fffdd188 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,11 +1,11 @@ from __future__ import absolute_import + import pytest -from business_rules import fields -from business_rules import utils +from business_rules import fields, utils from business_rules.fields import FIELD_DATETIME, FIELD_TIME from tests import actions, variables -from tests.test_integration import SomeVariables, SomeActions +from tests.test_integration import SomeActions, SomeVariables def test_fn_name_to_pretty_label(): @@ -56,7 +56,7 @@ def test_export_rule_data(): { "name": "action_with_no_params", "label": "Action With No Params", - "params": None + "params": None, }, { "name": "some_action", @@ -66,9 +66,9 @@ def test_export_rule_data(): 'fieldType': 'numeric', 'label': 'Foo', 'name': 'foo', - 'defaultValue': None + 'defaultValue': None, } - ] + ], }, { "name": "some_other_action", @@ -78,9 +78,9 @@ def test_export_rule_data(): 'fieldType': 'text', 'label': 'Bar', 'name': 'bar', - 'defaultValue': None + 'defaultValue': None, } - ] + ], }, { "name": "some_select_action", @@ -91,12 +91,12 @@ def test_export_rule_data(): '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'}, + ], } - ] - } + ], + }, ] assert all_data.get("variables") == [ @@ -106,7 +106,7 @@ def test_export_rule_data(): "field_type": "string", "options": [], "params": [], - "public": True + "public": True, }, { 'name': 'private_string_variable', @@ -114,7 +114,7 @@ def test_export_rule_data(): 'field_type': 'string', 'options': [], 'params': [], - 'public': False + 'public': False, }, { 'name': 'rule_received', @@ -122,7 +122,7 @@ def test_export_rule_data(): 'field_type': 'boolean', 'options': [], 'params': [], - "public": True + "public": True, }, { 'name': 'string_variable_with_options', @@ -130,7 +130,7 @@ def test_export_rule_data(): 'field_type': 'string', 'options': ['one', 'two', 'three'], 'params': [], - "public": True + "public": True, }, { "name": "ten", @@ -138,7 +138,7 @@ def test_export_rule_data(): "field_type": "numeric", "options": [], "params": [], - "public": True + "public": True, }, { 'name': 'true_bool', @@ -146,128 +146,119 @@ def test_export_rule_data(): 'field_type': 'boolean', 'options': [], "params": [], - "public": True + "public": True, }, { 'name': 'x_plus_one', 'label': 'X Plus One', 'field_type': 'numeric', 'options': [], - 'params': [ - {'field_type': 'numeric', 'name': 'x', 'label': 'X'} - ], - "public": True - } + 'params': [{'field_type': 'numeric', 'name': 'x', 'label': 'X'}], + "public": True, + }, ] assert 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'}], + 'boolean': [ + {'input_type': 'none', 'label': 'Is False', 'name': 'is_false'}, + {'input_type': 'none', 'label': 'Is True', 'name': 'is_true'}, + ], 'datetime': [ - { - 'input_type': FIELD_DATETIME, - 'label': 'After Than', - 'name': 'after_than' - }, + {'input_type': FIELD_DATETIME, 'label': 'After Than', 'name': 'after_than'}, { 'input_type': FIELD_DATETIME, 'label': 'After Than Or Equal To', - 'name': 'after_than_or_equal_to' + 'name': 'after_than_or_equal_to', }, { 'input_type': FIELD_DATETIME, 'label': 'Before Than', - 'name': 'before_than' + 'name': 'before_than', }, { 'input_type': FIELD_DATETIME, 'label': 'Before Than Or Equal To', - 'name': 'before_than_or_equal_to' + 'name': 'before_than_or_equal_to', }, + {'input_type': FIELD_DATETIME, 'label': 'Equal To', 'name': 'equal_to'}, + ], + 'numeric': [ + {'input_type': 'numeric', 'label': 'Equal To', 'name': 'equal_to'}, + {'input_type': 'numeric', 'label': 'Greater Than', 'name': 'greater_than'}, { - 'input_type': FIELD_DATETIME, - 'label': 'Equal To', - 'name': 'equal_to' + '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', }, ], - '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'}], - 'time': [ + 'select': [ + {'input_type': 'select', 'label': 'Contains', 'name': 'contains'}, { - 'input_type': FIELD_TIME, - 'label': 'After Than', - 'name': 'after_than' + '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': FIELD_TIME, - 'label': 'After Than Or Equal To', - 'name': 'after_than_or_equal_to' + 'input_type': 'select_multiple', + 'label': 'Is Contained By', + 'name': 'is_contained_by', }, { - 'input_type': FIELD_TIME, - 'label': 'Before Than', - 'name': 'before_than' + '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'}, + ], + 'time': [ + {'input_type': FIELD_TIME, 'label': 'After Than', 'name': 'after_than'}, { 'input_type': FIELD_TIME, - 'label': 'Before Than Or Equal To', - 'name': 'before_than_or_equal_to' + 'label': 'After Than Or Equal To', + 'name': 'after_than_or_equal_to', }, + {'input_type': FIELD_TIME, 'label': 'Before Than', 'name': 'before_than'}, { 'input_type': FIELD_TIME, - 'label': 'Equal To', - 'name': 'equal_to' + 'label': 'Before Than Or Equal To', + 'name': 'before_than_or_equal_to', }, + {'input_type': FIELD_TIME, 'label': 'Equal To', 'name': 'equal_to'}, ], } @@ -276,51 +267,24 @@ def test_validate_rule_data_success(): valid_rule = { 'conditions': { 'all': [ - { - 'name': 'bool_variable', - 'operator': 'is_false', - 'value': '' - }, - { - 'name': 'str_variable', - 'operator': 'contains', - 'value': 'test' - }, + {'name': 'bool_variable', 'operator': 'is_false', 'value': ''}, + {'name': 'str_variable', 'operator': 'contains', 'value': 'test'}, { 'name': 'select_multiple_variable', 'operator': 'contains_all', - 'value': [1, 2, 3] - }, - { - 'name': 'numeric_variable', - 'operator': 'equal_to', - 'value': 1 + 'value': [1, 2, 3], }, + {'name': 'numeric_variable', 'operator': 'equal_to', 'value': 1}, { 'name': 'datetime_variable', 'operator': 'equal_to', - 'value': '2016-01-01' + 'value': '2016-01-01', }, - { - 'name': 'time_variable', - 'operator': 'equal_to', - 'value': '10:00:00' - }, - { - 'name': 'select_variable', - 'operator': 'contains', - 'value': [1] - } + {'name': 'time_variable', 'operator': 'equal_to', 'value': '10:00:00'}, + {'name': 'select_variable', 'operator': 'contains', 'value': [1]}, ] }, - 'actions': [ - { - 'name': 'example_action', - 'params': { - 'param': 1 - } - } - ] + 'actions': [{'name': 'example_action', 'params': {'param': 1}}], } utils.validate_rule_data(variables.TestVariables, actions.TestActions, valid_rule) @@ -329,35 +293,24 @@ def test_validate_rule_data_nested_success(): valid_rule = { 'conditions': { 'any': [ - { - 'name': 'bool_variable', - 'operator': 'is_false', - 'value': '' - }, + {'name': 'bool_variable', 'operator': 'is_false', 'value': ''}, { "all": [ { 'name': 'str_variable', 'operator': 'contains', - 'value': 'test' + 'value': 'test', }, { 'name': 'select_multiple_variable', 'operator': 'contains_all', - 'value': [1, 2, 3] + 'value': [1, 2, 3], }, ] - } + }, ] }, - 'actions': [ - { - 'name': 'example_action', - 'params': { - 'param': 1 - } - } - ] + 'actions': [{'name': 'example_action', 'params': {'param': 1}}], } utils.validate_rule_data(variables.TestVariables, actions.TestActions, valid_rule) @@ -368,96 +321,70 @@ def test_validate_rule_data_empty_dict(): def test_validate_rule_data_no_conditions(): - invalid_rule = { - 'actions': [] - } + invalid_rule = {'actions': []} utils.validate_rule_data(variables.TestVariables, actions.TestActions, invalid_rule) def test_validate_rule_data_no_actions(): - invalid_rule = { - 'conditions': {} - } + invalid_rule = {'conditions': {}} with pytest.raises(AssertionError): - utils.validate_rule_data(variables.TestVariables, actions.TestActions, invalid_rule) + utils.validate_rule_data( + variables.TestVariables, actions.TestActions, invalid_rule + ) def test_validate_rule_data_unknown_action(): - invalid_rule = { - 'conditions': {}, - 'actions': [ - { - 'name': 'unknown', - 'params': {} - } - ] - } + invalid_rule = {'conditions': {}, 'actions': [{'name': 'unknown', 'params': {}}]} with pytest.raises(AssertionError): - utils.validate_rule_data(variables.TestVariables, actions.TestActions, invalid_rule) + utils.validate_rule_data( + variables.TestVariables, actions.TestActions, invalid_rule + ) def test_validate_rule_data_missing_action_name(): - invalid_rule = { - 'conditions': {}, - 'actions': [ - { - 'params': {} - } - ] - } + invalid_rule = {'conditions': {}, 'actions': [{'params': {}}]} with pytest.raises(AssertionError): - utils.validate_rule_data(variables.TestVariables, actions.TestActions, invalid_rule) + utils.validate_rule_data( + variables.TestVariables, actions.TestActions, invalid_rule + ) def test_validate_rule_data_unknown_condition_name(): invalid_rule = { 'conditions': { - 'any': [ - { - 'name': 'unknown', - 'operator': 'unknown', - 'value': 'unknown' - } - ] + 'any': [{'name': 'unknown', 'operator': 'unknown', 'value': 'unknown'}] }, - 'actions': [] + 'actions': [], } with pytest.raises(AssertionError): - utils.validate_rule_data(variables.TestVariables, actions.TestActions, invalid_rule) + utils.validate_rule_data( + variables.TestVariables, actions.TestActions, invalid_rule + ) def test_validate_rule_data_unknown_condition_operator(): invalid_rule = { 'conditions': { - 'any': [ - { - 'name': 'bool_variable', - 'operator': 'unknown', - 'value': '' - } - ] + 'any': [{'name': 'bool_variable', 'operator': 'unknown', 'value': ''}] }, - 'actions': [] + 'actions': [], } with pytest.raises(AssertionError): - utils.validate_rule_data(variables.TestVariables, actions.TestActions, invalid_rule) + utils.validate_rule_data( + variables.TestVariables, actions.TestActions, invalid_rule + ) def test_validate_rule_data_missing_condition_operator(): invalid_rule = { - 'conditions': { - 'any': [ - { - 'name': 'bool_variable', - 'value': '' - } - ] - }, - 'actions': [] + 'conditions': {'any': [{'name': 'bool_variable', 'value': ''}]}, + 'actions': [], } with pytest.raises(AssertionError): - utils.validate_rule_data(variables.TestVariables, actions.TestActions, invalid_rule) + utils.validate_rule_data( + variables.TestVariables, actions.TestActions, invalid_rule + ) def test_validate_rule_data_bool_value_ignored(): @@ -467,11 +394,11 @@ def test_validate_rule_data_bool_value_ignored(): { 'name': 'bool_variable', 'operator': 'is_true', - 'value': 'any value here' + 'value': 'any value here', } ] }, - 'actions': [] + 'actions': [], } utils.validate_rule_data(variables.TestVariables, actions.TestActions, invalid_rule) @@ -479,18 +406,14 @@ def test_validate_rule_data_bool_value_ignored(): def test_validate_rule_data_bad_condition_value(): invalid_rule = { 'conditions': { - 'any': [ - { - 'name': 'string_variable', - 'operator': 'equal_to', - 'value': 123 - } - ] + 'any': [{'name': 'string_variable', 'operator': 'equal_to', 'value': 123}] }, - 'actions': [] + 'actions': [], } with pytest.raises(AssertionError): - utils.validate_rule_data(variables.TestVariables, actions.TestActions, invalid_rule) + utils.validate_rule_data( + variables.TestVariables, actions.TestActions, invalid_rule + ) def test_validate_rule_data_unknown_condition_key(): @@ -501,62 +424,49 @@ def test_validate_rule_data_unknown_condition_key(): 'name': 'string_variable', 'operator': 'equal_to', 'value': 'test', - 'unknown': 'unknown' + 'unknown': 'unknown', } ] }, - 'actions': [] + 'actions': [], } with pytest.raises(AssertionError): - utils.validate_rule_data(variables.TestVariables, actions.TestActions, invalid_rule) + utils.validate_rule_data( + variables.TestVariables, actions.TestActions, invalid_rule + ) def test_validate_rule_multiple_special_keys_in_condition(): - """ A rule cannot contain more than one 'any' or 'all' keys """ + """A rule cannot contain more than one 'any' or 'all' keys""" invalid_rule = { 'conditions': { - 'any': [ - { - 'name': 'bool_variable', - 'operator': 'is_false', - 'value': '' - } - ], - 'all': [ - { - 'name': 'bool_variable', - 'operator': 'is_false', - 'value': '' - } - ] + 'any': [{'name': 'bool_variable', 'operator': 'is_false', 'value': ''}], + 'all': [{'name': 'bool_variable', 'operator': 'is_false', 'value': ''}], }, - 'actions': [] + 'actions': [], } with pytest.raises(AssertionError): - utils.validate_rule_data(variables.TestVariables, actions.TestActions, invalid_rule) + utils.validate_rule_data( + variables.TestVariables, actions.TestActions, invalid_rule + ) def test_validate_rule_actions_is_not_a_list(): invalid_rule = { 'conditions': { - 'any': [ - { - 'name': 'bool_variable', - 'operator': 'is_false', - 'value': '' - } - ] + 'any': [{'name': 'bool_variable', 'operator': 'is_false', 'value': ''}] }, - 'actions': {} + 'actions': {}, } with pytest.raises(AssertionError): - utils.validate_rule_data(variables.TestVariables, actions.TestActions, invalid_rule) + utils.validate_rule_data( + variables.TestVariables, actions.TestActions, invalid_rule + ) def test_validate_rule_contions_is_not_a_dictionary(): - invalid_rule = { - 'conditions': [], - 'actions': {} - } + invalid_rule = {'conditions': [], 'actions': {}} with pytest.raises(AssertionError): - utils.validate_rule_data(variables.TestVariables, actions.TestActions, invalid_rule) + utils.validate_rule_data( + variables.TestVariables, actions.TestActions, invalid_rule + ) diff --git a/tests/test_variables.py b/tests/test_variables.py index 0e7425ab..5a646743 100644 --- a/tests/test_variables.py +++ b/tests/test_variables.py @@ -1,44 +1,45 @@ -from __future__ import absolute_import -from . import TestCase -from business_rules.utils import fn_name_to_pretty_label -from business_rules.variables import ( - rule_variable, - numeric_rule_variable, - string_rule_variable, - boolean_rule_variable, - select_rule_variable, - select_multiple_rule_variable, - datetime_rule_variable, - time_rule_variable, -) +from unittest import TestCase from business_rules.operators import ( - NumericType, - StringType, BooleanType, - SelectType, - SelectMultipleType, DateTimeType, + NumericType, + SelectMultipleType, + SelectType, + StringType, TimeType, ) +from business_rules.utils import fn_name_to_pretty_label +from business_rules.variables import ( + boolean_rule_variable, + datetime_rule_variable, + numeric_rule_variable, + rule_variable, + select_multiple_rule_variable, + select_rule_variable, + string_rule_variable, + time_rule_variable, +) class RuleVariableTests(TestCase): - """ Tests for the base rule_variable decorator. - """ + """Tests for the base rule_variable decorator.""" def test_pretty_label(self): self.assertEqual( - fn_name_to_pretty_label('some_name_Of_a_thing'), - 'Some Name Of A Thing') - self.assertEqual(fn_name_to_pretty_label('hi'), 'Hi') + fn_name_to_pretty_label("some_name_Of_a_thing"), "Some Name Of A Thing" + ) + self.assertEqual(fn_name_to_pretty_label("hi"), "Hi") def test_rule_variable_requires_instance_of_base_type(self): - err_string = "a_string is not instance of BaseType in rule_variable " \ - "field_type" - with self.assertRaisesRegexp(AssertionError, err_string): - @rule_variable('a_string') - def some_test_function(self): pass + err_string = ( + "a_string is not instance of BaseType in rule_variable " "field_type" + ) + with self.assertRaisesRegex(AssertionError, err_string): + + @rule_variable("a_string") + def some_test_function(self): + pass def test_rule_variable_decorator_internals(self): """ @@ -47,134 +48,153 @@ def test_rule_variable_decorator_internals(self): :return: """ - def some_test_function(self): pass + def some_test_function(self): + pass - wrapper = rule_variable(StringType, 'Foo Name', options=['op1', 'op2']) + wrapper = rule_variable(StringType, "Foo Name", options=["op1", "op2"]) func = wrapper(some_test_function) self.assertTrue(func.is_rule_variable) - self.assertEqual(func.label, 'Foo Name') + self.assertEqual(func.label, "Foo Name") self.assertEqual(func.field_type, StringType) - self.assertEqual(func.options, ['op1', 'op2']) + self.assertEqual(func.options, ["op1", "op2"]) def test_rule_variable_works_as_decorator(self): - @rule_variable(StringType, 'Blah') - def some_test_function(self): pass + @rule_variable(StringType, "Blah") + def some_test_function(self): + pass self.assertTrue(some_test_function.is_rule_variable) def test_rule_variable_decorator_auto_fills_label(self): @rule_variable(StringType) - def some_test_function(self): pass + def some_test_function(self): + pass - self.assertTrue(some_test_function.label, 'Some Test Function') + self.assertTrue(some_test_function.label, "Some Test Function") - ### - ### rule_variable wrappers for each variable type - ### + # + # rule_variable wrappers for each variable type + # def test_rule_variable_function_with_parameter_not_defined(self): with self.assertRaises(AssertionError): - @rule_variable(NumericType, params={'parameter_not_defined': 'type'}) - def variable_function(): pass + + @rule_variable(NumericType, params={"parameter_not_defined": "type"}) + def variable_function(): + pass def test_rule_variable_function_with_parameter_invalid_type(self): with self.assertRaises(AssertionError): - @rule_variable(NumericType, params={'parameter': 'invalid_type'}) - def variable_function(parameter): pass + + @rule_variable(NumericType, params={"parameter": "invalid_type"}) + def variable_function(parameter): + pass def test_numeric_rule_variable(self): - @numeric_rule_variable('My Label') - def numeric_var(): pass + @numeric_rule_variable("My Label") + def numeric_var(): + pass - self.assertTrue(getattr(numeric_var, 'is_rule_variable')) - self.assertEqual(getattr(numeric_var, 'field_type'), NumericType) - self.assertEqual(getattr(numeric_var, 'label'), 'My Label') + self.assertTrue(numeric_var.is_rule_variable) + self.assertEqual(numeric_var.field_type, NumericType) + self.assertEqual(numeric_var.label, "My Label") def test_numeric_rule_variable_no_parens(self): @numeric_rule_variable - def numeric_var(): pass + def numeric_var(): + pass - self.assertTrue(getattr(numeric_var, 'is_rule_variable')) - self.assertEqual(getattr(numeric_var, 'field_type'), NumericType) + self.assertTrue(numeric_var.is_rule_variable) + self.assertEqual(numeric_var.field_type, NumericType) def test_string_rule_variable(self): - @string_rule_variable(label='My Label') - def string_var(): pass + @string_rule_variable(label="My Label") + def string_var(): + pass - self.assertTrue(getattr(string_var, 'is_rule_variable')) - self.assertEqual(getattr(string_var, 'field_type'), StringType) - self.assertEqual(getattr(string_var, 'label'), 'My Label') + self.assertTrue(string_var.is_rule_variable) + self.assertEqual(string_var.field_type, StringType) + self.assertEqual(string_var.label, "My Label") def test_string_rule_variable_no_parens(self): @string_rule_variable - def string_var(): pass + def string_var(): + pass - self.assertTrue(getattr(string_var, 'is_rule_variable')) - self.assertEqual(getattr(string_var, 'field_type'), StringType) + self.assertTrue(string_var.is_rule_variable) + self.assertEqual(string_var.field_type, StringType) def test_boolean_rule_variable(self): - @boolean_rule_variable(label='My Label') - def boolean_var(): pass + @boolean_rule_variable(label="My Label") + def boolean_var(): + pass - self.assertTrue(getattr(boolean_var, 'is_rule_variable')) - self.assertEqual(getattr(boolean_var, 'field_type'), BooleanType) - self.assertEqual(getattr(boolean_var, 'label'), 'My Label') + self.assertTrue(boolean_var.is_rule_variable) + self.assertEqual(boolean_var.field_type, BooleanType) + self.assertEqual(boolean_var.label, "My Label") def test_boolean_rule_variable_no_parens(self): @boolean_rule_variable - def boolean_var(): pass + def boolean_var(): + pass - self.assertTrue(getattr(boolean_var, 'is_rule_variable')) - self.assertEqual(getattr(boolean_var, 'field_type'), BooleanType) + self.assertTrue(boolean_var.is_rule_variable) + self.assertEqual(boolean_var.field_type, BooleanType) def test_select_rule_variable(self): - options = {'foo': 'bar'} + options = {"foo": "bar"} @select_rule_variable(options=options) - def select_var(): pass + def select_var(): + pass - self.assertTrue(getattr(select_var, 'is_rule_variable')) - self.assertEqual(getattr(select_var, 'field_type'), SelectType) - self.assertEqual(getattr(select_var, 'options'), options) + self.assertTrue(select_var.is_rule_variable) + self.assertEqual(select_var.field_type, SelectType) + self.assertEqual(select_var.options, options) def test_select_multiple_rule_variable(self): - options = {'foo': 'bar'} + options = {"foo": "bar"} @select_multiple_rule_variable(options=options) - def select_multiple_var(): pass + def select_multiple_var(): + pass - self.assertTrue(getattr(select_multiple_var, 'is_rule_variable')) - self.assertEqual(getattr(select_multiple_var, 'field_type'), SelectMultipleType) - self.assertEqual(getattr(select_multiple_var, 'options'), options) + self.assertTrue(select_multiple_var.is_rule_variable) + self.assertEqual(select_multiple_var.field_type, SelectMultipleType) + self.assertEqual(select_multiple_var.options, options) def test_datetime_variable(self): @datetime_rule_variable() - def datetime_variable(): pass + def datetime_variable(): + pass - self.assertTrue(getattr(datetime_variable, 'is_rule_variable')) - self.assertEqual(getattr(datetime_variable, 'field_type'), DateTimeType) - self.assertEqual(getattr(datetime_variable, 'label'), 'Datetime Variable') + self.assertTrue(datetime_variable.is_rule_variable) + self.assertEqual(datetime_variable.field_type, DateTimeType) + self.assertEqual(datetime_variable.label, "Datetime Variable") def test_datetime_variable_with_label(self): - @datetime_rule_variable(label='Custom Label') - def datetime_variable(): pass + @datetime_rule_variable(label="Custom Label") + def datetime_variable(): + pass - self.assertTrue(getattr(datetime_variable, 'is_rule_variable')) - self.assertEqual(getattr(datetime_variable, 'field_type'), DateTimeType) - self.assertEqual(getattr(datetime_variable, 'label'), 'Custom Label') + self.assertTrue(datetime_variable.is_rule_variable) + self.assertEqual(datetime_variable.field_type, DateTimeType) + self.assertEqual(datetime_variable.label, "Custom Label") def test_time_variable(self): @time_rule_variable() - def time_variable(): pass + def time_variable(): + pass - self.assertTrue(getattr(time_variable, 'is_rule_variable')) - self.assertEqual(getattr(time_variable, 'field_type'), TimeType) - self.assertEqual(getattr(time_variable, 'label'), 'Time Variable') + self.assertTrue(time_variable.is_rule_variable) + self.assertEqual(time_variable.field_type, TimeType) + self.assertEqual(time_variable.label, "Time Variable") def test_time_variable_with_label(self): - @time_rule_variable(label='Custom Label') - def datetime_variable(): pass + @time_rule_variable(label="Custom Label") + def datetime_variable(): + pass - self.assertTrue(getattr(datetime_variable, 'is_rule_variable')) - self.assertEqual(getattr(datetime_variable, 'field_type'), TimeType) - self.assertEqual(getattr(datetime_variable, 'label'), 'Custom Label') + self.assertTrue(datetime_variable.is_rule_variable) + self.assertEqual(datetime_variable.field_type, TimeType) + self.assertEqual(datetime_variable.label, "Custom Label") diff --git a/tests/test_variables_class.py b/tests/test_variables_class.py index e0538a39..4d326467 100644 --- a/tests/test_variables_class.py +++ b/tests/test_variables_class.py @@ -1,18 +1,17 @@ -from __future__ import absolute_import +from unittest import TestCase + from business_rules.operators import StringType from business_rules.variables import BaseVariables, rule_variable -from . import TestCase class VariablesClassTests(TestCase): - """ Test methods on classes that inherit from BaseVariables - """ + """Test methods on classes that inherit from BaseVariables""" def test_base_has_no_variables(self): self.assertEqual(len(BaseVariables.get_all_variables()), 0) def test_get_all_variables(self): - """ Returns a dictionary listing all the functions on the class that + """Returns a dictionary listing all the functions on the class that have been decorated as variables, with some of the data about them. """ @@ -26,10 +25,10 @@ def non_rule(self): 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]["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"], []) # should work on an instance of the class too self.assertEqual(len(SomeVariables().get_all_variables()), 1) diff --git a/tests/variables.py b/tests/variables.py index 830ff90d..b25a7e9c 100644 --- a/tests/variables.py +++ b/tests/variables.py @@ -1,9 +1,9 @@ -from __future__ import absolute_import +from datetime import datetime + from business_rules import variables class TestVariables(variables.BaseVariables): - @variables.boolean_rule_variable() def bool_variable(self): return True @@ -26,7 +26,7 @@ def datetime_variable(self): @variables.time_rule_variable() def time_variable(self): - return time.time() + return datetime.time() @variables.select_rule_variable() def select_variable(self): diff --git a/tox.ini b/tox.ini new file mode 100644 index 00000000..a79479d8 --- /dev/null +++ b/tox.ini @@ -0,0 +1,34 @@ +# Tox (https://tox.readthedocs.io/) is a tool for running tests in multiple virtualenvs. This configuration file helps +# to run the test suite against different combinations of libraries and Python versions. +# To use it locally, "pip install tox" and then run "tox --skip-missing-interpreters" from this directory. + +[tox] +isolated_build = true +minversion = 3.24.3 +envlist = py{37,38,39,310,311,312} + +[gh-actions] +# Mapping of Python versions (MAJOR.MINOR) to Tox factors. +# When running Tox inside GitHub Actions, the `tox-gh-actions` plugin automatically: +# 1. Identifies the Python version used to run Tox. +# 2. Determines the corresponding Tox factor for that Python version, based on the `python` mapping below. +# 3. Narrows down the Tox `envlist` to environments that match the factor. +# For more details, please see the `tox-gh-actions` README [0] and architecture documentation [1]. +# [0] https://github.com/ymyzk/tox-gh-actions/tree/v2.8.1 +# [1] https://github.com/ymyzk/tox-gh-actions/blob/v2.8.1/ARCHITECTURE.md +python = + 3.7: py37 + 3.8: py38 + 3.9: py39 + 3.10: py310 + 3.11: py311 + 3.12: py312 + +[testenv] +allowlist_externals = + pytest +usedevelop = true +extras = + test +commands = + pytest -vv {posargs}