From 87e9d50d04cc6f817bc03318ea457db287b8acc5 Mon Sep 17 00:00:00 2001 From: Santiago Alvarez Date: Tue, 11 Feb 2025 12:12:36 -0300 Subject: [PATCH 1/9] Implement pydantic in models --- pyproject.toml | 6 +- src/python_rule_engine/__init__.py | 2 + src/python_rule_engine/engine.py | 15 ++--- src/python_rule_engine/json_path.py | 18 ++++-- src/python_rule_engine/models/condition.py | 7 ++- .../models/multi_condition.py | 54 +++++++++--------- src/python_rule_engine/models/rule.py | 35 ++++++++---- .../models/simple_condition.py | 56 +++++++------------ src/python_rule_engine/operators.py | 6 +- src/python_rule_engine/utils.py | 7 --- tests/conftest.py | 21 ++----- tests/test_simple_condition.py | 15 +++++ 12 files changed, 119 insertions(+), 123 deletions(-) delete mode 100644 src/python_rule_engine/utils.py create mode 100644 tests/test_simple_condition.py diff --git a/pyproject.toml b/pyproject.toml index 7d14ff7..234fdc7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 "] homepage = "https://github.com/santalvarez/python-rule-engine" @@ -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" diff --git a/src/python_rule_engine/__init__.py b/src/python_rule_engine/__init__.py index e87804b..bab428d 100644 --- a/src/python_rule_engine/__init__.py +++ b/src/python_rule_engine/__init__.py @@ -1,6 +1,8 @@ import importlib.metadata from .engine import RuleEngine from .operators import Operator +from .models.rule import Rule + __version__ = importlib.metadata.version("python-rule-engine") diff --git a/src/python_rule_engine/engine.py b/src/python_rule_engine/engine.py index 3f89621..e858c54 100644 --- a/src/python_rule_engine/engine.py +++ b/src/python_rule_engine/engine.py @@ -1,18 +1,11 @@ -from copy import deepcopy from typing import Any, Dict, List, Optional, Type 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 DEFAULT_OPERATORS, Operator 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) @@ -22,7 +15,7 @@ def _merge_operators(self, operators: Optional[List[Type[Operator]]] = None) -> if operators is None: operators = [] - for p in self.default_operators + operators: + for p in DEFAULT_OPERATORS + operators: if p.id in merged_operators: raise DuplicateOperatorError merged_operators[p.id] = p @@ -31,7 +24,7 @@ def _merge_operators(self, operators: Optional[List[Type[Operator]]] = None) -> def _deserialize_rules(self, rules: List[Dict]) -> List[Rule]: aux_rules = [] for rule in rules: - aux_rules.append(Rule(rule, self.operators)) + aux_rules.append(Rule(**rule, operators_dict=self.operators)) return aux_rules def evaluate(self, obj: Any) -> List[Rule]: @@ -42,7 +35,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: diff --git a/src/python_rule_engine/json_path.py b/src/python_rule_engine/json_path.py index fe0d9cf..a97e5f2 100644 --- a/src/python_rule_engine/json_path.py +++ b/src/python_rule_engine/json_path.py @@ -1,19 +1,25 @@ from typing import Any from jsonpath_ng import parse +from pydantic_core import core_schema from .exceptions import JSONPathValueNotFound -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 JSONPathValueNotFound(f"Value not found at path {self}") if len(result) == 1: return result[0].value diff --git a/src/python_rule_engine/models/condition.py b/src/python_rule_engine/models/condition.py index df8b100..10a1a09 100644 --- a/src/python_rule_engine/models/condition.py +++ b/src/python_rule_engine/models/condition.py @@ -1,7 +1,8 @@ +from pydantic import BaseModel -class Condition: - def __init__(self) -> None: - self.match = False + +class Condition(BaseModel): + match: bool = False def evaluate(self, obj): raise NotImplementedError diff --git a/src/python_rule_engine/models/multi_condition.py b/src/python_rule_engine/models/multi_condition.py index d1a0bae..2f3547d 100644 --- a/src/python_rule_engine/models/multi_condition.py +++ b/src/python_rule_engine/models/multi_condition.py @@ -1,42 +1,40 @@ from __future__ import annotations -from typing import List, Optional +from typing import Dict, List, Union + +from pydantic import Field, model_validator 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: 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 diff --git a/src/python_rule_engine/models/rule.py b/src/python_rule_engine/models/rule.py index d55144d..bdccf75 100644 --- a/src/python_rule_engine/models/rule.py +++ b/src/python_rule_engine/models/rule.py @@ -1,13 +1,24 @@ +from typing import Dict, Optional, Type + +from pydantic import BaseModel, Field, model_validator + +from ..operators import Operator, DEFAULT_OPERATORS 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: Dict[str, Type[Operator]] = Field(DEFAULT_OPERATORS, + 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 diff --git a/src/python_rule_engine/models/simple_condition.py b/src/python_rule_engine/models/simple_condition.py index 3bc9796..fa14663 100644 --- a/src/python_rule_engine/models/simple_condition.py +++ b/src/python_rule_engine/models/simple_condition.py @@ -1,51 +1,38 @@ from __future__ import annotations from copy import deepcopy -from typing import Any, Optional +from typing import Any, Dict, Optional + +from pydantic import Field, model_validator, PrivateAttr +from pydantic.json_schema import SkipJsonSchema from ..exceptions import JSONPathValueNotFound from ..json_path import JSONPath -from .condition import Condition from ..operators import Operator +from .condition import Condition class SimpleCondition(Condition): - def __init__(self, **data): - super().__init__() - self.operator: Operator = self.__validate_operator(data) - self.path: Optional[JSONPath] = self.__validate_path(data) - self.value: Any = data["value"] - self.params = data.get('params', {}) - self.match_detail = None - - def __deepcopy__(self, memo): - # Do not deep copy the operator, use the same instance - cls = self.__class__ - result = cls.__new__(cls) - memo[id(self)] = result - for k, v in self.__dict__.items(): - if k == 'operator': - setattr(result, k, v) - else: - setattr(result, k, deepcopy(v, memo)) - return result - - def __validate_path(self, data: dict) -> Optional[JSONPath]: - if 'path' in data: - return JSONPath(data['path']) - return None + operators_dict: SkipJsonSchema[Dict] = Field(..., exclude=True, repr=False) + _operator_object: SkipJsonSchema[Optional[Operator]] = PrivateAttr(None) - def __validate_operator(self, data: dict) -> Operator: - operators_dict = data['operators_dict'] + path: Optional[JSONPath] = Field(None, description="A JSONPath expression to extract a value from the object") + operator: str = Field(..., description="The operator to use for the comparison") + value: Any = Field(..., description="The value to compare against") + params: dict = Field({}, description="Additional parameters for the operator") + match_detail: Any = None - if 'operator' not in data: - raise ValueError("Operator attribute missing") + def __deepcopy__(self, memo=None): + return self.model_copy(deep=False) - if data['operator'] not in operators_dict: + @model_validator(mode="after") + def validate_operator_object(self): + if self.operator not in self.operators_dict: raise ValueError("Specified operator not found in engine") - # Initialize the found Operator type and pass self to init - return operators_dict[data['operator']](self) + self._operator_object = self.operators_dict[self.operator](self) + + return self def __obj_to_dict(self, obj: Any) -> Any: """ Recursively convert an object to a dict if possible @@ -61,7 +48,6 @@ def __obj_to_dict(self, obj: Any) -> Any: return [self.__obj_to_dict(v) for v in obj] return obj - def evaluate(self, obj: dict): """ Run the condition on an object @@ -76,7 +62,7 @@ def evaluate(self, obj: dict): else: path_obj = obj - match, match_detail = self.operator.match(path_obj) + match, match_detail = self._operator_object.match(path_obj) self.match = match self.match_detail = self.__obj_to_dict(match_detail) except JSONPathValueNotFound as e: diff --git a/src/python_rule_engine/operators.py b/src/python_rule_engine/operators.py index 886fe56..7a350b0 100644 --- a/src/python_rule_engine/operators.py +++ b/src/python_rule_engine/operators.py @@ -1,6 +1,5 @@ -from typing import Tuple, Any from abc import ABC, abstractmethod - +from typing import Any, Tuple class Operator(ABC): @@ -92,3 +91,6 @@ def match(self, obj_value) -> Tuple[bool, Any]: """ Return True if the object value does not contain the condition value""" return self.condition.value not in obj_value, obj_value + +DEFAULT_OPERATORS = [Equal, NotEqual, LessThan, LessThanInclusive, GreaterThan, + GreaterThanInclusive, In, NotIn, Contains, NotContains] diff --git a/src/python_rule_engine/utils.py b/src/python_rule_engine/utils.py deleted file mode 100644 index 488f6da..0000000 --- a/src/python_rule_engine/utils.py +++ /dev/null @@ -1,7 +0,0 @@ - -def validate_value(value, type_, name, nullable=False): - if nullable and value is None: - return value - if not isinstance(value, type_): - raise ValueError(f"{name} must be of type {type_.__name__}") - return value diff --git a/tests/conftest.py b/tests/conftest.py index e584875..d9df483 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,22 +1,9 @@ from pytest import fixture -from python_rule_engine.operators import (Contains, Equal, GreaterThan, - GreaterThanInclusive, In, LessThan, - LessThanInclusive, NotContains, - NotEqual, NotIn) - +from python_rule_engine.operators import DEFAULT_OPERATORS @fixture def operators_dict(): - return { - "in": In, - "not_in": NotIn, - "equal": Equal, - "not_equal": NotEqual, - "less_than": LessThan, - "less_than_inclusive": LessThanInclusive, - "greater_than": GreaterThan, - "greater_than_inclusive": GreaterThanInclusive, - "contains": Contains, - "not_contains": NotContains, - } + # convert the list of operators to a dictionary + return {op.id: op for op in DEFAULT_OPERATORS} + diff --git a/tests/test_simple_condition.py b/tests/test_simple_condition.py new file mode 100644 index 0000000..36921be --- /dev/null +++ b/tests/test_simple_condition.py @@ -0,0 +1,15 @@ +from python_rule_engine.models.simple_condition import SimpleCondition + + +def test_deepcopy_operator_not_copied(operators_dict): + sc = SimpleCondition(path="$.foo", operator="equal", + value="bar", operators_dict=operators_dict) + sc_copy = sc.model_copy(deep=True) + sc.match = True + + # assert that operator kept the same reference + assert id(sc.operator) == id(sc_copy.operator) + assert id(sc._operator_object) == id(sc_copy._operator_object) + assert sc_copy.match == False + + From 9ab1399f32212192838cf51756ecc7603864dd5b Mon Sep 17 00:00:00 2001 From: Santiago Alvarez Date: Tue, 11 Feb 2025 13:37:18 -0300 Subject: [PATCH 2/9] Remove internal fields from pydantic json schema --- src/python_rule_engine/models/condition.py | 3 ++- src/python_rule_engine/models/multi_condition.py | 3 ++- src/python_rule_engine/models/rule.py | 3 ++- src/python_rule_engine/models/simple_condition.py | 3 +-- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/python_rule_engine/models/condition.py b/src/python_rule_engine/models/condition.py index 10a1a09..f28f7a2 100644 --- a/src/python_rule_engine/models/condition.py +++ b/src/python_rule_engine/models/condition.py @@ -1,8 +1,9 @@ from pydantic import BaseModel +from pydantic.json_schema import SkipJsonSchema class Condition(BaseModel): - match: bool = False + match: SkipJsonSchema[bool] = False def evaluate(self, obj): raise NotImplementedError diff --git a/src/python_rule_engine/models/multi_condition.py b/src/python_rule_engine/models/multi_condition.py index 2f3547d..51928d8 100644 --- a/src/python_rule_engine/models/multi_condition.py +++ b/src/python_rule_engine/models/multi_condition.py @@ -3,6 +3,7 @@ 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 @@ -13,7 +14,7 @@ class MultiCondition(Condition): all: List[Union[SimpleCondition, MultiCondition]] = None not_: Union[SimpleCondition, MultiCondition] = Field(None, alias="not") - operators_dict: Dict = Field(..., exclude=True) + operators_dict: SkipJsonSchema[Dict] = Field(..., exclude=True) @model_validator(mode="after") def validate_conditions(self): diff --git a/src/python_rule_engine/models/rule.py b/src/python_rule_engine/models/rule.py index bdccf75..f4bcc63 100644 --- a/src/python_rule_engine/models/rule.py +++ b/src/python_rule_engine/models/rule.py @@ -1,6 +1,7 @@ from typing import Dict, Optional, Type from pydantic import BaseModel, Field, model_validator +from pydantic.json_schema import SkipJsonSchema from ..operators import Operator, DEFAULT_OPERATORS from .multi_condition import MultiCondition @@ -13,7 +14,7 @@ class Rule(BaseModel): event: Dict = {} conditions: MultiCondition = Field(..., description="The conditions that must be met for the rule to trigger") - operators_dict: Dict[str, Type[Operator]] = Field(DEFAULT_OPERATORS, + operators_dict: SkipJsonSchema[Dict[str, Type[Operator]]] = Field(DEFAULT_OPERATORS, description="A dictionary of operators to use in the conditions", exclude=True) diff --git a/src/python_rule_engine/models/simple_condition.py b/src/python_rule_engine/models/simple_condition.py index fa14663..b20390e 100644 --- a/src/python_rule_engine/models/simple_condition.py +++ b/src/python_rule_engine/models/simple_condition.py @@ -1,6 +1,5 @@ from __future__ import annotations -from copy import deepcopy from typing import Any, Dict, Optional from pydantic import Field, model_validator, PrivateAttr @@ -20,7 +19,7 @@ class SimpleCondition(Condition): operator: str = Field(..., description="The operator to use for the comparison") value: Any = Field(..., description="The value to compare against") params: dict = Field({}, description="Additional parameters for the operator") - match_detail: Any = None + match_detail: SkipJsonSchema[Any] = None def __deepcopy__(self, memo=None): return self.model_copy(deep=False) From 42ddfc0302157554e24b985be8453360914acc41 Mon Sep 17 00:00:00 2001 From: Santiago Alvarez Date: Tue, 11 Feb 2025 14:00:27 -0300 Subject: [PATCH 3/9] Add examples to operator field --- src/python_rule_engine/models/simple_condition.py | 5 +++-- src/python_rule_engine/operators.py | 7 ++++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/python_rule_engine/models/simple_condition.py b/src/python_rule_engine/models/simple_condition.py index b20390e..474c4e3 100644 --- a/src/python_rule_engine/models/simple_condition.py +++ b/src/python_rule_engine/models/simple_condition.py @@ -7,7 +7,7 @@ from ..exceptions import JSONPathValueNotFound from ..json_path import JSONPath -from ..operators import Operator +from ..operators import Operator, DEFAULT_OPERATORS from .condition import Condition @@ -16,7 +16,8 @@ class SimpleCondition(Condition): _operator_object: SkipJsonSchema[Optional[Operator]] = PrivateAttr(None) path: Optional[JSONPath] = Field(None, description="A JSONPath expression to extract a value from the object") - operator: str = Field(..., description="The operator to use for the comparison") + operator: str = Field(..., description="The operator to use for the comparison", + examples=[o.id for o in DEFAULT_OPERATORS]) value: Any = Field(..., description="The value to compare against") params: dict = Field({}, description="Additional parameters for the operator") match_detail: SkipJsonSchema[Any] = None diff --git a/src/python_rule_engine/operators.py b/src/python_rule_engine/operators.py index 7a350b0..2e74aaa 100644 --- a/src/python_rule_engine/operators.py +++ b/src/python_rule_engine/operators.py @@ -1,5 +1,5 @@ from abc import ABC, abstractmethod -from typing import Any, Tuple +from typing import Any, Tuple, List, Type class Operator(ABC): @@ -92,5 +92,6 @@ def match(self, obj_value) -> Tuple[bool, Any]: return self.condition.value not in obj_value, obj_value -DEFAULT_OPERATORS = [Equal, NotEqual, LessThan, LessThanInclusive, GreaterThan, - GreaterThanInclusive, In, NotIn, Contains, NotContains] +DEFAULT_OPERATORS: List[Type[Operator]] = [ + Equal, NotEqual, LessThan, LessThanInclusive, GreaterThan, + GreaterThanInclusive, In, NotIn, Contains, NotContains] From 19d54361fed653ed662a8c54fc5812950e958ea0 Mon Sep 17 00:00:00 2001 From: Santiago Alvarez Date: Tue, 11 Feb 2025 14:09:53 -0300 Subject: [PATCH 4/9] Add chatgpt rule generation example --- README.md | 37 ++++++++++++++++++++++++++++++++++--- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index f19a09f..897f8fe 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,11 @@ [![license](https://img.shields.io/github/license/pydantic/pydantic.svg)](https://github.com/pydantic/pydantic/blob/main/LICENSE) - 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 @@ -50,6 +52,35 @@ results = engine.evaluate(obj) ``` -## Rule Format +## Generating rules with ChatGPT +You can leverage the Rule's model definition to generate rules using ChatGPT. The following example shows how to generate a rule using OpenAI's API. More info on how to use the API can be found [here](https://platform.openai.com/docs/guides/structured-outputs). + +```python +from openai import OpenAI +from python_rule_engine import Rule + + +obj = { + "player": { + "name": "Lionel", + "age": 34, + } +} + +client = OpenAI(api_key="your_api_key") + +completion = client.beta.chat.completions.parse( + model="gpt-4o-2024-08-06", + messages=[ + {"role": "system", "content": "Generate a json rule that matches the following conditions:"}, + {"role": "user", "content": "Create a rule that matches if the player's name is Lionel and the player's age bigger than 30"}, + ], + response_format=Rule, +) + +rule = completion.choices[0].message.parsed + +engine = RuleEngine([rule]) -Find more info about the rules [here](docs/rules.md). +assert engine.evaluate(obj).match +``` \ No newline at end of file From d94604f442ce5550c492d659c8d049dff4dd0c2c Mon Sep 17 00:00:00 2001 From: Santiago Alvarez Date: Tue, 11 Feb 2025 22:25:52 -0300 Subject: [PATCH 5/9] Add RuleDecoder class + Add/Remove rule methods --- src/python_rule_engine/__init__.py | 2 +- src/python_rule_engine/decoder.py | 64 +++++++++++++++++++ src/python_rule_engine/engine.py | 48 +++++++------- src/python_rule_engine/errors.py | 26 ++++++++ src/python_rule_engine/exceptions.py | 10 --- src/python_rule_engine/json_path.py | 4 +- src/python_rule_engine/models/rule.py | 9 +-- .../models/simple_condition.py | 9 ++- tests/test_rule_decoder.py | 37 +++++++++++ 9 files changed, 165 insertions(+), 44 deletions(-) create mode 100644 src/python_rule_engine/decoder.py create mode 100644 src/python_rule_engine/errors.py delete mode 100644 src/python_rule_engine/exceptions.py create mode 100644 tests/test_rule_decoder.py diff --git a/src/python_rule_engine/__init__.py b/src/python_rule_engine/__init__.py index bab428d..3258228 100644 --- a/src/python_rule_engine/__init__.py +++ b/src/python_rule_engine/__init__.py @@ -1,7 +1,7 @@ import importlib.metadata from .engine import RuleEngine from .operators import Operator -from .models.rule import Rule +from .decoder import RuleDecoder __version__ = importlib.metadata.version("python-rule-engine") diff --git a/src/python_rule_engine/decoder.py b/src/python_rule_engine/decoder.py new file mode 100644 index 0000000..ec728c9 --- /dev/null +++ b/src/python_rule_engine/decoder.py @@ -0,0 +1,64 @@ +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 ValidationError as e: + raise InvalidRuleSchemaError from e + except JSONDecodeError as e: + raise InvalidRuleJSONError + + def rule_schema(self) -> Dict[str, Any]: + schema = Rule.model_json_schema() + + operator_names = list(self.operators.keys()) + + schema["$defs"]["SimpleCondition"]["properties"]["operator"]["examples"] = operator_names + + return schema + + diff --git a/src/python_rule_engine/engine.py b/src/python_rule_engine/engine.py index e858c54..fa9e81a 100644 --- a/src/python_rule_engine/engine.py +++ b/src/python_rule_engine/engine.py @@ -1,31 +1,35 @@ -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 DEFAULT_OPERATORS, Operator +from .operators import Operator +from .decoder import RuleDecoder class RuleEngine: 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 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, operators_dict=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 diff --git a/src/python_rule_engine/errors.py b/src/python_rule_engine/errors.py new file mode 100644 index 0000000..4fe8ec5 --- /dev/null +++ b/src/python_rule_engine/errors.py @@ -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, ValidationError): + """ 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 """ + diff --git a/src/python_rule_engine/exceptions.py b/src/python_rule_engine/exceptions.py deleted file mode 100644 index 301c97b..0000000 --- a/src/python_rule_engine/exceptions.py +++ /dev/null @@ -1,10 +0,0 @@ -class RuleEngineBaseException(Exception): - """ Base Rule Engine Exception """ - -class DuplicateOperatorError(RuleEngineBaseException): - """ Raised when there is already a operator with the same ID - loaded in the engine """ - -class JSONPathValueNotFound(RuleEngineBaseException): - """ Raised when the value indicated by 'path' could not be found - on the object.""" diff --git a/src/python_rule_engine/json_path.py b/src/python_rule_engine/json_path.py index a97e5f2..e9bea7c 100644 --- a/src/python_rule_engine/json_path.py +++ b/src/python_rule_engine/json_path.py @@ -3,7 +3,7 @@ from jsonpath_ng import parse from pydantic_core import core_schema -from .exceptions import JSONPathValueNotFound +from .errors import JSONPathValueNotFoundError class JSONPath(str): @@ -19,7 +19,7 @@ def __get_pydantic_core_schema__(cls, source_type, handler): def get_value_from(self, obj: Any) -> Any: result = self.parsed.find(obj) if len(result) == 0: - raise JSONPathValueNotFound(f"Value not found at path {self}") + raise JSONPathValueNotFoundError(f"Value not found at path {self}") if len(result) == 1: return result[0].value diff --git a/src/python_rule_engine/models/rule.py b/src/python_rule_engine/models/rule.py index f4bcc63..a426533 100644 --- a/src/python_rule_engine/models/rule.py +++ b/src/python_rule_engine/models/rule.py @@ -3,7 +3,7 @@ from pydantic import BaseModel, Field, model_validator from pydantic.json_schema import SkipJsonSchema -from ..operators import Operator, DEFAULT_OPERATORS +from ..operators import Operator from .multi_condition import MultiCondition @@ -14,9 +14,10 @@ class Rule(BaseModel): 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(DEFAULT_OPERATORS, - description="A dictionary of operators to use in the conditions", - exclude=True) + 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 diff --git a/src/python_rule_engine/models/simple_condition.py b/src/python_rule_engine/models/simple_condition.py index 474c4e3..2aa4823 100644 --- a/src/python_rule_engine/models/simple_condition.py +++ b/src/python_rule_engine/models/simple_condition.py @@ -5,9 +5,9 @@ from pydantic import Field, model_validator, PrivateAttr from pydantic.json_schema import SkipJsonSchema -from ..exceptions import JSONPathValueNotFound +from ..errors import JSONPathValueNotFoundError from ..json_path import JSONPath -from ..operators import Operator, DEFAULT_OPERATORS +from ..operators import Operator from .condition import Condition @@ -16,8 +16,7 @@ class SimpleCondition(Condition): _operator_object: SkipJsonSchema[Optional[Operator]] = PrivateAttr(None) path: Optional[JSONPath] = Field(None, description="A JSONPath expression to extract a value from the object") - operator: str = Field(..., description="The operator to use for the comparison", - examples=[o.id for o in DEFAULT_OPERATORS]) + operator: str = Field(..., description="The operator to use for the comparison") value: Any = Field(..., description="The value to compare against") params: dict = Field({}, description="Additional parameters for the operator") match_detail: SkipJsonSchema[Any] = None @@ -65,6 +64,6 @@ def evaluate(self, obj: dict): match, match_detail = self._operator_object.match(path_obj) self.match = match self.match_detail = self.__obj_to_dict(match_detail) - except JSONPathValueNotFound as e: + except JSONPathValueNotFoundError as e: self.match = False self.match_detail = str(e) diff --git a/tests/test_rule_decoder.py b/tests/test_rule_decoder.py new file mode 100644 index 0000000..18aa0cb --- /dev/null +++ b/tests/test_rule_decoder.py @@ -0,0 +1,37 @@ +from python_rule_engine.decoder import RuleDecoder +from python_rule_engine.errors import RuleDecodeError + + +def test_rule_decode_success(): + d = RuleDecoder() + + r = { + "name": "test", + "conditions": { + "all": [ + { + "path": "$.foo.bar", + "operator": "equal", + "value": "test" + } + ] + } + } + + try: + d.decode_rule(r) + except: + assert False + +def test_rule_decode_error(): + d = RuleDecoder() + + r = "foobar" + + try: + d.decode_str_rule(r) + assert False + except RuleDecodeError: + assert True + except Exception as e: + assert False From d4e753a30094d616c8a45185ce58dd13a827754d Mon Sep 17 00:00:00 2001 From: santalvarez_meli Date: Sat, 7 Jun 2025 10:46:04 -0300 Subject: [PATCH 6/9] Add missing tests --- README.md | 33 -------- src/python_rule_engine/decoder.py | 15 +--- src/python_rule_engine/errors.py | 2 +- src/python_rule_engine/models/condition.py | 2 +- .../models/simple_condition.py | 7 +- tests/test_rule_decoder.py | 77 +++++++++++++++++-- tests/test_rule_engine.py | 50 ++++++++++++ tests/test_simple_condition.py | 15 ++++ 8 files changed, 144 insertions(+), 57 deletions(-) diff --git a/README.md b/README.md index 897f8fe..b8949ba 100644 --- a/README.md +++ b/README.md @@ -51,36 +51,3 @@ engine = RuleEngine([rule]) results = engine.evaluate(obj) ``` - -## Generating rules with ChatGPT -You can leverage the Rule's model definition to generate rules using ChatGPT. The following example shows how to generate a rule using OpenAI's API. More info on how to use the API can be found [here](https://platform.openai.com/docs/guides/structured-outputs). - -```python -from openai import OpenAI -from python_rule_engine import Rule - - -obj = { - "player": { - "name": "Lionel", - "age": 34, - } -} - -client = OpenAI(api_key="your_api_key") - -completion = client.beta.chat.completions.parse( - model="gpt-4o-2024-08-06", - messages=[ - {"role": "system", "content": "Generate a json rule that matches the following conditions:"}, - {"role": "user", "content": "Create a rule that matches if the player's name is Lionel and the player's age bigger than 30"}, - ], - response_format=Rule, -) - -rule = completion.choices[0].message.parsed - -engine = RuleEngine([rule]) - -assert engine.evaluate(obj).match -``` \ No newline at end of file diff --git a/src/python_rule_engine/decoder.py b/src/python_rule_engine/decoder.py index ec728c9..b0e3c6e 100644 --- a/src/python_rule_engine/decoder.py +++ b/src/python_rule_engine/decoder.py @@ -47,18 +47,5 @@ def decode_str_rule(self, rule: str) -> Rule: try: rule_dict = json.loads(rule) return Rule(**rule_dict, operators_dict=self.operators) - except ValidationError as e: + except (JSONDecodeError, ValidationError) as e: raise InvalidRuleSchemaError from e - except JSONDecodeError as e: - raise InvalidRuleJSONError - - def rule_schema(self) -> Dict[str, Any]: - schema = Rule.model_json_schema() - - operator_names = list(self.operators.keys()) - - schema["$defs"]["SimpleCondition"]["properties"]["operator"]["examples"] = operator_names - - return schema - - diff --git a/src/python_rule_engine/errors.py b/src/python_rule_engine/errors.py index 4fe8ec5..374724c 100644 --- a/src/python_rule_engine/errors.py +++ b/src/python_rule_engine/errors.py @@ -14,7 +14,7 @@ class JSONPathValueNotFoundError(RuleEngineBaseError): class RuleDecodeError(RuleEngineBaseError): """ Base error for all rule decoding errors """ -class InvalidRuleSchemaError(RuleDecodeError, ValidationError): +class InvalidRuleSchemaError(RuleDecodeError): """ Raised when the field of the provided rule don't match with the rule's schema """ class InvalidRuleTypeError(RuleDecodeError): diff --git a/src/python_rule_engine/models/condition.py b/src/python_rule_engine/models/condition.py index f28f7a2..7224559 100644 --- a/src/python_rule_engine/models/condition.py +++ b/src/python_rule_engine/models/condition.py @@ -6,4 +6,4 @@ class Condition(BaseModel): match: SkipJsonSchema[bool] = False def evaluate(self, obj): - raise NotImplementedError + """ Evaluate the condition on an object """ diff --git a/src/python_rule_engine/models/simple_condition.py b/src/python_rule_engine/models/simple_condition.py index 2aa4823..42926e0 100644 --- a/src/python_rule_engine/models/simple_condition.py +++ b/src/python_rule_engine/models/simple_condition.py @@ -12,15 +12,16 @@ class SimpleCondition(Condition): - operators_dict: SkipJsonSchema[Dict] = Field(..., exclude=True, repr=False) - _operator_object: SkipJsonSchema[Optional[Operator]] = PrivateAttr(None) - path: Optional[JSONPath] = Field(None, description="A JSONPath expression to extract a value from the object") operator: str = Field(..., description="The operator to use for the comparison") value: Any = Field(..., description="The value to compare against") params: dict = Field({}, description="Additional parameters for the operator") match_detail: SkipJsonSchema[Any] = None + operators_dict: SkipJsonSchema[Dict] = Field(..., exclude=True, repr=False) + _operator_object: SkipJsonSchema[Optional[Operator]] = PrivateAttr(None) + + def __deepcopy__(self, memo=None): return self.model_copy(deep=False) diff --git a/tests/test_rule_decoder.py b/tests/test_rule_decoder.py index 18aa0cb..7de8720 100644 --- a/tests/test_rule_decoder.py +++ b/tests/test_rule_decoder.py @@ -1,8 +1,20 @@ from python_rule_engine.decoder import RuleDecoder -from python_rule_engine.errors import RuleDecodeError +from python_rule_engine.errors import (DuplicateOperatorError, + InvalidRuleSchemaError, + InvalidRuleTypeError) +from python_rule_engine.operators import Equal -def test_rule_decode_success(): +def test_duplicate_operator_error(): + try: + RuleDecoder(custom_operators=[Equal]) + assert False + except DuplicateOperatorError: + assert True + except Exception: + assert False + +def test_decode_rule_success(): d = RuleDecoder() r = { @@ -23,7 +35,36 @@ def test_rule_decode_success(): except: assert False -def test_rule_decode_error(): +def test_decode_rule_type_error(): + d = RuleDecoder() + + r = 123 + + try: + d.decode_rule(r) + assert False + except InvalidRuleTypeError: + assert True + except Exception: + assert False + +def test_decode_rule_schema_error(): + d = RuleDecoder() + + r = { + "name": "test", + "conditions": "foobar" + } + + try: + d.decode_rule(r) + assert False + except InvalidRuleSchemaError: + assert True + except Exception: + assert False + +def test_str_rule_decode_schema_error(): d = RuleDecoder() r = "foobar" @@ -31,7 +72,33 @@ def test_rule_decode_error(): try: d.decode_str_rule(r) assert False - except RuleDecodeError: + except InvalidRuleSchemaError: + assert True + except Exception: + assert False + +def test_str_rule_decode_type_error(): + d = RuleDecoder() + + r = 123 + + try: + d.decode_str_rule(r) + assert False + except InvalidRuleTypeError: assert True - except Exception as e: + except Exception: + assert False + +def test_decode_str_rules_success(): + d = RuleDecoder() + + r = [ + "{\"name\": \"test\", \"conditions\": {\"all\": [{\"path\": \"$.foo.bar\", \"operator\": \"equal\", \"value\": \"test\"}]}}", + "{\"name\": \"test\", \"conditions\": {\"all\": [{\"path\": \"$.foo.bar\", \"operator\": \"equal\", \"value\": \"test\"}]}}" + ] + + try: + d.decode_str_rules(r) + except Exception: assert False diff --git a/tests/test_rule_engine.py b/tests/test_rule_engine.py index ffda502..22e772e 100644 --- a/tests/test_rule_engine.py +++ b/tests/test_rule_engine.py @@ -102,3 +102,53 @@ def test_rule_with_not_condition(): results = engine.evaluate(obj) assert results[0].conditions.match is True + +def test_rule_engine_add_rule(): + obj = { + "person": { + "name": "Santiago", + "last_name": "Alvarez" + } + } + engine = RuleEngine([]) + + engine.add_rule({"name": "test", "conditions": {"all": [{"path": "$.person.name", "value": "Santiago", "operator": "equal"}]}}) + + results = engine.evaluate(obj) + + assert results[0].conditions.match is True + +def test_rule_engine_add_str_rule(): + obj = { + "person": { + "name": "Santiago", + "last_name": "Alvarez" + } + } + + engine = RuleEngine([]) + + engine.add_str_rule('{"name": "test", "conditions": {"all": [{"path": "$.person.name", "value": "Santiago", "operator": "equal"}]}}') + + results = engine.evaluate(obj) + + assert results[0].conditions.match is True + +def test_rule_engine_remove_rule(): + obj = { + "person": { + "name": "Santiago", + "last_name": "Alvarez" + } + } + engine = RuleEngine([]) + + engine.add_rule({"name": "test", "conditions": {"all": [{"path": "$.person.name", "value": "Santiago", "operator": "equal"}]}}) + + results = engine.evaluate(obj) + + assert results[0].conditions.match is True + + engine.remove_rule("test") + + assert not engine.evaluate(obj) diff --git a/tests/test_simple_condition.py b/tests/test_simple_condition.py index 36921be..3528153 100644 --- a/tests/test_simple_condition.py +++ b/tests/test_simple_condition.py @@ -1,4 +1,6 @@ from python_rule_engine.models.simple_condition import SimpleCondition +import pytest +from python_rule_engine.operators import DEFAULT_OPERATORS def test_deepcopy_operator_not_copied(operators_dict): @@ -13,3 +15,16 @@ def test_deepcopy_operator_not_copied(operators_dict): assert sc_copy.match == False +def test_invalid_operator(): + with pytest.raises(ValueError): + SimpleCondition(path="$.foo", operator="invalid", + value="bar", operators_dict={}) + +def test_evaluate_json_path_not_found(): + operators_dict = {op.id: op for op in DEFAULT_OPERATORS} + sc = SimpleCondition(path="$.foo", operator="equal", + value="bar", operators_dict=operators_dict) + sc.evaluate({}) + assert sc.match == False + assert sc.match_detail == "Value not found at path $.foo" + From 8c1b7d02a0e5ca48e74543243e2f666066c3438d Mon Sep 17 00:00:00 2001 From: santalvarez_meli Date: Sat, 7 Jun 2025 10:50:35 -0300 Subject: [PATCH 7/9] Add downloads badges --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index b8949ba..b6d2784 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,9 @@ [![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. From f97e55124a68a2659908ffd96b60eb41c1b7bcf7 Mon Sep 17 00:00:00 2001 From: santalvarez_meli Date: Sat, 7 Jun 2025 16:32:03 -0300 Subject: [PATCH 8/9] Couple more rule engine tests --- tests/test_rule_engine.py | 88 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/tests/test_rule_engine.py b/tests/test_rule_engine.py index 22e772e..7889245 100644 --- a/tests/test_rule_engine.py +++ b/tests/test_rule_engine.py @@ -1,6 +1,94 @@ from python_rule_engine import RuleEngine +def test_two_objects_with_same_rule_one_match(): + obj = { + "person": { + "name": "Santiago", + "last_name": "Alvarez" + } + } + + rule = { + "name": "basic_rule", + "description": "Basic rule to test the engine", + "extra": { + "some_field": "some_value" + }, + "conditions": { + "all": [ + { + "path": "$.person.name", + "value": "Santiago", + "operator": "equal" + }, + { + "path": "$.person.last_name", + "value": "Alvarez", + "operator": "equal" + } + ] + } + } + + engine = RuleEngine([rule]) + + results = engine.evaluate(obj) + + assert results[0].conditions.match is True + + obj["person"]["name"] = "Martin" + + results = engine.evaluate(obj) + + assert len(results) == 0 + +def test_two_objects_with_same_rule_match_different_match_detail(): + obj = { + "person": { + "name": "Santiago", + } + } + + rule = { + "name": "basic_rule", + "description": "Basic rule to test the engine", + "extra": { + "some_field": "some_value" + }, + "conditions": { + "any": [ + { + "path": "$.person.name", + "value": "Santiago", + "operator": "equal" + }, + { + "path": "$.person.name", + "value": "Martin", + "operator": "equal" + } + ] + } + } + + engine = RuleEngine([rule]) + + results = engine.evaluate(obj) + + assert results[0].conditions.match is True + assert results[0].conditions.any[0].match is True + assert results[0].conditions.any[0].match_detail == "Santiago" + + obj["person"]["name"] = "Martin" + + results = engine.evaluate(obj) + + assert results[0].conditions.match is True + assert results[0].conditions.any[0].match is False + assert results[0].conditions.any[0].match_detail == "Martin" + assert results[0].conditions.any[1].match_detail == "Martin" + def test_rule_with_all_condition(): obj = { "person": { From 23123fddbdf64f01a1e7905efd506d0310193081 Mon Sep 17 00:00:00 2001 From: santalvarez_meli Date: Sat, 7 Jun 2025 16:34:25 -0300 Subject: [PATCH 9/9] Fix docs --- docs/rules.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/rules.md b/docs/rules.md index 29f144c..fe5a3af 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -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.