Repository: MAIF/arta

Issue:
0.7.0 final release
**Describe the solution you'd like**:
Here are the different tasks that must be done to validate the beta version:

- [ ] SPDX references #13 
- [ ] Fix some docstrings
- [ ] Add unit tests for the "simple conditions" feature
- [ ] Variable renaming


Discussion:

Patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 66807e6..bff0bf2 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,8 +1,9 @@
+---
 default_language_version:
   python: python3
 repos:
   - repo: https://github.com/astral-sh/ruff-pre-commit
-    rev: v0.4.9
+    rev: v0.4.10
     hooks:
       - id: ruff
         args: [ --fix ]
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2096996..42ba294 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,7 +4,33 @@ 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.7.0] - June, 2024
+
+### Features
+
+* Configure your rules in a YAML file or use a straightforward python dictionary.
+* Use "[standard conditions](https://maif.github.io/arta/how_to/#standard-condition)" (YAML defined conditions) for flexibility.
+* Use "[simple conditions](https://maif.github.io/arta/how_to/#simple-condition)" (one-liner conditions) for simplicity (beta feature).
+* Use *math expressions* in "[simple conditions](https://maif.github.io/arta/how_to/#simple-condition)" (#25).
+* Implement your own "[custom conditions](https://maif.github.io/arta/special_conditions/#custom-condition)" (python classes) for adaptability.
+* Define your own parameter's [parsing strategy](https://maif.github.io/arta/parameters/#parsing-error) (raise, ignore, default value).
+* Use many [rule sets](https://maif.github.io/arta/rule_sets/) as you need.
+
+### Fixes
+
+* You can now use *whitespaces* in a string within a *simple condition* (e.g., `input.text=="super hero"`) (#23).
+
+
 ## [0.7.0b*] - April, 2024
 
-### New Features
-- Beta release implementing what you can find in its [documentation](https://maif.github.io/arta/home/).
+*Beta release*
+
+### Features
+
+* Configure your rules in a YAML file or use a straightforward python dictionary.
+* Use "[standard conditions](https://maif.github.io/arta/how_to/#standard-condition)" (YAML defined conditions) for flexibility.
+* Use "[simple conditions](https://maif.github.io/arta/how_to/#simple-condition)" (one-liner conditions) for simplicity (beta feature).
+* Implement your own "[custom conditions](https://maif.github.io/arta/special_conditions/#custom-condition)" (python classes) for adaptability.
+* Define your own parameter's [parsing strategy](https://maif.github.io/arta/parameters/#parsing-error) (raise, ignore, default value).
+* Use many [rule sets](https://maif.github.io/arta/rule_sets/) as you need.
diff --git a/README.md b/README.md
index a931d60..277fce4 100644
--- a/README.md
+++ b/README.md
@@ -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-97%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/dm/arta" alt="Downloads">
diff --git a/docs/mkdocs.yaml b/docs/mkdocs.yaml
index 5237772..0952d4a 100644
--- a/docs/mkdocs.yaml
+++ b/docs/mkdocs.yaml
@@ -85,7 +85,7 @@ nav:
   - Advanced User Guide: 
     - Parameters: parameters.md
     - Rule sets: rule_sets.md
-    - Special conditions: special_conditions.md
+    - Custom conditions: custom_conditions.md
     - API Reference: api_reference.md
 extra_css:
   - assets/css/mkdocs_extra.css
\ No newline at end of file
diff --git a/docs/pages/a_simple_example.md b/docs/pages/a_simple_example.md
index defef39..3f104ac 100644
--- a/docs/pages/a_simple_example.md
+++ b/docs/pages/a_simple_example.md
@@ -149,7 +149,7 @@ def send_email(mail_to: str, mail_content: str, meal: str, **kwargs: Any) -> boo
 
 ### Engine
 
-The *rules engine* is responsible for evaluating the [configured rules](#rules-definition) against some *data* (usually named *"input data"*). 
+The *rules engine* is responsible for evaluating the [configured rules](#rules) against some *data* (usually named *"input data"*). 
 
 In our use case, the input data could be a list of applicants:
 
@@ -220,7 +220,7 @@ In the **rules engine** result, we have 3 outputs:
 * `"course": {"course_id": "senior"},`
 * `"favorite_meal": "sent"` 
 
-Each corresponds to one of these [rules](#quick-example-the-superhero-school).
+Each corresponds to one of these [rules](#the-superhero-school).
 
 Here, we can apply the rules to all the **data set** (3 applicants) with a simple dictionary comprehension:
 
diff --git a/docs/pages/special_conditions.md b/docs/pages/custom_conditions.md
similarity index 98%
rename from docs/pages/special_conditions.md
rename to docs/pages/custom_conditions.md
index fe82a4a..738db89 100644
--- a/docs/pages/special_conditions.md
+++ b/docs/pages/custom_conditions.md
@@ -1,5 +1,3 @@
-## Custom condition
-
 **Custom conditions** are user-defined conditions. 
 
 A **custom condition** will impact the atomic evaluation of each **conditions** (i.e., condition ids).
@@ -39,7 +37,7 @@ With the **custom conditions** it's quite simple to implement.
 
     The main goal is to simplify handling of recurrent conditions (e.i., "recurrent" meaning very similar conditions).
 
-### Class implementation
+## Class implementation
 
 First, create a class inheriting from `BaseCondtion` and implement the `verify()` method as you want/need:
 
@@ -75,7 +73,7 @@ class MyCondition(BaseCondition):
         ParsingErrorStrategy.IGNORE
         ParsingErrorStrategy.DEFAULT_VALUE
 
-### Configuration
+## Configuration
 
 Last thing to do is to add your new **custom condition** in the configuration:
 
@@ -118,7 +116,7 @@ condition_factory_mapping:
 2. List of the modules containing custom classes
 3. Mapping between condition keys (`my_condition`) and custom classes (`MyCondition`)
 
-### Class diagram
+## Class diagram
 
 It is based on the following *strategy pattern*:
 
diff --git a/docs/pages/how_to.md b/docs/pages/how_to.md
index 4aaa1de..23eef18 100644
--- a/docs/pages/how_to.md
+++ b/docs/pages/how_to.md
@@ -61,14 +61,21 @@ How to write a simple condition like:
         * `input` (for input data)
         * `output` (for the previous rule's result)
     * A *dot path* expression like `input.powers.main_power`.
+    * Even a *math expression* like: `input.x*input.y>input.threshold` *(but without any whitespaces)*.
 * **Operator:** you must use basic python *boolean operator* (i.e., `==, <, >, <=, >=, !=`)
-* **Right operand:** basic python data types (e.i., `str, int, None`).
+* **Right operand:** basic python data types (e.i., `str, int, float, None`) or a *dot path* expression (e.g., `input.threshold`).
 
-!!! warning
+!!! tip
+
+    You can use simple **math expressions** in a *simple condition*:
+    
+    `simple_condition: input.x+input.y>input.threshold and input.x*input.y<123.4`
+
+!!! warning "Warnings"
 
-    * You can't use: `is` or `in`, as an **operator** (yet).
-    * You can't use a `float` as **right operand** (it's a bug, will be fixed).
-    * For strings, don't forget the **double quotes** `"`.
+    * You can only use `+`, `-`, `*`, `/` as **math operators**.
+    * You can't use `is` neither `in`, as a **boolean operator**.
+    * Don't forget the *double quotes* `"` for **strings**.
 
 !!! danger "Security concern"
 
@@ -78,6 +85,7 @@ How to write a simple condition like:
     
     * **You should never let the user** being able of dynamically define a *simple condition* (in `simple_condition:` conf. key).
     * You should verify that **write permissions on the YAML files** are not allowed when your app is deployed.
+    * You should implement **data validation** of your input data (e.g., with Pydantic).
 
 ## Standard condition
 
diff --git a/pyproject.toml b/pyproject.toml
index 0f8e8eb..53ae256 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,10 +4,11 @@ build-backend = "setuptools.build_meta"
 
 [project]
 name = "arta"
-version = "0.7.0b3"
+version = "0.7.0"
 requires-python = ">3.8.0"
 description = "An Open Source Rules Engine - Make rule handling simple"
 readme = "README.md"
+license = {text = "Apache-2.0"}
 authors = [
     {name = "develop-cs"},
     {name = "HugoPerrier"},
@@ -15,13 +16,19 @@ authors = [
 ]
 
 classifiers = [
-    "Development Status :: 4 - Beta",
+    "Development Status :: 5 - Production/Stable",
+    "Operating System :: OS Independent",
     "Intended Audience :: Developers",
+    "Intended Audience :: Information Technology",
     "Topic :: Software Development :: Libraries :: Python Modules",
+    "Programming Language :: Python",
+    "Programming Language :: Python :: 3",
+    "Programming Language :: Python :: 3 :: Only",
     "Programming Language :: Python :: 3.9",
     "Programming Language :: Python :: 3.10",
     "Programming Language :: Python :: 3.11",
     "Programming Language :: Python :: 3.12",
+    "License :: OSI Approved :: Apache Software License",
 ]
 
 dependencies = [
@@ -30,6 +37,11 @@ dependencies = [
     "urllib3!=2.2.1",
 ]
 
+[project.urls]
+Homepage = "https://maif.github.io/arta/"
+Documentation = "https://maif.github.io/arta/home/"
+Repository = "https://github.com/MAIF/arta"
+
 [project.optional-dependencies]
 all = ["arta[test,dev,doc,mypy,ruff]"]
 test = ["pytest", "tox", "pytest-cov"]
diff --git a/src/arta/_engine.py b/src/arta/_engine.py
index 2a878bb..18555c8 100644
--- a/src/arta/_engine.py
+++ b/src/arta/_engine.py
@@ -65,6 +65,7 @@ class RulesEngine:
         Raises:
             KeyError: Key not found.
             TypeError: Wrong type.
+            ValueError: Bad given parameters.
         """
         # Var init.
         factory_mapping_classes: dict[str, type[BaseCondition]] = {}
@@ -169,8 +170,10 @@ class RulesEngine:
             A dictionary containing the rule groups' results (k: group id, v: action result).
 
         Raises:
-            TypeError: Wrong type.
-            KeyError: Key not found.
+            TypeError: Wrong type (e.g., input_data is not a dictionary).
+            KeyError: Key not found (e.g., input_data is an empty dictionary).
+            RuleExecutionError: A rule fails during execution.
+            ConditionExecutionError: A condition fails during execution.
         """
         # Input_data validation
         if not isinstance(input_data, dict):
diff --git a/src/arta/condition.py b/src/arta/condition.py
index c616d9a..238b98e 100644
--- a/src/arta/condition.py
+++ b/src/arta/condition.py
@@ -26,7 +26,7 @@ class BaseCondition(ABC):
     """
 
     # Class constants
-    CONST_CONDITION_DATA_LABEL: str = "Custom condition data (not needed)"
+    CONDITION_DATA_LABEL: str = "Custom condition data (not needed)"
     CONDITION_ID_PATTERN: str = UPPERCASE_WORD_PATTERN
 
     def __init__(
@@ -45,10 +45,10 @@ class BaseCondition(ABC):
             validation_function: Validation function of a condition.
             validation_function_parameters: Arguments of the validation function.
         """
-        self._condition_id = condition_id  # NOSONAR
-        self._description = description  # NOSONAR
-        self._validation_function = validation_function  # NOSONAR
-        self._validation_function_parameters = validation_function_parameters  # NOSONAR
+        self._condition_id = condition_id
+        self._description = description
+        self._validation_function = validation_function
+        self._validation_function_parameters = validation_function_parameters
 
     @classmethod
     def extract_condition_ids_from_expression(cls, condition_expr: str | None = None) -> set[str]:
@@ -142,8 +142,8 @@ class SimpleCondition(BaseCondition):
     """
 
     # Class constants
-    CONST_CUSTOM_CONDITION_DATA_LABEL: str = "Simple condition data (not needed)"
-    CONDITION_ID_PATTERN: str = r"(?:input\.|output\.)(?:[a-z_\-0-9!=<>\"NTF\.]*)"
+    CUSTOM_CONDITION_DATA_LABEL: str = "Simple condition data (not needed)"
+    CONDITION_ID_PATTERN: str = r"(?:input\.|output\.)(?:[a-z_0-9!=<>\"NTF\.\*\+\-/]*)(?:[a-z\s]*\"|)"
 
     def verify(self, input_data: dict[str, Any], parsing_error_strategy: ParsingErrorStrategy, **kwargs: Any) -> bool:
         """Return True if the condition is verified.
@@ -164,26 +164,25 @@ class SimpleCondition(BaseCondition):
         bool_var: bool = False
         unitary_expr: str = self._condition_id
 
-        data_path_patt: str = r"(?:input\.|output\.)(?:[a-z_\-\.]*)"
+        data_path_patt: str = r"(?:input\.|output\.)(?:[a-z_\.]*)"
 
         # Retrieve only the data path
         path_matches: list[str] = re.findall(data_path_patt, unitary_expr)
 
-        if len(path_matches) == 1:
-            # Regular case: we have a data_path
-            data_path: str = path_matches[0]
+        if len(path_matches) > 0:
+            # Regular case: we have a data paths
+            for idx, path in enumerate(path_matches):
+                # Read data from the path
+                locals()[f"data_{idx}"] = parse_dynamic_parameter(  # noqa
+                    parameter=path, input_data=input_data, parsing_error_strategy=parsing_error_strategy
+                )
 
-            # Read data from its path
-            data = parse_dynamic_parameter(  # noqa
-                parameter=data_path, input_data=input_data, parsing_error_strategy=parsing_error_strategy
-            )
-
-            # Replace with the variable name in the expression
-            eval_expr: str = unitary_expr.replace(data_path, "data")
+                # Replace with the variable name in the expression
+                unitary_expr = unitary_expr.replace(path, f"data_{idx}")
 
             # Evaluate the expression
             try:
-                bool_var = eval(eval_expr)  # noqa
+                bool_var = eval(unitary_expr)  # noqa
             except TypeError:
                 # Ignore evaluation --> False
                 pass
diff --git a/src/arta/models.py b/src/arta/models.py
index e9d2339..d3044a9 100644
--- a/src/arta/models.py
+++ b/src/arta/models.py
@@ -54,7 +54,7 @@ class RulesConfig(pydantic.BaseModel):
 
     condition: Optional[str]
     simple_condition: Optional[str]
-    action: pydantic.constr(to_lower=True)  # type: ignore  # NOSONAR
+    action: pydantic.constr(to_lower=True)  # type: ignore
     action_parameters: Optional[Any]
 
     class Config:
@@ -69,7 +69,7 @@ class Configuration(pydantic.BaseModel):
     actions_source_modules: List[str]
     custom_classes_source_modules: Optional[List[str]]
     condition_factory_mapping: Optional[Dict[str, str]]
-    rules: Dict[str, Dict[str, Dict[pydantic.constr(to_upper=True), RulesConfig]]]  # type: ignore  # NOSONAR
+    rules: Dict[str, Dict[str, Dict[pydantic.constr(to_upper=True), RulesConfig]]]  # type: ignore
     parsing_error_strategy: Optional[ParsingErrorStrategy]
 
     class Config:
diff --git a/src/arta/rule.py b/src/arta/rule.py
index 3acd86e..68a9819 100644
--- a/src/arta/rule.py
+++ b/src/arta/rule.py
@@ -260,7 +260,7 @@ class Rule:
                     # Instanciate the custom (unknown) condition object
                     cond_instances[cond_id] = self._condition_factory_mapping[conf_key](
                         condition_id=cond_id,
-                        description=self._condition_factory_mapping[conf_key].CONST_CONDITION_DATA_LABEL,
+                        description=self._condition_factory_mapping[conf_key].CONDITION_DATA_LABEL,
                     )
             else:
                 # Should be a standard condition
diff --git a/src/arta/utils.py b/src/arta/utils.py
index 40fc1bb..7b98362 100644
--- a/src/arta/utils.py
+++ b/src/arta/utils.py
@@ -163,7 +163,9 @@ def sanitize_regex(pattern: str) -> str:
     """
     if re.search(UPPERCASE_WORD_PATTERN, pattern) is None:
         # Pattern is not like 'CONDITION_2' but like 'input.power=="fly"'
-        pattern = pattern.replace('"', r"\"").replace(".", r"\.")
+        pattern = (
+            pattern.replace('"', r"\"").replace(".", r"\.").replace("+", r"\+").replace("-", r"\-").replace("*", r"\*")
+        )
     else:
         pattern = rf"\b{pattern}\b"
 


Test patch:
diff --git a/tests/examples/simple_cond_conf/default/rules_simple_cond.yaml b/tests/examples/simple_cond_conf/default/rules_simple_cond.yaml
index 08a712e..0b6bc8b 100644
--- a/tests/examples/simple_cond_conf/default/rules_simple_cond.yaml
+++ b/tests/examples/simple_cond_conf/default/rules_simple_cond.yaml
@@ -8,7 +8,7 @@ rules:
   default_rule_set:
     admission:
       ADM_OK:
-        simple_condition: input.power=="strength" or input.power=="fly"
+        simple_condition: input.power=="strength" or input.power=="fly" or input.power=="time-manipulation"
         action: set_admission
         action_parameters:
           value: true      
diff --git a/tests/examples/simple_cond_conf/math/rules_simpl_cond_math.yaml b/tests/examples/simple_cond_conf/math/rules_simpl_cond_math.yaml
new file mode 100644
index 0000000..da12d32
--- /dev/null
+++ b/tests/examples/simple_cond_conf/math/rules_simpl_cond_math.yaml
@@ -0,0 +1,102 @@
+---
+# Global settings
+actions_source_modules:
+  - "tests.examples.code.actions"
+
+# Rule sets for simple conditions tests
+rules:
+  default_rule_set:
+    add:
+      GREATER:
+        simple_condition: input.a+input.b>input.threshold
+        action: concatenate_str
+        action_parameters:
+          list_str:
+            - greater
+            - " than "
+            - threshold
+      LESS:
+        simple_condition: input.a+input.b<=input.threshold
+        action: concatenate_str
+        action_parameters:
+          list_str:
+            - less or equal
+            - " than "
+            - threshold
+    sub:
+      GREATER:
+        simple_condition: input.a-input.b>input.threshold
+        action: concatenate_str
+        action_parameters:
+          list_str:
+            - greater
+            - " than "
+            - threshold
+      LESS:
+        simple_condition: input.a-input.b<=input.threshold
+        action: concatenate_str
+        action_parameters:
+          list_str:
+            - less or equal
+            - " than "
+            - threshold
+    mul:
+      GREATER:
+        simple_condition: input.a*input.b>input.threshold
+        action: concatenate_str
+        action_parameters:
+          list_str:
+            - greater
+            - " than "
+            - threshold
+      LESS:
+        simple_condition: input.a*input.b<=input.threshold
+        action: concatenate_str
+        action_parameters:
+          list_str:
+            - less or equal
+            - " than "
+            - threshold
+    div:
+      GREATER:
+        simple_condition: input.a/input.b>input.threshold
+        action: concatenate_str
+        action_parameters:
+          list_str:
+            - greater
+            - " than "
+            - threshold
+      LESS:
+        simple_condition: input.a/input.b<=input.threshold
+        action: concatenate_str
+        action_parameters:
+          list_str:
+            - less or equal
+            - " than "
+            - threshold
+    equal_1:
+      YES:
+        simple_condition: input.a==input.b
+        action: concatenate_str
+        action_parameters:
+          list_str:
+            - "yes"
+      NO:
+        simple_condition: input.a!=input.b
+        action: concatenate_str
+        action_parameters:
+          list_str:
+            - "no"
+    equal_2:
+      YES:
+        simple_condition: input.a==1.3
+        action: concatenate_str
+        action_parameters:
+          list_str:
+            - "yes"
+      NO:
+        simple_condition: input.a!=1.3
+        action: concatenate_str
+        action_parameters:
+          list_str:
+            - "no"
diff --git a/tests/examples/simple_cond_conf/whitespace/rules_simple_whitespace.yaml b/tests/examples/simple_cond_conf/whitespace/rules_simple_whitespace.yaml
new file mode 100644
index 0000000..d609d69
--- /dev/null
+++ b/tests/examples/simple_cond_conf/whitespace/rules_simple_whitespace.yaml
@@ -0,0 +1,15 @@
+---
+# Global settings
+actions_source_modules:
+  - "tests.examples.code.actions"
+
+# Rule sets for simple conditions tests
+rules:
+  default_rule_set:
+    whitespace:
+      RULE_1:
+        simple_condition: input.text=="super hero super hero"
+        action: concatenate_str
+        action_parameters:
+          list_str:
+            - OK
diff --git a/tests/unit/test_simple_condition.py b/tests/unit/test_simple_condition.py
index f75d92a..4344469 100644
--- a/tests/unit/test_simple_condition.py
+++ b/tests/unit/test_simple_condition.py
@@ -60,6 +60,13 @@ from arta.exceptions import ConditionExecutionError, RuleExecutionError
             "simple_cond_conf/wrong/ignore",
             {"admission": {"admission": True}, "course": {"course_id": "senior"}, "email": True},
         ),
+        (
+            {
+                "text": "super hero super hero",
+            },
+            "simple_cond_conf/whitespace",
+            {"whitespace": "OK"},
+        ),
     ],
 )
 def test_simple_condition(input_data, config_dir, good_results, base_config_path):
@@ -90,8 +97,15 @@ def test_simple_condition(input_data, config_dir, good_results, base_config_path
                             "verified_conditions": {
                                 "condition": {"expression": None, "values": {}},
                                 "simple_condition": {
-                                    "expression": 'input.power=="strength" or input.power=="fly"',
-                                    "values": {'input.power=="strength"': True, 'input.power=="fly"': False},
+                                    "expression": (
+                                        'input.power=="strength" or input.power=="fly" or input.'
+                                        'power=="time-manipulation"'
+                                    ),
+                                    "values": {
+                                        'input.power=="strength"': True,
+                                        'input.power=="fly"': False,
+                                        'input.power=="time-manipulation"': False,
+                                    },
                                 },
                             },
                             "activated_rule": "ADM_OK",
@@ -194,3 +208,34 @@ def test_error_apply_rules_missing_input_key(input_data, config_dir, expected_er
 
     with pytest.raises(expected_error):
         _ = eng.apply_rules(input_data=input_data, verbose=False)
+
+
+# @pytest.mark.skip(reason="Not yet implemented, coming soon")
+@pytest.mark.parametrize(
+    "input_data, config_dir, good_results",
+    [
+        (
+            {
+                "a": 1.3,
+                "b": 0.7,
+                "threshold": 0.89,
+            },
+            "simple_cond_conf/math",
+            {
+                "add": "greater than threshold",
+                "sub": "less or equal than threshold",
+                "mul": "greater than threshold",
+                "div": "greater than threshold",
+                "equal_1": "no",
+                "equal_2": "yes",
+            },
+        ),
+    ],
+)
+def test_math(input_data, config_dir, 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)
+
+    assert res == good_results