Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: rule activation mode #40

Merged
merged 2 commits into from
Dec 18, 2024
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
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):
develop-cs marked this conversation as resolved.
Show resolved Hide resolved
"""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
Loading