diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..3cf60b2 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,22 @@ +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. First step... +2. ... + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Logs** +If applicable, add logs to help explain your problem. + +**Desktop (please complete the following information):** + - Python Version(s): [e.g. 3.12] + - OS: [e.g. macOS] + - IDE: [e.g. VSCode] + - Arta Version [e.g. 1.0.0] + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/config.yaml b/.github/ISSUE_TEMPLATE/config.yaml new file mode 100644 index 0000000..4d3b612 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yaml @@ -0,0 +1,2 @@ +--- +blank_issues_enabled: false \ No newline at end of file diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..96ea742 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,12 @@ +## Description +A few sentences describing the overall goals of the pull request's commits. + +## Todos +- [ ] Tests +- [ ] Documentation + +## Steps to Test or Reproduce +Outline the steps to test or reproduce the PR here. + +1. First step +2. ... diff --git a/.github/workflows/ci-cd-docs.yml b/.github/workflows/ci-cd-docs.yml new file mode 100644 index 0000000..952488e --- /dev/null +++ b/.github/workflows/ci-cd-docs.yml @@ -0,0 +1,49 @@ +--- +name: Documentation CI/CD +on: + push: + branches: + - main + pull_request: + types: + - opened + - synchronize + branches: + - main + +jobs: + check: + if: ${{ github.actor != 'dependabot[bot]' }} + name: Check docs build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install package with optional dependency 'doc' + run: | + python -m pip install --upgrade pip + pip install .[doc] + - name: Run MkDocs build + working-directory: ./docs + run: mkdocs build + publish: + if: success() && startsWith(github.ref, 'refs/tags') + name: Publish docs on GH Pages + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install package with optional dependency 'doc' + run: | + python -m pip install --upgrade pip + pip install .[doc] + - name: Run MkDocs deploy + working-directory: ./docs + run: mkdocs gh-deploy + \ No newline at end of file diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml new file mode 100644 index 0000000..df82d20 --- /dev/null +++ b/.github/workflows/ci-cd.yml @@ -0,0 +1,59 @@ +--- +name: Package CI/CD +on: + push: + branches: + - main + pull_request: + types: + - opened + - synchronize + branches: + - main + +jobs: + pre-commit: + name: Apply pre-commit hooks + runs-on: ubuntu-latest + strategy: + max-parallel: 1 + matrix: + python-version: ["3.9", "3.10", "3.11", "3.12"] + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install extra dependencies + run: | + python -m pip install --upgrade pip + pip install .[dev,test] + - name: Cache pre-commit hooks + uses: action/cache@v4 + with: + path: ~/.cache/pre-commit + key: ${{ runner.os }}-precommit-${{ hashFiles('.pre-commit-config.yaml') }} + - name: Run pre-commit hooks + run: pre-commit run --all-files + publish: + if: success() && startsWith(github.ref, 'refs/tags') + name: Publish to PyPI + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Set up Python version + uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Build package + run: | + python -m pip install --upgrade build + python -m build + - name: Publish package distributions to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + user: __token__ + password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..218b30c --- /dev/null +++ b/.gitignore @@ -0,0 +1,138 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# mlflow +mlruns/ + +# tensorboard +runs/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +*/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# VSCode +.vscode diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..b4d38f8 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,40 @@ +default_language_version: + python: python3 +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.3.2 + hooks: + - id: ruff + args: [ --fix ] + - id: ruff-format + - repo: https://github.com/pre-commit/mirrors-mypy + rev: 'v1.9.0' + hooks: + - id: mypy + args: [--config-file=pyproject.toml] + files: src + - repo: https://github.com/gitleaks/gitleaks + rev: v8.18.2 + hooks: + - id: gitleaks + - repo: https://github.com/compilerla/conventional-pre-commit + rev: v3.1.0 + hooks: + - id: conventional-pre-commit + stages: [commit-msg] + args: [feat, fix, ci, chore, test, docs] + - repo: local + hooks: + - id: tox-check + name: Tests + entry: tox + language: system + pass_filenames: false + always_run: true + - id: cov-check + name: Coverage + language: system + entry: pytest -v --cov=./src/arta --cov-fail-under=90 + types: [ python ] + pass_filenames: false + always_run: true diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..103e173 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,16 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.7.0] - 2014-04-13 + +### Added +- Beta release implementing what you can find in its [documentation](https://pages.github.com/MAIF/arta). + +### Changed +- Nothing. + +### Removed +- Nothing. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..1b54773 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,73 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as +contributors and maintainers pledge to making participation in our project and +our community a harassment-free experience for everyone, regardless of age, body +size, disability, ethnicity, gender identity and expression, level of experience, +nationality, personal appearance, race, religion, or sexual identity and +orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment +include: + +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery and unwelcome sexual attention or + advances +* Trolling, insulting/derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or electronic + address, without explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable +behavior and are expected to take appropriate and fair corrective action in +response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or +reject comments, commits, code, wiki edits, issues, and other contributions +that are not aligned to this Code of Conduct, or to ban temporarily or +permanently any contributor for other behaviors that they deem inappropriate, +threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces +when an individual is representing the project or its community. Examples of +representing a project or community include using an official project e-mail +address, posting via an official social media account, or acting as an appointed +representative at an online or offline event. Representation of a project may be +further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported by contacting the project team at oss@maif.fr. All +complaints will be reviewed and investigated and will result in a response that +is deemed necessary and appropriate to the circumstances. The project team is +obligated to maintain confidentiality with regard to the reporter of an incident. +Further details of specific enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good +faith may face temporary or permanent repercussions as determined by other +members of the project's leadership. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, +available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html + +[homepage]: https://www.contributor-covenant.org \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..398186f --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,77 @@ +# Contributing to Arta + +These guidelines apply to all your-project projects living in the the `MAIF/arta` repository. + +These guidelines are meant to be a living document that should be changed and adapted as needed. +We encourage changes that make it easier to achieve our goals in an efficient way. + +## Codebase + +explain the layout of your repo. + +## Workflow + +The steps below describe how to get a patch into a main development branch (e.g. `master`). +The steps are exactly the same for everyone involved in the project (be it core team, or first time contributor). +We follow the standard GitHub [fork & pull](https://help.github.com/articles/using-pull-requests/#fork--pull) approach to pull requests. Just fork the official repo, develop in a branch, and submit a PR! + +1. To avoid duplicated effort, it might be good to check the [issue tracker](https://github.com/MAIF/arta/issues) and [existing pull requests](https://github.com/MAIF/arta/pulls) for existing work. + - If there is no ticket yet, feel free to [create one](https://github.com/MAIF/arta/issues/new) to discuss the problem and the approach you want to take to solve it. +1. [Fork the project](https://github.com/MAIF/arta#fork-destination-box) on GitHub. You'll need to create a feature-branch for your work on your fork, as this way you'll be able to submit a pull request against the mainline *Arta*. +1. Create a branch on your fork and work on the feature. For example: `git checkout -b feature/awesome-new-feature` + - Please make sure to follow the general quality guidelines (specified below) when developing your patch. + - Please write additional tests covering your feature and adjust existing ones if needed before submitting your pull request. +1. Once your feature is complete, prepare the commit with a good commit message, for example: `Adding canary mode support for services #42` (note the reference to the ticket it aimed to resolve). +1. If it's a new feature, or a change of behaviour, document it on the [Arta docs](https://github.com/MAIF/arta/tree/master/manual), remember, an undocumented feature is not a feature. +1. Now it's finally time to [submit the pull request](https://help.github.com/articles/using-pull-requests)! + - Please make sure to include a reference to the issue you're solving *in the comment* for the Pull Request, this will cause the PR to be linked properly with the Issue. Examples of good phrases for this are: "Resolves #1234" or "Refs #1234". +1. Now both committers and interested people will review your code. This process is to ensure the code we merge is of the best possible quality, and that no silly mistakes slip through. You're expected to follow-up these comments by adding new commits to the same branch. The commit messages of those commits can be more loose, for example: `Removed debugging using printline`, as they all will be squashed into one commit before merging into the main branch. + - The community and team are eager to share, so don't be afraid to ask follow up questions if you didn't understand some comment, or would like clarification on how to continue with a given feature. We're here to help, so feel free to ask and discuss any kind of questions you might have during review! +1. After the review you should fix the issues as needed (pushing a new commit for new review etc.), iterating until the reviewers give their thumbs up–which is signalled usually by a comment saying `LGTM`, which means "Looks Good To Me". +1. If the code change needs to be applied to other branches as well (for example a bugfix needing to be backported to a previous version), one of the team will either ask you to submit a PR with the same commit to the old branch, or do this for you. +1. Once everything is said and done, your pull request gets merged. You've made it! + +The TL;DR; of the above very precise workflow version is: + +1. Fork arta +2. Hack and test on your feature (on a branch) +3. Document it +4. Submit a PR +6. Keep polishing it until received thumbs up from the core team +7. Profit! + +## External dependencies + +All the external runtime dependencies for the project, including transitive dependencies, must have an open source license that is equal to, or compatible with, [Apache 2](http://www.apache.org/licenses/LICENSE-2.0). + +This must be ensured by manually verifying the license for all the dependencies for the project: + +1. Whenever a committer to the project changes a version of a dependency (including Scala) in the build file. +2. Whenever a committer to the project adds a new dependency. +3. Whenever a new release is cut (public or private for a customer). + +Which licenses are compatible with Apache 2 are defined in [this doc](http://www.apache.org/legal/3party.html#category-a), where you can see that the licenses that are listed under ``Category A`` are automatically compatible with Apache 2, while the ones listed under ``Category B`` need additional action: + +> Each license in this category requires some degree of [reciprocity](http://www.apache.org/legal/3party.html#define-reciprocal); therefore, additional action must be taken in order to minimize the chance that a user of an Apache product will create a derivative work of a reciprocally-licensed portion of an Apache product without being aware of the applicable requirements. + +Each project must also create and maintain a list of all dependencies and their licenses, including all their transitive dependencies. This can be done either in the documentation or in the build file next to each dependency. + +You must add the dependency and its licence in https://github.com/MAIF/arta/blob/master/licences.md + +## Documentation + +If you add features to *Arta*, don't forget to modify the user documentation : + +* https://github.com/MAIF/arta/tree/master/docs/ + +## Tests + +Every new feature should provide corresponding tests to ensure everything is working and will still working in future releases. To run the tests, just run + +```sh +pytest tests/ +``` + +## Continuous integration + +TODO \ No newline at end of file diff --git a/LICENSE b/LICENSE index 261eeb9..4947287 100644 --- a/LICENSE +++ b/LICENSE @@ -1,3 +1,4 @@ + Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ @@ -173,29 +174,4 @@ incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. + END OF TERMS AND CONDITIONS \ No newline at end of file diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..d2d00dc --- /dev/null +++ b/NOTICE @@ -0,0 +1,15 @@ +This software is licensed under the Apache 2 license, quoted below. + +Copyright 2019 MAIF and contributors + +Licensed under the Apache License, Version 2.0 (the "License"); you may not +use this file except in compliance with the License. You may obtain a copy of +the License at + + [http://www.apache.org/licenses/LICENSE-2.0] + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +License for the specific language governing permissions and limitations under +the License. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..02af46d --- /dev/null +++ b/README.md @@ -0,0 +1,34 @@ +# Arta + +![Python](https://img.shields.io/badge/Python-3.9_--_3.12-blue) + +![Alt MAIF Logo](https://static.maif.fr/resources/img/logo-maif.svg) + +**Arta** is a very simple python rules engine designed for and by python developers. + +## Reference Documentation + +The reference documentation is available at [here](https://pages.github.com/MAIF/arta) + +## What's New + +Want to see last updates, check the [Release Notes](https://github.com/MAIF/arta/releases) or the [Changelog](./CHANGELOG.md). + +## Community +You can discuss and ask *Arta* related questions: + +- Issue tracker: [![github: MAIF/arta/issues](https://img.shields.io/github/issues/MAIF/arta.svg)](https://github.com/MAIF/arta/issues) +- Pull request: [![github: MAIF/arta/pulls](https://img.shields.io/github/issues-pr/MAIF/arta.svg)](https://github.com/MAIF/arta/pulls) + +## Contributing + +Contributions are *very* welcome! + +If you see an issue that you'd like to see fixed, the best way to make it happen is to help out by submitting a pull request implementing it. + +Refer to the [CONTRIBUTING.md](./CONTRIBUTING.md) file for more details about the workflow, +and general hints on how to prepare your pull request. You can also ask for clarifications or guidance in GitHub issues directly. + +## License + +This project is Open Source and available under the Apache 2 License. \ No newline at end of file diff --git a/docs/mkdocs.yaml b/docs/mkdocs.yaml new file mode 100644 index 0000000..b99d3f1 --- /dev/null +++ b/docs/mkdocs.yaml @@ -0,0 +1,66 @@ +site_name: Arta +site_url: https://pages.github.com/MAIF/arta +repo_url: https://github.com/MAIF/arta +repo_name: MAIF/arta +site_author: OSSbyMAIF Team +docs_dir: pages +theme: + name: 'material' + logo: https://static.maif.fr/resources/img/logo-maif.svg + favicon: https://static.maif.fr/resources/img/logo-maif.svg + palette: + primary: white + accent: light green + font: + text: 'Roboto' + code: 'Roboto Mono' + language: en + features: + - content.tabs.link + - content.code.annotate + - content.code.copy + - content.code.select + - announce.dismiss + - navigation.tabs + - search.highlight + - search.share +markdown_extensions: + tables: + admonition: + pymdownx.details: +# pymdownx.extra: + pymdownx.emoji: + emoji_index: !!python/name:material.extensions.emoji.twemoji + emoji_generator: !!python/name:material.extensions.emoji.to_svg + pymdownx.tabbed: + alternate_style: true + pymdownx.highlight: + anchor_linenums: true + line_spans: __span + pygments_lang_class: true + use_pygments: true + linenums: true + pymdownx.inlinehilite: + pymdownx.snippets: + pymdownx.superfences: + custom_fences: + - name: mermaid + class: mermaid + format: !!python/name:pymdownx.superfences.fence_code_format '' +plugins: + - mkdocstrings + - search +nav: + - Home: index.md + - Get Started: + - In a nutshell: in_a_nutshell.md + - Installation: installation.md + - Why use Arta?: why.md + - User Guide: + - How to: how_to.md + - Glossary: glossary.md + - Advanced User Guide: + - Parameters: parameters.md + - Rule sets: rule_sets.md + - Special conditions: special_conditions.md + - API Reference: api_reference.md \ No newline at end of file diff --git a/docs/pages/api_reference.md b/docs/pages/api_reference.md new file mode 100644 index 0000000..c0bd704 --- /dev/null +++ b/docs/pages/api_reference.md @@ -0,0 +1,5 @@ +## _engine.py +::: arta._engine + +## condition.py +::: arta.condition \ No newline at end of file diff --git a/docs/pages/glossary.md b/docs/pages/glossary.md new file mode 100644 index 0000000..4b9b7af --- /dev/null +++ b/docs/pages/glossary.md @@ -0,0 +1,18 @@ +| Concept | Definition | +| ----------- | ------------------------------------ | +| action | A task which is executed when conditions are verified. | +| action function | A callable object called to execute the action. | +| action parameter | Parameter of an action function. | +| condition | A condition to be verified before executing an action. | +| condition id | Identifier of a single condition (must be in CAPITAL LETTER). | +| condition expression | A boolean expression combining several conditions (meaning several condition id).| +| condition function | A callable object called to be verified therefore it returns a boolean.| +| condition parameter | Parameter of a condition/validation function. | +| custom condition | A user-defined condition. | +| rule | A set of conditions combined to one action. | +| rule group | A group of rules (usually sharing a common context). | +| rule id | Identifier of a single rule. | +| rule set | A set of rule groups (mostly one: `default_rule_set`). | +| simple condition | A built-in very simple condition. | +| standard condition | The regular built-in condition. | +| validation function | Same thing as a condition function. | \ No newline at end of file diff --git a/docs/pages/how_to.md b/docs/pages/how_to.md new file mode 100644 index 0000000..cb4dca2 --- /dev/null +++ b/docs/pages/how_to.md @@ -0,0 +1,414 @@ +Ensure that you have correctly installed **Arta** before, check the [Installation](installation.md) page :wrench: + +## Hello World + +You want a simple code to play with? Here it comes: + +=== "Without type hints" + + ```python + from arta import RulesEngine + + set_admission = lambda value, **kwargs: {"is_admitted": value} + + rules = { + "check_admission": { + "ADMITTED_RULE": { + "condition": lambda power: power in ["strength", "fly", "immortality"], + "condition_parameters": {"power": "input.super_power"}, + "action": set_admission, + "action_parameters": {"value": True}, + }, + "DEFAULT_RULE": { + "condition": None, + "condition_parameters": None, + "action": set_admission, + "action_parameters": {"value": False}, + }, + } + } + + input_data = { + "id": 1, + "name": "Superman", + "civilian_name": "Clark Kent", + "age": None, + "city": "Metropolis", + "language": "french", + "super_power": "fly", + "favorite_meal": "Spinach", + "secret_weakness": "Kryptonite", + "weapons": [], + } + + eng = RulesEngine(rules_dict=rules) + + result = eng.apply_rules(input_data) + + print(result) + ``` + +=== "With type hints (>=3.9)" + + ```python + from typing import Any, Callable + + from arta import RulesEngine + + set_admission: Callable = lambda value, **kwargs: {"is_admitted": value} + + rules: dict[str, Any] = { + "check_admission": { + "ADMITTED_RULE": { + "condition": lambda power: power in ["strength", "fly", "immortality"], + "condition_parameters": {"power": "input.super_power"}, + "action": set_admission, + "action_parameters": {"value": True}, + }, + "DEFAULT_RULE": { + "condition": None, + "condition_parameters": None, + "action": set_admission, + "action_parameters": {"value": False}, + }, + } + } + + input_data: dict[str, Any] = { + "id": 1, + "name": "Superman", + "civilian_name": "Clark Kent", + "age": None, + "city": "Metropolis", + "language": "french", + "super_power": "fly", + "favorite_meal": "Spinach", + "secret_weakness": "Kryptonite", + "weapons": [], + } + + eng = RulesEngine(rules_dict=rules) + + result: dict[str, Any] = eng.apply_rules(input_data) + + print(result) + ``` + +You should get: + + {'check_admission': {'is_admitted': True}} + +!!! success + Superman is admitted to the superhero school! + +Well done! By executing this code you have: + +1. Defined an **action function** (`set_admission`) +2. Defined a **rule set** (`rules`) +3. Used some **input data** (`input_data`) +4. Instanciated a **rules engine** (`RulesEngine`) +5. Applied the rules on the data and get some results (`.apply_rules()`) + +!!! note + + In the code example we used some anonymous/lambda function for simplicity but it could be regular python functions as well. + +!!! abstract "API Documentation" + + You can get details on the `RulesEngine` parameters in the [API Reference](api_reference.md). + +Have you read the [Get Started](in_a_nutshell.md) section? If not, you probably should before going further :smiley: + +## Concepts + +Let's go deeper into the previous code: + +### Rule sets and rule groups + +A **rule set** is composed of **rule groups** which are themselves composed of **rules**. We can find this tree structure in the following dictionary: + +```python +rules = { # (1) + "check_admission": { # (2) + "ADMITTED_RULE": { # (3) + "condition": lambda power: power in ["strength", "fly", "immortality"], + "condition_parameters": {"power": "input.super_power"}, + "action": set_admission, + "action_parameters": {"value": True}, + }, + "DEFAULT_RULE": { + "condition": None, + "condition_parameters": None, + "action": set_admission, + "action_parameters": {"value": False}, + }, + } +} +``` + +1. This dictionary contains a *rule set*. +2. This key define a *rule group*, we can have many groups (we have only one here for simplicity). +3. This key is a *rule id*, which identifies *rules* among others. + +### Rules + +**Rules** are identified by an id or key (e.g., `ADMITTED_RULE`) and defined by a dictionary: + +```python hl_lines="2-5" +"ADMITTED_RULE": { + "condition": lambda power: power in ["strength", "fly", "immortality"], + "condition_parameters": {"power": "input.super_power"}, + "action": set_admission, + "action_parameters": {"value": True}, +} +``` + +!!! tip + + Rule **ids** are in capital letters for readability only: it is an advised best practice. + +**Rules** are made of 2 different things: + +* Condition: + +```python hl_lines="2 3" +{ + "condition": lambda power: power in ["strength", "fly", "immortality"], + "condition_parameters": {"power": "input.super_power"}, + "action": set_admission, + "action_parameters": {"value": True}, +} +``` + +* Action: + +```python hl_lines="4 5" +{ + "condition": lambda power: power in ["strength", "fly", "immortality"], + "condition_parameters": {"power": "input.super_power"}, + "action": set_admission, + "action_parameters": {"value": True}, +} +``` + +### Conditions and Actions + +**Conditions** and **actions** are quite similar in terms of implementation but their goal is different. + +Both are made of a *callable object* and *parameters*: + +* Condition keys: + * `condition`: a callable python object that returns a `bool`, we called this function the **validation function** (or *condition function*). + * `condition_parameters`: a dictionary mapping the validation function's parameters with their correponding values. +* Action keys: + * `action`: a callable python object that returns what you want (or does what you want such as: requesting an api, sending an email, etc.), we called this function the **action function**. + * `action_parameters`: a dictionary mapping the action function's parameters with their correponding values. + +!!! question "Does a condition could be something else than a function?" + + Actually yes, a `condition` can be a python function but you will learn later that it can also be a **condition expression** (i.e., a boolean expression combining different individual conditions). + +!!! tip "Parameter's special syntax" + + As you can see in the previous code, the action and condition parameters can have a special syntax: + + {"power": "input.super_power"} + + The string `input.super_power` is evaluated by the rules engine and it means *"fetch the key `super_power` in the input data"*. Keep reading, you will find out later. + +## Configuration + +In the [Hello World](#hello-world) section of this user guide, you learnt how to instanciate and use the *rules engine* with a dictionary **rule set**. It's the reason why you used the correponding parameter `rules_dict` for the instancation: + + eng = RulesEngine(rules_dict=rules) + +But there is another way to define your rules: using a **configuration** (i.e., some configuration files). + +For real use cases, using configuration is way much more convenient than using a dictionary :+1: + +!!! info "YAML" + + The built-in file format used by Arta for configuration is YAML. + +!!! example "Enhancement proposal" + + We are thinking on a design that will allow custom configuration backend which will allow user-implemented loading of the configuration (e.g., you prefer using a JSON format). Stay tuned. + +### YAML file + +!!! tip "Simple Conditions" + + The following **YAML** example illustrates how to configure usual *standard conditions* but there is another and simpler way to do it by using a special feature: the [simple condition](special_conditions.md#simple-condition). + +Create a YAML file and define your rules almost like you did with the dictionary `rules`. There is few differences that we will focus on later: + +```yaml hl_lines="6 16-26" +--- +rules: + default_rule_set: # (1) + check_admission: + ADMITTED_RULE: + condition: HAS_SCHOOL_AUTHORIZED_POWER # (2) + action: set_admission + action_parameters: + value: true + DEFAULT_RULE: + condition: null + action: set_admission + action_parameters: + value: false + +conditions: # (3) + HAS_SCHOOL_AUTHORIZED_POWER: + description: "Does applicant have a school authorized power?" + validation_function: has_authorized_super_power + condition_parameters: + power: input.super_power + +conditions_source_modules: # (4) + - my_folder.conditions +actions_source_modules: # (5) + - my_folder.actions +``` + +1. This is the name of your **rule set** (i.e., `default_rule_set` is by default). +2. You can't set a callable object here so we need to use a **condition id**. +3. The conditions are identified by an **id** and defined here. The **validation function** is defined in a user's python module. +4. This is the path of the module where the **validation functions** are implemented (you must change it). +5. This is the path of the module where the **action functions** are implemented (you must change it). + +!!! warning + + **Condition ids** must be in capital letters here, it is mandatory (e.g., `HAS_SCHOOL_AUTHORIZED_POWER`). + +!!! tip + + You can split your configuration in multiple YAML files seamlessly in order to keep things clear. Example: + + * global.yaml => source modules + * rules.yaml => rule definitions + * conditions.yaml => condition definitions + + It's very convenient when you have a lot of different rules and conditions in your app. + +### Condition expression + +In the above YAML, the following **condition expression** is intentionally very simple: + +```yaml hl_lines="6" +--- +rules: + default_rule_set: + check_admission: + ADMITTED_RULE: + condition: HAS_SCHOOL_AUTHORIZED_POWER + action: set_admission + action_parameters: + value: true +``` + +As it was previously mentionned, the key `condition:` can take one **condition id** but also a **condition expression** (i.e., a boolean expression of condition ids) combining several conditions: + +```yaml hl_lines="6" +--- +rules: + default_rule_set: + check_admission: + ADMITTED_RULE: + condition: (HAS_SCHOOL_AUTHORIZED_POWER or SPEAKS_FRENCH) and not(IS_EVIL) + action: set_admission + action_parameters: + value: true +``` + +!!! warning + + In that example, you must define the 3 condition ids in the configuration: + + * HAS_SCHOOL_AUTHORIZED_POWER + * SPEAKS_FRENCH + * IS_EVIL + +!!! tip + + Use the **condition expressions** to keep things simple. Put your conditions in one expression as you can rather than creating several rules :wink: + +### Implementing functions + +We must create 2 modules: + +* `conditions.py` -> implements the needed **validation functions**. +* `actions.py` -> implements the needed **action functions**. + +!!! note + + Module names are arbitrary, you can choose what you want. + +And implement our 2 needed validation and action functions (the one defined in the configuration file): + +**conditions.py**: + +```python +def has_authorized_super_power(power): + return power in ["strength", "fly", "immortality"] +``` + +**actions.py**: + +```python +def set_admission(value, **kwargs): # (1) + return {"is_admitted": value} +``` + +1. `**kwargs` is mandatory here. + +!!! warning + + Function name and parameters must be the same as the one configured in the YAML file. + +### Usage + +Once your configuration file and your functions are ready, you can use it very simply: + +```python +from arta import RulesEngine + +input_data = { + "id": 1, + "name": "Superman", + "civilian_name": "Clark Kent", + "age": None, + "city": "Metropolis", + "language": "french", + "super_power": "fly", + "favorite_meal": "Spinach", + "secret_weakness": "Kryptonite", + "weapons": [], +} + +eng = RulesEngine(config_path="path/to/conf/dir") + +result = eng.apply_rules(input_data) + +print(result) +``` + +You should get the same result as [previously](#hello-world) (dictionary version): + + {'check_admission': {'is_admitted': True}} + +## To sum up + +At this point you have learnt the regular use of an **Arta** rules engine and you have seen the two major ways of defining rules: + +* [Using a dictionary of rules](#hello-world). +* [Using a configuration file](#configuration) (or many). + +!!! tip + + **How to choose between dictionary and configuration?** + + In most cases, you must choose the configuration way of defining your rules. You will improve your rules' maintainability a lot. + In some cases like proof-of-concepts or Jupyter notebook works, you will probably be happy to use straightforward dictionaries. + +**Arta** has plenty more features to discover. If you want to learn more, go to the next chapter: [Advanced User Guide](parameters.md). \ No newline at end of file diff --git a/docs/pages/in_a_nutshell.md b/docs/pages/in_a_nutshell.md new file mode 100644 index 0000000..354379a --- /dev/null +++ b/docs/pages/in_a_nutshell.md @@ -0,0 +1,199 @@ +## Intro + +As we already mentioned in the [Home](index.md) page: ***Arta** is a very simple python rules engine*, but what do we mean by *rules engine*? + +* **rule** : a set of different conditions that can be `True` or `False` (i.e., we say *verified* or *not verified*) triggering an action (i.e., any python callable object). +* **engine** : some code used for combining and evaluating different rules on some input data. + +## Quick example: The Superhero School :school: :superhero: + +Imagine the following use case: + +*Your are managing a superhero school and you want to use some school rules in your python app.* + +The rules are (intentionally simple): + +!!! success "Admission rules" + + If the applicant has a school authorized power then he is admitted, + + Else he is not. + +!!! example "Course selection rules" + + If he is speaking french and his age is known then he must take the "french" course, + + Else if his age is unknown (e.g., it's a very old superhero), then he must take the "senior" course, + + Else if he is not speaking french, then he must take the "international" course. + +!!! info "Send favorite meal rules" + + If he is admitted and has a prefered dish, then we send an email to the school cook with the dish name. + +## Focus on a rule + +If we focus on one rule: + +> If the applicant has a school authorized power then he is admitted, else he is not. + +Here we can identify: + +* The condition: **has a school authorized power** (only one condition) +* The triggered action: **is admitted** + +## Focus on a condition + +Let's be more precise on the following condition: + +> has a school authorized power + +If we define a list of "school authorized powers" it will be easy to verify this condition for an applicant: + +```python +authorized_powers = [ + "strength", + "fly", + "immortality", +] +``` + +## Focus on an action + +Defining the action is probably the hardest part because it means defining the rules engine output, which depends on the use of the rules' results. + +Let's focus on the following *action*: + +> he is admitted + +Let's say that in our use case, a simple dictionary *key* is used for storing the admission status: `{"is_admitted": True}` + +We previously mentioned that action are *python callable object*, so it could be a simple function as: + +```python +def set_admission(value: bool, **kwargs: Any) -> dict[str, bool]: + """Return a dictionary containing the admission result.""" + return {"is_admitted": value} +``` + +We will see later how the `value` argument is passed to this **action function**. + +## Focus on the input data + +The *rules engine* is responsible for evaluating the [configured rules](#quick-example-the-superhero-school) against some *data* (usually named *input data*). + +In our use case, the input data could be a list of applicants: + +```python +applicants = [ + { + "id": 1, + "name": "Superman", + "civilian_name": "Clark Kent", + "age": None, + "city": "Metropolis", + "language": "french", + "powers": ["strength", "fly"], + "favorite_meal": "Spinach", + "secret_weakness": "Kryptonite", + "weapons": [], + }, + { + "id": 2, + "name": "Batman", + "civilian_name": "Bruce Wayne", + "age": 33, + "city": "Gotham City", + "language": "english", + "powers": ["bank_account", "determination", "strength"], + "favorite_meal": None, + "secret_weakness": "Feel alone", + "weapons": ["Hands", "Batarang", "Batgrenade"], + }, + { + "id": 3, + "name": "Wonder Woman", + "civilian_name": "Diana Prince", + "age": 5000, + "city": "Island of Themyscira", + "language": "french", + "powers": ["strength", "greek_gods", "regeneration", "immortality"], + "favorite_meal": None, + "secret_weakness": "Lost faith in humanity", + "weapons": ["Magic lasso", "Bulletproof bracelets", "Sword", "Shield"], + }, +] +``` + +## Focus on the results + +We talked about *rules*, *conditions*, *actions* and then *input data*, it is the **rules engine** responsability to put all them together and output some results. + +To do that we need only two things: + +1. Instanciating a *rules engine* (by giving it the rules' definition). +2. Applying the rules on the *input data*. + +The first task is explained in the [User Guide](how_to.md) section as the second but if you are curious you will find a simple example below of how to apply the rules on a data set. + +Let's apply the rules on a single applicant of our data set: + +```python +from arta import RulesEngine + +eng = RulesEngine(config_path="/to/my/config/dir") # (1) + +result = eng.apply_rules(applicants[0]) + +print(result) # (2) +# { +# "admission": {"is_admitted": True}, +# "course_selection": {"course": "senior"}, +# "send_dish": True +# } +``` + +1. Many possibilites for instanciation, we will explain them later +2. Print a single result for the first applicant + +In the *rules engine* result, we have 3 outputs: + +* `"admission": {"is_admitted": True},` +* `"course_selection": {"course": "senior"},` +* `"send_dish": True` + +It's simple, each correpond to one [rule](#quick-example-the-superhero-school). + +Then we can apply the rules to all the data set (only 3 applicants): + +```python +from arta import RulesEngine + +results = {applicant["name"]: eng.apply_rules(applicant) for applicant in applicants} + +print(results) # (1) +# { +# "Superman": { +# "admission": {"is_admitted": True}, +# "course_selection": {"course": "senior"}, +# "send_dish": True}, +# "Batman": { +# "admission": {"is_admitted": True}, +# "course_selection": {"course": "international"}, +# "send_dish": False, +# }, +# "Wonder Woman": { +# "admission": {"is_admitted": True}, +# "course_selection": {"course": "french"}, +# "send_dish": False, +# } +# } +``` + +1. Print the results of all applicants + +!!! success "Human readable format of the result" + + Superman, Batman and Wonder Womam are all admitted to school. Superman to the "senior" course, Batman to the "international" course and Wonder Woman to the "french" one. An email has been sent to the cook with Superman's favorite meal 'spinach'. + +Now, if you want to learn how to configure your rules, go to the [User Guide](how_to.md) section. \ No newline at end of file diff --git a/docs/pages/index.md b/docs/pages/index.md new file mode 100644 index 0000000..18271ca --- /dev/null +++ b/docs/pages/index.md @@ -0,0 +1,26 @@ +# Welcome to the Arta documentation + +**Arta** is a very simple python *rules engine* designed for and by python developers. + +* Want to discover what is **Arta**? :arrow_right: [Get Started](in_a_nutshell.md) +* Want to know how to use it? :arrow_right: [User Guide](how_to.md) + +!!! info "New feature" + + Check out the new and very convenient feature called the [simple condition](special_conditions.md#simple-condition). A new and lightweight way of configuring your rules' conditions. + +**Arta** is automatically tested with: + +![Python](https://img.shields.io/badge/Python-3.9_--_3.12-blue) + +!!! example "Hello World" + + Want to try it very quickly on a very simple use case? Go to the [Hello World](how_to.md#hello-world) section. + +!!! tip "Releases" + + Want to see last updates, check the [Release notes](https://github.com/MAIF/arta/releases) :rocket: + +!!! success "Pydantic 2" + + **Arta** is now working with [Pydantic 2](https://docs.pydantic.dev/latest/)! And of course, Pydantic 1 as well. \ No newline at end of file diff --git a/docs/pages/installation.md b/docs/pages/installation.md new file mode 100644 index 0000000..00707fe --- /dev/null +++ b/docs/pages/installation.md @@ -0,0 +1,21 @@ +## Python + +Compatible with: + +![Python](https://img.shields.io/badge/Python-3.9_--_3.12-blue) + +## pip + +In your python environment: + +### Regular use + +```shell +pip install arta +``` + +### Development + +```shell +pip install arta[all] +``` diff --git a/docs/pages/parameters.md b/docs/pages/parameters.md new file mode 100644 index 0000000..aaf1a08 --- /dev/null +++ b/docs/pages/parameters.md @@ -0,0 +1,198 @@ +## Parsing prefix keywords + +There is 2 allowed parsing **prefix keywords**: + +* `input`: corresponding to the `input_data`. +* `output` : corresponding to the result output data (returned by the `apply_rules()` method). + +Here are examples: + +1. `input.name`: maps to `input_data["name"]`. +2. `output.check_admission.is_admitted`: maps to `result["check_admission"]["is_admitted"]`. + +They both can be used in **condition and action parameters**. + +!!! info + + A value without any prefix keyword is a constant. + +## Parsing error + +### Raise by default + +By default, errors during *condition* and *action parameters* parsing are **raised**. + +If we refer to the [Hello World](how_to.md#hello-world) example: + +```python hl_lines="5" +rules = { + "check_admission": { + "ADMITTED_RULE": { + "condition": lambda power: power in ["strength", "fly", "immortality"], + "condition_parameters": {"power": "input.super_power"}, + "action": set_admission, + "action_parameters": {"value": True}, + }, + "DEFAULT_RULE": { + "condition": None, + "condition_parameters": None, + "action": set_admission, + "action_parameters": {"value": False}, + }, + } +} +``` + +With modified data like: + +```python hl_lines="8" +input_data = { + "id": 1, + "name": "Superman", + "civilian_name": "Clark Kent", + "age": None, + "city": "Metropolis", + "language": "french", + "power": "fly", + "favorite_meal": "Spinach", + "secret_weakness": "Kryptonite", + "weapons": [], +} +``` + +By default we will get a `KeyError` exception during the execution of the `apply_rules()` method because of `power` vs `super_power`. + +### Ignore + +You can change the by default raising behavior of the parameter's parsing. + +Two ways are possible: + +* At the configuration level: impacts all the **parameters**. +* At the **parameter**'s level. + + +#### Configuration level + +You just have to add the following key somewhere in your configuration: + +```yaml hl_lines="28" +--- +rules: + default_rule_set: + check_admission: + ADMITTED_RULE: + condition: HAS_SCHOOL_AUTHORIZED_POWER + action: set_admission + action_parameters: + value: true + DEFAULT_RULE: + condition: null + action: set_admission + action_parameters: + value: false + +conditions: + HAS_SCHOOL_AUTHORIZED_POWER: + description: "Does applicant have a school authorized power?" + validation_function: has_authorized_super_power + condition_parameters: + power: input.super_power + +conditions_source_modules: + - my_folder.conditions +actions_source_modules: + - my_folder.actions + +parsing_error_strategy: ignore # (1) +``` + +1. `parsing_error_strategy` has two possible values: `raise` and `ignore`. + +It will affect all the parameters. + +#### Parameter level + +!!! tip "Quick Sum Up" + + * `input.super_power?`: set the value to `None` + * `input.super_power?no_power`: set the value to `no_power` + * `input.super_power!`: force raise exception (case when ignore is set by default) + +You can also handle more precisely that aspect at parameter's level: + +```yaml hl_lines="21" +--- +rules: + default_rule_set: + check_admission: + ADMITTED_RULE: + condition: HAS_SCHOOL_AUTHORIZED_POWER + action: set_admission + action_parameters: + value: true + DEFAULT_RULE: + condition: null + action: set_admission + action_parameters: + value: false + +conditions: + HAS_SCHOOL_AUTHORIZED_POWER: + description: "Does applicant have a school authorized power?" + validation_function: has_authorized_super_power + condition_parameters: + power: input.super_power? # (1) + +conditions_source_modules: + - my_folder.conditions +actions_source_modules: + - my_folder.actions +``` + +1. Have you noticed the **'?'** ? If there is a `KeyError` when reading, `power` will be set to `None` rather than raising the exception. + +!!! info + + You can enforce raising exceptions at parameter's level with `!`. + + power: input.super_power! + +### Default value (parameter level) + +Finally, you can set a default value at **parameter's level**. This value will be used if there is an exception during parsing: + +```yaml hl_lines="21" +--- +rules: + default_rule_set: + check_admission: + ADMITTED_RULE: + condition: HAS_SCHOOL_AUTHORIZED_POWER + action: set_admission + action_parameters: + value: true + DEFAULT_RULE: + condition: null + action: set_admission + action_parameters: + value: false + +conditions: + HAS_SCHOOL_AUTHORIZED_POWER: + description: "Does applicant have a school authorized power?" + validation_function: has_authorized_super_power + condition_parameters: + power: input.super_power?no_power # (1) + +conditions_source_modules: + - my_folder.conditions +actions_source_modules: + - my_folder.actions +``` + +1. If there is an exception during parsing, `power` will be set to `"no_power"`. + +!!! tip "Good to know" + + Parameter's level is overriding configuration level. diff --git a/docs/pages/rule_sets.md b/docs/pages/rule_sets.md new file mode 100644 index 0000000..e9d3dc9 --- /dev/null +++ b/docs/pages/rule_sets.md @@ -0,0 +1,128 @@ +**Rule sets** are a convenient way to separate your business rules into different collections. + +Doing so increases the rules' maintainability because of a better organization and fully uncoupled rules. + +!!! tip + + **Rule sets** are very usefull when you have a lot of rules. + +!!! info + + Most of the time, you won't need to handle different **rule sets** and will only use the default one: `default_rule_set`. + +The good news is that different **rule sets** can be used seamlessly with the same *rules engine* instance :+1: + +Let's take the following example: + +> Based on the [Hello World](how_to.md#hello-world) example, imagine that you need to add some rules about something totally different than the superhero school. Let's say rules for a dinosaur school. + +## Configuration + +Update your configuration by adding a new **rule set**: `dinosaur_school_set` + +```yaml hl_lines="15-26" +--- +rules: + superhero_school_set: + check_admission: + ADMITTED_RULE: + condition: HAS_SCHOOL_AUTHORIZED_POWER + action: set_admission + action_parameters: + value: true + DEFAULT_RULE: + condition: null + action: set_admission + action_parameters: + value: false + dinosaur_school_set: # (1) + food_habit: + HERBIVOROUS: + condition: not(IS_EATING_MEAT) + action: send_mail_to_cook + action_parameters: + meal: "plant" + CARNIVOROUS: + condition: null + action: send_mail_to_cook + action_parameters: + meal: "meat" + +conditions: + HAS_SCHOOL_AUTHORIZED_POWER: + description: "Does applicant have a school authorized power?" + validation_function: has_authorized_super_power + condition_parameters: + power: input.super_power + IS_EATING_MEAT: # (2) + description: "Is dinosaur eating meat?" + validation_function: is_eating_meat + condition_parameters: + power: input.diet.regular_food + +conditions_source_modules: + - my_folder.conditions +actions_source_modules: + - my_folder.actions +``` + +1. Add your new set of rules under the `rules` key +2. Regular condition configuration, nothing new here + +!!! tip "Good to know" + + You can define your **rule sets** into different YAML files (under the `rules` key in each). + +## Usage + +Now that your **rule sets** are defined (and assuming that your condition and action functions are implemented in the right modules), you can easily use them: + +```python +from arta import RulesEngine + +input_data_1 = { + "id": 1, + "name": "Superman", + "civilian_name": "Clark Kent", + "age": None, + "city": "Metropolis", + "language": "french", + "super_power": "fly", + "favorite_meal": "Spinach", + "secret_weakness": "Kryptonite", + "weapons": [], +} + +input_data_2 = { + "id": 1, + "name": "Diplodocus", + "age": 152000000, + "length": 31, + "area": "north_america", + "diet": { + "regular_food": "plants", + }, +} + +eng = RulesEngine(config_path="path/to/conf/dir") + +superhero_result = eng.apply_rules(input_data_1, rule_set="superhero_school_set") # (1) + +dinosaur_result = eng.apply_rules(input_data_2, rule_set="dinosaur_school_set") +``` + +1. Select the **rule set** that you want to use when applying rules on your input data. + +!!! tip "Good to know" + + **Input data** can be different or the same among the rule sets. It depends on the use case. + +## Object-Oriented Model + +```mermaid +classDiagram + rule_set "1" -- "1..*" rule_group + rule_group "1" -- "1..*" rule + rule "1..*" -- "0..*" condition + rule "1..*" -- "1" action +``` diff --git a/docs/pages/special_conditions.md b/docs/pages/special_conditions.md new file mode 100644 index 0000000..77b180b --- /dev/null +++ b/docs/pages/special_conditions.md @@ -0,0 +1,255 @@ +## Simple condition + +!!! example "Beta feature" + + **Simple condition** is still a *beta feature*, some cases could not work as designed. + +**Simple conditions** are a new and straightforward way of configuring your *conditions*. + +It simplifies a lot your rules by: + +* Removing the use of a `conditions.py` module (no validation functions needed). +* Removing the `conditions:` configuration key in your YAML files. + +!!! note + + With the **simple conditions** you use straight *boolean expressions* directly in your configuration. + + It is easyer to read and maintain :+1: + +Example : + +```yaml hl_lines="6 11 16" +--- +rules: + default_rule_set: + admission: + ADM_OK: + simple_condition: input.power=="strength" or input.power=="fly" + action: set_admission + action_parameters: + value: OK + ADM_TO_BE_CHECKED: + simple_condition: input.age>=150 and input.age!=None + action: set_admission + action_parameters: + value: TO_CHECK + ADM_KO: + simple_condition: null + action: set_admission + action_parameters: + value: KO + +actions_source_modules: + - my_folder.actions # (1) +``` + +1. Contains *action function* implementations, no need of the key `conditions_source_modules` here. + +How to write a simple condition like: + + input.power=="strength" or input.power=="fly" + +* **Left operand (data mapping):** + * You must use one of the following prefixes: + * `input` (for input data) + * `output` (for previous rule's result) + * A *dot path* expression like `input.powers.main_power`. +* **Operator:** you must use basic python *boolean operator* (i.e., `==, <, >, <=, >=, !=`) +* **Right operand:** basic python data types (e.i., `str, int, None`). + +!!! warning + + * You can't use: `is` or `in`, as an **operator** (yet). + * You can't use a `float` as **right operand** (it's a bug, will be fixed). + * For strings, don't forget the **double quotes** `"`. + +!!! danger "Security concern" + + **Python code injection:** + + Because **Arta** is using the `eval()` built-in function to evaluate *simple conditions*: + + * **You should never let the user** being able of dynamically define a *simple condition* (in `simple_condition:` conf. key). + * You should verify that **write permissions on the YAML files** are not allowed when your app is deployed. + + +## Custom condition + +**Custom conditions** are user-defined conditions. + +A **custom condition** will impact the atomic evaluation of each **conditions** (i.e., condition ids). + +!!! warning "Vocabulary" + + To be more precise, a **condition expression** is something like: + + CONDITION_1 and CONDITION_2 + + In that example, the condition expression is made of 2 **conditions** whose **condition ids** are: + + * CONDITION_1 + * CONDITION_2 + +With the built-in condition (also named *standard condition*), **condition ids** map to **validation functions** and **condition parameters** but we can change that with a brand new custom condition. + +A custom condition example: + + my_condition: NAME_JOHN and AGE_42 + +!!! note "Remember" + + *condition ids* have to be in CAPITAL LETTERS. + +Imagine you want it to be interpreted as (pseudo-code): + +```python +if input.name == "john" and input.age == "42": + # Do something + ... +``` + +With the **custom conditions** it's quite simple to implement. + +!!! question "Why using a custom condition?" + + The main goal is to simplify handling of recurrent conditions (e.i., "recurrent" meaning very similar conditions). + +### Class implementation + +First, create a class inheriting from `BaseCondtion` and implement the `verify()` method as you want/need: + +=== "Python >= 3.10" + + ```python + from typing import Any + + from arta.condition import BaseCondition + from arta.utils import ParsingErrorStrategy + + + class MyCondition(BaseCondition): + def verify( + self, + input_data: dict[str, Any], + parsing_error_strategy: ParsingErrorStrategy, + ) -> bool: + + field, value = tuple(self.condition_id.split("_")) + + return input_data[field.lower()] == value.lower() + ``` + +=== "Python < 3.10" + + ```python + from typing import Any, Optional + + from arta.condition import BaseCondition + from arta.utils import ParsingErrorStrategy + + + class MyCondition(BaseCondition): + def verify( + self, + input_data: dict[str, Any], + parsing_error_strategy: ParsingErrorStrategy, + ) -> bool: + + field, value = tuple(self.condition_id.split("_")) + + return input_data[field.lower()] == value.lower() + ``` + +!!! example "self.condition_id" + + `self.condition_id` will be `NAME_JOHN` for the first condition and `AGE_42` for the second. + +!!! info "Good to know" + + The `parsing_error_strategy` can be used by the developer to adapt exception handling behavior. Possible values: + + ParsingErrorStrategy.RAISE + ParsingErrorStrategy.IGNORE + ParsingErrorStrategy.DEFAULT_VALUE + +### Configuration + +Last thing to do is to add your new **custom condition** in the configuration: + +```yaml hl_lines="7 29-32" +--- +rules: + default_rule_set: + check_admission: + ADMITTED_RULE: + condition: HAS_SCHOOL_AUTHORIZED_POWER + my_condition: NAME_JOHN and AGE_42 # (1) + action: set_admission + action_parameters: + value: true + DEFAULT_RULE: + condition: null + action: set_admission + action_parameters: + value: false + +conditions: + HAS_SCHOOL_AUTHORIZED_POWER: + description: "Does applicant have a school authorized power?" + validation_function: has_authorized_super_power + condition_parameters: + power: input.super_power + +conditions_source_modules: + - my_folder.conditions +actions_source_modules: + - my_folder.actions + +custom_classes_source_modules: + - dir.to.my_module # (2) +condition_factory_mapping: + my_condition: MyCondition # (3) +``` + +1. Order is important, here it will evaluate `condition` then `my_condition`. Order is arbitrary. +2. List of the modules containing custom classes +3. Mapping between condition keys (`my_condition`) and custom classes (`MyCondition`) + +### Class diagram + +It is based on the following *strategy pattern*: + +```mermaid +classDiagram + note for MyCondition "This is a custom condition class" + RulesEngine "1" -- "1..*" Rule + Rule "0..*" -- "0..*" BaseCondition + BaseCondition <|-- StandardCondition + BaseCondition <|-- MyCondition + class RulesEngine{ + +rules + +apply_rules() + } + class Rule { + #set_id + #group_id + #rule_id + #condition_exprs + #action + #action_parameters + +apply() + } + class BaseCondition { + <> + #condition_id + #description + #validation_function + #validation_function_parameters + +verify() + } +``` + +!!! info "Good to know" + + The class `StandardCondition` is the built-in implementation of a condition. diff --git a/docs/pages/why.md b/docs/pages/why.md new file mode 100644 index 0000000..28c0583 --- /dev/null +++ b/docs/pages/why.md @@ -0,0 +1,21 @@ +There is one main reason for using **Arta** and it was the main goal of its development: + +> Increase business rules maintainability. + +In other words, facilitate rules handling in a python app. + + +## Before Arta :spaghetti: + +Rules in code can rapidly become a headache, kind of spaghetti dish of `if`, `elif` and `else` (or even `match/case` since Python 3.10) + +## After Arta :sparkles: + +**Arta** increases rules maintainability: + +* By standardizing the definition of a rule. All rules are configured or defined the same way in a unique place (or few). +* Rules are released from the code base, which is less error prone and increases clearness. + +!!! example "Improve collaboration" + + Reading python code vs reading YAML. diff --git a/licenses.md b/licenses.md new file mode 100644 index 0000000..1bd3ef5 --- /dev/null +++ b/licenses.md @@ -0,0 +1,8 @@ +# Your Project Dependencies + +Your project dependencies and licenses : + +## Python + +* Pydantic - MIT : https://github.com/pydantic/pydantic +* OmegaConf - BSD 3-Clause : https://github.com/omry/omegaconf diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..261622e --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,63 @@ +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + +[project] +name = "arta" +version = "0.7.0" +requires-python = ">3.8.0" +description = "Arta is a very simple python rules engine designed for and by python developers" +readme = "README.md" +authors = [ + {name = "develop-cs"}, + {name = "HugoPerrier"}, + {name = "Mathis NICOLI"}, +] + +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Software Development :: Libraries :: Python Modules", +] + +dependencies = [ + "omegaconf", + "pydantic", +] + +[project.optional-dependencies] +all = ["arta[test,dev,doc,mypy,ruff]"] +test = ["pytest", "tox", "pytest-cov"] +dev = ["mypy", "pre-commit", "ruff"] +doc = ["mkdocs-material", "mkdocstrings[python]"] +mypy = ["mypy"] +ruff = ["ruff"] + +[tool.setuptools] +package-dir = {"" = "src"} + +[tool.pytest.ini_options] +testpaths = ["tests"] + +[tool.mypy] +exclude = ["tests"] +ignore_missing_imports = true + +[tool.ruff] +line-length = 120 + +[tool.ruff.lint] +ignore = ["E501", "D2", "D3", "D4", "D104", "D100", "D106", "S311"] +extend-select = [ + "UP", # pyupgrade" + "S", # flake8-bandit, + "B", # flake8-bugbear + "I", # isort + "D", # pydocstyle + "NPY", # NumPy-specific rules +] +exclude = ["tests/*"] \ No newline at end of file diff --git a/src/arta/__init__.py b/src/arta/__init__.py new file mode 100644 index 0000000..8b0528c --- /dev/null +++ b/src/arta/__init__.py @@ -0,0 +1,10 @@ +"""Top-level __init__.""" + +from importlib.metadata import version + +from arta._engine import RulesEngine +from arta.utils import ConditionExecutionError, RuleExecutionError + +__all__ = ["RulesEngine", "ConditionExecutionError", "RuleExecutionError"] + +__version__ = version("arta") diff --git a/src/arta/_engine.py b/src/arta/_engine.py new file mode 100644 index 0000000..5f8fb22 --- /dev/null +++ b/src/arta/_engine.py @@ -0,0 +1,449 @@ +"""Module implementing the rules engine. + +Class: RulesEngine +""" + +import copy +import importlib +import inspect +from inspect import getmembers, isclass, isfunction +from types import FunctionType, MethodType, ModuleType +from typing import Any, Callable, Dict, List, Optional, Set, Tuple, Type + +from arta.condition import BaseCondition, SimpleCondition, StandardCondition +from arta.config import load_config +from arta.models import Configuration, RulesDict +from arta.rule import Rule +from arta.utils import ParsingErrorStrategy + + +class RulesEngine: + """The Rules Engine is in charge of executing different groups of rules of a given rule set on user input data. + + Attributes: + rules: A dictionary of rules with k: rule set, v: (k: rule group, v: list of rule instances). + """ + + # ==== Class constants ==== + + # Rule related config keys + CONST_RULE_SETS_CONF_KEY: str = "rules" + CONST_DFLT_RULE_SET_ID: str = "default_rule_set" + CONST_STD_RULE_CONDITION_CONF_KEY: str = "condition" + CONST_ACTION_CONF_KEY: str = "action" + CONST_ACTION_PARAMETERS_CONF_KEY: str = "action_parameters" + + # Condition related config keys + CONST_STD_CONDITIONS_CONF_KEY: str = "conditions" + CONST_CONDITION_VALIDATION_FUNCTION_CONF_KEY: str = "validation_function" + CONST_CONDITION_DESCRIPTION_CONF_KEY: str = "description" + CONST_CONDITION_VALIDATION_PARAMETERS_CONF_KEY: str = "condition_parameters" + CONST_USER_CONDITION_STRING: str = "USER_CONDITION" + + # Built-in factory mapping + BUILTIN_FACTORY_MAPPING: Dict[str, Type[BaseCondition]] = { + "condition": StandardCondition, + "simple_condition": SimpleCondition, + } + + def __init__( + self, + *, + rules_dict: Optional[Dict[str, Dict[str, Any]]] = None, + config_path: Optional[str] = None, + ) -> None: + """Initialize the rules. + + 2 possibilities: either 'rules_dict', or 'config_path', not both. + + Args: + rules_dict: A dictionary containing the rules' definitions. + config_path: Path of a directory containing the YAML files. + + Raises: + KeyError: Key not found. + TypeError: Wrong type. + """ + # Var init. + factory_mapping_classes: Dict[str, Type[BaseCondition]] = {} + std_condition_instances: Dict[str, StandardCondition] = {} + + if config_path is not None and rules_dict is not None: + raise ValueError("RulesEngine takes only one parameter: 'rules_dict' or 'config_path', not both.") + + # Init. default parsing_error_strategy (probably not needed because already defined elsewhere) + self._parsing_error_strategy: ParsingErrorStrategy = ParsingErrorStrategy.RAISE + + # Initialize directly with a rules dict + if rules_dict is not None: + # Data validation + RulesDict.parse_obj(rules_dict) + + # Edge cases data validation + if not isinstance(rules_dict, dict): + raise TypeError(f"'rules_dict' must be dict type, not '{type(rules_dict)}'") + elif len(rules_dict) == 0: + raise KeyError("'rules_dict' couldn't be empty.") + + # Attribute definition + self.rules: Dict[str, Dict[str, List[Rule]]] = self._adapt_user_rules_dict(rules_dict) + + # Initialize with a config_path + elif config_path is not None: + # Load config in attribute + config_dict: Dict[str, Any] = load_config(config_path) + + # Data validation + config: Configuration = Configuration(**config_dict) + + if config.parsing_error_strategy is not None: + # Set parsing error handling strategy from config + self._parsing_error_strategy = ParsingErrorStrategy(config.parsing_error_strategy) + + # Dict of available action functions (k: function name, v: function object) + action_modules: List[str] = config.actions_source_modules + action_functions: Dict[str, Callable] = self._get_object_from_source_modules(action_modules) + + # Dict of available standard condition functions (k: function name, v: function object) + condition_modules: List[str] = ( + config.conditions_source_modules if config.conditions_source_modules is not None else [] + ) + std_condition_functions: Dict[str, Callable] = self._get_object_from_source_modules(condition_modules) + + # Dictionary of condition instances (k: condition id, v: instance), built from config data + if len(std_condition_functions) > 0: + std_condition_instances = self._build_std_conditions( + config=config.dict(), condition_functions_dict=std_condition_functions + ) + + # User-defined/custom conditions + if config.condition_factory_mapping is not None and config.custom_classes_source_modules is not None: + # Dict of custom condition classes (k: classe name, v: class object) + custom_condition_classes: Dict[str, Type[BaseCondition]] = self._get_object_from_source_modules( + config.custom_classes_source_modules + ) + + # Build a factory mapping dictionary (k: conf key, v:class object) + factory_mapping_classes.update( + { + conf_key: custom_condition_classes[class_name] + for conf_key, class_name in config.condition_factory_mapping.items() + } + ) + + # Arta built-in conditions + factory_mapping_classes.update(self.BUILTIN_FACTORY_MAPPING) + + # Attribute definition + self.rules = self._build_rules( + std_condition_instances=std_condition_instances, + action_functions=action_functions, + config=config.dict(), + factory_mapping_classes=factory_mapping_classes, + ) + else: + raise ValueError("RulesEngine needs a parameter: 'rule_dict' or 'config_path'.") + + def apply_rules( + self, input_data: Dict[str, Any], *, rule_set: Optional[str] = None, verbose: bool = False, **kwargs: Any + ) -> Dict[str, Any]: + """Apply the rules and return results. + + For each rule group of a given rule set, rules are applied sequentially, + The loop is broken when a rule is applied (an action is triggered). + Then, the next rule group is evaluated. + And so on... + + This means that the order of the rules in the configuration file + (e.g., rules.yaml) is meaningful. + + Args: + input_data: Input data to apply rules on. + rule_set: Apply rules associated with the specified rule set. + verbose: If True, add extra ids (group_id, rule_id) for result explicability. + **kwargs: For user extra arguments. + + Returns: + A dictionary containing the rule groups' results (k: group id, v: action result). + + Raises: + TypeError: Wrong type. + KeyError: Key not found. + """ + # Input_data validation + if not isinstance(input_data, dict): + raise TypeError(f"'input_data' must be dict type, not '{type(input_data)}'") + elif len(input_data) == 0: + raise KeyError("'input_data' couldn't be empty.") + + # Var init. + input_data_copy: Dict[str, Any] = copy.deepcopy(input_data) + + # Prepare the result key + input_data_copy["output"] = {} + + # If there is no given rule set param. and there is only one rule set in self.rules + # and its value is 'default_rule_set', look for this one (rule_set='default_rule_set') + if rule_set is None and len(self.rules) == 1 and self.rules.get(self.CONST_DFLT_RULE_SET_ID) is not None: + rule_set = self.CONST_DFLT_RULE_SET_ID + + # Check if given rule set is in self.rules? + if rule_set not in self.rules: + raise KeyError( + f"Rule set '{rule_set}' not found in the rules, available rule sets are : {list(self.rules.keys())}." + ) + + # Var init. + results_dict: Dict[str, Any] = {"verbosity": {"rule_set": rule_set, "results": []}} + + # Groups' loop + for group_id, rules_list in self.rules[rule_set].items(): + # Initialize result of the rule group with None + results_dict[group_id] = None + + # Rules' loop (inside a group) + for rule in rules_list: + # Apply rules + action_result, rule_details = rule.apply( + input_data_copy, parsing_error_strategy=self._parsing_error_strategy, **kwargs + ) + + # Check if the rule has been applied (= action activated) + if "action_result" in rule_details: + # Save result and details + results_dict[group_id] = action_result + results_dict["verbosity"]["results"].append(rule_details) + + # Update input data with current result with key 'output' (can be used in next rules) + input_data_copy["output"][group_id] = copy.deepcopy(results_dict[group_id]) + + # We can only have one result per group => break when "action_result" in rule_details + break + + # Handling non-verbose mode + if not verbose: + results_dict.pop("verbosity") + + return results_dict + + @staticmethod + def _get_object_from_source_modules(module_list: List[str]) -> Dict[str, Any]: + """(Protected) + Collect all functions defined in the list of modules. + + Args: + module_list: List of source module names. + + Returns: + Dictionary with objects found in the modules. + """ + object_dict: Dict[str, Any] = {} + + for module_name in module_list: + # Import module + mod: ModuleType = importlib.import_module(module_name) + + # Collect functions + module_functions: Dict[str, Any] = {key: val for key, val in getmembers(mod, isfunction)} + object_dict.update(module_functions) + + # Collect classes + module_classes: Dict[str, Any] = {key: val for key, val in getmembers(mod, isclass)} + object_dict.update(module_classes) + + return object_dict + + def _build_rules( + self, + std_condition_instances: Dict[str, StandardCondition], + action_functions: Dict[str, Callable], + config: Dict[str, Any], + factory_mapping_classes: Dict[str, Type[BaseCondition]], + ) -> Dict[str, Dict[str, List[Any]]]: + """(Protected) + Return a dictionary of Rule instances built from the configuration. + + Args: + rule_sets: Sets of rules to be loaded in the Rules Engine (as needed by further uses). + std_condition_instances: Dictionary of condition instances (k: condition id, v: StandardCondition instance) + actions_dict: Dictionary of action functions (k: action name, v: Callable) + config: Dictionary of the imported configuration from yaml files. + factory_mapping_classes: A mapping dictionary (k: condition conf. key, v: custom class object) + + Returns: + A dictionary of rules. + """ + # Var init. + rules_dict: Dict[str, Dict[str, List[Any]]] = {} + + # Retrieve rule set ids from config + rule_set_ids: List[str] = list(config[self.CONST_RULE_SETS_CONF_KEY].keys()) + + # Going all way down to the rules (rule set > rule group > rule) + for set_id in rule_set_ids: + rules_conf: Dict[str, Any] = config[self.CONST_RULE_SETS_CONF_KEY][set_id] + rules_dict[set_id] = {} + rule_set_dict: Dict[str, List[Any]] = rules_dict[set_id] + + # Looping throught groups + for group_id, group_rules in rules_conf.items(): + # Initialize list or rules in the group + rule_set_dict[group_id] = [] + + # Looping through rules (inside a group) + for rule_id, rule_dict in group_rules.items(): + # Get action function + action_function_name: str = rule_dict[self.CONST_ACTION_CONF_KEY] + + if action_function_name not in action_functions: + raise KeyError(f"Unknwown action function : {action_function_name}") + + action: Callable = action_functions[action_function_name] + + # Look for condition conf. keys inside the rule + condition_conf_keys: Set[str] = set(rule_dict.keys()) - { + self.CONST_ACTION_CONF_KEY, + self.CONST_ACTION_PARAMETERS_CONF_KEY, + } + + # Store the cond. expressions with the same order as in the configuration file (very important) + condition_exprs: Dict[str, Optional[str]] = { + key: value for key, value in rule_dict.items() if key in condition_conf_keys + } + + # Create the corresponding Rule instance + rule: Rule = Rule( + set_id=set_id, + group_id=group_id, + rule_id=rule_id, + action=action, + action_parameters=rule_dict[self.CONST_ACTION_PARAMETERS_CONF_KEY], + condition_exprs=condition_exprs, + std_condition_instances=std_condition_instances, + condition_factory_mapping=factory_mapping_classes, + ) + rule_set_dict[group_id].append(rule) + + return rules_dict + + def _build_std_conditions( + self, config: Dict[str, Any], condition_functions_dict: Dict[str, Callable] + ) -> Dict[str, StandardCondition]: + """(Protected) + Return a dictionary of Condition instances built from the configuration file. + + Args: + config: Dictionary of the imported configuration from yaml files. + condition_functions_dict: A dictionary where k:condition id, v:Callable (validation function). + + Returns: + A dictionary of StandardCondition instances (k: condition id, v: StandardCondition instance). + """ + # Var init. + conditions_dict: Dict[str, StandardCondition] = {} + + # Condition configuration (under conditions' key) + conditions_conf: Dict[str, Dict[str, Any]] = config[self.CONST_STD_CONDITIONS_CONF_KEY] + + # Looping through conditions (inside a group) + for condition_id, condition_params in conditions_conf.items(): + # Get condition validation function + validation_function_name: str = condition_params[self.CONST_CONDITION_VALIDATION_FUNCTION_CONF_KEY] + + if validation_function_name not in condition_functions_dict: + raise KeyError(f"Unknwown validation function : {validation_function_name}") + + # Get Callable from function name + validation_function: Callable = condition_functions_dict[validation_function_name] + + # Create Condition instance + condition_instance: StandardCondition = StandardCondition( + condition_id=condition_id, + description=condition_params[self.CONST_CONDITION_DESCRIPTION_CONF_KEY], + validation_function=validation_function, + validation_function_parameters=condition_params[self.CONST_CONDITION_VALIDATION_PARAMETERS_CONF_KEY], + ) + conditions_dict[condition_id] = condition_instance + + return conditions_dict + + def _adapt_user_rules_dict(self, rules_dict: Dict[str, Dict[str, Any]]) -> Dict[str, Dict[str, List[Any]]]: + """(Protected) + Return a dictionary of Rule's instances built from user's rules dictionary. + + Args: + rules_dict: User raw rules dictionary. + + Returns: + A rules dictionary made from the user input rules. + """ + # Var init. + rules_dict_formatted: Dict[str, List[Any]] = {} + + # Looping throught groups + for group_id, group_rules in rules_dict.items(): + # Initialize list or rules in the group + rules_dict_formatted[group_id] = [] + + # Looping through rules (inside a group) + for rule_id, rule_dict in group_rules.items(): + # Get action function + action = rule_dict["action"] + + # Trigger if not **kwargs + if "kwargs" not in inspect.signature(action).parameters: + raise KeyError(f"The action function {action} must have a '**kwargs' parameter.") + + # Create Rule instance + rule = Rule( + set_id=self.CONST_DFLT_RULE_SET_ID, + group_id=group_id, + rule_id=rule_id, + action=action, + action_parameters=rule_dict.get(self.CONST_ACTION_PARAMETERS_CONF_KEY), + condition_exprs={self.CONST_STD_RULE_CONDITION_CONF_KEY: self.CONST_USER_CONDITION_STRING} + if self.CONST_STD_RULE_CONDITION_CONF_KEY in rule_dict + and rule_dict.get(self.CONST_STD_RULE_CONDITION_CONF_KEY) is not None + else {self.CONST_STD_RULE_CONDITION_CONF_KEY: None}, + std_condition_instances={ + self.CONST_USER_CONDITION_STRING: StandardCondition( + condition_id=self.CONST_USER_CONDITION_STRING, + description="Automatic description", + validation_function=rule_dict.get(self.CONST_STD_RULE_CONDITION_CONF_KEY), + validation_function_parameters=rule_dict.get( + self.CONST_CONDITION_VALIDATION_PARAMETERS_CONF_KEY + ), + ) + }, + condition_factory_mapping=self.BUILTIN_FACTORY_MAPPING, + ) + rules_dict_formatted[group_id].append(rule) + + return {self.CONST_DFLT_RULE_SET_ID: rules_dict_formatted} + + def __str__(self) -> str: + """Object human string representation (called by str()). + + Returns: + A string representation of the instance. + """ + # Vars init. + attrs_str: str = "" + + # Get some instance attributes infos + class_name: str = self.__class__.__name__ + attrs: List[Tuple[str, Any]] = [ + attr + for attr in inspect.getmembers(self) + if not ( + attr[0].startswith("_") + or attr[0].startswith("CONST_") + or isinstance(attr[1], (FunctionType, MethodType)) + ) + ] + + # Build string representation + for attr, val in attrs: + attrs_str += f"{attr}={str(val)}, " + + return f"{class_name}({attrs_str})" diff --git a/src/arta/condition.py b/src/arta/condition.py new file mode 100644 index 0000000..4f9a0be --- /dev/null +++ b/src/arta/condition.py @@ -0,0 +1,196 @@ +"""Condition implementation. + +Classes: BaseCondition, StandardCondition, SimpleCondition +""" + +import re +from abc import ABC, abstractmethod +from typing import Any, Callable, Dict, List, Optional, Set + +from arta.utils import UPPERCASE_WORD_PATTERN, ConditionExecutionError, ParsingErrorStrategy, parse_dynamic_parameter + + +class BaseCondition(ABC): + """Base class of a Condition object (Strategy Pattern). + + Is an abstract class and can't be instantiated. + + Attributes: + condition_id: Id of a condition. + description: Description of a condition. + validation_function: Validation function of a condition. + validation_function_parameters: Arguments of the validation function. + """ + + # Class constants + CONST_CONDITION_DATA_LABEL: str = "Custom condition data (not needed)" + CONDITION_ID_PATTERN: str = UPPERCASE_WORD_PATTERN + + def __init__( + self, + condition_id: str, + description: str, + validation_function: Optional[Callable] = None, + validation_function_parameters: Optional[Dict[str, Any]] = None, + ) -> None: + """ + Initialize attributes. + + Args: + condition_id: Id of a condition. + description: Description of a condition. + validation_function: Validation function of a condition. + validation_function_parameters: Arguments of the validation function. + """ + self._condition_id = condition_id # NOSONAR + self._description = description # NOSONAR + self._validation_function = validation_function # NOSONAR + self._validation_function_parameters = validation_function_parameters # NOSONAR + + @classmethod + def extract_condition_ids_from_expression(cls, condition_expr: Optional[str] = None) -> Set[str]: + """Get the condition ids from a string (e.g., UPPERCASE words). + + E.g., CONDITION_1 and not CONDITION_2 + + Warning: implementation is based on the current class constant CONDITION_SPLIT_PATTERN. + + Args: + condition_expr: A boolean expression (string). + + Returns: + A set of extracted condition ids. + """ + cond_ids: Set[str] = set() + + if condition_expr is not None: + cond_ids = set(re.findall(cls.CONDITION_ID_PATTERN, condition_expr)) + + return cond_ids + + @abstractmethod + def verify(self, input_data: Dict[str, Any], parsing_error_strategy: ParsingErrorStrategy, **kwargs: Any) -> bool: + """(Abstract) + Return True if the condition is verified. + + Args: + input_data: Input data to apply rules on. + parsing_error_strategy: Error handling strategy for parameter parsing. + **kwargs: For user extra arguments. + + Returns: + True if the condition is verified, otherwise False. + """ + raise NotImplementedError + + +class StandardCondition(BaseCondition): + """Class implementing a built-in condition, named standard condition. + + Attributes: + condition_id: Id of a condition. + description: Description of a condition. + validation_function: Validation function of a condition. + validation_function_parameters: Arguments of the validation function. + """ + + def verify(self, input_data: Dict[str, Any], parsing_error_strategy: ParsingErrorStrategy, **kwargs: Any) -> bool: + """Return True if the condition is verified. + + Example of a unitary standard condition: CONDITION_1 + + Args: + input_data: Request or input data to apply rules on. + parsing_error_strategy: Error handling strategy for parameter parsing. + **kwargs: For user extra arguments. + + Returns: + True if the condition is verified, otherwise False. + + Raises: + AttributeError: Check the validation function or its parameters. + """ + if self._validation_function is None: + raise AttributeError("Validation function should not be None") + + if self._validation_function_parameters is None: + raise AttributeError("Validation function parameters should not be None") + + # Parse dynamic parameters + parameters: Dict[str, Any] = {} + + for key, value in self._validation_function_parameters.items(): + parameters[key] = parse_dynamic_parameter( + parameter=value, input_data=input_data, parsing_error_strategy=parsing_error_strategy + ) + + # Run validation_function + return self._validation_function(**parameters) + + +class SimpleCondition(BaseCondition): + """Class implementing a built-in simple condition. + + Attributes: + condition_id: Id of a condition. + description: Description of a condition. + validation_function: Validation function of a condition. + validation_function_parameters: Arguments of the validation function. + """ + + # Class constants + CONST_CUSTOM_CONDITION_DATA_LABEL: str = "Simple condition data (not needed)" + CONDITION_ID_PATTERN: str = r"(?:input\.|output\.)(?:[a-z_\-0-9!=<>\"NTF\.]*)" + + def verify(self, input_data: Dict[str, Any], parsing_error_strategy: ParsingErrorStrategy, **kwargs: Any) -> bool: + """Return True if the condition is verified. + + Example of a unitary simple condition to be verified: 'input.age>=100' + + Args: + input_data: Request or input data to apply rules on. + parsing_error_strategy: Error handling strategy for parameter parsing. + **kwargs: For user extra arguments. + + Returns: + True if the condition is verified, otherwise False. + + Raises: + AttributeError: Check the validation function or its parameters. + """ + bool_var: bool = False + unitary_expr: str = self._condition_id + + data_path_patt: str = r"(?:input\.|output\.)(?:[a-z_\-\.]*)" + + # Retrieve only the data path + path_matches: List[str] = re.findall(data_path_patt, unitary_expr) + + if len(path_matches) == 1: + # Regular case: we have a data_path + data_path: str = path_matches[0] + + # Read data from its path + data = parse_dynamic_parameter( # noqa + parameter=data_path, input_data=input_data, parsing_error_strategy=parsing_error_strategy + ) + + # Replace with the variable name in the expression + eval_expr: str = unitary_expr.replace(data_path, "data") + + # Evaluate the expression + try: + bool_var = eval(eval_expr) # noqa + except TypeError: + # Ignore evaluation --> False + pass + + elif parsing_error_strategy == ParsingErrorStrategy.RAISE: + # Raise an error because of no match for a data path + raise ConditionExecutionError(f"Error when verifying simple condition: '{unitary_expr}'") + + else: + # Other case: ignore, default value => return False + pass + + return bool_var diff --git a/src/arta/config.py b/src/arta/config.py new file mode 100644 index 0000000..6cbd709 --- /dev/null +++ b/src/arta/config.py @@ -0,0 +1,25 @@ +"""Configuration handling module.""" + +from pathlib import Path +from typing import Any, Dict, List, Union, cast + +from omegaconf import DictConfig, ListConfig, OmegaConf + + +def load_config(config_dir_path: str) -> Dict[str, Any]: + """Load a configuration dictionary from all the yaml files in a given directory (and its subdirectories). + + Args: + config_dir_path: Path to a directory containing YML files. + prefix: Prefix for the rglob pattern. + exclude_pattern: Regex pattern to exclude files. + + Returns: + config: Loaded config dictionary. + """ + conf_files: List[Path] = [f for patt in ["*.yml", "*.yaml"] for f in Path(config_dir_path).rglob(patt)] + omega_config: Union[DictConfig, ListConfig] = OmegaConf.unsafe_merge(*[OmegaConf.load(file) for file in conf_files]) + + config: Dict[str, Any] = cast(Dict[str, Any], OmegaConf.to_object(omega_config)) + + return config diff --git a/src/arta/models.py b/src/arta/models.py new file mode 100644 index 0000000..94d29e5 --- /dev/null +++ b/src/arta/models.py @@ -0,0 +1,73 @@ +"""Pydantic model implementations.""" + +from typing import Any, Callable, Dict, List, Optional + +try: + from pydantic import v1 as pydantic +except ImportError: + import pydantic # type: ignore + +from arta.utils import ParsingErrorStrategy + + +# ---------------------------------- +# For instantiation using rules_dict +class RuleRaw(pydantic.BaseModel): + """Pydantic model for validating a rule.""" + + condition: Optional[Callable] + condition_parameters: Optional[Dict[str, Any]] + action: Callable + action_parameters: Optional[Dict[str, Any]] + + class Config: + extra = "forbid" + + +class RulesGroup(pydantic.BaseModel): + """Pydantic model for validating a rules group.""" + + __root__: Dict[str, RuleRaw] + + +class RulesDict(pydantic.BaseModel): + """Pydantic model for validating rules dict instanciation.""" + + __root__: Dict[str, RulesGroup] + + +# ---------------------------------- +# For instantiation using config_path +class Condition(pydantic.BaseModel): + """Pydantic model for validating a condition.""" + + description: str + validation_function: str + condition_parameters: Optional[Dict[str, Any]] + + +class RulesConfig(pydantic.BaseModel): + """Pydantic model for validating a rule group from config file.""" + + condition: Optional[str] + simple_condition: Optional[str] + action: pydantic.constr(to_lower=True) # type: ignore # NOSONAR + action_parameters: Optional[Any] + + class Config: + extra = "allow" + + +class Configuration(pydantic.BaseModel): + """Pydantic model for validating configuration files.""" + + conditions: Optional[Dict[str, Condition]] + conditions_source_modules: Optional[List[str]] + actions_source_modules: List[str] + custom_classes_source_modules: Optional[List[str]] + condition_factory_mapping: Optional[Dict[str, str]] + rules: Dict[str, Dict[str, Dict[pydantic.constr(to_upper=True), RulesConfig]]] # type: ignore # NOSONAR + parsing_error_strategy: Optional[ParsingErrorStrategy] + + class Config: + extra = "ignore" diff --git a/src/arta/py.typed b/src/arta/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/arta/rule.py b/src/arta/rule.py new file mode 100644 index 0000000..15fbe59 --- /dev/null +++ b/src/arta/rule.py @@ -0,0 +1,275 @@ +"""Module containing the class implementation of a rule. + +Class: Rule +""" + +import re +from typing import Any, Callable, Dict, Optional, Set, Tuple, Type + +from arta.condition import BaseCondition, StandardCondition +from arta.utils import ( + ConditionExecutionError, + ParsingErrorStrategy, + RuleExecutionError, + parse_dynamic_parameter, + sanitize_regex, +) + + +class Rule: + """A rule is the combination of some conditions and one action. + + Attributes: + set_id: An id of a rule set. + group_id: An id of a rule group. + rule_id: The id of the rule. + condition_exprs: A dictionary of condition expressions (k: condition conf. key, v: condition expression). + condition_factory_mapping: A dictionary mapping between condition conf. keys and condition class objects + (k: condition conf. key, v: condition class object). + action: Function to perform when the conditions are valid (action function). + action_parameters: Parameters of the action function. + """ + + def __init__( + self, + set_id: str, + group_id: str, + rule_id: str, + condition_exprs: Dict[str, Optional[str]], + condition_factory_mapping: Dict[str, Type[BaseCondition]], + action: Callable, + std_condition_instances: Dict[str, StandardCondition], + action_parameters: Optional[Dict[str, Any]] = None, + ) -> None: + """Initialize attributes. + + Args: + std_condition_instances: Dictionary containing the BaseCondition instances required by the Rule + (k: condition_id, v: StandardCondition instance). + """ + # IDs + self._set_id = set_id + self._group_id = group_id + self._rule_id = rule_id + + # Action + self._action = action + self._action_parameters = action_parameters or {} + + # Condition expressions + self._condition_exprs = condition_exprs + + # Factory mapping + self._condition_factory_mapping = condition_factory_mapping + + # Condition instances (k: condition id (not conf key), v: instances) + self._condition_instances: Dict[str, BaseCondition] = self._instantiate_conditions(std_condition_instances) + + def apply( + self, + input_data: Dict[str, Any], + *, + parsing_error_strategy: ParsingErrorStrategy, + **kwargs: Any, + ) -> Tuple[Optional[Any], Dict[str, Any]]: + """Apply the rule on the input data, return action output (optional). + + Args: + input_data: Request or input data to apply rules on. + parsing_error_strategy: Parsing error strategy. + **kwargs: For user extra arguments. + + Returns: + A tuple as: (action result, rule result details). + + Raises: + RuleExecutionError: Error during the rule execution. + """ + # If rule conditions are verified, the action is executed w/ the parameters' value + is_conditions_ok: bool + rule_results: Dict[str, Any] + + is_conditions_ok, rule_results = self._check_conditions( + input_data, parsing_error_strategy=parsing_error_strategy, **kwargs + ) + + if is_conditions_ok: + try: + # Parse dynamic parameters + parameters: Dict[str, Any] = {} + for key, value in self._action_parameters.items(): + parameters[key] = parse_dynamic_parameter( + parameter=value, input_data=input_data, parsing_error_strategy=parsing_error_strategy + ) + + # Track the rule id + rule_results["activated_rule"] = self._rule_id + + # Run action + rule_results["action_result"] = self._action(**parameters, input_data=input_data) + + return rule_results["action_result"], rule_results + except Exception as error: + raise RuleExecutionError(f"Error while executing rule '{self._rule_id}': {str(error)}") from error + + else: + return None, {} + + def _check_conditions( + self, input_data: Dict[str, Any], parsing_error_strategy: ParsingErrorStrategy, **kwargs: Any + ) -> Tuple[bool, Dict[str, Any]]: + """(Protected) + Return True if all conditions are verified. + + Args: + input_data: Request or input data to apply rules on. + parsing_error_strategy: Error handling strategy for parameter's parsing. + **kwargs: For user extra arguments. + + Returns: + A tuple as: (True if all conditions are verified, otherwise False, condition results dictionary). + """ + # Var init. + all_conditions_res: bool = True + condition_results: Dict[str, Any] = {"rule_group": self._group_id, "verified_conditions": {}} + + # Loop among condition expressions + for cond_conf_key, expr in self._condition_exprs.items(): + condition_class: Type[BaseCondition] = self._condition_factory_mapping[cond_conf_key] + + # Evaluate the condition expression + try: + condition_res, unitary_res = self._evaluate_condition_expr( + input_data=input_data, + condition_class=condition_class, + condition_expr=expr, + parsing_error_strategy=parsing_error_strategy, + **kwargs, + ) + except NameError as e: + raise RuleExecutionError(f"Error during evaluation of '{cond_conf_key}: {expr}': {str(e)}") from e + + # Combine conditions (AND) + all_conditions_res = all_conditions_res and condition_res + + # Store condition results + condition_results["verified_conditions"].update( + {cond_conf_key: {"expression": expr, "values": unitary_res}} + ) + + if not all_conditions_res: + # If False, no need to go further + break + + return all_conditions_res, condition_results + + def _evaluate_condition_expr( + self, + input_data: Dict[str, Any], + condition_class: Type[BaseCondition], + parsing_error_strategy: ParsingErrorStrategy, + condition_expr: Optional[str] = None, + **kwargs: Any, + ) -> Tuple[bool, Dict[str, bool]]: + """(Protected) + Evaluate the condition expr (a boolean expression) and + return the result (a boolean). + + Args: + input_data: Request or input data. + condition_class: Class object of the analyzed condition (given by its conf. key). + parsing_error_strategy: Error handling strategy for parameter's parsing. + condition_expr: A boolean expression (string). + **kwargs: For user extra arguments. + + Returns: + A tuple as: (final result, unitary results (dictionary)). + + Raises: + ConditionExecutionError: Error during condition execution. + """ + # Var init. + unitary_results: Dict[str, bool] = {} + + # Case of null condition expressions => Always True + if condition_expr is None: + return True, unitary_results + + # The final boolean expression is formed by replacing condition IDs by their evaluated boolean values + bool_expr: str = condition_expr + + # Extract condition ids from the expression + condition_ids: Set[str] = condition_class.extract_condition_ids_from_expression(condition_expr) + + # Loop among the conditions of the expression + # Verify the unitary condition + for cond_id in condition_ids: + # Retrieve condition instance + condition: BaseCondition = self._condition_instances[cond_id] + + # Check unitary condition + try: + bool_var: bool = condition.verify(input_data, parsing_error_strategy=parsing_error_strategy, **kwargs) + + # Store unitary result + unitary_results[cond_id] = bool_var + except Exception as error: + raise ConditionExecutionError(f"Error while executing condition '{cond_id}': {str(error)}") from error + + # Replace the result in the boolean expression + sanit_cond_id: str = sanitize_regex(cond_id) + bool_expr = re.sub(rf"{sanit_cond_id}", str(bool_var), bool_expr) + + # Evaluate the final boolean expressions = final result + return eval(bool_expr), unitary_results # noqa + + def _instantiate_conditions( + self, + std_conditions: Dict[str, StandardCondition], + ) -> Dict[str, BaseCondition]: + """Parse condition expressions and build corresponding instances. + + E.g., for one condition: + - Input : "not(CONDITION_A) and CONDITION_B" + - Output : {"CONDITION_A": condition_A_instance, "CONDITION_B": condition_B_instance} + + Args: + std_conditions: A dictionary containing the StandardCondition instances + (k: cond. id, v: StandardCondition instance) + + Returns: + Condition instances which are in the condition expressions (k: condition id, v: BaseCondition instance). + + Raises: + KeyError: Condition id is unknown. + """ + # Var init. + condition_ids: Set[str] = set() + cond_instances: Dict[str, BaseCondition] = {} + + # Get all condition ids from the expressions (1 or many) + for conf_key, expr in self._condition_exprs.items(): + # Expression parsing is condition class dependent + condition_ids = self._condition_factory_mapping[conf_key].extract_condition_ids_from_expression(expr) + + # Is a custom condition or a simple condition? + if self._condition_factory_mapping is not None and conf_key != "condition": + # Yes + for cond_id in condition_ids: + # Instanciate the custom (unknown) condition object + cond_instances[cond_id] = self._condition_factory_mapping[conf_key]( + condition_id=cond_id, + description=self._condition_factory_mapping[conf_key].CONST_CONDITION_DATA_LABEL, + ) + else: + # Should be a standard condition + for cond_id in condition_ids: + # Retrieve the standard condition instance (already instantiated) + try: + cond_instances[cond_id] = std_conditions[cond_id] + except KeyError as error: + raise KeyError( + f"Following condition id is unknown '{cond_id}' in {conf_key}: {expr}" + ) from error + + return cond_instances diff --git a/src/arta/utils.py b/src/arta/utils.py new file mode 100644 index 0000000..db1587b --- /dev/null +++ b/src/arta/utils.py @@ -0,0 +1,180 @@ +"""Utility module.""" + +import copy +import re +from enum import Enum +from typing import Any, Dict, List, Tuple + +# Global constants +UPPERCASE_WORD_PATTERN: str = r"\b[A-Z_0-9]+\b" + + +class RuleExecutionError(Exception): + """Exception raised when a Rule fails during its execution.""" + + pass + + +class ConditionExecutionError(Exception): + """Exception raised when a Condition fails during its execution.""" + + pass + + +class ParsingErrorStrategy(str, Enum): + """Define authorized error handling strategies when a key is missing in the input data.""" + + RAISE: str = "raise" + IGNORE: str = "ignore" + DEFAULT_VALUE: str = "default_value" + + +def get_value_in_nested_dict_from_path(path: str, nested_dict: Dict[str, Any]) -> Any: + """From a path, get a value in a nested dict. + + Ex: + path : this.is.a.path + result : nested_dict["this"]["is"]["a"]["path"] + + Args: + path: A dictionary path. + nested_dict: A nested dictionary. + + Returns: + Found value. + """ + keys: List[str] = path.split(".") + + # Initialize value with whole nested dict + value: Dict[str, Any] = nested_dict + + # Loop on path keys + for key in keys: + if value is None: + raise KeyError(f"Key {value} of path {path} not found in input data.") + value = value[key] + + return value + + +def parse_dynamic_parameter( + parameter: Any, + input_data: Dict[str, Any], + parsing_error_strategy: ParsingErrorStrategy, +) -> Any: + """Parse the value of parameterized parameters. + + (e.g.1, input.age -> 20, e.g.2, input.name.first -> "John") + + Args: + parameter: The parameters configured in conditions.yaml. + input_data: Request or input data to apply rules on. + parsing_error_strategy: Strategy to adopt when confronted with a missing key. + + Returns: + The list of the parameters' values. + + Raises: + KeyError: Key not found. + """ + # Copy parameters to not alterate original + parameter = copy.deepcopy(parameter) + + if isinstance(parameter, list): + parameter = [parse_dynamic_parameter(element, input_data, parsing_error_strategy) for element in parameter] + + elif isinstance(parameter, str): + if not parameter.startswith(("input.", "output.")): + # Keep parameter value unchanged + return parameter + + # Remove the "input" prefix + param_path: str = re.sub(r"^input\.", r"", parameter) + + # Check if a parsing error strategy flag is present + default_value, param_path, parsing_error_strategy = check_parsing_error_strategy_override( + param_path, parsing_error_strategy + ) + + # Get value from path + try: + parameter = get_value_in_nested_dict_from_path(path=param_path, nested_dict=input_data) + except KeyError as error: + if parsing_error_strategy is ParsingErrorStrategy.IGNORE: + return None + if parsing_error_strategy is ParsingErrorStrategy.DEFAULT_VALUE: + return default_value + else: + raise KeyError(f"Could not find path '{param_path}' in the input data: {str(error)}") from error + + return parameter + + +def check_parsing_error_strategy_override( + param_path: str, parsing_error_strategy: ParsingErrorStrategy +) -> Tuple[Any, str, ParsingErrorStrategy]: + """Check if the input parameter contains a flag to override the parsing error strategy. + + The following override syntaxes are accepted: + - output.favorite_meal! # raise an exception + - output.favorite_meal? # parameter = None + - output.favorite_meal?default_str # parameter = default_str (works on str only at first) + + Args: + param_path: Path to a parameter in a nested dict. + parsing_error_strategy: Strategy to adopt when a missing key occured. + + Returns: + default_value: Default value to use if the DEFAULT_VALUE strategy is adopted. + param_path: Clean path to the input parameter. + parsing_error_strategy: Strategy adopted to handle parsing errors. + """ + # Override default error handling strategy + default_value: Any = None + + last_key: str = param_path.split(".")[-1] + + # Replace missing fields with None + if last_key.endswith("?"): + # Set strategy + parsing_error_strategy = ParsingErrorStrategy.IGNORE + # Cleanup path + param_path = param_path.rstrip("?") + + # Raise error on missing fields + elif last_key.endswith("!"): + # Set strategy + parsing_error_strategy = ParsingErrorStrategy.RAISE + # Cleanup path + param_path = param_path.rstrip("!") + + # Replace missing fields with default value + elif "?" in last_key: + default_value = last_key.split("?")[-1] + # Set strategy + parsing_error_strategy = ParsingErrorStrategy.DEFAULT_VALUE + # Cleanup path + param_path = "".join(param_path.split("?")[:-1]) + + return default_value, param_path, parsing_error_strategy + + +def sanitize_regex(pattern: str) -> str: + """Return a sanitized regex string. + + E.g., 'input.power=="fly"' --> 'input\\.power==\\"fly\\"' + 'CONDITION_2' --> '\\bCONDITION_2\\b' + + Args: + pattern: A regex pattern string. + + Returns: + A sanitized regex pattern string. + """ + if re.search(UPPERCASE_WORD_PATTERN, pattern) is None: + # Pattern is not like 'CONDITION_2' but like 'input.power=="fly"' + pattern = pattern.replace('"', r"\"").replace(".", r"\.") + else: + pattern = rf"\b{pattern}\b" + + return pattern diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..3716543 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,12 @@ +"""Setup tests and fixtures""" + +import os + +import pytest + + +@pytest.fixture(scope="session") +def base_config_path() -> str: + """Dynamic config path base for tests.""" + current_dir_path: str = os.path.dirname(__file__) + return os.path.join(current_dir_path, "examples") diff --git a/tests/examples/__init__.py b/tests/examples/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/examples/code/__init__.py b/tests/examples/code/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/examples/code/actions.py b/tests/examples/code/actions.py new file mode 100644 index 0000000..8d645cd --- /dev/null +++ b/tests/examples/code/actions.py @@ -0,0 +1,44 @@ +"""This module holds the action function implementations used for +triggering an action (set in rules.yaml). + +N.B: They are only demo functions. +""" + +from typing import Any, Dict, List + + +def set_admission(value: bool, **kwargs: Any) -> Dict[str, bool]: + """Return a dictionary containing the admission result.""" + return {"admission": value} + + +def set_student_course(course_id: str, **kwargs: Any) -> Dict[str, str]: + """Return the course id as a dictionary.""" + return {"course_id": course_id} + + +def send_email(mail_to: str, mail_content: str, meal: str, **kwargs: Any) -> bool: + """Send an email and return True if OK.""" + is_ok = False + + # API call mock + if meal is not None: + is_ok = True + + return is_ok + + +def concatenate_str(list_str: List[Any], **kwargs: Any) -> str: + """Demo function: return the concatenation of a list of string using input_data (two levels max).""" + list_str = [str(element) for element in list_str] + return "".join(list_str) + + +def do_nothing(**kwargs: Any) -> None: + """Demo function: do nothing = return None.""" + pass + + +def compute_sum(value1: float, value2: float, **kwargs: Any) -> float: + """Demo function: return sum of two values.""" + return value1 + value2 diff --git a/tests/examples/code/conditions.py b/tests/examples/code/conditions.py new file mode 100644 index 0000000..25b7792 --- /dev/null +++ b/tests/examples/code/conditions.py @@ -0,0 +1,28 @@ +"""This module contains the validation function implementations +of the conditions in conditions.yaml. + +N.B: They are only demo functions. +""" + +from typing import List, Optional + + +def has_authorized_super_power(authorized_powers: List[str], candidate_powers: List[str]) -> bool: + """Check candidate's powers and return True if OK.""" + auth_powers = [power for power in authorized_powers if candidate_powers is not None and power in candidate_powers] + return len(auth_powers) > 0 + + +def is_age_unknown(age: Optional[int]) -> bool: + """Check if age is unknown = return True if age is None.""" + return age is None + + +def is_speaking_language(value: str, spoken_language: str) -> bool: + """Check if the candidate spoken language is the same as value.""" + return value == spoken_language + + +def has_favorite_meal(favorite_meal: str) -> bool: + """Check if candidate has a favorite meal.""" + return favorite_meal is not None diff --git a/tests/examples/code/custom_class.py b/tests/examples/code/custom_class.py new file mode 100644 index 0000000..63a606d --- /dev/null +++ b/tests/examples/code/custom_class.py @@ -0,0 +1,40 @@ +"""Module containing a custom class (example). + +Custom classes are +""" + +from typing import Any, Dict + +from arta.condition import BaseCondition +from arta.utils import ParsingErrorStrategy + + +class CustomCondition(BaseCondition): + """ + This class is not included in maif-rules-engine, it's a user developed class and its goal is to extend the original + package. + + It implements a dummy condition as an example of custom condition. + + A condition class must inherit from BaseCondition. + """ + + def verify(self, input_data: Dict[str, Any], parsing_error_strategy: ParsingErrorStrategy, **kwargs: Any) -> bool: + """ + Return True if the condition is verified. + + Args: + input_data: Request or input data to apply rules on. + parsing_error_strategy: Error handling strategy for parameter parsing. + **kwargs: For user extra arguments. + + Returns: + True if the condition is verified, otherwise False. + """ + + if len(kwargs) == 0: + # Dummy condition check: check presence of the condition id as a key in input data + return self._condition_id in input_data + else: + # Only here for testing kwargs propagation + return True diff --git a/tests/examples/failing_conf/missing_action_source_modules/missing_action_source_modules.yaml b/tests/examples/failing_conf/missing_action_source_modules/missing_action_source_modules.yaml new file mode 100644 index 0000000..ffa5175 --- /dev/null +++ b/tests/examples/failing_conf/missing_action_source_modules/missing_action_source_modules.yaml @@ -0,0 +1,31 @@ +--- +# Global settings +conditions_source_modules: + - "tests.examples.code.conditions" + +# Conditions configuration file +conditions: + HAS_SCHOOL_AUTHORIZED_POWER: + description: "Does it have school authorized power?" + validation_function: has_authorized_super_power + condition_parameters: + authorized_powers: + - "strength" + - "fly" + - "immortality" + candidate_powers: input.powers + +# Rules configuration file +rules: + default_rule_set: + admission: + ADM_OK: + condition: HAS_SCHOOL_AUTHORIZED_POWER + action: set_admission + action_parameters: + value: True + ADM_KO: + condition: null + action: set_admission + action_parameters: + value: False diff --git a/tests/examples/failing_conf/missing_condition_source_modules/missing_condition_source_modules.yaml b/tests/examples/failing_conf/missing_condition_source_modules/missing_condition_source_modules.yaml new file mode 100644 index 0000000..ac4e805 --- /dev/null +++ b/tests/examples/failing_conf/missing_condition_source_modules/missing_condition_source_modules.yaml @@ -0,0 +1,31 @@ +--- +# Global settings +actions_source_modules: + - "tests.examples.code.actions" + +# Conditions configuration file +conditions: + HAS_SCHOOL_AUTHORIZED_POWER: + description: "Does it have school authorized power?" + validation_function: has_authorized_super_power + condition_parameters: + authorized_powers: + - "strength" + - "fly" + - "immortality" + candidate_powers: input.powers + +# Rules configuration file +rules: + default_rule_set: + admission: + ADM_OK: + condition: HAS_SCHOOL_AUTHORIZED_POWER + action: set_admission + action_parameters: + value: True + ADM_KO: + condition: null + action: set_admission + action_parameters: + value: False diff --git a/tests/examples/failing_conf/missing_conditions/missing_conditions.yaml b/tests/examples/failing_conf/missing_conditions/missing_conditions.yaml new file mode 100644 index 0000000..f8b2481 --- /dev/null +++ b/tests/examples/failing_conf/missing_conditions/missing_conditions.yaml @@ -0,0 +1,21 @@ +--- +# Global settings +conditions_source_modules: + - "tests.examples.code.conditions" +actions_source_modules: + - "tests.examples.code.actions" + +# Rules configuration file +rules: + default_rule_set: + admission: + ADM_OK: + condition: HAS_SCHOOL_AUTHORIZED_POWER + action: set_admission + action_parameters: + value: True + ADM_KO: + condition: null + action: set_admission + action_parameters: + value: False diff --git a/tests/examples/failing_conf/missing_rules/missing_rules.yaml b/tests/examples/failing_conf/missing_rules/missing_rules.yaml new file mode 100644 index 0000000..aea9e89 --- /dev/null +++ b/tests/examples/failing_conf/missing_rules/missing_rules.yaml @@ -0,0 +1,18 @@ +--- +# Global settings +conditions_source_modules: + - "tests.examples.code.conditions" +actions_source_modules: + - "tests.examples.code.actions" + +# Conditions configuration file +conditions: + HAS_SCHOOL_AUTHORIZED_POWER: + description: "Does it have school authorized power?" + validation_function: has_authorized_super_power + condition_parameters: + authorized_powers: + - "strength" + - "fly" + - "immortality" + candidate_powers: input.powers diff --git a/tests/examples/failing_conf/without_parsing_error_strategy/without_parsing_error_strategy.yaml b/tests/examples/failing_conf/without_parsing_error_strategy/without_parsing_error_strategy.yaml new file mode 100644 index 0000000..f378128 --- /dev/null +++ b/tests/examples/failing_conf/without_parsing_error_strategy/without_parsing_error_strategy.yaml @@ -0,0 +1,33 @@ +--- +# Global settings +conditions_source_modules: + - "tests.examples.code.conditions" +actions_source_modules: + - "tests.examples.code.actions" + +# Conditions configuration file +conditions: + HAS_SCHOOL_AUTHORIZED_POWER: + description: "Does it have school authorized power?" + validation_function: has_authorized_super_power + condition_parameters: + authorized_powers: + - "strength" + - "fly" + - "immortality" + candidate_powers: input.powers + +# Rules configuration file +rules: + default_rule_set: + admission: + ADM_OK: + condition: HAS_SCHOOL_AUTHORIZED_POWER + action: set_admission + action_parameters: + value: True + ADM_KO: + condition: null + action: set_admission + action_parameters: + value: False diff --git a/tests/examples/failing_conf/wrong_action/wrong_action.yaml b/tests/examples/failing_conf/wrong_action/wrong_action.yaml new file mode 100644 index 0000000..8cb8b11 --- /dev/null +++ b/tests/examples/failing_conf/wrong_action/wrong_action.yaml @@ -0,0 +1,33 @@ +--- +# Global settings +conditions_source_modules: + - "tests.examples.code.conditions" +actions_source_modules: + - "tests.examples.code.actions" + +# Conditions configuration file +conditions: + HAS_SCHOOL_AUTHORIZED_POWER: + description: "Does it have school authorized power?" + validation_function: has_authorized_super_power + condition_parameters: + authorized_powers: + - "strength" + - "fly" + - "immortality" + candidate_powers: input.powers + +# Rules configuration file +rules: + default_rule_set: + admission: + ADM_OK: + condition: HAS_SCHOOL_AUTHORIZED_POWER + action: set_admission + action_parameters: + value: True + ADM_KO: + condition: null + action: dummy_funct + action_parameters: + value: False diff --git a/tests/examples/failing_conf/wrong_condition/wrong_condition.yaml b/tests/examples/failing_conf/wrong_condition/wrong_condition.yaml new file mode 100644 index 0000000..a0bb2e9 --- /dev/null +++ b/tests/examples/failing_conf/wrong_condition/wrong_condition.yaml @@ -0,0 +1,33 @@ +--- +# Global settings +conditions_source_modules: + - "tests.examples.code.conditions" +actions_source_modules: + - "tests.examples.code.actions" + +# Conditions configuration file +conditions: + HAS_SCHOOL_AUTHORIZED_POWER: + description: "Does it have school authorized power?" + validation_function: has_authorized_super_power + condition_parameters: + authorized_powers: + - "strength" + - "fly" + - "immortality" + candidate_powers: input.powers + +# Rules configuration file +rules: + default_rule_set: + admission: + ADM_OK: + condition: DUMMY_CONDITION + action: set_admission + action_parameters: + value: True + ADM_KO: + condition: null + action: set_admission + action_parameters: + value: False diff --git a/tests/examples/failing_conf/wrong_parsing_error_strategy/wrong_parsing_error_strategy.yaml b/tests/examples/failing_conf/wrong_parsing_error_strategy/wrong_parsing_error_strategy.yaml new file mode 100644 index 0000000..84fcb49 --- /dev/null +++ b/tests/examples/failing_conf/wrong_parsing_error_strategy/wrong_parsing_error_strategy.yaml @@ -0,0 +1,35 @@ +--- +# Global settings +conditions_source_modules: + - "tests.examples.code.conditions" +actions_source_modules: + - "tests.examples.code.actions" + +parsing_error_strategy: dummy + +# Conditions configuration file +conditions: + HAS_SCHOOL_AUTHORIZED_POWER: + description: "Does it have school authorized power?" + validation_function: has_authorized_super_power + condition_parameters: + authorized_powers: + - "strength" + - "fly" + - "immortality" + candidate_powers: input.powers + +# Rules configuration file +rules: + default_rule_set: + admission: + ADM_OK: + condition: HAS_SCHOOL_AUTHORIZED_POWER + action: set_admission + action_parameters: + value: True + ADM_KO: + condition: null + action: set_admission + action_parameters: + value: False diff --git a/tests/examples/failing_conf/wrong_rules_nested/wrong_rules_nested.yaml b/tests/examples/failing_conf/wrong_rules_nested/wrong_rules_nested.yaml new file mode 100644 index 0000000..f53784b --- /dev/null +++ b/tests/examples/failing_conf/wrong_rules_nested/wrong_rules_nested.yaml @@ -0,0 +1,33 @@ +--- +# Global settings +conditions_source_modules: + - "tests.examples.code.conditions" +actions_source_modules: + - "tests.examples.code.actions" + +# Conditions configuration file +conditions: + HAS_SCHOOL_AUTHORIZED_POWER: + description: "Does it have school authorized power?" + validation_function: has_authorized_super_power + condition_parameters: + authorized_powers: + - "strength" + - "fly" + - "immortality" + candidate_powers: input.powers + +# Rules configuration file +rules: + default_rule_set: + admission: + ADM_OK: + condition: HAS_SCHOOL_AUTHORIZED_POWER + dummy: set_admission + action_parameters: + value: True + ADM_KO: + condition: null + action: set_admission + action_parameters: + value: False diff --git a/tests/examples/failing_conf/wrong_validation_function/wrong_validation_function.yaml b/tests/examples/failing_conf/wrong_validation_function/wrong_validation_function.yaml new file mode 100644 index 0000000..87be6f4 --- /dev/null +++ b/tests/examples/failing_conf/wrong_validation_function/wrong_validation_function.yaml @@ -0,0 +1,33 @@ +--- +# Global settings +conditions_source_modules: + - "tests.examples.code.conditions" +actions_source_modules: + - "tests.examples.code.actions" + +# Conditions configuration file +conditions: + HAS_SCHOOL_AUTHORIZED_POWER: + description: "Does it have school authorized power?" + validation_function: dummy_funct + condition_parameters: + authorized_powers: + - "strength" + - "fly" + - "immortality" + candidate_powers: input.powers + +# Rules configuration file +rules: + default_rule_set: + admission: + ADM_OK: + condition: HAS_SCHOOL_AUTHORIZED_POWER + action: set_admission + action_parameters: + value: True + ADM_KO: + condition: null + action: set_admission + action_parameters: + value: False diff --git a/tests/examples/good_conf/__init__.py b/tests/examples/good_conf/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/examples/good_conf/conditions.yaml b/tests/examples/good_conf/conditions.yaml new file mode 100644 index 0000000..ad890c1 --- /dev/null +++ b/tests/examples/good_conf/conditions.yaml @@ -0,0 +1,34 @@ +--- +# Conditions configuration file +conditions: + HAS_SCHOOL_AUTHORIZED_POWER: + description: "Does it have school authorized power?" + validation_function: has_authorized_super_power + condition_parameters: + authorized_powers: + - "strength" + - "fly" + - "immortality" + candidate_powers: input.powers + IS_SPEAKING_FRENCH: + description: "Does it speak french?" + validation_function: is_speaking_language + condition_parameters: + value: "french" + spoken_language: input.language + IS_SPEAKING_ENGLISH: + description: "Does it speak english?" + validation_function: is_speaking_language + condition_parameters: + value: "english" + spoken_language: input.language + IS_AGE_UNKNOWN: + description: "Do we know his age?" + validation_function: is_age_unknown + condition_parameters: + age: input.age + HAS_FAVORITE_MEAL: + description: "Does it have a favorite meal?" + validation_function: has_favorite_meal + condition_parameters: + favorite_meal: input.favorite_meal diff --git a/tests/examples/good_conf/global.yaml b/tests/examples/good_conf/global.yaml new file mode 100644 index 0000000..1e79763 --- /dev/null +++ b/tests/examples/good_conf/global.yaml @@ -0,0 +1,16 @@ +--- +# Global settings +conditions_source_modules: + - "tests.examples.code.conditions" +actions_source_modules: + - "tests.examples.code.actions" + +parsing_error_strategy: raise + +# Following settings are only needed when custom condition classes are used +custom_classes_source_modules: + - "tests.examples.code.custom_class" +condition_factory_mapping: + custom_condition: "CustomCondition" + +dummy_value: for test purpose diff --git a/tests/examples/good_conf/rules.yaml b/tests/examples/good_conf/rules.yaml new file mode 100644 index 0000000..7e2c038 --- /dev/null +++ b/tests/examples/good_conf/rules.yaml @@ -0,0 +1,39 @@ +--- +# Default rules configuration file +rules: + default_rule_set: + admission: + ADM_OK: + condition: HAS_SCHOOL_AUTHORIZED_POWER + action: set_admission + action_parameters: + value: True + ADM_KO: + condition: null + action: set_admission + action_parameters: + value: False + course: + COURSE_ENGLISH: + condition: IS_SPEAKING_ENGLISH and not(IS_AGE_UNKNOWN) + action: set_student_course + action_parameters: + course_id: "english" + COURSE_SENIOR: + condition: IS_AGE_UNKNOWN + action: set_student_course + action_parameters: + course_id: "senior" + COURSE_INTERNATIONAL: + condition: not(IS_SPEAKING_ENGLISH) + action: set_student_course + action_parameters: + course_id: "international" + email: + EMAIL_COOK: + condition: HAS_SCHOOL_AUTHORIZED_POWER + action: send_email + action_parameters: + mail_to: "cook@super-heroes.test" + mail_content: "Thanks for preparing once a month the following dish:" + meal: input.favorite_meal \ No newline at end of file diff --git a/tests/examples/good_conf/rules_bis.yaml b/tests/examples/good_conf/rules_bis.yaml new file mode 100644 index 0000000..24f16f2 --- /dev/null +++ b/tests/examples/good_conf/rules_bis.yaml @@ -0,0 +1,81 @@ +--- +# Second rule set +rules: + second_rule_set: + admission: + ADM_OK: + custom_condition: DUMMY_KEY or DUMMY_KEY_2 + condition: HAS_SCHOOL_AUTHORIZED_POWER + action: set_admission + action_parameters: + value: True + ADM_KO: + custom_condition: null + condition: null + action: set_admission + action_parameters: + value: False + course: + COURSE_FRENCH: + custom_condition: null + condition: IS_SPEAKING_FRENCH + action: set_student_course + action_parameters: + course_id: "french" + COURSE_ENGLISH: + custom_condition: null + condition: IS_SPEAKING_ENGLISH + action: set_student_course + action_parameters: + course_id: "english" + COURSE_DUMMY: + custom_condition: DUMMY_KEY_3 + condition: null + action: set_student_course + action_parameters: + course_id: "dummy_course" + COURSE_INTERNATIONAL: + custom_condition: null + condition: null + action: set_student_course + action_parameters: + course_id: "international" + email: + EMAIL_COOK: + custom_condition: null + condition: HAS_SCHOOL_AUTHORIZED_POWER and HAS_FAVORITE_MEAL + action: send_email + action_parameters: + mail_to: "cook@super-heroes.test" + mail_content: "Thanks for preparing once a month the following dish:" + meal: input.favorite_meal +# Third rule set + third_rule_set: + admission: + ADM_OK: + custom_condition: DUMMY_KEY_4 + condition: HAS_SCHOOL_AUTHORIZED_POWER + action: set_admission + action_parameters: + value: True + ADM_KO: + custom_condition: null + condition: null + action: set_admission + action_parameters: + value: False +# Fourth rule set + fourth_rule_set: + admission_rules: + ADM_OK: + custom_condition: DUMMY_KEY_4 + condition: null + action: set_admission + action_parameters: + value: True + ADM_KO: + custom_condition: null + condition: null + action: set_admission + action_parameters: + value: False \ No newline at end of file diff --git a/tests/examples/ignore_conf/__init__.py b/tests/examples/ignore_conf/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/examples/ignore_conf/conditions.yaml b/tests/examples/ignore_conf/conditions.yaml new file mode 100644 index 0000000..75005ba --- /dev/null +++ b/tests/examples/ignore_conf/conditions.yaml @@ -0,0 +1,11 @@ +--- +conditions: + HAS_SCHOOL_AUTHORIZED_POWER: + description: "Does it have school authorized power?" + validation_function: has_authorized_super_power + condition_parameters: + authorized_powers: + - "strength" + - "fly" + - "immortality" + candidate_powers: input.powers \ No newline at end of file diff --git a/tests/examples/ignore_conf/global.yaml b/tests/examples/ignore_conf/global.yaml new file mode 100644 index 0000000..950c3fe --- /dev/null +++ b/tests/examples/ignore_conf/global.yaml @@ -0,0 +1,14 @@ +--- +# Global settings +conditions_source_modules: + - "tests.examples.code.conditions" +actions_source_modules: + - "tests.examples.code.actions" + +parsing_error_strategy: ignore + +# Following settings are only needed when custom condition classes are used +custom_classes_source_modules: + - "tests.examples.code.custom_class" +condition_factory_mapping: + custom_condition: "CustomCondition" diff --git a/tests/examples/ignore_conf/rules.yaml b/tests/examples/ignore_conf/rules.yaml new file mode 100644 index 0000000..83d6e47 --- /dev/null +++ b/tests/examples/ignore_conf/rules.yaml @@ -0,0 +1,13 @@ +--- +# Rules configuration file +rules: + default_rule_set: + test_action: + TEST_IGNORE: + condition: null + action: concatenate_str + action_parameters: + list_str: + - "My name is " + - input.name.first + diff --git a/tests/examples/simple_cond_conf/default/rules_simple_cond.yaml b/tests/examples/simple_cond_conf/default/rules_simple_cond.yaml new file mode 100644 index 0000000..8a31986 --- /dev/null +++ b/tests/examples/simple_cond_conf/default/rules_simple_cond.yaml @@ -0,0 +1,43 @@ +--- +# Global settings +actions_source_modules: + - "tests.examples.code.actions" + +# Rule sets for simple conditions tests +rules: + default_rule_set: + admission: + ADM_OK: + simple_condition: input.power=="strength" or input.power=="fly" + action: set_admission + action_parameters: + value: True + ADM_KO: + simple_condition: null + action: set_admission + action_parameters: + value: False + course: + COURSE_ENGLISH: + simple_condition: input.language=="english" and input.age!=None + action: set_student_course + action_parameters: + course_id: "english" + COURSE_SENIOR: + simple_condition: input.age>=100 or input.age==None + action: set_student_course + action_parameters: + course_id: "senior" + COURSE_INTERNATIONAL: + simple_condition: input.language!="english" + action: set_student_course + action_parameters: + course_id: "international" + email: + EMAIL_COOK: + simple_condition: input.favorite_meal!=None and not output.admission.admission==False + action: send_email + action_parameters: + mail_to: "cook@super-heroes.test" + mail_content: "Thanks for preparing once a month the following dish:" + meal: input.favorite_meal \ No newline at end of file diff --git a/tests/examples/simple_cond_conf/ignore/rules_simple_cond_ignore.yaml b/tests/examples/simple_cond_conf/ignore/rules_simple_cond_ignore.yaml new file mode 100644 index 0000000..26620aa --- /dev/null +++ b/tests/examples/simple_cond_conf/ignore/rules_simple_cond_ignore.yaml @@ -0,0 +1,45 @@ +--- +# Global settings +actions_source_modules: + - "tests.examples.code.actions" + +parsing_error_strategy: ignore + +# Rule sets for simple conditions tests +rules: + default_rule_set: + admission: + ADM_OK: + simple_condition: input.power=="strength" or input.power=="fly" + action: set_admission + action_parameters: + value: True + ADM_KO: + simple_condition: null + action: set_admission + action_parameters: + value: False + course: + COURSE_ENGLISH: + simple_condition: input.language=="english" and input.age!=None + action: set_student_course + action_parameters: + course_id: "english" + COURSE_SENIOR: + simple_condition: input.age>=100 or input.age==None + action: set_student_course + action_parameters: + course_id: "senior" + COURSE_INTERNATIONAL: + simple_condition: input.language!="english" + action: set_student_course + action_parameters: + course_id: "international" + email: + EMAIL_COOK: + simple_condition: input.favorite_meal!=None and not output.admission.admission==False + action: send_email + action_parameters: + mail_to: "cook@super-heroes.test" + mail_content: "Thanks for preparing once a month the following dish:" + meal: input.favorite_meal \ No newline at end of file diff --git a/tests/examples/simple_cond_conf/raise/rules_simple_cond_raise.yaml b/tests/examples/simple_cond_conf/raise/rules_simple_cond_raise.yaml new file mode 100644 index 0000000..eac0f02 --- /dev/null +++ b/tests/examples/simple_cond_conf/raise/rules_simple_cond_raise.yaml @@ -0,0 +1,45 @@ +--- +# Global settings +actions_source_modules: + - "tests.examples.code.actions" + +parsing_error_strategy: raise + +# Rule sets for simple conditions tests +rules: + default_rule_set: + admission: + ADM_OK: + simple_condition: input.power=="strength" or input.power=="fly" + action: set_admission + action_parameters: + value: True + ADM_KO: + simple_condition: null + action: set_admission + action_parameters: + value: False + course: + COURSE_ENGLISH: + simple_condition: input.language=="english" and input.age!=None + action: set_student_course + action_parameters: + course_id: "english" + COURSE_SENIOR: + simple_condition: input.age>=100 or input.age==None + action: set_student_course + action_parameters: + course_id: "senior" + COURSE_INTERNATIONAL: + simple_condition: input.language!="english" + action: set_student_course + action_parameters: + course_id: "international" + email: + EMAIL_COOK: + simple_condition: input.favorite_meal!=None and not output.admission.admission==False + action: send_email + action_parameters: + mail_to: "cook@super-heroes.test" + mail_content: "Thanks for preparing once a month the following dish:" + meal: input.favorite_meal \ No newline at end of file diff --git a/tests/examples/simple_cond_conf/wrong/dummy/ignore/rules_simple_dummy.yaml b/tests/examples/simple_cond_conf/wrong/dummy/ignore/rules_simple_dummy.yaml new file mode 100644 index 0000000..4b01545 --- /dev/null +++ b/tests/examples/simple_cond_conf/wrong/dummy/ignore/rules_simple_dummy.yaml @@ -0,0 +1,45 @@ +--- +# Global settings +actions_source_modules: + - "tests.examples.code.actions" + +parsing_error_strategy: ignore + +# Rule sets for simple conditions tests +rules: + default_rule_set: + admission: + ADM_OK: + simple_condition: input.age=="strength" or dummy + action: set_admission + action_parameters: + value: True + ADM_KO: + simple_condition: null + action: set_admission + action_parameters: + value: False + course: + COURSE_ENGLISH: + simple_condition: input.language=="english" and input.age!=None + action: set_student_course + action_parameters: + course_id: "english" + COURSE_SENIOR: + simple_condition: input.age>=100 or input.age==None + action: set_student_course + action_parameters: + course_id: "senior" + COURSE_INTERNATIONAL: + simple_condition: input.language!="english" + action: set_student_course + action_parameters: + course_id: "international" + email: + EMAIL_COOK: + simple_condition: input.favorite_meal!=None and not output.admission.admission==False + action: send_email + action_parameters: + mail_to: "cook@super-heroes.test" + mail_content: "Thanks for preparing once a month the following dish:" + meal: input.favorite_meal \ No newline at end of file diff --git a/tests/examples/simple_cond_conf/wrong/ignore/rules_simple_wrong_ignore.yaml b/tests/examples/simple_cond_conf/wrong/ignore/rules_simple_wrong_ignore.yaml new file mode 100644 index 0000000..93f2672 --- /dev/null +++ b/tests/examples/simple_cond_conf/wrong/ignore/rules_simple_wrong_ignore.yaml @@ -0,0 +1,45 @@ +--- +# Global settings +actions_source_modules: + - "tests.examples.code.actions" + +parsing_error_strategy: ignore + +# Rule sets for simple conditions tests +rules: + default_rule_set: + admission: + ADM_OK: + simple_condition: input.power=="strength" or input.dummy=="fly" + action: set_admission + action_parameters: + value: True + ADM_KO: + simple_condition: null + action: set_admission + action_parameters: + value: False + course: + COURSE_ENGLISH: + simple_condition: input.language=="english" and input.age!=None + action: set_student_course + action_parameters: + course_id: "english" + COURSE_SENIOR: + simple_condition: input.age>=100 or input.age==None + action: set_student_course + action_parameters: + course_id: "senior" + COURSE_INTERNATIONAL: + simple_condition: input.language!="english" + action: set_student_course + action_parameters: + course_id: "international" + email: + EMAIL_COOK: + simple_condition: input.favorite_meal!=None and not output.dummy.admission==False + action: send_email + action_parameters: + mail_to: "cook@super-heroes.test" + mail_content: "Thanks for preparing once a month the following dish:" + meal: input.favorite_meal \ No newline at end of file diff --git a/tests/examples/simple_cond_conf/wrong/raise/rules_simple_wrong_raise.yaml b/tests/examples/simple_cond_conf/wrong/raise/rules_simple_wrong_raise.yaml new file mode 100644 index 0000000..0ac859d --- /dev/null +++ b/tests/examples/simple_cond_conf/wrong/raise/rules_simple_wrong_raise.yaml @@ -0,0 +1,45 @@ +--- +# Global settings +actions_source_modules: + - "tests.examples.code.actions" + +parsing_error_strategy: raise + +# Rule sets for simple conditions tests +rules: + default_rule_set: + admission: + ADM_OK: + simple_condition: input.power=="strength" or input.dummy=="fly" + action: set_admission + action_parameters: + value: True + ADM_KO: + simple_condition: null + action: set_admission + action_parameters: + value: False + course: + COURSE_ENGLISH: + simple_condition: input.language=="english" and input.age!=None + action: set_student_course + action_parameters: + course_id: "english" + COURSE_SENIOR: + simple_condition: input.age>=100 or input.age==None + action: set_student_course + action_parameters: + course_id: "senior" + COURSE_INTERNATIONAL: + simple_condition: input.language!="english" + action: set_student_course + action_parameters: + course_id: "international" + email: + EMAIL_COOK: + simple_condition: input.favorite_meal!=None and not output.dummy.admission==False + action: send_email + action_parameters: + mail_to: "cook@super-heroes.test" + mail_content: "Thanks for preparing once a month the following dish:" + meal: input.favorite_meal \ No newline at end of file diff --git a/tests/test_example_code/__init__.py b/tests/test_example_code/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_example_code/test_actions.py b/tests/test_example_code/test_actions.py new file mode 100644 index 0000000..6f61a0e --- /dev/null +++ b/tests/test_example_code/test_actions.py @@ -0,0 +1,67 @@ +"""Action function unit tests.""" + +import pytest + +from tests.examples.code import actions + + +@pytest.mark.parametrize( + "value, expected", + [ + (True, {"admission": True}), + (False, {"admission": False}), + ], +) +def test_set_admission(value, expected): + """Action function unit test.""" + result = actions.set_admission(value) + assert result == expected + + +@pytest.mark.parametrize( + "course_id, expected", + [ + ("french", {"course_id": "french"}), + ("international", {"course_id": "international"}), + ], +) +def test_set_student_course(course_id, expected): + """Action function unit test.""" + result = actions.set_student_course(course_id) + assert result == expected + + +@pytest.mark.parametrize( + "mail_to, mail_content, meal, expected", + [ + ( + "cook@test.fr", + "Thanks for preparing the following meal: ", + "roast beef", + True, + ), + ("suppercook@dummy.fr", "Thanks for preparing the following meal: ", "pancakes", True), + ], +) +def test_send_email(mail_to, mail_content, meal, expected): + """Action function unit test.""" + result = actions.send_email(mail_to, mail_content, meal) + assert result == expected + + +def test_concatenate_str(): + """Action function unit test.""" + result = actions.concatenate_str(["a", "b", "c"]) + assert result == "abc" + + +def test_do_nothing(): + """Action function unit test.""" + result = actions.do_nothing() + assert result is None + + +def test_compute_sum(): + """Action function unit test.""" + result = actions.compute_sum(2, 3) + assert result == 5 diff --git a/tests/test_example_code/test_conditions.py b/tests/test_example_code/test_conditions.py new file mode 100644 index 0000000..0f914e3 --- /dev/null +++ b/tests/test_example_code/test_conditions.py @@ -0,0 +1,33 @@ +""" +Unit tests of the conditions.py module. +""" + +import pytest + +from tests.examples.code import conditions + + +@pytest.mark.parametrize( + "function, kwargs, expected", + [ + ( + conditions.has_authorized_super_power, + {"authorized_powers": ["strength", "fly", "immortality"], "candidate_powers": ["invisibility", "fly"]}, + True, + ), + ( + conditions.has_authorized_super_power, + {"authorized_powers": ["strength", "fly", "immortality"], "candidate_powers": ["invisibility", "fast"]}, + False, + ), + (conditions.is_age_unknown, {"age": None}, True), + (conditions.is_age_unknown, {"age": 100}, False), + (conditions.is_speaking_language, {"value": "english", "spoken_language": "english"}, True), + (conditions.is_speaking_language, {"value": "french", "spoken_language": "english"}, False), + (conditions.has_favorite_meal, {"favorite_meal": "gratin"}, True), + (conditions.has_favorite_meal, {"favorite_meal": None}, False), + ], +) +def test_functions(function, kwargs, expected): + """Validation funtion unit test.""" + assert function(**kwargs) == expected diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/test_engine.py b/tests/unit/test_engine.py new file mode 100644 index 0000000..20b8ebc --- /dev/null +++ b/tests/unit/test_engine.py @@ -0,0 +1,409 @@ +"""RulesEngine class UT.""" + +import os + +import pytest +from arta import RulesEngine + + +def test_instanciation(base_config_path): + """Unit test of the class RulesEngine""" + # Config. instanciation + path = os.path.join(base_config_path, "good_conf") + eng_1 = RulesEngine(config_path=path) + assert isinstance(eng_1, RulesEngine) + + # Dummy action function + set_value = lambda value, **kwargs: {"value": value} + + raw_rules = { + "type": { + "rule_str": { + "condition": lambda x: isinstance(x, str), + "condition_parameters": {"x": "input.to_check"}, + "action": set_value, + "action_parameters": {"value": "String"}, + }, + "rule_other": { + "condition": None, + "condition_parameters": None, + "action": set_value, + "action_parameters": {"value": "other type"}, + }, + } + } + # Dictionary instanciation + eng_2 = RulesEngine(rules_dict=raw_rules) + assert isinstance(eng_2, RulesEngine) + + +@pytest.mark.parametrize( + "input_data, rule_set, verbose, good_results", + [ + ( + { + "age": None, + "language": "french", + "powers": ["strength", "fly"], + "favorite_meal": "Spinach", + }, + "default_rule_set", + False, + { + "admission": {"admission": True}, + "course": {"course_id": "senior"}, + "email": True, + }, + ), + ( + { + "age": None, + "language": "french", + "powers": ["strength", "fly"], + "favorite_meal": "Spinach", + }, + "default_rule_set", + True, + { + "verbosity": { + "rule_set": "default_rule_set", + "results": [ + { + "rule_group": "admission", + "verified_conditions": { + "condition": { + "expression": "HAS_SCHOOL_AUTHORIZED_POWER", + "values": {"HAS_SCHOOL_AUTHORIZED_POWER": True}, + }, + "simple_condition": {"expression": None, "values": {}}, + }, + "activated_rule": "ADM_OK", + "action_result": {"admission": True}, + }, + { + "rule_group": "course", + "verified_conditions": { + "condition": {"expression": "IS_AGE_UNKNOWN", "values": {"IS_AGE_UNKNOWN": True}}, + "simple_condition": {"expression": None, "values": {}}, + }, + "activated_rule": "COURSE_SENIOR", + "action_result": {"course_id": "senior"}, + }, + { + "rule_group": "email", + "verified_conditions": { + "condition": { + "expression": "HAS_SCHOOL_AUTHORIZED_POWER", + "values": {"HAS_SCHOOL_AUTHORIZED_POWER": True}, + }, + "simple_condition": {"expression": None, "values": {}}, + }, + "activated_rule": "EMAIL_COOK", + "action_result": True, + }, + ], + }, + "admission": {"admission": True}, + "course": {"course_id": "senior"}, + "email": True, + }, + ), + ( + { + "age": None, + "language": "spanich", + "powers": ["strength", "fly"], + "favorite_meal": "Spinach", + "DUMMY_KEY": "foo", + }, + "second_rule_set", + False, + { + "admission": {"admission": True}, + "course": {"course_id": "international"}, + "email": True, + }, + ), + ( + { + "age": None, + "language": "french", + "powers": ["strength", "fly"], + "favorite_meal": "Spinach", + "DUMMY_KEY": "foo", + }, + "second_rule_set", + True, + { + "verbosity": { + "rule_set": "second_rule_set", + "results": [ + { + "rule_group": "admission", + "verified_conditions": { + "condition": { + "expression": "HAS_SCHOOL_AUTHORIZED_POWER", + "values": {"HAS_SCHOOL_AUTHORIZED_POWER": True}, + }, + "simple_condition": {"expression": None, "values": {}}, + "custom_condition": { + "expression": "DUMMY_KEY or DUMMY_KEY_2", + "values": {"DUMMY_KEY_2": False, "DUMMY_KEY": True}, + }, + }, + "activated_rule": "ADM_OK", + "action_result": {"admission": True}, + }, + { + "rule_group": "course", + "verified_conditions": { + "condition": { + "expression": "IS_SPEAKING_FRENCH", + "values": {"IS_SPEAKING_FRENCH": True}, + }, + "simple_condition": {"expression": None, "values": {}}, + "custom_condition": {"expression": None, "values": {}}, + }, + "activated_rule": "COURSE_FRENCH", + "action_result": {"course_id": "french"}, + }, + { + "rule_group": "email", + "verified_conditions": { + "condition": { + "expression": "HAS_SCHOOL_AUTHORIZED_POWER and HAS_FAVORITE_MEAL", + "values": {"HAS_FAVORITE_MEAL": True, "HAS_SCHOOL_AUTHORIZED_POWER": True}, + }, + "simple_condition": {"expression": None, "values": {}}, + "custom_condition": {"expression": None, "values": {}}, + }, + "activated_rule": "EMAIL_COOK", + "action_result": True, + }, + ], + }, + "admission": {"admission": True}, + "course": {"course_id": "french"}, + "email": True, + }, + ), + ( + { + "age": 5000, + "language": "english", + "powers": ["strength", "greek_gods", "regeneration", "immortality"], + "favorite_meal": None, + "weapons": ["Magic lasso", "Bulletproof bracelets", "Sword", "Shield"], + "DUMMY_KEY_2": "bar", + }, + "second_rule_set", + False, + { + "admission": {"admission": True}, + "course": {"course_id": "english"}, + "email": None, + }, + ), + ( + { + "age": 5000, + "language": "spanish", + "powers": ["strength", "greek_gods", "regeneration", "immortality"], + "favorite_meal": None, + "weapons": ["Magic lasso", "Bulletproof bracelets", "Sword", "Shield"], + "DUMMY_KEY_3": "bar", + }, + "second_rule_set", + False, + { + "admission": {"admission": False}, + "course": {"course_id": "dummy_course"}, + "email": None, + }, + ), + ( + { + "age": 5000, + "language": "english", + "powers": ["strength", "greek_gods", "regeneration", "immortality"], + "favorite_meal": None, + "weapons": ["Magic lasso", "Bulletproof bracelets", "Sword", "Shield"], + "DUMMY_KEY_4": None, + }, + "third_rule_set", + False, + { + "admission": {"admission": True}, + }, + ), + ( + { + "age": 5000, + "language": "english", + "powers": ["strength", "greek_gods", "regeneration", "immortality"], + "favorite_meal": None, + "weapons": ["Magic lasso", "Bulletproof bracelets", "Sword", "Shield"], + "DUMMY_KEY": None, + }, + "third_rule_set", + False, + { + "admission": {"admission": False}, + }, + ), + ], +) +def test_conf_apply_rules(input_data, rule_set, verbose, good_results, base_config_path): + """Unit test of the method RulesEngine.apply_rules()""" + config_path = os.path.join(base_config_path, "good_conf") + eng = RulesEngine(config_path=config_path) + res = eng.apply_rules(input_data=input_data, rule_set=rule_set, verbose=verbose) + + assert res == good_results + + +@pytest.mark.parametrize( + "input_data, verbose, good_results", + [ + ( + { + "age": 5000, + "language": "english", + "power": "immortality", + "favorite_meal": None, + "weapons": ["Magic lasso", "Bulletproof bracelets", "Sword", "Shield"], + }, + True, + { + "verbosity": { + "rule_set": "default_rule_set", + "results": [ + { + "rule_group": "power_level", + "verified_conditions": { + "condition": {"expression": "USER_CONDITION", "values": {"USER_CONDITION": True}} + }, + "activated_rule": "super_power", + "action_result": "super", + }, + { + "rule_group": "admission", + "verified_conditions": { + "condition": {"expression": "USER_CONDITION", "values": {"USER_CONDITION": True}} + }, + "activated_rule": "admitted", + "action_result": "Admitted!", + }, + ], + }, + "power_level": "super", + "admission": "Admitted!", + }, + ), + ( + { + "age": 5000, + "language": "english", + "power": "immortality", + "favorite_meal": None, + "weapons": ["Magic lasso", "Bulletproof bracelets", "Sword", "Shield"], + }, + False, + {"power_level": "super", "admission": "Admitted!"}, + ), + ], +) +def test_dict_apply_rules(input_data, verbose, good_results): + """Unit test of the method RulesEngine.apply_rules() when init was done using rule_dict""" + # Dummy action function + + def is_a_super_power(level, **kwargs): + """Dummy validation function.""" + return level == "super" + + def admit(**kwargs): + """Dummy action function.""" + return "Admitted!" + + def set_value(value, **kwargs): + """Dummy action function.""" + return value + + rules_raw = { + "power_level": { + "super_power": { + "condition": lambda p: p in ["immortality", "time_travelling", "invisibility"], + "condition_parameters": {"p": "input.power"}, + "action": lambda x, **kwargs: x, + "action_parameters": {"x": "super"}, + }, + "minor_power": { + "condition": lambda p: p in ["juggle", "sing", "sleep"], + "condition_parameters": {"p": "input.power"}, + "action": lambda x, **kwargs: x, + "action_parameters": {"x": "minor"}, + }, + "no_power": { + "condition": None, + "condition_parameters": None, + "action": lambda x, **kwargs: x, + "action_parameters": {"x": "no_power"}, + }, + }, + "admission": { + "admitted": { + "condition": is_a_super_power, + "condition_parameters": {"level": "output.power_level"}, + "action": admit, + "action_parameters": None, + }, + "not_admitted": { + "condition": None, + "condition_parameters": None, + "action": set_value, + "action_parameters": {"value": "Not admitted :-("}, + }, + }, + } + + eng_2 = RulesEngine(rules_dict=rules_raw) + res = eng_2.apply_rules(input_data, verbose=verbose) + assert res == good_results + + +def test_ignore_global_strategy(base_config_path): + """Unit test of the class RulesEngine""" + # Config. instanciation + engine = RulesEngine(config_path=os.path.join(base_config_path, "ignore_conf")) + assert isinstance(engine, RulesEngine) + + # Dummy action function + context_data = {"dummy": "dummy"} + + result = engine.apply_rules(context_data) + + assert result == {"test_action": "My name is None"} + + +@pytest.mark.parametrize( + "input_data, good_results", + [ + ( + { + "age": 5000, + "language": "english", + "powers": ["greek_gods", "regeneration"], + "favorite_meal": None, + "weapons": ["Magic lasso", "Bulletproof bracelets", "Sword", "Shield"], + }, + {"admission_rules": {"admission": True}}, + ), + ], +) +def test_kwargs_in_apply_rules(input_data, good_results, base_config_path): + """Unit test of user extra arguments.""" + config_path = os.path.join(base_config_path, "good_conf") + eng = RulesEngine(config_path=config_path) + res = eng.apply_rules(input_data, rule_set="fourth_rule_set", my_parameter="super@connection") + + assert res == good_results diff --git a/tests/unit/test_engine_errors.py b/tests/unit/test_engine_errors.py new file mode 100644 index 0000000..55875a7 --- /dev/null +++ b/tests/unit/test_engine_errors.py @@ -0,0 +1,244 @@ +"""UT of error handling.""" + +import os + +import pytest +from arta import RulesEngine +from arta import ConditionExecutionError, RuleExecutionError + +try: + from pydantic import v1 as pydantic +except ImportError: + import pydantic # type: ignore + + +@pytest.mark.parametrize( + "rules_dict, config_dir, expected_error", + [ + ( + None, + None, + ValueError, + ), + ( + { + "type": { + "rule_str": { + "extra_condition": lambda x: isinstance(x, str), + "condition": lambda x: isinstance(x, str), + "condition_parameters": {"x": "input.to_check"}, + "action": lambda value, **kwargs: {"value": value}, + "action_parameters": {"value": "String"}, + }, + } + }, + None, + pydantic.ValidationError, + ), + ( + "Something, doesn't matter", + "Something, doesn't matter", + ValueError, + ), + ( + "Not a dict", + None, + pydantic.ValidationError, + ), + ( + {}, + None, + KeyError, + ), + ( + { + "test": { + "rule_str": { + "condition": lambda x: isinstance(x, str), + "condition_parameters": {"x": "input.to_check"}, + "action": lambda value: {"value": value}, + "action_parameters": {"value": "Rules not only dict."}, + }, + }, + "test2": "not good", + }, + None, + pydantic.ValidationError, + ), + ( + { + "type": { + "rule_str": { + "condition": "need to be a callable", + "condition_parameters": {"x": "input.to_check"}, + "action": lambda value: {"value": value}, + "action_parameters": {"value": "Condition not None or Callable."}, + }, + } + }, + None, + pydantic.ValidationError, + ), + ( + { + "type": { + "rule_str": { + "action": "need to be a callable", + }, + } + }, + None, + pydantic.ValidationError, + ), + ( + { + "type": { + "rule_str": { + "condition": lambda x: isinstance(x, str), + "condition_parameters": {"x": "input.to_check"}, + "action": lambda value: {"value": value}, + "action_parameters": {"value": "Missing **kwargs in action function."}, + }, + } + }, + None, + KeyError, + ), + ( + { + "type": { + "rule_str": { + "condition": lambda x: isinstance(x, str), + "condition_parameters": {"x": "input.to_check"}, + "action_parameters": {"value": "Missing an action."}, + }, + } + }, + None, + pydantic.ValidationError, + ), + ( + None, + "failing_conf/missing_action_source_modules/", + pydantic.ValidationError, + ), + ( + None, + "failing_conf/missing_rules/", + ValueError, + ), + ( + None, + "failing_conf/wrong_condition/", + KeyError, + ), + ( + None, + "failing_conf/wrong_action/", + KeyError, + ), + ( + None, + "failing_conf/wrong_validation_function/", + KeyError, + ), + ( + None, + "failing_conf/wrong_rules_nested/", + pydantic.ValidationError, + ), + ( + None, + "failing_conf/wrong_parsing_error_strategy/", + pydantic.ValidationError, + ), + ], +) +def test_instance_error(rules_dict, config_dir, expected_error, base_config_path): + """Unit test of class instanciation and rules_dict loading.""" + config_path = None + + if config_dir is not None: + config_path = os.path.join(base_config_path, config_dir) + + with pytest.raises(expected_error): + _ = RulesEngine(rules_dict=rules_dict, config_path=config_path) + + +@pytest.mark.parametrize( + "rules_dict, config_dir", + [ + ( + { + "type": { + "rule_str": { + "condition": lambda x: isinstance(x, str), + "condition_parameters": {"x": "input.to_check"}, + "action": lambda value, **kwargs: value, + "action_parameters": {"value": "String"}, + }, + } + }, + None, + ), + ( + None, + "failing_conf/without_parsing_error_strategy/", + ), + ], +) +def test_instance_error_without_expected_error(rules_dict, config_dir, base_config_path): + """Unit test of class instanciation and rules_dict loading.""" + config_path = None + + if config_dir is not None: + config_path = os.path.join(base_config_path, config_dir) + + engine = RulesEngine(rules_dict=rules_dict, config_path=config_path) + assert isinstance(engine, RulesEngine) + + +@pytest.mark.parametrize( + "input_data, expected_error", + [ + ( + "Something, doesn't matter type", + TypeError, + ), + ( + None, + TypeError, + ), + ( + {}, + KeyError, + ), + ( + # Be carefull with the following case, not having condition param. or action param. in input_data + # can be ok or not.It depends on the condition and action implementations. + { + "age": 5000, + "powers": ["strength", "greek_gods", "regeneration", "immortality"], + "favorite_meal": None, + "weapons": ["Magic lasso", "Bulletproof bracelets", "Sword", "Shield"], + }, + ConditionExecutionError, + ), + ( + # Be carefull with the following case, not having condition param. or action param. in input_data + # can be ok or not.It depends on the condition and action implementations. + { + "age": 5000, + "language": "english", + "powers": ["strength", "greek_gods", "regeneration", "immortality"], + "weapons": ["Magic lasso", "Bulletproof bracelets", "Sword", "Shield"], + }, + RuleExecutionError, + ), + ], +) +def test_error_apply_rules_missing_input_key(input_data, expected_error, base_config_path): + """Unit test of RulesEngine.apply_process with config_path instance.""" + eng = RulesEngine(config_path=os.path.join(base_config_path, "good_conf")) + with pytest.raises(expected_error): + _ = eng.apply_rules(input_data=input_data, rule_set="default_rule_set", verbose=False) diff --git a/tests/unit/test_simple_condition.py b/tests/unit/test_simple_condition.py new file mode 100644 index 0000000..583559b --- /dev/null +++ b/tests/unit/test_simple_condition.py @@ -0,0 +1,196 @@ +"""UT of the simple conditions.""" + +import os + +import pytest +from arta import RulesEngine +from arta.utils import ConditionExecutionError, RuleExecutionError + + +@pytest.mark.parametrize( + "input_data, config_dir, good_results", + [ + ( + { + "age": 100, + "language": "french", + "power": "strength", + "favorite_meal": "Spinach", + }, + "simple_cond_conf/default", + {"admission": {"admission": True}, "course": {"course_id": "senior"}, "email": True}, + ), + ( + { + "age": 30, + "language": "english", + "power": "fly", + "favorite_meal": None, + }, + "simple_cond_conf/default", + {"admission": {"admission": True}, "course": {"course_id": "english"}, "email": None}, + ), + ( + { + "age": None, + "language": "german", + "power": "invisibility", + "favorite_meal": "French Fries", + }, + "simple_cond_conf/default", + {"admission": {"admission": False}, "course": {"course_id": "senior"}, "email": None}, + ), + ( + { + "dummy": 100, + "language": "french", + "power": "strength", + "favorite_meal": "Spinach", + }, + "simple_cond_conf/ignore", + {"admission": {"admission": True}, "course": {"course_id": "senior"}, "email": True}, + ), + ( + { + "age": 100, + "language": "french", + "power": "strength", + "favorite_meal": "Spinach", + }, + "simple_cond_conf/wrong/ignore", + {"admission": {"admission": True}, "course": {"course_id": "senior"}, "email": True}, + ), + ], +) +def test_simple_condition(input_data, config_dir, good_results, base_config_path): + """Unit test of the method RulesEngine.apply_rules()""" + config_path = os.path.join(base_config_path, config_dir) + eng = RulesEngine(config_path=config_path) + res = eng.apply_rules(input_data=input_data) + + assert res == good_results + + +@pytest.mark.parametrize( + "input_data, good_results", + [ + ( + { + "age": 100, + "language": "french", + "power": "strength", + "favorite_meal": "Spinach", + }, + { + "verbosity": { + "rule_set": "default_rule_set", + "results": [ + { + "rule_group": "admission", + "verified_conditions": { + "condition": {"expression": None, "values": {}}, + "simple_condition": { + "expression": 'input.power=="strength" or input.power=="fly"', + "values": {'input.power=="strength"': True, 'input.power=="fly"': False}, + }, + }, + "activated_rule": "ADM_OK", + "action_result": {"admission": True}, + }, + { + "rule_group": "course", + "verified_conditions": { + "condition": {"expression": None, "values": {}}, + "simple_condition": { + "expression": "input.age>=100 or input.age==None", + "values": {"input.age==None": False, "input.age>=100": True}, + }, + }, + "activated_rule": "COURSE_SENIOR", + "action_result": {"course_id": "senior"}, + }, + { + "rule_group": "email", + "verified_conditions": { + "condition": {"expression": None, "values": {}}, + "simple_condition": { + "expression": "input.favorite_meal!=None and not output.admission.admission==False", + "values": { + "input.favorite_meal!=None": True, + "output.admission.admission==False": False, + }, + }, + }, + "activated_rule": "EMAIL_COOK", + "action_result": True, + }, + ], + }, + "admission": {"admission": True}, + "course": {"course_id": "senior"}, + "email": True, + }, + ), + ], +) +def test_simple_condition_verbose(input_data, good_results, base_config_path): + """Unit test of the method RulesEngine.apply_rules()""" + config_path = os.path.join(base_config_path, "simple_cond_conf/default") + eng = RulesEngine(config_path=config_path) + res = eng.apply_rules(input_data=input_data, verbose=True) + + assert res == good_results + + +@pytest.mark.parametrize( + "input_data, config_dir, expected_error", + [ + ( + { + "dummy": 100, + "language": "french", + "power": "strength", + "favorite_meal": "Spinach", + }, + "simple_cond_conf/default", + ConditionExecutionError, + ), + ( + { + "dummy": 100, + "language": "french", + "power": "strength", + "favorite_meal": "Spinach", + }, + "simple_cond_conf/raise", + ConditionExecutionError, + ), + ( + { + "age": 100, + "language": "french", + "power": "strength", + "favorite_meal": "Spinach", + }, + "simple_cond_conf/wrong/dummy", + RuleExecutionError, + ), + ( + { + "age": 100, + "language": "french", + "power": "strength", + "favorite_meal": "Spinach", + }, + "simple_cond_conf/wrong/raise", + ConditionExecutionError, + ), + ], +) +def test_error_apply_rules_missing_input_key(input_data, config_dir, expected_error, base_config_path): + """Unit test of RulesEngine.apply_process with config_path instance.""" + config_path = os.path.join(base_config_path, config_dir) + eng = RulesEngine(config_path=config_path) + + with pytest.raises(expected_error): + _ = eng.apply_rules(input_data=input_data, verbose=False) diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py new file mode 100644 index 0000000..f93218b --- /dev/null +++ b/tests/unit/test_utils.py @@ -0,0 +1,141 @@ +"""Utility functions UT.""" + +import pytest +from arta.utils import ParsingErrorStrategy, parse_dynamic_parameter + + +@pytest.mark.parametrize( + "parameter, input_data, expected_value", + [ + ( + "output.age", + dict(output=dict(age=20, first_name="Joe")), + 20, + ), + ( + "output.sub_dict1.sub_dict2.key", + dict(output=dict(sub_dict1=dict(sub_dict2=dict(key="abc")))), + "abc", + ), + ( + ["output.foo", 32, "output.bar"], + dict(output=dict(foo="foofoo", bar="barbar")), + ["foofoo", 32, "barbar"], + ), + ], +) +def test_parse_dynamic_parameter(parameter, input_data, expected_value): + """Utils function unit test.""" + result = parse_dynamic_parameter(parameter, input_data, parsing_error_strategy=ParsingErrorStrategy.RAISE) + assert result == expected_value + + +def test_raise_error_handling_strategy(): + """Utils function unit test.""" + parameter = "output.unknown.path" + input_data = dict(output=dict(age=20, first_name="Joe")) + error_regex_match = "output.unknown.path" + + with pytest.raises(KeyError, match=error_regex_match): + _ = parse_dynamic_parameter(parameter, input_data, parsing_error_strategy=ParsingErrorStrategy.RAISE) + + +def test_raise_error_handling_strategy_as_string(): + """Utils function unit test.""" + parameter = "output.unknown.path" + input_data = dict(output=dict(age=20, first_name="Joe")) + error_regex_match = "output.unknown.path" + + with pytest.raises(KeyError, match=error_regex_match): + _ = parse_dynamic_parameter( + parameter, + input_data, + parsing_error_strategy=ParsingErrorStrategy.RAISE, + ) + + +def test_raise_error_handling_strategy_override(): + """Utils function unit test.""" + # Arrange + parameter = "output.unknown.path?" + input_data = dict(output=dict(age=20, first_name="Joe")) + expected_value = None + + # Act + result = parse_dynamic_parameter(parameter, input_data, parsing_error_strategy=ParsingErrorStrategy.RAISE) + + # Assert + assert result == expected_value + + +@pytest.mark.parametrize("parameter", ["output.age?", "output.age!"]) +def test_raise_error_handling_strategy_override_on_existing_key(parameter): + """Utils function unit test.""" + # Arrange + parameter = "output.age?" + input_data = dict(output=dict(age=20, first_name="Joe")) + expected_value = 20 + + # Act + result = parse_dynamic_parameter(parameter, input_data, parsing_error_strategy=ParsingErrorStrategy.RAISE) + + # Assert + assert result == expected_value + + +def test_ignore_error_handling_strategy(): + """Utils function unit test.""" + # Arrange + parameter = "output.unknown.path" + input_data = dict(output=dict(age=20, first_name="Joe")) + expected_value = None + + # Act + result = parse_dynamic_parameter(parameter, input_data, parsing_error_strategy=ParsingErrorStrategy.IGNORE) + + # Assert + assert result == expected_value + + +def test_ignore_error_handling_strategy_as_string(): + """Utils function unit test.""" + # Arrange + parameter = "output.unknown.path" + input_data = dict(output=dict(age=20, first_name="Joe")) + expected_value = None + + # Act + result = parse_dynamic_parameter(parameter, input_data, parsing_error_strategy=ParsingErrorStrategy.IGNORE) + + # Assert + assert result == expected_value + + +def test_ignore_error_handling_strategy_override(): + """Utils function unit test.""" + # Arrange + parameter = "output.unknown.path!" + input_data = dict(output=dict(age=20, first_name="Joe")) + error_regex_match = "output.unknown.path" + + with pytest.raises(KeyError, match=error_regex_match): + _ = parse_dynamic_parameter(parameter, input_data, parsing_error_strategy=ParsingErrorStrategy.RAISE) + + +@pytest.mark.parametrize( + "parameter, expected_value", + [ + ("output.unknown?None", "None"), + ("output.unknown?True", "True"), + ("output.unknown?hello", "hello"), + ], +) +def test_raise_error_handling_strategy_with_default_value_override(parameter, expected_value): + """Utils function unit test.""" + input_data = dict(output=dict(age=20, first_name="Joe")) + + # Act + result = parse_dynamic_parameter(parameter, input_data, parsing_error_strategy=ParsingErrorStrategy.RAISE) + + # Assert + assert result == expected_value diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..4e75f9e --- /dev/null +++ b/tox.ini @@ -0,0 +1,22 @@ +[tox] +min_version = 4.0 +env_list = + py312 + py311 + py310 + py39 + pydantic1-{py39,py310,py311,py312} + +[testenv] +description = run unit tests +deps = + pytest + pydantic>=2.0.0 +commands = pytest tests + +[testenv:pydantic1] +description = check backward compatibility with pydantic < 2.0.0 +deps = + pytest + pydantic<2.0.0 +commands = pytest tests