diff --git a/CHANGELOG.md b/CHANGELOG.md index f95769b..944e159 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,11 +4,15 @@ 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.10.4 - October, 2025 +## 0.11.0 - October, 2025 + +### Features + +* Add some logs. ### Maintenance -* Use [uv](https://docs.astral.sh/uv/) in CI/CD (`uv.lock` voluntarily in `.gitignore`). +* Use [uv](https://docs.astral.sh/uv/) in ci/cd (`uv.lock` voluntarily in `.gitignore`). ## 0.10.3 - July, 2025 diff --git a/README.md b/README.md index 63af951..399a703 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@
-
+
diff --git a/docs/pages/home.md b/docs/pages/home.md
index 9af430c..8cc07f3 100644
--- a/docs/pages/home.md
+++ b/docs/pages/home.md
@@ -14,12 +14,12 @@ Want to discover what is **Arta**? :arrow_right: [Get Started](a_simple_example.
Want to know how to use it? :arrow_right: [User Guide](how_to.md)
-!!! info inline "New feature"
+!!! info inline "News"
- Use [value sharing](value_sharing.md) to customize actions and conditions :tools:
+ The lastest release improved **logging** which can help debug your *engine* execution :glasses:
!!! tip "Releases"
-
+
Check the [Release notes](https://github.com/MAIF/arta/releases) :rocket:
!!! warning "Pydantic v1"
diff --git a/pyproject.toml b/pyproject.toml
index 8ebde75..cd12229 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "arta"
-version = "0.10.4"
+version = "0.11.0"
requires-python = ">=3.9"
description = "A Python Rules Engine - Make rule handling simple"
readme = "README.md"
@@ -45,7 +45,7 @@ Repository = "https://github.com/MAIF/arta"
[project.optional-dependencies]
all = ["arta[test,doc]"]
test = ["pytest", "pytest-cov", "tox", "tox-uv"]
-doc = ["mkdocs-material", "mkdocstrings[python]"]
+doc = ["mkdocs-material", "mkdocstrings[python]", "click<8.3.0"]
[dependency-groups]
dev = [
diff --git a/src/arta/_engine.py b/src/arta/_engine.py
index 0bbd65d..d153d96 100644
--- a/src/arta/_engine.py
+++ b/src/arta/_engine.py
@@ -8,7 +8,9 @@
import copy
import importlib
import inspect
+import logging
from inspect import getmembers, isclass, isfunction
+from pathlib import Path
from types import FunctionType, MethodType, ModuleType
from typing import Any, Callable
@@ -18,6 +20,8 @@
from arta.rule import Rule
from arta.utils import ParsingErrorStrategy, RuleActivationMode
+logger: logging.Logger = logging.getLogger(__name__)
+
class RulesEngine:
"""The Rules Engine is in charge of executing different groups of rules of a given rule set on user input data.
@@ -52,7 +56,7 @@ def __init__(
self,
*,
rules_dict: dict[str, dict[str, Any]] | None = None,
- config_path: str | None = None,
+ config_path: Path | str | None = None,
config_dict: dict[str, Any] | None = None,
) -> None:
"""Initialize the rules.
@@ -61,7 +65,7 @@ def __init__(
Args:
rules_dict: A dictionary containing the rules' definitions.
- config_path: Path of a directory containing the YAML files.
+ config_path: Path to the directory containing the YAML files.
config_dict: A dictionary containing the configuration (same as YAML files but already
parsed in a dictionary).
@@ -77,9 +81,9 @@ def __init__(
given_params: list[bool] = [config_path is not None, rules_dict is not None, config_dict is not None]
if given_params.count(True) != 1:
- raise ValueError(
- "RulesEngine takes one (and only one) parameter: 'rules_dict' or 'config_path' or 'config_dict'."
- )
+ msg: str = "RulesEngine takes one (and only one) parameter: 'rules_dict' or 'config_path' or 'config_dict'."
+ logger.error(msg)
+ raise ValueError(msg)
# Init. default global settings (useful if not set, can't be set in the Pydantic model
# because of the rules dict mode)
@@ -94,9 +98,13 @@ def __init__(
# Edge cases data validation
if not isinstance(rules_dict, dict):
- raise TypeError(f"'rules_dict' must be dict type, not '{type(rules_dict)}'")
+ msg = f"'rules_dict' must be dict type, not '{type(rules_dict)}'."
+ logger.error(msg)
+ raise TypeError(msg)
elif len(rules_dict) == 0:
- raise KeyError("'rules_dict' couldn't be empty.")
+ msg = "'rules_dict' couldn't be empty."
+ logger.error(msg)
+ raise KeyError(msg)
# Attribute definition
self.rules: dict[str, dict[str, list[Rule]]] = self._adapt_user_rules_dict(rules_dict)
@@ -136,6 +144,8 @@ def __init__(
# User-defined/custom conditions
if config.condition_factory_mapping is not None and config.custom_classes_source_modules is not None:
+ logger.info("Custom condition configuration detected.")
+
# 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
@@ -160,6 +170,10 @@ def __init__(
factory_mapping_classes=factory_mapping_classes,
)
+ logger.info(
+ f"Rules engine correctly instanciated with '{str(self._parsing_error_strategy)}' and '{str(self._rule_activation_mode)}'"
+ )
+
def apply_rules(
self,
input_data: dict[str, Any],
@@ -196,15 +210,23 @@ def apply_rules(
RuleExecutionError: A rule fails during execution.
ConditionExecutionError: A condition fails during execution.
"""
+ rule_count: int = 0
+
# Input_data validation
if not isinstance(input_data, dict):
- raise TypeError(f"'input_data' must be dict type, not '{type(input_data)}'")
+ msg: str = f"'input_data' must be dict type, not '{type(input_data)}'."
+ logger.error(msg)
+ raise TypeError(msg)
elif len(input_data) == 0:
- raise KeyError("'input_data' couldn't be empty.")
+ msg = "'input_data' couldn't be empty."
+ logger.error(msg)
+ raise KeyError(msg)
# 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()
+ if len(ignored_ids) > 0:
+ logger.info(f"Configured ignored rules are: {ignored_ids}")
# Prepare the result key
input_data_copy["output"] = {}
@@ -214,17 +236,22 @@ def apply_rules(
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
+ logger.info(f"Rules engine is running with the following rule set: '{rule_set}', verbose: {verbose}")
+
# 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())}."
- )
+ msg = f"Rule set '{rule_set}' not found in the rules, available rule sets are : {list(self.rules.keys())}."
+ logger.error(msg)
+ raise KeyError(msg)
# 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():
+ group_rule_count: int = 0
+ logger.debug(f"Entering rule group: {group_id}")
+
# Initialize result of the rule group with None
results_dict[group_id] = None
@@ -234,6 +261,10 @@ def apply_rules(
# Ignore that rule
continue
+ rule_count += 1
+ group_rule_count += 1
+ logger.debug(f"Evaluating rule '{group_rule_count}': {rule._rule_id}")
+
# Apply rules
action_result, rule_details = rule.apply(
input_data_copy, parsing_error_strategy=self._parsing_error_strategy, **kwargs
@@ -256,6 +287,7 @@ def apply_rules(
if not verbose:
results_dict.pop("verbosity")
+ logger.info(f"'{rule_count}' rules were correctly evaluated against input data.")
return results_dict
@staticmethod
@@ -327,7 +359,9 @@ def _build_rules(
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}")
+ msg: str = f"Unknwown action function : {action_function_name}"
+ logger.error(msg)
+ raise KeyError(msg)
action: Callable = action_functions[action_function_name]
@@ -382,7 +416,9 @@ def _build_std_conditions(
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}")
+ msg: str = f"Unknwown validation function : {validation_function_name}"
+ logger.error(msg)
+ raise KeyError(msg)
# Get Callable from function name
validation_function: Callable = condition_functions_dict[validation_function_name]
diff --git a/src/arta/condition.py b/src/arta/condition.py
index 01b26c6..689fd42 100644
--- a/src/arta/condition.py
+++ b/src/arta/condition.py
@@ -6,6 +6,7 @@
from __future__ import annotations
import inspect
+import logging
import re
from abc import ABC, abstractmethod
from typing import Any, Callable
@@ -13,6 +14,8 @@
from arta.exceptions import ConditionExecutionError
from arta.utils import ParsingErrorStrategy, parse_dynamic_parameter
+logger: logging.Logger = logging.getLogger(__name__)
+
class BaseCondition(ABC):
"""Base class of a Condition object (Strategy Pattern).
@@ -128,10 +131,14 @@ def verify(self, input_data: dict[str, Any], parsing_error_strategy: ParsingErro
AttributeError: Check the validation function or its parameters.
"""
if self._validation_function is None:
- raise AttributeError("Validation function should not be None")
+ msg: str = "Validation function should not be None"
+ logger.error(msg)
+ raise AttributeError(msg)
if self._validation_function_parameters is None:
- raise AttributeError("Validation function parameters should not be None")
+ msg = "Validation function parameters should not be None"
+ logger.error(msg)
+ raise AttributeError(msg)
# Parse dynamic parameters
parameters: dict[str, Any] = {}
@@ -148,7 +155,9 @@ def verify(self, input_data: dict[str, Any], parsing_error_strategy: ParsingErro
parameters.update(kwargs)
# Run validation_function
- return self._validation_function(**parameters)
+ result: bool = self._validation_function(**parameters)
+ logger.debug(f"'{self._condition_id}' verification result is: {result}")
+ return result
class SimpleCondition(BaseCondition):
@@ -207,16 +216,20 @@ def verify(self, input_data: dict[str, Any], parsing_error_strategy: ParsingErro
bool_var = eval(unitary_expr, None, locals_ns) # noqa
except TypeError:
# Ignore evaluation --> False
+ logger.warning(f"Condition '{self._condition_id}' is ignored because of the parameter's type.")
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}'")
+ msg = f"Error when verifying simple condition: '{unitary_expr}'"
+ logger.error(msg)
+ raise ConditionExecutionError(msg)
else:
# Other case: ignore, default value => return False
pass
+ logger.debug(f"'{self._condition_id}' verification result is: {bool_var}")
return bool_var
def get_sanitized_id(self) -> str:
diff --git a/src/arta/config.py b/src/arta/config.py
index 9ced71d..fd1cd24 100644
--- a/src/arta/config.py
+++ b/src/arta/config.py
@@ -8,11 +8,11 @@
from omegaconf import DictConfig, ListConfig, OmegaConf
-def load_config(config_dir_path: str) -> dict[str, Any]:
+def load_config(config_dir_path: 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.
+ config_dir_path: Path to a directory containing YAML files.
prefix: Prefix for the rglob pattern.
exclude_pattern: Regex pattern to exclude files.
diff --git a/src/arta/rule.py b/src/arta/rule.py
index 3068e89..0406349 100644
--- a/src/arta/rule.py
+++ b/src/arta/rule.py
@@ -6,6 +6,7 @@
from __future__ import annotations
import inspect
+import logging
import re
from typing import Any, Callable
from warnings import warn
@@ -17,6 +18,8 @@
parse_dynamic_parameter,
)
+logger: logging.Logger = logging.getLogger(__name__)
+
class Rule:
"""A rule is the combination of some conditions and one action.
@@ -96,6 +99,7 @@ def apply(
)
if is_conditions_ok:
+ logger.debug("Conditions are verified.")
try:
# Parse dynamic parameters
parameters: dict[str, Any] = {}
@@ -127,14 +131,19 @@ def apply(
parameters["input_data"] = input_data
parameters.update(kwargs)
+ logger.debug(f"Action '{self._action.__name__}' is triggered.")
+
# Run action
rule_results["action_result"] = self._action(**parameters)
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
+ msg: str = f"Error while executing rule '{self._rule_id}': {str(error)}"
+ logger.error(msg)
+ raise RuleExecutionError(msg) from error
else:
+ logger.debug("Conditions are not verified.")
return None, {}
def _check_conditions(
@@ -159,6 +168,8 @@ def _check_conditions(
for cond_conf_key, expr in self._condition_exprs.items():
condition_class: type[BaseCondition] = self._condition_factory_mapping[cond_conf_key]
+ logger.debug(f"Verifying '{cond_conf_key}': {expr}")
+
# Evaluate the condition expression
try:
condition_res, unitary_res = self._evaluate_condition_expr(
@@ -169,7 +180,9 @@ def _check_conditions(
**kwargs,
)
except NameError as e:
- raise RuleExecutionError(f"Error during evaluation of '{cond_conf_key}: {expr}': {str(e)}") from e
+ msg: str = f"Error during evaluation of '{cond_conf_key}: {expr}': {str(e)}"
+ logger.error(msg)
+ raise RuleExecutionError(msg) from e
# Combine conditions (AND)
all_conditions_res = all_conditions_res and condition_res
@@ -236,7 +249,9 @@ def _evaluate_condition_expr(
# 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
+ msg: str = f"Error while executing condition '{cond_id}': {str(error)}"
+ logger.error(msg)
+ raise ConditionExecutionError(msg) from error
# Replace the result in the boolean expression
sanit_cond_id: str = condition.get_sanitized_id()
@@ -290,8 +305,8 @@ def _instantiate_conditions(
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
+ msg: str = f"Following condition id is unknown '{cond_id}' in {conf_key}: {expr}"
+ logger.error(msg)
+ raise KeyError(msg) from error
return cond_instances
diff --git a/src/arta/utils.py b/src/arta/utils.py
index f429889..aca87a4 100644
--- a/src/arta/utils.py
+++ b/src/arta/utils.py
@@ -3,10 +3,13 @@
from __future__ import annotations
import copy
+import logging
import re
from enum import Enum
from typing import Any
+logger: logging.Logger = logging.getLogger(__name__)
+
class ParsingErrorStrategy(str, Enum):
"""Define authorized error handling strategies when a key is missing in the input data."""
@@ -48,7 +51,9 @@ def get_value_in_nested_dict_from_path(path: str, nested_dict: dict[str, Any]) -
# 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.")
+ msg: str = f"Key {value} of path {path} not found in input data."
+ logger.error(msg)
+ raise KeyError(msg)
value = value[key]
return value
@@ -102,7 +107,9 @@ def parse_dynamic_parameter(
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
+ msg: str = f"Could not find path '{param_path}' in the input data: {str(error)}"
+ logger.error(msg)
+ raise KeyError(msg) from error
return parameter
diff --git a/tests/examples/ignored_rules/ignored_rules.yaml b/tests/examples/ignored_rules/ignored_rules.yaml
index 06d2efe..2078bd8 100644
--- a/tests/examples/ignored_rules/ignored_rules.yaml
+++ b/tests/examples/ignored_rules/ignored_rules.yaml
@@ -75,10 +75,3 @@ 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/unit/test_logs.py b/tests/unit/test_logs.py
new file mode 100644
index 0000000..3e26b9e
--- /dev/null
+++ b/tests/unit/test_logs.py
@@ -0,0 +1,30 @@
+import logging
+from pathlib import Path
+
+
+from arta import RulesEngine
+
+
+def test_info_logs(base_config_path, caplog):
+ """Only logs at INFO level are tested."""
+ caplog.set_level(logging.INFO, logger="arta")
+
+ config_dir = Path(base_config_path) / "good_conf"
+
+ eng = RulesEngine(config_path=config_dir)
+
+ assert (
+ caplog.messages[-1]
+ == "Rules engine correctly instanciated with 'ParsingErrorStrategy.RAISE' and 'RuleActivationMode.ONE_BY_GROUP'"
+ )
+
+ input_data = {
+ "age": None,
+ "language": "french",
+ "powers": ["strength", "fly"],
+ "favorite_meal": "Spinach",
+ }
+
+ eng.apply_rules(input_data, rule_set="default_rule_set")
+
+ assert caplog.messages[-1] == "'4' rules were correctly evaluated against input data."