Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
</p>
<p align="center">
<img src="https://github.com/MAIF/arta/actions/workflows/ci-cd.yml/badge.svg?branch=main" alt="CI">
<img src="https://img.shields.io/badge/coverage-95%25-dark_green" alt="Coverage">
<img src="https://img.shields.io/badge/coverage-94%25-dark_green" alt="Coverage">
<img src="https://img.shields.io/pypi/v/arta" alt="Versions">
<img src="https://img.shields.io/pypi/pyversions/arta" alt="Python">
<img src="https://img.shields.io/pypi/implementation/arta" alt="Python implementation">
Expand Down
6 changes: 3 additions & 3 deletions docs/pages/home.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 = [
Expand Down
64 changes: 50 additions & 14 deletions src/arta/_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.
Expand Down Expand Up @@ -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.
Expand All @@ -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).

Expand All @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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],
Expand Down Expand Up @@ -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"] = {}
Expand All @@ -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

Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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]

Expand Down Expand Up @@ -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]
Expand Down
21 changes: 17 additions & 4 deletions src/arta/condition.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,16 @@
from __future__ import annotations

import inspect
import logging
import re
from abc import ABC, abstractmethod
from typing import Any, Callable

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).
Expand Down Expand Up @@ -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] = {}
Expand All @@ -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):
Expand Down Expand Up @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions src/arta/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Loading
Loading