diff --git a/README.md b/README.md index f19a09f..b6d2784 100644 --- a/README.md +++ b/README.md @@ -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 @@ -49,7 +54,3 @@ engine = RuleEngine([rule]) results = engine.evaluate(obj) ``` - -## Rule Format - -Find more info about the rules [here](docs/rules.md). 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. 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..3258228 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 .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..b0e3c6e --- /dev/null +++ b/src/python_rule_engine/decoder.py @@ -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 diff --git a/src/python_rule_engine/engine.py b/src/python_rule_engine/engine.py index 3f89621..fa9e81a 100644 --- a/src/python_rule_engine/engine.py +++ b/src/python_rule_engine/engine.py @@ -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 @@ -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: diff --git a/src/python_rule_engine/errors.py b/src/python_rule_engine/errors.py new file mode 100644 index 0000000..374724c --- /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): + """ 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 fe0d9cf..e9bea7c 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 +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 diff --git a/src/python_rule_engine/models/condition.py b/src/python_rule_engine/models/condition.py index df8b100..7224559 100644 --- a/src/python_rule_engine/models/condition.py +++ b/src/python_rule_engine/models/condition.py @@ -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 """ diff --git a/src/python_rule_engine/models/multi_condition.py b/src/python_rule_engine/models/multi_condition.py index d1a0bae..51928d8 100644 --- a/src/python_rule_engine/models/multi_condition.py +++ b/src/python_rule_engine/models/multi_condition.py @@ -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 diff --git a/src/python_rule_engine/models/rule.py b/src/python_rule_engine/models/rule.py index d55144d..a426533 100644 --- a/src/python_rule_engine/models/rule.py +++ b/src/python_rule_engine/models/rule.py @@ -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 diff --git a/src/python_rule_engine/models/simple_condition.py b/src/python_rule_engine/models/simple_condition.py index 3bc9796..42926e0 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 ..exceptions import JSONPathValueNotFound +from pydantic import Field, model_validator, PrivateAttr +from pydantic.json_schema import SkipJsonSchema + +from ..errors import JSONPathValueNotFoundError 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 + 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 - 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'] - 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,9 +62,9 @@ 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: + except JSONPathValueNotFoundError as e: self.match = False self.match_detail = str(e) diff --git a/src/python_rule_engine/operators.py b/src/python_rule_engine/operators.py index 886fe56..2e74aaa 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, List, Type class Operator(ABC): @@ -92,3 +91,7 @@ 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: List[Type[Operator]] = [ + 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_rule_decoder.py b/tests/test_rule_decoder.py new file mode 100644 index 0000000..7de8720 --- /dev/null +++ b/tests/test_rule_decoder.py @@ -0,0 +1,104 @@ +from python_rule_engine.decoder import RuleDecoder +from python_rule_engine.errors import (DuplicateOperatorError, + InvalidRuleSchemaError, + InvalidRuleTypeError) +from python_rule_engine.operators import Equal + + +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 = { + "name": "test", + "conditions": { + "all": [ + { + "path": "$.foo.bar", + "operator": "equal", + "value": "test" + } + ] + } + } + + try: + d.decode_rule(r) + except: + assert False + +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" + + try: + d.decode_str_rule(r) + assert False + 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: + 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..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": { @@ -102,3 +190,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 new file mode 100644 index 0000000..3528153 --- /dev/null +++ b/tests/test_simple_condition.py @@ -0,0 +1,30 @@ +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): + 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 + + +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" +