Skip to content

Commit

Permalink
feat: rule activation mode (#40)
Browse files Browse the repository at this point in the history
* feat: rule activation mode

Signed-off-by: develop-cs <43383361+develop-cs@users.noreply.github.com>

* fix: add python 3.13 compatibility

Signed-off-by: develop-cs <43383361+develop-cs@users.noreply.github.com>

---------

Signed-off-by: develop-cs <43383361+develop-cs@users.noreply.github.com>
  • Loading branch information
develop-cs authored Dec 18, 2024
1 parent 81e2498 commit 88ebd80
Show file tree
Hide file tree
Showing 23 changed files with 394 additions and 52 deletions.
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ repos:
- id: check-added-large-files
args: [--maxkb=500]
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.8.0
rev: v0.8.2
hooks:
- id: ruff
args: [--fix]
Expand Down
14 changes: 12 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,25 @@ 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.8.2] - November, 2024
## [0.9.0] - December, 2024

### Features

* Add a new configuration setting for rule execution: `rule_activation_mode` (#38).

### Maintenance

* Compatibility with Python 3.13.
* Use true Pydantic V2 (or Pydantic V1) models (`DeprecationWarning` added about Pydantic V1).

> [!IMPORTANT]
> **Arta** + **Pydantic V1** + **Python 3.13** is not supported because Pydantic V1 is not supported for Python > 3.12 ([issue 9663](https://github.com/pydantic/pydantic/issues/9663)).
### Documentation

* New page: *"Use your business objects".*
* New pages:
* *Use your business objects*
* *Rule activation mode*

### Breaking changes

Expand Down
6 changes: 4 additions & 2 deletions docs/mkdocs.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -83,10 +83,12 @@ nav:
- How to: how_to.md
- Glossary: glossary.md
- Advanced User Guide:
- API Reference: api_reference.md
- Custom conditions: custom_conditions.md
- Parameters: parameters.md
- Rule activation mode: rule_activation_mode.md
- Rule sets: rule_sets.md
- Use your business objects: business_objects.md
- Custom conditions: custom_conditions.md
- API Reference: api_reference.md

extra_css:
- assets/css/mkdocs_extra.css
29 changes: 19 additions & 10 deletions docs/pages/home.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,26 +5,35 @@
<em>An Open Source Rules Engine - Make rule handling simple</em>
</p>
<p align="center">
<img src="https://img.shields.io/pypi/v/arta" alt="Versions">
<a href="https://pypi.org/project/arta/"><img src="https://img.shields.io/pypi/v/arta" alt="Versions"></a>
</p>

# Welcome to the documentation

* Want to discover what is **Arta**? :arrow_right: [Get Started](a_simple_example.md)
* Want to know how to use it? :arrow_right: [User Guide](how_to.md)
Want to discover what is **Arta**? :arrow_right: [Get Started](a_simple_example.md)

!!! info "New feature"

Check out the new and very convenient feature called the [simple condition](how_to.md#simple-condition). A new and lightweight way of configuring your rules' conditions.
Want to know how to use it? :arrow_right: [User Guide](how_to.md)

**Arta** is automatically tested with:

![Alt Python](https://img.shields.io/pypi/pyversions/arta)
!!! info inline "New feature"

Use **Arta** as a *process execution engine* :zap:

Read [this page](rule_activation_mode.md) for more details.

!!! tip "Releases"

Want to see last updates, check the [Release notes](https://github.com/MAIF/arta/releases) :rocket:
Check the [Release notes](https://github.com/MAIF/arta/releases) :rocket:

!!! warning "Pydantic 1 compatibility is deprecated"

**Arta** is working with [Pydantic 2](https://docs.pydantic.dev/latest/) and Pydantic 1 but compatibility with V1 will be removed in the next **major** release.

**Arta** is working and automatically tested with:

![Alt Python](https://img.shields.io/pypi/pyversions/arta)

!!! success "Pydantic 2"
You like **Arta**? Add a :star:

**Arta** is now working with [Pydantic 2](https://docs.pydantic.dev/latest/)! And of course, Pydantic 1 as well.
[![GitHub Repo stars](https://img.shields.io/github/stars/maif/arta)](https://github.com/MAIF/arta)
4 changes: 0 additions & 4 deletions docs/pages/how_to.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,6 @@ Ensure that you have correctly installed **Arta** before, check the [Installatio

## Simple condition

!!! beta "Beta feature"

**Simple condition** is still a *beta feature*, some cases could not work as designed.

**Simple conditions** are a new and straightforward way of configuring your *conditions*.

It simplifies your rules a lot by:
Expand Down
95 changes: 95 additions & 0 deletions docs/pages/rule_activation_mode.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
!!! example "Beta feature"

This new feature (i.e., `rule_activation_mode: many_by_group`) is a **beta feature**, some cases could not work as designed. Please report them using [issues](https://github.com/MAIF/arta/issues).

This feature was designed at MAIF when the idea to use **Arta** as a simple *process execution engine* came about.

Our goal was to handle different *rules* of data processing inside an ETL pipeline.

It's actually quite simple :zap:

## Illustration

Traditionaly, **Arta** is evaluating rules (like most rules engines) like this:

```mermaid
---
title: one_by_group
---
flowchart
s((Start))
e((End))
subgraph Group_1
r1(Rule 1, conditions False)-->r2(Rule 2, conditions True)-.not evaluated.->r3(Rule 3, conditions True)
end
r2-.execute.->a2(Action A)
subgraph Group_2
r1b(Rule 1, conditions True)-.not evaluated.->r2b(Rule 2, conditions False)-.not evaluated.->r3b(Rule 3, conditions True)
end
s-->r1
r1b-.execute.->a1b(Action B)
r2-->r1b
r3-.->r1b
r1b-->e
r3b-.->e
```

> **Only one rule is activated (i.e., meaning one action is triggered) by rule group.**
---

But if we need to use **Arta** to execute *simple workflows*, we need a *control flow* like this one:

```mermaid
---
title: many_by_group
---
flowchart
s((Start))
e((End))
subgraph Group_1
r1(Rule 1, conditions False)-->r2(Rule 2, conditions True)-->r3(Rule 3, conditions True)
end
r2-.execute.->a2(Action A)
r3-.execute.->a3(Action B)
subgraph Group_2
r1b(Rule 1, conditions True)-->r2b(Rule 2, conditions False)-->r3b(Rule 3, conditions True)
end
s-->r1
r1b-.execute.->a1b(Action C)
r3b-.execute.->a3b(Action D)
r3-->r1b
r3b-->e
```

> **All rules are evaluated.**
> **Therefore, many rules can be activated (i.e., meaning many actions can be triggered) by rule group.**
---

## Setting

You just need to add somewhere in the YAML configuration file of **Arta** the following setting:

### One by group

This *traditional* flow of control is the **default one**:

```yaml
rule_activation_mode: one_by_group
```
!!! note "Default value"
Because it is the **default value**, it is *useless* to add this line in the configuration.
### Many by group
This is the *flow of control* of a **process execution engine**:
```yaml
rule_activation_mode: many_by_group
```
That's all! You are all set :+1:
7 changes: 3 additions & 4 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.8.2"
version = "0.9.0"
requires-python = ">3.8.0"
description = "A Python Rules Engine - Make rule handling simple"
readme = "README.md"
Expand All @@ -28,6 +28,7 @@ classifiers = [
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"License :: OSI Approved :: Apache Software License",
]

Expand All @@ -42,12 +43,10 @@ Documentation = "https://maif.github.io/arta/home/"
Repository = "https://github.com/MAIF/arta"

[project.optional-dependencies]
all = ["arta[test,dev,doc,mypy,ruff]"]
all = ["arta[test,dev,doc]"]
test = ["pytest", "tox", "pytest-cov"]
dev = ["mypy", "pre-commit", "ruff"]
doc = ["mkdocs-material", "mkdocstrings[python]"]
mypy = ["mypy"]
ruff = ["ruff"]

[tool.setuptools]
package-dir = {"" = "src"}
Expand Down
18 changes: 13 additions & 5 deletions src/arta/_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
from arta.config import load_config
from arta.models import Configuration, RulesDict
from arta.rule import Rule
from arta.utils import ParsingErrorStrategy
from arta.utils import ParsingErrorStrategy, RuleActivationMode


class RulesEngine:
Expand Down Expand Up @@ -81,8 +81,10 @@ def __init__(
"RulesEngine takes one (and only one) parameter: 'rules_dict' or 'config_path' or 'config_dict'."
)

# Init. default parsing_error_strategy (probably not needed because already defined elsewhere)
# Init. default global settings (useful if not set, can't be set in the Pydantic model
# because of the rules dict mode)
self._parsing_error_strategy: ParsingErrorStrategy = ParsingErrorStrategy.RAISE
self._rule_activation_mode: RuleActivationMode = RuleActivationMode.ONE_BY_GROUP

# Initialize directly with a rules dict
if rules_dict is not None:
Expand Down Expand Up @@ -112,6 +114,10 @@ def __init__(
# Set parsing error handling strategy from config
self._parsing_error_strategy = ParsingErrorStrategy(config.parsing_error_strategy)

if config.rule_activation_mode is not None:
# Set rule activation mode from config
self._rule_activation_mode = RuleActivationMode(config.rule_activation_mode)

# dict of available action functions (k: function name, v: function object)
action_modules: list[str] = config.actions_source_modules
action_functions: dict[str, Callable] = self._get_object_from_source_modules(action_modules)
Expand Down Expand Up @@ -166,7 +172,8 @@ def apply_rules(
"""Apply the rules and return results.
For each rule group of a given rule set, rules are applied sequentially,
The loop is broken when a rule is applied (an action is triggered).
The loop is broken when a rule is applied (an action is triggered)
or not (depends on the rule activation mode).
Then, the next rule group is evaluated.
And so on...
Expand Down Expand Up @@ -241,8 +248,9 @@ def apply_rules(
# Update input data with current result with key 'output' (can be used in next rules)
input_data_copy["output"][group_id] = copy.deepcopy(results_dict[group_id])

# We can only have one result per group => break when "action_result" in rule_details
break
if self._rule_activation_mode is RuleActivationMode.ONE_BY_GROUP:
# We can only have one result per group => break when "action_result" in rule_details
break

# Handling non-verbose mode
if not verbose:
Expand Down
6 changes: 4 additions & 2 deletions src/arta/condition.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,10 +183,12 @@ def verify(self, input_data: dict[str, Any], parsing_error_strategy: ParsingErro
path_matches: list[str] = re.findall(data_path_patt, unitary_expr)

if len(path_matches) > 0:
locals_ns: dict[str, Any] = {}

# 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
locals_ns[f"data_{idx}"] = parse_dynamic_parameter(
parameter=path, input_data=input_data, parsing_error_strategy=parsing_error_strategy
)

Expand All @@ -195,7 +197,7 @@ def verify(self, input_data: dict[str, Any], parsing_error_strategy: ParsingErro

# Evaluate the expression
try:
bool_var = eval(unitary_expr) # noqa
bool_var = eval(unitary_expr, None, locals_ns) # noqa
except TypeError:
# Ignore evaluation --> False
pass
Expand Down
6 changes: 4 additions & 2 deletions src/arta/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import pydantic
from pydantic.version import VERSION

from arta.utils import ParsingErrorStrategy
from arta.utils import ParsingErrorStrategy, RuleActivationMode

PYDANTIC_V1: bool = VERSION.startswith("1.")

Expand Down Expand Up @@ -66,6 +66,7 @@ class Configuration(pydantic.BaseModel):
condition_factory_mapping: Optional[dict[str, str]] = None
rules: dict[str, dict[str, dict[Annotated[str, pydantic.StringConstraints(to_upper=True)], RulesConfig]]]
parsing_error_strategy: Optional[ParsingErrorStrategy] = None
rule_activation_mode: Optional[RuleActivationMode] = None

else:
# Pydantic V1
Expand Down Expand Up @@ -141,4 +142,5 @@ class Configuration(BaseModelV2): # type: ignore[no-redef]
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
parsing_error_strategy: Optional[ParsingErrorStrategy]
parsing_error_strategy: Optional[ParsingErrorStrategy] = None
rule_activation_mode: Optional[RuleActivationMode] = None
10 changes: 10 additions & 0 deletions src/arta/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,16 @@ class ParsingErrorStrategy(str, Enum):
DEFAULT_VALUE: str = "default_value"


class RuleActivationMode(str, Enum):
"""Define how Arta is processing rules.
ONE_BY_GROUP is the default mode.
"""

ONE_BY_GROUP: str = "one_by_group"
MANY_BY_GROUP: str = "many_by_group"


def get_value_in_nested_dict_from_path(path: str, nested_dict: dict[str, Any]) -> Any:
"""From a path, get a value in a nested dict.
Expand Down
7 changes: 6 additions & 1 deletion tests/examples/code/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ def send_email(mail_to: str, mail_content: str, meal: str, **kwargs: Any) -> boo
return is_ok


def concatenate_str(list_str: list[Any], **kwargs: Any) -> str:
def concatenate_list(list_str: list[Any], **kwargs: Any) -> str:
"""Demo function: return the concatenation of a list of string using input_data (two levels max)."""
list_str = [str(element) for element in list_str]
return "".join(list_str)
Expand All @@ -44,3 +44,8 @@ def do_nothing(**kwargs: Any) -> None:
def compute_sum(value1: float, value2: float, **kwargs: Any) -> float:
"""Demo function: return sum of two values."""
return value1 + value2


def concatenate(value1: str, value2: str, **kwargs: Any) -> str:
"""Demo function: return the concatenation of two strings."""
return value1 + value2
Loading

0 comments on commit 88ebd80

Please sign in to comment.