Skip to content

Commit

Permalink
feat: ignore rules
Browse files Browse the repository at this point in the history
* feat: add config_dict and splitted rules capabilities

Signed-off-by: develop-cs <[email protected]>

* chore: fix pre-commit warnings

Signed-off-by: develop-cs <[email protected]>

* docs: add feature explanations

Signed-off-by: develop-cs <[email protected]>

* docs: typos

Signed-off-by: develop-cs <[email protected]>

* tests: typos

Signed-off-by: develop-cs <[email protected]>

* ✨ Add ignore rule feature

* ✨ Add ignore rule feature

* chore: refactoring

Signed-off-by: develop-cs <[email protected]>

* ci: fix pre-commit hooks

Signed-off-by: develop-cs <[email protected]>

---------

Signed-off-by: develop-cs <[email protected]>
Signed-off-by: Charles <[email protected]>
Co-authored-by: develop-cs <[email protected]>
  • Loading branch information
HugoPerrier and develop-cs authored Jul 29, 2024
1 parent 3bcf4e6 commit 86fcd7c
Show file tree
Hide file tree
Showing 8 changed files with 375 additions and 139 deletions.
2 changes: 0 additions & 2 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 14 additions & 2 deletions src/arta/_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand All @@ -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"] = {}
Expand All @@ -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
Expand Down
84 changes: 84 additions & 0 deletions tests/examples/ignored_rules/ignored_rules.yaml
Original file line number Diff line number Diff line change
@@ -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: "[email protected]"
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"
Original file line number Diff line number Diff line change
@@ -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
178 changes: 46 additions & 132 deletions tests/unit/test_engine.py → tests/unit/test_engine_with_conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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):
Expand All @@ -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
Expand Down Expand Up @@ -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
Loading

0 comments on commit 86fcd7c

Please sign in to comment.