├── .github ├── FUNDING.yml └── workflows │ └── ci.yml ├── .gitignore ├── LICENSE ├── Makefile ├── Pipfile ├── README.rst ├── docs └── source │ ├── _static │ └── theme_overrides.css │ ├── attributes.rst │ ├── change_log.rst │ ├── conf.py │ ├── debug_repl.rst │ ├── getting_started.rst │ ├── index.rst │ ├── rule_engine │ ├── ast.rst │ ├── builtins.rst │ ├── engine.rst │ ├── errors.rst │ ├── index.rst │ ├── parser │ │ ├── index.rst │ │ └── utilities.rst │ ├── suggestions.rst │ └── types.rst │ ├── syntax.rst │ └── types.rst ├── examples ├── csv_filter.py ├── database.py ├── github_filter.py └── shodan │ ├── query.py │ ├── results_filter.py │ ├── results_scan.py │ └── rules.yml ├── lib └── rule_engine │ ├── __init__.py │ ├── ast.py │ ├── builtins.py │ ├── debug_ast.py │ ├── debug_repl.py │ ├── engine.py │ ├── errors.py │ ├── parser │ ├── __init__.py │ ├── base.py │ ├── parser.out │ ├── parsetab.py │ └── utilities.py │ ├── suggestions.py │ └── types.py ├── setup.py └── tests ├── __init__.py ├── _utils.py ├── ast ├── __init__.py └── expression │ ├── __init__.py │ ├── attribute.py │ ├── function_call.py │ ├── left_operator_right.py │ ├── literal.py │ └── miscellaneous.py ├── builtins.py ├── engine.py ├── errors.py ├── issues.py ├── parser.py ├── suggestions.py ├── thread_safety.py └── types.py /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | ko_fi: zerosteiner 2 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Continuous Integration 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | jobs: 8 | unit-tests: 9 | name: Test on Python v${{ matrix.python-version }} 10 | runs-on: ubuntu-20.04 11 | strategy: 12 | matrix: 13 | include: 14 | - pipenv-version: '2022.4.8' 15 | python-version: '3.6' 16 | - pipenv-version: '2022.4.8' 17 | python-version: '3.7' 18 | - pipenv-version: '2022.4.8' 19 | python-version: '3.8' 20 | - pipenv-version: '2022.4.8' 21 | python-version: '3.9' 22 | - pipenv-version: '2023.2.4' 23 | python-version: '3.10' 24 | - pipenv-version: '2023.2.4' 25 | python-version: '3.11' 26 | - pipenv-version: '2024.4.0' 27 | python-version: '3.12' 28 | - pipenv-version: '2024.4.0' 29 | python-version: '3.13' 30 | 31 | steps: 32 | - name: Checkout the repository 33 | uses: actions/checkout@v2 34 | with: 35 | persist-credentials: false 36 | 37 | - name: Install build essentials 38 | run: sudo apt-get --yes install build-essential 39 | 40 | - name: Set up Python 41 | uses: actions/setup-python@v2 42 | with: 43 | python-version: ${{ matrix.python-version }} 44 | 45 | - name: Install dependencies 46 | run: | 47 | pip install pipenv==${{ matrix.pipenv-version }} 48 | pipenv install --dev 49 | 50 | - run: pipenv run tests 51 | - run: pipenv run tests-coverage 52 | 53 | test-documentation: 54 | name: Test building the documentation 55 | if: github.ref != 'refs/heads/master' 56 | runs-on: ubuntu-latest 57 | 58 | steps: 59 | - name: Checkout the repository 60 | uses: actions/checkout@v2 61 | with: 62 | persist-credentials: false 63 | 64 | - name: Install build essentials 65 | run: sudo apt-get --yes install build-essential 66 | 67 | - name: Set up Python 68 | uses: actions/setup-python@v2 69 | 70 | - name: Install dependencies 71 | run: | 72 | pip install pipenv 73 | pipenv install --dev 74 | 75 | - name: Build the documentation 76 | run: pipenv run sphinx-build -b html -a -E -v docs/source docs/html 77 | 78 | publish-documentation: 79 | name: Build and publish the documentation 80 | if: github.ref == 'refs/heads/master' 81 | runs-on: ubuntu-latest 82 | 83 | steps: 84 | - name: Checkout the repository 85 | uses: actions/checkout@v2 86 | with: 87 | persist-credentials: false 88 | 89 | - name: Install build essentials 90 | run: sudo apt-get --yes install build-essential 91 | 92 | - name: Set up Python 93 | uses: actions/setup-python@v2 94 | 95 | - name: Install dependencies 96 | run: | 97 | pip install pipenv 98 | pipenv install --dev 99 | 100 | - name: Build the documentation 101 | run: pipenv run sphinx-build -b html -a -E -v docs/source docs/html 102 | 103 | - name: Publish the documentation 104 | uses: JamesIves/github-pages-deploy-action@3.7.1 105 | with: 106 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 107 | BRANCH: gh-pages 108 | FOLDER: docs/html 109 | CLEAN: true 110 | 111 | publish-release: 112 | name: Publish the release 113 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') 114 | runs-on: ubuntu-latest 115 | 116 | steps: 117 | - name: Checkout the repository 118 | uses: actions/checkout@v2 119 | with: 120 | persist-credentials: false 121 | 122 | - name: Install build essentials 123 | run: sudo apt-get --yes install build-essential 124 | 125 | - name: Set up Python 126 | uses: actions/setup-python@v2 127 | 128 | - name: Install dependencies 129 | run: | 130 | pip install pipenv 131 | pipenv install --dev 132 | 133 | - name: Create the distribution 134 | run: pipenv run python setup.py build sdist 135 | 136 | - name: Publish the distribution 137 | uses: pypa/gh-action-pypi-publish@release/v1 138 | with: 139 | user: __token__ 140 | password: ${{ secrets.PYPI_TOKEN }} 141 | 142 | - name: Create the release 143 | id: create_release 144 | uses: actions/create-release@v1 145 | env: 146 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 147 | with: 148 | tag_name: ${{ github.ref }} 149 | release_name: Release ${{ github.ref }} 150 | 151 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.ipynb 2 | *.py[cod] 3 | *.swp 4 | 5 | .coverage 6 | .github/workflows/*.jnj 7 | .pylintrc 8 | .python-version 9 | Pipfile.lock 10 | repl_setup.py 11 | build/* 12 | dist/* 13 | docs/build/* 14 | docs/coverage 15 | docs/html 16 | examples/shodan/*.json 17 | lib/rule_engine.egg-info/* 18 | lib/rule_engine/parser.out 19 | lib/rule_engine/parsetab.py 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020, Spencer McIntyre 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are 6 | met: 7 | 8 | * Redistributions of source code must retain the above copyright 9 | notice, this list of conditions and the following disclaimer. 10 | * Redistributions in binary form must reproduce the above 11 | copyright notice, this list of conditions and the following disclaimer 12 | in the documentation and/or other materials provided with the 13 | distribution. 14 | * Neither the name of the project nor the names of its 15 | contributors may be used to endorse or promote products derived from 16 | this software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 19 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 20 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 21 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 22 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 23 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 24 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 25 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 26 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 27 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | VERSION := $(shell ./setup.py --version) 2 | 3 | .PHONY: build 4 | build: 5 | python setup.py build sdist 6 | 7 | .PHONY: clean 8 | clean: 9 | rm -rf build dist 10 | 11 | .PHONY: docs 12 | docs: 13 | pipenv install --dev 14 | pipenv run sphinx-build -b html -a -E -v docs/source docs/html 15 | 16 | .PHONY: release 17 | release: build 18 | $(eval RELEASE_TAG := v$(VERSION)) 19 | git tag -sm "Version $(VERSION)" $(RELEASE_TAG) 20 | git push --tags 21 | 22 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | url = "https://pypi.org/simple" 3 | verify_ssl = true 4 | name = "pypi" 5 | 6 | [packages] 7 | ply = ">=3.9" 8 | python-dateutil = "~=2.7" 9 | 10 | [dev-packages] 11 | sphinx = "*" 12 | sphinx-rtd-theme = "*" 13 | graphviz = "*" 14 | coverage = "*" 15 | ipython = "*" 16 | setuptools = "*" 17 | 18 | [scripts] 19 | tests = 'sh -c "PYTHONPATH=$(pwd)/lib python -m unittest -v tests"' 20 | tests-coverage = 'sh -c "PYTHONPATH=$(pwd)/lib coverage run -m unittest -v tests && coverage report --include=\"*/rule_engine/*\""' 21 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Rule Engine 2 | =========== 3 | |badge-build| |badge-pypi| 4 | 5 | A lightweight, optionally typed expression language with a custom grammar for matching arbitrary Python objects. 6 | 7 | Documentation is available at https://zeroSteiner.github.io/rule-engine/. 8 | 9 | :Warning: 10 | The next major version (5.0) will remove support Python versions 3.6, 3.7 and 3.8. There is currently no timeline for 11 | its release. 12 | 13 | Rule Engine expressions are written in their own language, defined as strings in Python. The syntax is most similar to 14 | Python with some inspiration from Ruby. Some features of this language includes: 15 | 16 | - Optional type hinting 17 | - Matching strings with regular expressions 18 | - Datetime datatypes 19 | - Compound datatypes (equivalents for Python dict, list and set types) 20 | - Data attributes 21 | - Thread safety 22 | 23 | Example Usage 24 | ------------- 25 | The following example demonstrates the basic usage of defining a rule object and applying it to two dictionaries, 26 | showing that one matches while the other does not. See `Getting Started`_ for more information. 27 | 28 | .. code-block:: python 29 | 30 | import rule_engine 31 | # match a literal first name and applying a regex to the email 32 | rule = rule_engine.Rule( 33 | 'first_name == "Luke" and email =~ ".*@rebels.org$"' 34 | ) # => 35 | rule.matches({ 36 | 'first_name': 'Luke', 'last_name': 'Skywalker', 'email': 'luke@rebels.org' 37 | }) # => True 38 | rule.matches({ 39 | 'first_name': 'Darth', 'last_name': 'Vader', 'email': 'dvader@empire.net' 40 | }) # => False 41 | 42 | The next example demonstrates the optional type system. A custom context is created that defines two symbols, one string 43 | and one float. Because symbols are defined, an exception will be raised if an unknown symbol is specified or an invalid 44 | operation is used. See `Type Hinting`_ for more information. 45 | 46 | .. code-block:: python 47 | 48 | import rule_engine 49 | # define the custom context with two symbols 50 | context = rule_engine.Context(type_resolver=rule_engine.type_resolver_from_dict({ 51 | 'first_name': rule_engine.DataType.STRING, 52 | 'age': rule_engine.DataType.FLOAT 53 | })) 54 | 55 | # receive an error when an unknown symbol is used 56 | rule = rule_engine.Rule('last_name == "Vader"', context=context) 57 | # => SymbolResolutionError: last_name 58 | 59 | # receive an error when an invalid operation is used 60 | rule = rule_engine.Rule('first_name + 1', context=context) 61 | # => EvaluationError: data type mismatch 62 | 63 | Want to give the rule expression language a try? Checkout the `Debug REPL`_ that makes experimentation easy. After 64 | installing just run ``python -m rule_engine.debug_repl``. 65 | 66 | Installation 67 | ------------ 68 | Install the latest release from PyPi using ``pip install rule-engine``. Releases follow `Semantic Versioning`_ to 69 | indicate in each new version whether it fixes bugs, adds features or breaks backwards compatibility. See the 70 | `Change Log`_ for a curated list of changes. 71 | 72 | Credits 73 | ------- 74 | * Spencer McIntyre - zeroSteiner |social-github| 75 | 76 | License 77 | ------- 78 | The Rule Engine library is released under the BSD 3-Clause license. It is able to be used for both commercial and 79 | private purposes. For more information, see the `LICENSE`_ file. 80 | 81 | .. |badge-build| image:: https://img.shields.io/github/actions/workflow/status/zeroSteiner/rule-engine/ci.yml?branch=master&style=flat-square 82 | :alt: GitHub Workflow Status (branch) 83 | :target: https://github.com/zeroSteiner/rule-engine/actions/workflows/ci.yml 84 | 85 | .. |badge-pypi| image:: https://img.shields.io/pypi/v/rule-engine?style=flat-square 86 | :alt: PyPI 87 | :target: https://pypi.org/project/rule-engine/ 88 | 89 | .. |social-github| image:: https://img.shields.io/github/followers/zeroSteiner?style=social 90 | :alt: GitHub followers 91 | :target: https://github.com/zeroSteiner 92 | 93 | .. |social-twitter| image:: https://img.shields.io/twitter/follow/zeroSteiner 94 | :alt: Twitter Follow 95 | :target: https://twitter.com/zeroSteiner 96 | 97 | .. _Change Log: https://zerosteiner.github.io/rule-engine/change_log.html 98 | .. _Debug REPL: https://zerosteiner.github.io/rule-engine/debug_repl.html 99 | .. _Getting Started: https://zerosteiner.github.io/rule-engine/getting_started.html 100 | .. _LICENSE: https://github.com/zeroSteiner/rule-engine/blob/master/LICENSE 101 | .. _Semantic Versioning: https://semver.org/ 102 | .. _Type Hinting: https://zerosteiner.github.io/rule-engine/getting_started.html#type-hinting 103 | -------------------------------------------------------------------------------- /docs/source/_static/theme_overrides.css: -------------------------------------------------------------------------------- 1 | /* override table width restrictions */ 2 | @media screen and (min-width: 767px) { 3 | 4 | .wy-table-responsive table td { 5 | /* !important prevents the common CSS stylesheets from overriding 6 | this as on RTD they are loaded after this stylesheet */ 7 | white-space: normal !important; 8 | } 9 | 10 | .wy-table-responsive { 11 | overflow: visible !important; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /docs/source/attributes.rst: -------------------------------------------------------------------------------- 1 | .. py:currentmodule:: rule_engine 2 | 3 | Data Attributes 4 | =============== 5 | The attribute operator (``.``) can be used to recursively resolve values from a compound native Python data type such as 6 | an object or dictionary. This can be used when the **thing** which the rule is evaluating has members with their own 7 | submembers. If the resolver function fails, the attribute will be checked to determine if it is a builtin attribute. 8 | 9 | .. _builtin-attributes: 10 | 11 | Builtin Attributes 12 | ------------------ 13 | The following attributes are builtin to the default :py:class:`~.Context` object. 14 | 15 | +-------------------------+-----------------------------------------------+ 16 | | Attribute Name | Attribute Type | 17 | +-------------------------+-----------------------------------------------+ 18 | | :py:attr:`~.DataType.ARRAY` **Attributes** | 19 | +-------------------------+-----------------------------------------------+ 20 | | ``ends_with(prefix)`` | :ref:`FUNCTION` | 21 | +-------------------------+-----------------------------------------------+ 22 | | ``is_empty`` | :py:attr:`~.DataType.BOOLEAN` | 23 | +-------------------------+-----------------------------------------------+ 24 | | ``length`` | :py:attr:`~.DataType.FLOAT` | 25 | +-------------------------+-----------------------------------------------+ 26 | | ``starts_with(prefix)`` | :ref:`FUNCTION` | 27 | +-------------------------+-----------------------------------------------+ 28 | | ``to_ary`` | :py:attr:`~.DataType.ARRAY` | 29 | +-------------------------+-----------------------------------------------+ 30 | | ``to_set`` | :py:attr:`~.DataType.SET` | 31 | +-------------------------+-----------------------------------------------+ 32 | | :py:attr:`~.DataType.BYTES` **Attributes** | 33 | +-------------------------+-----------------------------------------------+ 34 | | ``ends_with(prefix)`` | :ref:`FUNCTION` | 35 | +-------------------------+-----------------------------------------------+ 36 | | ``is_empty`` | :py:attr:`~.DataType.BOOLEAN` | 37 | +-------------------------+-----------------------------------------------+ 38 | | ``length`` | :py:attr:`~.DataType.FLOAT` | 39 | +-------------------------+-----------------------------------------------+ 40 | | ``starts_with(prefix)`` | :ref:`FUNCTION` | 41 | +-------------------------+-----------------------------------------------+ 42 | | ``to_ary`` | :py:attr:`~.DataType.ARRAY` | 43 | +-------------------------+-----------------------------------------------+ 44 | | ``to_set`` | :py:attr:`~.DataType.SET` | 45 | +-------------------------+-----------------------------------------------+ 46 | | ``decode(encoding)`` | :ref:`FUNCTION` | 47 | +-------------------------+-----------------------------------------------+ 48 | | :py:attr:`~.DataType.DATETIME` **Attributes** | 49 | +-------------------------+-----------------------------------------------+ 50 | | ``date`` | :py:attr:`~.DataType.DATETIME` | 51 | +-------------------------+-----------------------------------------------+ 52 | | ``day`` | :py:attr:`~.DataType.FLOAT` | 53 | +-------------------------+-----------------------------------------------+ 54 | | ``hour`` | :py:attr:`~.DataType.FLOAT` | 55 | +-------------------------+-----------------------------------------------+ 56 | | ``microsecond`` | :py:attr:`~.DataType.FLOAT` | 57 | +-------------------------+-----------------------------------------------+ 58 | | ``millisecond`` | :py:attr:`~.DataType.FLOAT` | 59 | +-------------------------+-----------------------------------------------+ 60 | | ``minute`` | :py:attr:`~.DataType.FLOAT` | 61 | +-------------------------+-----------------------------------------------+ 62 | | ``month`` | :py:attr:`~.DataType.FLOAT` | 63 | +-------------------------+-----------------------------------------------+ 64 | | ``second`` | :py:attr:`~.DataType.FLOAT` | 65 | +-------------------------+-----------------------------------------------+ 66 | | ``to_epoch`` | :py:attr:`~.DataType.FLOAT` | 67 | +-------------------------+-----------------------------------------------+ 68 | | ``weekday`` | :py:attr:`~.DataType.STRING` | 69 | +-------------------------+-----------------------------------------------+ 70 | | ``year`` | :py:attr:`~.DataType.FLOAT` | 71 | +-------------------------+-----------------------------------------------+ 72 | | ``zone_name`` | :py:attr:`~.DataType.STRING` | 73 | +-------------------------+-----------------------------------------------+ 74 | | :py:attr:`~.DataType.FLOAT` **Attributes** :sup:`1` | 75 | +-------------------------+-----------------------------------------------+ 76 | | ``ceiling`` | :py:attr:`~.DataType.FLOAT` | 77 | +-------------------------+-----------------------------------------------+ 78 | | ``floor`` | :py:attr:`~.DataType.FLOAT` | 79 | +-------------------------+-----------------------------------------------+ 80 | | ``is_nan`` | :py:attr:`~.DataType.BOOLEAN` | 81 | +-------------------------+-----------------------------------------------+ 82 | | ``to_flt`` | :py:attr:`~.DataType.FLOAT` | 83 | +-------------------------+-----------------------------------------------+ 84 | | ``to_str`` | :py:attr:`~.DataType.STRING` | 85 | +-------------------------+-----------------------------------------------+ 86 | | :py:attr:`~.DataType.MAPPING` **Attributes** | 87 | +-------------------------+-----------------------------------------------+ 88 | | ``is_empty`` | :py:attr:`~.DataType.BOOLEAN` | 89 | +-------------------------+-----------------------------------------------+ 90 | | ``keys`` | :py:attr:`~.DataType.ARRAY` | 91 | +-------------------------+-----------------------------------------------+ 92 | | ``length`` | :py:attr:`~.DataType.FLOAT` | 93 | +-------------------------+-----------------------------------------------+ 94 | | ``values`` | :py:attr:`~.DataType.ARRAY` | 95 | +-------------------------+-----------------------------------------------+ 96 | | :py:attr:`~.DataType.SET` **Attributes** | 97 | +-------------------------+-----------------------------------------------+ 98 | | ``is_empty`` | :py:attr:`~.DataType.BOOLEAN` | 99 | +-------------------------+-----------------------------------------------+ 100 | | ``length`` | :py:attr:`~.DataType.FLOAT` | 101 | +-------------------------+-----------------------------------------------+ 102 | | ``to_ary`` | :py:attr:`~.DataType.ARRAY` | 103 | +-------------------------+-----------------------------------------------+ 104 | | ``to_set`` | :py:attr:`~.DataType.SET` | 105 | +-------------------------+-----------------------------------------------+ 106 | | :py:attr:`~.DataType.STRING` **Attributes** | 107 | +-------------------------+-----------------------------------------------+ 108 | | ``as_lower`` | :py:attr:`~.DataType.STRING` | 109 | +-------------------------+-----------------------------------------------+ 110 | | ``as_upper`` | :py:attr:`~.DataType.STRING` | 111 | +-------------------------+-----------------------------------------------+ 112 | | ``encode(encoding)`` | :ref:`FUNCTION` | 113 | +-------------------------+-----------------------------------------------+ 114 | | ``ends_with(prefix)`` | :ref:`FUNCTION` | 115 | +-------------------------+-----------------------------------------------+ 116 | | ``to_ary`` | :py:attr:`~.DataType.ARRAY` | 117 | +-------------------------+-----------------------------------------------+ 118 | | ``to_flt`` | :py:attr:`~.DataType.FLOAT` | 119 | +-------------------------+-----------------------------------------------+ 120 | | ``to_set`` | :py:attr:`~.DataType.SET` | 121 | +-------------------------+-----------------------------------------------+ 122 | | ``to_str`` | :py:attr:`~.DataType.STRING` | 123 | +-------------------------+-----------------------------------------------+ 124 | | ``to_int`` | :py:attr:`~.DataType.FLOAT` | 125 | +-------------------------+-----------------------------------------------+ 126 | | ``is_empty`` | :py:attr:`~.DataType.BOOLEAN` | 127 | +-------------------------+-----------------------------------------------+ 128 | | ``length`` | :py:attr:`~.DataType.FLOAT` | 129 | +-------------------------+-----------------------------------------------+ 130 | | ``starts_with(prefix)`` | :ref:`FUNCTION` | 131 | +-------------------------+-----------------------------------------------+ 132 | | :py:attr:`~.DataType.TIMEDELTA` **Attributes** | 133 | +-------------------------+-----------------------------------------------+ 134 | | ``days`` | :py:attr:`~.DataType.FLOAT` | 135 | +-------------------------+-----------------------------------------------+ 136 | | ``seconds`` | :py:attr:`~.DataType.FLOAT` | 137 | +-------------------------+-----------------------------------------------+ 138 | | ``microseconds`` | :py:attr:`~.DataType.FLOAT` | 139 | +-------------------------+-----------------------------------------------+ 140 | | ``total_seconds`` | :py:attr:`~.DataType.FLOAT` | 141 | +-------------------------+-----------------------------------------------+ 142 | 143 | FLOAT Attributes :sup:`1` 144 | ^^^^^^^^^^^^^^^^^^^^^^^^^ 145 | Due to the syntax of floating point literals, the attributes must be accessed using parenthesis. For example 146 | ``3.14.to_str`` is invalid while ``(3.14).to_str`` is valid. 147 | 148 | Encoding and Decoding 149 | ^^^^^^^^^^^^^^^^^^^^^ 150 | :py:attr:`~.DataType.BYTES` values can be converted to :py:attr:`~.DataType.STRING` values by calling their ``.decode`` 151 | method. :py:attr:`~.DataType.STRING` values can be converted to :py:attr:`~.DataType.BYTES` values by calling their 152 | ``.encode`` method. This resembles Python's native functionality and the ``encoding`` argument to each is the same, i.e. 153 | it can be any encoding name that Python can handle such as ``UTF-8``. In addition to the encoding names that Python can 154 | handle, the special names ``hex``, ``base16`` and ``base64`` can be used for transcoding ascii-hex, and base-64 155 | formatted data. 156 | 157 | Object Methods 158 | ^^^^^^^^^^^^^^ 159 | Much like in Python, a method is a function that is associated with an object. They are defined as 160 | :py:attr:`~.DataType.FUNCTION` values and are accessed as attributes. 161 | 162 | .. _builtin-method-decode: 163 | 164 | ``BYTES decode(STRING encoding) -> STRING`` 165 | 166 | :returns: :py:attr:`~.DataType.STRING` 167 | :encoding: (:py:attr:`~.DataType.STRING`) The encoding name to use. 168 | 169 | Returns the decoded value. 170 | 171 | .. versionadded:: 4.5.0 172 | 173 | .. _builtin-method-encode: 174 | 175 | ``STRING encode(STRING encoding) -> BYTES`` 176 | 177 | :returns: :py:attr:`~.DataType.BYTES` 178 | :encoding: (:py:attr:`~.DataType.STRING`) The encoding name to use. 179 | 180 | Returns the encoded value. 181 | 182 | .. versionadded:: 4.5.0 183 | 184 | .. _builtin-method-ends-with: 185 | 186 | ``ARRAY | BYTES | STRING ends_with(ARRAY | BYTES | STRING suffix) -> BOOLEAN`` 187 | 188 | :returns: :py:attr:`~.DataType.BOOLEAN` 189 | :suffix: The suffix to check for. The data type must match the object type. 190 | 191 | Check whether the value ends with the specified value. 192 | 193 | .. versionadded:: 4.5.0 194 | 195 | .. _builtin-method-starts-with: 196 | 197 | ``ARRAY | BYTES | STRING starts_with(ARRAY | BYTES | STRING prefix) -> BOOLEAN`` 198 | 199 | :returns: :py:attr:`~.DataType.BOOLEAN` 200 | :prefix: The prefix to check for. The data type must match the object type. 201 | 202 | Check whether the value starts with the specified value. 203 | 204 | .. versionadded:: 4.5.0 205 | -------------------------------------------------------------------------------- /docs/source/change_log.rst: -------------------------------------------------------------------------------- 1 | Change Log 2 | ========== 3 | 4 | This document contains notes on the major changes for each version of the Rule Engine. In comparison to the git log, 5 | this list is curated by the development team for note worthy changes. 6 | 7 | Version 4.x.x 8 | ------------- 9 | 10 | Version 4.5.0 11 | ^^^^^^^^^^^^^ 12 | 13 | Released :release:`4.5.0` on June 19th, 2024 14 | 15 | * :py:class:`~rule_engine.errors.StringSyntaxError` is now raised for invalid string literals 16 | * :py:class:`~rule_engine.errors.FunctionCallError` is now raised when a typed function returns an incompatible value 17 | * Added the new :py:class:`~rule_engine.types.DataType.BYTES` data type 18 | * Added some new data attributes 19 | 20 | * Added ``starts_with`` and ``ends_with`` to :py:class:`~rule_engine.types.DataType.ARRAY`, and 21 | :py:class:`~rule_engine.types.DataType.STRING` 22 | 23 | Version 4.4.0 24 | ^^^^^^^^^^^^^ 25 | 26 | Released :release:`4.4.0` on April 5th, 2024 27 | 28 | * Added the ``$range`` builtin function 29 | * Added the ``rule_engine.parser.utilities`` module with a few functions and documentation 30 | 31 | Version 4.3.0 32 | ^^^^^^^^^^^^^ 33 | 34 | Released :release:`4.3.0` on January 15th, 2024 35 | 36 | * Added the ``is_nan`` attribute for ``FLOAT`` values 37 | 38 | Version 4.2.0 39 | ^^^^^^^^^^^^^ 40 | 41 | Released :release:`4.2.0` on December 11th, 2023 42 | 43 | * Added attributes for coercion of types to themselves, e.g. ``to_str`` for ``STRING`` values 44 | 45 | Version 4.1.0 46 | ^^^^^^^^^^^^^ 47 | 48 | Released :release:`4.1.0` on August 3rd, 2023 49 | 50 | * Added the ``$abs`` builtin function 51 | * Added support to :py:class:`~rule_engine.types.DataType.from_type` to handle Python's type hints 52 | 53 | Version 4.0.0 54 | ^^^^^^^^^^^^^ 55 | 56 | Released :release:`4.0.0` on July 15th, 2023 57 | 58 | * **Breaking:** Changed ``STRING.to_ary`` to return an array of characters instead of splitting the string 59 | 60 | * Use the new builtin ``$split`` function to split a string on whitespace into an array of words 61 | 62 | * **Breaking:** Changed :py:class:`~rule_engine.engine.Context` to use keyword-only arguments 63 | * **Breaking:** Dropped support for Python versions 3.4 and 3.5 64 | * **Breaking:** Invalid floating point literals now raise :py:exc:`~.errors.FloatSyntaxError` instead of 65 | :py:exc:`~.errors.RuleSyntaxError` 66 | * **Breaking:** Moved ``rule_engine.engine.Builtins`` to :py:class:`rule_engine.builtins.Builtins` 67 | * Added the new :py:class:`~rule_engine.types.DataType.FUNCTION` data type 68 | 69 | Version 3.x.x 70 | ------------- 71 | 72 | Version 3.6.0 73 | ^^^^^^^^^^^^^ 74 | 75 | Released :release:`3.6.0` on June 16th, 2023 76 | 77 | * Removed testing for Python versions 3.4 and 3.5 on GitHub Actions 78 | * Add regex error details to the debug REPL 79 | * Add support for Python-style comments 80 | 81 | Version 3.5.0 82 | ^^^^^^^^^^^^^ 83 | 84 | Released :release:`3.5.0` on July 16th, 2022 85 | 86 | * Added the new :py:class:`~rule_engine.types.DataType.TIMEDELTA` data type 87 | 88 | Version 3.4.0 89 | ^^^^^^^^^^^^^ 90 | 91 | Released :release:`3.4.0` on March 19th, 2022 92 | 93 | * Add support for string concatenation via the ``+`` operator 94 | 95 | Version 3.3.0 96 | ^^^^^^^^^^^^^ 97 | 98 | Released :release:`3.3.0` on July 20th, 2021 99 | 100 | * Added ``to_epoch`` to :py:class:`~rule_engine.types.DataType.DATETIME` 101 | 102 | Version 3.2.0 103 | ^^^^^^^^^^^^^ 104 | 105 | Released :release:`3.2.0` on April 3rd, 2021 106 | 107 | * Refactored the :py:mod:`~rule_engine.ast` module to move the :py:class:`~rule_engine.types.DataType` class into a new, 108 | dedicated :py:mod:`~rule_engine.types` module. 109 | * Added the new :py:class:`~rule_engine.ast.ComprehensionExpression` 110 | * Added suggestions to :py:class:`~rule_engine.errors.AttributeResolutionError` and 111 | :py:class:`~rule_engine.errors.SymbolResolutionError` 112 | 113 | Version 3.1.0 114 | ^^^^^^^^^^^^^ 115 | 116 | Released :release:`3.1.0` on March 15th, 2021 117 | 118 | * Added the new :py:class:`~rule_engine.types.DataType.SET` data type 119 | 120 | Version 3.0.0 121 | ^^^^^^^^^^^^^ 122 | 123 | Released :release:`3.0.0` on March 1st, 2021 124 | 125 | * Switched the ``FLOAT`` datatype to use Python's :py:class:`~decimal.Decimal` from :py:class:`float` internally 126 | * Reserved the ``if``, ``elif``, ``else``, ``for`` and ``while`` keywords for future use, they can no longer be used as 127 | symbol names 128 | * Added some new data attributes 129 | 130 | * Added ``ceiling``, ``floor`` and ``to_str`` to :py:class:`~rule_engine.types.DataType.FLOAT` 131 | 132 | Version 2.x.x 133 | ------------- 134 | 135 | Version 2.4.0 136 | ^^^^^^^^^^^^^ 137 | 138 | Released :release:`2.4.0` on November 7th, 2020 139 | 140 | * Added the :ref:`debug-repl` utility 141 | * Added the safe navigation version of the attribute, item and slice operators 142 | * Added the new :py:class:`~rule_engine.types.DataType.MAPPING` data type 143 | * Switched from Travis-CI to GitHub Actions for continuous integration 144 | * Added support for iterables to have multiple member types 145 | 146 | Version 2.3.0 147 | ^^^^^^^^^^^^^ 148 | 149 | Released :release:`2.3.0` on October 11th, 2020 150 | 151 | * Added support for arithmetic comparisons for all currently supported data types 152 | * Added support for proper type hinting of builtin symbols 153 | * Added the ``$re_groups`` builtin symbol for extracting groups from a regular expression match 154 | * Added some new data attributes 155 | 156 | * Added ``to_ary`` to :py:class:`~rule_engine.types.DataType.STRING` 157 | * Added ``to_int`` and ``to_flt`` to :py:class:`~rule_engine.types.DataType.STRING` 158 | 159 | Version 2.2.0 160 | ^^^^^^^^^^^^^ 161 | 162 | Released :release:`2.2.0` on September 9th, 2020 163 | 164 | * Added script entries to the Pipfile for development 165 | * Added support for slices on sequence data types 166 | 167 | Version 2.1.0 168 | ^^^^^^^^^^^^^ 169 | 170 | Released :release:`2.1.0` on August 3rd, 2020 171 | 172 | * Added coverage reporting to Travis-CI 173 | * Changed :py:class:`~rule_engine.types.DataType`. from an enum to a custom class 174 | * Improvements for the :py:class:`~rule_engine.types.DataType.ARRAY` data type 175 | 176 | * Added ``get[item]`` support for arrays, allowing items to be retrieved by index 177 | * Added ability for specifying the member type and optionally null 178 | 179 | Version 2.0.0 180 | ^^^^^^^^^^^^^ 181 | 182 | Released :release:`2.0.0` on October 2nd, 2019 183 | 184 | * Added proper support for attributes 185 | * Added a change log 186 | * Added additional information to the Graphviz output 187 | * Added the new :py:class:`~rule_engine.types.DataType.ARRAY` data type 188 | * Started using Travis-CI 189 | 190 | * Added automatic unit testing using Travis-CI 191 | * Added automatic deployment of documentation using Travis-CI 192 | 193 | * Removed the resolver conversion functions 194 | 195 | * Removed ``to_recursive_resolver`` in favor of attributes 196 | * Removed ``to_default_resolver`` in favor of the *default_value* kwarg to 197 | :py:meth:`~rule_engine.engine.Context.__init__` 198 | 199 | Version 1.x.x 200 | ------------- 201 | 202 | Version 1.1.0 203 | ^^^^^^^^^^^^^ 204 | 205 | Released :release:`1.1.0` on March 27th, 2019 206 | 207 | * Added the :py:func:`~rule_engine.engine.to_default_dict` function 208 | * Added the :py:func:`~rule_engine.engine.to_recursive_resolver` function 209 | 210 | Version 1.0.0 211 | ^^^^^^^^^^^^^ 212 | 213 | Released :release:`1.0.0` on December 15th, 2018 214 | 215 | * First major release 216 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Configuration file for the Sphinx documentation builder. 4 | # 5 | # This file does only contain a selection of the most common options. For a 6 | # full list see the documentation: 7 | # http://www.sphinx-doc.org/en/stable/config 8 | 9 | import datetime 10 | import os 11 | import sys 12 | 13 | # -- Path setup -------------------------------------------------------------- 14 | _prj_root = os.path.dirname(__file__) 15 | _prj_root = os.path.relpath(os.path.join('..', '..', 'lib'), _prj_root) 16 | _prj_root = os.path.abspath(_prj_root) 17 | sys.path.insert(1, _prj_root) 18 | import rule_engine 19 | 20 | # -- Project information ----------------------------------------------------- 21 | project = 'Rule Engine' 22 | copyright = "{.year}, Spencer McIntyre".format(datetime.datetime.now()) 23 | author = 'Spencer McIntyre' 24 | 25 | # The short X.Y.Z version 26 | version = rule_engine.__version__ 27 | # The full version, including alpha/beta/rc tags 28 | release = version 29 | 30 | 31 | # -- General configuration --------------------------------------------------- 32 | GITHUB_BRANCH = 'master' 33 | GITHUB_REPO = 'zeroSteiner/rule-engine' 34 | add_module_names = False 35 | 36 | extensions = [ 37 | 'sphinx.ext.autodoc', 38 | 'sphinx.ext.coverage', 39 | 'sphinx.ext.extlinks', 40 | 'sphinx.ext.githubpages', 41 | 'sphinx.ext.intersphinx', 42 | 'sphinx.ext.viewcode', 43 | ] 44 | 45 | # Add any paths that contain templates here, relative to this directory. 46 | templates_path = ['_templates'] 47 | 48 | # The suffix(es) of source filenames. 49 | # You can specify multiple suffix as a list of string: 50 | # 51 | # source_suffix = ['.rst', '.md'] 52 | source_suffix = '.rst' 53 | 54 | # The master toctree document. 55 | master_doc = 'index' 56 | 57 | # The language for content autogenerated by Sphinx. Refer to documentation 58 | # for a list of supported languages. 59 | # 60 | # This is also used if you do content translation via gettext catalogs. 61 | # Usually you set "language" from the command line for these cases. 62 | language = 'en' 63 | 64 | # List of patterns, relative to source directory, that match files and 65 | # directories to ignore when looking for source files. 66 | # This pattern also affects html_static_path and html_extra_path . 67 | exclude_patterns = [] 68 | 69 | # The name of the Pygments (syntax highlighting) style to use. 70 | pygments_style = 'sphinx' 71 | 72 | 73 | # -- Options for HTML output ------------------------------------------------- 74 | 75 | # The theme to use for HTML and HTML Help pages. See the documentation for 76 | # a list of builtin themes. 77 | # 78 | html_theme = 'sphinx_rtd_theme' 79 | 80 | # Theme options are theme-specific and customize the look and feel of a theme 81 | # further. For a list of options available for each theme, see the 82 | # documentation. 83 | # 84 | # html_theme_options = {} 85 | 86 | # Add any paths that contain custom static files (such as style sheets) here, 87 | # relative to this directory. They are copied after the builtin static files, 88 | # so a file named "default.css" will overwrite the builtin "default.css". 89 | html_static_path = ['_static'] 90 | 91 | # Custom sidebar templates, must be a dictionary that maps document names 92 | # to template names. 93 | # 94 | # The default sidebars (for documents that don't match any pattern) are 95 | # defined by theme itself. Builtin themes are using these templates by 96 | # default: ``['localtoc.html', 'relations.html', 'sourcelink.html', 97 | # 'searchbox.html']``. 98 | # 99 | # html_sidebars = {} 100 | 101 | 102 | # -- Options for HTMLHelp output --------------------------------------------- 103 | 104 | # Output file base name for HTML help builder. 105 | htmlhelp_basename = 'RuleEnginedoc' 106 | 107 | 108 | # -- Options for LaTeX output ------------------------------------------------ 109 | 110 | latex_elements = { 111 | # The paper size ('letterpaper' or 'a4paper'). 112 | # 113 | # 'papersize': 'letterpaper', 114 | 115 | # The font size ('10pt', '11pt' or '12pt'). 116 | # 117 | # 'pointsize': '10pt', 118 | 119 | # Additional stuff for the LaTeX preamble. 120 | # 121 | # 'preamble': '', 122 | 123 | # Latex figure (float) alignment 124 | # 125 | # 'figure_align': 'htbp', 126 | } 127 | 128 | # Grouping the document tree into LaTeX files. List of tuples 129 | # (source start file, target name, title, 130 | # author, documentclass [howto, manual, or own class]). 131 | latex_documents = [ 132 | (master_doc, 'RuleEngine.tex', 'Rule Engine Documentation', 133 | 'Spencer McIntyre', 'manual'), 134 | ] 135 | 136 | 137 | # -- Options for manual page output ------------------------------------------ 138 | 139 | # One entry per manual page. List of tuples 140 | # (source start file, name, description, authors, manual section). 141 | man_pages = [ 142 | (master_doc, 'ruleengine', 'Rule Engine Documentation', 143 | [author], 1) 144 | ] 145 | 146 | 147 | # -- Options for Texinfo output ---------------------------------------------- 148 | 149 | # Grouping the document tree into Texinfo files. List of tuples 150 | # (source start file, target name, title, author, 151 | # dir menu entry, description, category) 152 | texinfo_documents = [ 153 | (master_doc, 'RuleEngine', 'Rule Engine Documentation', 154 | author, 'RuleEngine', 'One line description of project.', 155 | 'Miscellaneous'), 156 | ] 157 | 158 | 159 | # -- Extension configuration ------------------------------------------------- 160 | extlinks = { 161 | 'issue': ("https://github.com/{0}/issues/%s".format(GITHUB_REPO), '#%s'), 162 | 'release': ("https://github.com/{0}/releases/tag/v%s".format(GITHUB_REPO), 'v%s'), 163 | 'wiki': ("https://github.com/{0}/wiki/%s".format(GITHUB_REPO), '%s'), 164 | } 165 | 166 | intersphinx_mapping = { 167 | 'dateutil': ('https://dateutil.readthedocs.io/en/stable/', None), 168 | 'python': ("https://docs.python.org/{version.major}.{version.minor}".format(version=sys.version_info), None) 169 | } 170 | 171 | def setup(app): 172 | app.add_css_file('theme_overrides.css') 173 | 174 | def linkcode_resolve(domain, info): 175 | if domain != 'py': 176 | return None 177 | if not info['module']: 178 | return None 179 | file_name = info['module'].replace('.', '/') + '.py' 180 | return "https://github.com/{0}/blob/{1}/{2}".format(GITHUB_REPO, GITHUB_BRANCH, file_name) 181 | -------------------------------------------------------------------------------- /docs/source/debug_repl.rst: -------------------------------------------------------------------------------- 1 | .. _debug-repl: 2 | 3 | Debug REPL 4 | ========== 5 | Since version :release:`2.4.0`, the Rule Engine package includes a module which provides a Read Eval Print Loop (REPL) 6 | for debugging and testing purposes. This module can be executed using ``python -m rule_engine.debug_repl``. Once 7 | started, the REPL loop can be used to evaluate rule expressions and view the results. 8 | 9 | CLI Arguments 10 | ------------- 11 | 12 | The module when executed from the command line has the following options available. 13 | 14 | .. program:: debug_repl 15 | 16 | .. option:: --edit-console 17 | 18 | Start an interactive Python console that allows the user to setup the environment for the rule evaluation. 19 | 20 | .. option:: --edit-file 21 | 22 | Run the specified file containing Python source code, allowing it to setup the environment for the rule evaluation. 23 | 24 | Configuration 25 | ------------- 26 | When configured through either the ``--edit-console`` or ``--edit-file`` options, the ``context`` symbol may be 27 | customized using a user-defined :py:class:`~rule_engine.engine.Context` instance. Additionally, the object to evaluate 28 | can be configured through the ``thing`` symbol. 29 | 30 | Example Usage 31 | ------------- 32 | The following example demonstrates using the Debug REPL with a *thing* (in this case a comic book) defined through the 33 | interactive console. 34 | 35 | .. code-block:: text 36 | 37 | python -m rule_engine.debug_repl --edit-console 38 | edit the 'context' and 'thing' objects as necessary 39 | >>> thing = dict(title='Batman', publisher='DC', issue=1) 40 | >>> exit() 41 | exiting the edit console... 42 | rule > title == 'Superman' 43 | result: 44 | False 45 | rule > issue < 5 46 | result: 47 | True 48 | rule > 49 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | Rule Engine Documentation 2 | ========================= 3 | This project provides a library for creating general purpose "Rule" objects from a logical expression which can then be 4 | applied to arbitrary objects to evaluate whether or not they match. 5 | 6 | Documentation is available at https://zeroSteiner.github.io/rule-engine/. 7 | 8 | Rule Engine expressions are written in their own language, defined as strings in Python. Some features of this language 9 | includes: 10 | 11 | * Optional type hinting 12 | * Matching strings with regular expressions 13 | * Datetime datatypes 14 | * Data attributes 15 | 16 | Usage Example 17 | ------------- 18 | 19 | .. code-block:: python 20 | 21 | import rule_engine 22 | # match a literal first name and applying a regex to the email 23 | rule = rule_engine.Rule( 24 | 'first_name == "Luke" and email =~ ".*@rebels.org$"' 25 | ) # => 26 | rule.matches({ 27 | 'first_name': 'Luke', 'last_name': 'Skywalker', 'email': 'luke@rebels.org' 28 | }) # => True 29 | rule.matches({ 30 | 'first_name': 'Darth', 'last_name': 'Vader', 'email': 'dvader@empire.net' 31 | }) # => False 32 | 33 | See the `examples`_ folder for more. 34 | 35 | .. toctree:: 36 | :maxdepth: 2 37 | :caption: Contents: 38 | 39 | getting_started.rst 40 | syntax.rst 41 | types.rst 42 | attributes.rst 43 | rule_engine/index.rst 44 | debug_repl.rst 45 | change_log.rst 46 | 47 | Indices and tables 48 | ================== 49 | 50 | * :ref:`genindex` 51 | * :ref:`modindex` 52 | * :ref:`search` 53 | 54 | .. _examples: https://github.com/zeroSteiner/rule-engine/tree/master/examples 55 | .. _GitHub homepage: https://github.com/zeroSteiner/rule-engine 56 | -------------------------------------------------------------------------------- /docs/source/rule_engine/ast.rst: -------------------------------------------------------------------------------- 1 | :mod:`ast` 2 | ========== 3 | 4 | .. module:: rule_engine.ast 5 | :synopsis: 6 | 7 | This module contains the nodes which comprise the abstract syntax tree generated from parsed grammar text. 8 | 9 | .. warning:: 10 | The content of this module should be treated as private. 11 | 12 | While the code within this module is documented, it is *not* meant to be used by consumers of the package. Directly 13 | accessing and using any object or function within this module should be done with care. Breaking API changes within this 14 | module may not always cause a major version bump. The reason for this is that it is often necessary to update the AST in 15 | an API breaking way in order to add new features. 16 | 17 | Classes 18 | ------- 19 | 20 | .. autoclass:: Assignment 21 | :members: 22 | :show-inheritance: 23 | :special-members: __init__ 24 | :undoc-members: 25 | 26 | .. autoclass:: Statement 27 | :show-inheritance: 28 | 29 | Base Classes 30 | ~~~~~~~~~~~~ 31 | 32 | .. autoclass:: ExpressionBase 33 | :members: 34 | :exclude-members: result_type 35 | :show-inheritance: 36 | :special-members: __init__ 37 | 38 | .. autoattribute:: result_type 39 | :annotation: = UNDEFINED 40 | 41 | .. autoclass:: LeftOperatorRightExpressionBase 42 | :show-inheritance: 43 | 44 | .. autoattribute:: compatible_types 45 | :annotation: 46 | 47 | .. automethod:: __init__ 48 | 49 | .. autoclass:: LiteralExpressionBase 50 | :show-inheritance: 51 | 52 | .. automethod:: __init__ 53 | 54 | Left-Operator-Right Expressions 55 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 56 | 57 | .. autoclass:: AddExpression 58 | :show-inheritance: 59 | 60 | .. autoattribute:: result_type 61 | :annotation: = UNDEFINED 62 | 63 | .. autoclass:: SubtractExpression 64 | :show-inheritance: 65 | 66 | .. autoattribute:: result_type 67 | :annotation: = UNDEFINED 68 | 69 | .. autoclass:: ArithmeticExpression 70 | :show-inheritance: 71 | 72 | .. autoattribute:: result_type 73 | :annotation: = FLOAT 74 | 75 | .. autoclass:: ArithmeticComparisonExpression 76 | :show-inheritance: 77 | 78 | .. autoattribute:: result_type 79 | :annotation: = BOOLEAN 80 | 81 | .. autoclass:: BitwiseExpression 82 | :show-inheritance: 83 | 84 | .. autoattribute:: result_type 85 | :annotation: = UNDEFINED 86 | 87 | .. autoclass:: BitwiseShiftExpression 88 | :show-inheritance: 89 | 90 | .. autoattribute:: result_type 91 | :annotation: = FLOAT 92 | 93 | .. autoclass:: ComparisonExpression 94 | :show-inheritance: 95 | 96 | .. autoattribute:: result_type 97 | :annotation: = BOOLEAN 98 | 99 | .. autoclass:: LogicExpression 100 | :show-inheritance: 101 | 102 | .. autoattribute:: result_type 103 | :annotation: = BOOLEAN 104 | 105 | .. autoclass:: FuzzyComparisonExpression 106 | :show-inheritance: 107 | 108 | .. autoattribute:: result_type 109 | :annotation: = BOOLEAN 110 | 111 | Literal Expressions 112 | ~~~~~~~~~~~~~~~~~~~ 113 | 114 | .. autoclass:: ArrayExpression 115 | :show-inheritance: 116 | 117 | .. autoattribute:: result_type 118 | :annotation: = ARRAY 119 | 120 | .. autoclass:: BooleanExpression 121 | :show-inheritance: 122 | 123 | .. autoattribute:: result_type 124 | :annotation: = BOOLEAN 125 | 126 | .. autoclass:: BytesExpression 127 | :show-inheritance: 128 | 129 | .. autoattribute:: result_type 130 | :annotation: = BYTES 131 | 132 | .. autoclass:: DatetimeExpression 133 | :show-inheritance: 134 | 135 | .. autoattribute:: result_type 136 | :annotation: = DATETIME 137 | 138 | .. autoclass:: FloatExpression 139 | :show-inheritance: 140 | 141 | .. autoattribute:: result_type 142 | :annotation: = FLOAT 143 | 144 | .. autoclass:: FunctionExpression 145 | :show-inheritance: 146 | 147 | .. autoattribute:: result_type 148 | :annotation: = FUNCTION 149 | 150 | .. autoclass:: MappingExpression 151 | :show-inheritance: 152 | 153 | .. autoattribute:: result_type 154 | :annotation: = MAPPING 155 | 156 | .. autoclass:: NullExpression 157 | :show-inheritance: 158 | 159 | .. autoattribute:: result_type 160 | :annotation: = NULL 161 | 162 | .. autoclass:: SetExpression 163 | :show-inheritance: 164 | 165 | .. autoattribute:: result_type 166 | :annotation: = SET 167 | 168 | .. autoclass:: StringExpression 169 | :show-inheritance: 170 | 171 | .. autoattribute:: result_type 172 | :annotation: = STRING 173 | 174 | .. autoclass:: TimedeltaExpression 175 | :show-inheritance: 176 | 177 | 178 | .. autoattribute:: result_type 179 | :annotation: = TIMEDELTA 180 | 181 | Miscellaneous Expressions 182 | ~~~~~~~~~~~~~~~~~~~~~~~~~ 183 | 184 | .. autoclass:: ComprehensionExpression 185 | :show-inheritance: 186 | 187 | .. autoattribute:: result_type 188 | :annotation: = ARRAY 189 | 190 | .. autoclass:: ContainsExpression 191 | :show-inheritance: 192 | 193 | .. autoattribute:: result_type 194 | :annotation: = BOOLEAN 195 | 196 | .. autoclass:: GetAttributeExpression 197 | :show-inheritance: 198 | 199 | .. autoattribute:: result_type 200 | :annotation: = UNDEFINED 201 | 202 | .. autoclass:: GetItemExpression 203 | :show-inheritance: 204 | 205 | .. autoattribute:: result_type 206 | :annotation: = UNDEFINED 207 | 208 | .. autoclass:: GetSliceExpression 209 | :show-inheritance: 210 | 211 | .. autoattribute:: result_type 212 | :annotation: = UNDEFINED 213 | 214 | .. autoclass:: SymbolExpression 215 | :show-inheritance: 216 | 217 | .. autoattribute:: result_type 218 | :annotation: = UNDEFINED 219 | 220 | .. autoclass:: TernaryExpression 221 | :show-inheritance: 222 | 223 | .. autoattribute:: result_type 224 | :annotation: = UNDEFINED 225 | 226 | .. autoclass:: UnaryExpression 227 | :show-inheritance: 228 | 229 | .. autoattribute:: result_type 230 | :annotation: = UNDEFINED 231 | -------------------------------------------------------------------------------- /docs/source/rule_engine/builtins.rst: -------------------------------------------------------------------------------- 1 | :mod:`builtins` 2 | =============== 3 | 4 | .. module:: rule_engine.builtins 5 | :synopsis: 6 | 7 | This module contains the class which defines the builtin values for the engine. 8 | 9 | .. autoclass:: Builtins 10 | :members: 11 | :show-inheritance: 12 | :special-members: __init__ 13 | :undoc-members: 14 | -------------------------------------------------------------------------------- /docs/source/rule_engine/engine.rst: -------------------------------------------------------------------------------- 1 | :mod:`engine` 2 | ============= 3 | 4 | .. module:: rule_engine.engine 5 | :synopsis: 6 | 7 | This module contains the primary externally facing API for the package. 8 | 9 | Functions 10 | --------- 11 | 12 | .. autofunction:: resolve_attribute 13 | 14 | .. autofunction:: resolve_item 15 | 16 | .. autofunction:: type_resolver_from_dict 17 | 18 | Classes 19 | ------- 20 | 21 | .. autoclass:: Context 22 | :members: 23 | :show-inheritance: 24 | :special-members: __init__ 25 | :undoc-members: 26 | 27 | .. autoclass:: Rule 28 | :members: 29 | :show-inheritance: 30 | :special-members: __init__ 31 | :undoc-members: 32 | -------------------------------------------------------------------------------- /docs/source/rule_engine/errors.rst: -------------------------------------------------------------------------------- 1 | :mod:`errors` 2 | ============= 3 | 4 | .. module:: rule_engine.errors 5 | :synopsis: 6 | 7 | This module contains the exceptions raised by the package. 8 | 9 | Data 10 | ---- 11 | 12 | .. autodata:: UNDEFINED 13 | 14 | Exceptions 15 | ---------- 16 | 17 | .. autoexception:: AttributeResolutionError 18 | :members: 19 | :show-inheritance: 20 | :special-members: __init__ 21 | 22 | .. autoexception:: AttributeTypeError 23 | :members: 24 | :show-inheritance: 25 | :special-members: __init__ 26 | 27 | .. autoexception:: BytesSyntaxError 28 | :members: 29 | :show-inheritance: 30 | :special-members: __init__ 31 | 32 | .. autoexception:: DatetimeSyntaxError 33 | :members: 34 | :show-inheritance: 35 | :special-members: __init__ 36 | 37 | .. autoexception:: FloatSyntaxError 38 | :members: 39 | :show-inheritance: 40 | :special-members: __init__ 41 | 42 | .. autoexception:: EngineError 43 | :members: 44 | :show-inheritance: 45 | :special-members: __init__ 46 | 47 | .. autoexception:: EvaluationError 48 | :members: 49 | :show-inheritance: 50 | :special-members: __init__ 51 | 52 | .. autoexception:: FunctionCallError 53 | :members: 54 | :show-inheritance: 55 | :special-members: __init__ 56 | 57 | .. autoexception:: LookupError 58 | :members: 59 | :show-inheritance: 60 | :special-members: __init__ 61 | 62 | .. autoexception:: RegexSyntaxError 63 | :members: 64 | :show-inheritance: 65 | :special-members: __init__ 66 | 67 | .. autoexception:: RuleSyntaxError 68 | :members: 69 | :show-inheritance: 70 | :special-members: __init__ 71 | 72 | .. autoexception:: StringSyntaxError 73 | :members: 74 | :show-inheritance: 75 | :special-members: __init__ 76 | 77 | .. autoexception:: SymbolResolutionError 78 | :members: 79 | :show-inheritance: 80 | :special-members: __init__ 81 | 82 | .. autoexception:: SymbolTypeError 83 | :members: 84 | :show-inheritance: 85 | :special-members: __init__ 86 | 87 | .. autoexception:: SyntaxError 88 | :members: 89 | :show-inheritance: 90 | :special-members: __init__ 91 | 92 | .. autoexception:: TimedeltaSyntaxError 93 | :members: 94 | :show-inheritance: 95 | :special-members: __init__ 96 | 97 | Exception Hierarchy 98 | ------------------- 99 | 100 | The class hierarchy for Rule Engine exceptions is: 101 | 102 | .. code-block:: text 103 | 104 | EngineError 105 | +-- EvaluationError 106 | +-- AttributeResolutionError 107 | +-- AttributeTypeError 108 | +-- FunctionCallError 109 | +-- LookupError 110 | +-- SymbolResolutionError 111 | +-- SymbolTypeError 112 | +-- SyntaxError 113 | +-- BytesSyntaxError 114 | +-- DatetimeSyntaxError 115 | +-- FloatSyntaxError 116 | +-- RegexSyntaxError 117 | +-- RuleSyntaxError 118 | +-- StringSyntaxError 119 | +-- TimedeltaSyntaxError 120 | -------------------------------------------------------------------------------- /docs/source/rule_engine/index.rst: -------------------------------------------------------------------------------- 1 | :mod:`rule_engine` 2 | ================== 3 | 4 | .. toctree:: 5 | :maxdepth: 2 6 | :titlesonly: 7 | 8 | ast.rst 9 | builtins.rst 10 | engine.rst 11 | errors.rst 12 | parser/index.rst 13 | suggestions.rst 14 | types.rst 15 | -------------------------------------------------------------------------------- /docs/source/rule_engine/parser/index.rst: -------------------------------------------------------------------------------- 1 | :mod:`parser` 2 | ============= 3 | 4 | .. module:: rule_engine.parser 5 | :synopsis: 6 | 7 | This module contains the parsing logic for taking rule text in the specified grammar and converting it to an abstract 8 | syntax tree that can later be evaluated. 9 | 10 | .. toctree:: 11 | :maxdepth: 2 12 | :titlesonly: 13 | 14 | utilities.rst 15 | 16 | Classes 17 | ------- 18 | 19 | .. autoclass:: Parser 20 | :show-inheritance: 21 | :special-members: __init__ 22 | 23 | .. autoclass:: ParserBase 24 | :members: 25 | :show-inheritance: 26 | :special-members: __init__ 27 | -------------------------------------------------------------------------------- /docs/source/rule_engine/parser/utilities.rst: -------------------------------------------------------------------------------- 1 | :mod:`utilities` 2 | ======================= 3 | 4 | .. module:: rule_engine.parser.utilities 5 | :synopsis: 6 | 7 | This module contains utility functions for parsing various strings. 8 | 9 | Functions 10 | --------- 11 | 12 | .. autofunction:: parse_datetime 13 | 14 | .. autofunction:: parse_float 15 | 16 | .. autofunction:: parse_timedelta 17 | -------------------------------------------------------------------------------- /docs/source/rule_engine/suggestions.rst: -------------------------------------------------------------------------------- 1 | :mod:`suggestions` 2 | ================== 3 | 4 | .. module:: rule_engine.suggestions 5 | :synopsis: 6 | 7 | .. versionadded:: 3.2.0 8 | 9 | This module contains the helper functions needed to make suggestions when errors occur. 10 | 11 | Functions 12 | --------- 13 | 14 | .. autofunction:: suggest_symbol -------------------------------------------------------------------------------- /docs/source/rule_engine/types.rst: -------------------------------------------------------------------------------- 1 | :mod:`types` 2 | ============ 3 | 4 | .. module:: rule_engine.types 5 | :synopsis: 6 | 7 | .. versionadded:: 3.2.0 8 | 9 | This module contains the internal type definitions and utility functions for working with them. 10 | 11 | Functions 12 | --------- 13 | 14 | .. autofunction:: coerce_value 15 | 16 | .. autofunction:: is_integer_number 17 | 18 | .. autofunction:: is_natural_number 19 | 20 | .. autofunction:: is_numeric 21 | 22 | .. autofunction:: is_real_number 23 | 24 | .. autofunction:: iterable_member_value_type 25 | 26 | Classes 27 | ------- 28 | 29 | .. autoclass:: DataType 30 | :members: 31 | :exclude-members: ARRAY, MAPPING, SET 32 | :show-inheritance: 33 | 34 | .. automethod:: ARRAY 35 | 36 | .. autoattribute:: BOOLEAN 37 | :annotation: 38 | 39 | .. autoattribute:: BYTES 40 | :annotation: 41 | 42 | .. autoattribute:: DATETIME 43 | :annotation: 44 | 45 | .. autoattribute:: FLOAT 46 | :annotation: 47 | 48 | .. automethod:: FUNCTION 49 | 50 | .. automethod:: MAPPING 51 | 52 | .. autoattribute:: NULL 53 | :annotation: 54 | 55 | .. automethod:: SET 56 | 57 | .. autoattribute:: STRING 58 | :annotation: 59 | 60 | .. autoattribute:: TIMEDELTA 61 | :annotation: 62 | 63 | .. autoattribute:: UNDEFINED 64 | -------------------------------------------------------------------------------- /docs/source/types.rst: -------------------------------------------------------------------------------- 1 | .. py:currentmodule:: rule_engine.types 2 | 3 | Data Types 4 | ========== 5 | The following table describes the data types supported by the Rule Engine and the Python data types that each is 6 | compatible with. For a information regarding supported operations, see the 7 | :ref:`Supported Operations` table. 8 | 9 | .. _data-types: 10 | 11 | +-------------------------------+-------------------------------+ 12 | | Rule Engine Data Type | Compatible Python Types | 13 | +-------------------------------+-------------------------------+ 14 | | :py:attr:`~DataType.ARRAY` | :py:class:`list`, | 15 | | | :py:class:`tuple` | 16 | +-------------------------------+-------------------------------+ 17 | | :py:attr:`~DataType.BOOLEAN` | :py:class:`bool` | 18 | +-------------------------------+-------------------------------+ 19 | | :py:attr:`~DataType.BYTES` | :py:class:`bytes` | 20 | +-------------------------------+-------------------------------+ 21 | | :py:attr:`~DataType.DATETIME` | :py:class:`datetime.date`, | 22 | | | :py:class:`datetime.datetime` | 23 | +-------------------------------+-------------------------------+ 24 | | :py:attr:`~DataType.FLOAT` | :py:class:`int`, | 25 | | | :py:class:`float` | 26 | | | :py:class:`decimal.Decimal` | 27 | +-------------------------------+-------------------------------+ 28 | | :py:attr:`~DataType.FUNCTION` | *anything callable* | 29 | +-------------------------------+-------------------------------+ 30 | | :py:attr:`~DataType.MAPPING` | :py:class:`dict` | 31 | +-------------------------------+-------------------------------+ 32 | | :py:attr:`~DataType.NULL` | :py:class:`NoneType` | 33 | +-------------------------------+-------------------------------+ 34 | | :py:attr:`~DataType.SET` | :py:class:`set` | 35 | +-------------------------------+-------------------------------+ 36 | | :py:attr:`~DataType.STRING` | :py:class:`str` | 37 | +-------------------------------+-------------------------------+ 38 | | :py:attr:`~DataType.TIMEDELTA`| :py:class:`datetime.timedelta`| 39 | +-------------------------------+-------------------------------+ 40 | 41 | Compound Types 42 | -------------- 43 | The compound data types (:py:attr:`~DataType.ARRAY`, :py:attr:`~DataType.SET`, and :py:attr:`~DataType.MAPPING`) are all 44 | capable of containing zero or more values of other data types (though it should be noted that 45 | :py:attr:`~DataType.MAPPING` keys **must be scalars** while the values can be anything). The member types of compound 46 | data types can be defined, but only if the members are all of the same type. For an example, an array containing floats 47 | can be defined, and an mapping with string keys to string values can also be defined, but a mapping with string keys to 48 | values that are either floats, strings or booleans **may not be completely defined**. For more information, see the 49 | section on :ref:`getting-started-compound-data-types` in the Getting Started page. 50 | 51 | Compound data types are also iterable, meaning that array comprehension operations can be applied to them. Iteration 52 | operations apply to the members of :py:attr:`~DataType.ARRAY` and :py:attr:`~DataType.SET` values, and the keys of 53 | :py:attr:`~DataType.MAPPING` values. This allows the types to behave in the same was as they do in Python. 54 | 55 | FLOAT 56 | ----- 57 | See :ref:`literal-float-values` for syntax. 58 | 59 | Starting in :release:`3.0.0`, the ``FLOAT`` datatype is backed by Python's :py:class:`~decimal.Decimal` object. This 60 | makes the evaluation of arithmetic more intuitive for the audience of rule authors who are not assumed to be familiar 61 | with the nuances of binary floating point arithmetic. To take an example from the :py:mod:`decimal` documentation, rule 62 | authors should not have to know that ``0.1 + 0.1 + 0.1 - 0.3 != 0``. 63 | 64 | Internally, Rule Engine conversion values from Python :py:class:`float` and :py:class:`int` objects to 65 | :py:class:`~decimal.Decimal` using their string representation (as provided by :py:func:`repr`) **and not** 66 | :py:meth:`~decimal.Decimal.from_float`. This is to ensure that a Python :py:class:`float` value of ``0.1`` that is 67 | provided by an input will match a Rule Engine literal of ``0.1``. To explicitly pass a binary floating point value, the 68 | caller must convert it using :py:meth:`~decimal.Decimal.from_float` themselves. To change the behavior of the floating 69 | point arithmetic, a :py:class:`decimal.Context` can be specified by the :py:class:`~rule_engine.engine.Context` object. 70 | 71 | Since Python's :py:class:`~decimal.Decimal` values are not always equivalent to themselves (e.g. 72 | ``0.1 != Decimal('0.1')``) it's important to know that Rule Engine will coerce and normalize these values. That means 73 | that while in Python ``0.1 in [ Decimal('0.1') ]`` will evaluate to ``False``, in a rule it will evaluate to ``True`` 74 | (e.g. ``Rule('0.1 in numbers').evaluate({'numbers': [Decimal('0.1')]})``). This also affects Python dictionaries that 75 | are converted to Rule Engine :py:attr:`~DataType.MAPPING` values. While in Python the value 76 | ``{0.1: 'a', Decimal('0.1'): 'a'}`` would have a length of 2 with two unique keys, the same value once converted into a 77 | Rule Engine :py:attr:`~DataType.MAPPING` would have a length of 1 with a single unique key. For this reason, developers 78 | using Rule Engine should take care to not use compound data types with a mix of Python :py:class:`float` and 79 | :py:class:`~decimal.Decimal` values. 80 | 81 | FUNCTION 82 | -------- 83 | Version :release:`4.0.0` added the :py:attr:`~DataType.FUNCTION` datatype. This can be used to make functions available 84 | to rule authors. Rule Engine contains a few :ref:`builtin functions` that can be used by default. 85 | Additional functions must be defined in Python and can either be added to the evaluated object or by 86 | :ref:`extending the builtin symbols`. It is only possible to call a function from within the 87 | rule text. Functions can not be defined by rule authors as other data types can be. 88 | 89 | TIMEDELTA 90 | --------- 91 | See :ref:`literal-timedelta-values` for syntax. 92 | 93 | Version :release:`3.5.0` introduced the :py:attr:`~DataType.TIMEDELTA` datatype, backed by Python's 94 | :py:class:`~datetime.timedelta` class. This also comes with the ability to perform arithmetic with both 95 | :py:attr:`~DataType.TIMEDELTA` *and* :py:attr:`~DataType.DATETIME` values. This allows you to create rules for things 96 | such as "has it been 30 days since this thing happened?" or "how much time passed between two events?". 97 | 98 | The following mathematical operations are supported: 99 | 100 | * Adding a timedelta to a datetime (result is a datetime) 101 | * Adding a timedelta to another timedelta (result is a timedelta) 102 | * Subtracting a timedelta from a datetime (result is a datetime) 103 | * Subtracting a datetime from another datetime (result is a timedelta) 104 | * Subtracting a timedelta from another timedelta (result is a timedelta) 105 | -------------------------------------------------------------------------------- /examples/csv_filter.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # examples/csv_filter.py 5 | # 6 | # Redistribution and use in source and binary forms, with or without 7 | # modification, are permitted provided that the following conditions are 8 | # met: 9 | # 10 | # * Redistributions of source code must retain the above copyright 11 | # notice, this list of conditions and the following disclaimer. 12 | # * Redistributions in binary form must reproduce the above 13 | # copyright notice, this list of conditions and the following disclaimer 14 | # in the documentation and/or other materials provided with the 15 | # distribution. 16 | # * Neither the name of the project nor the names of its 17 | # contributors may be used to endorse or promote products derived from 18 | # this software without specific prior written permission. 19 | # 20 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 21 | # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 22 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 23 | # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 24 | # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 25 | # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 26 | # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 27 | # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 28 | # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 29 | # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 30 | # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | # 32 | 33 | import argparse 34 | import csv 35 | import functools 36 | import os 37 | import sys 38 | 39 | get_path = functools.partial(os.path.join, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) 40 | sys.path.append(get_path('lib')) 41 | 42 | import rule_engine 43 | 44 | DESCRIPTION = """\ 45 | Apply a rule to the specified CSV file. The first row of the CSV file must be 46 | the field names which will be used as the symbols for the rule. 47 | """ 48 | 49 | # specify a custom resolve function that checks if the symbol is a column name 50 | # in the csv file and if not replaces underscores with spaces 51 | def resolve_item(thing, name): 52 | if not name in thing: 53 | name = name.replace('_', ' ') 54 | return rule_engine.resolve_item(thing, name) 55 | 56 | def main(): 57 | parser = argparse.ArgumentParser( 58 | conflict_handler='resolve', 59 | description=DESCRIPTION, 60 | formatter_class=argparse.RawDescriptionHelpFormatter 61 | ) 62 | parser.add_argument('csv_file', type=argparse.FileType('r'), help='the CSV file to filter') 63 | parser.add_argument('rule', help='the rule to apply') 64 | arguments = parser.parse_args() 65 | 66 | # need to define a custom context to use a custom resolver function 67 | context = rule_engine.Context(resolver=resolve_item) 68 | try: 69 | rule = rule_engine.Rule(arguments.rule, context=context) 70 | except rule_engine.RuleSyntaxError as error: 71 | print(error.message) 72 | return 0 73 | 74 | csv_reader = csv.DictReader(arguments.csv_file) 75 | csv_writer = csv.DictWriter(sys.stdout, csv_reader.fieldnames, dialect=csv_reader.dialect) 76 | for row in rule.filter(csv_reader): 77 | csv_writer.writerow(row) 78 | return 0 79 | 80 | if __name__ == '__main__': 81 | sys.exit(main()) 82 | -------------------------------------------------------------------------------- /examples/database.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # examples/database.py 5 | # 6 | # Redistribution and use in source and binary forms, with or without 7 | # modification, are permitted provided that the following conditions are 8 | # met: 9 | # 10 | # * Redistributions of source code must retain the above copyright 11 | # notice, this list of conditions and the following disclaimer. 12 | # * Redistributions in binary form must reproduce the above 13 | # copyright notice, this list of conditions and the following disclaimer 14 | # in the documentation and/or other materials provided with the 15 | # distribution. 16 | # * Neither the name of the project nor the names of its 17 | # contributors may be used to endorse or promote products derived from 18 | # this software without specific prior written permission. 19 | # 20 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 21 | # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 22 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 23 | # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 24 | # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 25 | # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 26 | # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 27 | # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 28 | # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 29 | # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 30 | # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | # 32 | 33 | import collections 34 | import csv 35 | import json 36 | 37 | import rule_engine 38 | 39 | def isiterable(thing): 40 | return isinstance(thing, collections.abc.Iterable) 41 | 42 | class Database(object): 43 | def __init__(self, data): 44 | self.data = data 45 | self._rule_context = rule_engine.Context(default_value=None) 46 | 47 | @classmethod 48 | def from_csv(cls, file_path, headers=None, skip_first=False): 49 | file_h = open(file_path, 'r') 50 | reader = csv.DictReader(file_h, headers) 51 | if skip_first: 52 | next(reader) 53 | rows = tuple(reader) 54 | file_h.close() 55 | return cls(rows) 56 | 57 | @classmethod 58 | def from_json(cls, file_path): 59 | with open(file_path, 'r') as file_h: 60 | data = json.load(file_h) 61 | return cls(data) 62 | 63 | def select(self, *names, from_=None, where='true', limit=None): 64 | data = self.data 65 | if from_ is not None: 66 | data = rule_engine.Rule(from_, context=self._rule_context).evaluate(data) 67 | if isinstance(data, collections.abc.Mapping): 68 | data = data.values() 69 | if not isiterable(data): 70 | raise ValueError('data source is not iterable') 71 | rule = rule_engine.Rule(where, context=self._rule_context) 72 | count = 0 73 | for match in rule.filter(data): 74 | if count == limit: 75 | break 76 | yield tuple(rule_engine.Rule(name, context=self._rule_context).evaluate(match) for name in names) 77 | count += 1 78 | -------------------------------------------------------------------------------- /examples/github_filter.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # examples/github_filter.py 5 | # 6 | # Redistribution and use in source and binary forms, with or without 7 | # modification, are permitted provided that the following conditions are 8 | # met: 9 | # 10 | # * Redistributions of source code must retain the above copyright 11 | # notice, this list of conditions and the following disclaimer. 12 | # * Redistributions in binary form must reproduce the above 13 | # copyright notice, this list of conditions and the following disclaimer 14 | # in the documentation and/or other materials provided with the 15 | # distribution. 16 | # * Neither the name of the project nor the names of its 17 | # contributors may be used to endorse or promote products derived from 18 | # this software without specific prior written permission. 19 | # 20 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 21 | # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 22 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 23 | # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 24 | # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 25 | # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 26 | # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 27 | # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 28 | # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 29 | # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 30 | # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | # 32 | 33 | import argparse 34 | import functools 35 | import getpass 36 | import os 37 | import sys 38 | 39 | try: 40 | import github 41 | except ImportError: 42 | print('this script requires PyGithub', file=sys.stderr) 43 | sys.exit(os.EX_UNAVAILABLE) 44 | 45 | DESCRIPTION = 'Apply a rule to filter pull requests or issues from GitHub.' 46 | EPILOG = """\ 47 | example rules: 48 | * Show all merged pull requests by user zeroSteiner 49 | "user.login == 'zeroSteiner' and merged" 50 | * Show open issues assigned to user zeroSteiner 51 | "state != 'open' and assignee and assignee.login == 'zeroSteiner'" 52 | """ 53 | 54 | get_path = functools.partial(os.path.join, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) 55 | sys.path.append(get_path('lib')) 56 | 57 | import rule_engine 58 | import rule_engine.engine 59 | 60 | def _get_github(arguments): 61 | if arguments.auth_token: 62 | gh = github.Github(arguments.auth_token) 63 | elif arguments.auth_user: 64 | password = getpass.getpass("{0}@github.com: ".format(arguments.auth_user)) 65 | gh = github.Github(arguments.auth_user, password) 66 | else: 67 | gh = github.Github() 68 | return gh 69 | 70 | def main(): 71 | parser = argparse.ArgumentParser( 72 | conflict_handler='resolve', 73 | description=DESCRIPTION, 74 | formatter_class=argparse.RawDescriptionHelpFormatter 75 | ) 76 | auth_type_parser_group = parser.add_mutually_exclusive_group() 77 | auth_type_parser_group.add_argument('--auth-token', dest='auth_token', help='authenticate to github with a token') 78 | auth_type_parser_group.add_argument('--auth-user', dest='auth_user', help='authenticate to github with credentials') 79 | parser.add_argument('repo_slug', help='the repository to filter') 80 | parser.add_argument('type', choices=('issues', 'pulls'), help='thing to filter') 81 | parser.add_argument('rule', help='the rule to apply') 82 | parser.epilog = EPILOG 83 | arguments = parser.parse_args() 84 | 85 | # need to define a custom context to use a custom resolver function 86 | context = rule_engine.Context(resolver=rule_engine.engine.resolve_attribute) 87 | try: 88 | rule = rule_engine.Rule(arguments.rule, context=context) 89 | except rule_engine.RuleSyntaxError as error: 90 | print(error.message) 91 | return 0 92 | 93 | gh = _get_github(arguments) 94 | repo = gh.get_repo(arguments.repo_slug) 95 | things = tuple(getattr(repo, 'get_' + arguments.type)(state='all')) 96 | for thing in rule.filter(things): 97 | print("{0}#{1: <4} - {2}".format(arguments.repo_slug, thing.number, thing.title)) 98 | return 0 99 | 100 | if __name__ == '__main__': 101 | sys.exit(main()) 102 | -------------------------------------------------------------------------------- /examples/shodan/query.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # examples/shodan/query.py 5 | # 6 | # Redistribution and use in source and binary forms, with or without 7 | # modification, are permitted provided that the following conditions are 8 | # met: 9 | # 10 | # * Redistributions of source code must retain the above copyright 11 | # notice, this list of conditions and the following disclaimer. 12 | # * Redistributions in binary form must reproduce the above 13 | # copyright notice, this list of conditions and the following disclaimer 14 | # in the documentation and/or other materials provided with the 15 | # distribution. 16 | # * Neither the name of the project nor the names of its 17 | # contributors may be used to endorse or promote products derived from 18 | # this software without specific prior written permission. 19 | # 20 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 21 | # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 22 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 23 | # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 24 | # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 25 | # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 26 | # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 27 | # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 28 | # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 29 | # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 30 | # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | # 32 | 33 | import argparse 34 | import gzip 35 | import json 36 | import os 37 | 38 | import shodan 39 | 40 | DESCRIPTION = 'Query results from Shodan' 41 | 42 | def main(): 43 | parser = argparse.ArgumentParser( 44 | conflict_handler='resolve', 45 | description=DESCRIPTION, 46 | formatter_class=argparse.RawDescriptionHelpFormatter 47 | ) 48 | parser.add_argument('--api-key', default=os.getenv('SHODAN_API_KEY'), help='the API key') 49 | parser.add_argument('--gzip', action='store_true', default=False, help='compress the file') 50 | parser.add_argument('json_file', type=argparse.FileType('wb'), help='the JSON file to write to') 51 | parser.add_argument('query', nargs='+', help='the search queries to retrieve') 52 | arguments = parser.parse_args() 53 | 54 | if arguments.api_key is None: 55 | print('[-] The --api-key must be specified or the SHODAN_API_KEY environment variable must be defined') 56 | return os.EX_CONFIG 57 | api = shodan.Shodan(arguments.api_key) 58 | 59 | all_results = [] 60 | for query in arguments.query: 61 | print('[*] Querying: ' + query) 62 | 63 | search_results = api.search(query) 64 | all_results.extend(search_results['matches']) 65 | 66 | output = '\n'.join(json.dumps(result) for result in all_results) 67 | output = output.encode('utf-8') 68 | if arguments.gzip: 69 | output = gzip.compress(output) 70 | arguments.json_file.write(output) 71 | 72 | if __name__ == '__main__': 73 | main() -------------------------------------------------------------------------------- /examples/shodan/results_filter.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # examples/shodan/results_filter.py 5 | # 6 | # Redistribution and use in source and binary forms, with or without 7 | # modification, are permitted provided that the following conditions are 8 | # met: 9 | # 10 | # * Redistributions of source code must retain the above copyright 11 | # notice, this list of conditions and the following disclaimer. 12 | # * Redistributions in binary form must reproduce the above 13 | # copyright notice, this list of conditions and the following disclaimer 14 | # in the documentation and/or other materials provided with the 15 | # distribution. 16 | # * Neither the name of the project nor the names of its 17 | # contributors may be used to endorse or promote products derived from 18 | # this software without specific prior written permission. 19 | # 20 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 21 | # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 22 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 23 | # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 24 | # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 25 | # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 26 | # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 27 | # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 28 | # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 29 | # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 30 | # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | # 32 | 33 | import argparse 34 | import functools 35 | import gzip 36 | import json 37 | import os 38 | import pprint 39 | import re 40 | import sys 41 | 42 | get_path = functools.partial(os.path.join, os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))) 43 | sys.path.append(get_path('lib')) 44 | 45 | import rule_engine 46 | 47 | BLACKLIST = ('_shodan', 'asn', 'hash', 'ip') 48 | DESCRIPTION = 'Apply a rule to filter results exported from Shodan.' 49 | EPILOG = """\ 50 | example rules: 51 | * Find all HTTPS servers missing the Strict-Transport-Security header 52 | "http and ssl and data !~~ '^Strict-Transport-Security:\s'" 53 | * Find OpenSSH servers on non-default ports 54 | "product == 'OpenSSH' and port != 22" 55 | """ 56 | 57 | def result_to_url(result): 58 | protocol = result['transport'] 59 | if 'http' in result: 60 | protocol = 'https' if 'ssl' in result else 'http' 61 | return "{protocol}://{ip_str}:{port}".format(protocol=protocol, **result) 62 | 63 | def main(): 64 | parser = argparse.ArgumentParser( 65 | conflict_handler='resolve', 66 | description=DESCRIPTION, 67 | formatter_class=argparse.RawDescriptionHelpFormatter 68 | ) 69 | parser.add_argument('-d', '--depth', default=2, type=int, help='the depth to pretty print') 70 | parser.add_argument('--gzip', action='store_true', default=False, help='decompress the file') 71 | parser.add_argument('--regex-case-sensitive', default=False, action='store_true', help='use case-sensitive regular expressions') 72 | parser.add_argument('json_file', type=argparse.FileType('rb'), help='the JSON file to filter') 73 | parser.add_argument('rule', help='the rule to apply') 74 | parser.epilog = EPILOG 75 | arguments = parser.parse_args() 76 | 77 | re_flags = re.MULTILINE 78 | if arguments.regex_case_sensitive: 79 | re_flags &= re.IGNORECASE 80 | 81 | context = rule_engine.Context(default_value=None, regex_flags=re_flags) 82 | try: 83 | rule = rule_engine.Rule(arguments.rule, context=context) 84 | except rule_engine.RuleSyntaxError as error: 85 | print(error.message) 86 | return 0 87 | 88 | file_object = arguments.json_file 89 | if arguments.gzip: 90 | file_object = gzip.GzipFile(fileobj=file_object) 91 | 92 | total = 0 93 | matches = 0 94 | for line in file_object: 95 | result = json.loads(line.decode('utf-8')) 96 | total += 1 97 | if not rule.matches(result): 98 | continue 99 | matches += 1 100 | print(result_to_url(result)) 101 | if arguments.depth > 0: 102 | for key in BLACKLIST: 103 | result.pop(key, None) 104 | pprint.pprint(result, depth=arguments.depth) 105 | print("rule matched {:,} of {:,} results ({:.2f}%)".format(matches, total, ((matches / total) * 100))) 106 | return 0 107 | 108 | if __name__ == '__main__': 109 | sys.exit(main()) 110 | -------------------------------------------------------------------------------- /examples/shodan/results_scan.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # examples/shodan/results_scan.py 5 | # 6 | # Redistribution and use in source and binary forms, with or without 7 | # modification, are permitted provided that the following conditions are 8 | # met: 9 | # 10 | # * Redistributions of source code must retain the above copyright 11 | # notice, this list of conditions and the following disclaimer. 12 | # * Redistributions in binary form must reproduce the above 13 | # copyright notice, this list of conditions and the following disclaimer 14 | # in the documentation and/or other materials provided with the 15 | # distribution. 16 | # * Neither the name of the project nor the names of its 17 | # contributors may be used to endorse or promote products derived from 18 | # this software without specific prior written permission. 19 | # 20 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 21 | # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 22 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 23 | # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 24 | # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 25 | # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 26 | # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 27 | # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 28 | # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 29 | # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 30 | # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | # 32 | 33 | import argparse 34 | import functools 35 | import gzip 36 | import json 37 | import os 38 | import re 39 | import sys 40 | 41 | get_path = functools.partial(os.path.join, os.path.abspath(os.path.join(os.path.dirname(__file__)))) 42 | sys.path.append(get_path()) 43 | sys.path.append(get_path('..', '..', 'lib')) 44 | 45 | import results_filter 46 | import rule_engine 47 | 48 | import yaml 49 | 50 | DESCRIPTION = 'Scan results exported from Shodan for vulnerabilities.' 51 | RULES_FILE = get_path('rules.yml') 52 | 53 | def _print_references(references): 54 | cves = references.get('cves') 55 | if cves and len(cves) == 1: 56 | print('CVE: CVE-' + cves[0]) 57 | elif cves and len(cves) > 1: 58 | print('CVEs: ') 59 | for cve in cves: 60 | print(' * CVE-' + cve) 61 | 62 | msf_modules = references.get('metasploit-modules') 63 | if msf_modules and len(msf_modules) == 1: 64 | print('Metasploit Module: ' + msf_modules[0]) 65 | elif msf_modules and len(msf_modules) > 1: 66 | print('Metasploit Modules:') 67 | for msf_module in msf_modules: 68 | print(' * ' + msf_module) 69 | 70 | def main(): 71 | parser = argparse.ArgumentParser( 72 | conflict_handler='resolve', 73 | description=DESCRIPTION, 74 | formatter_class=argparse.RawDescriptionHelpFormatter 75 | ) 76 | parser.add_argument('--gzip', action='store_true', default=False, help='decompress the file') 77 | parser.add_argument('json_file', type=argparse.FileType('rb'), help='the JSON file to filter') 78 | arguments = parser.parse_args() 79 | 80 | re_flags = re.IGNORECASE | re.MULTILINE 81 | context = rule_engine.Context(default_value=None, regex_flags=re_flags) 82 | 83 | file_object = arguments.json_file 84 | if arguments.gzip: 85 | file_object = gzip.GzipFile(fileobj=file_object) 86 | results = [json.loads(line.decode('utf-8')) for line in file_object] 87 | 88 | with open(RULES_FILE, 'r') as file_h: 89 | rules = yaml.load(file_h, Loader=yaml.FullLoader) 90 | 91 | for vulnerability in rules['rules']: 92 | try: 93 | rule = rule_engine.Rule(vulnerability['rule'], context=context) 94 | except rule_engine.RuleSyntaxError as error: 95 | print(error.message) 96 | return 0 97 | 98 | matches = tuple(rule.filter(results)) 99 | if not matches: 100 | continue 101 | 102 | print(vulnerability['description']) 103 | references = vulnerability.get('references', {}) 104 | _print_references(references) 105 | print('Hosts:') 106 | for match in matches: 107 | print(" * {}".format(results_filter.result_to_url(match))) 108 | print() 109 | return 0 110 | 111 | if __name__ == '__main__': 112 | sys.exit(main()) 113 | 114 | -------------------------------------------------------------------------------- /examples/shodan/rules.yml: -------------------------------------------------------------------------------- 1 | rules: 2 | - description: Anonymous FTP Access is enabled 3 | rule: ftp.anonymous 4 | 5 | - description: HTTP server prompts for basic authentication without SSL 6 | rule: http and not ssl and data =~~ '^WWW-Authenticate:\s+Basic\s+realm=' 7 | 8 | - description: HTTP server missing the 'X-Frame-Options' header 9 | rule: http and data =~~ 'HTTP/\d.\d 2\d\d' and data !~~ '^X-Frame-Options:\s' 10 | 11 | - description: HTTPS server missing the 'Strict-Transport-Security' header 12 | rule: http and ssl and data !~~ '^Strict-Transport-Security:\s' 13 | 14 | - description: Outdated software - OpenSSH Server < v7.7 15 | rule: product == 'OpenSSH' and (version =~ '[1-6]\.\d+' or version =~ '7\.[0-6]') 16 | references: 17 | cves: 18 | - 2018-15473 19 | metasploit-modules: 20 | - auxiliary/scanner/ssh/ssh_enumusers 21 | 22 | - description: Outdated software - Allegro RomPager < v4.34 23 | rule: product == 'Allegro RomPager' and version =~ '([1-3]\.\d\d|4\.([0-2]\d|3[0-3]))' 24 | references: 25 | cves: 26 | - 2014-9222 27 | metasploit-modules: 28 | - auxiliary/admin/http/allegro_rompager_auth_bypass 29 | - auxiliary/scanner/http/allegro_rompager_misfortune_cookie 30 | 31 | - description: SSLv3 is enabled 32 | rule: ssl.versions and 'SSLv3' in ssl.versions 33 | 34 | - description: TLSv1 is enabled 35 | rule: ssl.versions and 'TLSv1' in ssl.versions 36 | 37 | - description: TLSv1.1 is enabled 38 | rule: ssl.versions and 'TLSv1.1' in ssl.versions 39 | -------------------------------------------------------------------------------- /lib/rule_engine/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # rule_engine/__init__.py 5 | # 6 | # Redistribution and use in source and binary forms, with or without 7 | # modification, are permitted provided that the following conditions are 8 | # met: 9 | # 10 | # * Redistributions of source code must retain the above copyright 11 | # notice, this list of conditions and the following disclaimer. 12 | # * Redistributions in binary form must reproduce the above 13 | # copyright notice, this list of conditions and the following disclaimer 14 | # in the documentation and/or other materials provided with the 15 | # distribution. 16 | # * Neither the name of the project nor the names of its 17 | # contributors may be used to endorse or promote products derived from 18 | # this software without specific prior written permission. 19 | # 20 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 21 | # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 22 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 23 | # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 24 | # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 25 | # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 26 | # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 27 | # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 28 | # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 29 | # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 30 | # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | # 32 | 33 | __version__ = '4.5.3' 34 | 35 | from .engine import resolve_attribute 36 | from .engine import resolve_item 37 | from .engine import type_resolver_from_dict 38 | from .engine import Context 39 | from .engine import Rule 40 | 41 | from .errors import AttributeResolutionError 42 | from .errors import EngineError 43 | from .errors import EvaluationError 44 | from .errors import RuleSyntaxError 45 | from .errors import SymbolResolutionError 46 | 47 | from .types import DataType 48 | -------------------------------------------------------------------------------- /lib/rule_engine/builtins.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # rule_engine/builtins.py 5 | # 6 | # Redistribution and use in source and binary forms, with or without 7 | # modification, are permitted provided that the following conditions are 8 | # met: 9 | # 10 | # * Redistributions of source code must retain the above copyright 11 | # notice, this list of conditions and the following disclaimer. 12 | # * Redistributions in binary form must reproduce the above 13 | # copyright notice, this list of conditions and the following disclaimer 14 | # in the documentation and/or other materials provided with the 15 | # distribution. 16 | # * Neither the name of the project nor the names of its 17 | # contributors may be used to endorse or promote products derived from 18 | # this software without specific prior written permission. 19 | # 20 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 21 | # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 22 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 23 | # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 24 | # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 25 | # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 26 | # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 27 | # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 28 | # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 29 | # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 30 | # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | # 32 | 33 | import collections 34 | import collections.abc 35 | import datetime 36 | import decimal 37 | import functools 38 | import math 39 | import random 40 | 41 | from . import ast 42 | from . import errors 43 | from . import types 44 | from .parser.utilities import parse_datetime, parse_float, parse_timedelta 45 | 46 | import dateutil.tz 47 | 48 | def _builtin_filter(function, iterable): 49 | return tuple(filter(function, iterable)) 50 | 51 | def _builtin_map(function, iterable): 52 | return tuple(map(function, iterable)) 53 | 54 | def _builtin_parse_datetime(builtins, string): 55 | return parse_datetime(string, builtins.timezone) 56 | 57 | def _builtin_random(boundary=None): 58 | if boundary: 59 | if not types.is_natural_number(boundary): 60 | raise errors.FunctionCallError('argument #1 (boundary) must be a natural number') 61 | return random.randint(0, int(boundary)) 62 | return random.random() 63 | 64 | def _builtin_range(start, stop=None, step=None): 65 | if not types.is_integer_number(start): 66 | raise errors.FunctionCallError('argument #1 (start) must be an integer number') 67 | if stop: 68 | if not types.is_integer_number(stop): 69 | raise errors.FunctionCallError('argument #2 (stop) must be an integer number') 70 | if step: 71 | if not types.is_integer_number(step): 72 | raise errors.FunctionCallError('argument #3 (step) must be an integer number') 73 | return list(range(int(start), int(stop), int(step))) 74 | return list(range(int(start), int(stop))) 75 | return list(range(int(start))) 76 | 77 | def _builtins_split(string, sep=None, maxsplit=None): 78 | if maxsplit is None: 79 | maxsplit = -1 80 | elif types.is_natural_number(maxsplit): 81 | maxsplit = int(maxsplit) 82 | else: 83 | raise errors.FunctionCallError('argument #3 (maxsplit) must be a natural number') 84 | return tuple(string.split(sep=sep, maxsplit=maxsplit)) 85 | 86 | class BuiltinValueGenerator(object): 87 | """ 88 | A class used as a wrapper for builtin values to differentiate between a value that is a function and a value that 89 | should be generated by calling a function. A value that is generated by calling a function is useful for determining 90 | the value during evaluation for things like the current time. 91 | 92 | .. versionadded:: 4.0.0 93 | """ 94 | __slots__ = ('callable',) 95 | def __init__(self, callable): 96 | self.callable = callable 97 | 98 | def __call__(self, builtins): 99 | return self.callable(builtins) 100 | 101 | class Builtins(collections.abc.Mapping): 102 | """ 103 | A class to define and provide variables to within the builtin context of rules. These can be accessed by specifying 104 | a symbol name with the ``$`` prefix. 105 | """ 106 | scope_name = 'built-in' 107 | """The identity name of the scope for builtin symbols.""" 108 | def __init__(self, values, namespace=None, timezone=None, value_types=None): 109 | """ 110 | :param dict values: A mapping of string keys to be used as symbol names with values of either Python literals or 111 | a function which will be called when the symbol is accessed. When using a function, it will be passed a 112 | single argument, which is the instance of :py:class:`Builtins`. 113 | :param str namespace: The namespace of the variables to resolve. 114 | :param timezone: A timezone to use when resolving timestamps. 115 | :type timezone: :py:class:`~datetime.tzinfo` 116 | :param dict value_types: A mapping of the values to their datatypes. 117 | 118 | .. versionchanged:: 2.3.0 119 | Added the *value_types* parameter. 120 | """ 121 | self.__values = values 122 | self.__value_types = value_types or {} 123 | self.namespace = namespace 124 | self.timezone = timezone or dateutil.tz.tzlocal() 125 | 126 | def resolve_type(self, name): 127 | """ 128 | The method to use for resolving the data type of a builtin symbol. 129 | 130 | :param str name: The name of the symbol to retrieve the data type of. 131 | :return: The data type of the symbol or :py:attr:`~rule_engine.ast.DataType.UNDEFINED`. 132 | """ 133 | return self.__value_types.get(name, ast.DataType.UNDEFINED) 134 | 135 | def __repr__(self): 136 | return "<{} namespace={!r} keys={!r} timezone={!r} >".format(self.__class__.__name__, self.namespace, tuple(self.keys()), self.timezone) 137 | 138 | def __getitem__(self, name): 139 | value = self.__values[name] 140 | if isinstance(value, collections.abc.Mapping): 141 | if self.namespace is None: 142 | namespace = name 143 | else: 144 | namespace = self.namespace + '.' + name 145 | return self.__class__(value, namespace=namespace, timezone=self.timezone) 146 | elif callable(value) and isinstance(value, BuiltinValueGenerator): 147 | value = value(self) 148 | return value 149 | 150 | def __iter__(self): 151 | return iter(self.__values) 152 | 153 | def __len__(self): 154 | return len(self.__values) 155 | 156 | @classmethod 157 | def from_defaults(cls, values=None, **kwargs): 158 | """Initialize a :py:class:`Builtins` instance with a set of default values.""" 159 | now = BuiltinValueGenerator(lambda builtins: datetime.datetime.now(tz=builtins.timezone)) 160 | # there may be errors here if the decimal.Context precision exceeds what is provided by the math constants 161 | default_values = { 162 | # mathematical constants 163 | 'e': decimal.Decimal(repr(math.e)), 164 | 'pi': decimal.Decimal(repr(math.pi)), 165 | # timestamps 166 | 'now': now, 167 | 'today': BuiltinValueGenerator(lambda builtins: now(builtins).replace(hour=0, minute=0, second=0, microsecond=0)), 168 | # functions 169 | 'abs': abs, 170 | 'any': any, 171 | 'all': all, 172 | 'sum': sum, 173 | 'map': _builtin_map, 174 | 'max': max, 175 | 'min': min, 176 | 'filter': _builtin_filter, 177 | 'parse_datetime': BuiltinValueGenerator(lambda builtins: functools.partial(_builtin_parse_datetime, builtins)), 178 | 'parse_float': parse_float, 179 | 'parse_timedelta': parse_timedelta, 180 | 'random': _builtin_random, 181 | 'range': _builtin_range, 182 | 'split': _builtins_split 183 | } 184 | default_values.update(values or {}) 185 | default_value_types = { 186 | # mathematical constants 187 | 'e': ast.DataType.FLOAT, 188 | 'pi': ast.DataType.FLOAT, 189 | # timestamps 190 | 'now': ast.DataType.DATETIME, 191 | 'today': ast.DataType.DATETIME, 192 | # functions 193 | 'abs': ast.DataType.FUNCTION('abs', return_type=ast.DataType.FLOAT, argument_types=(ast.DataType.FLOAT,)), 194 | 'all': ast.DataType.FUNCTION('all', return_type=ast.DataType.BOOLEAN, argument_types=(ast.DataType.ARRAY,)), 195 | 'any': ast.DataType.FUNCTION('any', return_type=ast.DataType.BOOLEAN, argument_types=(ast.DataType.ARRAY,)), 196 | 'sum': ast.DataType.FUNCTION('sum', return_type=ast.DataType.FLOAT, argument_types=(ast.DataType.ARRAY(ast.DataType.FLOAT),)), 197 | 'map': ast.DataType.FUNCTION('map', return_type=ast.DataType.ARRAY, argument_types=(ast.DataType.FUNCTION, ast.DataType.ARRAY)), 198 | 'max': ast.DataType.FUNCTION('max', return_type=ast.DataType.FLOAT, argument_types=(ast.DataType.ARRAY(ast.DataType.FLOAT),)), 199 | 'min': ast.DataType.FUNCTION('min', return_type=ast.DataType.FLOAT, argument_types=(ast.DataType.ARRAY(ast.DataType.FLOAT),)), 200 | 'filter': ast.DataType.FUNCTION('filter', return_type=ast.DataType.ARRAY, argument_types=(ast.DataType.FUNCTION, ast.DataType.ARRAY)), 201 | 'parse_datetime': ast.DataType.FUNCTION('parse_datetime', return_type=ast.DataType.DATETIME, argument_types=(ast.DataType.STRING,)), 202 | 'parse_float': ast.DataType.FUNCTION('parse_float', return_type=ast.DataType.FLOAT, argument_types=(ast.DataType.STRING,)), 203 | 'parse_timedelta': ast.DataType.FUNCTION('parse_timedelta', return_type=ast.DataType.TIMEDELTA, argument_types=(ast.DataType.STRING,)), 204 | 'random': ast.DataType.FUNCTION('random', return_type=ast.DataType.FLOAT, argument_types=(ast.DataType.FLOAT,), minimum_arguments=0), 205 | 'range': ast.DataType.FUNCTION('range', return_type=ast.DataType.ARRAY(ast.DataType.FLOAT), argument_types=(ast.DataType.FLOAT, ast.DataType.FLOAT, ast.DataType.FLOAT,), minimum_arguments=1), 206 | 'split': ast.DataType.FUNCTION( 207 | 'split', 208 | return_type=ast.DataType.ARRAY(ast.DataType.STRING), 209 | argument_types=(ast.DataType.STRING, ast.DataType.STRING, ast.DataType.FLOAT), 210 | minimum_arguments=1 211 | ) 212 | } 213 | default_value_types.update(kwargs.pop('value_types', {})) 214 | return cls(default_values, value_types=default_value_types, **kwargs) 215 | -------------------------------------------------------------------------------- /lib/rule_engine/debug_ast.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # rule_engine/debug_ast.py 5 | # 6 | # Redistribution and use in source and binary forms, with or without 7 | # modification, are permitted provided that the following conditions are 8 | # met: 9 | # 10 | # * Redistributions of source code must retain the above copyright 11 | # notice, this list of conditions and the following disclaimer. 12 | # * Redistributions in binary form must reproduce the above 13 | # copyright notice, this list of conditions and the following disclaimer 14 | # in the documentation and/or other materials provided with the 15 | # distribution. 16 | # * Neither the name of the project nor the names of its 17 | # contributors may be used to endorse or promote products derived from 18 | # this software without specific prior written permission. 19 | # 20 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 21 | # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 22 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 23 | # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 24 | # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 25 | # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 26 | # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 27 | # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 28 | # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 29 | # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 30 | # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | # 32 | 33 | import argparse 34 | import os 35 | 36 | from . import __version__ 37 | from . import engine 38 | 39 | def _print_written(file_path): 40 | size = os.stat(file_path).st_size 41 | print("wrote {:,} bytes to {}".format(size, file_path)) 42 | 43 | def main(): 44 | parser = argparse.ArgumentParser(description='Rule Engine: Debug AST', conflict_handler='resolve') 45 | parser.add_argument('output', help='output files') 46 | parser.add_argument('-t', '--text', dest='rule_text', help='the rule text to debug') 47 | parser.add_argument('-v', '--version', action='version', version=parser.prog + ' Version: ' + __version__) 48 | arguments = parser.parse_args() 49 | 50 | rule_text = arguments.rule_text 51 | if not rule_text: 52 | rule_text = input('rule > ') 53 | 54 | rule = engine.Rule(rule_text) 55 | digraph = rule.to_graphviz() 56 | 57 | digraph.save(arguments.output + '.gv') 58 | _print_written(arguments.output + '.gv') 59 | 60 | digraph.render(arguments.output) 61 | _print_written(arguments.output + '.pdf') 62 | 63 | 64 | if __name__ == '__main__': 65 | main() 66 | -------------------------------------------------------------------------------- /lib/rule_engine/debug_repl.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # rule_engine/debug_repl.py 5 | # 6 | # Redistribution and use in source and binary forms, with or without 7 | # modification, are permitted provided that the following conditions are 8 | # met: 9 | # 10 | # * Redistributions of source code must retain the above copyright 11 | # notice, this list of conditions and the following disclaimer. 12 | # * Redistributions in binary form must reproduce the above 13 | # copyright notice, this list of conditions and the following disclaimer 14 | # in the documentation and/or other materials provided with the 15 | # distribution. 16 | # * Neither the name of the project nor the names of its 17 | # contributors may be used to endorse or promote products derived from 18 | # this software without specific prior written permission. 19 | # 20 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 21 | # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 22 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 23 | # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 24 | # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 25 | # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 26 | # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 27 | # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 28 | # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 29 | # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 30 | # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | # 32 | 33 | import argparse 34 | import code 35 | import io 36 | import os 37 | import pprint 38 | import re 39 | import sys 40 | import textwrap 41 | import traceback 42 | 43 | from . import __version__ 44 | from . import engine 45 | from . import errors 46 | 47 | def _console_interact(console, *args, **kwargs): 48 | # see: https://bugs.python.org/issue34115 49 | stdin = os.dup(0) 50 | try: 51 | console.interact(*args, **kwargs) 52 | except SystemExit: 53 | if 'exitmsg' in kwargs: 54 | print(kwargs['exitmsg']) 55 | sys.stdin = io.TextIOWrapper(io.BufferedReader(io.FileIO(stdin, mode='rb', closefd=False))) 56 | 57 | def main(): 58 | parser = argparse.ArgumentParser(description='Rule Engine: Debug REPL', conflict_handler='resolve') 59 | parser.add_argument( 60 | '--debug', 61 | action='store_true', 62 | default=False, 63 | help='enable debugging output' 64 | ) 65 | parser.add_argument( 66 | '--edit-console', 67 | action='store_true', 68 | default=False, 69 | help='edit the environment (via an interactive console)' 70 | ) 71 | parser.add_argument( 72 | '--edit-file', 73 | metavar='', 74 | type=argparse.FileType('r'), 75 | help='edit the environment (via a file)' 76 | ) 77 | parser.add_argument('-v', '--version', action='version', version=parser.prog + ' Version: ' + __version__) 78 | arguments = parser.parse_args() 79 | 80 | context = engine.Context() 81 | thing = None 82 | if arguments.edit_console or arguments.edit_file: 83 | console = code.InteractiveConsole({ 84 | 'context': context, 85 | 'thing': thing 86 | }) 87 | if arguments.edit_file: 88 | print('executing: ' + arguments.edit_file.name) 89 | console.runcode(code.compile_command( 90 | arguments.edit_file.read(), 91 | filename=arguments.edit_file.name, 92 | symbol='exec' 93 | )) 94 | if arguments.edit_console: 95 | _console_interact( 96 | console, 97 | banner='edit the \'context\' and \'thing\' objects as necessary', 98 | exitmsg='exiting the edit console...' 99 | ) 100 | context = console.locals['context'] 101 | thing = console.locals['thing'] 102 | debugging = arguments.debug 103 | 104 | while True: 105 | try: 106 | rule_text = input('rule > ') 107 | except (EOFError, KeyboardInterrupt): 108 | break 109 | 110 | match = re.match(r'\s*#!\s*debug\s*=\s*(\w+)', rule_text) 111 | if match: 112 | debugging = match.group(1).lower() != 'false' 113 | print('# debugging = ' + str(debugging).lower()) 114 | continue 115 | 116 | try: 117 | rule = engine.Rule(rule_text, context=context) 118 | result = rule.evaluate(thing) 119 | except errors.EngineError as error: 120 | print("{}: {}".format(error.__class__.__name__, error.message)) 121 | if isinstance(error, (errors.AttributeResolutionError, errors.SymbolResolutionError)) and error.suggestion: 122 | print("Did you mean '{}'?".format(error.suggestion)) 123 | elif isinstance(error, errors.RegexSyntaxError): 124 | print(" Regex: {!r}".format(error.error.pattern)) 125 | print(" Details: {} at position {}".format(error.error.msg, error.error.pos)) 126 | elif isinstance(error, errors.FunctionCallError): 127 | print(" Function: {!r}".format(error.function_name)) 128 | if debugging and error.error: 129 | inner_exception = ''.join(traceback.format_exception( 130 | error.error, 131 | error.error, 132 | error.error.__traceback__ 133 | )) 134 | print(textwrap.indent(inner_exception, ' ' * 4)) 135 | if debugging: 136 | traceback.print_exc() 137 | except Exception as error: 138 | traceback.print_exc() 139 | else: 140 | print('result: ') 141 | pprint.pprint(result, indent=4) 142 | 143 | if __name__ == '__main__': 144 | main() 145 | -------------------------------------------------------------------------------- /lib/rule_engine/errors.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # rule_engine/errors.py 5 | # 6 | # Redistribution and use in source and binary forms, with or without 7 | # modification, are permitted provided that the following conditions are 8 | # met: 9 | # 10 | # * Redistributions of source code must retain the above copyright 11 | # notice, this list of conditions and the following disclaimer. 12 | # * Redistributions in binary form must reproduce the above 13 | # copyright notice, this list of conditions and the following disclaimer 14 | # in the documentation and/or other materials provided with the 15 | # distribution. 16 | # * Neither the name of the project nor the names of its 17 | # contributors may be used to endorse or promote products derived from 18 | # this software without specific prior written permission. 19 | # 20 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 21 | # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 22 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 23 | # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 24 | # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 25 | # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 26 | # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 27 | # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 28 | # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 29 | # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 30 | # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | # 32 | 33 | class _UNDEFINED(object): 34 | def __bool__(self): 35 | return False 36 | __name__ = 'UNDEFINED' 37 | __nonzero__ = __bool__ 38 | def __repr__(self): 39 | return self.__name__ 40 | UNDEFINED = _UNDEFINED() 41 | """ 42 | A sentinel value to specify that something is undefined. When evaluated, the value is falsy. 43 | 44 | .. versionadded:: 2.0.0 45 | """ 46 | 47 | class EngineError(Exception): 48 | """The base exception class from which other exceptions within this package inherit.""" 49 | def __init__(self, message=''): 50 | """ 51 | :param str message: A text description of what error occurred. 52 | """ 53 | self.message = message 54 | """A text description of what error occurred.""" 55 | 56 | def __repr__(self): 57 | return "<{} message={!r} >".format(self.__class__.__name__, self.message) 58 | 59 | class EvaluationError(EngineError): 60 | """ 61 | An error raised for issues which occur while the rule is being evaluated. This can occur at parse time while AST 62 | nodes are being evaluated during the reduction phase. 63 | """ 64 | pass 65 | 66 | class SyntaxError(EngineError): 67 | """A base error for syntax related issues.""" 68 | 69 | class BytesSyntaxError(SyntaxError): 70 | """ 71 | An error raised for issues regarding the use of improperly formatted bytes expressions. 72 | 73 | .. versionadded:: 4.5.0 74 | """ 75 | def __init__(self, message, value): 76 | """ 77 | :param str message: A text description of what error occurred. 78 | :param str value: The bytes value which contains the syntax error which caused this exception to be raised. 79 | """ 80 | super(BytesSyntaxError, self).__init__(message) 81 | self.value = value 82 | """The bytes value which contains the syntax error which caused this exception to be raised.""" 83 | 84 | class StringSyntaxError(SyntaxError): 85 | """ 86 | An error raised for issues regarding the use of improperly formatted string expressions. 87 | 88 | .. versionadded:: 4.5.0 89 | """ 90 | def __init__(self, message, value): 91 | """ 92 | :param str message: A text description of what error occurred. 93 | :param str value: The string value which contains the syntax error which caused this exception to be raised. 94 | """ 95 | super(StringSyntaxError, self).__init__(message) 96 | self.value = value 97 | """The string value which contains the syntax error which caused this exception to be raised.""" 98 | 99 | class DatetimeSyntaxError(SyntaxError): 100 | """An error raised for issues regarding the use of improperly formatted datetime expressions.""" 101 | def __init__(self, message, value): 102 | """ 103 | :param str message: A text description of what error occurred. 104 | :param str value: The datetime value which contains the syntax error which caused this exception to be raised. 105 | """ 106 | super(DatetimeSyntaxError, self).__init__(message) 107 | self.value = value 108 | """The datetime value which contains the syntax error which caused this exception to be raised.""" 109 | 110 | class FloatSyntaxError(SyntaxError): 111 | """ 112 | An error raised for issues regarding the use of improperly formatted float expressions. 113 | 114 | .. versionadded:: 4.0.0 115 | """ 116 | def __init__(self, message, value): 117 | """ 118 | :param str message: A text description of what error occurred. 119 | :param str value: The float value which contains the syntax error which caused this exception to be raised. 120 | """ 121 | super(FloatSyntaxError, self).__init__(message) 122 | self.value = value 123 | """The float value which contains the syntax error which caused this exception to be raised.""" 124 | 125 | class TimedeltaSyntaxError(SyntaxError): 126 | """ 127 | An error raised for issues regarding the use of improperly formatted timedelta expressions. 128 | 129 | .. versionadded:: 3.5.0 130 | """ 131 | def __init__(self, message, value): 132 | """ 133 | :param str message: A text description of what error occurred. 134 | :param str value: The timedelta value which contains the syntax error which caused this exception to be raised. 135 | """ 136 | super(TimedeltaSyntaxError, self).__init__(message) 137 | self.value = value 138 | """The timedelta value which contains the syntax error which caused this exception to be raised.""" 139 | 140 | class RegexSyntaxError(SyntaxError): 141 | """An error raised for issues regarding the use of improper regular expression syntax.""" 142 | def __init__(self, message, error, value): 143 | """ 144 | :param str message: A text description of what error occurred. 145 | :param error: The :py:exc:`re.error` exception from which this error was triggered. 146 | :type error: :py:exc:`re.error` 147 | :param str value: The regular expression value which contains the syntax error which caused this exception to be 148 | raised. 149 | """ 150 | super(RegexSyntaxError, self).__init__(message) 151 | self.error = error 152 | """The :py:exc:`re.error` exception from which this error was triggered.""" 153 | self.value = value 154 | """The regular expression value which contains the syntax error which caused this exception to be raised.""" 155 | 156 | class RuleSyntaxError(SyntaxError): 157 | """An error raised for issues identified while parsing the grammar of the rule text.""" 158 | def __init__(self, message, token=None): 159 | """ 160 | :param str message: A text description of what error occurred. 161 | :param token: The PLY token (if available) which is related to the syntax error. 162 | """ 163 | if token is None: 164 | position = 'EOF' 165 | else: 166 | position = "line {0}:{1}".format(token.lineno, token.lexpos) 167 | message = message + ' at: ' + position 168 | super(RuleSyntaxError, self).__init__(message) 169 | self.token = token 170 | """The PLY token (if available) which is related to the syntax error.""" 171 | 172 | class AttributeResolutionError(EvaluationError): 173 | """ 174 | An error raised with an attribute can not be resolved to a value. 175 | 176 | .. versionadded:: 2.0.0 177 | """ 178 | def __init__(self, attribute_name, object_, thing=UNDEFINED, suggestion=None): 179 | """ 180 | :param str attribute_name: The name of the symbol that can not be resolved. 181 | :param object_: The value that *attribute_name* was used as an attribute for. 182 | :param thing: The root-object that was used to resolve *object*. 183 | :param str suggestion: An optional suggestion for a valid attribute name. 184 | 185 | .. versionchanged:: 3.2.0 186 | Added the *suggestion* parameter. 187 | """ 188 | self.attribute_name = attribute_name 189 | """The name of the symbol that can not be resolved.""" 190 | self.object = object_ 191 | """The value that *attribute_name* was used as an attribute for.""" 192 | self.thing = thing 193 | """The root-object that was used to resolve *object*.""" 194 | self.suggestion = suggestion 195 | """An optional suggestion for a valid attribute name.""" 196 | super(AttributeResolutionError, self).__init__("unknown attribute: {0!r}".format(attribute_name)) 197 | 198 | def __repr__(self): 199 | return "<{} message={!r} suggestion={!r} >".format(self.__class__.__name__, self.message, self.suggestion) 200 | 201 | class AttributeTypeError(EvaluationError): 202 | """ 203 | An error raised when an attribute with type information is resolved to a Python value that is not of that type. 204 | """ 205 | def __init__(self, attribute_name, object_type, is_value, is_type, expected_type): 206 | """ 207 | :param str attribute_name: The name of the symbol that can not be resolved. 208 | :param object_type: The value that *attribute_name* was used as an attribute for. 209 | :param is_value: The native Python value of the incompatible attribute. 210 | :param is_type: The :py:class:`rule-engine type` of the incompatible attribute. 211 | :param expected_type: The :py:class:`rule-engine type` that was expected for this 212 | attribute. 213 | """ 214 | self.attribute_name = attribute_name 215 | """The name of the attribute that is of an incompatible type.""" 216 | self.object_type = object_type 217 | """The object on which the attribute was resolved.""" 218 | self.is_value = is_value 219 | """The native Python value of the incompatible attribute.""" 220 | self.is_type = is_type 221 | """The :py:class:`rule-engine type` of the incompatible attribute.""" 222 | self.expected_type = expected_type 223 | """The :py:class:`rule-engine type` that was expected for this attribute.""" 224 | message = "attribute {0!r} resolved to incorrect datatype (is: {1}, expected: {2})".format( 225 | attribute_name, 226 | is_type.name, 227 | expected_type.name 228 | ) 229 | super(AttributeTypeError, self).__init__(message) 230 | 231 | class LookupError(EvaluationError): 232 | """ 233 | An error raised when a lookup operation fails to obtain and *item* from a *container*. This is analogous to a 234 | combination of Python's builtin :py:exc:`IndexError` and :py:exc:`KeyError` exceptions. 235 | 236 | .. versionadded:: 2.4.0 237 | """ 238 | def __init__(self, container, item): 239 | """ 240 | :param container: The container object that the lookup was performed on. 241 | :param item: The item that was used as either the key or index of *container* for the lookup. 242 | """ 243 | self.container = container 244 | """The container object that the lookup was performed on.""" 245 | self.item = item 246 | """The item that was used as either the key or index of *container* for the lookup.""" 247 | super(LookupError, self).__init__('lookup operation failed') 248 | 249 | class SymbolResolutionError(EvaluationError): 250 | """An error raised when a symbol name is not able to be resolved to a value.""" 251 | def __init__(self, symbol_name, symbol_scope=None, thing=UNDEFINED, suggestion=None): 252 | """ 253 | :param str symbol_name: The name of the symbol that can not be resolved. 254 | :param str symbol_scope: The scope of where the symbol should be valid for resolution. 255 | :param thing: The root-object that was used to resolve the symbol. 256 | :param str suggestion: An optional suggestion for a valid symbol name. 257 | 258 | .. versionchanged:: 2.0.0 259 | Added the *thing* parameter. 260 | .. versionchanged:: 3.2.0 261 | Added the *suggestion* parameter. 262 | """ 263 | self.symbol_name = symbol_name 264 | """The name of the symbol that can not be resolved.""" 265 | self.symbol_scope = symbol_scope 266 | """The scope of where the symbol should be valid for resolution.""" 267 | self.thing = thing 268 | """The root-object that was used to resolve the symbol.""" 269 | self.suggestion = suggestion 270 | """An optional suggestion for a valid symbol name.""" 271 | super(SymbolResolutionError, self).__init__("unknown symbol: {0!r}".format(symbol_name)) 272 | 273 | def __repr__(self): 274 | return "<{} message={!r} suggestion={!r} >".format(self.__class__.__name__, self.message, self.suggestion) 275 | 276 | class SymbolTypeError(EvaluationError): 277 | """An error raised when a symbol with type information is resolved to a Python value that is not of that type.""" 278 | def __init__(self, symbol_name, is_value, is_type, expected_type): 279 | """ 280 | :param str symbol_name: The name of the symbol that is of an incompatible type. 281 | :param is_value: The native Python value of the incompatible symbol. 282 | :param is_type: The :py:class:`rule-engine type` of the incompatible symbol. 283 | :param expected_type: The :py:class:`rule-engine type` that was expected for this 284 | symbol. 285 | """ 286 | self.symbol_name = symbol_name 287 | """The name of the symbol that is of an incompatible type.""" 288 | self.is_value = is_value 289 | """The native Python value of the incompatible symbol.""" 290 | self.is_type = is_type 291 | """The :py:class:`rule-engine type` of the incompatible symbol.""" 292 | self.expected_type = expected_type 293 | """The :py:class:`rule-engine type` that was expected for this symbol.""" 294 | message = "symbol {0!r} resolved to incorrect datatype (is: {1}, expected: {2})".format( 295 | symbol_name, 296 | is_type.name, 297 | expected_type.name 298 | ) 299 | super(SymbolTypeError, self).__init__(message) 300 | 301 | class FunctionCallError(EvaluationError): 302 | """ 303 | An error raised when there is an issue calling a function. 304 | 305 | .. versionadded:: 4.0.0 306 | """ 307 | def __init__(self, message, error=None, function_name=None): 308 | super(FunctionCallError, self).__init__(message) 309 | self.error = error 310 | """The exception from which this error was triggered.""" 311 | self.function_name = function_name 312 | -------------------------------------------------------------------------------- /lib/rule_engine/parser/base.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # rule_engine/parser/base.py 5 | # 6 | # Redistribution and use in source and binary forms, with or without 7 | # modification, are permitted provided that the following conditions are 8 | # met: 9 | # 10 | # * Redistributions of source code must retain the above copyright 11 | # notice, this list of conditions and the following disclaimer. 12 | # * Redistributions in binary form must reproduce the above 13 | # copyright notice, this list of conditions and the following disclaimer 14 | # in the documentation and/or other materials provided with the 15 | # distribution. 16 | # * Neither the name of the project nor the names of its 17 | # contributors may be used to endorse or promote products derived from 18 | # this software without specific prior written permission. 19 | # 20 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 21 | # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 22 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 23 | # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 24 | # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 25 | # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 26 | # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 27 | # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 28 | # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 29 | # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 30 | # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | # 32 | 33 | import threading 34 | 35 | import ply.lex as lex 36 | import ply.yacc as yacc 37 | 38 | class ParserBase(object): 39 | """ 40 | A base class for parser objects to inherit from. This does not provide any 41 | grammar related definitions. 42 | """ 43 | precedence = () 44 | """The precedence for operators.""" 45 | tokens = () 46 | reserved_words = {} 47 | """ 48 | A mapping of literal words which are reserved to their corresponding grammar 49 | names. 50 | """ 51 | __mutex = threading.Lock() 52 | def __init__(self, debug=False): 53 | """ 54 | :param bool debug: Whether or not to enable debugging features when 55 | using the ply API. 56 | """ 57 | self.debug = debug 58 | self.context = None 59 | # Build the lexer and parser 60 | self._lexer = lex.lex(module=self, debug=self.debug) 61 | self._parser = yacc.yacc(module=self, debug=self.debug, write_tables=self.debug) 62 | 63 | def parse(self, text, context, **kwargs): 64 | """ 65 | Parse the specified text in an abstract syntax tree of nodes that can later be evaluated. This is done in two 66 | phases. First, the syntax is parsed and a tree of deferred / uninitialized AST nodes are constructed. Next each 67 | node is built recursively using it's respective :py:meth:`rule_engine.ast.ASTNodeBase.build`. 68 | 69 | :param str text: The grammar text to parse into an AST. 70 | :param context: A context for specifying parsing and evaluation options. 71 | :type context: :py:class:`~rule_engine.engine.Context` 72 | :return: The parsed AST statement. 73 | :rtype: :py:class:`~rule_engine.ast.Statement` 74 | """ 75 | kwargs['lexer'] = kwargs.pop('lexer', self._lexer) 76 | with self.__mutex: 77 | self.context = context 78 | # phase 1: parse the string into a tree of deferred nodes 79 | result = self._parser.parse(text, **kwargs) 80 | self.context = None 81 | # phase 2: initialize each AST node recursively, providing them with an opportunity to define assignments 82 | return result.build() 83 | -------------------------------------------------------------------------------- /lib/rule_engine/parser/utilities.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # rule_engine/parser/utilities.py 5 | # 6 | # Redistribution and use in source and binary forms, with or without 7 | # modification, are permitted provided that the following conditions are 8 | # met: 9 | # 10 | # * Redistributions of source code must retain the above copyright 11 | # notice, this list of conditions and the following disclaimer. 12 | # * Redistributions in binary form must reproduce the above 13 | # copyright notice, this list of conditions and the following disclaimer 14 | # in the documentation and/or other materials provided with the 15 | # distribution. 16 | # * Neither the name of the project nor the names of its 17 | # contributors may be used to endorse or promote products derived from 18 | # this software without specific prior written permission. 19 | # 20 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 21 | # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 22 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 23 | # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 24 | # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 25 | # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 26 | # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 27 | # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 28 | # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 29 | # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 30 | # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | # 32 | 33 | import ast as pyast 34 | import binascii 35 | import datetime 36 | import decimal 37 | import re 38 | 39 | from . import errors 40 | 41 | import dateutil.parser 42 | 43 | _sub_regex = r'[0-9]+([,.][0-9]+)?' 44 | timedelta_regex = ( 45 | r'P(?!\b)' 46 | r'(?P' + _sub_regex + r'W)?' 47 | r'(?P' + _sub_regex + r'D)?' 48 | r'(T' 49 | r'(?P' + _sub_regex + r'H)?' 50 | r'(?P' + _sub_regex + r'M)?' 51 | r'(?P' + _sub_regex + r'S)?' 52 | r')?' 53 | ) 54 | 55 | def parse_datetime(string, default_timezone): 56 | """ 57 | Parse a timestamp string. If the timestamp does not specify a timezone, *default_timezone* is used. 58 | 59 | :param str string: The string to parse. 60 | :param datetime.tzinfo default_timezone: The default timezone to set. 61 | :rtype: datetime.datetime 62 | """ 63 | try: 64 | dt = dateutil.parser.isoparse(string) 65 | except ValueError: 66 | raise errors.DatetimeSyntaxError('invalid datetime literal', string) from None 67 | if dt.tzinfo is None: 68 | dt = dt.replace(tzinfo=default_timezone) 69 | return dt 70 | 71 | def parse_float(string): 72 | """ 73 | Parse a literal string representing a floating point value. 74 | 75 | :param str string: The string to parse. 76 | :rtype: decimal.Decimal 77 | """ 78 | if re.match('^0[0-9]', string): 79 | raise errors.FloatSyntaxError('invalid floating point literal (leading zeros in decimal literals are not permitted)', string) 80 | try: 81 | if re.match('^0[box]', string): 82 | val = decimal.Decimal(pyast.literal_eval(string)) 83 | else: 84 | val = decimal.Decimal(string) 85 | except Exception: 86 | raise errors.FloatSyntaxError('invalid floating point literal', string) from None 87 | return val 88 | 89 | def parse_timedelta(string): 90 | """ 91 | Parse a literal string representing a time period in the ISO-8601 duration format. 92 | 93 | :param str string: The string to parse. 94 | :rtype: datetime.timedelta 95 | """ 96 | if string == "P": 97 | raise errors.TimedeltaSyntaxError('empty timedelta string', string) 98 | 99 | match = re.match("^" + timedelta_regex + "$", string) 100 | if not match: 101 | raise errors.TimedeltaSyntaxError('invalid timedelta string', string) 102 | 103 | groups = match.groupdict() 104 | for key, val in groups.items(): 105 | if val is None: 106 | val = "0n" 107 | groups[key] = float(val[:-1].replace(',', '.')) 108 | 109 | return datetime.timedelta( 110 | weeks=groups['weeks'], 111 | days=groups['days'], 112 | hours=groups['hours'], 113 | minutes=groups['minutes'], 114 | seconds=groups['seconds'], 115 | ) 116 | -------------------------------------------------------------------------------- /lib/rule_engine/suggestions.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # rule_engine/suggestions.py 5 | # 6 | # Redistribution and use in source and binary forms, with or without 7 | # modification, are permitted provided that the following conditions are 8 | # met: 9 | # 10 | # * Redistributions of source code must retain the above copyright 11 | # notice, this list of conditions and the following disclaimer. 12 | # * Redistributions in binary form must reproduce the above 13 | # copyright notice, this list of conditions and the following disclaimer 14 | # in the documentation and/or other materials provided with the 15 | # distribution. 16 | # * Neither the name of the project nor the names of its 17 | # contributors may be used to endorse or promote products derived from 18 | # this software without specific prior written permission. 19 | # 20 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 21 | # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 22 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 23 | # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 24 | # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 25 | # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 26 | # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 27 | # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 28 | # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 29 | # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 30 | # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | # 32 | 33 | import functools 34 | import re 35 | 36 | def jaro_distance(str1, str2): 37 | if str1 == str2: 38 | return 1.0 39 | 40 | str1_len = len(str1) 41 | str2_len = len(str2) 42 | max_len = max(str1_len, str2_len) 43 | match_distance = (max_len // 2) - 1 44 | str1_matches = [False] * max_len 45 | str2_matches = [False] * max_len 46 | matches = 0.0 47 | 48 | for i in range(str1_len): 49 | start = max(0, i - match_distance) 50 | end = min(i + match_distance, str2_len - 1) + 1 51 | for j in range(start, end): 52 | if not str2_matches[j] and str1[i] == str2[j]: 53 | str1_matches[i] = True 54 | str2_matches[j] = True 55 | matches += 1 56 | break 57 | 58 | if matches == 0.0: 59 | return 0.0 60 | 61 | k = 0 62 | transpositions = 0.0 63 | for i in range(str1_len): 64 | if not str1_matches[i]: 65 | continue 66 | while not str2_matches[k]: 67 | k += 1 68 | if str1[i] != str2[k]: 69 | transpositions += 1.0 70 | k += 1 71 | return ((matches / str1_len) + (matches / str2_len) + ((matches - transpositions / 2.0) / matches)) / 3.0 72 | 73 | def jaro_winkler_distance(str1, str2, scale=0.1): 74 | jaro_dist = jaro_distance(str1, str2) 75 | if jaro_dist > 0.7: 76 | prefix = 0 77 | while prefix < min(len(str1), len(str2), 5) and str1[prefix] == str2[prefix]: 78 | prefix += 1 79 | jaro_dist += scale * prefix * (1 - jaro_dist) 80 | return jaro_dist 81 | 82 | def jaro_winkler_similarity(*args, **kwargs): 83 | return 1 - jaro_winkler_distance(*args, **kwargs) 84 | 85 | def _suggest(word, options): 86 | if not len(options): 87 | return None 88 | return sorted(options, key=functools.partial(jaro_winkler_similarity, word))[0] 89 | 90 | def suggest_symbol(word, options): 91 | """ 92 | Select the best match for *word* from a list of value *options*. Values that are not suitable symbol names will be 93 | filtered out of *options*. If no match is found, this function will return None. 94 | 95 | :param str word: The original word to suggest an alternative for. 96 | :param tuple options: A list of strings to select the best match from. 97 | :return: The best replacement for *word*. 98 | :rtype: str 99 | """ 100 | from .parser import Parser # avoid circular imports 101 | symbol_regex = '^' + Parser.get_token_regex('SYMBOL') + '$' 102 | return _suggest(word, [option for option in options if re.match(symbol_regex, option)]) 103 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # setup.py 5 | # 6 | # Redistribution and use in source and binary forms, with or without 7 | # modification, are permitted provided that the following conditions are 8 | # met: 9 | # 10 | # * Redistributions of source code must retain the above copyright 11 | # notice, this list of conditions and the following disclaimer. 12 | # * Redistributions in binary form must reproduce the above 13 | # copyright notice, this list of conditions and the following disclaimer 14 | # in the documentation and/or other materials provided with the 15 | # distribution. 16 | # * Neither the name of the project nor the names of its 17 | # contributors may be used to endorse or promote products derived from 18 | # this software without specific prior written permission. 19 | # 20 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 21 | # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 22 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 23 | # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 24 | # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 25 | # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 26 | # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 27 | # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 28 | # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 29 | # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 30 | # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | # 32 | 33 | import os 34 | import re 35 | import sys 36 | 37 | base_directory = os.path.dirname(__file__) 38 | 39 | try: 40 | from setuptools import setup, find_packages 41 | except ImportError: 42 | print('This project needs setuptools in order to build. Install it using your package') 43 | print('manager (usually python-setuptools) or via pip (pip install setuptools).') 44 | sys.exit(1) 45 | 46 | try: 47 | with open(os.path.join(base_directory, 'README.rst')) as file_h: 48 | long_description = file_h.read() 49 | except OSError: 50 | sys.stderr.write('README.rst is unavailable, can not generate the long description\n') 51 | long_description = None 52 | 53 | with open(os.path.join(base_directory, 'lib', 'rule_engine', '__init__.py')) as file_h: 54 | match = re.search(r'^__version__\s*=\s*([\'"])(?P\d+(\.\d+)*)\1$', file_h.read(), flags=re.MULTILINE) 55 | if match is None: 56 | raise RuntimeError('Unable to find the version information') 57 | version = match.group('version') 58 | 59 | DESCRIPTION = """\ 60 | A lightweight, optionally typed expression language with a custom grammar for matching arbitrary Python objects.\ 61 | """ 62 | 63 | setup( 64 | name='rule-engine', 65 | version=version, 66 | author='Spencer McIntyre', 67 | author_email='zeroSteiner@gmail.com', 68 | maintainer='Spencer McIntyre', 69 | maintainer_email='zeroSteiner@gmail.com', 70 | description=DESCRIPTION, 71 | long_description=long_description, 72 | long_description_content_type='text/x-rst', 73 | url='https://github.com/zeroSteiner/rule-engine', 74 | license='BSD', 75 | # these are duplicated in requirements.txt 76 | install_requires=[ 77 | 'ply>=3.9', 78 | 'python-dateutil~=2.7' 79 | ], 80 | package_dir={'': 'lib'}, 81 | packages=find_packages('lib'), 82 | classifiers=[ 83 | 'Development Status :: 5 - Production/Stable', 84 | 'Environment :: Console', 85 | 'Intended Audience :: Developers', 86 | 'License :: OSI Approved :: BSD License', 87 | 'Operating System :: OS Independent', 88 | #'Programming Language :: Python :: 3.4', # dropped in v4.0 89 | #'Programming Language :: Python :: 3.5', # dropped in v4.0 90 | 'Programming Language :: Python :: 3.6', # will be dropped in v5.0 91 | 'Programming Language :: Python :: 3.7', # will be dropped in v5.0 92 | 'Programming Language :: Python :: 3.8', # will be dropped in v5.0 93 | 'Programming Language :: Python :: 3.9', 94 | 'Programming Language :: Python :: 3.10', 95 | 'Programming Language :: Python :: 3.11', 96 | 'Programming Language :: Python :: 3.12', 97 | 'Topic :: Software Development :: Libraries :: Python Modules' 98 | ] 99 | ) 100 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # tests/__init__.py 5 | # 6 | # Redistribution and use in source and binary forms, with or without 7 | # modification, are permitted provided that the following conditions are 8 | # met: 9 | # 10 | # * Redistributions of source code must retain the above copyright 11 | # notice, this list of conditions and the following disclaimer. 12 | # * Redistributions in binary form must reproduce the above 13 | # copyright notice, this list of conditions and the following disclaimer 14 | # in the documentation and/or other materials provided with the 15 | # distribution. 16 | # * Neither the name of the project nor the names of its 17 | # contributors may be used to endorse or promote products derived from 18 | # this software without specific prior written permission. 19 | # 20 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 21 | # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 22 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 23 | # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 24 | # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 25 | # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 26 | # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 27 | # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 28 | # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 29 | # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 30 | # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | # 32 | 33 | from .ast import * 34 | from .builtins import * 35 | from .engine import * 36 | from .errors import * 37 | from .issues import * 38 | from .parser import * 39 | from .suggestions import * 40 | from .thread_safety import * 41 | from .types import * 42 | -------------------------------------------------------------------------------- /tests/_utils.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import unittest 3 | 4 | import rule_engine._utils as utils 5 | import rule_engine.errors as errors 6 | 7 | class UtilityTests(unittest.TestCase): 8 | def test_parse_timedelta(self): 9 | self.assertEqual(utils.parse_timedelta('PT'), datetime.timedelta()) 10 | self.assertEqual(utils.parse_timedelta('P1W'), datetime.timedelta(weeks=1)) 11 | self.assertEqual(utils.parse_timedelta('P1D'), datetime.timedelta(days=1)) 12 | self.assertEqual(utils.parse_timedelta('PT1H'), datetime.timedelta(hours=1)) 13 | self.assertEqual(utils.parse_timedelta('PT1M'), datetime.timedelta(minutes=1)) 14 | self.assertEqual(utils.parse_timedelta('PT1S'), datetime.timedelta(seconds=1)) 15 | 16 | 17 | def test_parse_timedelta_error(self): 18 | with self.assertRaisesRegex(errors.TimedeltaSyntaxError, 'empty timedelta string'): 19 | utils.parse_timedelta('P') 20 | with self.assertRaisesRegex(errors.TimedeltaSyntaxError, 'invalid timedelta string'): 21 | utils.parse_timedelta('1W') 22 | with self.assertRaisesRegex(errors.TimedeltaSyntaxError, 'invalid timedelta string'): 23 | utils.parse_timedelta('p1w') 24 | with self.assertRaisesRegex(errors.TimedeltaSyntaxError, 'invalid timedelta string'): 25 | utils.parse_timedelta('PZ') 26 | -------------------------------------------------------------------------------- /tests/ast/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # tests/ast/__init__.py 5 | # 6 | # Redistribution and use in source and binary forms, with or without 7 | # modification, are permitted provided that the following conditions are 8 | # met: 9 | # 10 | # * Redistributions of source code must retain the above copyright 11 | # notice, this list of conditions and the following disclaimer. 12 | # * Redistributions in binary form must reproduce the above 13 | # copyright notice, this list of conditions and the following disclaimer 14 | # in the documentation and/or other materials provided with the 15 | # distribution. 16 | # * Neither the name of the project nor the names of its 17 | # contributors may be used to endorse or promote products derived from 18 | # this software without specific prior written permission. 19 | # 20 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 21 | # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 22 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 23 | # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 24 | # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 25 | # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 26 | # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 27 | # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 28 | # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 29 | # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 30 | # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | # 32 | 33 | import datetime 34 | import unittest 35 | 36 | from .expression import * 37 | 38 | import rule_engine.ast as ast 39 | import rule_engine.engine as engine 40 | import rule_engine.errors as errors 41 | import rule_engine.parser as parser 42 | 43 | inf = float('inf') 44 | nan = float('nan') 45 | 46 | class AstTests(unittest.TestCase): 47 | context = engine.Context() 48 | thing = {'age': 21.0, 'name': 'Alice'} 49 | def test_ast_evaluates_arithmetic_comparisons(self): 50 | parser_ = parser.Parser() 51 | statement = parser_.parse('age >= 21', self.context) 52 | self.assertTrue(statement.evaluate(self.thing)) 53 | statement = parser_.parse('age > 100', self.context) 54 | self.assertFalse(statement.evaluate(self.thing)) 55 | 56 | def test_ast_evaluates_logic(self): 57 | parser_ = parser.Parser() 58 | self.assertTrue(parser_.parse('true and true', self.context).evaluate(None)) 59 | self.assertFalse(parser_.parse('true and false', self.context).evaluate(None)) 60 | 61 | self.assertTrue(parser_.parse('true or false', self.context).evaluate(None)) 62 | self.assertFalse(parser_.parse('false or false', self.context).evaluate(None)) 63 | 64 | def test_ast_evaluates_fuzzy_comparisons(self): 65 | parser_ = parser.Parser() 66 | statement = parser_.parse('name =~ ".lic."', self.context) 67 | self.assertTrue(statement.evaluate(self.thing)) 68 | statement = parser_.parse('name =~~ "lic"', self.context) 69 | self.assertTrue(statement.evaluate(self.thing)) 70 | 71 | def test_ast_evaluates_string_comparisons(self): 72 | parser_ = parser.Parser() 73 | statement = parser_.parse('name == "Alice"', self.context) 74 | self.assertTrue(statement.evaluate(self.thing)) 75 | statement = parser_.parse('name == "calie"', self.context) 76 | self.assertFalse(statement.evaluate(self.thing)) 77 | 78 | def test_ast_evaluates_unary_not(self): 79 | parser_ = parser.Parser() 80 | statement = parser_.parse('not false', self.context) 81 | self.assertTrue(statement.evaluate(None)) 82 | statement = parser_.parse('not true', self.context) 83 | self.assertFalse(statement.evaluate(None)) 84 | 85 | statement = parser_.parse('true and not false', self.context) 86 | self.assertTrue(statement.evaluate(None)) 87 | statement = parser_.parse('false and not true', self.context) 88 | self.assertFalse(statement.evaluate(None)) 89 | 90 | def test_ast_evaluates_unary_uminus_float(self): 91 | parser_ = parser.Parser() 92 | statement = parser_.parse('-(2 * 5)', self.context) 93 | self.assertEqual(statement.evaluate(None), -10) 94 | 95 | def test_ast_evaluates_unary_uminus_timedelta(self): 96 | parser_ = parser.Parser() 97 | statement = parser_.parse('-(t"PT1H" + t"PT6M")', self.context) 98 | self.assertEqual(statement.evaluate(None), datetime.timedelta(days=-1, seconds=82440)) 99 | 100 | def test_ast_raises_type_mismatch_arithmetic_comparisons(self): 101 | parser_ = parser.Parser() 102 | statement = parser_.parse('symbol < 1', self.context) 103 | with self.assertRaises(errors.EvaluationError): 104 | statement.evaluate({'symbol': 'string'}) 105 | with self.assertRaises(errors.EvaluationError): 106 | statement.evaluate({'symbol': True}) 107 | self.assertTrue(statement.evaluate({'symbol': 0.0})) 108 | 109 | def test_ast_raises_type_mismatch_bitwise(self): 110 | parser_ = parser.Parser() 111 | statement = parser_.parse('symbol << 1', self.context) 112 | with self.assertRaises(errors.EvaluationError): 113 | statement.evaluate({'symbol': 1.1}) 114 | with self.assertRaises(errors.EvaluationError): 115 | statement.evaluate({'symbol': 'string'}) 116 | with self.assertRaises(errors.EvaluationError): 117 | statement.evaluate({'symbol': True}) 118 | with self.assertRaises(errors.EvaluationError): 119 | statement.evaluate({'symbol': inf}) 120 | with self.assertRaises(errors.EvaluationError): 121 | statement.evaluate({'symbol': nan}) 122 | self.assertEqual(statement.evaluate({'symbol': 1}), 2) 123 | 124 | with self.assertRaises(errors.EvaluationError): 125 | parser_.parse('symbol << 1.1', self.context) 126 | with self.assertRaises(errors.EvaluationError): 127 | parser_.parse('symbol << "string"', self.context) 128 | with self.assertRaises(errors.EvaluationError): 129 | parser_.parse('symbol << true', self.context) 130 | with self.assertRaises(errors.EvaluationError): 131 | parser_.parse('inf << 1', self.context) 132 | with self.assertRaises(errors.EvaluationError): 133 | parser_.parse('nan << 1', self.context) 134 | 135 | def test_ast_raises_type_mismatch_fuzzy_comparisons(self): 136 | parser_ = parser.Parser() 137 | statement = parser_.parse('symbol =~ "string"', self.context) 138 | with self.assertRaises(errors.EvaluationError): 139 | statement.evaluate({'symbol': 1.1}) 140 | with self.assertRaises(errors.EvaluationError): 141 | statement.evaluate({'symbol': True}) 142 | self.assertTrue(statement.evaluate({'symbol': 'string'})) 143 | 144 | with self.assertRaises(errors.EvaluationError): 145 | parser_.parse('"string" =~ 1', self.context) 146 | with self.assertRaises(errors.EvaluationError): 147 | parser_.parse('"string" =~ true', self.context) 148 | 149 | def test_ast_reduces_add_float(self): 150 | thing = {'one': 1, 'two': 2} 151 | parser_ = parser.Parser() 152 | statement = parser_.parse('1 + 2', self.context) 153 | self.assertIsInstance(statement.expression, ast.FloatExpression) 154 | self.assertEqual(statement.evaluate(None), 3) 155 | 156 | statement = parser_.parse('one + 2', self.context) 157 | self.assertIsInstance(statement.expression, ast.AddExpression) 158 | self.assertEqual(statement.evaluate(thing), 3) 159 | 160 | statement = parser_.parse('1 + two', self.context) 161 | self.assertIsInstance(statement.expression, ast.AddExpression) 162 | self.assertEqual(statement.evaluate(thing), 3) 163 | 164 | def test_ast_reduces_add_string(self): 165 | thing = {'first': 'Luke', 'last': 'Skywalker'} 166 | parser_ = parser.Parser() 167 | statement = parser_.parse('"Luke" + "Skywalker"', self.context) 168 | self.assertIsInstance(statement.expression, ast.StringExpression) 169 | self.assertEqual(statement.evaluate(None), 'LukeSkywalker') 170 | 171 | statement = parser_.parse('first + "Skywalker"', self.context) 172 | self.assertIsInstance(statement.expression, ast.AddExpression) 173 | self.assertEqual(statement.evaluate(thing), 'LukeSkywalker') 174 | 175 | statement = parser_.parse('"Luke" + last', self.context) 176 | self.assertIsInstance(statement.expression, ast.AddExpression) 177 | self.assertEqual(statement.evaluate(thing), 'LukeSkywalker') 178 | 179 | def test_ast_reduces_add_timedelta(self): 180 | thing = {'first': datetime.timedelta(seconds=5), 'last': datetime.timedelta(minutes=1)} 181 | parser_ = parser.Parser() 182 | 183 | statement = parser_.parse('t"PT5S" + t"PT1M"', self.context) 184 | self.assertIsInstance(statement.expression, ast.TimedeltaExpression) 185 | self.assertEqual(statement.evaluate(None), datetime.timedelta(minutes=1, seconds=5)) 186 | 187 | statement = parser_.parse('first + t"PT1M"', self.context) 188 | self.assertIsInstance(statement.expression, ast.AddExpression) 189 | self.assertEqual(statement.evaluate(thing), datetime.timedelta(minutes=1, seconds=5)) 190 | 191 | statement = parser_.parse('t"PT5S" + last', self.context) 192 | self.assertIsInstance(statement.expression, ast.AddExpression) 193 | self.assertEqual(statement.evaluate(thing), datetime.timedelta(minutes=1, seconds=5)) 194 | 195 | def test_ast_reduces_subtract_float(self): 196 | thing = {'one': 1, 'two': 2} 197 | parser_ = parser.Parser() 198 | statement = parser_.parse('2 - 1', self.context) 199 | self.assertIsInstance(statement.expression, ast.FloatExpression) 200 | self.assertEqual(statement.evaluate(None), 1) 201 | 202 | statement = parser_.parse('two - 1', self.context) 203 | self.assertIsInstance(statement.expression, ast.SubtractExpression) 204 | self.assertEqual(statement.evaluate(thing), 1) 205 | 206 | statement = parser_.parse('1 - two', self.context) 207 | self.assertIsInstance(statement.expression, ast.SubtractExpression) 208 | self.assertEqual(statement.evaluate(thing), -1) 209 | 210 | def test_ast_reduces_subtract_timedelta(self): 211 | thing = {'first': datetime.timedelta(seconds=5), 'last': datetime.timedelta(minutes=1)} 212 | parser_ = parser.Parser() 213 | 214 | statement = parser_.parse('t"PT1M" - t"PT5S"', self.context) 215 | self.assertIsInstance(statement.expression, ast.TimedeltaExpression) 216 | self.assertEqual(statement.evaluate(None), datetime.timedelta(seconds=55)) 217 | 218 | statement = parser_.parse('first - t"PT1M"', self.context) 219 | self.assertIsInstance(statement.expression, ast.SubtractExpression) 220 | self.assertEqual(statement.evaluate(thing), -datetime.timedelta(seconds=55)) 221 | 222 | statement = parser_.parse('t"PT5S" - last', self.context) 223 | self.assertIsInstance(statement.expression, ast.SubtractExpression) 224 | self.assertEqual(statement.evaluate(thing), -datetime.timedelta(seconds=55)) 225 | 226 | def test_ast_reduces_arithmetic(self): 227 | thing = {'two': 2, 'four': 4} 228 | parser_ = parser.Parser() 229 | statement = parser_.parse('2 * 4', self.context) 230 | self.assertIsInstance(statement.expression, ast.FloatExpression) 231 | self.assertEqual(statement.evaluate(None), 8) 232 | 233 | statement = parser_.parse('two * 4', self.context) 234 | self.assertIsInstance(statement.expression, ast.ArithmeticExpression) 235 | self.assertEqual(statement.evaluate(thing), 8) 236 | 237 | statement = parser_.parse('2 * four', self.context) 238 | self.assertIsInstance(statement.expression, ast.ArithmeticExpression) 239 | self.assertEqual(statement.evaluate(thing), 8) 240 | 241 | def test_ast_reduces_array_literals(self): 242 | parser_ = parser.Parser() 243 | statement = parser_.parse('[1, 2, 1 + 2]', self.context) 244 | self.assertIsInstance(statement.expression, ast.ArrayExpression) 245 | self.assertTrue(statement.expression.is_reduced) 246 | self.assertEqual(statement.evaluate(None), (1, 2, 3)) 247 | 248 | statement = parser_.parse('[foobar]', self.context) 249 | self.assertIsInstance(statement.expression, ast.ArrayExpression) 250 | self.assertFalse(statement.expression.is_reduced) 251 | 252 | def test_ast_reduces_attributes(self): 253 | parser_ = parser.Parser() 254 | statement = parser_.parse('"foobar".length', self.context) 255 | self.assertIsInstance(statement.expression, ast.FloatExpression) 256 | self.assertEqual(statement.evaluate(None), 6) 257 | 258 | def test_ast_reduces_bitwise(self): 259 | parser_ = parser.Parser() 260 | statement = parser_.parse('1 << 2', self.context) 261 | self.assertIsInstance(statement.expression, ast.FloatExpression) 262 | self.assertEqual(statement.evaluate(None), 4) 263 | 264 | def test_ast_reduces_ternary(self): 265 | parser_ = parser.Parser() 266 | statement = parser_.parse('true ? 1 : 0', self.context) 267 | self.assertIsInstance(statement.expression, ast.FloatExpression) 268 | self.assertEqual(statement.evaluate(None), 1) 269 | 270 | def test_ast_reduces_unary_uminus_float(self): 271 | parser_ = parser.Parser() 272 | 273 | statement = parser_.parse('-1.0', self.context) 274 | self.assertIsInstance(statement.expression, ast.FloatExpression) 275 | self.assertEqual(statement.evaluate(None), -1) 276 | 277 | statement = parser_.parse('-one', self.context) 278 | self.assertIsInstance(statement.expression, ast.UnaryExpression) 279 | self.assertEqual(statement.evaluate({'one': 1}), -1) 280 | 281 | def test_ast_reduces_unary_uminus_timedelta(self): 282 | parser_ = parser.Parser() 283 | 284 | statement = parser_.parse('-t"P1D"', self.context) 285 | self.assertIsInstance(statement.expression, ast.TimedeltaExpression) 286 | self.assertEqual(statement.evaluate(None), datetime.timedelta(days=-1)) 287 | 288 | statement = parser_.parse('-day', self.context) 289 | self.assertIsInstance(statement.expression, ast.UnaryExpression) 290 | self.assertEqual(statement.evaluate({'day': datetime.timedelta(days=1)}), datetime.timedelta(days=-1)) 291 | 292 | def test_ast_type_hints(self): 293 | parser_ = parser.Parser() 294 | cases = ( 295 | # type, type_is, type_is_not 296 | ('symbol << 1', ast.DataType.FLOAT, ast.DataType.STRING), 297 | ('symbol + 1', ast.DataType.FLOAT, ast.DataType.STRING), 298 | ('symbol - 1', ast.DataType.FLOAT, ast.DataType.STRING), 299 | ('symbol[1]', ast.DataType.STRING, ast.DataType.FLOAT), 300 | ('symbol[1]', ast.DataType.ARRAY, ast.DataType.FLOAT), 301 | ('symbol =~ "foo"', ast.DataType.STRING, ast.DataType.FLOAT), 302 | ) 303 | for case, type_is, type_is_not in cases: 304 | parser_.parse(case, self.context) 305 | context = engine.Context(type_resolver=engine.type_resolver_from_dict({'symbol': type_is})) 306 | parser_.parse(case, context) 307 | context = engine.Context(type_resolver=engine.type_resolver_from_dict({'symbol': type_is_not})) 308 | with self.assertRaises(errors.EvaluationError, msg='case: ' + case): 309 | parser_.parse(case, context) 310 | 311 | if __name__ == '__main__': 312 | unittest.main() 313 | -------------------------------------------------------------------------------- /tests/ast/expression/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # tests/ast/expression/__init__.py 5 | # 6 | # Redistribution and use in source and binary forms, with or without 7 | # modification, are permitted provided that the following conditions are 8 | # met: 9 | # 10 | # * Redistributions of source code must retain the above copyright 11 | # notice, this list of conditions and the following disclaimer. 12 | # * Redistributions in binary form must reproduce the above 13 | # copyright notice, this list of conditions and the following disclaimer 14 | # in the documentation and/or other materials provided with the 15 | # distribution. 16 | # * Neither the name of the project nor the names of its 17 | # contributors may be used to endorse or promote products derived from 18 | # this software without specific prior written permission. 19 | # 20 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 21 | # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 22 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 23 | # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 24 | # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 25 | # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 26 | # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 27 | # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 28 | # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 29 | # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 30 | # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | # 32 | 33 | import unittest 34 | 35 | from .attribute import * 36 | from .function_call import * 37 | from .left_operator_right import * 38 | from .literal import * 39 | from .miscellaneous import * 40 | 41 | if __name__ == '__main__': 42 | unittest.main() 43 | -------------------------------------------------------------------------------- /tests/ast/expression/function_call.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # tests/ast/expression/function_call.py 5 | # 6 | # Redistribution and use in source and binary forms, with or without 7 | # modification, are permitted provided that the following conditions are 8 | # met: 9 | # 10 | # * Redistributions of source code must retain the above copyright 11 | # notice, this list of conditions and the following disclaimer. 12 | # * Redistributions in binary form must reproduce the above 13 | # copyright notice, this list of conditions and the following disclaimer 14 | # in the documentation and/or other materials provided with the 15 | # distribution. 16 | # * Neither the name of the project nor the names of its 17 | # contributors may be used to endorse or promote products derived from 18 | # this software without specific prior written permission. 19 | # 20 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 21 | # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 22 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 23 | # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 24 | # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 25 | # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 26 | # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 27 | # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 28 | # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 29 | # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 30 | # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | # 32 | 33 | import unittest 34 | 35 | from .literal import context 36 | import rule_engine.ast as ast 37 | import rule_engine.engine as engine 38 | import rule_engine.errors as errors 39 | 40 | __all__ = ( 41 | 'FunctionCallExpressionTests', 42 | ) 43 | 44 | class FunctionCallExpressionTests(unittest.TestCase): 45 | def test_ast_expression_function_call(self): 46 | def _function(): 47 | return True 48 | symbol = ast.SymbolExpression(context, 'function') 49 | function_call = ast.FunctionCallExpression(context, symbol, []) 50 | self.assertTrue(function_call.evaluate({'function': _function})) 51 | 52 | def test_ast_expression_function_call_error_on_function_type_mismatch(self): 53 | # function type mismatch 54 | with self.assertRaises(errors.EvaluationError): 55 | context = engine.Context( 56 | type_resolver=engine.type_resolver_from_dict({ 57 | 'function': ast.DataType.NULL 58 | }) 59 | ) 60 | ast.FunctionCallExpression( 61 | context, 62 | ast.SymbolExpression(context, 'function'), 63 | [] 64 | ) 65 | 66 | def test_ast_expression_function_call_error_on_function_argument_type_mismatch(self): 67 | # function argument type mismatch 68 | with self.assertRaises(errors.EvaluationError): 69 | context = engine.Context( 70 | type_resolver=engine.type_resolver_from_dict({ 71 | 'function': ast.DataType.FUNCTION( 72 | 'function', 73 | argument_types=(ast.DataType.FLOAT,) 74 | ) 75 | }) 76 | ) 77 | ast.FunctionCallExpression( 78 | context, 79 | ast.SymbolExpression(context, 'function'), 80 | [ast.StringExpression(context, '1')] 81 | ) 82 | 83 | def test_ast_expression_function_call_error_on_uncallable_value(self): 84 | context = engine.Context() 85 | symbol = ast.SymbolExpression(context, 'function') 86 | function_call = ast.FunctionCallExpression(context, symbol, [ast.FloatExpression(context, 1)]) 87 | 88 | # function is not callable 89 | with self.assertRaises(errors.EvaluationError): 90 | self.assertTrue(function_call.evaluate({'function': True})) 91 | 92 | def test_ast_expression_function_call_error_on_to_few_arguments(self): 93 | context = engine.Context( 94 | type_resolver=engine.type_resolver_from_dict({ 95 | 'function': ast.DataType.FUNCTION( 96 | 'function', 97 | return_type=ast.DataType.FLOAT, 98 | argument_types=(ast.DataType.FLOAT, ast.DataType.FLOAT,), 99 | minimum_arguments=1 100 | ) 101 | }) 102 | ) 103 | symbol = ast.SymbolExpression(context, 'function') 104 | 105 | # function is missing arguments 106 | with self.assertRaises(errors.FunctionCallError): 107 | ast.FunctionCallExpression(context, symbol, []) 108 | 109 | def test_ast_expression_function_call_error_on_to_many_arguments(self): 110 | context = engine.Context( 111 | type_resolver=engine.type_resolver_from_dict({ 112 | 'function': ast.DataType.FUNCTION( 113 | 'function', 114 | return_type=ast.DataType.FLOAT, 115 | argument_types=(ast.DataType.FLOAT,), 116 | minimum_arguments=1 117 | ) 118 | }) 119 | ) 120 | symbol = ast.SymbolExpression(context, 'function') 121 | 122 | # function is missing arguments 123 | with self.assertRaises(errors.FunctionCallError): 124 | ast.FunctionCallExpression(context, symbol, [ 125 | ast.FloatExpression(context, 1), 126 | ast.FloatExpression(context, 1) 127 | ]) 128 | 129 | def test_ast_expression_function_call_error_on_exception(self): 130 | symbol = ast.SymbolExpression(context, 'function') 131 | function_call = ast.FunctionCallExpression(context, symbol, [ast.FloatExpression(context, 1)]) 132 | 133 | # function raises an exception 134 | class SomeException(Exception): 135 | pass 136 | def _function(): 137 | raise SomeException() 138 | with self.assertRaises(errors.EvaluationError): 139 | function_call.evaluate({'function': _function}) 140 | 141 | def test_ast_expression_function_call_error_on_incompatible_return_type(self): 142 | symbol = ast.SymbolExpression(context, 'function') 143 | function_call = ast.FunctionCallExpression(context, symbol, []) 144 | function_call.result_type = ast.DataType.FUNCTION('function', return_type=ast.DataType.FLOAT) 145 | 146 | def _function(): 147 | return '' 148 | with self.assertRaises(errors.FunctionCallError): 149 | function_call.evaluate({'function': _function}) 150 | -------------------------------------------------------------------------------- /tests/ast/expression/literal.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # tests/ast/expression/literal.py 5 | # 6 | # Redistribution and use in source and binary forms, with or without 7 | # modification, are permitted provided that the following conditions are 8 | # met: 9 | # 10 | # * Redistributions of source code must retain the above copyright 11 | # notice, this list of conditions and the following disclaimer. 12 | # * Redistributions in binary form must reproduce the above 13 | # copyright notice, this list of conditions and the following disclaimer 14 | # in the documentation and/or other materials provided with the 15 | # distribution. 16 | # * Neither the name of the project nor the names of its 17 | # contributors may be used to endorse or promote products derived from 18 | # this software without specific prior written permission. 19 | # 20 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 21 | # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 22 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 23 | # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 24 | # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 25 | # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 26 | # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 27 | # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 28 | # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 29 | # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 30 | # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | # 32 | 33 | import datetime 34 | import decimal 35 | import unittest 36 | 37 | import rule_engine.ast as ast 38 | import rule_engine.builtins as builtins 39 | import rule_engine.engine as engine 40 | import rule_engine.errors as errors 41 | 42 | __all__ = ('LiteralExpressionTests',) 43 | 44 | context = engine.Context() 45 | context.builtins = builtins.Builtins.from_defaults( 46 | {'test': {'one': 1.0, 'two': 2.0}} 47 | ) 48 | # literal expressions which should evaluate to false 49 | falseish = ( 50 | ast.ArrayExpression(context, tuple()), 51 | ast.BooleanExpression(context, False), 52 | ast.TimedeltaExpression(context, datetime.timedelta()), 53 | ast.FloatExpression(context, 0.0), 54 | ast.NullExpression(context), 55 | ast.StringExpression(context, '') 56 | ) 57 | # literal expressions which should evaluate to true 58 | trueish = ( 59 | ast.ArrayExpression(context, tuple((ast.NullExpression(context),))), 60 | ast.ArrayExpression(context, tuple((ast.FloatExpression(context, 1.0),))), 61 | ast.BooleanExpression(context, True), 62 | ast.DatetimeExpression(context, datetime.datetime.now()), 63 | ast.TimedeltaExpression(context, datetime.timedelta(seconds=1)), 64 | ast.FloatExpression(context, float('-inf')), 65 | ast.FloatExpression(context, -1.0), 66 | ast.FloatExpression(context, 1.0), 67 | ast.FloatExpression(context, float('inf')), 68 | ast.StringExpression(context, 'non-empty') 69 | ) 70 | 71 | class UnknownType(object): 72 | pass 73 | 74 | class LiteralExpressionTests(unittest.TestCase): 75 | context = engine.Context() 76 | def assertLiteralTests(self, ExpressionClass, false_value, *true_values): 77 | with self.assertRaises(TypeError): 78 | ast.StringExpression(self.context, UnknownType()) 79 | 80 | expression = ExpressionClass(self.context, false_value) 81 | self.assertIsInstance(expression, ast.LiteralExpressionBase) 82 | self.assertFalse(expression.evaluate(None)) 83 | 84 | for true_value in true_values: 85 | expression = ExpressionClass(self.context, true_value) 86 | self.assertTrue(expression.evaluate(None)) 87 | 88 | def test_ast_expression_literal(self): 89 | expressions = ( 90 | (ast.ArrayExpression, ()), 91 | (ast.BooleanExpression, False), 92 | (ast.BytesExpression, b''), 93 | (ast.DatetimeExpression, datetime.datetime(2020, 1, 1)), 94 | (ast.FloatExpression, 0), 95 | (ast.MappingExpression, {}), 96 | (ast.SetExpression, set()), 97 | (ast.StringExpression, ''), 98 | (ast.TimedeltaExpression, datetime.timedelta(seconds=42)), 99 | ) 100 | for expression_class, value in expressions: 101 | expression = ast.LiteralExpressionBase.from_value(context, value) 102 | self.assertIsInstance(expression, expression_class) 103 | 104 | with self.assertRaises(TypeError): 105 | ast.LiteralExpressionBase.from_value(context, object()) 106 | 107 | def test_ast_expression_literal_array(self): 108 | self.assertLiteralTests(ast.ArrayExpression, tuple(), tuple((ast.NullExpression(self.context),))) 109 | 110 | def test_ast_expression_literal_boolean(self): 111 | self.assertLiteralTests(ast.BooleanExpression, False, True) 112 | 113 | def test_ast_expression_literal_bytes(self): 114 | self.assertLiteralTests(ast.BytesExpression, b'', b'\x00') 115 | 116 | def test_ast_expression_literal_float(self): 117 | trueish_floats = (expression.value for expression in trueish if isinstance(expression, ast.FloatExpression)) 118 | self.assertLiteralTests(ast.FloatExpression, 0.0, float('nan'), *trueish_floats) 119 | # converts ints to floats automatically 120 | int_float = ast.FloatExpression(context, 1) 121 | self.assertIsInstance(int_float.value, decimal.Decimal) 122 | self.assertEqual(int_float.value, 1.0) 123 | 124 | def test_ast_expression_literal_mapping(self): 125 | self.assertLiteralTests(ast.MappingExpression, {}, {ast.StringExpression(context, 'one'): ast.FloatExpression(context, 1)}) 126 | 127 | with self.assertRaises(errors.EngineError): 128 | expression = ast.MappingExpression(context, {ast.MappingExpression(context, {}): ast.NullExpression(context)}) 129 | 130 | expression = ast.MappingExpression(context, {ast.SymbolExpression(context, 'map'): ast.NullExpression(context)}) 131 | with self.assertRaises(errors.EngineError): 132 | expression.evaluate({'map': {}}) 133 | 134 | def test_ast_expression_literal_null(self): 135 | expression = ast.NullExpression(self.context) 136 | self.assertIsNone(expression.evaluate(None)) 137 | with self.assertRaises(TypeError): 138 | ast.NullExpression(self.context, False) 139 | 140 | def test_ast_expression_literal_string(self): 141 | self.assertLiteralTests(ast.StringExpression, '', 'non-empty') 142 | 143 | def test_ast_expression_literal_timedelta(self): 144 | with self.assertRaises(errors.TimedeltaSyntaxError): 145 | ast.TimedeltaExpression.from_string(self.context, 'INVALID') 146 | 147 | if __name__ == '__main__': 148 | unittest.main() 149 | -------------------------------------------------------------------------------- /tests/builtins.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # tests/builtins.py 5 | # 6 | # Redistribution and use in source and binary forms, with or without 7 | # modification, are permitted provided that the following conditions are 8 | # met: 9 | # 10 | # * Redistributions of source code must retain the above copyright 11 | # notice, this list of conditions and the following disclaimer. 12 | # * Redistributions in binary form must reproduce the above 13 | # copyright notice, this list of conditions and the following disclaimer 14 | # in the documentation and/or other materials provided with the 15 | # distribution. 16 | # * Neither the name of the project nor the names of its 17 | # contributors may be used to endorse or promote products derived from 18 | # this software without specific prior written permission. 19 | # 20 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 21 | # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 22 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 23 | # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 24 | # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 25 | # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 26 | # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 27 | # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 28 | # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 29 | # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 30 | # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | # 32 | 33 | import contextlib 34 | import datetime 35 | import decimal 36 | import random 37 | import string 38 | import unittest 39 | 40 | import rule_engine.ast as ast 41 | import rule_engine.builtins as builtins 42 | import rule_engine.engine as engine 43 | import rule_engine.errors as errors 44 | 45 | import dateutil.tz 46 | 47 | try: 48 | import graphviz 49 | except ImportError: 50 | has_graphviz = False 51 | else: 52 | has_graphviz = True 53 | 54 | @contextlib.contextmanager 55 | def disable_random(): 56 | now = datetime.datetime.now() 57 | state = random.getstate() 58 | random.seed(now.timestamp()) 59 | try: 60 | yield random.getstate() 61 | finally: 62 | random.setstate(state) 63 | 64 | class BuiltinsTests(unittest.TestCase): 65 | def assertBuiltinFunction(self, name, expected_result, *arguments): 66 | blts = builtins.Builtins.from_defaults() 67 | function = blts[name] 68 | function_type = blts.resolve_type(name) 69 | self.assertIsNot( 70 | function_type.minimum_arguments, 71 | ast.DataType.UNDEFINED, 72 | msg='builtin function should have a defined minimum number of arguments' 73 | ) 74 | self.assertTrue(callable(function), msg='builtin functions should be callable') 75 | result = function(*arguments) 76 | self.assertEqual(result, expected_result, msg='builtin functions should return the expected result') 77 | result_type = ast.DataType.from_value(result) 78 | self.assertTrue(ast.DataType.is_compatible(result_type, function_type.return_type)) 79 | return result 80 | 81 | def test_builtin_functions(self): 82 | blts = builtins.Builtins.from_defaults() 83 | for name in blts: 84 | data_type = blts.resolve_type(name) 85 | if not isinstance(data_type, ast.DataType.FUNCTION.__class__): 86 | continue 87 | self.assertEqual(name, data_type.value_name) 88 | 89 | def test_builtins(self): 90 | blts = builtins.Builtins.from_defaults({'test': {'one': 1.0, 'two': 2.0}}) 91 | self.assertIsInstance(blts, builtins.Builtins) 92 | self.assertIsNone(blts.namespace) 93 | self.assertRegex(repr(blts), r' baz) : (bar < -baz)] # comment') 136 | digraph = rule.to_graphviz() 137 | self.assertIsInstance(digraph, graphviz.Digraph) 138 | 139 | @unittest.skipUnless(has_graphviz, 'graphviz is unavailable') 140 | def test_engine_rule_to_graphviz_3(self): 141 | rule = engine.Rule('[member for member in iterable if member]') 142 | digraph = rule.to_graphviz() 143 | self.assertIsInstance(digraph, graphviz.Digraph) 144 | 145 | def test_engine_rule_to_strings(self): 146 | rule = engine.Rule(self.rule_text) 147 | self.assertEqual(str(rule), self.rule_text) 148 | self.assertRegex(repr(rule), "".format(re.escape(self.rule_text))) 149 | 150 | def test_engine_rule_matches(self, rule=None): 151 | rule = rule or engine.Rule(self.rule_text) 152 | result = rule.matches(self.true_item) 153 | self.assertIsInstance(result, bool) 154 | self.assertTrue(result) 155 | result = rule.matches(self.false_item) 156 | self.assertIsInstance(result, bool) 157 | self.assertFalse(result) 158 | 159 | def test_engine_rule_filter(self, rule=None): 160 | rule = rule or engine.Rule(self.rule_text) 161 | result = rule.filter([self.true_item, self.false_item]) 162 | self.assertIsInstance(result, types.GeneratorType) 163 | result = tuple(result) 164 | self.assertIn(self.true_item, result) 165 | self.assertNotIn(self.false_item, result) 166 | 167 | def test_engine_rule_evaluate(self): 168 | rule = engine.Rule('"string"') 169 | self.assertEqual(rule.evaluate(None), 'string') 170 | 171 | def test_engine_rule_evaluate_attributes(self): 172 | # ensure that multiple levels can be evaluated as attributes 173 | rule = engine.Rule('a.b.c') 174 | self.assertTrue(rule.evaluate({'a': {'b': {'c': True}}})) 175 | 176 | value = rule.evaluate({'a': {'b': {'c': 1}}}) 177 | self.assertIsInstance(value, decimal.Decimal) 178 | self.assertEqual(value, 1.0) 179 | 180 | value = rule.evaluate({'a': {'b': {'c': {'d': None}}}}) 181 | self.assertIsInstance(value, dict) 182 | self.assertIn('d', value) 183 | 184 | with self.assertRaises(errors.AttributeResolutionError): 185 | rule.evaluate({'a': {}}) 186 | 187 | def test_engine_rule_debug_parser(self): 188 | with open(os.devnull, 'w') as file_h: 189 | original_stderr = sys.stderr 190 | sys.stderr = file_h 191 | debug_rule = engine.DebugRule(self.rule_text) 192 | sys.stderr = original_stderr 193 | self.test_engine_rule_matches(rule=debug_rule) 194 | self.test_engine_rule_filter(rule=debug_rule) 195 | 196 | 197 | class EngineDatetimeRuleTests(unittest.TestCase): 198 | def test_add_timedeltas(self): 199 | rule = engine.Rule("t'P4DT2H31S' + t'P1WT45M17S' == t'P1W4DT2H45M48S'") 200 | self.assertTrue(rule.evaluate({})) 201 | 202 | def test_add_empty_timedelta(self): 203 | rule = engine.Rule("t'P1DT3S' + t'PT' == t'P1DT3S'") 204 | self.assertTrue(rule.evaluate({})) 205 | 206 | def test_add_to_today(self): 207 | rule = engine.Rule("$today + t'PT' == $today") 208 | self.assertTrue(rule.evaluate({})) 209 | 210 | def test_add_datetime_to_timedelta(self): 211 | rule = engine.Rule("d'2022-05-23 08:23' + t'PT4H3M2S' == d'2022-05-23 12:26:02'") 212 | self.assertTrue(rule.evaluate({})) 213 | 214 | rule = engine.Rule("start + t'PT1H' == end") 215 | self.assertTrue(rule.evaluate({ 216 | "start": datetime.datetime(year=2022, month=2, day=28, hour=23, minute=32, second=56), 217 | "end": datetime.datetime(year=2022, month=3, day=1, hour=0, minute=32, second=56), 218 | })) 219 | 220 | def test_subtract_timedeltas(self): 221 | rule = engine.Rule("t'P4DT2H31S' - t'P1DT45S' == t'P3DT1H59M46S'") 222 | self.assertTrue(rule.evaluate({})) 223 | 224 | rule = engine.Rule("t'P4DT2H31S' - t'P1WT45M17S' == -t'P2DT22H44M46S'") 225 | self.assertTrue(rule.evaluate({})) 226 | 227 | def test_subtract_empty_timedelta(self): 228 | rule = engine.Rule("t'P1DT3S' - t'PT' == t'P1DT3S'") 229 | self.assertTrue(rule.evaluate({})) 230 | 231 | def test_subtract_from_today(self): 232 | rule = engine.Rule("$today - t'PT' == $today") 233 | self.assertTrue(rule.evaluate({})) 234 | 235 | def test_subtract_datetime_from_datetime(self): 236 | rule = engine.Rule("d'2022-05-23 14:12' - d'2022-05-23 12:15' == t'PT1H57M'") 237 | self.assertTrue(rule.evaluate({})) 238 | 239 | rule = engine.Rule("end - t'PT1H' == start") 240 | self.assertTrue(rule.evaluate({ 241 | "start": datetime.datetime(year=2022, month=2, day=28, hour=23, minute=32, second=56), 242 | "end": datetime.datetime(year=2022, month=3, day=1, hour=0, minute=32, second=56), 243 | })) 244 | 245 | def test_subtract_timedelta_from_datetime(self): 246 | rule = engine.Rule("d'2022-06-12' - t'P1D' == d'2022-06-11'") 247 | self.assertTrue(rule.evaluate({})) 248 | 249 | if __name__ == '__main__': 250 | unittest.main() 251 | -------------------------------------------------------------------------------- /tests/errors.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # tests/errors.py 5 | # 6 | # Redistribution and use in source and binary forms, with or without 7 | # modification, are permitted provided that the following conditions are 8 | # met: 9 | # 10 | # * Redistributions of source code must retain the above copyright 11 | # notice, this list of conditions and the following disclaimer. 12 | # * Redistributions in binary form must reproduce the above 13 | # copyright notice, this list of conditions and the following disclaimer 14 | # in the documentation and/or other materials provided with the 15 | # distribution. 16 | # * Neither the name of the project nor the names of its 17 | # contributors may be used to endorse or promote products derived from 18 | # this software without specific prior written permission. 19 | # 20 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 21 | # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 22 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 23 | # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 24 | # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 25 | # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 26 | # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 27 | # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 28 | # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 29 | # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 30 | # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | # 32 | 33 | import random 34 | import string 35 | import unittest 36 | 37 | import rule_engine.errors as errors 38 | 39 | class ResolutionErrorTests(unittest.TestCase): 40 | def test_attribute_error_repr(self): 41 | attribute_error = errors.AttributeResolutionError('doesnotexist', None) 42 | self.assertIn('suggestion', repr(attribute_error)) 43 | 44 | suggestion = ''.join(random.choice(string.ascii_letters) for _ in range(10)) 45 | attribute_error = errors.AttributeResolutionError('doesnotexist', None, suggestion=suggestion) 46 | self.assertIn('suggestion', repr(attribute_error)) 47 | self.assertIn(suggestion, repr(attribute_error)) 48 | 49 | def test_symbol_error_repr(self): 50 | symbol_error = errors.SymbolResolutionError('doesnotexist') 51 | self.assertIn('suggestion', repr(symbol_error)) 52 | 53 | suggestion = ''.join(random.choice(string.ascii_letters) for _ in range(10)) 54 | symbol_error = errors.SymbolResolutionError('doesnotexist', suggestion=suggestion) 55 | self.assertIn('suggestion', repr(symbol_error)) 56 | self.assertIn(suggestion, repr(symbol_error)) 57 | 58 | class UndefinedSentinelTests(unittest.TestCase): 59 | def test_undefined_has_a_repr(self): 60 | self.assertEqual(repr(errors.UNDEFINED), 'UNDEFINED') 61 | 62 | def test_undefined_is_a_sentinel(self): 63 | self.assertIsNotNone(errors.UNDEFINED) 64 | self.assertIs(errors.UNDEFINED, errors.UNDEFINED) 65 | 66 | def test_undefined_is_falsy(self): 67 | self.assertFalse(errors.UNDEFINED) 68 | 69 | if __name__ == '__main__': 70 | unittest.main() -------------------------------------------------------------------------------- /tests/issues.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # tests/issues.py 5 | # 6 | # Redistribution and use in source and binary forms, with or without 7 | # modification, are permitted provided that the following conditions are 8 | # met: 9 | # 10 | # * Redistributions of source code must retain the above copyright 11 | # notice, this list of conditions and the following disclaimer. 12 | # * Redistributions in binary form must reproduce the above 13 | # copyright notice, this list of conditions and the following disclaimer 14 | # in the documentation and/or other materials provided with the 15 | # distribution. 16 | # * Neither the name of the project nor the names of its 17 | # contributors may be used to endorse or promote products derived from 18 | # this software without specific prior written permission. 19 | # 20 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 21 | # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 22 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 23 | # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 24 | # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 25 | # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 26 | # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 27 | # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 28 | # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 29 | # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 30 | # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | # 32 | 33 | import datetime 34 | import random 35 | import re 36 | import unittest 37 | import warnings 38 | 39 | import rule_engine.engine as engine 40 | import rule_engine.errors as errors 41 | import rule_engine.types as types 42 | 43 | import dateutil.tz 44 | 45 | class GitHubIssueTests(unittest.TestCase): 46 | def test_number_10(self): 47 | value = random.randint(1, 10000) 48 | thing = { 49 | 'c': { 50 | 'c1': value, 51 | } 52 | } 53 | rule_text = 'c.c1 == ' + str(value) 54 | rule1 = engine.Rule(rule_text, context=engine.Context()) 55 | rule2 = engine.Rule(rule_text, context=engine.Context(default_value=None)) 56 | self.assertEqual(rule1.evaluate(thing), rule2.evaluate(thing)) 57 | 58 | def test_number_14(self): 59 | context = engine.Context( 60 | type_resolver=engine.type_resolver_from_dict({ 61 | 'TEST_FLOAT': types.DataType.FLOAT, 62 | }) 63 | ) 64 | rule = engine.Rule( 65 | '(TEST_FLOAT == null ? 0 : TEST_FLOAT) < 42', 66 | context=context 67 | ) 68 | rule.matches({'TEST_FLOAT': None}) 69 | 70 | def test_number_19(self): 71 | context = engine.Context( 72 | type_resolver=engine.type_resolver_from_dict({ 73 | 'facts': types.DataType.MAPPING( 74 | key_type=types.DataType.STRING, 75 | value_type=types.DataType.STRING 76 | ) 77 | }) 78 | ) 79 | rule = engine.Rule('facts.abc == "def"', context=context) 80 | self.assertTrue(rule.matches({'facts': {'abc': 'def'}})) 81 | 82 | def test_number_20(self): 83 | rule = engine.Rule('a / b ** 2') 84 | self.assertEqual(rule.evaluate({'a': 8, 'b': 4}), 0.5) 85 | 86 | def test_number_22(self): 87 | rules = ('object["timestamp"] > $now', 'object.timestamp > $now') 88 | for rule in rules: 89 | rule = engine.Rule(rule) 90 | self.assertFalse(rule.evaluate({ 91 | 'object': {'timestamp': datetime.datetime(2021, 8, 19)} 92 | })) 93 | 94 | def test_number_54(self): 95 | rules = ( 96 | 'count == 01', 97 | "test=='NOTTEST' and count==01 and other=='other'" 98 | ) 99 | for rule in rules: 100 | with self.assertRaises(errors.FloatSyntaxError): 101 | engine.Rule(rule) 102 | 103 | def test_number_66(self): 104 | rule = engine.Rule('$parse_datetime("2020-01-01")') 105 | try: 106 | result = rule.evaluate({}) 107 | except Exception: 108 | self.fail('evaluation raised an exception') 109 | self.assertEqual(result, datetime.datetime(2020, 1, 1, tzinfo=dateutil.tz.tzlocal())) 110 | 111 | def test_number_68(self): 112 | rule = engine.Rule('$min(items)') 113 | try: 114 | result = rule.evaluate({'items': [1, 2, 3, 4, 5, 6, 7, 8, 9]}) 115 | except Exception: 116 | self.fail('evaluation raised an exception') 117 | self.assertEqual(result, 1) 118 | 119 | def test_number_98(self): 120 | value = re.escape("joe.blogs") 121 | 122 | # Check if the some_attribute key in the dictionary matches 'joe.blogs' exactly 123 | with warnings.catch_warnings(record=True) as w: 124 | warnings.simplefilter('always', SyntaxWarning) 125 | engine.Rule(rf'some_attribute =~ "^{value}$"') 126 | for warning in w: 127 | if issubclass(warning.category, SyntaxWarning): 128 | self.fail('SyntaxWarning was raised') 129 | 130 | def test_number_104(self): 131 | rule = engine.Rule(r"'André' in name") 132 | self.assertTrue(rule.matches({'name': 'André the Giant'})) 133 | -------------------------------------------------------------------------------- /tests/suggestions.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # tests/suggestions.py 5 | # 6 | # Redistribution and use in source and binary forms, with or without 7 | # modification, are permitted provided that the following conditions are 8 | # met: 9 | # 10 | # * Redistributions of source code must retain the above copyright 11 | # notice, this list of conditions and the following disclaimer. 12 | # * Redistributions in binary form must reproduce the above 13 | # copyright notice, this list of conditions and the following disclaimer 14 | # in the documentation and/or other materials provided with the 15 | # distribution. 16 | # * Neither the name of the project nor the names of its 17 | # contributors may be used to endorse or promote products derived from 18 | # this software without specific prior written permission. 19 | # 20 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 21 | # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 22 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 23 | # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 24 | # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 25 | # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 26 | # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 27 | # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 28 | # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 29 | # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 30 | # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | # 32 | 33 | import random 34 | import string 35 | import unittest 36 | 37 | import rule_engine.suggestions as suggestions 38 | 39 | # JARO_WINKLER_TEST_CASES taken from the original whitepaper: see page 13, table 4 40 | # https://www.census.gov/srd/papers/pdf/rr91-9.pdf 41 | # not all JARO_WINKLER_TEST_CASES are a perfect match, so the table is a selection of those that are 42 | JARO_WINKLER_TEST_CASES = ( 43 | ('shackleford', 'shackelford', 0.9848), 44 | ('cunningham', 'cunnigham', 0.9833), 45 | ('galloway', 'calloway', 0.9167), 46 | ('lampley', 'campley', 0.9048), 47 | ('michele', 'michelle', 0.9792), 48 | ('jonathon', 'jonathan', 0.9583), 49 | ) 50 | 51 | class JaroWinklerTests(unittest.TestCase): 52 | def test_jaro_winkler_distance(self): 53 | for str1, str2, distance in JARO_WINKLER_TEST_CASES: 54 | self.assertEqual( 55 | round(suggestions.jaro_winkler_distance(str1, str2), 4), 56 | distance, 57 | msg="({}, {}) != {}".format(str1, str2, distance) 58 | ) 59 | 60 | def test_jaro_winkler_distance_match(self): 61 | strx = ''.join(random.choice(string.ascii_letters) for _ in range(10)) 62 | self.assertEqual( 63 | suggestions.jaro_winkler_distance(strx, strx), 64 | 1.0 65 | ) 66 | 67 | def test_jaro_winkler_similarity(self): 68 | for str1, str2, distance in JARO_WINKLER_TEST_CASES: 69 | similarity = round(1 - distance, 4) 70 | self.assertEqual( 71 | round(suggestions.jaro_winkler_similarity(str1, str2), 4), 72 | similarity, 73 | msg="({}, {}) != {}".format(str1, str2, similarity) 74 | ) 75 | 76 | def test_jaro_winkler_similarity_match(self): 77 | strx = ''.join(random.choice(string.ascii_letters) for _ in range(10)) 78 | self.assertEqual( 79 | suggestions.jaro_winkler_similarity(strx, strx), 80 | 0.0 81 | ) 82 | -------------------------------------------------------------------------------- /tests/thread_safety.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # tests/thread_safety.py 5 | # 6 | # Redistribution and use in source and binary forms, with or without 7 | # modification, are permitted provided that the following conditions are 8 | # met: 9 | # 10 | # * Redistributions of source code must retain the above copyright 11 | # notice, this list of conditions and the following disclaimer. 12 | # * Redistributions in binary form must reproduce the above 13 | # copyright notice, this list of conditions and the following disclaimer 14 | # in the documentation and/or other materials provided with the 15 | # distribution. 16 | # * Neither the name of the project nor the names of its 17 | # contributors may be used to endorse or promote products derived from 18 | # this software without specific prior written permission. 19 | # 20 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 21 | # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 22 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 23 | # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 24 | # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 25 | # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 26 | # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 27 | # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 28 | # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 29 | # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 30 | # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | # 32 | 33 | import functools 34 | import queue 35 | import random 36 | import threading 37 | import unittest 38 | 39 | import rule_engine.ast as ast 40 | import rule_engine.engine as engine 41 | import rule_engine.errors as errors 42 | 43 | __all__ = ('ThreadSafetyTests',) 44 | 45 | def testing_resolver(lock, thing, name): 46 | if name == 'lock': 47 | lock.acquire() 48 | return True 49 | return engine.resolve_item(thing, name) 50 | 51 | class RuleThread(threading.Thread): 52 | def __init__(self, rule, thing): 53 | self.rule = rule 54 | self.thing = thing 55 | self.queue = queue.Queue() 56 | super(RuleThread, self).__init__() 57 | self.start() 58 | 59 | def run(self): 60 | self.queue.put(self.rule.evaluate(self.thing)) 61 | 62 | def join(self, *args, **kwargs): 63 | super(RuleThread, self).join(*args, **kwargs) 64 | return self.queue.get() 65 | 66 | class ThreadSafetyTests(unittest.TestCase): 67 | def test_tls_for_comprehension(self): 68 | context = engine.Context() 69 | rule = engine.Rule('[word for word in words][0]', context=context) 70 | rule.evaluate({'words': ('MainThread', 'Test')}) 71 | # this isn't exactly a thread test since the assignment scope should be cleared after the comprehension is 72 | # complete 73 | self.assertEqual(len(context._tls.assignment_scopes), 0) 74 | 75 | def test_tls_for_regex1(self): 76 | context = engine.Context() 77 | rule = engine.Rule(r'words =~ "(\\w+) \\w+"', context=context) 78 | rule.evaluate({'words': 'MainThread Test'}) 79 | self.assertEqual(context._tls.regex_groups, ('MainThread',)) 80 | RuleThread(rule, {'words': 'AlternateThread Test'}).join() 81 | self.assertEqual(context._tls.regex_groups, ('MainThread',)) 82 | 83 | def test_tls_for_regex2(self): 84 | lock = threading.RLock() 85 | context = engine.Context(resolver=functools.partial(testing_resolver, lock)) 86 | rule = engine.Rule(r'words =~ "(\\w+) \\w+" and lock and $re_groups[0] == "MainThread"', context=context) 87 | self.assertTrue(rule.evaluate({'words': 'MainThread Test'})) 88 | lock.release() 89 | with lock: 90 | thread = RuleThread(rule, {'words': 'AlternateThread Test'}) 91 | self.assertTrue(rule.evaluate({'words': 'MainThread Test'})) 92 | lock.release() 93 | self.assertFalse(thread.join()) 94 | -------------------------------------------------------------------------------- /tests/types.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # tests/types.py 5 | # 6 | # Redistribution and use in source and binary forms, with or without 7 | # modification, are permitted provided that the following conditions are 8 | # met: 9 | # 10 | # * Redistributions of source code must retain the above copyright 11 | # notice, this list of conditions and the following disclaimer. 12 | # * Redistributions in binary form must reproduce the above 13 | # copyright notice, this list of conditions and the following disclaimer 14 | # in the documentation and/or other materials provided with the 15 | # distribution. 16 | # * Neither the name of the project nor the names of its 17 | # contributors may be used to endorse or promote products derived from 18 | # this software without specific prior written permission. 19 | # 20 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 21 | # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 22 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 23 | # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 24 | # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 25 | # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 26 | # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 27 | # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 28 | # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 29 | # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 30 | # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | # 32 | 33 | import collections 34 | import datetime 35 | import sys 36 | import typing 37 | import unittest 38 | 39 | import rule_engine.types as types 40 | 41 | __all__ = ('DataTypeTests', 'MetaDataTypeTests', 'ValueIsTests') 42 | 43 | DataType = types.DataType 44 | 45 | class DataTypeTests(unittest.TestCase): 46 | class _UnsupportedType(object): 47 | pass 48 | 49 | def test_data_type_collections(self): 50 | with self.assertRaises(TypeError): 51 | types._CollectionDataTypeDef('TEST', float) 52 | 53 | def test_data_type_equality_array(self): 54 | dt1 = DataType.ARRAY(DataType.STRING) 55 | self.assertIs(dt1.value_type, DataType.STRING) 56 | self.assertEqual(dt1, DataType.ARRAY(DataType.STRING)) 57 | self.assertNotEqual(dt1, DataType.ARRAY) 58 | self.assertNotEqual(dt1, DataType.ARRAY(DataType.STRING, value_type_nullable=False)) 59 | 60 | def test_data_type_equality_function(self): 61 | dt1 = DataType.FUNCTION('test', return_type=DataType.FLOAT, argument_types=(), minimum_arguments=0) 62 | self.assertEqual(dt1.value_name, 'test') 63 | self.assertEqual(dt1, DataType.FUNCTION('otherTest', return_type=DataType.FLOAT, argument_types=(), minimum_arguments=0)) 64 | self.assertNotEqual(dt1, DataType.NULL) 65 | self.assertNotEqual(dt1, DataType.FUNCTION('test', return_type=DataType.NULL, argument_types=(), minimum_arguments=0)) 66 | self.assertNotEqual(dt1, DataType.FUNCTION('test', return_type=DataType.FLOAT, argument_types=(DataType.FLOAT,), minimum_arguments=0)) 67 | self.assertNotEqual(dt1, DataType.FUNCTION('otherTest', return_type=DataType.FLOAT, minimum_arguments=1)) 68 | 69 | def test_data_type_equality_mapping(self): 70 | dt1 = DataType.MAPPING(DataType.STRING) 71 | self.assertIs(dt1.key_type, DataType.STRING) 72 | self.assertEqual(dt1, DataType.MAPPING(DataType.STRING)) 73 | self.assertNotEqual(dt1, DataType.MAPPING) 74 | self.assertNotEqual(dt1, DataType.MAPPING(DataType.STRING, value_type=DataType.STRING)) 75 | self.assertNotEqual(dt1, DataType.MAPPING(DataType.STRING, value_type_nullable=False)) 76 | 77 | def test_data_type_equality_set(self): 78 | dt1 = DataType.SET(DataType.STRING) 79 | self.assertIs(dt1.value_type, DataType.STRING) 80 | self.assertEqual(dt1, DataType.SET(DataType.STRING)) 81 | self.assertNotEqual(dt1, DataType.SET) 82 | self.assertNotEqual(dt1, DataType.SET(DataType.STRING, value_type_nullable=False)) 83 | 84 | def test_data_type_from_name(self): 85 | self.assertIs(DataType.from_name('ARRAY'), DataType.ARRAY) 86 | self.assertIs(DataType.from_name('BOOLEAN'), DataType.BOOLEAN) 87 | self.assertIs(DataType.from_name('BYTES'), DataType.BYTES) 88 | self.assertIs(DataType.from_name('DATETIME'), DataType.DATETIME) 89 | self.assertIs(DataType.from_name('FLOAT'), DataType.FLOAT) 90 | self.assertIs(DataType.from_name('FUNCTION'), DataType.FUNCTION) 91 | self.assertIs(DataType.from_name('MAPPING'), DataType.MAPPING) 92 | self.assertIs(DataType.from_name('NULL'), DataType.NULL) 93 | self.assertIs(DataType.from_name('SET'), DataType.SET) 94 | self.assertIs(DataType.from_name('STRING'), DataType.STRING) 95 | self.assertIs(DataType.from_name('TIMEDELTA'), DataType.TIMEDELTA) 96 | 97 | def test_data_type_from_name_error(self): 98 | with self.assertRaises(TypeError): 99 | DataType.from_name(1) 100 | with self.assertRaises(ValueError): 101 | DataType.from_name('FOOBAR') 102 | 103 | def test_data_type_from_type(self): 104 | self.assertIs(DataType.from_type(list), DataType.ARRAY) 105 | self.assertIs(DataType.from_type(tuple), DataType.ARRAY) 106 | self.assertIs(DataType.from_type(bool), DataType.BOOLEAN) 107 | self.assertIs(DataType.from_type(bytes), DataType.BYTES) 108 | self.assertIs(DataType.from_type(datetime.date), DataType.DATETIME) 109 | self.assertIs(DataType.from_type(datetime.datetime), DataType.DATETIME) 110 | self.assertIs(DataType.from_type(float), DataType.FLOAT) 111 | self.assertIs(DataType.from_type(int), DataType.FLOAT) 112 | self.assertIs(DataType.from_type(type(lambda: None)), DataType.FUNCTION) 113 | self.assertIs(DataType.from_type(dict), DataType.MAPPING) 114 | self.assertIs(DataType.from_type(type(None)), DataType.NULL) 115 | self.assertIs(DataType.from_type(set), DataType.SET) 116 | self.assertIs(DataType.from_type(str), DataType.STRING) 117 | self.assertIs(DataType.from_type(datetime.timedelta), DataType.TIMEDELTA) 118 | 119 | def test_data_type_from_type_hint(self): 120 | # simple compound tests 121 | self.assertEqual(DataType.from_type(typing.List[str]), DataType.ARRAY(DataType.STRING)) 122 | self.assertEqual(DataType.from_type(typing.Tuple[str]), DataType.ARRAY(DataType.UNDEFINED)) 123 | self.assertEqual(DataType.from_type(typing.Set[int]), DataType.SET(DataType.FLOAT)) 124 | self.assertEqual(DataType.from_type(typing.Dict[str, str]), DataType.MAPPING(DataType.STRING, DataType.STRING)) 125 | 126 | # complex compound tests 127 | self.assertEqual(DataType.from_type(typing.List[list]), DataType.ARRAY(DataType.ARRAY)) 128 | self.assertEqual(DataType.from_type( 129 | typing.Dict[str, typing.Dict[str, datetime.datetime]]), 130 | DataType.MAPPING(DataType.STRING, DataType.MAPPING(DataType.STRING, DataType.DATETIME) 131 | )) 132 | 133 | if sys.version_info >= (3, 9): 134 | self.assertEqual(DataType.from_type(list[str]), DataType.ARRAY(DataType.STRING)) 135 | self.assertEqual(DataType.from_type(tuple[str]), DataType.ARRAY(DataType.UNDEFINED)) 136 | self.assertEqual(DataType.from_type(set[int]), DataType.SET(DataType.FLOAT)) 137 | self.assertEqual(DataType.from_type(dict[str, str]), DataType.MAPPING(DataType.STRING, DataType.STRING)) 138 | 139 | self.assertEqual(DataType.from_type(list[list]), DataType.ARRAY(DataType.ARRAY)) 140 | self.assertEqual(DataType.from_type( 141 | dict[str, dict[str, datetime.datetime]]), 142 | DataType.MAPPING(DataType.STRING, DataType.MAPPING(DataType.STRING, DataType.DATETIME) 143 | )) 144 | 145 | def test_data_type_from_type_error(self): 146 | with self.assertRaisesRegex(TypeError, r'^from_type argument 1 must be a type or a type hint, not _UnsupportedType$'): 147 | DataType.from_type(self._UnsupportedType()) 148 | with self.assertRaisesRegex(ValueError, r'^can not map python type \'_UnsupportedType\' to a compatible data type$'): 149 | DataType.from_type(self._UnsupportedType) 150 | 151 | def test_data_type_from_value_compound_array(self): 152 | for value in [list(), range(0), tuple()]: 153 | value = DataType.from_value(value) 154 | self.assertEqual(value, DataType.ARRAY) 155 | self.assertIs(value.value_type, DataType.UNDEFINED) 156 | self.assertIs(value.iterable_type, DataType.UNDEFINED) 157 | value = DataType.from_value(['test']) 158 | self.assertEqual(value, DataType.ARRAY(DataType.STRING)) 159 | self.assertIs(value.value_type, DataType.STRING) 160 | self.assertIs(value.iterable_type, DataType.STRING) 161 | 162 | def test_data_type_from_value_compound_mapping(self): 163 | value = DataType.from_value({}) 164 | self.assertEqual(value, DataType.MAPPING) 165 | self.assertIs(value.key_type, DataType.UNDEFINED) 166 | self.assertIs(value.value_type, DataType.UNDEFINED) 167 | self.assertIs(value.iterable_type, DataType.UNDEFINED) 168 | 169 | value = DataType.from_value({'one': 1}) 170 | self.assertEqual(value, DataType.MAPPING(DataType.STRING, DataType.FLOAT)) 171 | self.assertIs(value.key_type, DataType.STRING) 172 | self.assertIs(value.value_type, DataType.FLOAT) 173 | self.assertIs(value.iterable_type, DataType.STRING) 174 | 175 | def test_data_type_from_value_compound_set(self): 176 | value = DataType.from_value(set()) 177 | self.assertEqual(value, DataType.SET) 178 | self.assertIs(value.value_type, DataType.UNDEFINED) 179 | self.assertIs(value.iterable_type, DataType.UNDEFINED) 180 | 181 | value = DataType.from_value({'test'}) 182 | self.assertEqual(value, DataType.SET(DataType.STRING)) 183 | self.assertIs(value.value_type, DataType.STRING) 184 | self.assertIs(value.iterable_type, DataType.STRING) 185 | 186 | def test_data_type_from_value_scalar(self): 187 | self.assertIs(DataType.from_value(False), DataType.BOOLEAN) 188 | self.assertIs(DataType.from_value(b''), DataType.BYTES) 189 | self.assertIs(DataType.from_value(datetime.date.today()), DataType.DATETIME) 190 | self.assertIs(DataType.from_value(datetime.datetime.now()), DataType.DATETIME) 191 | self.assertIs(DataType.from_value(0), DataType.FLOAT) 192 | self.assertIs(DataType.from_value(0.0), DataType.FLOAT) 193 | self.assertIs(DataType.from_value(lambda: None), DataType.FUNCTION) 194 | self.assertIs(DataType.from_value(print), DataType.FUNCTION) 195 | self.assertIs(DataType.from_value(None), DataType.NULL) 196 | self.assertIs(DataType.from_value(''), DataType.STRING) 197 | self.assertIs(DataType.from_value(datetime.timedelta()), DataType.TIMEDELTA) 198 | 199 | def test_data_type_from_value_error(self): 200 | with self.assertRaisesRegex(TypeError, r'^can not map python type \'_UnsupportedType\' to a compatible data type$'): 201 | DataType.from_value(self._UnsupportedType()) 202 | 203 | def test_data_type_function(self): 204 | with self.assertRaises(TypeError, msg='argument_types should be a sequence'): 205 | DataType.FUNCTION('test', argument_types=DataType.NULL) 206 | with self.assertRaises(ValueError, msg='minimum_arguments should be less than or equal to the length of argument_types'): 207 | DataType.FUNCTION('test', argument_types=(), minimum_arguments=1) 208 | 209 | def test_data_type_definitions_describe_themselves(self): 210 | for name in DataType: 211 | if name == 'UNDEFINED': 212 | continue 213 | data_type = getattr(DataType, name) 214 | self.assertRegex(repr(data_type), 'name=' + name) 215 | 216 | class MetaDataTypeTests(unittest.TestCase): 217 | def test_data_type_is_iterable(self): 218 | self.assertGreater(len(DataType), 0) 219 | for name in DataType: 220 | self.assertIsInstance(name, str) 221 | self.assertRegex(name, r'^[A-Z]+$') 222 | 223 | def test_data_type_is_compatible(self): 224 | def _is_compat(*args): 225 | return self.assertTrue(DataType.is_compatible(*args)) 226 | def _is_not_compat(*args): 227 | return self.assertFalse(DataType.is_compatible(*args)) 228 | _is_compat(DataType.STRING, DataType.STRING) 229 | _is_compat(DataType.STRING, DataType.UNDEFINED) 230 | _is_compat(DataType.UNDEFINED, DataType.STRING) 231 | 232 | _is_compat(DataType.UNDEFINED, DataType.ARRAY) 233 | _is_compat(DataType.ARRAY, DataType.ARRAY(DataType.STRING)) 234 | 235 | _is_not_compat(DataType.STRING, DataType.ARRAY) 236 | _is_not_compat(DataType.STRING, DataType.NULL) 237 | _is_not_compat(DataType.ARRAY(DataType.STRING), DataType.ARRAY(DataType.FLOAT)) 238 | 239 | _is_compat(DataType.MAPPING, DataType.MAPPING) 240 | _is_compat( 241 | DataType.MAPPING(DataType.STRING), 242 | DataType.MAPPING(DataType.STRING, value_type=DataType.ARRAY) 243 | ) 244 | _is_compat( 245 | DataType.MAPPING(DataType.STRING, value_type=DataType.ARRAY), 246 | DataType.MAPPING(DataType.STRING, value_type=DataType.ARRAY(DataType.STRING)) 247 | ) 248 | _is_not_compat( 249 | DataType.MAPPING(DataType.STRING), 250 | DataType.MAPPING(DataType.FLOAT) 251 | ) 252 | _is_not_compat( 253 | DataType.MAPPING(DataType.STRING, value_type=DataType.STRING), 254 | DataType.MAPPING(DataType.STRING, value_type=DataType.FLOAT) 255 | ) 256 | 257 | with self.assertRaises(TypeError): 258 | DataType.is_compatible(DataType.STRING, None) 259 | 260 | def test_data_type_is_compatible_function(self): 261 | def _is_compat(*args): 262 | return self.assertTrue(DataType.is_compatible(*args)) 263 | def _is_not_compat(*args): 264 | return self.assertFalse(DataType.is_compatible(*args)) 265 | # the function name doesn't matter, it's only for reporting 266 | _is_compat( 267 | DataType.FUNCTION('functionA'), 268 | DataType.FUNCTION('functionB') 269 | ) 270 | # return type is UNDEFINED by default which should be compatible 271 | _is_compat( 272 | DataType.FUNCTION('test', return_type=DataType.FLOAT), 273 | DataType.FUNCTION('test') 274 | ) 275 | # argument types are UNDEFINED by default which should be compatible 276 | _is_compat( 277 | DataType.FUNCTION('test', argument_types=(DataType.STRING,), minimum_arguments=1), 278 | DataType.FUNCTION('test', minimum_arguments=1) 279 | ) 280 | # minimum arguments defaults to the number of arguments 281 | _is_compat( 282 | DataType.FUNCTION('test', argument_types=(DataType.STRING,), minimum_arguments=1), 283 | DataType.FUNCTION('test', argument_types=(DataType.STRING,)) 284 | ) 285 | 286 | _is_not_compat( 287 | DataType.FUNCTION('test', return_type=DataType.FLOAT), 288 | DataType.FUNCTION('test', return_type=DataType.STRING) 289 | ) 290 | _is_not_compat( 291 | DataType.FUNCTION('test', argument_types=(DataType.STRING,)), 292 | DataType.FUNCTION('test', argument_types=()) 293 | ) 294 | _is_not_compat( 295 | DataType.FUNCTION('test', argument_types=(DataType.FLOAT,)), 296 | DataType.FUNCTION('test', argument_types=(DataType.STRING,)) 297 | ) 298 | _is_not_compat( 299 | DataType.FUNCTION('test', minimum_arguments=0), 300 | DataType.FUNCTION('test', minimum_arguments=1) 301 | ) 302 | 303 | def test_data_type_is_definition(self): 304 | self.assertTrue(DataType.is_definition(DataType.ARRAY)) 305 | self.assertFalse(DataType.is_definition(1)) 306 | self.assertFalse(DataType.is_definition(None)) 307 | 308 | def test_data_type_supports_contains(self): 309 | self.assertIn('ARRAY', DataType) 310 | self.assertIn('FUNCTION', DataType) 311 | self.assertIn('MAPPING', DataType) 312 | self.assertIn('STRING', DataType) 313 | self.assertIn('UNDEFINED', DataType) 314 | 315 | 316 | def test_data_type_supports_getitem(self): 317 | self.assertEqual(DataType['ARRAY'], DataType.ARRAY) 318 | self.assertEqual(DataType['FUNCTION'], DataType.FUNCTION) 319 | self.assertEqual(DataType['MAPPING'], DataType.MAPPING) 320 | self.assertEqual(DataType['STRING'], DataType.STRING) 321 | self.assertEqual(DataType['UNDEFINED'], DataType.UNDEFINED) 322 | 323 | inf = float('inf') 324 | nan = float('nan') 325 | 326 | class ValueIsTests(unittest.TestCase): 327 | _Case = collections.namedtuple('_Case', ('value', 'numeric', 'real', 'integer', 'natural')) 328 | cases = ( 329 | # value numeric real integer natural 330 | _Case(-inf, True, False, False, False), 331 | _Case(-1.5, True, True, False, False), 332 | _Case(-1.0, True, True, True, False), 333 | _Case(-1, True, True, True, False), 334 | _Case(0, True, True, True, True ), 335 | _Case(1, True, True, True, True ), 336 | _Case(1.0, True, True, True, True ), 337 | _Case(1.5, True, True, False, False), 338 | _Case(inf, True, False, False, False), 339 | _Case(nan, True, False, False, False), 340 | _Case(True, False, False, False, False), 341 | _Case(False, False, False, False, False), 342 | _Case('', False, False, False, False), 343 | _Case(None, False, False, False, False), 344 | ) 345 | def test_value_is_integer_number(self): 346 | for case in self.cases: 347 | self.assertEqual(types.is_integer_number(case.value), case.integer) 348 | 349 | def test_value_is_natural_number(self): 350 | for case in self.cases: 351 | self.assertEqual(types.is_natural_number(case.value), case.natural) 352 | 353 | def test_value_is_numeric(self): 354 | for case in self.cases: 355 | self.assertEqual(types.is_numeric(case.value), case.numeric) 356 | 357 | def test_value_is_real_number(self): 358 | for case in self.cases: 359 | self.assertEqual(types.is_real_number(case.value), case.real) 360 | 361 | if __name__ == '__main__': 362 | unittest.main() 363 | --------------------------------------------------------------------------------