From 86fcd7cd9c378ad16b5eb38604fb7e0a92142fff Mon Sep 17 00:00:00 2001 From: Hugo Perrier Date: Mon, 29 Jul 2024 15:39:15 +0200 Subject: [PATCH] feat: ignore rules * feat: add config_dict and splitted rules capabilities Signed-off-by: develop-cs <43383361+develop-cs@users.noreply.github.com> * chore: fix pre-commit warnings Signed-off-by: develop-cs <43383361+develop-cs@users.noreply.github.com> * docs: add feature explanations Signed-off-by: develop-cs <43383361+develop-cs@users.noreply.github.com> * docs: typos Signed-off-by: develop-cs <43383361+develop-cs@users.noreply.github.com> * tests: typos Signed-off-by: develop-cs <43383361+develop-cs@users.noreply.github.com> * :sparkles: Add ignore rule feature * :sparkles: Add ignore rule feature * chore: refactoring Signed-off-by: develop-cs <43383361+develop-cs@users.noreply.github.com> * ci: fix pre-commit hooks Signed-off-by: develop-cs <43383361+develop-cs@users.noreply.github.com> --------- Signed-off-by: develop-cs <43383361+develop-cs@users.noreply.github.com> Signed-off-by: Charles <43383361+develop-cs@users.noreply.github.com> Co-authored-by: develop-cs <43383361+develop-cs@users.noreply.github.com> --- .pre-commit-config.yaml | 2 - CHANGELOG.md | 1 + src/arta/_engine.py | 16 +- .../examples/ignored_rules/ignored_rules.yaml | 84 ++++++++ .../rules_simple_cond_ignored_rules.yaml | 26 +++ ...est_engine.py => test_engine_with_conf.py} | 178 +++++------------ tests/unit/test_engine_with_dict.py | 180 ++++++++++++++++++ tests/unit/test_simple_condition.py | 27 ++- 8 files changed, 375 insertions(+), 139 deletions(-) create mode 100644 tests/examples/ignored_rules/ignored_rules.yaml create mode 100644 tests/examples/simple_cond_conf/ignored_rules/rules_simple_cond_ignored_rules.yaml rename tests/unit/{test_engine.py => test_engine_with_conf.py} (72%) create mode 100644 tests/unit/test_engine_with_dict.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a548a83..616524f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -25,8 +25,6 @@ repos: exclude: ^(docs/) - id: check-added-large-files args: ['--maxkb=500'] - - id: no-commit-to-branch - args: ['--branch', 'main'] - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.5.4 hooks: diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c8618a..2af70e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Features * Add a new parameter `config_dict` in the `RulesEngine`'s constructor. It can be used when you have already loaded the YAML configuration in a dictionary and want to use it straightforward. +* Add a new parameter `ignored_rules` in the `apply_rules()` method. It can be used to easily disable a rule by its id. * Split a rule set in two (or more) files (keep the rules organized by their file names [alphabetically sorted]). ### Fixes diff --git a/src/arta/_engine.py b/src/arta/_engine.py index c3a1679..21e8012 100644 --- a/src/arta/_engine.py +++ b/src/arta/_engine.py @@ -63,7 +63,7 @@ def __init__( rules_dict: A dictionary containing the rules' definitions. config_path: Path of a directory containing the YAML files. config_dict: A dictionary containing the configuration (same as YAML files but already - parsed in a dictionary). + parsed in a dictionary). Raises: KeyError: Key not found. @@ -155,7 +155,13 @@ def __init__( ) def apply_rules( - self, input_data: dict[str, Any], *, rule_set: str | None = None, verbose: bool = False, **kwargs: Any + self, + input_data: dict[str, Any], + *, + rule_set: str | None = None, + ignored_rules: set[str] | None = None, + verbose: bool = False, + **kwargs: Any, ) -> dict[str, Any]: """Apply the rules and return results. @@ -170,6 +176,7 @@ def apply_rules( Args: input_data: Input data to apply rules on. rule_set: Apply rules associated with the specified rule set. + ignored_rules: A set/list of rule's ids to be ignored/disabled during evaluation. verbose: If True, add extra ids (group_id, rule_id) for result explicability. **kwargs: For user extra arguments. @@ -190,6 +197,7 @@ def apply_rules( # Var init. input_data_copy: dict[str, Any] = copy.deepcopy(input_data) + ignored_ids: set[str] = ignored_rules if ignored_rules is not None else set() # Prepare the result key input_data_copy["output"] = {} @@ -215,6 +223,10 @@ def apply_rules( # Rules' loop (inside a group) for rule in rules_list: + if rule._rule_id in ignored_ids: + # Ignore that rule + continue + # Apply rules action_result, rule_details = rule.apply( input_data_copy, parsing_error_strategy=self._parsing_error_strategy, **kwargs diff --git a/tests/examples/ignored_rules/ignored_rules.yaml b/tests/examples/ignored_rules/ignored_rules.yaml new file mode 100644 index 0000000..06d2efe --- /dev/null +++ b/tests/examples/ignored_rules/ignored_rules.yaml @@ -0,0 +1,84 @@ +--- +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 + +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 + + +conditions_source_modules: + - "tests.examples.code.conditions" +actions_source_modules: + - "tests.examples.code.actions" + +parsing_error_strategy: raise + +custom_classes_source_modules: + - "tests.examples.code.custom_class" +condition_factory_mapping: + custom_condition: "CustomCondition" diff --git a/tests/examples/simple_cond_conf/ignored_rules/rules_simple_cond_ignored_rules.yaml b/tests/examples/simple_cond_conf/ignored_rules/rules_simple_cond_ignored_rules.yaml new file mode 100644 index 0000000..dab6892 --- /dev/null +++ b/tests/examples/simple_cond_conf/ignored_rules/rules_simple_cond_ignored_rules.yaml @@ -0,0 +1,26 @@ +--- +# Global settings +actions_source_modules: + - "tests.examples.code.actions" + +# Rule sets for simple conditions tests +rules: + default_rule_set: + admission: + IGNORED_RULE_1: + simple_condition: input.power=="strength" + action: set_admission + action_parameters: + value: true + IGNORED_RULE_2: + simple_condition: input.dummy>1 + action: set_admission + action_parameters: + value: true + ADM_KO: + simple_condition: null + action: set_admission + action_parameters: + value: false + +parsing_error_strategy: raise diff --git a/tests/unit/test_engine.py b/tests/unit/test_engine_with_conf.py similarity index 72% rename from tests/unit/test_engine.py rename to tests/unit/test_engine_with_conf.py index 9e75710..ac8efc4 100644 --- a/tests/unit/test_engine.py +++ b/tests/unit/test_engine_with_conf.py @@ -18,29 +18,6 @@ def test_instanciation(base_config_path): eng_2 = RulesEngine(config_dict=config_dict) assert isinstance(eng_2, 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_3 = RulesEngine(rules_dict=raw_rules) - assert isinstance(eng_3, RulesEngine) - @pytest.mark.parametrize( "input_data, config_dir, rule_set, verbose, good_results", @@ -280,6 +257,22 @@ def test_instanciation(base_config_path): "email": True, }, ), + ( + { + "age": None, + "language": "french", + "powers": ["strength", "fly"], + "favorite_meal": "Spinach", + }, + "ignored_rules", + "default_rule_set", + False, + { + "admission": {"admission": True}, + "course": {"course_id": "senior"}, + "email": True, + }, + ), ], ) def test_conf_apply_rules(input_data, config_dir, rule_set, verbose, good_results, base_config_path): @@ -295,115 +288,6 @@ def test_conf_apply_rules(input_data, config_dir, rule_set, verbose, good_result assert res_1 == res_2 == 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 @@ -440,3 +324,33 @@ def test_kwargs_in_apply_rules(input_data, good_results, base_config_path): res = eng.apply_rules(input_data, rule_set="fourth_rule_set", my_parameter="super@connection") assert res == good_results + + +@pytest.mark.parametrize( + "input_data, config_dir, rule_set, ignored_rules, good_results", + [ + ( + { + "age": None, + "language": "french", + "powers": ["strength", "fly"], + "favorite_meal": "Spinach", + }, + "ignored_rules", + "default_rule_set", + {"ADM_OK"}, + { + "admission": {"admission": False}, + "course": {"course_id": "senior"}, + "email": True, + }, + ), + ], +) +def test_conf_ignored_rules(input_data, config_dir, rule_set, ignored_rules, good_results, base_config_path): + """UT of ignored rules.""" + path = os.path.join(base_config_path, config_dir) + eng = RulesEngine(config_path=path) + res = eng.apply_rules(input_data=input_data, rule_set=rule_set, ignored_rules=ignored_rules) + + assert res == good_results diff --git a/tests/unit/test_engine_with_dict.py b/tests/unit/test_engine_with_dict.py new file mode 100644 index 0000000..8e8b1ab --- /dev/null +++ b/tests/unit/test_engine_with_dict.py @@ -0,0 +1,180 @@ +"""RulesEngine class UT.""" + +import pytest +from arta import RulesEngine + + +def test_instanciation(base_config_path): + """Unit test of the class 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 = RulesEngine(rules_dict=raw_rules) + assert isinstance(eng, RulesEngine) + + +@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_ignored_rules(): + """Unit test of the method RulesEngine.apply_rules() when there are rules to ignore""" + rules_raw = { + "power_level": { + "ignored_1": { + "condition": None, + "condition_parameters": None, + "action": lambda x, **kwargs: x, + "action_parameters": {"x": "ignored_1"}, + }, + "ignored_2": { + "condition": lambda p: p in ["juggle", "sing", "sleep"], + "condition_parameters": {"p": "input.power"}, + "action": lambda x, **kwargs: x, + "action_parameters": {"x": "ignored_2"}, + }, + "no_power": { + "condition": None, + "condition_parameters": None, + "action": lambda x, **kwargs: x, + "action_parameters": {"x": "no_power"}, + }, + }, + } + + input_data = { + "age": 5000, + "language": "english", + "power": "juggle", + "favorite_meal": None, + "weapons": ["Magic lasso", "Bulletproof bracelets", "Sword", "Shield"], + } + + expected_results = {"power_level": "no_power"} + + eng_2 = RulesEngine(rules_dict=rules_raw) + res = eng_2.apply_rules(input_data, ignored_rules={"ignored_1", "ignored_2"}, verbose=False) + + assert res == expected_results diff --git a/tests/unit/test_simple_condition.py b/tests/unit/test_simple_condition.py index e277aff..441edaf 100644 --- a/tests/unit/test_simple_condition.py +++ b/tests/unit/test_simple_condition.py @@ -8,7 +8,7 @@ @pytest.mark.parametrize( - "input_data, config_dir, good_results", + "input_data, config_dir, ignored_rules, good_results", [ ( { @@ -18,6 +18,7 @@ "favorite_meal": "Spinach", }, "simple_cond_conf/default", + None, {"admission": {"admission": True}, "course": {"course_id": "senior"}, "email": True}, ), ( @@ -28,6 +29,7 @@ "favorite_meal": None, }, "simple_cond_conf/default", + None, {"admission": {"admission": True}, "course": {"course_id": "english"}, "email": None}, ), ( @@ -38,6 +40,7 @@ "favorite_meal": "French Fries", }, "simple_cond_conf/default", + None, {"admission": {"admission": False}, "course": {"course_id": "senior"}, "email": None}, ), ( @@ -48,6 +51,7 @@ "favorite_meal": "Spinach", }, "simple_cond_conf/ignore", + None, {"admission": {"admission": True}, "course": {"course_id": "senior"}, "email": True}, ), ( @@ -58,6 +62,7 @@ "favorite_meal": "Spinach", }, "simple_cond_conf/wrong/ignore", + None, {"admission": {"admission": True}, "course": {"course_id": "senior"}, "email": True}, ), ( @@ -65,6 +70,7 @@ "text": "super hero super hero", }, "simple_cond_conf/whitespace", + None, {"whitespace": "OK"}, ), ( @@ -72,15 +78,30 @@ "text": "SUPER HERO", }, "simple_cond_conf/uppercase", + None, {"uppercase": "OK"}, ), + ( + { + "text": "SUPER HERO", + }, + "simple_cond_conf/uppercase", + set(), + {"uppercase": "OK"}, + ), + ( + {"age": 100, "power": "strength"}, + "simple_cond_conf/ignored_rules", + {"IGNORED_RULE_1", "IGNORED_RULE_2"}, + {"admission": {"admission": False}}, + ), ], ) -def test_simple_condition(input_data, config_dir, good_results, base_config_path): +def test_simple_condition(input_data, config_dir, ignored_rules, 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) + res = eng.apply_rules(input_data=input_data, ignored_rules=ignored_rules) assert res == good_results