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
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,16 @@
[![pypi](https://img.shields.io/pypi/v/python-rule-engine.svg)](https://pypi.python.org/pypi/python-rule-engine)
[![versions](https://img.shields.io/pypi/pyversions/python-rule-engine.svg)](https://github.com/santalvarez/python-rule-engine)
[![license](https://img.shields.io/github/license/pydantic/pydantic.svg)](https://github.com/pydantic/pydantic/blob/main/LICENSE)
[![Downloads](https://pepy.tech/badge/python-rule-engine)](https://pepy.tech/project/python-rule-engine)
[![Downloads Month](https://pepy.tech/badge/python-rule-engine/month)](https://pepy.tech/project/python-rule-engine)



A rule engine where rules are defined in JSON format. The syntax of the rules belongs to the [json-rules-engine](https://github.com/CacheControl/json-rules-engine) javascript library though it contains some changes to make it more powerfull.

- [Rule Syntax](docs/rules.md)
- [Operators](docs/operators.md)

## Installation
```
pip install python-rule-engine
Expand Down Expand Up @@ -49,7 +54,3 @@ engine = RuleEngine([rule])
results = engine.evaluate(obj)

```

## Rule Format

Find more info about the rules [here](docs/rules.md).
2 changes: 1 addition & 1 deletion docs/rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,4 +78,4 @@ A rule result has the same structure as a rule but with two added fields.

**match(bool):** Indicates wether the condition matched.

**match_detail(bool):** Contains details about the object that matched.
**match_detail(any):** Contains details about the object that matched.
6 changes: 4 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "python-rule-engine"
version = "0.5.1"
version = "1.0.0"
description = "A rule engine where rules are written in JSON format"
authors = ["Santiago Alvarez <santiago.salvarez@mercadolibre.com>"]
homepage = "https://github.com/santalvarez/python-rule-engine"
Expand All @@ -17,8 +17,10 @@ classifiers = [
keywords = ["rule-engine", "rules", "json", "python"]

[tool.poetry.dependencies]
python = "^3.7.2"
python = "^3.9"
jsonpath-ng = "^1.5.3"
pydantic = "^2.10.6"
pydantic-core = "^2.29.0"

[tool.poetry.dev-dependencies]
pytest = "^7.2.0"
Expand Down
2 changes: 2 additions & 0 deletions src/python_rule_engine/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import importlib.metadata
from .engine import RuleEngine
from .operators import Operator
from .decoder import RuleDecoder


__version__ = importlib.metadata.version("python-rule-engine")

51 changes: 51 additions & 0 deletions src/python_rule_engine/decoder.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import json
from json import JSONDecodeError
from typing import Any, Dict, List, Optional, Type

from pydantic import ValidationError

from .errors import (DuplicateOperatorError, InvalidRuleJSONError,
InvalidRuleSchemaError, InvalidRuleTypeError)
from .models.rule import Rule
from .operators import DEFAULT_OPERATORS, Operator


class RuleDecoder:
""" Class responsible for decoding rules """
def __init__(self, custom_operators: Optional[List[Type[Operator]]]=None):
self.operators = {}

if custom_operators is None: custom_operators = []

for p in DEFAULT_OPERATORS + custom_operators:
if p.id in self.operators:
raise DuplicateOperatorError
self.operators[p.id] = p


def decode_rules(self, rules: List[Dict]) -> List[Rule]:
""" Decodes a list of rule dictionaries into a list of Rule objects """
return [self.decode_rule(rule) for rule in rules]

def decode_rule(self, rule: Dict) -> Rule:
""" Decodes a rule dictionary into a Rule object """
if not isinstance(rule, dict):
raise InvalidRuleTypeError
try:
return Rule(**rule, operators_dict=self.operators)
except Exception as e:
raise InvalidRuleSchemaError from e

def decode_str_rules(self, rules: List[str]) -> List[Rule]:
""" Decodes a list of rule strings into a list of Rule objects """
return [self.decode_str_rule(rule) for rule in rules]

def decode_str_rule(self, rule: str) -> Rule:
""" Decodes a rule string into a Rule object """
if not isinstance(rule, str):
raise InvalidRuleTypeError
try:
rule_dict = json.loads(rule)
return Rule(**rule_dict, operators_dict=self.operators)
except (JSONDecodeError, ValidationError) as e:
raise InvalidRuleSchemaError from e
57 changes: 27 additions & 30 deletions src/python_rule_engine/engine.py
Original file line number Diff line number Diff line change
@@ -1,38 +1,35 @@
from copy import deepcopy
from typing import Any, Dict, List, Optional, Type
from typing import Any, Dict, List, Optional

from .exceptions import DuplicateOperatorError
from .models.rule import Rule
from .operators import (Contains, Equal, GreaterThan, GreaterThanInclusive, In,
LessThan, LessThanInclusive, NotContains, NotEqual,
NotIn, Operator)
from .operators import Operator
from .decoder import RuleDecoder


class RuleEngine:
default_operators: List[Type[Operator]] = [Equal, NotEqual, LessThan,
LessThanInclusive, GreaterThan, GreaterThanInclusive,
In, NotIn, Contains, NotContains]

def __init__(self, rules: List[Dict], operators: Optional[List[Operator]] = None):
self.operators: Dict[str, Type[Operator]] = self._merge_operators(operators)
self.rules = self._deserialize_rules(rules)

def _merge_operators(self, operators: Optional[List[Type[Operator]]] = None) -> Dict[str, Type[Operator]]:
merged_operators = {}

if operators is None:
operators = []
for p in self.default_operators + operators:
if p.id in merged_operators:
raise DuplicateOperatorError
merged_operators[p.id] = p
return merged_operators

def _deserialize_rules(self, rules: List[Dict]) -> List[Rule]:
aux_rules = []
for rule in rules:
aux_rules.append(Rule(rule, self.operators))
return aux_rules
self.decoder = RuleDecoder(operators)
self.rules = self.decoder.decode_rules(rules)

def add_rule(self, rule: Dict):
""" Add a rule to the engine

:param Dict rule: The rule to add
"""
self.rules.append(self.decoder.decode_rule(rule))

def add_str_rule(self, rule: str):
""" Add a rule to the engine

:param str rule: The rule to add
"""
self.rules.append(self.decoder.decode_str_rule(rule))

def remove_rule(self, rule_name: str):
""" Remove a rule from the engine

:param str rule_name: The name of the rule to remove
"""
self.rules = [rule for rule in self.rules if rule.name != rule_name]

def evaluate(self, obj: Any) -> List[Rule]:
""" Evaluate an object on the loaded rules
Expand All @@ -42,7 +39,7 @@ def evaluate(self, obj: Any) -> List[Rule]:
"""
results = []
for rule in self.rules:
rule_copy = deepcopy(rule)
rule_copy = rule.model_copy(deep=True)
rule_copy.conditions.evaluate(obj)

if rule_copy.conditions.match:
Expand Down
26 changes: 26 additions & 0 deletions src/python_rule_engine/errors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
from pydantic import ValidationError

class RuleEngineBaseError(Exception):
""" Base Rule Engine Exception """

class DuplicateOperatorError(RuleEngineBaseError):
""" Raised when there is already a operator with the same ID
loaded in the engine """

class JSONPathValueNotFoundError(RuleEngineBaseError):
""" Raised when the value indicated by 'path' could not be found
on the object."""

class RuleDecodeError(RuleEngineBaseError):
""" Base error for all rule decoding errors """

class InvalidRuleSchemaError(RuleDecodeError):
""" Raised when the field of the provided rule don't match with the rule's schema """

class InvalidRuleTypeError(RuleDecodeError):
""" Raised when the provided rule is of an incorrect type """

class InvalidRuleJSONError(RuleDecodeError):
""" Raised when the rule can't be converted to JSON.
This will be raised when a provided string rule has invalid JSON format """

10 changes: 0 additions & 10 deletions src/python_rule_engine/exceptions.py

This file was deleted.

20 changes: 13 additions & 7 deletions src/python_rule_engine/json_path.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,25 @@
from typing import Any

from jsonpath_ng import parse
from pydantic_core import core_schema

from .exceptions import JSONPathValueNotFound
from .errors import JSONPathValueNotFoundError


class JSONPath:
def __init__(self, path: str) -> None:
self.original_path: str = path
self.jsonpath_parsed = parse(path)
class JSONPath(str):
def __new__(cls, value):
self = super().__new__(cls, value)
self.parsed = parse(self)
return self

@classmethod
def __get_pydantic_core_schema__(cls, source_type, handler):
return core_schema.no_info_after_validator_function(cls, handler(str))

def get_value_from(self, obj: Any) -> Any:
result = self.jsonpath_parsed.find(obj)
result = self.parsed.find(obj)
if len(result) == 0:
raise JSONPathValueNotFound(f"Value not found at path {self.original_path}")
raise JSONPathValueNotFoundError(f"Value not found at path {self}")

if len(result) == 1:
return result[0].value
Expand Down
10 changes: 6 additions & 4 deletions src/python_rule_engine/models/condition.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
from pydantic import BaseModel
from pydantic.json_schema import SkipJsonSchema

class Condition:
def __init__(self) -> None:
self.match = False

class Condition(BaseModel):
match: SkipJsonSchema[bool] = False

def evaluate(self, obj):
raise NotImplementedError
""" Evaluate the condition on an object """
55 changes: 27 additions & 28 deletions src/python_rule_engine/models/multi_condition.py
Original file line number Diff line number Diff line change
@@ -1,42 +1,41 @@
from __future__ import annotations

from typing import List, Optional
from typing import Dict, List, Union

from pydantic import Field, model_validator
from pydantic.json_schema import SkipJsonSchema

from .condition import Condition
from .simple_condition import SimpleCondition


class MultiCondition(Condition):
def __init__(self, **data) -> None:
super().__init__()
any: List[Union[SimpleCondition, MultiCondition]] = None
all: List[Union[SimpleCondition, MultiCondition]] = None
not_: Union[SimpleCondition, MultiCondition] = Field(None, alias="not")

operators_dict: SkipJsonSchema[Dict] = Field(..., exclude=True)

if sum([bool(data.get("any", [])), bool(data.get("all", [])), bool(data.get("not", {}))]) != 1:
@model_validator(mode="after")
def validate_conditions(self):
if sum([bool(self.any), bool(self.all), bool(self.not_)]) != 1:
raise ValueError("Only one of any, all or not can be defined")
return self

@model_validator(mode="before")
@classmethod
def set_operators_dict(cls, values):
operators_dict = values["operators_dict"]

for key in ["any", "all"]:
if values.get(key):
for item in values[key]:
item["operators_dict"] = operators_dict

if values.get("not"):
values["not"]["operators_dict"] = operators_dict

self.all = self.__validate_conditions(data.get("all", []), data["operators_dict"])
self.any = self.__validate_conditions(data.get("any", []), data["operators_dict"])
self.not_ = self.__validate_not_condition(data.get("not", {}), data["operators_dict"])

def __validate_conditions(self, data: List[dict], operators_dict) -> Optional[List[Condition]]:
if not data:
return None
cds = []
for cd in data:
cd["operators_dict"] = operators_dict
try:
cds.append(SimpleCondition(**cd))
except ValueError:
cds.append(MultiCondition(**cd))
return cds

def __validate_not_condition(self, data: dict, operators_dict) -> Optional[Condition]:
if not data:
return None
data["operators_dict"] = operators_dict
try:
return SimpleCondition(**data)
except ValueError:
return MultiCondition(**data)
return values

def evaluate(self, obj):
""" Run a multi condition on an object or dict
Expand Down
37 changes: 25 additions & 12 deletions src/python_rule_engine/models/rule.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,26 @@
from typing import Dict, Optional, Type

from pydantic import BaseModel, Field, model_validator
from pydantic.json_schema import SkipJsonSchema

from ..operators import Operator
from .multi_condition import MultiCondition
from ..utils import validate_value


class Rule:
def __init__(self, data, operators_dict):
self.name = validate_value(data.get("name"), str, "name")
self.description = validate_value(data.get("description"), str, "description", nullable=True)
self.extra = validate_value(data.get("extra"), dict, "extra", nullable=True)
self.event = validate_value(data.get("event"), dict, "event", nullable=True)
conditions = validate_value(data.get("conditions"), dict, "conditions")
conditions["operators_dict"] = operators_dict
self.conditions = MultiCondition(**conditions)


class Rule(BaseModel):
name: str = Field(..., description="The name of the rule")
description: Optional[str] = Field(None, description="A description of the rule")
extra: Dict = Field({}, description="Extra metadata for the rule")
event: Dict = {}
conditions: MultiCondition = Field(..., description="The conditions that must be met for the rule to trigger")

operators_dict: SkipJsonSchema[Dict[str, Type[Operator]]] = Field(
...,
description="A dictionary of operators to use in the conditions",
exclude=True)

@model_validator(mode="before")
@classmethod
def set_operators_dict(cls, values):
values.get("conditions", {})["operators_dict"] = values["operators_dict"]
return values
Loading