├── .python-version ├── tests ├── example │ ├── __init__.py │ ├── actions.py │ ├── basket.py │ ├── variables.py │ └── main.py ├── operators │ ├── __init__.py │ ├── test_operators_class.py │ ├── test_time_operator.py │ └── test_operators.py ├── __init__.py ├── actions.py ├── variables.py ├── test_variables_class.py ├── test_actions_class.py ├── test_integration.py ├── test_variables.py ├── test_operators.py ├── test_utils.py └── test_engine_logic.py ├── business_rules ├── util │ ├── __init__.py │ └── method_type.py ├── models.py ├── fields.py ├── __init__.py ├── actions.py ├── variables.py ├── utils.py ├── engine.py └── operators.py ├── MANIFEST.in ├── pytest.ini ├── HISTORY.md ├── .circleci └── config.yml ├── .gitignore ├── pyproject.toml ├── .github └── workflows │ └── tests.yaml ├── .pre-commit-config.yaml ├── .ruff.toml ├── LICENSE ├── tox.ini ├── makefile ├── README.md └── poetry.lock /.python-version: -------------------------------------------------------------------------------- 1 | 3.12 2 | -------------------------------------------------------------------------------- /tests/example/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/operators/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /business_rules/util/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include HISTORY.rst LICENSE README.md 2 | -------------------------------------------------------------------------------- /business_rules/util/method_type.py: -------------------------------------------------------------------------------- 1 | METHOD_TYPE_ACTION = 'action' 2 | METHOD_TYPE_VARIABLE = 'variable' 3 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | testpaths = tests 3 | addopts = 4 | --cov=business_rules 5 | --cov-fail-under=95 6 | -------------------------------------------------------------------------------- /business_rules/models.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | from collections import namedtuple 4 | 5 | ConditionResult = namedtuple( 6 | 'ConditionResult', ['result', 'name', 'operator', 'value', 'parameters'] 7 | ) 8 | -------------------------------------------------------------------------------- /business_rules/fields.py: -------------------------------------------------------------------------------- 1 | FIELD_TEXT = 'text' 2 | FIELD_NUMERIC = 'numeric' 3 | FIELD_NO_INPUT = 'none' 4 | FIELD_SELECT = 'select' 5 | FIELD_SELECT_MULTIPLE = 'select_multiple' 6 | FIELD_DATETIME = 'datetime' 7 | FIELD_TIME = 'time' 8 | FIELD_BOOLEAN = 'boolean' 9 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | # Allow us to use Python 3's `assertRaisesRegex` to avoid 4 | # "DeprecationWarning: Please use assertRaisesRegex instead." 5 | if not hasattr(TestCase, "assertRaisesRegex"): 6 | TestCase.assertRaisesRegex = TestCase.assertRaisesRegexp 7 | -------------------------------------------------------------------------------- /tests/actions.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | from business_rules import actions, fields 4 | 5 | 6 | class TestActions(actions.BaseActions): 7 | @actions.rule_action(params={"param": fields.FIELD_TEXT}) 8 | def example_action(self, param, **kargs): 9 | pass 10 | -------------------------------------------------------------------------------- /business_rules/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "1.1.1" 2 | 3 | from .engine import check_conditions_recursively, run_all 4 | from .utils import export_rule_data, validate_rule_data 5 | 6 | # Appease pyflakes by "using" these exports 7 | assert run_all 8 | assert export_rule_data 9 | assert check_conditions_recursively 10 | assert validate_rule_data 11 | -------------------------------------------------------------------------------- /tests/example/actions.py: -------------------------------------------------------------------------------- 1 | #! python3 2 | import logging 3 | 4 | from business_rules.actions import BaseActions, rule_action 5 | from business_rules.fields import FIELD_TEXT 6 | 7 | logger = logging.getLogger(__name__) 8 | 9 | 10 | class ExampleActions(BaseActions): 11 | def __init__(self, basket): 12 | self.basket = basket 13 | 14 | @rule_action(params={"message": FIELD_TEXT}) 15 | def log(self, message, **kargs): 16 | logger.info(message) 17 | -------------------------------------------------------------------------------- /HISTORY.md: -------------------------------------------------------------------------------- 1 | History 2 | ========= 3 | 4 | ## 1.1.1 5 | 6 | Release date: 2022-3-18 7 | 8 | - Fix package description and long description 9 | 10 | ## 1.1.0 11 | 12 | Release date: 2022-3-18 13 | 14 | - Add support for Python 3.5-3.7 15 | 16 | ## 1.0.1 17 | 18 | Release date: 2016-3-16 19 | 20 | - Fixes a packaging bug preventing 1.0.0 from being installed on some platforms. 21 | 22 | ## 1.0.0 23 | 24 | Release date: 2016-3-16 25 | 26 | - Removes caching layer on rule decorator 27 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | jobs: 4 | build: 5 | resource_class: small 6 | docker: 7 | - image: cimg/python:3.12 8 | steps: 9 | - checkout 10 | - run: 11 | name: Install Dependencies 12 | command: | 13 | make clean 14 | poetry install --no-ansi --no-interaction 15 | - run: 16 | name: Run Tests 17 | command: | 18 | make coverage 19 | - store_artifacts: 20 | path: htmlcov 21 | - store_test_results: 22 | path: ./test-results 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | .DS_Store 3 | 4 | # C extensions 5 | *.so 6 | 7 | # Packages 8 | *.egg 9 | *.egg-info 10 | dist 11 | build 12 | eggs 13 | parts 14 | bin 15 | var 16 | sdist 17 | develop-eggs 18 | .installed.cfg 19 | lib 20 | lib64 21 | 22 | # Installer logs 23 | pip-log.txt 24 | 25 | # Unit test / coverage reports 26 | .coverage 27 | .tox 28 | .pytest_cache 29 | test-results/ 30 | 31 | # Translations 32 | *.mo 33 | 34 | # Mr Developer 35 | .mr.developer.cfg 36 | .project 37 | .pydevproject 38 | 39 | # Vim 40 | *.sw[po] 41 | .idea/ 42 | 43 | # Virtual environments 44 | venv*/ 45 | tests/__pycache__/ 46 | .cache/ 47 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "business-rules" 3 | version = "1.7.0" 4 | description = "Python DSL for setting up business intelligence rules that can be configured without code [https://github.com/venmo/business-rules]" 5 | authors = ["venmo "] 6 | readme = "README.md" 7 | packages = [{include = "business_rules"}] 8 | license='MIT' 9 | 10 | [tool.poetry.extras] 11 | test = ["pytest", "pytest-cov"] 12 | 13 | [tool.poetry.dependencies] 14 | python = ">=3.9,<4.0" 15 | 16 | [tool.poetry.group.dev.dependencies] 17 | mock = "^5.2.0" 18 | nose = "^1.3.7" 19 | nose-run-line-number = "^0.0.2" 20 | pytest = "^8.4.2" 21 | pytest-cov = "^7.0.0" 22 | coverage = "^7.2.3" 23 | tox = "^4.5.1" 24 | 25 | [build-system] 26 | requires = ["poetry-core"] 27 | build-backend = "poetry.core.masonry.api" 28 | -------------------------------------------------------------------------------- /.github/workflows/tests.yaml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: [master] 8 | 9 | jobs: 10 | tests: 11 | # https://help.github.com/articles/virtual-environments-for-github-actions 12 | runs-on: ubuntu-latest 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] 17 | steps: 18 | - uses: actions/checkout@v3 19 | 20 | - name: Set up Python ${{ matrix.python-version }} 21 | uses: actions/setup-python@v4 22 | with: 23 | python-version: ${{ matrix.python-version }} 24 | 25 | - name: Install dependencies 26 | run: | 27 | curl -sSL https://install.python-poetry.org | python3 - 28 | poetry install -v --no-root --with dev 29 | 30 | - name: Test with tox 31 | run: poetry run tox -vv 32 | -------------------------------------------------------------------------------- /tests/variables.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from business_rules import variables 4 | 5 | 6 | class TestVariables(variables.BaseVariables): 7 | @variables.boolean_rule_variable() 8 | def bool_variable(self): 9 | return True 10 | 11 | @variables.string_rule_variable() 12 | def str_variable(self): 13 | return 'test' 14 | 15 | @variables.select_multiple_rule_variable() 16 | def select_multiple_variable(self): 17 | return [1, 2, 3] 18 | 19 | @variables.numeric_rule_variable() 20 | def numeric_variable(self): 21 | return 1 22 | 23 | @variables.datetime_rule_variable() 24 | def datetime_variable(self): 25 | return datetime.now() 26 | 27 | @variables.time_rule_variable() 28 | def time_variable(self): 29 | return datetime.time() 30 | 31 | @variables.select_rule_variable() 32 | def select_variable(self): 33 | return [1, 2, 3] 34 | -------------------------------------------------------------------------------- /tests/example/basket.py: -------------------------------------------------------------------------------- 1 | class Item(object): 2 | def __init__(self, code, name, line_number, quantity): 3 | self._code = code 4 | self._name = name 5 | self._line_number = line_number 6 | self._quantity = quantity 7 | 8 | @property 9 | def code(self): 10 | return self._code 11 | 12 | @property 13 | def name(self): 14 | return self._name 15 | 16 | @property 17 | def line_number(self): 18 | return self._line_number 19 | 20 | @property 21 | def quantity(self): 22 | return self._quantity 23 | 24 | 25 | class Basket(object): 26 | def __init__(self, id, items): 27 | self._id = id 28 | self._items = items 29 | 30 | @property 31 | def id(self): 32 | return self._id 33 | 34 | @property 35 | def items(self): 36 | return self._items 37 | 38 | @property 39 | def product_codes(self): 40 | return [item.code for item in self.items] 41 | -------------------------------------------------------------------------------- /tests/example/variables.py: -------------------------------------------------------------------------------- 1 | #! python3 2 | import datetime 3 | 4 | from business_rules.variables import ( 5 | BaseVariables, 6 | boolean_rule_variable, 7 | datetime_rule_variable, 8 | numeric_rule_variable, 9 | select_rule_variable, 10 | string_rule_variable, 11 | ) 12 | 13 | 14 | class ExampleVariables(BaseVariables): 15 | def __init__(self, basket): 16 | self.basket = basket 17 | 18 | @select_rule_variable(public=False) 19 | def items(self): 20 | return self.basket.product_codes 21 | 22 | @string_rule_variable() 23 | def current_month(self): 24 | return datetime.datetime.now().strftime("%B") 25 | 26 | @numeric_rule_variable() 27 | def item_count(self): 28 | return len(self.basket.product_codes) 29 | 30 | @boolean_rule_variable() 31 | def rule_variable(self, **kwargs): 32 | kwargs.get('rule') 33 | return True 34 | 35 | @datetime_rule_variable() 36 | def today(self): 37 | return datetime.date.today() 38 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/astral-sh/ruff-pre-commit 3 | rev: v0.12.12 4 | hooks: 5 | - id: ruff 6 | alias: autoformat 7 | args: [--fix] 8 | - id: ruff-format 9 | 10 | - repo: https://github.com/pre-commit/pre-commit-hooks 11 | rev: v6.0.0 12 | hooks: 13 | - id: check-added-large-files 14 | - id: check-ast 15 | language_version: python3.12 16 | - id: fix-byte-order-marker 17 | - id: check-case-conflict 18 | - id: check-executables-have-shebangs 19 | - id: check-json 20 | - id: check-symlinks 21 | - id: check-toml 22 | - id: check-vcs-permalinks 23 | - id: check-xml 24 | - id: check-yaml 25 | - id: debug-statements 26 | language_version: python3.12 27 | - id: detect-private-key 28 | - id: end-of-file-fixer 29 | - id: forbid-new-submodules 30 | - id: mixed-line-ending 31 | args: [--fix=lf] 32 | - id: no-commit-to-branch 33 | args: [--branch=master] 34 | - id: trailing-whitespace 35 | -------------------------------------------------------------------------------- /.ruff.toml: -------------------------------------------------------------------------------- 1 | # https://docs.astral.sh/ruff/tutorial/#configuration 2 | # https://docs.astral.sh/ruff/settings/ 3 | # Set the maximum line length to 88. 4 | line-length = 88 5 | indent-width = 4 6 | target-version="py39" 7 | 8 | [lint] 9 | select = [ 10 | "B", # flake8-bugbear 11 | "C", 12 | "DJ", # flake8-django 13 | "E", # pycodestyle 14 | "F", # Pyflakes 15 | "I", # isort 16 | "PL", # pylint 17 | "UP", # pyupgrade 18 | "W", 19 | "B9" 20 | ] 21 | ignore = ["E203", "B904", "DJ001", "DJ006", "W191"] 22 | 23 | 24 | # Add the `line-too-long` rule to the enforced rule set. By default, Ruff omits rules that 25 | # overlap with the use of a formatter, like Black, but we can override this behavior by 26 | # explicitly adding the rule. 27 | extend-select = ["E501"] 28 | 29 | [format] 30 | quote-style = "double" 31 | indent-style = "space" 32 | 33 | [lint.isort] 34 | known-first-party = ["deployer"] 35 | 36 | [lint.mccabe] # DO NOT INCREASE THIS VALUE 37 | max-complexity = 18 # default: 10 38 | 39 | [lint.pylint] 40 | max-args = 10 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 Venmo Inc 2 | 3 | Permission is hereby granted, free of charge, to any person 4 | obtaining a copy of this software and associated documentation 5 | files (the "Software"), to deal in the Software without 6 | restriction, including without limitation the rights to use, 7 | copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the 9 | Software is furnished to do so, subject to the following 10 | conditions: 11 | 12 | The above copyright notice and this permission notice shall be 13 | included in all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 17 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 18 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 19 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 20 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 21 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | # Tox (https://tox.readthedocs.io/) is a tool for running tests in multiple virtualenvs. This configuration file helps 2 | # to run the test suite against different combinations of libraries and Python versions. 3 | # To use it locally, "pip install tox" and then run "tox --skip-missing-interpreters" from this directory. 4 | 5 | [tox] 6 | isolated_build = true 7 | minversion = 3.24.3 8 | envlist = py{39,310,311,312,313} 9 | 10 | [gh-actions] 11 | # Mapping of Python versions (MAJOR.MINOR) to Tox factors. 12 | # When running Tox inside GitHub Actions, the `tox-gh-actions` plugin automatically: 13 | # 1. Identifies the Python version used to run Tox. 14 | # 2. Determines the corresponding Tox factor for that Python version, based on the `python` mapping below. 15 | # 3. Narrows down the Tox `envlist` to environments that match the factor. 16 | # For more details, please see the `tox-gh-actions` README [0] and architecture documentation [1]. 17 | # [0] https://github.com/ymyzk/tox-gh-actions/tree/v2.8.1 18 | # [1] https://github.com/ymyzk/tox-gh-actions/blob/v2.8.1/ARCHITECTURE.md 19 | python = 20 | 3.9: py39 21 | 3.10: py310 22 | 3.11: py311 23 | 3.12: py312 24 | 3.13: py313 25 | 26 | [testenv] 27 | allowlist_externals = 28 | pytest 29 | usedevelop = true 30 | extras = 31 | test 32 | commands = 33 | pytest -vv {posargs} 34 | -------------------------------------------------------------------------------- /tests/test_variables_class.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from business_rules.operators import StringType 4 | from business_rules.variables import BaseVariables, rule_variable 5 | 6 | 7 | class VariablesClassTests(TestCase): 8 | """Test methods on classes that inherit from BaseVariables""" 9 | 10 | def test_base_has_no_variables(self): 11 | self.assertEqual(len(BaseVariables.get_all_variables()), 0) 12 | 13 | def test_get_all_variables(self): 14 | """Returns a dictionary listing all the functions on the class that 15 | have been decorated as variables, with some of the data about them. 16 | """ 17 | 18 | class SomeVariables(BaseVariables): 19 | @rule_variable(StringType) 20 | def this_is_rule_1(self): 21 | return "blah" 22 | 23 | def non_rule(self): 24 | return "baz" 25 | 26 | vars = SomeVariables.get_all_variables() 27 | self.assertEqual(len(vars), 1) 28 | self.assertEqual(vars[0]["name"], "this_is_rule_1") 29 | self.assertEqual(vars[0]["label"], "This Is Rule 1") 30 | self.assertEqual(vars[0]["field_type"], "string") 31 | self.assertEqual(vars[0]["options"], []) 32 | 33 | # should work on an instance of the class too 34 | self.assertEqual(len(SomeVariables().get_all_variables()), 1) 35 | -------------------------------------------------------------------------------- /makefile: -------------------------------------------------------------------------------- 1 | .PHONY: all 2 | all: clean install deps tests coverage 3 | 4 | .PHONY: install 5 | install: 6 | sudo apt-get update 7 | sudo apt-get install make build-essential libssl-dev zlib1g-dev \ 8 | libbz2-dev libreadline-dev libsqlite3-dev wget curl llvm \ 9 | libncursesdev xz-utils tk-dev libxml2-dev libxmlsec1-dev libffi-dev liblzma-dev 10 | # python3.9 does not come with distutils on Ubuntu 22.04 so we need to symlink it 11 | # or the tox tests will fail for python 3.9 12 | sudo ln -sf /usr/lib/python3.10/distutils /usr/lib/python3.9/distutils 13 | 14 | .PHONY: clean 15 | clean: 16 | find . -name '*.pyc' -delete 17 | find . -name '__pycache__' -delete 18 | rm -rf dist 19 | 20 | .PHONY: deps-clean 21 | deps-clean: 22 | if command -v pyenv >/dev/null 2>&1; then pyenv local 3.12; fi 23 | # Remove the environment and ignore errors if it does not exist. 24 | poetry env remove 3.12 2>/dev/null || true 25 | poetry env use 3.12 26 | 27 | .PHONY: deps 28 | deps: deps-clean 29 | poetry install 30 | 31 | .PHONY: tests 32 | PYTEST_ARGS ?= 33 | tests: 34 | poetry run pytest $(pytest_args) ${PYTEST_ARGS} 35 | 36 | .PHONY: tox 37 | TOX_ARGS ?= 38 | tox: 39 | poetry run tox ${TOX_ARGS} 40 | 41 | .PHONY: coverage 42 | coverage: 43 | mkdir -p test-results 44 | poetry run py.test --junitxml=test-results/junit.xml --cov-report term-missing --cov=./business_rules $(pytest_args) 45 | poetry run coverage html # open htmlcov/index.html in a browser 46 | 47 | .PHONY: merge-upstream 48 | merge-upstream: 49 | # Merge the venmo/business-rules upstream master branch to our fork 50 | # Once this command completes there will likely be conflicts so you will need to fix them and commit the changes. 51 | git remote add upstream git@github.com:venmo/business-rules.git 52 | git fetch upstream 53 | git merge upstream/master 54 | -------------------------------------------------------------------------------- /tests/example/main.py: -------------------------------------------------------------------------------- 1 | #! python3 2 | import logging 3 | 4 | from business_rules import run_all 5 | from tests.example.actions import ExampleActions 6 | from tests.example.basket import Basket, Item 7 | from tests.example.variables import ExampleVariables 8 | 9 | logging.basicConfig(level=logging.DEBUG) 10 | 11 | rules = [ 12 | { 13 | "conditions": { 14 | "all": [ 15 | { 16 | "name": "items", 17 | "operator": "contains", 18 | "value": 1, 19 | }, 20 | { 21 | "name": "current_month", 22 | "operator": "equal_to", 23 | "value": "January", 24 | }, 25 | { 26 | "name": "item_count", 27 | "operator": "greater_than", 28 | "value": 1, 29 | }, 30 | { 31 | "name": "rule_variable", 32 | "operator": "is_true", 33 | "value": "True", 34 | }, 35 | { 36 | "name": "today", 37 | "operator": "after_than_or_equal_to", 38 | "value": "2017-01-16", 39 | }, 40 | ] 41 | }, 42 | "actions": [ 43 | { 44 | "name": "log", 45 | "params": { 46 | "message": "All criteria met!", 47 | }, 48 | } 49 | ], 50 | }, 51 | { 52 | "actions": [ 53 | { 54 | "name": "log", 55 | "params": { 56 | "message": "Rule with no conditions triggered!", 57 | }, 58 | } 59 | ] 60 | }, 61 | ] 62 | 63 | hot_drink = Item(code=1, name='Hot Drink', line_number=1, quantity=1) 64 | pastry = Item(code=2, name='Pastry', line_number=2, quantity=1) 65 | basket = Basket(id=0, items=[hot_drink, pastry]) 66 | run_all( 67 | rule_list=rules, 68 | defined_variables=ExampleVariables(basket), 69 | defined_actions=ExampleActions(basket), 70 | ) 71 | -------------------------------------------------------------------------------- /tests/test_actions_class.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from business_rules.actions import BaseActions, rule_action 4 | from business_rules.fields import FIELD_TEXT 5 | 6 | 7 | class ActionsClassTests(TestCase): 8 | """Test methods on classes that inherit from BaseActions.""" 9 | 10 | def test_base_has_no_actions(self): 11 | self.assertEqual(len(BaseActions.get_all_actions()), 0) 12 | 13 | def test_get_all_actions(self): 14 | """Returns a dictionary listing all the functions on the class that 15 | have been decorated as actions, with some of the data about them. 16 | """ 17 | 18 | class SomeActions(BaseActions): 19 | @rule_action(params={"foo": FIELD_TEXT}) 20 | def some_action(self, foo): 21 | return "blah" 22 | 23 | def non_action(self): 24 | return "baz" 25 | 26 | actions = SomeActions.get_all_actions() 27 | self.assertEqual(len(actions), 1) 28 | self.assertEqual(actions[0]["name"], "some_action") 29 | self.assertEqual(actions[0]["label"], "Some Action") 30 | self.assertEqual( 31 | actions[0]["params"], 32 | [ 33 | { 34 | "fieldType": FIELD_TEXT, 35 | "name": "foo", 36 | "label": "Foo", 37 | "defaultValue": None, 38 | }, 39 | ], 40 | ) 41 | 42 | # should work on an instance of the class too 43 | self.assertEqual(len(SomeActions().get_all_actions()), 1) 44 | 45 | def test_rule_action_doesnt_allow_unknown_field_types(self): 46 | err_string = ( 47 | "Unknown field type blah specified for action some_action" " param foo" 48 | ) 49 | with self.assertRaisesRegex(AssertionError, err_string): 50 | 51 | @rule_action(params={"foo": "blah"}) 52 | def some_action(self, foo): 53 | pass 54 | 55 | def test_rule_action_doesnt_allow_unknown_parameter_name(self): 56 | err_string = "Unknown parameter name foo specified for action some_action" 57 | with self.assertRaisesRegex(AssertionError, err_string): 58 | 59 | @rule_action(params={"foo": "blah"}) 60 | def some_action(self): 61 | pass 62 | 63 | def test_rule_action_with_no_params_or_label(self): 64 | """A rule action should not have to specify paramers or label.""" 65 | 66 | @rule_action() 67 | def some_action(self): 68 | pass 69 | 70 | self.assertTrue(some_action.is_rule_action) 71 | -------------------------------------------------------------------------------- /tests/operators/test_operators_class.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from mock import MagicMock 4 | 5 | from business_rules.operators import BaseType, type_operator 6 | 7 | 8 | class OperatorsClassTests(TestCase): 9 | """Test methods on classes that inherit from BaseType.""" 10 | 11 | def test_base_has_no_operators(self): 12 | self.assertEqual(len(BaseType.get_all_operators()), 0) 13 | 14 | def test_get_all_operators(self): 15 | """Returns a dictionary listing all the operators on the class 16 | that can be called on that type, with some data about them. 17 | """ 18 | 19 | class SomeType(BaseType): 20 | @type_operator(input_type="text") 21 | def some_operator(self): 22 | return True 23 | 24 | def not_an_operator(self): 25 | return "yo yo" 26 | 27 | operators = SomeType.get_all_operators() 28 | self.assertEqual(len(operators), 1) 29 | some_operator = operators[0] 30 | self.assertEqual(some_operator["name"], "some_operator") 31 | self.assertEqual(some_operator["label"], "Some Operator") 32 | self.assertEqual(some_operator["input_type"], "text") 33 | 34 | def test_operator_decorator_casts_argument(self): 35 | """Any operator that has the @type_operator decorator 36 | should call _assert_valid_value_and_cast on the parameter. 37 | """ 38 | 39 | class SomeType(BaseType): 40 | def __init__(self, value): 41 | self.value = value 42 | 43 | _assert_valid_value_and_cast = MagicMock() 44 | 45 | @type_operator("text") 46 | def some_operator(self, other_param): 47 | pass 48 | 49 | @type_operator("text", assert_type_for_arguments=False) 50 | def other_operator(self, other_param): 51 | pass 52 | 53 | # casts with positional args 54 | some_type = SomeType("val") 55 | some_type.some_operator("foo") # positional 56 | some_type._assert_valid_value_and_cast.assert_called_once_with("foo") 57 | 58 | # casts with keyword args 59 | some_type._assert_valid_value_and_cast.reset_mock() 60 | some_type.some_operator(other_param="foo2") # keyword 61 | some_type._assert_valid_value_and_cast.assert_called_once_with("foo2") 62 | 63 | # does not cast if that argument is set 64 | some_type._assert_valid_value_and_cast.reset_mock() 65 | some_type.other_operator("blah") 66 | some_type.other_operator(other_param="blah") 67 | self.assertEqual(some_type._assert_valid_value_and_cast.call_count, 0) 68 | -------------------------------------------------------------------------------- /business_rules/actions.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | from dataclasses import dataclass 3 | from typing import Optional 4 | 5 | from .utils import fn_name_to_pretty_label, get_valid_fields 6 | 7 | 8 | class BaseActions(object): 9 | """Classes that hold a collection of actions to use with the rules 10 | engine should inherit from this. 11 | """ 12 | 13 | @classmethod 14 | def get_all_actions(cls): 15 | methods = inspect.getmembers(cls) 16 | return [ 17 | {'name': m[0], 'label': m[1].label, 'params': m[1].params} 18 | for m in methods 19 | if getattr(m[1], 'is_rule_action', False) 20 | ] 21 | 22 | 23 | def _validate_action_parameters(func, params): 24 | """ 25 | Verifies that the parameters specified are actual parameters for the 26 | function `func`, and that the field types are FIELD_* types in fields. 27 | :param func: 28 | :param params: 29 | { 30 | 'label': 'action_label', 31 | 'name': 'action_parameter', 32 | 'fieldType': 'numeric', 33 | 'defaultValue': 123 34 | } 35 | :return: 36 | """ 37 | if params is not None: 38 | # Verify field name is valid 39 | valid_fields = get_valid_fields() 40 | 41 | for param in params: 42 | param_name, field_type = param['name'], param['fieldType'] 43 | if param_name not in func.__code__.co_varnames: 44 | raise AssertionError( 45 | "Unknown parameter name {0} specified for action {1}".format( 46 | param_name, func.__name__ 47 | ) 48 | ) 49 | 50 | if field_type not in valid_fields: 51 | raise AssertionError( 52 | "Unknown field type {0} specified for action {1} param {2}".format( 53 | field_type, func.__name__, param_name 54 | ) 55 | ) 56 | 57 | 58 | def rule_action(label=None, params=None): 59 | """ 60 | Decorator to make a function into a rule action. 61 | `params` parameter could be one of the following: 62 | 1. Dictionary with params names as keys and types as values 63 | Example: 64 | params={ 65 | 'param_name': fields.FIELD_NUMERIC, 66 | } 67 | 68 | 2. If a param has a default value, ActionParam can be used. Example: 69 | params={ 70 | 'action_parameter': ActionParam(field_type=fields.FIELD_NUMERIC, default_value=123) 71 | } 72 | 73 | :param label: Label for Action 74 | :param params: Parameters expected by the Action function 75 | :return: Decorator function wrapper 76 | """ 77 | 78 | def wrapper(func): 79 | params_ = params 80 | if isinstance(params, dict): 81 | params_ = [ 82 | dict( 83 | label=fn_name_to_pretty_label(key), 84 | name=key, 85 | fieldType=getattr(value, "field_type", value), 86 | defaultValue=getattr(value, "default_value", None), 87 | ) 88 | for key, value in params.items() 89 | ] 90 | 91 | _validate_action_parameters(func, params_) 92 | 93 | func.is_rule_action = True 94 | func.label = label or fn_name_to_pretty_label(func.__name__) 95 | func.params = params_ 96 | 97 | return func 98 | 99 | return wrapper 100 | 101 | 102 | @dataclass 103 | class ActionParam: 104 | field_type: type 105 | default_value: Optional[int] 106 | -------------------------------------------------------------------------------- /tests/operators/test_time_operator.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, time 2 | from unittest import TestCase 3 | 4 | from business_rules.operators import TimeType 5 | 6 | 7 | class TimeOperatorTests(TestCase): 8 | def setUp(self): 9 | super(TimeOperatorTests, self).setUp() 10 | self.TEST_HOUR = 13 11 | self.TEST_MINUTE = 55 12 | self.TEST_SECOND = 25 13 | self.TEST_TIME = "{hour}:{minute}:{second}".format( 14 | hour=self.TEST_HOUR, minute=self.TEST_MINUTE, second=self.TEST_SECOND 15 | ) 16 | self.TEST_TIME_OBJ = time(self.TEST_HOUR, self.TEST_MINUTE, self.TEST_SECOND) 17 | self.TEST_DATETIME_OBJ = datetime( 18 | 2017, 19 | 1, 20 | 1, 21 | hour=self.TEST_HOUR, 22 | minute=self.TEST_MINUTE, 23 | second=self.TEST_SECOND, 24 | ) 25 | 26 | def test_instantiate(self): 27 | err_string = "foo is not a valid time type" 28 | with self.assertRaisesRegex(AssertionError, err_string): 29 | TimeType("foo") 30 | 31 | def test_time_type_validates_and_cast_time(self): 32 | result = TimeType(self.TEST_TIME) 33 | self.assertTrue(isinstance(result.value, time)) 34 | 35 | result = TimeType(self.TEST_DATETIME_OBJ) 36 | self.assertTrue(isinstance(result.value, time)) 37 | 38 | def test_time_equal_to(self): 39 | self.assertTrue(TimeType(self.TEST_TIME).equal_to(self.TEST_TIME)) 40 | self.assertTrue(TimeType(self.TEST_TIME).equal_to(self.TEST_TIME_OBJ)) 41 | self.assertTrue(TimeType(self.TEST_TIME_OBJ).equal_to(self.TEST_TIME_OBJ)) 42 | self.assertTrue(TimeType(self.TEST_TIME_OBJ).equal_to(self.TEST_TIME)) 43 | 44 | def test_other_value_not_time(self): 45 | error_string = "2016-10 is not a valid time type" 46 | with self.assertRaisesRegex(AssertionError, error_string): 47 | TimeType(self.TEST_TIME).equal_to("2016-10") 48 | 49 | def test_time_after_than(self): 50 | self.assertTrue( 51 | TimeType(self.TEST_TIME).after_than( 52 | self._relative_time(self.TEST_TIME_OBJ, 0, 0, -1) 53 | ) 54 | ) 55 | self.assertFalse(TimeType(self.TEST_TIME).after_than(self.TEST_TIME)) 56 | self.assertFalse( 57 | TimeType(self.TEST_TIME).after_than( 58 | self._relative_time(self.TEST_TIME_OBJ, 0, 0, 1) 59 | ) 60 | ) 61 | 62 | def test_time_after_than_or_equal_to(self): 63 | self.assertTrue(TimeType(self.TEST_TIME).after_than_or_equal_to(self.TEST_TIME)) 64 | self.assertTrue( 65 | TimeType(self.TEST_TIME).after_than_or_equal_to( 66 | self._relative_time(self.TEST_TIME_OBJ, 0, 0, -1) 67 | ) 68 | ) 69 | self.assertFalse( 70 | TimeType(self.TEST_TIME).after_than_or_equal_to( 71 | self._relative_time(self.TEST_TIME_OBJ, 0, 0, 1) 72 | ) 73 | ) 74 | 75 | def test_time_before_than(self): 76 | self.assertFalse( 77 | TimeType(self.TEST_TIME).before_than( 78 | self._relative_time(self.TEST_TIME_OBJ, 0, 0, -1) 79 | ) 80 | ) 81 | self.assertFalse(TimeType(self.TEST_TIME).before_than(self.TEST_TIME)) 82 | self.assertTrue( 83 | TimeType(self.TEST_TIME).before_than( 84 | self._relative_time(self.TEST_TIME_OBJ, 0, 0, 1) 85 | ) 86 | ) 87 | 88 | def test_time_before_than_or_equal_to(self): 89 | self.assertTrue( 90 | TimeType(self.TEST_TIME).before_than_or_equal_to(self.TEST_TIME) 91 | ) 92 | self.assertFalse( 93 | TimeType(self.TEST_TIME_OBJ).before_than_or_equal_to( 94 | self._relative_time(self.TEST_TIME_OBJ, 0, 0, -1) 95 | ) 96 | ) 97 | self.assertTrue( 98 | TimeType(self.TEST_TIME).before_than_or_equal_to( 99 | self._relative_time(self.TEST_TIME_OBJ, 0, 0, 1) 100 | ) 101 | ) 102 | 103 | @staticmethod 104 | def _relative_time(base_time, hours, minutes, seconds): 105 | return time( 106 | base_time.hour + hours, 107 | base_time.minute + minutes, 108 | base_time.second + seconds, 109 | ) 110 | -------------------------------------------------------------------------------- /tests/test_integration.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | from unittest import TestCase 4 | 5 | from business_rules.actions import BaseActions, rule_action 6 | from business_rules.engine import check_condition, run_all 7 | from business_rules.fields import FIELD_NUMERIC, FIELD_SELECT, FIELD_TEXT 8 | from business_rules.variables import ( 9 | BaseVariables, 10 | boolean_rule_variable, 11 | numeric_rule_variable, 12 | string_rule_variable, 13 | ) 14 | 15 | 16 | class SomeVariables(BaseVariables): 17 | @string_rule_variable() 18 | def foo(self): 19 | return "foo" 20 | 21 | @numeric_rule_variable(label="Diez") 22 | def ten(self): 23 | return 10 24 | 25 | @boolean_rule_variable() 26 | def true_bool(self): 27 | return True 28 | 29 | @numeric_rule_variable( 30 | params=[{"field_type": FIELD_NUMERIC, "name": "x", "label": "X"}] 31 | ) 32 | def x_plus_one(self, x): 33 | return x + 1 34 | 35 | @boolean_rule_variable() 36 | def rule_received(self, **kwargs): 37 | rule = kwargs.get("rule") 38 | assert rule is not None 39 | return rule is not None 40 | 41 | @string_rule_variable(label="StringLabel", options=["one", "two", "three"]) 42 | def string_variable_with_options(self): 43 | return "foo" 44 | 45 | @string_rule_variable(public=False) 46 | def private_string_variable(self): 47 | return "foo" 48 | 49 | 50 | class SomeActions(BaseActions): 51 | @rule_action(params={"foo": FIELD_NUMERIC}) 52 | def some_action(self, foo): 53 | pass 54 | 55 | @rule_action(label="woohoo", params={"bar": FIELD_TEXT}) 56 | def some_other_action(self, bar): 57 | pass 58 | 59 | @rule_action( 60 | params=[ 61 | { 62 | "fieldType": FIELD_SELECT, 63 | "name": "baz", 64 | "label": "Baz", 65 | "options": [ 66 | {"label": "Chose Me", "name": "chose_me"}, 67 | {"label": "Or Me", "name": "or_me"}, 68 | ], 69 | } 70 | ] 71 | ) 72 | def some_select_action(self, baz): 73 | pass 74 | 75 | @rule_action() 76 | def action_with_no_params(self): 77 | pass 78 | 79 | 80 | class IntegrationTests(TestCase): 81 | """Integration test, using the library like a user would.""" 82 | 83 | def test_true_boolean_variable(self): 84 | condition = {"name": "true_bool", "operator": "is_true", "value": ""} 85 | 86 | rule = {"conditions": condition} 87 | 88 | condition_result = check_condition(condition, SomeVariables(), rule) 89 | self.assertTrue(condition_result.result) 90 | 91 | def test_false_boolean_variable(self): 92 | condition = {"name": "true_bool", "operator": "is_false", "value": ""} 93 | 94 | rule = {"conditions": condition} 95 | 96 | condition_result = check_condition(condition, SomeVariables(), rule) 97 | self.assertFalse(condition_result.result) 98 | 99 | def test_check_true_condition_happy_path(self): 100 | condition = {"name": "foo", "operator": "contains", "value": "o"} 101 | 102 | rule = {"conditions": condition} 103 | 104 | condition_result = check_condition(condition, SomeVariables(), rule) 105 | self.assertTrue(condition_result.result) 106 | 107 | def test_check_false_condition_happy_path(self): 108 | condition = {"name": "foo", "operator": "contains", "value": "m"} 109 | 110 | rule = {"conditions": condition} 111 | 112 | condition_result = check_condition(condition, SomeVariables(), rule) 113 | self.assertFalse(condition_result.result) 114 | 115 | def test_numeric_variable_with_params(self): 116 | condition = { 117 | "name": "x_plus_one", 118 | "operator": "equal_to", 119 | "value": 10, 120 | "params": {"x": 9}, 121 | } 122 | 123 | rule = {"conditions": condition} 124 | 125 | condition_result = check_condition(condition, SomeVariables(), rule) 126 | 127 | self.assertTrue(condition_result.result) 128 | 129 | def test_check_incorrect_method_name(self): 130 | condition = {"name": "food", "operator": "equal_to", "value": "m"} 131 | 132 | rule = {"conditions": condition} 133 | 134 | err_string = "Variable food is not defined in class SomeVariables" 135 | with self.assertRaisesRegex(AssertionError, err_string): 136 | check_condition(condition, SomeVariables(), rule) 137 | 138 | def test_check_incorrect_operator_name(self): 139 | condition = {"name": "foo", "operator": "equal_tooooze", "value": "foo"} 140 | 141 | rule = {"conditions": condition} 142 | 143 | with self.assertRaises(AssertionError): 144 | check_condition(condition, SomeVariables(), rule) 145 | 146 | def test_check_missing_params(self): 147 | condition = { 148 | "name": "x_plus_one", 149 | "operator": "equal_to", 150 | "value": 10, 151 | "params": {}, 152 | } 153 | 154 | rule = {"conditions": condition} 155 | 156 | err_string = "Missing parameters x for variable x_plus_one" 157 | 158 | with self.assertRaisesRegex(AssertionError, err_string): 159 | check_condition(condition, SomeVariables(), rule) 160 | 161 | def test_check_invalid_params(self): 162 | condition = { 163 | "name": "x_plus_one", 164 | "operator": "equal_to", 165 | "value": 10, 166 | "params": {"x": 9, "y": 9}, 167 | } 168 | 169 | rule = {"conditions": condition} 170 | 171 | err_string = "Invalid parameters y for variable x_plus_one" 172 | 173 | with self.assertRaisesRegex(AssertionError, err_string): 174 | check_condition(condition, SomeVariables(), rule) 175 | 176 | def test_variable_received_rules(self): 177 | condition = { 178 | "name": "rule_received", 179 | "operator": "is_true", 180 | "value": "true", 181 | } 182 | 183 | rule = {"conditions": condition} 184 | 185 | condition_result = check_condition(condition, SomeVariables(), rule) 186 | self.assertTrue(condition_result) 187 | 188 | def test_string_variable_with_options_with_wrong_value(self): 189 | condition = { 190 | "name": "string_variable_with_options", 191 | "operator": "equal_to", 192 | "value": "foo", 193 | } 194 | 195 | rule = {"conditions": condition} 196 | 197 | condition_result = check_condition(condition, SomeVariables(), rule) 198 | self.assertTrue(condition_result) 199 | 200 | def test_run_with_no_conditions(self): 201 | actions = [{"name": "action_with_no_params"}] 202 | 203 | rule = {"actions": actions} 204 | 205 | result = run_all( 206 | rule_list=[rule], 207 | defined_variables=SomeVariables(), 208 | defined_actions=SomeActions(), 209 | ) 210 | 211 | self.assertTrue(result) 212 | -------------------------------------------------------------------------------- /tests/test_variables.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from business_rules.operators import ( 4 | BooleanType, 5 | DateTimeType, 6 | NumericType, 7 | SelectMultipleType, 8 | SelectType, 9 | StringType, 10 | TimeType, 11 | ) 12 | from business_rules.utils import fn_name_to_pretty_label 13 | from business_rules.variables import ( 14 | boolean_rule_variable, 15 | datetime_rule_variable, 16 | numeric_rule_variable, 17 | rule_variable, 18 | select_multiple_rule_variable, 19 | select_rule_variable, 20 | string_rule_variable, 21 | time_rule_variable, 22 | ) 23 | 24 | 25 | class RuleVariableTests(TestCase): 26 | """Tests for the base rule_variable decorator.""" 27 | 28 | def test_pretty_label(self): 29 | self.assertEqual( 30 | fn_name_to_pretty_label("some_name_Of_a_thing"), "Some Name Of A Thing" 31 | ) 32 | self.assertEqual(fn_name_to_pretty_label("hi"), "Hi") 33 | 34 | def test_rule_variable_requires_instance_of_base_type(self): 35 | err_string = ( 36 | "a_string is not instance of BaseType in rule_variable " "field_type" 37 | ) 38 | with self.assertRaisesRegex(AssertionError, err_string): 39 | 40 | @rule_variable("a_string") 41 | def some_test_function(self): 42 | pass 43 | 44 | def test_rule_variable_decorator_internals(self): 45 | """ 46 | Make sure that the expected attributes are attached to a function 47 | by the variable decorators. 48 | :return: 49 | """ 50 | 51 | def some_test_function(self): 52 | pass 53 | 54 | wrapper = rule_variable(StringType, "Foo Name", options=["op1", "op2"]) 55 | func = wrapper(some_test_function) 56 | self.assertTrue(func.is_rule_variable) 57 | self.assertEqual(func.label, "Foo Name") 58 | self.assertEqual(func.field_type, StringType) 59 | self.assertEqual(func.options, ["op1", "op2"]) 60 | 61 | def test_rule_variable_works_as_decorator(self): 62 | @rule_variable(StringType, "Blah") 63 | def some_test_function(self): 64 | pass 65 | 66 | self.assertTrue(some_test_function.is_rule_variable) 67 | 68 | def test_rule_variable_decorator_auto_fills_label(self): 69 | @rule_variable(StringType) 70 | def some_test_function(self): 71 | pass 72 | 73 | self.assertTrue(some_test_function.label, "Some Test Function") 74 | 75 | # 76 | # rule_variable wrappers for each variable type 77 | # 78 | 79 | def test_rule_variable_function_with_parameter_not_defined(self): 80 | with self.assertRaises(AssertionError): 81 | 82 | @rule_variable(NumericType, params={"parameter_not_defined": "type"}) 83 | def variable_function(): 84 | pass 85 | 86 | def test_rule_variable_function_with_parameter_invalid_type(self): 87 | with self.assertRaises(AssertionError): 88 | 89 | @rule_variable(NumericType, params={"parameter": "invalid_type"}) 90 | def variable_function(parameter): 91 | pass 92 | 93 | def test_numeric_rule_variable(self): 94 | @numeric_rule_variable("My Label") 95 | def numeric_var(): 96 | pass 97 | 98 | self.assertTrue(numeric_var.is_rule_variable) 99 | self.assertEqual(numeric_var.field_type, NumericType) 100 | self.assertEqual(numeric_var.label, "My Label") 101 | 102 | def test_numeric_rule_variable_no_parens(self): 103 | @numeric_rule_variable 104 | def numeric_var(): 105 | pass 106 | 107 | self.assertTrue(numeric_var.is_rule_variable) 108 | self.assertEqual(numeric_var.field_type, NumericType) 109 | 110 | def test_string_rule_variable(self): 111 | @string_rule_variable(label="My Label") 112 | def string_var(): 113 | pass 114 | 115 | self.assertTrue(string_var.is_rule_variable) 116 | self.assertEqual(string_var.field_type, StringType) 117 | self.assertEqual(string_var.label, "My Label") 118 | 119 | def test_string_rule_variable_no_parens(self): 120 | @string_rule_variable 121 | def string_var(): 122 | pass 123 | 124 | self.assertTrue(string_var.is_rule_variable) 125 | self.assertEqual(string_var.field_type, StringType) 126 | 127 | def test_boolean_rule_variable(self): 128 | @boolean_rule_variable(label="My Label") 129 | def boolean_var(): 130 | pass 131 | 132 | self.assertTrue(boolean_var.is_rule_variable) 133 | self.assertEqual(boolean_var.field_type, BooleanType) 134 | self.assertEqual(boolean_var.label, "My Label") 135 | 136 | def test_boolean_rule_variable_no_parens(self): 137 | @boolean_rule_variable 138 | def boolean_var(): 139 | pass 140 | 141 | self.assertTrue(boolean_var.is_rule_variable) 142 | self.assertEqual(boolean_var.field_type, BooleanType) 143 | 144 | def test_select_rule_variable(self): 145 | options = {"foo": "bar"} 146 | 147 | @select_rule_variable(options=options) 148 | def select_var(): 149 | pass 150 | 151 | self.assertTrue(select_var.is_rule_variable) 152 | self.assertEqual(select_var.field_type, SelectType) 153 | self.assertEqual(select_var.options, options) 154 | 155 | def test_select_multiple_rule_variable(self): 156 | options = {"foo": "bar"} 157 | 158 | @select_multiple_rule_variable(options=options) 159 | def select_multiple_var(): 160 | pass 161 | 162 | self.assertTrue(select_multiple_var.is_rule_variable) 163 | self.assertEqual(select_multiple_var.field_type, SelectMultipleType) 164 | self.assertEqual(select_multiple_var.options, options) 165 | 166 | def test_datetime_variable(self): 167 | @datetime_rule_variable() 168 | def datetime_variable(): 169 | pass 170 | 171 | self.assertTrue(datetime_variable.is_rule_variable) 172 | self.assertEqual(datetime_variable.field_type, DateTimeType) 173 | self.assertEqual(datetime_variable.label, "Datetime Variable") 174 | 175 | def test_datetime_variable_with_label(self): 176 | @datetime_rule_variable(label="Custom Label") 177 | def datetime_variable(): 178 | pass 179 | 180 | self.assertTrue(datetime_variable.is_rule_variable) 181 | self.assertEqual(datetime_variable.field_type, DateTimeType) 182 | self.assertEqual(datetime_variable.label, "Custom Label") 183 | 184 | def test_time_variable(self): 185 | @time_rule_variable() 186 | def time_variable(): 187 | pass 188 | 189 | self.assertTrue(time_variable.is_rule_variable) 190 | self.assertEqual(time_variable.field_type, TimeType) 191 | self.assertEqual(time_variable.label, "Time Variable") 192 | 193 | def test_time_variable_with_label(self): 194 | @time_rule_variable(label="Custom Label") 195 | def datetime_variable(): 196 | pass 197 | 198 | self.assertTrue(datetime_variable.is_rule_variable) 199 | self.assertEqual(datetime_variable.field_type, TimeType) 200 | self.assertEqual(datetime_variable.label, "Custom Label") 201 | -------------------------------------------------------------------------------- /business_rules/variables.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | import inspect 4 | from typing import Callable, List, Type # noqa: F401 5 | 6 | from . import utils 7 | from .operators import ( 8 | BaseType, 9 | BooleanType, 10 | DateTimeType, 11 | NumericType, 12 | SelectMultipleType, 13 | SelectType, 14 | StringType, 15 | TimeType, 16 | ) 17 | from .utils import fn_name_to_pretty_label 18 | 19 | 20 | class BaseVariables(object): 21 | """ 22 | Classes that hold a collection of variables to use with the rules 23 | engine should inherit from this. 24 | """ 25 | 26 | @classmethod 27 | def get_all_variables(cls): 28 | methods = inspect.getmembers(cls) 29 | return [ 30 | { 31 | 'name': m[0], 32 | 'label': m[1].label, 33 | 'field_type': m[1].field_type.name, 34 | 'options': m[1].options, 35 | 'params': m[1].params, 36 | 'public': m[1].public, 37 | } 38 | for m in methods 39 | if getattr(m[1], 'is_rule_variable', False) 40 | ] 41 | 42 | 43 | def rule_variable(field_type, label=None, options=None, params=None, public=True): 44 | # type: (Type[BaseType], str, List[str], dict, bool) -> Callable 45 | """ 46 | Decorator to make a function into a rule variable 47 | :param field_type: 48 | :param label: 49 | :param options: 50 | :param params: 51 | :param inject_rule: 52 | :param public: Flag to identify if a variable is public or not 53 | :return: 54 | """ 55 | options = options or [] 56 | params = params or [] 57 | 58 | def wrapper(func): 59 | if not (type(field_type) == type and issubclass(field_type, BaseType)): 60 | raise AssertionError( 61 | "{0} is not instance of BaseType in" 62 | " rule_variable field_type".format(field_type) 63 | ) 64 | 65 | params_wrapper = utils.params_dict_to_list(params) 66 | 67 | _validate_variable_parameters(func, params_wrapper) 68 | 69 | func.params = params 70 | func.field_type = field_type 71 | func.is_rule_variable = True 72 | func.label = label or fn_name_to_pretty_label(func.__name__) 73 | func.options = options 74 | func.public = public 75 | 76 | return func 77 | 78 | return wrapper 79 | 80 | 81 | def _rule_variable_wrapper(field_type, label, params=None, options=None, public=True): 82 | if callable(label): 83 | # Decorator is being called with no args, label is actually the decorated func 84 | return rule_variable(field_type, params=params, public=public)(label) 85 | 86 | return rule_variable( 87 | field_type, label=label, params=params, options=options, public=public 88 | ) 89 | 90 | 91 | def numeric_rule_variable(label=None, params=None, public=True): 92 | """ 93 | Decorator to make a function into a numeric rule variable. 94 | 95 | NOTE: add **kwargs argument to receive Rule as parameters 96 | 97 | :param label: Label for Variable 98 | :param params: Parameters expected by the Variable function 99 | :param public: Flag to identify if a variable is public or not 100 | :return: Decorator function wrapper 101 | """ 102 | return _rule_variable_wrapper(NumericType, label, params=params, public=public) 103 | 104 | 105 | def string_rule_variable(label=None, params=None, options=None, public=True): 106 | """ 107 | Decorator to make a function into a string rule variable. 108 | 109 | NOTE: add **kwargs argument to receive Rule as parameters 110 | 111 | :param label: Label for Variable 112 | :param params: Parameters expected by the Variable function 113 | :param options: Options parameter to specify expected options for the variable. 114 | The value used in the Condition IS NOT checked against this list. 115 | :param public: Flag to identify if a variable is public or not 116 | :return: Decorator function wrapper 117 | """ 118 | return _rule_variable_wrapper( 119 | StringType, label, params=params, options=options, public=public 120 | ) 121 | 122 | 123 | def boolean_rule_variable(label=None, params=None, public=True): 124 | """ 125 | Decorator to make a function into a boolean rule variable. 126 | 127 | NOTE: add **kwargs argument to receive Rule as parameters 128 | 129 | :param label: Label for Variable 130 | :param params: Parameters expected by the Variable function 131 | :param public: Flag to identify if a variable is public or not 132 | :return: Decorator function wrapper 133 | """ 134 | return _rule_variable_wrapper(BooleanType, label, params=params, public=public) 135 | 136 | 137 | def select_rule_variable(label=None, options=None, params=None, public=True): 138 | """ 139 | Decorator to make a function into a select rule variable. 140 | 141 | NOTE: add **kwargs argument to receive Rule as parameters 142 | 143 | :param label: Label for Variable 144 | :param options: 145 | :param params: Parameters expected by the Variable function 146 | :param public: Flag to identify if a variable is public or not 147 | :return: Decorator function wrapper 148 | """ 149 | return rule_variable( 150 | SelectType, label=label, options=options, params=params, public=public 151 | ) 152 | 153 | 154 | def select_multiple_rule_variable(label=None, options=None, params=None, public=True): 155 | """ 156 | Decorator to make a function into a select multiple rule variable. 157 | 158 | NOTE: add **kwargs argument to receive Rule as parameters 159 | 160 | :param label: Label for Variable 161 | :param options: 162 | :param params: Parameters expected by the Variable function 163 | :param public: Flag to identify if a variable is public or not 164 | :return: Decorator function wrapper 165 | """ 166 | return rule_variable( 167 | SelectMultipleType, label=label, options=options, params=params, public=public 168 | ) 169 | 170 | 171 | def datetime_rule_variable(label=None, params=None, public=True): 172 | """ 173 | Decorator to make a function into a datetime rule variable. 174 | 175 | NOTE: add **kwargs argument to receive Rule as parameters 176 | 177 | :param label: 178 | :param params 179 | :param public: Flag to identify if a variable is public or not: 180 | :return: Decorator function wrapper for DateTime values 181 | """ 182 | 183 | return _rule_variable_wrapper( 184 | field_type=DateTimeType, label=label, params=params, public=public 185 | ) 186 | 187 | 188 | def time_rule_variable(label=None, params=None, public=True): 189 | """ 190 | Decorator to make a function into a Time rule variable. 191 | 192 | NOTE: add **kwargs argument to receive Rule as parameters 193 | 194 | :param label: 195 | :param params: 196 | :return: Decorator function wrapper for Time values 197 | """ 198 | 199 | return _rule_variable_wrapper( 200 | field_type=TimeType, label=label, params=params, public=public 201 | ) 202 | 203 | 204 | def _validate_variable_parameters(func, params): 205 | """ 206 | Verifies that the parameters specified are actual parameters for the 207 | function `func`, and that the field types are FIELD_* types in fields. 208 | :param func: 209 | :param params: 210 | :return: 211 | """ 212 | valid_fields = utils.get_valid_fields() 213 | 214 | if params is not None: 215 | for param in params: 216 | param_name, field_type = param['name'], param['field_type'] 217 | 218 | if param_name not in func.__code__.co_varnames: 219 | raise AssertionError( 220 | "Unknown parameter name {0} specified for variable {1}".format( 221 | param_name, func.__name__ 222 | ) 223 | ) 224 | 225 | if field_type not in valid_fields: 226 | raise AssertionError( 227 | "Unknown field type {0} specified for variable {1} param {2}".format( 228 | field_type, func.__name__, param_name 229 | ) 230 | ) 231 | -------------------------------------------------------------------------------- /tests/test_operators.py: -------------------------------------------------------------------------------- 1 | from decimal import Decimal 2 | from unittest import TestCase 3 | 4 | from business_rules.operators import ( 5 | BooleanType, 6 | NumericType, 7 | SelectMultipleType, 8 | SelectType, 9 | StringType, 10 | ) 11 | 12 | 13 | class StringOperatorTests(TestCase): 14 | def test_operator_decorator(self): 15 | self.assertTrue(StringType("foo").equal_to.is_operator) 16 | 17 | def test_string_equal_to(self): 18 | self.assertTrue(StringType("foo").equal_to("foo")) 19 | self.assertFalse(StringType("foo").equal_to("Foo")) 20 | 21 | def test_string_equal_to_case_insensitive(self): 22 | self.assertTrue(StringType("foo").equal_to_case_insensitive("FOo")) 23 | self.assertTrue(StringType("foo").equal_to_case_insensitive("foo")) 24 | self.assertFalse(StringType("foo").equal_to_case_insensitive("blah")) 25 | 26 | def test_string_starts_with(self): 27 | self.assertTrue(StringType("hello").starts_with("he")) 28 | self.assertFalse(StringType("hello").starts_with("hey")) 29 | self.assertFalse(StringType("hello").starts_with("He")) 30 | 31 | def test_string_ends_with(self): 32 | self.assertTrue(StringType("hello").ends_with("lo")) 33 | self.assertFalse(StringType("hello").ends_with("boom")) 34 | self.assertFalse(StringType("hello").ends_with("Lo")) 35 | 36 | def test_string_contains(self): 37 | self.assertTrue(StringType("hello").contains("ell")) 38 | self.assertTrue(StringType("hello").contains("he")) 39 | self.assertTrue(StringType("hello").contains("lo")) 40 | self.assertFalse(StringType("hello").contains("asdf")) 41 | self.assertFalse(StringType("hello").contains("ElL")) 42 | 43 | def test_string_matches_regex(self): 44 | self.assertTrue(StringType("hello").matches_regex(r"^h")) 45 | self.assertFalse(StringType("hello").matches_regex(r"^sh")) 46 | 47 | def test_non_empty(self): 48 | self.assertTrue(StringType("hello").non_empty()) 49 | self.assertFalse(StringType("").non_empty()) 50 | self.assertFalse(StringType(None).non_empty()) 51 | 52 | 53 | class NumericOperatorTests(TestCase): 54 | def test_instantiate(self): 55 | err_string = "foo is not a valid numeric type" 56 | with self.assertRaisesRegex(AssertionError, err_string): 57 | NumericType("foo") 58 | 59 | def test_numeric_type_validates_and_casts_decimal(self): 60 | ten_dec = Decimal(10) 61 | ten_int = 10 62 | ten_float = 10.0 63 | ten_long = int(10) # long and int are same in python3 64 | ten_var_dec = NumericType(ten_dec) # this should not throw an exception 65 | ten_var_int = NumericType(ten_int) 66 | ten_var_float = NumericType(ten_float) 67 | ten_var_long = NumericType(ten_long) 68 | self.assertTrue(isinstance(ten_var_dec.value, Decimal)) 69 | self.assertTrue(isinstance(ten_var_int.value, Decimal)) 70 | self.assertTrue(isinstance(ten_var_float.value, Decimal)) 71 | self.assertTrue(isinstance(ten_var_long.value, Decimal)) 72 | 73 | def test_numeric_equal_to(self): 74 | self.assertTrue(NumericType(10).equal_to(10)) 75 | self.assertTrue(NumericType(10).equal_to(10.0)) 76 | self.assertTrue(NumericType(10).equal_to(10.000001)) 77 | self.assertTrue(NumericType(10.000001).equal_to(10)) 78 | self.assertTrue(NumericType(Decimal("10.0")).equal_to(10)) 79 | self.assertTrue(NumericType(10).equal_to(Decimal("10.0"))) 80 | self.assertFalse(NumericType(10).equal_to(10.00001)) 81 | self.assertFalse(NumericType(10).equal_to(11)) 82 | 83 | def test_other_value_not_numeric(self): 84 | error_string = "10 is not a valid numeric type" 85 | with self.assertRaisesRegex(AssertionError, error_string): 86 | NumericType(10).equal_to("10") 87 | 88 | def test_numeric_greater_than(self): 89 | self.assertTrue(NumericType(10).greater_than(1)) 90 | self.assertFalse(NumericType(10).greater_than(11)) 91 | self.assertTrue(NumericType(10.1).greater_than(10)) 92 | self.assertFalse(NumericType(10.000001).greater_than(10)) 93 | self.assertTrue(NumericType(10.000002).greater_than(10)) 94 | 95 | def test_numeric_greater_than_or_equal_to(self): 96 | self.assertTrue(NumericType(10).greater_than_or_equal_to(1)) 97 | self.assertFalse(NumericType(10).greater_than_or_equal_to(11)) 98 | self.assertTrue(NumericType(10.1).greater_than_or_equal_to(10)) 99 | self.assertTrue(NumericType(10.000001).greater_than_or_equal_to(10)) 100 | self.assertTrue(NumericType(10.000002).greater_than_or_equal_to(10)) 101 | self.assertTrue(NumericType(10).greater_than_or_equal_to(10)) 102 | 103 | def test_numeric_less_than(self): 104 | self.assertTrue(NumericType(1).less_than(10)) 105 | self.assertFalse(NumericType(11).less_than(10)) 106 | self.assertTrue(NumericType(10).less_than(10.1)) 107 | self.assertFalse(NumericType(10).less_than(10.000001)) 108 | self.assertTrue(NumericType(10).less_than(10.000002)) 109 | 110 | def test_numeric_less_than_or_equal_to(self): 111 | self.assertTrue(NumericType(1).less_than_or_equal_to(10)) 112 | self.assertFalse(NumericType(11).less_than_or_equal_to(10)) 113 | self.assertTrue(NumericType(10).less_than_or_equal_to(10.1)) 114 | self.assertTrue(NumericType(10).less_than_or_equal_to(10.000001)) 115 | self.assertTrue(NumericType(10).less_than_or_equal_to(10.000002)) 116 | self.assertTrue(NumericType(10).less_than_or_equal_to(10)) 117 | 118 | def test_numeric_divisible(self): 119 | self.assertTrue(NumericType(1).divisible(1)) 120 | self.assertTrue(NumericType(10).divisible(1)) 121 | self.assertTrue(NumericType(10).divisible(5)) 122 | self.assertTrue(NumericType(10).divisible(10)) 123 | self.assertTrue(NumericType(9).divisible(3)) 124 | self.assertFalse(NumericType(10).divisible(3)) 125 | self.assertFalse(NumericType(11).divisible(3)) 126 | self.assertFalse(NumericType(11).divisible(12)) 127 | 128 | 129 | class BooleanOperatorTests(TestCase): 130 | def test_instantiate(self): 131 | err_string = "foo is not a valid boolean type" 132 | with self.assertRaisesRegex(AssertionError, err_string): 133 | BooleanType("foo") 134 | err_string = "None is not a valid boolean type" 135 | with self.assertRaisesRegex(AssertionError, err_string): 136 | BooleanType(None) 137 | 138 | def test_boolean_is_true_and_is_false(self): 139 | self.assertTrue(BooleanType(True).is_true()) 140 | self.assertFalse(BooleanType(True).is_false()) 141 | self.assertFalse(BooleanType(False).is_true()) 142 | self.assertTrue(BooleanType(False).is_false()) 143 | 144 | 145 | class SelectOperatorTests(TestCase): 146 | def test_contains(self): 147 | self.assertTrue(SelectType([1, 2]).contains(2)) 148 | self.assertFalse(SelectType([1, 2]).contains(3)) 149 | self.assertTrue(SelectType([1, 2, "a"]).contains("A")) 150 | 151 | def test_does_not_contain(self): 152 | self.assertTrue(SelectType([1, 2]).does_not_contain(3)) 153 | self.assertFalse(SelectType([1, 2]).does_not_contain(2)) 154 | self.assertFalse(SelectType([1, 2, "a"]).does_not_contain("A")) 155 | 156 | 157 | class SelectMultipleOperatorTests(TestCase): 158 | def test_contains_all(self): 159 | self.assertTrue(SelectMultipleType([1, 2]).contains_all([2, 1])) 160 | self.assertFalse(SelectMultipleType([1, 2]).contains_all([2, 3])) 161 | self.assertTrue(SelectMultipleType([1, 2, "a"]).contains_all([2, 1, "A"])) 162 | 163 | def test_is_contained_by(self): 164 | self.assertTrue(SelectMultipleType([1, 2]).is_contained_by([2, 1, 3])) 165 | self.assertFalse(SelectMultipleType([1, 2]).is_contained_by([2, 3, 4])) 166 | self.assertTrue(SelectMultipleType([1, 2, "a"]).is_contained_by([2, 1, "A"])) 167 | 168 | def test_shares_at_least_one_element_with(self): 169 | self.assertTrue( 170 | SelectMultipleType([1, 2]).shares_at_least_one_element_with([2, 3]) 171 | ) 172 | self.assertFalse( 173 | SelectMultipleType([1, 2]).shares_at_least_one_element_with([4, 3]) 174 | ) 175 | self.assertTrue( 176 | SelectMultipleType([1, 2, "a"]).shares_at_least_one_element_with([4, "A"]) 177 | ) 178 | 179 | def test_shares_exactly_one_element_with(self): 180 | self.assertTrue( 181 | SelectMultipleType([1, 2]).shares_exactly_one_element_with([2, 3]) 182 | ) 183 | self.assertFalse( 184 | SelectMultipleType([1, 2]).shares_exactly_one_element_with([4, 3]) 185 | ) 186 | self.assertTrue( 187 | SelectMultipleType([1, 2, "a"]).shares_exactly_one_element_with([4, "A"]) 188 | ) 189 | self.assertFalse( 190 | SelectMultipleType([1, 2, 3]).shares_exactly_one_element_with([2, 3, "a"]) 191 | ) 192 | 193 | def test_shares_no_elements_with(self): 194 | self.assertTrue(SelectMultipleType([1, 2]).shares_no_elements_with([4, 3])) 195 | self.assertFalse(SelectMultipleType([1, 2]).shares_no_elements_with([2, 3])) 196 | self.assertFalse( 197 | SelectMultipleType([1, 2, "a"]).shares_no_elements_with([4, "A"]) 198 | ) 199 | -------------------------------------------------------------------------------- /business_rules/utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | import inspect 4 | from decimal import Context, Decimal, Inexact 5 | 6 | from .util import method_type 7 | 8 | 9 | def fn_name_to_pretty_label(name): 10 | return ' '.join([w.title() for w in name.split('_')]) 11 | 12 | 13 | def export_rule_data(variables, actions): 14 | """ 15 | Export_rule_data is used to export all information about the 16 | variables, actions, and operators to the client. This will return a 17 | dictionary with three keys: 18 | - variables: a list of all available variables along with their label, type, 19 | options and params 20 | - actions: a list of all actions along with their label and params 21 | - variable_type_operators: a dictionary of all field_types -> list of available 22 | operators 23 | :param variables: 24 | :param actions: 25 | :return: 26 | """ 27 | from . import operators 28 | 29 | actions_data = actions.get_all_actions() 30 | variables_data = variables.get_all_variables() 31 | 32 | variable_type_operators = {} 33 | for variable_class in inspect.getmembers( 34 | operators, lambda x: getattr(x, 'export_in_rule_data', False) 35 | ): 36 | variable_type = variable_class[1] # getmembers returns (name, value) 37 | variable_type_operators[variable_type.name] = variable_type.get_all_operators() 38 | 39 | return { 40 | "variables": variables_data, 41 | "actions": actions_data, 42 | "variable_type_operators": variable_type_operators, 43 | } 44 | 45 | 46 | def float_to_decimal(f): 47 | """ 48 | Convert a floating point number to a Decimal with 49 | no loss of information. Intended for Python 2.6 where 50 | casting float to Decimal does not work. 51 | """ 52 | n, d = f.as_integer_ratio() 53 | numerator, denominator = Decimal(n), Decimal(d) 54 | ctx = Context(prec=60) 55 | result = ctx.divide(numerator, denominator) 56 | while ctx.flags[Inexact]: 57 | ctx.flags[Inexact] = False 58 | ctx.prec *= 2 59 | result = ctx.divide(numerator, denominator) 60 | return result 61 | 62 | 63 | def get_valid_fields(): 64 | from . import fields 65 | 66 | valid_fields = [getattr(fields, f) for f in dir(fields) if f.startswith("FIELD_")] 67 | return valid_fields 68 | 69 | 70 | def params_dict_to_list(params): 71 | """ 72 | Transform parameters in dict format to list of dictionaries with a standard format. 73 | If 'params' is not a dictionary, then the result will be 'params' 74 | :param params: Dictionary of parameters with the following format: 75 | { 76 | 'param_name': param_type 77 | } 78 | :return: 79 | [ 80 | { 81 | 'label': 'param_name' 82 | 'name': 'param_name' 83 | 'field_type': param_type 84 | } 85 | ] 86 | """ 87 | if params is None: 88 | return [] 89 | 90 | if not isinstance(params, dict): 91 | return params 92 | 93 | return [ 94 | { 95 | 'label': fn_name_to_pretty_label(name), 96 | 'name': name, 97 | 'field_type': param_field_type, 98 | } 99 | for name, param_field_type in params.items() 100 | ] 101 | 102 | 103 | def check_params_valid_for_method(method, given_params, method_type_name): 104 | """ 105 | Verifies that the given parameters (defined in the Rule) match the names of those 106 | defined in the variable or action decorator. Raise an error if one of the sets 107 | contains a parameter that the other does not. 108 | 109 | :param method: 110 | :param given_params: Parameters defined within the Rule (Action or Condition) 111 | :param method_type_name: A method type defined in util.method_type module 112 | :return: Set of default values for params which are missing but have a default 113 | value. Raise exception if parameters 114 | don't 115 | match (defined in method and 116 | Rule) 117 | """ 118 | method_params = params_dict_to_list(method.params) 119 | defined_params = [param.get('name') for param in method_params] 120 | missing_params = set(defined_params).difference(given_params) 121 | 122 | # check for default value in action parameters, if it is present, exclude param 123 | # from missing params 124 | params_with_default_value = set() 125 | if method_type_name == method_type.METHOD_TYPE_ACTION and missing_params: 126 | params_with_default_value = check_for_default_value_for_missing_params( 127 | missing_params, method_params 128 | ) 129 | missing_params -= params_with_default_value 130 | 131 | if missing_params: 132 | raise AssertionError( 133 | "Missing parameters {0} for {1} {2}".format( 134 | ', '.join(missing_params), method_type_name, method.__name__ 135 | ) 136 | ) 137 | 138 | invalid_params = set(given_params).difference(defined_params) 139 | 140 | if invalid_params: 141 | raise AssertionError( 142 | "Invalid parameters {0} for {1} {2}".format( 143 | ', '.join(invalid_params), method_type_name, method.__name__ 144 | ) 145 | ) 146 | 147 | return params_with_default_value 148 | 149 | 150 | def check_for_default_value_for_missing_params(missing_params, method_params): 151 | """ 152 | :param missing_params: Params missing from Rule 153 | :param method_params: Params defined on method, which could have default value for 154 | missing param 155 | [{ 156 | 'label': 'action_label', 157 | 'name': 'action_parameter', 158 | 'fieldType': 'numeric', 159 | 'defaultValue': 123 160 | }, 161 | ... 162 | ] 163 | :return Params that are missing from rule but have 164 | default params: {'action_parameter'} 165 | """ 166 | missing_params_with_default_value = set() 167 | if method_params: 168 | for param in method_params: 169 | if ( 170 | param['name'] in missing_params 171 | and param.get('defaultValue', None) is not None 172 | ): 173 | missing_params_with_default_value.add(param['name']) 174 | 175 | return missing_params_with_default_value 176 | 177 | 178 | def validate_root_keys(rule): 179 | """ 180 | Check the root object contains both 'actions' & 'conditions' 181 | """ 182 | root_keys = list(rule.keys()) 183 | if 'actions' not in root_keys: 184 | raise AssertionError('Missing "{}" key'.format('actions')) 185 | 186 | 187 | def validate_condition_operator(condition, rule_schema): 188 | """ 189 | Check provided condition contains a valid operator 190 | """ 191 | if "operator" not in condition: 192 | raise AssertionError( 193 | 'Missing "operator" key for condition {}'.format(condition.get('name')) 194 | ) 195 | for item in rule_schema.get('variables'): 196 | if item.get('name') == condition.get('name'): 197 | condition_field_type = item.get('field_type') 198 | variable_operators = rule_schema.get('variable_type_operators', {}).get( 199 | condition_field_type, [] 200 | ) 201 | for operators in variable_operators: 202 | if operators['name'] == condition['operator']: 203 | return True 204 | raise AssertionError('Unknown operator "{}"'.format(condition['operator'])) 205 | raise AssertionError('Name "{}" not supported'.format(condition.get('name'))) 206 | 207 | 208 | def validate_condition_name(condition, variables): 209 | """ 210 | Check provided condition contains a 'name' key and the value is valid 211 | """ 212 | condition_name = condition.get('name') 213 | if not condition_name: 214 | raise AssertionError('Missing condition "name" key in {}'.format(condition)) 215 | if not hasattr(variables, condition_name): 216 | raise AssertionError('Unknown condition "{}"'.format(condition_name)) 217 | 218 | 219 | def validate_condition(condition, variables, rule_schema): 220 | validate_condition_name(condition, variables) 221 | validate_condition_operator(condition, rule_schema) 222 | method = getattr(variables, condition.get('name')) 223 | params = condition.get('params', {}) 224 | check_params_valid_for_method(method, params, method_type.METHOD_TYPE_VARIABLE) 225 | 226 | 227 | def validate_conditions(input_conditions, rule_schema, variables): 228 | """ 229 | Recursively check all levels of input conditions 230 | """ 231 | if isinstance(input_conditions, list): 232 | for condition in input_conditions: 233 | validate_conditions(condition, rule_schema, variables) 234 | if isinstance(input_conditions, dict): 235 | keys = list(input_conditions.keys()) 236 | if 'any' in keys or 'all' in keys: 237 | if len(keys) > 1: 238 | raise AssertionError( 239 | 'Expected ONE of "any" or "all" but found {}'.format(keys) 240 | ) 241 | else: 242 | for _, v in input_conditions.items(): 243 | validate_conditions(v, rule_schema, variables) 244 | else: 245 | validate_condition(input_conditions, variables, rule_schema) 246 | 247 | 248 | def validate_actions(input_actions, actions): 249 | """ 250 | Check all input actions contain valid names and parameters for defined actions 251 | """ 252 | if type(input_actions) is not list: 253 | raise AssertionError('"actions" key must be a list') 254 | for action in input_actions: 255 | method = getattr(actions, action.get('name'), None) 256 | params = action.get('params', {}) 257 | check_params_valid_for_method(method, params, method_type.METHOD_TYPE_ACTION) 258 | 259 | 260 | def validate_rule_data(variables, actions, rule): 261 | """ 262 | validate_rule_data is used to check a generated rule against a set of variables and actions 263 | :param variables: 264 | :param actions: 265 | :param rule: 266 | :return: bool 267 | :raises AssertionError: 268 | """ 269 | rule_schema = export_rule_data(variables, actions) 270 | validate_root_keys(rule) 271 | conditions = rule.get('conditions', None) 272 | if conditions is not None and type(conditions) is not dict: 273 | raise AssertionError('"conditions" must be a dictionary') 274 | validate_conditions(conditions, rule_schema, variables) 275 | validate_actions(rule.get('actions'), actions) 276 | return True 277 | -------------------------------------------------------------------------------- /business_rules/engine.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from inspect import getfullargspec 3 | from typing import List 4 | 5 | from . import utils 6 | from .fields import FIELD_NO_INPUT 7 | from .models import ConditionResult 8 | from .util import method_type 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | def run_all( 14 | rule_list, defined_variables, defined_actions, stop_on_first_trigger=False 15 | ) -> List[bool]: 16 | results = [False] * len(rule_list) 17 | for i, rule in enumerate(rule_list): 18 | result = run(rule, defined_variables, defined_actions) 19 | if result: 20 | results[i] = True 21 | if stop_on_first_trigger: 22 | break 23 | return results 24 | 25 | 26 | def run(rule, defined_variables, defined_actions): 27 | conditions, actions = rule.get("conditions"), rule["actions"] 28 | 29 | if conditions is not None: 30 | rule_triggered, checked_conditions_results = check_conditions_recursively( 31 | conditions, defined_variables, rule 32 | ) 33 | else: 34 | # If there are no conditions then trigger actions 35 | rule_triggered = True 36 | checked_conditions_results = [] 37 | 38 | if rule_triggered: 39 | do_actions(actions, defined_actions, checked_conditions_results, rule) 40 | return True 41 | 42 | return False 43 | 44 | 45 | def check_conditions_recursively(conditions, defined_variables, rule): 46 | """ 47 | Check if the conditions are true given a set of variables. 48 | This method checks all conditions including embedded ones. 49 | 50 | :param conditions: Conditions to be checked 51 | :param defined_variables: BaseVariables instance to get variables values to check 52 | Conditions 53 | :param rule: Original rule where Conditions and Actions are defined 54 | :return: tuple with result of condition check and list of checked conditions with 55 | each individual result. 56 | 57 | (condition_result, [(condition1_result), (condition2_result)] 58 | 59 | condition1_result = (condition_result, variable name, condition operator, 60 | condition value, condition params) 61 | """ 62 | keys = list(conditions.keys()) 63 | if keys == ["all"]: 64 | assert len(conditions["all"]) >= 1 65 | matches = [] 66 | for condition in conditions["all"]: 67 | check_condition_result, matches_results = check_conditions_recursively( 68 | condition, defined_variables, rule 69 | ) 70 | matches.extend(matches_results) 71 | if not check_condition_result: 72 | return False, [] 73 | return True, matches 74 | 75 | elif keys == ["any"]: 76 | assert len(conditions["any"]) >= 1 77 | for condition in conditions["any"]: 78 | check_condition_result, matches_results = check_conditions_recursively( 79 | condition, defined_variables, rule 80 | ) 81 | if check_condition_result: 82 | return True, matches_results 83 | return False, [] 84 | 85 | else: 86 | # help prevent errors - any and all can only be in the condition dict 87 | # if they're the only item 88 | assert not ("any" in keys or "all" in keys) 89 | result = check_condition(conditions, defined_variables, rule) 90 | return result[0], [result] 91 | 92 | 93 | def check_condition(condition, defined_variables, rule): 94 | """ 95 | Checks a single rule condition - the condition will be made up of 96 | variables, values, and the comparison operator. The defined_variables 97 | object must have a variable defined for any variables in this condition. 98 | 99 | :param condition: 100 | :param defined_variables: 101 | :param rule: 102 | :return: business_rules.models.ConditionResult 103 | 104 | .. code-block:: 105 | ( 106 | result of condition: bool, 107 | condition name: str, 108 | condition operator: str, 109 | condition value: ?, 110 | condition params: {} 111 | ) 112 | """ 113 | name, op, value = condition["name"], condition["operator"], condition["value"] 114 | params = condition.get("params", {}) 115 | operator_type = _get_variable_value(defined_variables, name, params, rule) 116 | return ConditionResult( 117 | result=_do_operator_comparison(operator_type, op, value), 118 | name=name, 119 | operator=op, 120 | value=value, 121 | parameters=params, 122 | ) 123 | 124 | 125 | def _get_variable_value(defined_variables, name, params, rule): 126 | """ 127 | Call the function provided on the defined_variables object with the 128 | given name (raise exception if that doesn't exist) and casts it to the 129 | specified type. 130 | 131 | Returns an instance of operators.BaseType 132 | :param defined_variables: 133 | :param name: 134 | :param params: 135 | :return: Instance of operators.BaseType 136 | """ 137 | 138 | method = getattr(defined_variables, name, None) 139 | 140 | if method is None: 141 | raise AssertionError( 142 | "Variable {0} is not defined in class {1}".format( 143 | name, defined_variables.__class__.__name__ 144 | ) 145 | ) 146 | 147 | utils.check_params_valid_for_method( 148 | method, params, method_type.METHOD_TYPE_VARIABLE 149 | ) 150 | 151 | method_params = _build_variable_parameters(method, params, rule) 152 | variable_value = method(**method_params) 153 | return method.field_type(variable_value) 154 | 155 | 156 | def _do_operator_comparison(operator_type, operator_name, comparison_value): 157 | """ 158 | Finds the method on the given operator_type and compares it to the 159 | given comparison_value. 160 | 161 | operator_type should be an instance of operators.BaseType 162 | comparison_value is whatever python type to compare to 163 | returns a bool 164 | :param operator_type: 165 | :param operator_name: 166 | :param comparison_value: 167 | :return: 168 | """ 169 | 170 | def fallback(*args, **kwargs): 171 | raise AssertionError( 172 | "Operator {0} does not exist for type {1}".format( 173 | operator_name, operator_type.__class__.__name__ 174 | ) 175 | ) 176 | 177 | method = getattr(operator_type, operator_name, fallback) 178 | if getattr(method, "input_type", "") == FIELD_NO_INPUT: 179 | return method() 180 | return method(comparison_value) 181 | 182 | 183 | def do_actions(actions, defined_actions, checked_conditions_results, rule): 184 | """ 185 | 186 | :param actions: List of actions objects to be executed (defined in library) 187 | Example: 188 | 189 | .. code-block:: json 190 | 191 | { 192 | "name": "action name", 193 | "params": { 194 | "param1": value 195 | } 196 | } 197 | :param defined_actions: Class with function that implement the logic for each 198 | possible action defined in 'actions' parameter 199 | :param checked_conditions_results: 200 | :param rule: Rule that is being executed 201 | :return: None 202 | """ 203 | 204 | # Get only conditions when result was TRUE 205 | successful_conditions = [x for x in checked_conditions_results if x[0]] 206 | 207 | for action in actions: 208 | method_name = action["name"] 209 | action_params = action.get("params", {}) 210 | 211 | method = getattr(defined_actions, method_name, None) 212 | 213 | if not method: 214 | raise AssertionError( 215 | "Action {0} is not defined in class {1}".format( 216 | method_name, defined_actions.__class__.__name__ 217 | ) 218 | ) 219 | 220 | missing_params_with_default_value = utils.check_params_valid_for_method( 221 | method, action_params, method_type.METHOD_TYPE_ACTION 222 | ) 223 | 224 | if missing_params_with_default_value: 225 | action_params = _set_default_values_for_missing_action_params( 226 | method, missing_params_with_default_value, action_params 227 | ) 228 | 229 | method_params = _build_action_parameters( 230 | method, action_params, rule, successful_conditions 231 | ) 232 | method(**method_params) 233 | 234 | 235 | def _set_default_values_for_missing_action_params( 236 | method, missing_parameters_with_default_value, action_params 237 | ): 238 | """ 239 | Adds default parameter from method params to Action parameters. 240 | :param method: Action object. 241 | :param parameters_with_default_value: set of parameters which have a default value 242 | for Action parameters. 243 | :param action_params: Action parameters dict. 244 | :return: Modified action_params. 245 | """ 246 | modified_action_params = {} 247 | if getattr(method, "params", None): 248 | for param in method.params: 249 | param_name = param["name"] 250 | if param_name in missing_parameters_with_default_value: 251 | default_value = param.get("defaultValue", None) 252 | if default_value is not None: 253 | modified_action_params[param_name] = default_value 254 | continue 255 | modified_action_params[param_name] = action_params[param_name] 256 | return modified_action_params 257 | 258 | 259 | def _build_action_parameters(method, parameters, rule, conditions): 260 | """ 261 | Adds extra parameters to the parameters defined for the method 262 | :param method: 263 | :param parameters: 264 | :param rule: 265 | :param conditions: 266 | :return: 267 | """ 268 | extra_parameters = {"rule": rule, "conditions": conditions} 269 | 270 | return _build_parameters(method, parameters, extra_parameters) 271 | 272 | 273 | def _build_variable_parameters(method, parameters, rule): 274 | """ 275 | Adds extra parameters to the Variable's method parameters 276 | :param method: 277 | :param parameters: 278 | :param rule: 279 | :return: 280 | """ 281 | extra_parameters = { 282 | "rule": rule, 283 | } 284 | 285 | return _build_parameters(method, parameters, extra_parameters) 286 | 287 | 288 | def _build_parameters(method, parameters, extra_parameters): 289 | if getfullargspec(method).varkw is not None: 290 | method_params = extra_parameters 291 | else: 292 | method_params = {} 293 | 294 | method_params.update(parameters) 295 | 296 | return method_params 297 | -------------------------------------------------------------------------------- /business_rules/operators.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | import re 3 | from datetime import date, datetime, time 4 | from decimal import Decimal 5 | from functools import wraps 6 | 7 | from .fields import ( 8 | FIELD_DATETIME, 9 | FIELD_NO_INPUT, 10 | FIELD_NUMERIC, 11 | FIELD_SELECT, 12 | FIELD_SELECT_MULTIPLE, 13 | FIELD_TEXT, 14 | FIELD_TIME, 15 | ) 16 | from .utils import float_to_decimal, fn_name_to_pretty_label 17 | 18 | 19 | class BaseType(object): 20 | def __init__(self, value): 21 | self.value = self._assert_valid_value_and_cast(value) 22 | 23 | def _assert_valid_value_and_cast(self, value): 24 | raise NotImplementedError() 25 | 26 | @classmethod 27 | def get_all_operators(cls): 28 | methods = inspect.getmembers(cls) 29 | return [ 30 | {"name": m[0], "label": m[1].label, "input_type": m[1].input_type} 31 | for m in methods 32 | if getattr(m[1], "is_operator", False) 33 | ] 34 | 35 | 36 | def export_type(cls): 37 | """Decorator to expose the given class to business_rules.export_rule_data.""" 38 | cls.export_in_rule_data = True 39 | return cls 40 | 41 | 42 | def type_operator(input_type, label=None, assert_type_for_arguments=True): 43 | """Decorator to make a function into a type operator. 44 | 45 | - assert_type_for_arguments - if True this patches the operator function 46 | so that arguments passed to it will have _assert_valid_value_and_cast 47 | called on them to make type errors explicit. 48 | """ 49 | 50 | def wrapper(func): 51 | func.is_operator = True 52 | func.label = label or fn_name_to_pretty_label(func.__name__) 53 | func.input_type = input_type 54 | 55 | @wraps(func) 56 | def inner(self, *args, **kwargs): 57 | if assert_type_for_arguments: 58 | args = [self._assert_valid_value_and_cast(arg) for arg in args] 59 | kwargs = dict( 60 | (k, self._assert_valid_value_and_cast(v)) for k, v in kwargs.items() 61 | ) 62 | return func(self, *args, **kwargs) 63 | 64 | return inner 65 | 66 | return wrapper 67 | 68 | 69 | @export_type 70 | class StringType(BaseType): 71 | 72 | name = "string" 73 | 74 | def _assert_valid_value_and_cast(self, value): 75 | value = value or "" 76 | if not isinstance(value, str): 77 | raise AssertionError("{0} is not a valid string type.".format(value)) 78 | return value 79 | 80 | @type_operator(FIELD_TEXT) 81 | def equal_to(self, other_string): 82 | return self.value == other_string 83 | 84 | @type_operator(FIELD_TEXT, label="Equal To (case insensitive)") 85 | def equal_to_case_insensitive(self, other_string): 86 | return self.value.lower() == other_string.lower() 87 | 88 | @type_operator(FIELD_TEXT) 89 | def starts_with(self, other_string): 90 | return self.value.startswith(other_string) 91 | 92 | @type_operator(FIELD_TEXT) 93 | def ends_with(self, other_string): 94 | return self.value.endswith(other_string) 95 | 96 | @type_operator(FIELD_TEXT) 97 | def contains(self, other_string): 98 | return other_string in self.value 99 | 100 | @type_operator(FIELD_TEXT) 101 | def matches_regex(self, regex): 102 | return re.search(regex, self.value) 103 | 104 | @type_operator(FIELD_NO_INPUT) 105 | def non_empty(self): 106 | return bool(self.value) 107 | 108 | 109 | @export_type 110 | class NumericType(BaseType): 111 | EPSILON = Decimal("0.000001") 112 | 113 | name = "numeric" 114 | 115 | @staticmethod 116 | def _assert_valid_value_and_cast(value): 117 | if isinstance(value, float): 118 | # In python 2.6, casting float to Decimal doesn't work 119 | return float_to_decimal(value) 120 | if isinstance(value, int): 121 | return Decimal(value) 122 | if isinstance(value, Decimal): 123 | return value 124 | else: 125 | raise AssertionError("{0} is not a valid numeric type.".format(value)) 126 | 127 | @type_operator(FIELD_NUMERIC) 128 | def equal_to(self, other_numeric): 129 | return abs(self.value - other_numeric) <= self.EPSILON 130 | 131 | @type_operator(FIELD_NUMERIC) 132 | def greater_than(self, other_numeric): 133 | return (self.value - other_numeric) > self.EPSILON 134 | 135 | @type_operator(FIELD_NUMERIC) 136 | def greater_than_or_equal_to(self, other_numeric): 137 | return self.greater_than(other_numeric) or self.equal_to(other_numeric) 138 | 139 | @type_operator(FIELD_NUMERIC) 140 | def less_than(self, other_numeric): 141 | return (other_numeric - self.value) > self.EPSILON 142 | 143 | @type_operator(FIELD_NUMERIC) 144 | def less_than_or_equal_to(self, other_numeric): 145 | return self.less_than(other_numeric) or self.equal_to(other_numeric) 146 | 147 | @type_operator(FIELD_NUMERIC) 148 | def divisible(self, other_numeric): 149 | return self.value % other_numeric == 0 150 | 151 | 152 | @export_type 153 | class BooleanType(BaseType): 154 | 155 | name = "boolean" 156 | 157 | def _assert_valid_value_and_cast(self, value): 158 | if type(value) != bool: 159 | raise AssertionError("{0} is not a valid boolean type".format(value)) 160 | return value 161 | 162 | @type_operator(FIELD_NO_INPUT) 163 | def is_true(self): 164 | return self.value 165 | 166 | @type_operator(FIELD_NO_INPUT) 167 | def is_false(self): 168 | return not self.value 169 | 170 | 171 | @export_type 172 | class SelectType(BaseType): 173 | 174 | name = "select" 175 | 176 | def _assert_valid_value_and_cast(self, value): 177 | if not hasattr(value, "__iter__"): 178 | raise AssertionError("{0} is not a valid select type".format(value)) 179 | return value 180 | 181 | @staticmethod 182 | def _case_insensitive_equal_to(value_from_list, other_value): 183 | if isinstance(value_from_list, str) and isinstance(other_value, str): 184 | return value_from_list.lower() == other_value.lower() 185 | else: 186 | return value_from_list == other_value 187 | 188 | @type_operator(FIELD_SELECT, assert_type_for_arguments=False) 189 | def contains(self, other_value): 190 | for val in self.value: 191 | if self._case_insensitive_equal_to(val, other_value): 192 | return True 193 | return False 194 | 195 | @type_operator(FIELD_SELECT, assert_type_for_arguments=False) 196 | def does_not_contain(self, other_value): 197 | for val in self.value: 198 | if self._case_insensitive_equal_to(val, other_value): 199 | return False 200 | return True 201 | 202 | 203 | @export_type 204 | class SelectMultipleType(BaseType): 205 | 206 | name = "select_multiple" 207 | 208 | def _assert_valid_value_and_cast(self, value): 209 | if not hasattr(value, "__iter__"): 210 | raise AssertionError( 211 | "{0} is not a valid select multiple type".format(value) 212 | ) 213 | return value 214 | 215 | @type_operator(FIELD_SELECT_MULTIPLE) 216 | def contains_all(self, other_value): 217 | select = SelectType(self.value) 218 | for other_val in other_value: 219 | if not select.contains(other_val): 220 | return False 221 | return True 222 | 223 | @type_operator(FIELD_SELECT_MULTIPLE) 224 | def is_contained_by(self, other_value): 225 | other_select_multiple = SelectMultipleType(other_value) 226 | return other_select_multiple.contains_all(self.value) 227 | 228 | @type_operator(FIELD_SELECT_MULTIPLE) 229 | def shares_at_least_one_element_with(self, other_value): 230 | select = SelectType(self.value) 231 | for other_val in other_value: 232 | if select.contains(other_val): 233 | return True 234 | return False 235 | 236 | @type_operator(FIELD_SELECT_MULTIPLE) 237 | def shares_exactly_one_element_with(self, other_value): 238 | found_one = False 239 | select = SelectType(self.value) 240 | for other_val in other_value: 241 | if select.contains(other_val): 242 | if found_one: 243 | return False 244 | found_one = True 245 | return found_one 246 | 247 | @type_operator(FIELD_SELECT_MULTIPLE) 248 | def shares_no_elements_with(self, other_value): 249 | return not self.shares_at_least_one_element_with(other_value) 250 | 251 | 252 | @export_type 253 | class DateTimeType(BaseType): 254 | name = "datetime" 255 | DATETIME_FORMAT = "%Y-%m-%dT%H:%M:%S" 256 | DATE_FORMAT = "%Y-%m-%d" 257 | 258 | def _assert_valid_value_and_cast(self, value): 259 | """ 260 | Parse string with formats '%Y-%m-%dT%H:%M:%S' or '%Y-%m-%d' into 261 | datetime.datetime instance. 262 | 263 | :param value: 264 | :return: 265 | """ 266 | if isinstance(value, datetime): 267 | return value 268 | 269 | if isinstance(value, date): 270 | return datetime(value.year, value.month, value.day) 271 | 272 | try: 273 | return datetime.strptime(value, self.DATETIME_FORMAT) 274 | except (ValueError, TypeError): 275 | pass 276 | 277 | try: 278 | return datetime.strptime(value, self.DATE_FORMAT) 279 | except (ValueError, TypeError): 280 | raise AssertionError("{0} is not a valid datetime type.".format(value)) 281 | 282 | def _set_timezone_if_different(self, variable_datetime, condition_value_datetime): 283 | # type: (datetime, datetime) -> datetime 284 | if variable_datetime.tzinfo is None: 285 | if condition_value_datetime.tzinfo is None: 286 | return condition_value_datetime 287 | else: 288 | return condition_value_datetime.replace(tzinfo=None) 289 | 290 | return condition_value_datetime.replace(tzinfo=variable_datetime.tzinfo) 291 | 292 | @type_operator(FIELD_DATETIME) 293 | def equal_to(self, other_datetime): 294 | # type: (datetime) -> bool 295 | other_datetime = self._set_timezone_if_different(self.value, other_datetime) 296 | 297 | return self.value == other_datetime 298 | 299 | @type_operator(FIELD_DATETIME) 300 | def after_than(self, other_datetime): 301 | # type: (datetime) -> bool 302 | other_datetime = self._set_timezone_if_different(self.value, other_datetime) 303 | 304 | return self.value > other_datetime 305 | 306 | @type_operator(FIELD_DATETIME) 307 | def after_than_or_equal_to(self, other_datetime): 308 | return self.after_than(other_datetime) or self.equal_to(other_datetime) 309 | 310 | @type_operator(FIELD_DATETIME) 311 | def before_than(self, other_datetime): 312 | # type: (datetime) -> bool 313 | other_datetime = self._set_timezone_if_different(self.value, other_datetime) 314 | 315 | return self.value < other_datetime 316 | 317 | @type_operator(FIELD_DATETIME) 318 | def before_than_or_equal_to(self, other_datetime): 319 | return self.before_than(other_datetime) or self.equal_to(other_datetime) 320 | 321 | 322 | @export_type 323 | class TimeType(BaseType): 324 | name = "time" 325 | TIME_FORMAT = "%H:%M:%S" 326 | TIME_FORMAT_NO_SECONDS = "%H:%M" 327 | 328 | def _assert_valid_value_and_cast(self, value): 329 | """ 330 | Parse datetime, time or string with format %H:%M:%S into time instance. 331 | 332 | :param value: datetime, date or string with format %H:%M:%S 333 | :return: time 334 | """ 335 | if isinstance(value, time): 336 | return value 337 | 338 | if isinstance(value, datetime): 339 | return value.time() 340 | 341 | try: 342 | dt = datetime.strptime(value, self.TIME_FORMAT) 343 | return time(dt.hour, dt.minute, dt.second) 344 | except (ValueError, TypeError): 345 | pass 346 | 347 | try: 348 | dt = datetime.strptime(value, self.TIME_FORMAT_NO_SECONDS) 349 | return time(dt.hour, dt.minute, dt.second) 350 | except (ValueError, TypeError): 351 | raise AssertionError("{0} is not a valid time type.".format(value)) 352 | 353 | @type_operator(FIELD_TIME) 354 | def equal_to(self, other_time): 355 | return self.value == other_time 356 | 357 | @type_operator(FIELD_TIME) 358 | def after_than(self, other_time): 359 | return self.value > other_time 360 | 361 | @type_operator(FIELD_TIME) 362 | def after_than_or_equal_to(self, other_time): 363 | return self.after_than(other_time) or self.equal_to(other_time) 364 | 365 | @type_operator(FIELD_TIME) 366 | def before_than(self, other_time): 367 | return self.value < other_time 368 | 369 | @type_operator(FIELD_TIME) 370 | def before_than_or_equal_to(self, other_time): 371 | return self.before_than(other_time) or self.equal_to(other_time) 372 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | business-rules 2 | ============== 3 | 4 | As a software system grows in complexity and usage, it can become burdensome if 5 | every change to the logic/behavior of the system also requires you to write and 6 | deploy new code. The goal of this business rules engine is to provide a simple 7 | interface allowing anyone to capture new rules and logic defining the behavior 8 | of a system, and a way to then process those rules on the backend. 9 | 10 | You might, for example, find this is a useful way for analysts to define 11 | marketing logic around when certain customers or items are eligible for a 12 | discount or to automate emails after users enter a certain state or go through 13 | a particular sequence of events. 14 | 15 | ## Usage 16 | 17 | ### 1. Define Your set of variables 18 | 19 | Variables represent values in your system, usually the value of some particular object. You create rules by setting threshold conditions such that when a variable is computed that triggers the condition some action is taken. 20 | 21 | You define all the available variables for a certain kind of object in your code, and then later dynamically set the conditions and thresholds for those. 22 | 23 | For example: 24 | 25 | ```python 26 | class ProductVariables(BaseVariables): 27 | 28 | def __init__(self, product): 29 | self.product = product 30 | 31 | @numeric_rule_variable 32 | def current_inventory(self): 33 | return self.product.current_inventory 34 | 35 | @numeric_rule_variable(label='Days until expiration') 36 | def expiration_days(self) 37 | last_order = self.product.orders[-1] 38 | return (last_order.expiration_date - datetime.date.today()).days 39 | 40 | @string_rule_variable() 41 | def current_month(self): 42 | return datetime.datetime.now().strftime("%B") 43 | 44 | @select_rule_variable(options=Products.top_holiday_items()) 45 | def goes_well_with(self): 46 | return products.related_products 47 | 48 | @numeric_rule_variable(params=[{ 49 | 'field_type': FIELD_NUMERIC, 50 | 'name': 'days', 51 | 'label': 'Days' 52 | }]) 53 | def orders_sold_in_last_x_days(self, days): 54 | count = 0 55 | for order in self.product.orders: 56 | if (datetime.date.today() - order.date_sold).days < days: 57 | count += 1 58 | return count 59 | ``` 60 | 61 | ### 2. Define your set of actions 62 | 63 | These are the actions that are available to be taken when a condition is triggered. 64 | 65 | For example: 66 | 67 | ```python 68 | class ProductActions(BaseActions): 69 | 70 | def __init__(self, product): 71 | self.product = product 72 | 73 | @rule_action(params={"sale_percentage": FIELD_NUMERIC}) 74 | def put_on_sale(self, sale_percentage): 75 | self.product.price = (1.0 - sale_percentage) * self.product.price 76 | self.product.save() 77 | 78 | @rule_action(params={"number_to_order": FIELD_NUMERIC}) 79 | def order_more(self, number_to_order): 80 | ProductOrder.objects.create(product_id=self.product.id, 81 | quantity=number_to_order) 82 | ``` 83 | 84 | If you need a select field for an action parameter, another -more verbose- syntax is available: 85 | 86 | ```python 87 | class ProductActions(BaseActions): 88 | 89 | def __init__(self, product): 90 | self.product = product 91 | 92 | @rule_action(params=[{'fieldType': FIELD_SELECT, 93 | 'name': 'stock_state', 94 | 'label': 'Stock state', 95 | 'options': [ 96 | {'label': 'Available', 'name': 'available'}, 97 | {'label': 'Last items', 'name': 'last_items'}, 98 | {'label': 'Out of stock', 'name': 'out_of_stock'} 99 | ]}]) 100 | def change_stock_state(self, stock_state): 101 | self.product.stock_state = stock_state 102 | self.product.save() 103 | ``` 104 | 105 | If you introduced a new action parameter but already have an older set of active Rules, 106 | which will all require to be updated with this new parameter, you can use `ActionParam` class which 107 | contains default value for parameter along with field type: 108 | ```python 109 | from business_rules.actions import ActionParam, BaseActions, rule_action 110 | from business_rules import fields 111 | 112 | class ProductActions(BaseActions): 113 | 114 | def __init__(self, product): 115 | self.product = product 116 | 117 | @rule_action(params={"sale_percentage": fields.FIELD_NUMERIC, 118 | "on_sale": ActionParam(field_type=fields.FIELD_BOOLEAN, default_value=False}) 119 | def put_on_sale(self, sale_percentage, on_sale): 120 | self.product.price = (1.0 - sale_percentage) * self.product.price 121 | self.on_sale = on_sale 122 | self.product.save() 123 | 124 | ``` 125 | ### 3. Build the rules 126 | 127 | A rule is just a JSON object that gets interpreted by the business-rules engine. 128 | 129 | Note that the JSON is expected to be auto-generated by a UI, which makes it simple for anyone to set and tweak business rules without knowing anything about the code. The javascript library used for generating these on the web can be found [here](https://github.com/venmo/business-rules-ui). 130 | 131 | An example of the resulting python lists/dicts is: 132 | 133 | ```python 134 | rules = [ 135 | # expiration_days < 5 AND current_inventory > 20 136 | { "conditions": { "all": [ 137 | { "name": "expiration_days", 138 | "operator": "less_than", 139 | "value": 5, 140 | }, 141 | { "name": "current_inventory", 142 | "operator": "greater_than", 143 | "value": 20, 144 | }, 145 | ]}, 146 | "actions": [ 147 | { "name": "put_on_sale", 148 | "params": {"sale_percentage": 0.25}, 149 | }, 150 | ], 151 | }, 152 | 153 | # current_inventory < 5 OR (current_month = "December" AND current_inventory < 20) 154 | { "conditions": { "any": [ 155 | { 156 | "name": "current_inventory", 157 | "operator": "less_than", 158 | "value": 5, 159 | }, 160 | { 161 | "all": [ 162 | { 163 | "name": "current_month", 164 | "operator": "equal_to", 165 | "value": "December", 166 | }, 167 | { 168 | "name": "current_inventory", 169 | "operator": "less_than", 170 | "value": 20, 171 | } 172 | ] 173 | } 174 | ] 175 | }, 176 | "actions": [ 177 | { "name": "order_more", 178 | "params":{"number_to_order": 40}, 179 | }, 180 | ], 181 | }, 182 | # orders_sold_in_last_x_days(5) > 10 183 | { 184 | "conditions": { "all": [ 185 | { 186 | "name": "orders_sold_in_last_x_days", 187 | "operator": "greater_than", 188 | "value": 10, 189 | "params": {"days": 5}, 190 | } 191 | ]}, 192 | "actions": [ 193 | { 194 | "name": "order_more", 195 | "fields": [{"name": "number_to_order", "value": 40}] 196 | } 197 | ] 198 | }] 199 | ``` 200 | 201 | ### Export the available variables, operators and actions 202 | 203 | To e.g. send to your client so it knows how to build rules 204 | 205 | ```python 206 | from business_rules import export_rule_data 207 | export_rule_data(ProductVariables, ProductActions) 208 | ``` 209 | 210 | that returns 211 | 212 | ```json 213 | { 214 | "variables": [ 215 | { 216 | "name": "expiration_days", 217 | "label": "Days until expiration", 218 | "field_type": "numeric", 219 | "options": [], 220 | "params": [] 221 | }, 222 | { 223 | "name": "current_month", 224 | "label": "Current Month", 225 | "field_type": "string", 226 | "options": [], 227 | "params": [] 228 | }, 229 | { 230 | "name": "goes_well_with", 231 | "label": "Goes Well With", 232 | "field_type": "select", 233 | "options": [ 234 | "Eggnog", 235 | "Cookies", 236 | "Beef Jerkey" 237 | ], 238 | "params": [] 239 | }, 240 | { 241 | "name": "orders_sold_in_last_x_days", 242 | "label": "Orders Sold In Last X Days", 243 | "field_type": "numeric", 244 | "options": [], 245 | "params": [ 246 | { 247 | "field_type": "numeric", 248 | "name": "days", 249 | "label": "Days" 250 | } 251 | ] 252 | } 253 | ], 254 | "actions": [ 255 | { 256 | "name": "put_on_sale", 257 | "label": "Put On Sale", 258 | "params": { 259 | "sale_percentage": "numeric" 260 | } 261 | }, 262 | { 263 | "name": "order_more", 264 | "label": "Order More", 265 | "params": { 266 | "number_to_order": "numeric" 267 | } 268 | } 269 | ], 270 | "variable_type_operators": { 271 | "numeric": [ 272 | { 273 | "name": "equal_to", 274 | "label": "Equal To", 275 | "input_type": "numeric" 276 | }, 277 | { 278 | "name": "less_than", 279 | "label": "Less Than", 280 | "input_type": "numeric" 281 | }, 282 | { 283 | "name": "greater_than", 284 | "label": "Greater Than", 285 | "input_type": "numeric" 286 | } 287 | ], 288 | "string": [ 289 | { 290 | "name": "equal_to", 291 | "label": "Equal To", 292 | "input_type": "text" 293 | }, 294 | { 295 | "name": "non_empty", 296 | "label": "Non Empty", 297 | "input_type": "none" 298 | } 299 | ] 300 | } 301 | } 302 | ``` 303 | 304 | To validate rule data: 305 | 306 | ```python 307 | from business_rules import validate_rule_data 308 | is_valid = validate_rule_data(ProductVariables, ProductActions, {'conditions':[], 'actions':[]}) 309 | ``` 310 | 311 | ### Run your rules 312 | 313 | ```python 314 | from business_rules import run_all 315 | 316 | rules = _some_function_to_receive_from_client() 317 | 318 | for product in Products.objects.all(): 319 | run_all(rule_list=rules, 320 | defined_variables=ProductVariables(product), 321 | defined_actions=ProductActions(product), 322 | stop_on_first_trigger=True, 323 | ) 324 | ``` 325 | 326 | ## API 327 | 328 | ### Variable Types and Decorators: 329 | 330 | The type represents the type of the value that will be returned for the variable and is necessary since there are different available comparison operators for different types, and the front-end that's generating the rules needs to know which operators are available. 331 | 332 | All decorators can optionally take a label: 333 | - `label` - A human-readable label to show on the frontend. By default we just split the variable name on underscores and capitalize the words. 334 | - `params` - A list of parameters that will be passed to the variable when its value is calculated. The list elements 335 | should be dictionaries with a `field_type` to specify the type and `name` that corresponds to an argument of the 336 | variable function. 337 | 338 | The available types and decorators are: 339 | 340 | #### `numeric` - an integer, float, or python Decimal. 341 | 342 | `@numeric_rule_variable` operators: 343 | 344 | * `equal_to` 345 | * `greater_than` 346 | * `less_than` 347 | * `greater_than_or_equal_to` 348 | * `less_than_or_equal_to` 349 | 350 | Note: to compare floating point equality we just check that the difference is less than some small epsilon 351 | 352 | #### `string` - a python bytestring or unicode string. 353 | 354 | `@string_rule_variable` operators: 355 | 356 | * `equal_to` 357 | * `starts_with` 358 | * `ends_with` 359 | * `contains` 360 | * `matches_regex` 361 | * `non_empty` 362 | 363 | #### `boolean` - a True or False value. 364 | 365 | `@boolean_rule_variable` operators: 366 | 367 | * `is_true` 368 | * `is_false` 369 | 370 | #### `select` - a set of values, where the threshold will be a single item. 371 | 372 | `@select_rule_variable` operators: 373 | 374 | * `contains` 375 | * `does_not_contain` 376 | 377 | #### `select_multiple` - a set of values, where the threshold will be a set of items. 378 | 379 | `@select_multiple_rule_variable` operators: 380 | 381 | * `contains_all` 382 | * `is_contained_by` 383 | * `shares_at_least_one_element_with` 384 | * `shares_exactly_one_element_with` 385 | * `shares_no_elements_with` 386 | 387 | #### `datetime` - a Timestamp value 388 | 389 | A rule variable accepts the following types of values: 390 | 391 | * int 392 | * string with format `%Y-%m-%dT%H:%M:%S` 393 | * string with format `%Y-%m-%d` 394 | 395 | A variable can return the following types of values: 396 | 397 | * int 398 | * datetime 399 | * date 400 | * string with format `%Y-%m-%dT%H:%M:%S` 401 | * string with format `%Y-%m-%d` 402 | 403 | `@datetime_rule_variable` operators: 404 | 405 | * `equal_to` 406 | * `before_than` 407 | * `before_than_or_equal_to` 408 | * `after_than` 409 | * `after_than_or_equal_to` 410 | 411 | 412 | #### `time` - a Time value 413 | 414 | A rule variable accepts the following types of values: 415 | 416 | * string with format `%H:%M:%S` 417 | * string with format `%H:%M` 418 | 419 | A variable can return the following types of values: 420 | 421 | * datetime 422 | * time 423 | * string with format `%H:%M:%S` 424 | * string with format `%H:%M` 425 | 426 | `@time_rule_variable` operators: 427 | 428 | * `equal_to` 429 | * `before_than` 430 | * `before_than_or_equal_to` 431 | * `after_than` 432 | * `after_than_or_equal_to` 433 | 434 | 435 | ## Contributing 436 | 437 | 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)): 438 | 439 | ```bash 440 | $ python -m virtualenv venv 441 | $ source ./venv/bin/activate 442 | $ pip install -r dev-requirements.txt -e . 443 | $ pytest 444 | ``` 445 | 446 | Alternatively, you can also use Tox: 447 | 448 | ```bash 449 | $ pip install "tox<4" 450 | $ tox -p auto --skip-missing-interpreters 451 | ``` 452 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | import pytest 4 | 5 | from business_rules import fields, utils 6 | from business_rules.fields import FIELD_DATETIME, FIELD_TIME 7 | from tests import actions, variables 8 | from tests.test_integration import SomeActions, SomeVariables 9 | 10 | 11 | def test_fn_name_to_pretty_label(): 12 | name = 'action_function_name' 13 | 14 | pretty_label = utils.fn_name_to_pretty_label(name) 15 | 16 | assert pretty_label != name 17 | assert pretty_label == 'Action Function Name' 18 | 19 | 20 | def test_fn_name_to_pretty_label_with_no_underscores(): 21 | name = 'Action Function Name' 22 | 23 | pretty_label = utils.fn_name_to_pretty_label(name) 24 | 25 | assert pretty_label == name 26 | 27 | 28 | def test_fn_name_to_pretty_label_with_different_cases(): 29 | name = 'actiON_FUNCTION_nAMe' 30 | 31 | pretty_label = utils.fn_name_to_pretty_label(name) 32 | 33 | assert pretty_label != name 34 | assert pretty_label == 'Action Function Name' 35 | 36 | 37 | def test_get_valid_fields(): 38 | valid_fields = utils.get_valid_fields() 39 | 40 | assert len(valid_fields) == 8 41 | 42 | 43 | def test_params_dict_to_list_when_params_none(): 44 | result = utils.params_dict_to_list(None) 45 | 46 | assert result == [] 47 | 48 | 49 | def test_export_rule_data(): 50 | """ 51 | Tests that export_rule_data has the three expected keys in the right format. 52 | """ 53 | all_data = utils.export_rule_data(SomeVariables(), SomeActions()) 54 | 55 | assert all_data.get("actions") == [ 56 | { 57 | "name": "action_with_no_params", 58 | "label": "Action With No Params", 59 | "params": None, 60 | }, 61 | { 62 | "name": "some_action", 63 | "label": "Some Action", 64 | "params": [ 65 | { 66 | 'fieldType': 'numeric', 67 | 'label': 'Foo', 68 | 'name': 'foo', 69 | 'defaultValue': None, 70 | } 71 | ], 72 | }, 73 | { 74 | "name": "some_other_action", 75 | "label": "woohoo", 76 | "params": [ 77 | { 78 | 'fieldType': 'text', 79 | 'label': 'Bar', 80 | 'name': 'bar', 81 | 'defaultValue': None, 82 | } 83 | ], 84 | }, 85 | { 86 | "name": "some_select_action", 87 | "label": "Some Select Action", 88 | "params": [ 89 | { 90 | 'fieldType': fields.FIELD_SELECT, 91 | 'name': 'baz', 92 | 'label': 'Baz', 93 | 'options': [ 94 | {'label': 'Chose Me', 'name': 'chose_me'}, 95 | {'label': 'Or Me', 'name': 'or_me'}, 96 | ], 97 | } 98 | ], 99 | }, 100 | ] 101 | 102 | assert all_data.get("variables") == [ 103 | { 104 | "name": "foo", 105 | "label": "Foo", 106 | "field_type": "string", 107 | "options": [], 108 | "params": [], 109 | "public": True, 110 | }, 111 | { 112 | 'name': 'private_string_variable', 113 | 'label': 'Private String Variable', 114 | 'field_type': 'string', 115 | 'options': [], 116 | 'params': [], 117 | 'public': False, 118 | }, 119 | { 120 | 'name': 'rule_received', 121 | 'label': 'Rule Received', 122 | 'field_type': 'boolean', 123 | 'options': [], 124 | 'params': [], 125 | "public": True, 126 | }, 127 | { 128 | 'name': 'string_variable_with_options', 129 | 'label': 'StringLabel', 130 | 'field_type': 'string', 131 | 'options': ['one', 'two', 'three'], 132 | 'params': [], 133 | "public": True, 134 | }, 135 | { 136 | "name": "ten", 137 | "label": "Diez", 138 | "field_type": "numeric", 139 | "options": [], 140 | "params": [], 141 | "public": True, 142 | }, 143 | { 144 | 'name': 'true_bool', 145 | 'label': 'True Bool', 146 | 'field_type': 'boolean', 147 | 'options': [], 148 | "params": [], 149 | "public": True, 150 | }, 151 | { 152 | 'name': 'x_plus_one', 153 | 'label': 'X Plus One', 154 | 'field_type': 'numeric', 155 | 'options': [], 156 | 'params': [{'field_type': 'numeric', 'name': 'x', 'label': 'X'}], 157 | "public": True, 158 | }, 159 | ] 160 | 161 | assert all_data.get("variable_type_operators") == { 162 | 'boolean': [ 163 | {'input_type': 'none', 'label': 'Is False', 'name': 'is_false'}, 164 | {'input_type': 'none', 'label': 'Is True', 'name': 'is_true'}, 165 | ], 166 | 'datetime': [ 167 | {'input_type': FIELD_DATETIME, 'label': 'After Than', 'name': 'after_than'}, 168 | { 169 | 'input_type': FIELD_DATETIME, 170 | 'label': 'After Than Or Equal To', 171 | 'name': 'after_than_or_equal_to', 172 | }, 173 | { 174 | 'input_type': FIELD_DATETIME, 175 | 'label': 'Before Than', 176 | 'name': 'before_than', 177 | }, 178 | { 179 | 'input_type': FIELD_DATETIME, 180 | 'label': 'Before Than Or Equal To', 181 | 'name': 'before_than_or_equal_to', 182 | }, 183 | {'input_type': FIELD_DATETIME, 'label': 'Equal To', 'name': 'equal_to'}, 184 | ], 185 | 'numeric': [ 186 | {'input_type': 'numeric', 'label': 'Divisible', 'name': 'divisible'}, 187 | {'input_type': 'numeric', 'label': 'Equal To', 'name': 'equal_to'}, 188 | {'input_type': 'numeric', 'label': 'Greater Than', 'name': 'greater_than'}, 189 | { 190 | 'input_type': 'numeric', 191 | 'label': 'Greater Than Or Equal To', 192 | 'name': 'greater_than_or_equal_to', 193 | }, 194 | {'input_type': 'numeric', 'label': 'Less Than', 'name': 'less_than'}, 195 | { 196 | 'input_type': 'numeric', 197 | 'label': 'Less Than Or Equal To', 198 | 'name': 'less_than_or_equal_to', 199 | }, 200 | ], 201 | 'select': [ 202 | {'input_type': 'select', 'label': 'Contains', 'name': 'contains'}, 203 | { 204 | 'input_type': 'select', 205 | 'label': 'Does Not Contain', 206 | 'name': 'does_not_contain', 207 | }, 208 | ], 209 | 'select_multiple': [ 210 | { 211 | 'input_type': 'select_multiple', 212 | 'label': 'Contains All', 213 | 'name': 'contains_all', 214 | }, 215 | { 216 | 'input_type': 'select_multiple', 217 | 'label': 'Is Contained By', 218 | 'name': 'is_contained_by', 219 | }, 220 | { 221 | 'input_type': 'select_multiple', 222 | 'label': 'Shares At Least One Element With', 223 | 'name': 'shares_at_least_one_element_with', 224 | }, 225 | { 226 | 'input_type': 'select_multiple', 227 | 'label': 'Shares Exactly One Element With', 228 | 'name': 'shares_exactly_one_element_with', 229 | }, 230 | { 231 | 'input_type': 'select_multiple', 232 | 'label': 'Shares No Elements With', 233 | 'name': 'shares_no_elements_with', 234 | }, 235 | ], 236 | 'string': [ 237 | {'input_type': 'text', 'label': 'Contains', 'name': 'contains'}, 238 | {'input_type': 'text', 'label': 'Ends With', 'name': 'ends_with'}, 239 | {'input_type': 'text', 'label': 'Equal To', 'name': 'equal_to'}, 240 | { 241 | 'input_type': 'text', 242 | 'label': 'Equal To (case insensitive)', 243 | 'name': 'equal_to_case_insensitive', 244 | }, 245 | {'input_type': 'text', 'label': 'Matches Regex', 'name': 'matches_regex'}, 246 | {'input_type': 'none', 'label': 'Non Empty', 'name': 'non_empty'}, 247 | {'input_type': 'text', 'label': 'Starts With', 'name': 'starts_with'}, 248 | ], 249 | 'time': [ 250 | {'input_type': FIELD_TIME, 'label': 'After Than', 'name': 'after_than'}, 251 | { 252 | 'input_type': FIELD_TIME, 253 | 'label': 'After Than Or Equal To', 254 | 'name': 'after_than_or_equal_to', 255 | }, 256 | {'input_type': FIELD_TIME, 'label': 'Before Than', 'name': 'before_than'}, 257 | { 258 | 'input_type': FIELD_TIME, 259 | 'label': 'Before Than Or Equal To', 260 | 'name': 'before_than_or_equal_to', 261 | }, 262 | {'input_type': FIELD_TIME, 'label': 'Equal To', 'name': 'equal_to'}, 263 | ], 264 | } 265 | 266 | 267 | def test_validate_rule_data_success(): 268 | valid_rule = { 269 | 'conditions': { 270 | 'all': [ 271 | {'name': 'bool_variable', 'operator': 'is_false', 'value': ''}, 272 | {'name': 'str_variable', 'operator': 'contains', 'value': 'test'}, 273 | { 274 | 'name': 'select_multiple_variable', 275 | 'operator': 'contains_all', 276 | 'value': [1, 2, 3], 277 | }, 278 | {'name': 'numeric_variable', 'operator': 'equal_to', 'value': 1}, 279 | { 280 | 'name': 'datetime_variable', 281 | 'operator': 'equal_to', 282 | 'value': '2016-01-01', 283 | }, 284 | {'name': 'time_variable', 'operator': 'equal_to', 'value': '10:00:00'}, 285 | {'name': 'select_variable', 'operator': 'contains', 'value': [1]}, 286 | ] 287 | }, 288 | 'actions': [{'name': 'example_action', 'params': {'param': 1}}], 289 | } 290 | utils.validate_rule_data(variables.TestVariables, actions.TestActions, valid_rule) 291 | 292 | 293 | def test_validate_rule_data_nested_success(): 294 | valid_rule = { 295 | 'conditions': { 296 | 'any': [ 297 | {'name': 'bool_variable', 'operator': 'is_false', 'value': ''}, 298 | { 299 | "all": [ 300 | { 301 | 'name': 'str_variable', 302 | 'operator': 'contains', 303 | 'value': 'test', 304 | }, 305 | { 306 | 'name': 'select_multiple_variable', 307 | 'operator': 'contains_all', 308 | 'value': [1, 2, 3], 309 | }, 310 | ] 311 | }, 312 | ] 313 | }, 314 | 'actions': [{'name': 'example_action', 'params': {'param': 1}}], 315 | } 316 | utils.validate_rule_data(variables.TestVariables, actions.TestActions, valid_rule) 317 | 318 | 319 | def test_validate_rule_data_empty_dict(): 320 | with pytest.raises(AssertionError): 321 | utils.validate_rule_data(variables.TestVariables, actions.TestActions, {}) 322 | 323 | 324 | def test_validate_rule_data_no_conditions(): 325 | invalid_rule = {'actions': []} 326 | 327 | utils.validate_rule_data(variables.TestVariables, actions.TestActions, invalid_rule) 328 | 329 | 330 | def test_validate_rule_data_no_actions(): 331 | invalid_rule = {'conditions': {}} 332 | with pytest.raises(AssertionError): 333 | utils.validate_rule_data( 334 | variables.TestVariables, actions.TestActions, invalid_rule 335 | ) 336 | 337 | 338 | def test_validate_rule_data_unknown_action(): 339 | invalid_rule = {'conditions': {}, 'actions': [{'name': 'unknown', 'params': {}}]} 340 | with pytest.raises(AssertionError): 341 | utils.validate_rule_data( 342 | variables.TestVariables, actions.TestActions, invalid_rule 343 | ) 344 | 345 | 346 | def test_validate_rule_data_missing_action_name(): 347 | invalid_rule = {'conditions': {}, 'actions': [{'params': {}}]} 348 | with pytest.raises(AssertionError): 349 | utils.validate_rule_data( 350 | variables.TestVariables, actions.TestActions, invalid_rule 351 | ) 352 | 353 | 354 | def test_validate_rule_data_unknown_condition_name(): 355 | invalid_rule = { 356 | 'conditions': { 357 | 'any': [{'name': 'unknown', 'operator': 'unknown', 'value': 'unknown'}] 358 | }, 359 | 'actions': [], 360 | } 361 | with pytest.raises(AssertionError): 362 | utils.validate_rule_data( 363 | variables.TestVariables, actions.TestActions, invalid_rule 364 | ) 365 | 366 | 367 | def test_validate_rule_data_unknown_condition_operator(): 368 | invalid_rule = { 369 | 'conditions': { 370 | 'any': [{'name': 'bool_variable', 'operator': 'unknown', 'value': ''}] 371 | }, 372 | 'actions': [], 373 | } 374 | with pytest.raises(AssertionError): 375 | utils.validate_rule_data( 376 | variables.TestVariables, actions.TestActions, invalid_rule 377 | ) 378 | 379 | 380 | def test_validate_rule_data_missing_condition_operator(): 381 | invalid_rule = { 382 | 'conditions': {'any': [{'name': 'bool_variable', 'value': ''}]}, 383 | 'actions': [], 384 | } 385 | with pytest.raises(AssertionError): 386 | utils.validate_rule_data( 387 | variables.TestVariables, actions.TestActions, invalid_rule 388 | ) 389 | 390 | 391 | def test_validate_rule_data_bool_value_ignored(): 392 | invalid_rule = { 393 | 'conditions': { 394 | 'any': [ 395 | { 396 | 'name': 'bool_variable', 397 | 'operator': 'is_true', 398 | 'value': 'any value here', 399 | } 400 | ] 401 | }, 402 | 'actions': [], 403 | } 404 | utils.validate_rule_data(variables.TestVariables, actions.TestActions, invalid_rule) 405 | 406 | 407 | def test_validate_rule_data_bad_condition_value(): 408 | invalid_rule = { 409 | 'conditions': { 410 | 'any': [{'name': 'string_variable', 'operator': 'equal_to', 'value': 123}] 411 | }, 412 | 'actions': [], 413 | } 414 | with pytest.raises(AssertionError): 415 | utils.validate_rule_data( 416 | variables.TestVariables, actions.TestActions, invalid_rule 417 | ) 418 | 419 | 420 | def test_validate_rule_data_unknown_condition_key(): 421 | invalid_rule = { 422 | 'conditions': { 423 | 'any': [ 424 | { 425 | 'name': 'string_variable', 426 | 'operator': 'equal_to', 427 | 'value': 'test', 428 | 'unknown': 'unknown', 429 | } 430 | ] 431 | }, 432 | 'actions': [], 433 | } 434 | with pytest.raises(AssertionError): 435 | utils.validate_rule_data( 436 | variables.TestVariables, actions.TestActions, invalid_rule 437 | ) 438 | 439 | 440 | def test_validate_rule_multiple_special_keys_in_condition(): 441 | """A rule cannot contain more than one 'any' or 'all' keys""" 442 | invalid_rule = { 443 | 'conditions': { 444 | 'any': [{'name': 'bool_variable', 'operator': 'is_false', 'value': ''}], 445 | 'all': [{'name': 'bool_variable', 'operator': 'is_false', 'value': ''}], 446 | }, 447 | 'actions': [], 448 | } 449 | with pytest.raises(AssertionError): 450 | utils.validate_rule_data( 451 | variables.TestVariables, actions.TestActions, invalid_rule 452 | ) 453 | 454 | 455 | def test_validate_rule_actions_is_not_a_list(): 456 | invalid_rule = { 457 | 'conditions': { 458 | 'any': [{'name': 'bool_variable', 'operator': 'is_false', 'value': ''}] 459 | }, 460 | 'actions': {}, 461 | } 462 | with pytest.raises(AssertionError): 463 | utils.validate_rule_data( 464 | variables.TestVariables, actions.TestActions, invalid_rule 465 | ) 466 | 467 | 468 | def test_validate_rule_contions_is_not_a_dictionary(): 469 | invalid_rule = {'conditions': [], 'actions': {}} 470 | with pytest.raises(AssertionError): 471 | utils.validate_rule_data( 472 | variables.TestVariables, actions.TestActions, invalid_rule 473 | ) 474 | -------------------------------------------------------------------------------- /tests/operators/test_operators.py: -------------------------------------------------------------------------------- 1 | import datetime as datetime_module 2 | import sys 3 | from datetime import date, datetime, time, timedelta 4 | from decimal import Decimal 5 | 6 | from business_rules.operators import ( 7 | BaseType, 8 | BooleanType, 9 | DateTimeType, 10 | NumericType, 11 | SelectMultipleType, 12 | SelectType, 13 | StringType, 14 | TimeType, 15 | ) 16 | from tests import TestCase 17 | 18 | if sys.version_info >= (3, 11): 19 | # Python 3.11+ has datetime.UTC 20 | import datetime as datetime_module 21 | 22 | UTC = datetime_module.UTC 23 | else: 24 | # Python < 3.11, use timezone.utc 25 | UTC = datetime_module.timezone.utc 26 | 27 | 28 | class BaseTypeOperatorTests(TestCase): 29 | def test_base_type_cannot_be_created(self): 30 | with self.assertRaises(NotImplementedError): 31 | BaseType("test") 32 | 33 | 34 | class StringOperatorTests(TestCase): 35 | def test_invalid_value(self): 36 | with self.assertRaises(AssertionError): 37 | StringType(123) 38 | 39 | def test_operator_decorator(self): 40 | self.assertTrue(StringType("foo").equal_to.is_operator) 41 | 42 | def test_string_equal_to(self): 43 | self.assertTrue(StringType("foo").equal_to("foo")) 44 | self.assertFalse(StringType("foo").equal_to("Foo")) 45 | 46 | def test_string_equal_to_case_insensitive(self): 47 | self.assertTrue(StringType("foo").equal_to_case_insensitive("FOo")) 48 | self.assertTrue(StringType("foo").equal_to_case_insensitive("foo")) 49 | self.assertFalse(StringType("foo").equal_to_case_insensitive("blah")) 50 | 51 | def test_string_starts_with(self): 52 | self.assertTrue(StringType("hello").starts_with("he")) 53 | self.assertFalse(StringType("hello").starts_with("hey")) 54 | self.assertFalse(StringType("hello").starts_with("He")) 55 | 56 | def test_string_ends_with(self): 57 | self.assertTrue(StringType("hello").ends_with("lo")) 58 | self.assertFalse(StringType("hello").ends_with("boom")) 59 | self.assertFalse(StringType("hello").ends_with("Lo")) 60 | 61 | def test_string_contains(self): 62 | self.assertTrue(StringType("hello").contains("ell")) 63 | self.assertTrue(StringType("hello").contains("he")) 64 | self.assertTrue(StringType("hello").contains("lo")) 65 | self.assertFalse(StringType("hello").contains("asdf")) 66 | self.assertFalse(StringType("hello").contains("ElL")) 67 | 68 | def test_string_matches_regex(self): 69 | self.assertTrue(StringType("hello").matches_regex(r"^h")) 70 | self.assertFalse(StringType("hello").matches_regex(r"^sh")) 71 | 72 | def test_non_empty(self): 73 | self.assertTrue(StringType("hello").non_empty()) 74 | self.assertFalse(StringType("").non_empty()) 75 | self.assertFalse(StringType(None).non_empty()) 76 | 77 | 78 | class NumericOperatorTests(TestCase): 79 | def test_instantiate(self): 80 | err_string = "foo is not a valid numeric type" 81 | with self.assertRaisesRegex(AssertionError, err_string): 82 | NumericType("foo") 83 | 84 | def test_numeric_type_validates_and_casts_decimal(self): 85 | ten_dec = Decimal(10) 86 | ten_int = 10 87 | ten_float = 10.0 88 | ten_long = 10 # long and int are same in python3 89 | ten_var_dec = NumericType(ten_dec) # this should not throw an exception 90 | ten_var_int = NumericType(ten_int) 91 | ten_var_float = NumericType(ten_float) 92 | ten_var_long = NumericType(ten_long) 93 | self.assertTrue(isinstance(ten_var_dec.value, Decimal)) 94 | self.assertTrue(isinstance(ten_var_int.value, Decimal)) 95 | self.assertTrue(isinstance(ten_var_float.value, Decimal)) 96 | self.assertTrue(isinstance(ten_var_long.value, Decimal)) 97 | 98 | def test_numeric_equal_to(self): 99 | self.assertTrue(NumericType(10).equal_to(10)) 100 | self.assertTrue(NumericType(10).equal_to(10.0)) 101 | self.assertTrue(NumericType(10).equal_to(10.000001)) 102 | self.assertTrue(NumericType(10.000001).equal_to(10)) 103 | self.assertTrue(NumericType(Decimal("10.0")).equal_to(10)) 104 | self.assertTrue(NumericType(10).equal_to(Decimal("10.0"))) 105 | self.assertFalse(NumericType(10).equal_to(10.00001)) 106 | self.assertFalse(NumericType(10).equal_to(11)) 107 | 108 | def test_other_value_not_numeric(self): 109 | error_string = "10 is not a valid numeric type" 110 | with self.assertRaisesRegex(AssertionError, error_string): 111 | NumericType(10).equal_to("10") 112 | 113 | def test_numeric_greater_than(self): 114 | self.assertTrue(NumericType(10).greater_than(1)) 115 | self.assertFalse(NumericType(10).greater_than(11)) 116 | self.assertTrue(NumericType(10.1).greater_than(10)) 117 | self.assertFalse(NumericType(10.000001).greater_than(10)) 118 | self.assertTrue(NumericType(10.000002).greater_than(10)) 119 | 120 | def test_numeric_greater_than_or_equal_to(self): 121 | self.assertTrue(NumericType(10).greater_than_or_equal_to(1)) 122 | self.assertFalse(NumericType(10).greater_than_or_equal_to(11)) 123 | self.assertTrue(NumericType(10.1).greater_than_or_equal_to(10)) 124 | self.assertTrue(NumericType(10.000001).greater_than_or_equal_to(10)) 125 | self.assertTrue(NumericType(10.000002).greater_than_or_equal_to(10)) 126 | self.assertTrue(NumericType(10).greater_than_or_equal_to(10)) 127 | 128 | def test_numeric_less_than(self): 129 | self.assertTrue(NumericType(1).less_than(10)) 130 | self.assertFalse(NumericType(11).less_than(10)) 131 | self.assertTrue(NumericType(10).less_than(10.1)) 132 | self.assertFalse(NumericType(10).less_than(10.000001)) 133 | self.assertTrue(NumericType(10).less_than(10.000002)) 134 | 135 | def test_numeric_less_than_or_equal_to(self): 136 | self.assertTrue(NumericType(1).less_than_or_equal_to(10)) 137 | self.assertFalse(NumericType(11).less_than_or_equal_to(10)) 138 | self.assertTrue(NumericType(10).less_than_or_equal_to(10.1)) 139 | self.assertTrue(NumericType(10).less_than_or_equal_to(10.000001)) 140 | self.assertTrue(NumericType(10).less_than_or_equal_to(10.000002)) 141 | self.assertTrue(NumericType(10).less_than_or_equal_to(10)) 142 | 143 | 144 | class BooleanOperatorTests(TestCase): 145 | def test_instantiate(self): 146 | err_string = "foo is not a valid boolean type" 147 | with self.assertRaisesRegex(AssertionError, err_string): 148 | BooleanType("foo") 149 | err_string = "None is not a valid boolean type" 150 | with self.assertRaisesRegex(AssertionError, err_string): 151 | BooleanType(None) 152 | 153 | def test_boolean_is_true_and_is_false(self): 154 | self.assertTrue(BooleanType(True).is_true()) 155 | self.assertFalse(BooleanType(True).is_false()) 156 | self.assertFalse(BooleanType(False).is_true()) 157 | self.assertTrue(BooleanType(False).is_false()) 158 | 159 | 160 | class SelectOperatorTests(TestCase): 161 | def test_invalid_value(self): 162 | with self.assertRaises(AssertionError): 163 | SelectType(123) 164 | 165 | def test_contains(self): 166 | self.assertTrue(SelectType([1, 2]).contains(2)) 167 | self.assertFalse(SelectType([1, 2]).contains(3)) 168 | self.assertTrue(SelectType([1, 2, "a"]).contains("A")) 169 | 170 | def test_does_not_contain(self): 171 | self.assertTrue(SelectType([1, 2]).does_not_contain(3)) 172 | self.assertFalse(SelectType([1, 2]).does_not_contain(2)) 173 | self.assertFalse(SelectType([1, 2, "a"]).does_not_contain("A")) 174 | 175 | 176 | class SelectMultipleOperatorTests(TestCase): 177 | def test_invalid_value(self): 178 | with self.assertRaises(AssertionError): 179 | SelectMultipleType(123) 180 | 181 | def test_contains_all(self): 182 | self.assertTrue(SelectMultipleType([1, 2]).contains_all([2, 1])) 183 | self.assertFalse(SelectMultipleType([1, 2]).contains_all([2, 3])) 184 | self.assertTrue(SelectMultipleType([1, 2, "a"]).contains_all([2, 1, "A"])) 185 | 186 | def test_is_contained_by(self): 187 | self.assertTrue(SelectMultipleType([1, 2]).is_contained_by([2, 1, 3])) 188 | self.assertFalse(SelectMultipleType([1, 2]).is_contained_by([2, 3, 4])) 189 | self.assertTrue(SelectMultipleType([1, 2, "a"]).is_contained_by([2, 1, "A"])) 190 | 191 | def test_shares_at_least_one_element_with(self): 192 | self.assertTrue( 193 | SelectMultipleType([1, 2]).shares_at_least_one_element_with([2, 3]) 194 | ) 195 | self.assertFalse( 196 | SelectMultipleType([1, 2]).shares_at_least_one_element_with([4, 3]) 197 | ) 198 | self.assertTrue( 199 | SelectMultipleType([1, 2, "a"]).shares_at_least_one_element_with([4, "A"]) 200 | ) 201 | 202 | def test_shares_exactly_one_element_with(self): 203 | self.assertTrue( 204 | SelectMultipleType([1, 2]).shares_exactly_one_element_with([2, 3]) 205 | ) 206 | self.assertFalse( 207 | SelectMultipleType([1, 2]).shares_exactly_one_element_with([4, 3]) 208 | ) 209 | self.assertTrue( 210 | SelectMultipleType([1, 2, "a"]).shares_exactly_one_element_with([4, "A"]) 211 | ) 212 | self.assertFalse( 213 | SelectMultipleType([1, 2, 3]).shares_exactly_one_element_with([2, 3, "a"]) 214 | ) 215 | 216 | def test_shares_no_elements_with(self): 217 | self.assertTrue(SelectMultipleType([1, 2]).shares_no_elements_with([4, 3])) 218 | self.assertFalse(SelectMultipleType([1, 2]).shares_no_elements_with([2, 3])) 219 | self.assertFalse( 220 | SelectMultipleType([1, 2, "a"]).shares_no_elements_with([4, "A"]) 221 | ) 222 | 223 | 224 | class DateTimeOperatorTests(TestCase): 225 | def setUp(self): 226 | super().setUp() 227 | self.TEST_YEAR = 2017 228 | self.TEST_MONTH = 1 229 | self.TEST_DAY = 16 230 | self.TEST_HOUR = 13 231 | self.TEST_MINUTE = 55 232 | self.TEST_SECOND = 25 233 | self.TEST_DATETIME = ( 234 | f"{self.TEST_YEAR}-0{self.TEST_MONTH}-{self.TEST_DAY}" 235 | f"T{self.TEST_HOUR}:{self.TEST_MINUTE}:{self.TEST_SECOND}" 236 | ) 237 | self.TEST_DATE = f"{self.TEST_YEAR}-0{self.TEST_MONTH}-{self.TEST_DAY}" 238 | self.TEST_DATETIME_OBJ = datetime( 239 | self.TEST_YEAR, 240 | self.TEST_MONTH, 241 | self.TEST_DAY, 242 | self.TEST_HOUR, 243 | self.TEST_MINUTE, 244 | self.TEST_SECOND, 245 | ) 246 | self.TEST_DATE_OBJ = date(self.TEST_YEAR, self.TEST_MONTH, self.TEST_DAY) 247 | 248 | self.TEST_DATETIME_UTC_OBJ = datetime( 249 | self.TEST_YEAR, 250 | self.TEST_MONTH, 251 | self.TEST_DAY, 252 | self.TEST_HOUR, 253 | self.TEST_MINUTE, 254 | self.TEST_SECOND, 255 | tzinfo=UTC, 256 | ) 257 | 258 | self.datetime_type_date = DateTimeType(self.TEST_DATE) 259 | self.datetime_type_datetime = DateTimeType(self.TEST_DATETIME) 260 | self.datetime_type_datetime_obj = DateTimeType(self.TEST_DATETIME_OBJ) 261 | self.datetime_type_datetime_utc_obj = DateTimeType(self.TEST_DATETIME_UTC_OBJ) 262 | 263 | def test_instantiate(self): 264 | err_string = "foo is not a valid datetime type" 265 | with self.assertRaisesRegex(AssertionError, err_string): 266 | DateTimeType("foo") 267 | 268 | def test_datetime_type_validates_and_cast_datetime(self): 269 | result = DateTimeType(self.TEST_DATETIME) 270 | self.assertTrue(isinstance(result.value, datetime)) 271 | 272 | result = DateTimeType(self.TEST_DATE) 273 | self.assertTrue(isinstance(result.value, datetime)) 274 | 275 | result = DateTimeType(self.TEST_DATETIME_OBJ) 276 | self.assertTrue(isinstance(result.value, datetime)) 277 | 278 | result = DateTimeType(self.TEST_DATE_OBJ) 279 | self.assertTrue(isinstance(result.value, datetime)) 280 | 281 | def test_datetime_equal_to(self): 282 | self.assertTrue(self.datetime_type_datetime.equal_to(self.TEST_DATETIME)) 283 | self.assertTrue(self.datetime_type_datetime.equal_to(self.TEST_DATETIME_OBJ)) 284 | self.assertTrue( 285 | self.datetime_type_datetime.equal_to(self.TEST_DATETIME_UTC_OBJ) 286 | ) 287 | 288 | self.assertTrue(self.datetime_type_datetime_obj.equal_to(self.TEST_DATETIME)) 289 | self.assertTrue( 290 | self.datetime_type_datetime_obj.equal_to(self.TEST_DATETIME_OBJ) 291 | ) 292 | self.assertTrue( 293 | self.datetime_type_datetime_obj.equal_to(self.TEST_DATETIME_UTC_OBJ) 294 | ) 295 | 296 | self.assertTrue( 297 | self.datetime_type_datetime_utc_obj.equal_to(self.TEST_DATETIME) 298 | ) 299 | self.assertTrue( 300 | self.datetime_type_datetime_utc_obj.equal_to(self.TEST_DATETIME_OBJ) 301 | ) 302 | self.assertTrue( 303 | self.datetime_type_datetime_utc_obj.equal_to(self.TEST_DATETIME_UTC_OBJ) 304 | ) 305 | 306 | self.assertTrue(self.datetime_type_date.equal_to(self.TEST_DATE)) 307 | self.assertTrue(self.datetime_type_date.equal_to(self.TEST_DATE_OBJ)) 308 | 309 | def test_other_value_not_datetime(self): 310 | error_string = "2016-10 is not a valid datetime type" 311 | with self.assertRaisesRegex(AssertionError, error_string): 312 | DateTimeType(self.TEST_DATE).equal_to("2016-10") 313 | 314 | def datetime_after_than_asserts(self, datetime_type): 315 | # type: (DateTimeType) -> None 316 | self.assertFalse(datetime_type.after_than(self.TEST_DATETIME)) 317 | self.assertFalse(datetime_type.after_than(self.TEST_DATETIME_OBJ)) 318 | self.assertFalse(datetime_type.after_than(self.TEST_DATETIME_UTC_OBJ)) 319 | self.assertTrue( 320 | datetime_type.after_than(self.TEST_DATETIME_OBJ - timedelta(seconds=1)) 321 | ) 322 | self.assertTrue( 323 | datetime_type.after_than(self.TEST_DATETIME_UTC_OBJ - timedelta(seconds=1)) 324 | ) 325 | self.assertFalse( 326 | datetime_type.after_than(self.TEST_DATETIME_OBJ + timedelta(seconds=1)) 327 | ) 328 | self.assertFalse( 329 | datetime_type.after_than(self.TEST_DATETIME_UTC_OBJ + timedelta(seconds=1)) 330 | ) 331 | 332 | def test_datetime_after_than(self): 333 | self.datetime_after_than_asserts(self.datetime_type_datetime) 334 | self.datetime_after_than_asserts(self.datetime_type_datetime_obj) 335 | self.datetime_after_than_asserts(self.datetime_type_datetime_utc_obj) 336 | 337 | self.assertFalse(self.datetime_type_date.after_than(self.TEST_DATE)) 338 | self.assertFalse( 339 | self.datetime_type_date.after_than( 340 | self.TEST_DATETIME_OBJ + timedelta(seconds=1) 341 | ) 342 | ) 343 | self.assertFalse( 344 | self.datetime_type_date.after_than( 345 | self.TEST_DATETIME_UTC_OBJ + timedelta(seconds=1) 346 | ) 347 | ) 348 | 349 | def datetime_after_than_or_equal_to_asserts(self, datetime_type): 350 | # type: (DateTimeType) -> None 351 | self.assertTrue(datetime_type.after_than_or_equal_to(self.TEST_DATETIME)) 352 | self.assertTrue(datetime_type.after_than_or_equal_to(self.TEST_DATETIME_OBJ)) 353 | self.assertTrue( 354 | datetime_type.after_than_or_equal_to(self.TEST_DATETIME_UTC_OBJ) 355 | ) 356 | self.assertTrue( 357 | datetime_type.after_than_or_equal_to( 358 | self.TEST_DATETIME_OBJ - timedelta(seconds=1) 359 | ) 360 | ) 361 | self.assertFalse( 362 | datetime_type.after_than_or_equal_to( 363 | self.TEST_DATETIME_OBJ + timedelta(seconds=1) 364 | ) 365 | ) 366 | self.assertTrue( 367 | datetime_type.after_than_or_equal_to( 368 | self.TEST_DATETIME_UTC_OBJ - timedelta(seconds=1) 369 | ) 370 | ) 371 | self.assertFalse( 372 | datetime_type.after_than_or_equal_to( 373 | self.TEST_DATETIME_UTC_OBJ + timedelta(seconds=1) 374 | ) 375 | ) 376 | 377 | def test_datetime_after_than_or_equal_to(self): 378 | self.assertTrue(self.datetime_type_date.after_than_or_equal_to(self.TEST_DATE)) 379 | 380 | self.datetime_after_than_or_equal_to_asserts(self.datetime_type_datetime) 381 | self.datetime_after_than_or_equal_to_asserts(self.datetime_type_datetime_obj) 382 | self.datetime_after_than_or_equal_to_asserts( 383 | self.datetime_type_datetime_utc_obj 384 | ) 385 | 386 | def datetime_before_than_asserts(self, datetime_type): 387 | # type: (DateTimeType) -> None 388 | self.assertFalse(datetime_type.before_than(self.TEST_DATETIME)) 389 | self.assertFalse(datetime_type.before_than(self.TEST_DATETIME_OBJ)) 390 | self.assertFalse(datetime_type.before_than(self.TEST_DATETIME_UTC_OBJ)) 391 | self.assertFalse( 392 | datetime_type.before_than(self.TEST_DATETIME_OBJ - timedelta(seconds=1)) 393 | ) 394 | self.assertFalse( 395 | datetime_type.before_than(self.TEST_DATETIME_UTC_OBJ - timedelta(seconds=1)) 396 | ) 397 | self.assertTrue( 398 | datetime_type.before_than(self.TEST_DATETIME_OBJ + timedelta(seconds=1)) 399 | ) 400 | self.assertTrue( 401 | datetime_type.before_than(self.TEST_DATETIME_UTC_OBJ + timedelta(seconds=1)) 402 | ) 403 | 404 | def test_datetime_before_than(self): 405 | self.datetime_before_than_asserts(self.datetime_type_datetime) 406 | self.datetime_before_than_asserts(self.datetime_type_datetime_obj) 407 | self.datetime_before_than_asserts(self.datetime_type_datetime_utc_obj) 408 | 409 | self.assertFalse(self.datetime_type_date.before_than(self.TEST_DATE)) 410 | self.assertTrue( 411 | self.datetime_type_date.before_than( 412 | self.TEST_DATETIME_OBJ + timedelta(seconds=1) 413 | ) 414 | ) 415 | self.assertTrue( 416 | self.datetime_type_date.before_than( 417 | self.TEST_DATETIME_UTC_OBJ + timedelta(seconds=1) 418 | ) 419 | ) 420 | 421 | def datetime_before_than_or_equal_to_asserts(self, datetime_type): 422 | # type: (DateTimeType) -> None 423 | self.assertTrue(datetime_type.before_than_or_equal_to(self.TEST_DATETIME)) 424 | self.assertTrue(datetime_type.before_than_or_equal_to(self.TEST_DATETIME_OBJ)) 425 | self.assertTrue( 426 | datetime_type.before_than_or_equal_to(self.TEST_DATETIME_UTC_OBJ) 427 | ) 428 | self.assertFalse( 429 | datetime_type.before_than_or_equal_to( 430 | self.TEST_DATETIME_OBJ - timedelta(seconds=1) 431 | ) 432 | ) 433 | self.assertFalse( 434 | datetime_type.before_than_or_equal_to( 435 | self.TEST_DATETIME_UTC_OBJ - timedelta(seconds=1) 436 | ) 437 | ) 438 | self.assertTrue( 439 | datetime_type.before_than_or_equal_to( 440 | self.TEST_DATETIME_OBJ + timedelta(seconds=1) 441 | ) 442 | ) 443 | self.assertTrue( 444 | datetime_type.before_than_or_equal_to( 445 | self.TEST_DATETIME_UTC_OBJ + timedelta(seconds=1) 446 | ) 447 | ) 448 | 449 | def test_datetime_before_than_or_equal_to(self): 450 | self.datetime_before_than_or_equal_to_asserts(self.datetime_type_datetime) 451 | self.datetime_before_than_or_equal_to_asserts(self.datetime_type_datetime_obj) 452 | self.datetime_before_than_or_equal_to_asserts( 453 | self.datetime_type_datetime_utc_obj 454 | ) 455 | 456 | self.assertTrue(self.datetime_type_date.before_than_or_equal_to(self.TEST_DATE)) 457 | self.assertTrue( 458 | self.datetime_type_date.before_than_or_equal_to( 459 | self.TEST_DATETIME_OBJ + timedelta(seconds=1) 460 | ) 461 | ) 462 | self.assertTrue( 463 | self.datetime_type_date.before_than_or_equal_to( 464 | self.TEST_DATETIME_UTC_OBJ + timedelta(seconds=1) 465 | ) 466 | ) 467 | 468 | 469 | class TimeOperatorTests(TestCase): 470 | def setUp(self): 471 | super().setUp() 472 | self.TEST_HOUR = 13 473 | self.TEST_MINUTE = 55 474 | self.TEST_SECOND = 00 475 | self.TEST_TIME = f"{self.TEST_HOUR}:{self.TEST_MINUTE}:{self.TEST_SECOND}" 476 | self.TEST_TIME_NO_SECONDS = f"{self.TEST_HOUR}:{self.TEST_MINUTE}" 477 | self.TEST_TIME_OBJ = time(self.TEST_HOUR, self.TEST_MINUTE, self.TEST_SECOND) 478 | 479 | self.time_type_time = TimeType(self.TEST_TIME) 480 | self.time_type_time_no_seconds = TimeType(self.TEST_TIME_NO_SECONDS) 481 | self.time_type_time_obj = TimeType(self.TEST_TIME_OBJ) 482 | 483 | def test_instantiate(self): 484 | err_string = "foo is not a valid time type" 485 | with self.assertRaisesRegex(AssertionError, err_string): 486 | TimeType("foo") 487 | 488 | def test_time_type_validates_and_cast_time(self): 489 | result = TimeType(self.TEST_TIME) 490 | self.assertTrue(isinstance(result.value, time)) 491 | 492 | result = TimeType(self.TEST_TIME_NO_SECONDS) 493 | self.assertTrue(isinstance(result.value, time)) 494 | 495 | result = TimeType(self.TEST_TIME_OBJ) 496 | self.assertTrue(isinstance(result.value, time)) 497 | 498 | def test_time_equal_to(self): 499 | self.assertTrue(self.time_type_time_no_seconds.equal_to(self.TEST_TIME)) 500 | self.assertTrue(self.time_type_time_no_seconds.equal_to(self.TEST_TIME_OBJ)) 501 | 502 | self.assertTrue(self.time_type_time_obj.equal_to(self.TEST_TIME)) 503 | self.assertTrue(self.time_type_time_obj.equal_to(self.TEST_TIME_OBJ)) 504 | 505 | self.assertTrue(self.time_type_time.equal_to(self.TEST_TIME_NO_SECONDS)) 506 | 507 | def test_other_value_not_time(self): 508 | error_string = "2016-10 is not a valid time type" 509 | with self.assertRaisesRegex(AssertionError, error_string): 510 | TimeType(self.TEST_TIME_NO_SECONDS).equal_to("2016-10") 511 | 512 | def time_after_than_asserts(self, time_type): 513 | # type: (TimeType) -> None 514 | self.assertFalse(time_type.after_than(self.TEST_TIME)) 515 | self.assertFalse(time_type.after_than(self.TEST_TIME_OBJ)) 516 | 517 | test_time = time(self.TEST_TIME_OBJ.hour, self.TEST_TIME_OBJ.minute - 1, 59) 518 | self.assertTrue(time_type.after_than(test_time)) 519 | 520 | test_time = time( 521 | self.TEST_TIME_OBJ.hour, 522 | self.TEST_TIME_OBJ.minute, 523 | self.TEST_TIME_OBJ.second + 1, 524 | ) 525 | self.assertFalse(time_type.after_than(test_time)) 526 | 527 | def test_time_after_than(self): 528 | self.time_after_than_asserts(self.time_type_time_no_seconds) 529 | self.time_after_than_asserts(self.time_type_time_obj) 530 | 531 | self.assertFalse(self.time_type_time.after_than(self.TEST_TIME_NO_SECONDS)) 532 | 533 | test_time = time( 534 | self.TEST_TIME_OBJ.hour, 535 | self.TEST_TIME_OBJ.minute, 536 | self.TEST_TIME_OBJ.second + 1, 537 | ) 538 | self.assertFalse(self.time_type_time.after_than(test_time)) 539 | 540 | def time_after_than_or_equal_to_asserts(self, time_type): 541 | # type: (TimeType) -> None 542 | self.assertTrue(time_type.after_than_or_equal_to(self.TEST_TIME)) 543 | self.assertTrue(time_type.after_than_or_equal_to(self.TEST_TIME_OBJ)) 544 | 545 | test_time = time(self.TEST_TIME_OBJ.hour, self.TEST_TIME_OBJ.minute - 1, 59) 546 | self.assertTrue(time_type.after_than_or_equal_to(test_time)) 547 | 548 | test_time = time( 549 | self.TEST_TIME_OBJ.hour, 550 | self.TEST_TIME_OBJ.minute, 551 | self.TEST_TIME_OBJ.second + 1, 552 | ) 553 | self.assertFalse(time_type.after_than_or_equal_to(test_time)) 554 | 555 | def test_time_after_than_or_equal_to(self): 556 | self.assertTrue( 557 | self.time_type_time.after_than_or_equal_to(self.TEST_TIME_NO_SECONDS) 558 | ) 559 | 560 | self.time_after_than_or_equal_to_asserts(self.time_type_time_no_seconds) 561 | self.time_after_than_or_equal_to_asserts(self.time_type_time_obj) 562 | 563 | def time_before_than_asserts(self, time_type): 564 | # type: (TimeType) -> None 565 | self.assertFalse(time_type.before_than(self.TEST_TIME)) 566 | self.assertFalse(time_type.before_than(self.TEST_TIME_OBJ)) 567 | 568 | test_time = time(self.TEST_TIME_OBJ.hour, self.TEST_TIME_OBJ.minute - 1, 59) 569 | self.assertFalse(time_type.before_than(test_time)) 570 | 571 | test_time = time( 572 | self.TEST_TIME_OBJ.hour, 573 | self.TEST_TIME_OBJ.minute, 574 | self.TEST_TIME_OBJ.second + 1, 575 | ) 576 | self.assertTrue(time_type.before_than(test_time)) 577 | 578 | def test_time_before_than(self): 579 | self.time_before_than_asserts(self.time_type_time_no_seconds) 580 | self.time_before_than_asserts(self.time_type_time_obj) 581 | 582 | self.assertFalse(self.time_type_time.before_than(self.TEST_TIME_NO_SECONDS)) 583 | 584 | test_time = time( 585 | self.TEST_TIME_OBJ.hour, 586 | self.TEST_TIME_OBJ.minute, 587 | self.TEST_TIME_OBJ.second + 1, 588 | ) 589 | self.assertTrue(self.time_type_time.before_than(test_time)) 590 | 591 | def time_before_than_or_equal_to_asserts(self, time_type): 592 | # type: (TimeType) -> None 593 | self.assertTrue(time_type.before_than_or_equal_to(self.TEST_TIME)) 594 | self.assertTrue(time_type.before_than_or_equal_to(self.TEST_TIME_OBJ)) 595 | 596 | test_time = time(self.TEST_TIME_OBJ.hour, self.TEST_TIME_OBJ.minute - 1, 59) 597 | self.assertFalse(time_type.before_than_or_equal_to(test_time)) 598 | 599 | test_time = time( 600 | self.TEST_TIME_OBJ.hour, 601 | self.TEST_TIME_OBJ.minute, 602 | self.TEST_TIME_OBJ.second + 1, 603 | ) 604 | self.assertTrue(time_type.before_than_or_equal_to(test_time)) 605 | 606 | def test_time_before_than_or_equal_to(self): 607 | self.time_before_than_or_equal_to_asserts(self.time_type_time_no_seconds) 608 | self.time_before_than_or_equal_to_asserts(self.time_type_time_obj) 609 | 610 | self.assertTrue( 611 | self.time_type_time.before_than_or_equal_to(self.TEST_TIME_NO_SECONDS) 612 | ) 613 | 614 | test_time = time( 615 | self.TEST_TIME_OBJ.hour, 616 | self.TEST_TIME_OBJ.minute, 617 | self.TEST_TIME_OBJ.second + 1, 618 | ) 619 | self.assertTrue(self.time_type_time.before_than_or_equal_to(test_time)) 620 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand. 2 | 3 | [[package]] 4 | name = "cachetools" 5 | version = "5.3.0" 6 | description = "Extensible memoizing collections and decorators" 7 | optional = false 8 | python-versions = "~=3.7" 9 | groups = ["dev"] 10 | files = [ 11 | {file = "cachetools-5.3.0-py3-none-any.whl", hash = "sha256:429e1a1e845c008ea6c85aa35d4b98b65d6a9763eeef3e37e92728a12d1de9d4"}, 12 | {file = "cachetools-5.3.0.tar.gz", hash = "sha256:13dfddc7b8df938c21a940dfa6557ce6e94a2f1cdfa58eb90c805721d58f2c14"}, 13 | ] 14 | 15 | [[package]] 16 | name = "chardet" 17 | version = "5.1.0" 18 | description = "Universal encoding detector for Python 3" 19 | optional = false 20 | python-versions = ">=3.7" 21 | groups = ["dev"] 22 | files = [ 23 | {file = "chardet-5.1.0-py3-none-any.whl", hash = "sha256:362777fb014af596ad31334fde1e8c327dfdb076e1960d1694662d46a6917ab9"}, 24 | {file = "chardet-5.1.0.tar.gz", hash = "sha256:0d62712b956bc154f85fb0a266e2a3c5913c2967e00348701b32411d6def31e5"}, 25 | ] 26 | 27 | [[package]] 28 | name = "colorama" 29 | version = "0.4.6" 30 | description = "Cross-platform colored terminal text." 31 | optional = false 32 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" 33 | groups = ["dev"] 34 | files = [ 35 | {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, 36 | {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, 37 | ] 38 | 39 | [[package]] 40 | name = "coverage" 41 | version = "7.10.6" 42 | description = "Code coverage measurement for Python" 43 | optional = false 44 | python-versions = ">=3.9" 45 | groups = ["dev"] 46 | files = [ 47 | {file = "coverage-7.10.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:70e7bfbd57126b5554aa482691145f798d7df77489a177a6bef80de78860a356"}, 48 | {file = "coverage-7.10.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e41be6f0f19da64af13403e52f2dec38bbc2937af54df8ecef10850ff8d35301"}, 49 | {file = "coverage-7.10.6-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c61fc91ab80b23f5fddbee342d19662f3d3328173229caded831aa0bd7595460"}, 50 | {file = "coverage-7.10.6-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10356fdd33a7cc06e8051413140bbdc6f972137508a3572e3f59f805cd2832fd"}, 51 | {file = "coverage-7.10.6-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:80b1695cf7c5ebe7b44bf2521221b9bb8cdf69b1f24231149a7e3eb1ae5fa2fb"}, 52 | {file = "coverage-7.10.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2e4c33e6378b9d52d3454bd08847a8651f4ed23ddbb4a0520227bd346382bbc6"}, 53 | {file = "coverage-7.10.6-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c8a3ec16e34ef980a46f60dc6ad86ec60f763c3f2fa0db6d261e6e754f72e945"}, 54 | {file = "coverage-7.10.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7d79dabc0a56f5af990cc6da9ad1e40766e82773c075f09cc571e2076fef882e"}, 55 | {file = "coverage-7.10.6-cp310-cp310-win32.whl", hash = "sha256:86b9b59f2b16e981906e9d6383eb6446d5b46c278460ae2c36487667717eccf1"}, 56 | {file = "coverage-7.10.6-cp310-cp310-win_amd64.whl", hash = "sha256:e132b9152749bd33534e5bd8565c7576f135f157b4029b975e15ee184325f528"}, 57 | {file = "coverage-7.10.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c706db3cabb7ceef779de68270150665e710b46d56372455cd741184f3868d8f"}, 58 | {file = "coverage-7.10.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8e0c38dc289e0508ef68ec95834cb5d2e96fdbe792eaccaa1bccac3966bbadcc"}, 59 | {file = "coverage-7.10.6-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:752a3005a1ded28f2f3a6e8787e24f28d6abe176ca64677bcd8d53d6fe2ec08a"}, 60 | {file = "coverage-7.10.6-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:689920ecfd60f992cafca4f5477d55720466ad2c7fa29bb56ac8d44a1ac2b47a"}, 61 | {file = "coverage-7.10.6-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec98435796d2624d6905820a42f82149ee9fc4f2d45c2c5bc5a44481cc50db62"}, 62 | {file = "coverage-7.10.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b37201ce4a458c7a758ecc4efa92fa8ed783c66e0fa3c42ae19fc454a0792153"}, 63 | {file = "coverage-7.10.6-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:2904271c80898663c810a6b067920a61dd8d38341244a3605bd31ab55250dad5"}, 64 | {file = "coverage-7.10.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5aea98383463d6e1fa4e95416d8de66f2d0cb588774ee20ae1b28df826bcb619"}, 65 | {file = "coverage-7.10.6-cp311-cp311-win32.whl", hash = "sha256:e3fb1fa01d3598002777dd259c0c2e6d9d5e10e7222976fc8e03992f972a2cba"}, 66 | {file = "coverage-7.10.6-cp311-cp311-win_amd64.whl", hash = "sha256:f35ed9d945bece26553d5b4c8630453169672bea0050a564456eb88bdffd927e"}, 67 | {file = "coverage-7.10.6-cp311-cp311-win_arm64.whl", hash = "sha256:99e1a305c7765631d74b98bf7dbf54eeea931f975e80f115437d23848ee8c27c"}, 68 | {file = "coverage-7.10.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5b2dd6059938063a2c9fee1af729d4f2af28fd1a545e9b7652861f0d752ebcea"}, 69 | {file = "coverage-7.10.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:388d80e56191bf846c485c14ae2bc8898aa3124d9d35903fef7d907780477634"}, 70 | {file = "coverage-7.10.6-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:90cb5b1a4670662719591aa92d0095bb41714970c0b065b02a2610172dbf0af6"}, 71 | {file = "coverage-7.10.6-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:961834e2f2b863a0e14260a9a273aff07ff7818ab6e66d2addf5628590c628f9"}, 72 | {file = "coverage-7.10.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf9a19f5012dab774628491659646335b1928cfc931bf8d97b0d5918dd58033c"}, 73 | {file = "coverage-7.10.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:99c4283e2a0e147b9c9cc6bc9c96124de9419d6044837e9799763a0e29a7321a"}, 74 | {file = "coverage-7.10.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:282b1b20f45df57cc508c1e033403f02283adfb67d4c9c35a90281d81e5c52c5"}, 75 | {file = "coverage-7.10.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8cdbe264f11afd69841bd8c0d83ca10b5b32853263ee62e6ac6a0ab63895f972"}, 76 | {file = "coverage-7.10.6-cp312-cp312-win32.whl", hash = "sha256:a517feaf3a0a3eca1ee985d8373135cfdedfbba3882a5eab4362bda7c7cf518d"}, 77 | {file = "coverage-7.10.6-cp312-cp312-win_amd64.whl", hash = "sha256:856986eadf41f52b214176d894a7de05331117f6035a28ac0016c0f63d887629"}, 78 | {file = "coverage-7.10.6-cp312-cp312-win_arm64.whl", hash = "sha256:acf36b8268785aad739443fa2780c16260ee3fa09d12b3a70f772ef100939d80"}, 79 | {file = "coverage-7.10.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ffea0575345e9ee0144dfe5701aa17f3ba546f8c3bb48db62ae101afb740e7d6"}, 80 | {file = "coverage-7.10.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:95d91d7317cde40a1c249d6b7382750b7e6d86fad9d8eaf4fa3f8f44cf171e80"}, 81 | {file = "coverage-7.10.6-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3e23dd5408fe71a356b41baa82892772a4cefcf758f2ca3383d2aa39e1b7a003"}, 82 | {file = "coverage-7.10.6-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0f3f56e4cb573755e96a16501a98bf211f100463d70275759e73f3cbc00d4f27"}, 83 | {file = "coverage-7.10.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:db4a1d897bbbe7339946ffa2fe60c10cc81c43fab8b062d3fcb84188688174a4"}, 84 | {file = "coverage-7.10.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d8fd7879082953c156d5b13c74aa6cca37f6a6f4747b39538504c3f9c63d043d"}, 85 | {file = "coverage-7.10.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:28395ca3f71cd103b8c116333fa9db867f3a3e1ad6a084aa3725ae002b6583bc"}, 86 | {file = "coverage-7.10.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:61c950fc33d29c91b9e18540e1aed7d9f6787cc870a3e4032493bbbe641d12fc"}, 87 | {file = "coverage-7.10.6-cp313-cp313-win32.whl", hash = "sha256:160c00a5e6b6bdf4e5984b0ef21fc860bc94416c41b7df4d63f536d17c38902e"}, 88 | {file = "coverage-7.10.6-cp313-cp313-win_amd64.whl", hash = "sha256:628055297f3e2aa181464c3808402887643405573eb3d9de060d81531fa79d32"}, 89 | {file = "coverage-7.10.6-cp313-cp313-win_arm64.whl", hash = "sha256:df4ec1f8540b0bcbe26ca7dd0f541847cc8a108b35596f9f91f59f0c060bfdd2"}, 90 | {file = "coverage-7.10.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:c9a8b7a34a4de3ed987f636f71881cd3b8339f61118b1aa311fbda12741bff0b"}, 91 | {file = "coverage-7.10.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8dd5af36092430c2b075cee966719898f2ae87b636cefb85a653f1d0ba5d5393"}, 92 | {file = "coverage-7.10.6-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b0353b0f0850d49ada66fdd7d0c7cdb0f86b900bb9e367024fd14a60cecc1e27"}, 93 | {file = "coverage-7.10.6-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d6b9ae13d5d3e8aeca9ca94198aa7b3ebbc5acfada557d724f2a1f03d2c0b0df"}, 94 | {file = "coverage-7.10.6-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:675824a363cc05781b1527b39dc2587b8984965834a748177ee3c37b64ffeafb"}, 95 | {file = "coverage-7.10.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:692d70ea725f471a547c305f0d0fc6a73480c62fb0da726370c088ab21aed282"}, 96 | {file = "coverage-7.10.6-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:851430a9a361c7a8484a36126d1d0ff8d529d97385eacc8dfdc9bfc8c2d2cbe4"}, 97 | {file = "coverage-7.10.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d9369a23186d189b2fc95cc08b8160ba242057e887d766864f7adf3c46b2df21"}, 98 | {file = "coverage-7.10.6-cp313-cp313t-win32.whl", hash = "sha256:92be86fcb125e9bda0da7806afd29a3fd33fdf58fba5d60318399adf40bf37d0"}, 99 | {file = "coverage-7.10.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6b3039e2ca459a70c79523d39347d83b73f2f06af5624905eba7ec34d64d80b5"}, 100 | {file = "coverage-7.10.6-cp313-cp313t-win_arm64.whl", hash = "sha256:3fb99d0786fe17b228eab663d16bee2288e8724d26a199c29325aac4b0319b9b"}, 101 | {file = "coverage-7.10.6-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6008a021907be8c4c02f37cdc3ffb258493bdebfeaf9a839f9e71dfdc47b018e"}, 102 | {file = "coverage-7.10.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5e75e37f23eb144e78940b40395b42f2321951206a4f50e23cfd6e8a198d3ceb"}, 103 | {file = "coverage-7.10.6-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0f7cb359a448e043c576f0da00aa8bfd796a01b06aa610ca453d4dde09cc1034"}, 104 | {file = "coverage-7.10.6-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c68018e4fc4e14b5668f1353b41ccf4bc83ba355f0e1b3836861c6f042d89ac1"}, 105 | {file = "coverage-7.10.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cd4b2b0707fc55afa160cd5fc33b27ccbf75ca11d81f4ec9863d5793fc6df56a"}, 106 | {file = "coverage-7.10.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4cec13817a651f8804a86e4f79d815b3b28472c910e099e4d5a0e8a3b6a1d4cb"}, 107 | {file = "coverage-7.10.6-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f2a6a8e06bbda06f78739f40bfb56c45d14eb8249d0f0ea6d4b3d48e1f7c695d"}, 108 | {file = "coverage-7.10.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:081b98395ced0d9bcf60ada7661a0b75f36b78b9d7e39ea0790bb4ed8da14747"}, 109 | {file = "coverage-7.10.6-cp314-cp314-win32.whl", hash = "sha256:6937347c5d7d069ee776b2bf4e1212f912a9f1f141a429c475e6089462fcecc5"}, 110 | {file = "coverage-7.10.6-cp314-cp314-win_amd64.whl", hash = "sha256:adec1d980fa07e60b6ef865f9e5410ba760e4e1d26f60f7e5772c73b9a5b0713"}, 111 | {file = "coverage-7.10.6-cp314-cp314-win_arm64.whl", hash = "sha256:a80f7aef9535442bdcf562e5a0d5a5538ce8abe6bb209cfbf170c462ac2c2a32"}, 112 | {file = "coverage-7.10.6-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:0de434f4fbbe5af4fa7989521c655c8c779afb61c53ab561b64dcee6149e4c65"}, 113 | {file = "coverage-7.10.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6e31b8155150c57e5ac43ccd289d079eb3f825187d7c66e755a055d2c85794c6"}, 114 | {file = "coverage-7.10.6-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:98cede73eb83c31e2118ae8d379c12e3e42736903a8afcca92a7218e1f2903b0"}, 115 | {file = "coverage-7.10.6-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f863c08f4ff6b64fa8045b1e3da480f5374779ef187f07b82e0538c68cb4ff8e"}, 116 | {file = "coverage-7.10.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b38261034fda87be356f2c3f42221fdb4171c3ce7658066ae449241485390d5"}, 117 | {file = "coverage-7.10.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e93b1476b79eae849dc3872faeb0bf7948fd9ea34869590bc16a2a00b9c82a7"}, 118 | {file = "coverage-7.10.6-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ff8a991f70f4c0cf53088abf1e3886edcc87d53004c7bb94e78650b4d3dac3b5"}, 119 | {file = "coverage-7.10.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ac765b026c9f33044419cbba1da913cfb82cca1b60598ac1c7a5ed6aac4621a0"}, 120 | {file = "coverage-7.10.6-cp314-cp314t-win32.whl", hash = "sha256:441c357d55f4936875636ef2cfb3bee36e466dcf50df9afbd398ce79dba1ebb7"}, 121 | {file = "coverage-7.10.6-cp314-cp314t-win_amd64.whl", hash = "sha256:073711de3181b2e204e4870ac83a7c4853115b42e9cd4d145f2231e12d670930"}, 122 | {file = "coverage-7.10.6-cp314-cp314t-win_arm64.whl", hash = "sha256:137921f2bac5559334ba66122b753db6dc5d1cf01eb7b64eb412bb0d064ef35b"}, 123 | {file = "coverage-7.10.6-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:90558c35af64971d65fbd935c32010f9a2f52776103a259f1dee865fe8259352"}, 124 | {file = "coverage-7.10.6-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8953746d371e5695405806c46d705a3cd170b9cc2b9f93953ad838f6c1e58612"}, 125 | {file = "coverage-7.10.6-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c83f6afb480eae0313114297d29d7c295670a41c11b274e6bca0c64540c1ce7b"}, 126 | {file = "coverage-7.10.6-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7eb68d356ba0cc158ca535ce1381dbf2037fa8cb5b1ae5ddfc302e7317d04144"}, 127 | {file = "coverage-7.10.6-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5b15a87265e96307482746d86995f4bff282f14b027db75469c446da6127433b"}, 128 | {file = "coverage-7.10.6-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fc53ba868875bfbb66ee447d64d6413c2db91fddcfca57025a0e7ab5b07d5862"}, 129 | {file = "coverage-7.10.6-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:efeda443000aa23f276f4df973cb82beca682fd800bb119d19e80504ffe53ec2"}, 130 | {file = "coverage-7.10.6-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:9702b59d582ff1e184945d8b501ffdd08d2cee38d93a2206aa5f1365ce0b8d78"}, 131 | {file = "coverage-7.10.6-cp39-cp39-win32.whl", hash = "sha256:2195f8e16ba1a44651ca684db2ea2b2d4b5345da12f07d9c22a395202a05b23c"}, 132 | {file = "coverage-7.10.6-cp39-cp39-win_amd64.whl", hash = "sha256:f32ff80e7ef6a5b5b606ea69a36e97b219cd9dc799bcf2963018a4d8f788cfbf"}, 133 | {file = "coverage-7.10.6-py3-none-any.whl", hash = "sha256:92c4ecf6bf11b2e85fd4d8204814dc26e6a19f0c9d938c207c5cb0eadfcabbe3"}, 134 | {file = "coverage-7.10.6.tar.gz", hash = "sha256:f644a3ae5933a552a29dbb9aa2f90c677a875f80ebea028e5a52a4f429044b90"}, 135 | ] 136 | 137 | [package.dependencies] 138 | tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} 139 | 140 | [package.extras] 141 | toml = ["tomli ; python_full_version <= \"3.11.0a6\""] 142 | 143 | [[package]] 144 | name = "distlib" 145 | version = "0.3.6" 146 | description = "Distribution utilities" 147 | optional = false 148 | python-versions = "*" 149 | groups = ["dev"] 150 | files = [ 151 | {file = "distlib-0.3.6-py2.py3-none-any.whl", hash = "sha256:f35c4b692542ca110de7ef0bea44d73981caeb34ca0b9b6b2e6d7790dda8f80e"}, 152 | {file = "distlib-0.3.6.tar.gz", hash = "sha256:14bad2d9b04d3a36127ac97f30b12a19268f211063d8f8ee4f47108896e11b46"}, 153 | ] 154 | 155 | [[package]] 156 | name = "exceptiongroup" 157 | version = "1.1.1" 158 | description = "Backport of PEP 654 (exception groups)" 159 | optional = false 160 | python-versions = ">=3.7" 161 | groups = ["dev"] 162 | markers = "python_version < \"3.11\"" 163 | files = [ 164 | {file = "exceptiongroup-1.1.1-py3-none-any.whl", hash = "sha256:232c37c63e4f682982c8b6459f33a8981039e5fb8756b2074364e5055c498c9e"}, 165 | {file = "exceptiongroup-1.1.1.tar.gz", hash = "sha256:d484c3090ba2889ae2928419117447a14daf3c1231d5e30d0aae34f354f01785"}, 166 | ] 167 | 168 | [package.extras] 169 | test = ["pytest (>=6)"] 170 | 171 | [[package]] 172 | name = "filelock" 173 | version = "3.12.0" 174 | description = "A platform independent file lock." 175 | optional = false 176 | python-versions = ">=3.7" 177 | groups = ["dev"] 178 | files = [ 179 | {file = "filelock-3.12.0-py3-none-any.whl", hash = "sha256:ad98852315c2ab702aeb628412cbf7e95b7ce8c3bf9565670b4eaecf1db370a9"}, 180 | {file = "filelock-3.12.0.tar.gz", hash = "sha256:fc03ae43288c013d2ea83c8597001b1129db351aad9c57fe2409327916b8e718"}, 181 | ] 182 | 183 | [package.extras] 184 | docs = ["furo (>=2023.3.27)", "sphinx (>=6.1.3)", "sphinx-autodoc-typehints (>=1.23,!=1.23.4)"] 185 | 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)"] 186 | 187 | [[package]] 188 | name = "iniconfig" 189 | version = "2.0.0" 190 | description = "brain-dead simple config-ini parsing" 191 | optional = false 192 | python-versions = ">=3.7" 193 | groups = ["dev"] 194 | files = [ 195 | {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, 196 | {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, 197 | ] 198 | 199 | [[package]] 200 | name = "mock" 201 | version = "5.2.0" 202 | description = "Rolling backport of unittest.mock for all Pythons" 203 | optional = false 204 | python-versions = ">=3.6" 205 | groups = ["dev"] 206 | files = [ 207 | {file = "mock-5.2.0-py3-none-any.whl", hash = "sha256:7ba87f72ca0e915175596069dbbcc7c75af7b5e9b9bc107ad6349ede0819982f"}, 208 | {file = "mock-5.2.0.tar.gz", hash = "sha256:4e460e818629b4b173f32d08bf30d3af8123afbb8e04bb5707a1fd4799e503f0"}, 209 | ] 210 | 211 | [package.extras] 212 | build = ["blurb", "twine", "wheel"] 213 | docs = ["sphinx"] 214 | test = ["pytest", "pytest-cov"] 215 | 216 | [[package]] 217 | name = "nose" 218 | version = "1.3.7" 219 | description = "nose extends unittest to make testing easier" 220 | optional = false 221 | python-versions = "*" 222 | groups = ["dev"] 223 | files = [ 224 | {file = "nose-1.3.7-py2-none-any.whl", hash = "sha256:dadcddc0aefbf99eea214e0f1232b94f2fa9bd98fa8353711dacb112bfcbbb2a"}, 225 | {file = "nose-1.3.7-py3-none-any.whl", hash = "sha256:9ff7c6cc443f8c51994b34a667bbcf45afd6d945be7477b52e97516fd17c53ac"}, 226 | {file = "nose-1.3.7.tar.gz", hash = "sha256:f1bffef9cbc82628f6e7d7b40d7e255aefaa1adb6a1b1d26c69a8b79e6208a98"}, 227 | ] 228 | 229 | [[package]] 230 | name = "nose-run-line-number" 231 | version = "0.0.2" 232 | description = "Nose plugin to run tests by line number" 233 | optional = false 234 | python-versions = "*" 235 | groups = ["dev"] 236 | files = [ 237 | {file = "nose-run-line-number-0.0.2.tar.gz", hash = "sha256:521ed2d1c4259d7cc0cab84225e63b1d6f7c7582b580263a2b7c19f2591cb1d4"}, 238 | ] 239 | 240 | [[package]] 241 | name = "packaging" 242 | version = "23.1" 243 | description = "Core utilities for Python packages" 244 | optional = false 245 | python-versions = ">=3.7" 246 | groups = ["dev"] 247 | files = [ 248 | {file = "packaging-23.1-py3-none-any.whl", hash = "sha256:994793af429502c4ea2ebf6bf664629d07c1a9fe974af92966e4b8d2df7edc61"}, 249 | {file = "packaging-23.1.tar.gz", hash = "sha256:a392980d2b6cffa644431898be54b0045151319d1e7ec34f0cfed48767dd334f"}, 250 | ] 251 | 252 | [[package]] 253 | name = "platformdirs" 254 | version = "3.5.0" 255 | description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." 256 | optional = false 257 | python-versions = ">=3.7" 258 | groups = ["dev"] 259 | files = [ 260 | {file = "platformdirs-3.5.0-py3-none-any.whl", hash = "sha256:47692bc24c1958e8b0f13dd727307cff1db103fca36399f457da8e05f222fdc4"}, 261 | {file = "platformdirs-3.5.0.tar.gz", hash = "sha256:7954a68d0ba23558d753f73437c55f89027cf8f5108c19844d4b82e5af396335"}, 262 | ] 263 | 264 | [package.extras] 265 | docs = ["furo (>=2023.3.27)", "proselint (>=0.13)", "sphinx (>=6.1.3)", "sphinx-autodoc-typehints (>=1.23,!=1.23.4)"] 266 | test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.3.1)", "pytest-cov (>=4)", "pytest-mock (>=3.10)"] 267 | 268 | [[package]] 269 | name = "pluggy" 270 | version = "1.6.0" 271 | description = "plugin and hook calling mechanisms for python" 272 | optional = false 273 | python-versions = ">=3.9" 274 | groups = ["dev"] 275 | files = [ 276 | {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, 277 | {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, 278 | ] 279 | 280 | [package.extras] 281 | dev = ["pre-commit", "tox"] 282 | testing = ["coverage", "pytest", "pytest-benchmark"] 283 | 284 | [[package]] 285 | name = "pygments" 286 | version = "2.19.2" 287 | description = "Pygments is a syntax highlighting package written in Python." 288 | optional = false 289 | python-versions = ">=3.8" 290 | groups = ["dev"] 291 | files = [ 292 | {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, 293 | {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, 294 | ] 295 | 296 | [package.extras] 297 | windows-terminal = ["colorama (>=0.4.6)"] 298 | 299 | [[package]] 300 | name = "pyproject-api" 301 | version = "1.5.1" 302 | description = "API to interact with the python pyproject.toml based projects" 303 | optional = false 304 | python-versions = ">=3.7" 305 | groups = ["dev"] 306 | files = [ 307 | {file = "pyproject_api-1.5.1-py3-none-any.whl", hash = "sha256:4698a3777c2e0f6b624f8a4599131e2a25376d90fe8d146d7ac74c67c6f97c43"}, 308 | {file = "pyproject_api-1.5.1.tar.gz", hash = "sha256:435f46547a9ff22cf4208ee274fca3e2869aeb062a4834adfc99a4dd64af3cf9"}, 309 | ] 310 | 311 | [package.dependencies] 312 | packaging = ">=23" 313 | tomli = {version = ">=2.0.1", markers = "python_version < \"3.11\""} 314 | 315 | [package.extras] 316 | docs = ["furo (>=2022.12.7)", "sphinx (>=6.1.3)", "sphinx-autodoc-typehints (>=1.22,!=1.23.4)"] 317 | testing = ["covdefaults (>=2.2.2)", "importlib-metadata (>=6) ; python_version < \"3.8\"", "pytest (>=7.2.1)", "pytest-cov (>=4)", "pytest-mock (>=3.10)", "virtualenv (>=20.17.1)", "wheel (>=0.38.4)"] 318 | 319 | [[package]] 320 | name = "pytest" 321 | version = "8.4.2" 322 | description = "pytest: simple powerful testing with Python" 323 | optional = false 324 | python-versions = ">=3.9" 325 | groups = ["dev"] 326 | files = [ 327 | {file = "pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79"}, 328 | {file = "pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01"}, 329 | ] 330 | 331 | [package.dependencies] 332 | colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} 333 | exceptiongroup = {version = ">=1", markers = "python_version < \"3.11\""} 334 | iniconfig = ">=1" 335 | packaging = ">=20" 336 | pluggy = ">=1.5,<2" 337 | pygments = ">=2.7.2" 338 | tomli = {version = ">=1", markers = "python_version < \"3.11\""} 339 | 340 | [package.extras] 341 | dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] 342 | 343 | [[package]] 344 | name = "pytest-cov" 345 | version = "7.0.0" 346 | description = "Pytest plugin for measuring coverage." 347 | optional = false 348 | python-versions = ">=3.9" 349 | groups = ["dev"] 350 | files = [ 351 | {file = "pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861"}, 352 | {file = "pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1"}, 353 | ] 354 | 355 | [package.dependencies] 356 | coverage = {version = ">=7.10.6", extras = ["toml"]} 357 | pluggy = ">=1.2" 358 | pytest = ">=7" 359 | 360 | [package.extras] 361 | testing = ["process-tests", "pytest-xdist", "virtualenv"] 362 | 363 | [[package]] 364 | name = "tomli" 365 | version = "2.0.1" 366 | description = "A lil' TOML parser" 367 | optional = false 368 | python-versions = ">=3.7" 369 | groups = ["dev"] 370 | markers = "python_full_version <= \"3.11.0a6\"" 371 | files = [ 372 | {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, 373 | {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, 374 | ] 375 | 376 | [[package]] 377 | name = "tox" 378 | version = "4.5.1" 379 | description = "tox is a generic virtualenv management and test command line tool" 380 | optional = false 381 | python-versions = ">=3.7" 382 | groups = ["dev"] 383 | files = [ 384 | {file = "tox-4.5.1-py3-none-any.whl", hash = "sha256:d25a2e6cb261adc489604fafd76cd689efeadfa79709965e965668d6d3f63046"}, 385 | {file = "tox-4.5.1.tar.gz", hash = "sha256:5a2eac5fb816779dfdf5cb00fecbc27eb0524e4626626bb1de84747b24cacc56"}, 386 | ] 387 | 388 | [package.dependencies] 389 | cachetools = ">=5.3" 390 | chardet = ">=5.1" 391 | colorama = ">=0.4.6" 392 | filelock = ">=3.11" 393 | packaging = ">=23.1" 394 | platformdirs = ">=3.2" 395 | pluggy = ">=1" 396 | pyproject-api = ">=1.5.1" 397 | tomli = {version = ">=2.0.1", markers = "python_version < \"3.11\""} 398 | virtualenv = ">=20.21" 399 | 400 | [package.extras] 401 | 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)"] 402 | 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) ; implementation_name != \"pypy\"", "wheel (>=0.40)"] 403 | 404 | [[package]] 405 | name = "virtualenv" 406 | version = "20.23.0" 407 | description = "Virtual Python Environment builder" 408 | optional = false 409 | python-versions = ">=3.7" 410 | groups = ["dev"] 411 | files = [ 412 | {file = "virtualenv-20.23.0-py3-none-any.whl", hash = "sha256:6abec7670e5802a528357fdc75b26b9f57d5d92f29c5462ba0fbe45feacc685e"}, 413 | {file = "virtualenv-20.23.0.tar.gz", hash = "sha256:a85caa554ced0c0afbd0d638e7e2d7b5f92d23478d05d17a76daeac8f279f924"}, 414 | ] 415 | 416 | [package.dependencies] 417 | distlib = ">=0.3.6,<1" 418 | filelock = ">=3.11,<4" 419 | platformdirs = ">=3.2,<4" 420 | 421 | [package.extras] 422 | docs = ["furo (>=2023.3.27)", "proselint (>=0.13)", "sphinx (>=6.1.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=22.12)"] 423 | 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) ; platform_python_implementation == \"PyPy\"", "pytest-mock (>=3.10)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=67.7.1)", "time-machine (>=2.9) ; platform_python_implementation == \"CPython\""] 424 | 425 | [extras] 426 | test = [] 427 | 428 | [metadata] 429 | lock-version = "2.1" 430 | python-versions = ">=3.9,<4.0" 431 | content-hash = "715863c148810acd5035687732e77837bf600b22d3238f2d36dc102ada0349a0" 432 | -------------------------------------------------------------------------------- /tests/test_engine_logic.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from mock import MagicMock, patch 4 | 5 | from business_rules import engine, fields 6 | from business_rules.actions import ActionParam, BaseActions, rule_action 7 | from business_rules.fields import FIELD_NUMERIC, FIELD_TEXT 8 | from business_rules.models import ConditionResult 9 | from business_rules.operators import StringType 10 | from business_rules.variables import BaseVariables 11 | 12 | 13 | class EngineTests(TestCase): 14 | @patch.object(engine, "run") 15 | def test_run_all_some_rule_triggered(self, *args): 16 | """ 17 | By default, does not stop on first triggered rule. Returns a list of 18 | booleans indicating whether each rule was triggered. 19 | """ 20 | rule1 = {"conditions": "condition1", "actions": "action name 1"} 21 | rule2 = {"conditions": "condition2", "actions": "action name 2"} 22 | variables = BaseVariables() 23 | actions = BaseActions() 24 | 25 | def return_action1(rule, *args, **kwargs): 26 | return rule["actions"] == "action name 1" 27 | 28 | engine.run.side_effect = return_action1 29 | 30 | results = engine.run_all([rule1, rule2], variables, actions) 31 | self.assertEqual(results, [True, False]) 32 | self.assertEqual(engine.run.call_count, 2) 33 | 34 | # switch order and try again 35 | engine.run.reset_mock() 36 | 37 | results = engine.run_all([rule2, rule1], variables, actions) 38 | self.assertEqual(results, [False, True]) 39 | self.assertEqual(engine.run.call_count, 2) 40 | 41 | @patch.object(engine, "run", return_value=True) 42 | def test_run_all_stop_on_first(self, *args): 43 | rule1 = {"conditions": "condition1", "actions": "action name 1"} 44 | rule2 = {"conditions": "condition2", "actions": "action name 2"} 45 | variables = BaseVariables() 46 | actions = BaseActions() 47 | 48 | results = engine.run_all( 49 | [rule1, rule2], variables, actions, stop_on_first_trigger=True 50 | ) 51 | 52 | self.assertEqual(results, [True, False]) 53 | self.assertEqual(engine.run.call_count, 1) 54 | engine.run.assert_called_once_with(rule1, variables, actions) 55 | 56 | @patch.object(engine, "check_conditions_recursively", return_value=(True, [])) 57 | @patch.object(engine, "do_actions") 58 | def test_run_that_triggers_rule(self, *args): 59 | rule = {"conditions": "blah", "actions": "blah2"} 60 | variables = BaseVariables() 61 | actions = BaseActions() 62 | 63 | result = engine.run(rule, variables, actions) 64 | self.assertEqual(result, True) 65 | engine.check_conditions_recursively.assert_called_once_with( 66 | rule["conditions"], variables, rule 67 | ) 68 | engine.do_actions.assert_called_once_with(rule["actions"], actions, [], rule) 69 | 70 | @patch.object(engine, "check_conditions_recursively", return_value=(False, [])) 71 | @patch.object(engine, "do_actions") 72 | def test_run_that_doesnt_trigger_rule(self, *args): 73 | rule = {"conditions": "blah", "actions": "blah2"} 74 | variables = BaseVariables() 75 | actions = BaseActions() 76 | 77 | result = engine.run(rule, variables, actions) 78 | self.assertEqual(result, False) 79 | engine.check_conditions_recursively.assert_called_once_with( 80 | rule["conditions"], variables, rule 81 | ) 82 | self.assertEqual(engine.do_actions.call_count, 0) 83 | 84 | @patch.object(engine, "check_condition", return_value=(True,)) 85 | def test_check_all_conditions_with_all_true(self, *args): 86 | conditions = {"all": [{"thing1": ""}, {"thing2": ""}]} 87 | variables = BaseVariables() 88 | rule = {"conditions": conditions, "actions": []} 89 | 90 | result = engine.check_conditions_recursively(conditions, variables, rule) 91 | self.assertEqual(result, (True, [(True,), (True,)])) 92 | # assert call count and most recent call are as expected 93 | self.assertEqual(engine.check_condition.call_count, 2) 94 | engine.check_condition.assert_called_with({"thing2": ""}, variables, rule) 95 | 96 | # ########################################################## # 97 | # #################### Check conditions #################### # 98 | # ########################################################## # 99 | @patch.object(engine, "check_condition", return_value=(False,)) 100 | def test_check_all_conditions_with_all_false(self, *args): 101 | conditions = {"all": [{"thing1": ""}, {"thing2": ""}]} 102 | variables = BaseVariables() 103 | rule = {"conditions": conditions, "actions": []} 104 | 105 | result = engine.check_conditions_recursively(conditions, variables, rule) 106 | self.assertEqual(result, (False, [])) 107 | engine.check_condition.assert_called_once_with({"thing1": ""}, variables, rule) 108 | 109 | def test_check_all_condition_with_no_items_fails(self): 110 | conditions = {"all": []} 111 | rule = {"conditions": conditions, "actions": []} 112 | variables = BaseVariables() 113 | with self.assertRaises(AssertionError): 114 | engine.check_conditions_recursively(conditions, variables, rule) 115 | 116 | @patch.object(engine, "check_condition", return_value=(True,)) 117 | def test_check_any_conditions_with_all_true(self, *args): 118 | conditions = {"any": [{"thing1": ""}, {"thing2": ""}]} 119 | variables = BaseVariables() 120 | rule = {"conditions": conditions, "actions": []} 121 | 122 | result = engine.check_conditions_recursively(conditions, variables, rule) 123 | self.assertEqual(result, (True, [(True,)])) 124 | engine.check_condition.assert_called_once_with({"thing1": ""}, variables, rule) 125 | 126 | @patch.object(engine, "check_condition", return_value=(False,)) 127 | def test_check_any_conditions_with_all_false(self, *args): 128 | conditions = {"any": [{"thing1": ""}, {"thing2": ""}]} 129 | variables = BaseVariables() 130 | rule = {"conditions": conditions, "actions": []} 131 | 132 | result = engine.check_conditions_recursively(conditions, variables, rule) 133 | self.assertEqual(result, (False, [])) 134 | # assert call count and most recent call are as expected 135 | self.assertEqual(engine.check_condition.call_count, 2) 136 | engine.check_condition.assert_called_with(conditions["any"][1], variables, rule) 137 | 138 | def test_check_any_condition_with_no_items_fails(self): 139 | conditions = {"any": []} 140 | variables = BaseVariables() 141 | rule = {"conditions": conditions, "actions": []} 142 | 143 | with self.assertRaises(AssertionError): 144 | engine.check_conditions_recursively(conditions, variables, rule) 145 | 146 | def test_check_all_and_any_together(self): 147 | conditions = {"any": [], "all": []} 148 | variables = BaseVariables() 149 | rule = {"conditions": conditions, "actions": []} 150 | with self.assertRaises(AssertionError): 151 | engine.check_conditions_recursively(conditions, variables, rule) 152 | 153 | @patch.object(engine, "check_condition") 154 | def test_nested_all_and_any(self, *args): 155 | conditions = {"all": [{"any": [{"name": 1}, {"name": 2}]}, {"name": 3}]} 156 | 157 | rule = {"conditions": conditions, "actions": {}} 158 | 159 | bv = BaseVariables() 160 | 161 | def side_effect(condition, _, rule): 162 | return ConditionResult( 163 | result=condition["name"] in [2, 3], 164 | name=condition["name"], 165 | operator="", 166 | value="", 167 | parameters="", 168 | ) 169 | 170 | engine.check_condition.side_effect = side_effect 171 | 172 | engine.check_conditions_recursively(conditions, bv, rule) 173 | self.assertEqual(engine.check_condition.call_count, 3) 174 | engine.check_condition.assert_any_call({"name": 1}, bv, rule) 175 | engine.check_condition.assert_any_call({"name": 2}, bv, rule) 176 | engine.check_condition.assert_any_call({"name": 3}, bv, rule) 177 | 178 | # ##################################### # 179 | # ####### Operator comparisons ######## # 180 | # ##################################### # 181 | def test_check_operator_comparison(self): 182 | string_type = StringType("yo yo") 183 | with patch.object(string_type, "contains", return_value=True): 184 | result = engine._do_operator_comparison( 185 | string_type, "contains", "its mocked" 186 | ) 187 | self.assertTrue(result) 188 | string_type.contains.assert_called_once_with("its mocked") 189 | 190 | # ##################################### # 191 | # ############## Actions ############## # 192 | # ##################################### # 193 | def test_do_actions(self): 194 | function_params_mock = MagicMock() 195 | function_params_mock.varkw = None 196 | with patch( 197 | "business_rules.engine.getfullargspec", return_value=function_params_mock 198 | ): 199 | rule_actions = [ 200 | {"name": "action1"}, 201 | {"name": "action2", "params": {"param1": "foo", "param2": 10}}, 202 | ] 203 | 204 | rule = {"conditions": {}, "actions": rule_actions} 205 | 206 | action1_mock = MagicMock() 207 | action2_mock = MagicMock() 208 | 209 | class SomeActions(BaseActions): 210 | @rule_action() 211 | def action1(self): 212 | return action1_mock() 213 | 214 | @rule_action(params={"param1": FIELD_TEXT, "param2": FIELD_NUMERIC}) 215 | def action2(self, param1, param2): 216 | return action2_mock(param1=param1, param2=param2) 217 | 218 | defined_actions = SomeActions() 219 | 220 | payload = [(True, "condition_name", "operator_name", "condition_value")] 221 | 222 | engine.do_actions(rule_actions, defined_actions, payload, rule) 223 | 224 | action1_mock.assert_called_once_with() 225 | action2_mock.assert_called_once_with(param1="foo", param2=10) 226 | 227 | def test_do_actions_with_injected_parameters(self): 228 | function_params_mock = MagicMock() 229 | function_params_mock.varkw = True 230 | with patch( 231 | "business_rules.engine.getfullargspec", return_value=function_params_mock 232 | ): 233 | rule_actions = [ 234 | {"name": "action1"}, 235 | {"name": "action2", "params": {"param1": "foo", "param2": 10}}, 236 | ] 237 | 238 | rule = {"conditions": {}, "actions": rule_actions} 239 | 240 | defined_actions = BaseActions() 241 | defined_actions.action1 = MagicMock() 242 | defined_actions.action1.params = [] 243 | defined_actions.action2 = MagicMock() 244 | defined_actions.action2.params = [ 245 | { 246 | "label": "action2", 247 | "name": "param1", 248 | "fieldType": fields.FIELD_TEXT, 249 | "defaultValue": None, 250 | }, 251 | { 252 | "label": "action2", 253 | "name": "param2", 254 | "fieldType": fields.FIELD_NUMERIC, 255 | "defaultValue": None, 256 | }, 257 | ] 258 | payload = [(True, "condition_name", "operator_name", "condition_value")] 259 | 260 | engine.do_actions(rule_actions, defined_actions, payload, rule) 261 | 262 | defined_actions.action1.assert_called_once_with( 263 | conditions=payload, rule=rule 264 | ) 265 | defined_actions.action2.assert_called_once_with( 266 | param1="foo", param2=10, conditions=payload, rule=rule 267 | ) 268 | 269 | def test_do_with_invalid_action(self): 270 | actions = [{"name": "fakeone"}] 271 | err_string = "Action fakeone is not defined in class BaseActions" 272 | 273 | rule = {"conditions": {}, "actions": {}} 274 | 275 | checked_conditions_results = [ 276 | (True, "condition_name", "operator_name", "condition_value") 277 | ] 278 | 279 | with self.assertRaisesRegex(AssertionError, err_string): 280 | engine.do_actions(actions, BaseActions(), checked_conditions_results, rule) 281 | 282 | def test_do_with_parameter_with_default_value(self): 283 | function_params_mock = MagicMock() 284 | function_params_mock.varkw = None 285 | with patch( 286 | "business_rules.engine.getfullargspec", return_value=function_params_mock 287 | ): 288 | # param2 is not set in rule, but there is a default parameter for it in 289 | # action which will be used instead 290 | rule_actions = [{"name": "some_action", "params": {"param1": "foo"}}] 291 | 292 | rule = {"conditions": {}, "actions": rule_actions} 293 | 294 | action_param_with_default_value = ActionParam( 295 | field_type=fields.FIELD_NUMERIC, default_value=42 296 | ) 297 | 298 | action_mock = MagicMock() 299 | 300 | class SomeActions(BaseActions): 301 | @rule_action( 302 | params={ 303 | "param1": FIELD_TEXT, 304 | "param2": action_param_with_default_value, 305 | } 306 | ) 307 | def some_action(self, param1, param2): 308 | return action_mock(param1=param1, param2=param2) 309 | 310 | defined_actions = SomeActions() 311 | 312 | defined_actions.action = MagicMock() 313 | defined_actions.action.params = { 314 | "param1": fields.FIELD_TEXT, 315 | "param2": action_param_with_default_value, 316 | } 317 | 318 | payload = [(True, "condition_name", "operator_name", "condition_value")] 319 | 320 | engine.do_actions(rule_actions, defined_actions, payload, rule) 321 | 322 | action_mock.assert_called_once_with(param1="foo", param2=42) 323 | 324 | def test_default_param_overrides_action_param(self): 325 | 326 | function_params_mock = MagicMock() 327 | function_params_mock.varkw = None 328 | with patch( 329 | "business_rules.engine.getfullargspec", return_value=function_params_mock 330 | ): 331 | rule_actions = [{"name": "some_action", "params": {"param1": False}}] 332 | 333 | rule = {"conditions": {}, "actions": rule_actions} 334 | 335 | action_param_with_default_value = ActionParam( 336 | field_type=fields.FIELD_TEXT, default_value="bar" 337 | ) 338 | 339 | action_mock = MagicMock() 340 | 341 | class SomeActions(BaseActions): 342 | @rule_action(params={"param1": action_param_with_default_value}) 343 | def some_action(self, param1): 344 | return action_mock(param1=param1) 345 | 346 | defined_actions = SomeActions() 347 | 348 | defined_actions.action = MagicMock() 349 | defined_actions.action.params = { 350 | "param1": action_param_with_default_value, 351 | } 352 | 353 | payload = [(True, "condition_name", "operator_name", "condition_value")] 354 | 355 | engine.do_actions(rule_actions, defined_actions, payload, rule) 356 | 357 | action_mock.assert_called_once_with(param1=False) 358 | 359 | 360 | class EngineCheckConditionsTests(TestCase): 361 | def test_case1(self): 362 | """cond1: true and cond2: false => []""" 363 | conditions = { 364 | "all": [ 365 | {"name": "true_variable", "operator": "is_true", "value": ""}, 366 | {"name": "true_variable", "operator": "is_false", "value": ""}, 367 | ] 368 | } 369 | variables = TrueVariables() 370 | rule = {"conditions": conditions, "actions": []} 371 | 372 | result = engine.check_conditions_recursively(conditions, variables, rule) 373 | self.assertEqual(result, (False, [])) 374 | 375 | def test_case2(self): 376 | """ 377 | cond1: false and cond2: true => [] 378 | """ 379 | conditions = { 380 | "all": [ 381 | {"name": "true_variable", "operator": "is_false", "value": ""}, 382 | {"name": "true_variable", "operator": "is_true", "value": ""}, 383 | ] 384 | } 385 | variables = TrueVariables() 386 | rule = {"conditions": conditions, "actions": []} 387 | 388 | result = engine.check_conditions_recursively(conditions, variables, rule) 389 | self.assertEqual(result, (False, [])) 390 | 391 | def test_case3(self): 392 | """ 393 | cond1: true and cond2: true => [cond1, cond2] 394 | """ 395 | conditions = { 396 | "all": [ 397 | {"name": "true_variable", "operator": "is_true", "value": ""}, 398 | {"name": "true_variable", "operator": "is_true", "value": ""}, 399 | ] 400 | } 401 | variables = TrueVariables() 402 | rule = {"conditions": conditions, "actions": []} 403 | 404 | result = engine.check_conditions_recursively(conditions, variables, rule) 405 | self.assertEqual( 406 | result, 407 | ( 408 | True, 409 | [ 410 | ConditionResult( 411 | result=True, 412 | name="true_variable", 413 | operator="is_true", 414 | value="", 415 | parameters={}, 416 | ), 417 | ConditionResult( 418 | result=True, 419 | name="true_variable", 420 | operator="is_true", 421 | value="", 422 | parameters={}, 423 | ), 424 | ], 425 | ), 426 | ) 427 | 428 | def test_case4(self): 429 | """ 430 | cond1: true and (cond2: false or cond3: true) => [cond1, cond3] 431 | """ 432 | conditions = { 433 | "all": [ 434 | {"name": "true_variable", "operator": "is_true", "value": "1"}, 435 | { 436 | "any": [ 437 | {"name": "true_variable", "operator": "is_false", "value": "2"}, 438 | {"name": "true_variable", "operator": "is_true", "value": "3"}, 439 | ] 440 | }, 441 | ] 442 | } 443 | variables = TrueVariables() 444 | rule = {"conditions": conditions, "actions": []} 445 | 446 | result = engine.check_conditions_recursively(conditions, variables, rule) 447 | self.assertEqual( 448 | result, 449 | ( 450 | True, 451 | [ 452 | ConditionResult( 453 | result=True, 454 | name="true_variable", 455 | operator="is_true", 456 | value="1", 457 | parameters={}, 458 | ), 459 | ConditionResult( 460 | result=True, 461 | name="true_variable", 462 | operator="is_true", 463 | value="3", 464 | parameters={}, 465 | ), 466 | ], 467 | ), 468 | ) 469 | 470 | def test_case5(self): 471 | """ 472 | cond1: false and (cond2: false or cond3: true) => [] 473 | """ 474 | conditions = { 475 | "all": [ 476 | {"name": "true_variable", "operator": "is_false", "value": "1"}, 477 | { 478 | "any": [ 479 | {"name": "true_variable", "operator": "is_false", "value": "2"}, 480 | {"name": "true_variable", "operator": "is_true", "value": "3"}, 481 | ] 482 | }, 483 | ] 484 | } 485 | variables = TrueVariables() 486 | rule = {"conditions": conditions, "actions": []} 487 | 488 | result = engine.check_conditions_recursively(conditions, variables, rule) 489 | self.assertEqual(result, (False, [])) 490 | 491 | def test_case6(self): 492 | """ 493 | cond1: true or (cond2: false or cond3: true) => [cond1] 494 | """ 495 | conditions = { 496 | "any": [ 497 | {"name": "true_variable", "operator": "is_true", "value": "1"}, 498 | { 499 | "any": [ 500 | {"name": "true_variable", "operator": "is_false", "value": "2"}, 501 | {"name": "true_variable", "operator": "is_true", "value": "3"}, 502 | ] 503 | }, 504 | ] 505 | } 506 | variables = TrueVariables() 507 | rule = {"conditions": conditions, "actions": []} 508 | 509 | result = engine.check_conditions_recursively(conditions, variables, rule) 510 | self.assertEqual( 511 | result, 512 | ( 513 | True, 514 | [ 515 | ConditionResult( 516 | result=True, 517 | name="true_variable", 518 | operator="is_true", 519 | value="1", 520 | parameters={}, 521 | ), 522 | ], 523 | ), 524 | ) 525 | 526 | def test_case7(self): 527 | """ 528 | cond1: false or (cond2: false or cond3: true) => [cond3] 529 | """ 530 | conditions = { 531 | "any": [ 532 | {"name": "true_variable", "operator": "is_false", "value": "1"}, 533 | { 534 | "any": [ 535 | {"name": "true_variable", "operator": "is_false", "value": "2"}, 536 | {"name": "true_variable", "operator": "is_true", "value": "3"}, 537 | ] 538 | }, 539 | ] 540 | } 541 | variables = TrueVariables() 542 | rule = {"conditions": conditions, "actions": []} 543 | 544 | result = engine.check_conditions_recursively(conditions, variables, rule) 545 | self.assertEqual( 546 | result, 547 | ( 548 | True, 549 | [ 550 | ConditionResult( 551 | result=True, 552 | name="true_variable", 553 | operator="is_true", 554 | value="3", 555 | parameters={}, 556 | ), 557 | ], 558 | ), 559 | ) 560 | 561 | def test_case8(self): 562 | """ 563 | cond1: false or (cond2: true and cond3: true) => [cond2, cond3] 564 | """ 565 | conditions = { 566 | "any": [ 567 | {"name": "true_variable", "operator": "is_false", "value": "1"}, 568 | { 569 | "all": [ 570 | {"name": "true_variable", "operator": "is_true", "value": "2"}, 571 | {"name": "true_variable", "operator": "is_true", "value": "3"}, 572 | ] 573 | }, 574 | ] 575 | } 576 | variables = TrueVariables() 577 | rule = {"conditions": conditions, "actions": []} 578 | 579 | result = engine.check_conditions_recursively(conditions, variables, rule) 580 | self.assertEqual( 581 | result, 582 | ( 583 | True, 584 | [ 585 | ConditionResult( 586 | result=True, 587 | name="true_variable", 588 | operator="is_true", 589 | value="2", 590 | parameters={}, 591 | ), 592 | ConditionResult( 593 | result=True, 594 | name="true_variable", 595 | operator="is_true", 596 | value="3", 597 | parameters={}, 598 | ), 599 | ], 600 | ), 601 | ) 602 | 603 | def test_case9(self): 604 | """ 605 | (cond2: true and cond3: true) or cond1: true => [cond2, cond3] 606 | """ 607 | conditions = { 608 | "any": [ 609 | { 610 | "all": [ 611 | {"name": "true_variable", "operator": "is_true", "value": "2"}, 612 | {"name": "true_variable", "operator": "is_true", "value": "3"}, 613 | ] 614 | }, 615 | {"name": "true_variable", "operator": "is_false", "value": "1"}, 616 | ] 617 | } 618 | variables = TrueVariables() 619 | rule = {"conditions": conditions, "actions": []} 620 | 621 | result = engine.check_conditions_recursively(conditions, variables, rule) 622 | self.assertEqual( 623 | result, 624 | ( 625 | True, 626 | [ 627 | ConditionResult( 628 | result=True, 629 | name="true_variable", 630 | operator="is_true", 631 | value="2", 632 | parameters={}, 633 | ), 634 | ConditionResult( 635 | result=True, 636 | name="true_variable", 637 | operator="is_true", 638 | value="3", 639 | parameters={}, 640 | ), 641 | ], 642 | ), 643 | ) 644 | 645 | def test_case10(self): 646 | """ 647 | cond1: true or cond2: false => [cond1] 648 | """ 649 | conditions = { 650 | "any": [ 651 | {"name": "true_variable", "operator": "is_true", "value": "1"}, 652 | {"name": "true_variable", "operator": "is_false", "value": "2"}, 653 | ] 654 | } 655 | variables = TrueVariables() 656 | rule = {"conditions": conditions, "actions": []} 657 | 658 | result = engine.check_conditions_recursively(conditions, variables, rule) 659 | self.assertEqual( 660 | result, 661 | ( 662 | True, 663 | [ 664 | ConditionResult( 665 | result=True, 666 | name="true_variable", 667 | operator="is_true", 668 | value="1", 669 | parameters={}, 670 | ), 671 | ], 672 | ), 673 | ) 674 | 675 | def test_case11(self): 676 | """ 677 | cond1: false or cond2: true => [cond2] 678 | """ 679 | conditions = { 680 | "any": [ 681 | {"name": "true_variable", "operator": "is_false", "value": "1"}, 682 | {"name": "true_variable", "operator": "is_true", "value": "2"}, 683 | ] 684 | } 685 | variables = TrueVariables() 686 | rule = {"conditions": conditions, "actions": []} 687 | 688 | result = engine.check_conditions_recursively(conditions, variables, rule) 689 | self.assertEqual( 690 | result, 691 | ( 692 | True, 693 | [ 694 | ConditionResult( 695 | result=True, 696 | name="true_variable", 697 | operator="is_true", 698 | value="2", 699 | parameters={}, 700 | ), 701 | ], 702 | ), 703 | ) 704 | 705 | def test_case12(self): 706 | """ 707 | cond1: true or cond2: true => [cond1] 708 | """ 709 | conditions = { 710 | "any": [ 711 | {"name": "true_variable", "operator": "is_true", "value": "1"}, 712 | {"name": "true_variable", "operator": "is_true", "value": "2"}, 713 | ] 714 | } 715 | variables = TrueVariables() 716 | rule = {"conditions": conditions, "actions": []} 717 | 718 | result = engine.check_conditions_recursively(conditions, variables, rule) 719 | self.assertEqual( 720 | result, 721 | ( 722 | True, 723 | [ 724 | ConditionResult( 725 | result=True, 726 | name="true_variable", 727 | operator="is_true", 728 | value="1", 729 | parameters={}, 730 | ), 731 | ], 732 | ), 733 | ) 734 | 735 | def test_case13(self): 736 | """ 737 | (cond1: true and cond2: false) or cond3: true => [cond3] 738 | """ 739 | conditions = { 740 | "any": [ 741 | { 742 | "all": [ 743 | {"name": "true_variable", "operator": "is_true", "value": "1"}, 744 | {"name": "true_variable", "operator": "is_false", "value": "2"}, 745 | ] 746 | }, 747 | {"name": "true_variable", "operator": "is_true", "value": "3"}, 748 | ] 749 | } 750 | variables = TrueVariables() 751 | rule = {"conditions": conditions, "actions": []} 752 | 753 | result = engine.check_conditions_recursively(conditions, variables, rule) 754 | self.assertEqual( 755 | result, 756 | ( 757 | True, 758 | [ 759 | ConditionResult( 760 | result=True, 761 | name="true_variable", 762 | operator="is_true", 763 | value="3", 764 | parameters={}, 765 | ), 766 | ], 767 | ), 768 | ) 769 | 770 | 771 | class TrueVariables(BaseVariables): 772 | from business_rules.variables import boolean_rule_variable 773 | 774 | @boolean_rule_variable 775 | def true_variable(self): 776 | return True 777 | --------------------------------------------------------------------------------