From 2c624c8e4bc78e4f4c46f086f9d7b73570e24018 Mon Sep 17 00:00:00 2001 From: develop-cs <43383361+develop-cs@users.noreply.github.com> Date: Mon, 6 Oct 2025 16:36:33 +0200 Subject: [PATCH 1/2] feat: add some logs Signed-off-by: develop-cs <43383361+develop-cs@users.noreply.github.com> --- CHANGELOG.md | 6 +- pyproject.toml | 2 +- src/arta/_engine.py | 59 +++++++++++++++---- src/arta/condition.py | 21 +++++-- src/arta/rule.py | 27 +++++++-- src/arta/utils.py | 11 +++- .../examples/ignored_rules/ignored_rules.yaml | 7 --- 7 files changed, 100 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f95769b..12483c1 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.10.4 - October, 2025 +## 0.11.0 - October, 2025 + +#### Features + +* Add logs. ### Maintenance diff --git a/pyproject.toml b/pyproject.toml index 8ebde75..67183d3 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" diff --git a/src/arta/_engine.py b/src/arta/_engine.py index 0bbd65d..d8085fb 100644 --- a/src/arta/_engine.py +++ b/src/arta/_engine.py @@ -8,6 +8,7 @@ import copy import importlib import inspect +import logging from inspect import getmembers, isclass, isfunction from types import FunctionType, MethodType, ModuleType from typing import Any, Callable @@ -18,6 +19,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. @@ -77,9 +80,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 +97,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 +143,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 +169,10 @@ def __init__( factory_mapping_classes=factory_mapping_classes, ) + logger.info( + f"Rules engine correctly instanciated with '{self._parsing_error_strategy}' and '{self._rule_activation_mode}'" + ) + def apply_rules( self, input_data: dict[str, Any], @@ -196,15 +209,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 +235,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 +260,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 +286,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 +358,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 +415,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/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" From fa81952035693667fe59fc718f3a719f0147d3b4 Mon Sep 17 00:00:00 2001 From: develop-cs <43383361+develop-cs@users.noreply.github.com> Date: Tue, 7 Oct 2025 17:43:18 +0200 Subject: [PATCH 2/2] test: add UT for logs (INFO) Signed-off-by: develop-cs <43383361+develop-cs@users.noreply.github.com> --- CHANGELOG.md | 6 +++--- README.md | 2 +- docs/pages/home.md | 6 +++--- pyproject.toml | 2 +- src/arta/_engine.py | 7 ++++--- src/arta/config.py | 4 ++-- tests/unit/test_logs.py | 30 ++++++++++++++++++++++++++++++ 7 files changed, 44 insertions(+), 13 deletions(-) create mode 100644 tests/unit/test_logs.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 12483c1..944e159 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,13 +6,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## 0.11.0 - October, 2025 -#### Features +### Features -* Add logs. +* 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 @@

CI - Coverage + Coverage Versions Python Python implementation 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 67183d3..cd12229 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 d8085fb..d153d96 100644 --- a/src/arta/_engine.py +++ b/src/arta/_engine.py @@ -10,6 +10,7 @@ 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 @@ -55,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. @@ -64,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). @@ -170,7 +171,7 @@ def __init__( ) logger.info( - f"Rules engine correctly instanciated with '{self._parsing_error_strategy}' and '{self._rule_activation_mode}'" + f"Rules engine correctly instanciated with '{str(self._parsing_error_strategy)}' and '{str(self._rule_activation_mode)}'" ) def apply_rules( 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/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."