diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 26ff3d3..2a2bd6e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -26,7 +26,7 @@ repos: - id: check-added-large-files args: [--maxkb=500] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.8.0 + rev: v0.8.2 hooks: - id: ruff args: [--fix] diff --git a/CHANGELOG.md b/CHANGELOG.md index 944b29f..c9f3658 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,11 @@ 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.8.2] - November, 2024 +## [0.9.0] - December, 2024 + +### Features + +* Add a new configuration setting for rules' execution: `rule_activation_mode` (#38). ### Maintenance diff --git a/docs/mkdocs.yaml b/docs/mkdocs.yaml index 23a1de4..597613d 100644 --- a/docs/mkdocs.yaml +++ b/docs/mkdocs.yaml @@ -83,10 +83,12 @@ nav: - How to: how_to.md - Glossary: glossary.md - Advanced User Guide: + - API Reference: api_reference.md + - Custom conditions: custom_conditions.md - Parameters: parameters.md + - Rule activation mode: rule_activation_mode.md - Rule sets: rule_sets.md - Use your business objects: business_objects.md - - Custom conditions: custom_conditions.md - - API Reference: api_reference.md + extra_css: - assets/css/mkdocs_extra.css \ No newline at end of file diff --git a/docs/pages/home.md b/docs/pages/home.md index 23abc2c..372288d 100644 --- a/docs/pages/home.md +++ b/docs/pages/home.md @@ -5,26 +5,35 @@ An Open Source Rules Engine - Make rule handling simple

- Versions + Versions

# Welcome to the documentation -* Want to discover what is **Arta**? :arrow_right: [Get Started](a_simple_example.md) -* Want to know how to use it? :arrow_right: [User Guide](how_to.md) +Want to discover what is **Arta**? :arrow_right: [Get Started](a_simple_example.md) -!!! info "New feature" - Check out the new and very convenient feature called the [simple condition](how_to.md#simple-condition). A new and lightweight way of configuring your rules' conditions. +Want to know how to use it? :arrow_right: [User Guide](how_to.md) -**Arta** is automatically tested with: -![Alt Python](https://img.shields.io/pypi/pyversions/arta) +!!! info inline "New feature" + + Use **Arta** as a *process execution engine* :zap: + + Read [this page](rule_activation_mode.md) for more details. !!! tip "Releases" - Want to see last updates, check the [Release notes](https://github.com/MAIF/arta/releases) :rocket: + Check the [Release notes](https://github.com/MAIF/arta/releases) :rocket: + +!!! warning "Pydantic 1 compatibility is deprecated" + + **Arta** is working with [Pydantic 2](https://docs.pydantic.dev/latest/) and Pydantic 1 but compatibility with V1 will be removed in the next **major** release. + +**Arta** is working and automatically tested with: + +![Alt Python](https://img.shields.io/pypi/pyversions/arta) -!!! success "Pydantic 2" +You like **Arta**? Add a :star: - **Arta** is now working with [Pydantic 2](https://docs.pydantic.dev/latest/)! And of course, Pydantic 1 as well. +[![GitHub Repo stars](https://img.shields.io/github/stars/maif/arta)](https://github.com/MAIF/arta) diff --git a/docs/pages/how_to.md b/docs/pages/how_to.md index d25a534..6dd8552 100644 --- a/docs/pages/how_to.md +++ b/docs/pages/how_to.md @@ -2,10 +2,6 @@ Ensure that you have correctly installed **Arta** before, check the [Installatio ## Simple condition -!!! beta "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 your rules a lot by: diff --git a/docs/pages/rule_activation_mode.md b/docs/pages/rule_activation_mode.md new file mode 100644 index 0000000..1d2525c --- /dev/null +++ b/docs/pages/rule_activation_mode.md @@ -0,0 +1,95 @@ +!!! example "Beta feature" + + This new feature (i.e., `rule_activation_mode: many_by_group`) is a **beta feature**, some cases could not work as designed. Please report them using [issues](https://github.com/MAIF/arta/issues). + +This feature was designed at MAIF when the idea to use **Arta** as a simple *process execution engine* came about. + +Our goal was to handle different *rules* of data processing inside an ETL pipeline. + +It's actually quite simple :zap: + +## Illustration + +Traditionaly, **Arta** is evaluating rules (like most rules engines) like this: + +```mermaid +--- +title: one_by_group +--- +flowchart + s((Start)) + e((End)) + subgraph Group_1 + r1(Rule 1, conditions False)-->r2(Rule 2, conditions True)-.not evaluated.->r3(Rule 3, conditions True) + end + r2-.execute.->a2(Action A) + subgraph Group_2 + r1b(Rule 1, conditions True)-.not evaluated.->r2b(Rule 2, conditions False)-.not evaluated.->r3b(Rule 3, conditions True) + end + s-->r1 + r1b-.execute.->a1b(Action B) + r2-->r1b + r3-.->r1b + r1b-->e + r3b-.->e +``` + +> **Only one rule is activated (i.e., meaning one action is triggered) by rule group.** + +--- + +But if we need to use **Arta** to execute *simple workflows*, we need a *control flow* like this one: + +```mermaid +--- +title: many_by_group +--- +flowchart + s((Start)) + e((End)) + subgraph Group_1 + r1(Rule 1, conditions False)-->r2(Rule 2, conditions True)-->r3(Rule 3, conditions True) + end + r2-.execute.->a2(Action A) + r3-.execute.->a3(Action B) + subgraph Group_2 + r1b(Rule 1, conditions True)-->r2b(Rule 2, conditions False)-->r3b(Rule 3, conditions True) + end + s-->r1 + r1b-.execute.->a1b(Action C) + r3b-.execute.->a3b(Action D) + r3-->r1b + r3b-->e +``` + +> **All rules are evaluated.** + +> **Therefore, many rules can be activated (i.e., meaning many actions can be triggered) by rule group.** + +--- + +## Setting + +You just need to add somewhere in the YAML configuration file of **Arta** the following setting: + +### One by group + +This *traditional* flow of control is the **default one**: + +```yaml +rule_activation_mode: one_by_group +``` + +!!! note "Default value" + + Because it is the **default value**, it is *useless* to add this line in the configuration. + +### Many by group + +This is the *flow of control* of a **process execution engine**: + +```yaml +rule_activation_mode: many_by_group +``` + +That's all! You are all set :+1: \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 221cd13..e5c8691 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "arta" -version = "0.8.2" +version = "0.9.0" requires-python = ">3.8.0" description = "A Python Rules Engine - Make rule handling simple" readme = "README.md" diff --git a/src/arta/_engine.py b/src/arta/_engine.py index 85ea1a2..487f0f4 100644 --- a/src/arta/_engine.py +++ b/src/arta/_engine.py @@ -16,7 +16,7 @@ from arta.config import load_config from arta.models import Configuration, RulesDict from arta.rule import Rule -from arta.utils import ParsingErrorStrategy +from arta.utils import ParsingErrorStrategy, RuleActivationMode class RulesEngine: @@ -81,8 +81,10 @@ def __init__( "RulesEngine takes one (and only one) parameter: 'rules_dict' or 'config_path' or 'config_dict'." ) - # Init. default parsing_error_strategy (probably not needed because already defined elsewhere) + # Init. default global settings (useful if not set, can't be set in the Pydantic model + # because of the rules dict mode) self._parsing_error_strategy: ParsingErrorStrategy = ParsingErrorStrategy.RAISE + self._rule_activation_mode: RuleActivationMode = RuleActivationMode.ONE_BY_GROUP # Initialize directly with a rules dict if rules_dict is not None: @@ -112,6 +114,10 @@ def __init__( # Set parsing error handling strategy from config self._parsing_error_strategy = ParsingErrorStrategy(config.parsing_error_strategy) + if config.rule_activation_mode is not None: + # Set rule activation mode from config + self._rule_activation_mode = RuleActivationMode(config.rule_activation_mode) + # 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) @@ -166,7 +172,8 @@ def apply_rules( """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). + The loop is broken when a rule is applied (an action is triggered) + or not (depends on the rule activation mode). Then, the next rule group is evaluated. And so on... @@ -241,8 +248,9 @@ def apply_rules( # 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 + if self._rule_activation_mode is RuleActivationMode.ONE_BY_GROUP: + # We can only have one result per group => break when "action_result" in rule_details + break # Handling non-verbose mode if not verbose: diff --git a/src/arta/models.py b/src/arta/models.py index 05d578d..20669bc 100644 --- a/src/arta/models.py +++ b/src/arta/models.py @@ -8,7 +8,7 @@ import pydantic from pydantic.version import VERSION -from arta.utils import ParsingErrorStrategy +from arta.utils import ParsingErrorStrategy, RuleActivationMode PYDANTIC_V1: bool = VERSION.startswith("1.") @@ -66,6 +66,7 @@ class Configuration(pydantic.BaseModel): condition_factory_mapping: Optional[dict[str, str]] = None rules: dict[str, dict[str, dict[Annotated[str, pydantic.StringConstraints(to_upper=True)], RulesConfig]]] parsing_error_strategy: Optional[ParsingErrorStrategy] = None + rule_activation_mode: Optional[RuleActivationMode] = None else: # Pydantic V1 @@ -141,4 +142,5 @@ class Configuration(BaseModelV2): # type: ignore[no-redef] 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 - parsing_error_strategy: Optional[ParsingErrorStrategy] + parsing_error_strategy: Optional[ParsingErrorStrategy] = None + rule_activation_mode: Optional[RuleActivationMode] = None diff --git a/src/arta/utils.py b/src/arta/utils.py index 2552707..ef13b46 100644 --- a/src/arta/utils.py +++ b/src/arta/utils.py @@ -16,6 +16,16 @@ class ParsingErrorStrategy(str, Enum): DEFAULT_VALUE: str = "default_value" +class RuleActivationMode(str, Enum): + """Define how Arta is processing rules. + + ONE_BY_GROUP is the default mode. + """ + + ONE_BY_GROUP: str = "one_by_group" + MANY_BY_GROUP: str = "many_by_group" + + 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. diff --git a/tests/examples/code/actions.py b/tests/examples/code/actions.py index 9a4be3e..0dae41f 100644 --- a/tests/examples/code/actions.py +++ b/tests/examples/code/actions.py @@ -30,7 +30,7 @@ def send_email(mail_to: str, mail_content: str, meal: str, **kwargs: Any) -> boo return is_ok -def concatenate_str(list_str: list[Any], **kwargs: Any) -> str: +def concatenate_list(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) @@ -44,3 +44,8 @@ def do_nothing(**kwargs: Any) -> None: def compute_sum(value1: float, value2: float, **kwargs: Any) -> float: """Demo function: return sum of two values.""" return value1 + value2 + + +def concatenate(value1: str, value2: str, **kwargs: Any) -> str: + """Demo function: return the concatenation of two strings.""" + return value1 + value2 diff --git a/tests/examples/failing_conf/wrong_rule_activation_mode/wrong_rule_activation_mode.yaml b/tests/examples/failing_conf/wrong_rule_activation_mode/wrong_rule_activation_mode.yaml new file mode 100644 index 0000000..9fd37c0 --- /dev/null +++ b/tests/examples/failing_conf/wrong_rule_activation_mode/wrong_rule_activation_mode.yaml @@ -0,0 +1,47 @@ +--- +rule_activation_mode: dummy-value + +actions_source_modules: + - tests.examples.code.actions + +rules: + default_rule_set: + rg_1: + RULE_1: + simple_condition: input.power=="strength" or input.power=="fly" or input.power=="time-manipulation" + action: concatenate + action_parameters: + value1: "" + value2: a + RULE_2: + simple_condition: null + action: concatenate + action_parameters: + value1: "" + value2: b + rg_2: + RULE_1: + simple_condition: input.language=="english" and input.age!=None + action: concatenate + action_parameters: + value1: output.rg_1 + value2: c + RULE_2: + simple_condition: input.age>=100 or input.age==None + action: concatenate + action_parameters: + value1: output.rg_1 + value2: d + RULE_3: + simple_condition: input.language=="french" + action: concatenate + action_parameters: + value1: output.rg_1 + value2: e + rg_3: + RULE_1: + simple_condition: input.favorite_meal!=None + action: concatenate + action_parameters: + value1: output.rg_2 + value2: f diff --git a/tests/examples/ignore_conf/rules.yaml b/tests/examples/ignore_conf/rules.yaml index 94d7486..d4d6750 100644 --- a/tests/examples/ignore_conf/rules.yaml +++ b/tests/examples/ignore_conf/rules.yaml @@ -5,7 +5,7 @@ rules: test_action: TEST_IGNORE: condition: null - action: concatenate_str + action: concatenate_list action_parameters: list_str: - "My name is " diff --git a/tests/examples/process_execution/simple_process.yaml b/tests/examples/process_execution/simple_process.yaml new file mode 100644 index 0000000..4f5087d --- /dev/null +++ b/tests/examples/process_execution/simple_process.yaml @@ -0,0 +1,41 @@ +--- +rule_activation_mode: many_by_group # default is 'one_by_group' + +actions_source_modules: + - tests.examples.code.actions + +rules: + default_rule_set: + gr_1: + TASK_1: + action: concatenate + action_parameters: + value1: a + value2: "" + TASK_2: + action: concatenate + action_parameters: + value1: output.gr_1 + value2: b + gr_2: + TASK_1: + action: concatenate + action_parameters: + value1: output.gr_1 + value2: c + TASK_2: + action: concatenate + action_parameters: + value1: output.gr_2 + value2: d + TASK_3: + action: concatenate + action_parameters: + value1: output.gr_2 + value2: e + gr_3: + TASK_1: + action: concatenate + action_parameters: + value1: output.gr_2 + value2: f diff --git a/tests/examples/rule_activation_mode/rules_many_by_group.yaml b/tests/examples/rule_activation_mode/rules_many_by_group.yaml new file mode 100644 index 0000000..f42184e --- /dev/null +++ b/tests/examples/rule_activation_mode/rules_many_by_group.yaml @@ -0,0 +1,47 @@ +--- +rule_activation_mode: many_by_group # default is 'one_by_group' + +actions_source_modules: + - tests.examples.code.actions + +rules: + default_rule_set: + rg_1: + RULE_1: + simple_condition: input.power=="strength" or input.power=="fly" or input.power=="time-manipulation" + action: concatenate + action_parameters: + value1: "" + value2: a + RULE_2: + simple_condition: null + action: concatenate + action_parameters: + value1: "" + value2: b + rg_2: + RULE_1: + simple_condition: input.language=="english" and input.age!=None + action: concatenate + action_parameters: + value1: output.rg_1 + value2: c + RULE_2: + simple_condition: input.age>=100 or input.age==None + action: concatenate + action_parameters: + value1: output.rg_1 + value2: d + RULE_3: + simple_condition: input.language=="french" + action: concatenate + action_parameters: + value1: output.rg_1 + value2: e + rg_3: + RULE_1: + simple_condition: input.favorite_meal!=None + action: concatenate + action_parameters: + value1: output.rg_2 + value2: f diff --git a/tests/examples/simple_condition/math/rules_simpl_cond_math.yaml b/tests/examples/simple_condition/math/rules_simpl_cond_math.yaml index 0167666..fa8cc4a 100644 --- a/tests/examples/simple_condition/math/rules_simpl_cond_math.yaml +++ b/tests/examples/simple_condition/math/rules_simpl_cond_math.yaml @@ -9,7 +9,7 @@ rules: add: GREATER: simple_condition: input.a+input.b>input.threshold - action: concatenate_str + action: concatenate_list action_parameters: list_str: - greater @@ -17,7 +17,7 @@ rules: - threshold LESS: simple_condition: input.a+input.b<=input.threshold - action: concatenate_str + action: concatenate_list action_parameters: list_str: - less or equal @@ -26,7 +26,7 @@ rules: sub: GREATER: simple_condition: input.a-input.b>input.threshold - action: concatenate_str + action: concatenate_list action_parameters: list_str: - greater @@ -34,7 +34,7 @@ rules: - threshold LESS: simple_condition: input.a-input.b<=input.threshold - action: concatenate_str + action: concatenate_list action_parameters: list_str: - less or equal @@ -43,7 +43,7 @@ rules: mul: GREATER: simple_condition: input.a*input.b>input.threshold - action: concatenate_str + action: concatenate_list action_parameters: list_str: - greater @@ -51,7 +51,7 @@ rules: - threshold LESS: simple_condition: input.a*input.b<=input.threshold - action: concatenate_str + action: concatenate_list action_parameters: list_str: - less or equal @@ -60,7 +60,7 @@ rules: div: GREATER: simple_condition: input.a/input.b>input.threshold - action: concatenate_str + action: concatenate_list action_parameters: list_str: - greater @@ -68,7 +68,7 @@ rules: - threshold LESS: simple_condition: input.a/input.b<=input.threshold - action: concatenate_str + action: concatenate_list action_parameters: list_str: - less or equal @@ -77,26 +77,26 @@ rules: equal_1: "YES": simple_condition: input.a==input.b - action: concatenate_str + action: concatenate_list action_parameters: list_str: - "yes" "NO": simple_condition: input.a!=input.b - action: concatenate_str + action: concatenate_list action_parameters: list_str: - "no" equal_2: "YES": simple_condition: input.a==1.3 - action: concatenate_str + action: concatenate_list action_parameters: list_str: - "yes" "NO": simple_condition: input.a!=1.3 - action: concatenate_str + action: concatenate_list action_parameters: list_str: - "no" diff --git a/tests/examples/simple_condition/uppercase/rules_simple_cond_uppercase.yaml b/tests/examples/simple_condition/uppercase/rules_simple_cond_uppercase.yaml index ddf4458..b1b47a6 100644 --- a/tests/examples/simple_condition/uppercase/rules_simple_cond_uppercase.yaml +++ b/tests/examples/simple_condition/uppercase/rules_simple_cond_uppercase.yaml @@ -9,19 +9,19 @@ rules: uppercase: RULE_1: simple_condition: input.text=="SUPERHERO" or input.text=="SUPER HERO" or input.text=="SUPER_HERO" or input.text=="SUPER-HERO" - action: concatenate_str + action: concatenate_list action_parameters: list_str: - OK camelcase: RULE_1: simple_condition: input.streetNumber>0 and input.streetName!="" and input.PostalCode>0 - action: concatenate_str + action: concatenate_list action_parameters: list_str: - OK RULE_2: - action: concatenate_str + action: concatenate_list action_parameters: list_str: - KO diff --git a/tests/examples/simple_condition/whitespace/rules_simple_cond_whitespace.yaml b/tests/examples/simple_condition/whitespace/rules_simple_cond_whitespace.yaml index d609d69..c60eddc 100644 --- a/tests/examples/simple_condition/whitespace/rules_simple_cond_whitespace.yaml +++ b/tests/examples/simple_condition/whitespace/rules_simple_cond_whitespace.yaml @@ -9,7 +9,7 @@ rules: whitespace: RULE_1: simple_condition: input.text=="super hero super hero" - action: concatenate_str + action: concatenate_list action_parameters: list_str: - OK diff --git a/tests/test_example_code/test_actions.py b/tests/test_example_code/test_actions.py index f05c98e..442e003 100644 --- a/tests/test_example_code/test_actions.py +++ b/tests/test_example_code/test_actions.py @@ -49,9 +49,9 @@ def test_send_email(mail_to, mail_content, meal, expected): assert result == expected -def test_concatenate_str(): +def test_concatenate_list(): """Action function unit test.""" - result = actions.concatenate_str(["a", "b", "c"]) + result = actions.concatenate_list(["a", "b", "c"]) assert result == "abc" @@ -65,3 +65,9 @@ def test_compute_sum(): """Action function unit test.""" result = actions.compute_sum(2, 3) assert result == 5 + + +def test_concatenate(): + """Action function unit test.""" + result = actions.concatenate("a", "b") + assert result == "ab" diff --git a/tests/unit/test_engine_errors.py b/tests/unit/test_engine_errors.py index 9893cd4..fa3273a 100644 --- a/tests/unit/test_engine_errors.py +++ b/tests/unit/test_engine_errors.py @@ -149,6 +149,11 @@ "failing_conf/wrong_parsing_error_strategy/", pydantic.ValidationError, ), + ( + None, + "failing_conf/wrong_rule_activation_mode/", + pydantic.ValidationError, + ), ], ) def test_instance_error(rules_dict, config_dir, expected_error, base_config_path): diff --git a/tests/unit/test_rule_activation_mode.py b/tests/unit/test_rule_activation_mode.py new file mode 100644 index 0000000..6dd780c --- /dev/null +++ b/tests/unit/test_rule_activation_mode.py @@ -0,0 +1,57 @@ +"""UT of the rule activation mode.""" + +import os + +import pytest + +from arta import RulesEngine + + +@pytest.mark.parametrize( + "input_data, config_dir, good_results", + [ + ( + { + "age": 100, + "language": "french", + "power": "strength", + "favorite_meal": "Spinach", + }, + "rule_activation_mode", + { + "rg_1": "b", + "rg_2": "be", + "rg_3": "bef", + }, + ), + ( + { + "age": None, + "language": "english", + "power": "strength", + "favorite_meal": None, + }, + "rule_activation_mode", + { + "rg_1": "b", + "rg_2": "bd", + "rg_3": None, + }, + ), + ( + {"key": "value"}, + "process_execution", + { + "gr_1": "ab", + "gr_2": "abcde", + "gr_3": "abcdef", + }, + ), + ], +) +def test_many_by_group(input_data, config_dir, good_results, base_config_path): + """Unit test of the regular case""" + 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