Giter Club home page Giter Club logo

rule-engine's Introduction

Rule Engine

GitHub Workflow Status (branch) PyPI

A lightweight, optionally typed expression language with a custom grammar for matching arbitrary Python objects.

Documentation is available at https://zeroSteiner.github.io/rule-engine/.

Warning:The next major version (5.0) will remove support Python versions 3.6 and 3.7. There is currently no timeline for its release.

Rule Engine expressions are written in their own language, defined as strings in Python. The syntax is most similar to Python with some inspiration from Ruby. Some features of this language includes:

  • Optional type hinting
  • Matching strings with regular expressions
  • Datetime datatypes
  • Compound datatypes (equivalents for Python dict, list and set types)
  • Data attributes
  • Thread safety

Example Usage

The following example demonstrates the basic usage of defining a rule object and applying it to two dictionaries, showing that one matches while the other does not. See Getting Started for more information.

import rule_engine
# match a literal first name and applying a regex to the email
rule = rule_engine.Rule(
    'first_name == "Luke" and email =~ ".*@rebels.org$"'
) # => <Rule text='first_name == "Luke" and email =~ ".*@rebels.org$"' >
rule.matches({
    'first_name': 'Luke', 'last_name': 'Skywalker', 'email': '[email protected]'
}) # => True
rule.matches({
   'first_name': 'Darth', 'last_name': 'Vader', 'email': '[email protected]'
}) # => False

The next example demonstrates the optional type system. A custom context is created that defines two symbols, one string and one float. Because symbols are defined, an exception will be raised if an unknown symbol is specified or an invalid operation is used. See Type Hinting for more information.

import rule_engine
# define the custom context with two symbols
context = rule_engine.Context(type_resolver=rule_engine.type_resolver_from_dict({
    'first_name': rule_engine.DataType.STRING,
    'age': rule_engine.DataType.FLOAT
}))

# receive an error when an unknown symbol is used
rule = rule_engine.Rule('last_name == "Vader"', context=context)
# => SymbolResolutionError: last_name

# receive an error when an invalid operation is used
rule = rule_engine.Rule('first_name + 1', context=context)
# => EvaluationError: data type mismatch

Want to give the rule expression language a try? Checkout the Debug REPL that makes experimentation easy. After installing just run python -m rule_engine.debug_repl.

Installation

Install the latest release from PyPi using pip install rule-engine. Releases follow Semantic Versioning to indicate in each new version whether it fixes bugs, adds features or breaks backwards compatibility. See the Change Log for a curated list of changes.

Credits

  • Spencer McIntyre - zeroSteiner GitHub followers

License

The Rule Engine library is released under the BSD 3-Clause license. It is able to be used for both commercial and private purposes. For more information, see the LICENSE file.

rule-engine's People

Contributors

djmattyg007 avatar kamforka avatar kuldeeps48 avatar patrickcd avatar rwspielman avatar shivamtrivedi01 avatar zerosteiner avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

rule-engine's Issues

debug_repl: Error when trying to parse datetime string

Thank you for your OSS contribution!

I am not sure what I am doing wrong when using the debug_repl. I want to parse a datetime string:

± |feat/fbs-217/bre ✗| → python -m rule_engine.debug_repl
rule > $parse_datetime("2020-01-01")
Traceback (most recent call last):
  File "/Users/Q187392/dev/z/fbs-csf/.venv/lib/python3.11/site-packages/rule_engine/debug_repl.py", line 118, in main
    result = rule.evaluate(thing)
             ^^^^^^^^^^^^^^^^^^^^
  File "/Users/Q187392/dev/z/fbs-csf/.venv/lib/python3.11/site-packages/rule_engine/engine.py", line 546, in evaluate
    return self.statement.evaluate(thing)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/Q187392/dev/z/fbs-csf/.venv/lib/python3.11/site-packages/rule_engine/ast.py", line 1111, in evaluate
    return self.expression.evaluate(thing)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/Q187392/dev/z/fbs-csf/.venv/lib/python3.11/site-packages/rule_engine/ast.py", line 1052, in evaluate
    function_name = function.__name__ + '?'
                    ^^^^^^^^^^^^^^^^^
AttributeError: 'functools.partial' object has no attribute '__name__'
rule >

Also tried: 2020-01-01T00:00:00.000000+00:00, 2020-01-01T00:00:00+00:00.

I understand this should work. What am I doing wrong?

how do I coerce string to datetime?

I have data structures that may contain strings that are serialized dates.

Simplified I'm trying to read

{
    'config':[
        {'created': '2023-06-27T17:27:22.803255939-07:00'}
    ]
}

I have a rule like "[ cfg for cfg in config if cfg.created > $today ]" I'm trying to have it explicitly cast to a datetime so I can evaluate the date, but no matter what I try, I can't find a flexible way to resolve it and keep a flexible description of the given object

When parsing I get

>>> r = Rule("[ cfg for cfg in config if cfg.created > $today ]",)
>>> r.matches(i)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/Users/andrew.woodward/.pyenv/versions/3.8.16/lib/python3.8/site-packages/rule_engine/engine.py", line 636, in matches
    return bool(self.evaluate(thing))
  File "/Users/andrew.woodward/.pyenv/versions/3.8.16/lib/python3.8/site-packages/rule_engine/engine.py", line 625, in evaluate
    return self.statement.evaluate(thing)
  File "/Users/andrew.woodward/.pyenv/versions/3.8.16/lib/python3.8/site-packages/rule_engine/ast.py", line 1056, in evaluate
    return self.expression.evaluate(thing)
  File "/Users/andrew.woodward/.pyenv/versions/3.8.16/lib/python3.8/site-packages/rule_engine/ast.py", line 693, in evaluate
    if self.condition is None or self.condition.evaluate(thing):
  File "/Users/andrew.woodward/.pyenv/versions/3.8.16/lib/python3.8/site-packages/rule_engine/ast.py", line 386, in evaluate
    return self._evaluator(thing)
  File "/Users/andrew.woodward/.pyenv/versions/3.8.16/lib/python3.8/site-packages/rule_engine/ast.py", line 591, in __op_arithmetic
    return self.__op_arithmetic_values(op, left_value, right_value)
  File "/Users/andrew.woodward/.pyenv/versions/3.8.16/lib/python3.8/site-packages/rule_engine/ast.py", line 607, in __op_arithmetic_values
    raise errors.EvaluationError('data type mismatch')
rule_engine.errors.EvaluationError: data type mismatch

If I change this to what appears to be the way to coerce it to a date, it still freaks out (both cfg.created.date and (cfg.created).date)

>>> r = Rule("[ cfg for cfg in config if (cfg.created).date > $today ]",)
>>> r.matches(i)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/Users/andrew.woodward/.pyenv/versions/3.8.16/lib/python3.8/site-packages/rule_engine/engine.py", line 636, in matches
    return bool(self.evaluate(thing))
  File "/Users/andrew.woodward/.pyenv/versions/3.8.16/lib/python3.8/site-packages/rule_engine/engine.py", line 625, in evaluate
    return self.statement.evaluate(thing)
  File "/Users/andrew.woodward/.pyenv/versions/3.8.16/lib/python3.8/site-packages/rule_engine/ast.py", line 1056, in evaluate
    return self.expression.evaluate(thing)
  File "/Users/andrew.woodward/.pyenv/versions/3.8.16/lib/python3.8/site-packages/rule_engine/ast.py", line 693, in evaluate
    if self.condition is None or self.condition.evaluate(thing):
  File "/Users/andrew.woodward/.pyenv/versions/3.8.16/lib/python3.8/site-packages/rule_engine/ast.py", line 386, in evaluate
    return self._evaluator(thing)
  File "/Users/andrew.woodward/.pyenv/versions/3.8.16/lib/python3.8/site-packages/rule_engine/ast.py", line 589, in __op_arithmetic
    left_value = self.left.evaluate(thing)
  File "/Users/andrew.woodward/.pyenv/versions/3.8.16/lib/python3.8/site-packages/rule_engine/ast.py", line 806, in evaluate
    raise attribute_error from None
  File "/Users/andrew.woodward/.pyenv/versions/3.8.16/lib/python3.8/site-packages/rule_engine/ast.py", line 790, in evaluate
    value = self.context.resolve_attribute(thing, resolved_obj, self.name)
  File "/Users/andrew.woodward/.pyenv/versions/3.8.16/lib/python3.8/site-packages/rule_engine/engine.py", line 536, in resolve_attribute
    return self.__resolve_attribute(thing, object_, name)
  File "/Users/andrew.woodward/.pyenv/versions/3.8.16/lib/python3.8/site-packages/rule_engine/engine.py", line 140, in __call__
    resolver = self._get_resolver(object_type, name, thing=thing)
  File "/Users/andrew.woodward/.pyenv/versions/3.8.16/lib/python3.8/site-packages/rule_engine/engine.py", line 158, in _get_resolver
    raise errors.AttributeResolutionError(name, object_type, thing=thing, suggestion=suggest_symbol(name, attribute_resolvers.keys()))
rule_engine.errors.AttributeResolutionError: ('date', <_DataTypeDef name=STRING python_type=str >)

If I load a context and explicitly declare the date it can resolve the value, but It doesn't look like there is a way to do this with out declaring the type for every possible object we want to access.

ctx = rule_engine.Context(
    resolver=rule_engine.resolve_attribute,
    type_resolver = rule_engine.type_resolver_from_dict({
        'created': rule_engine.DataType.DATETIME,
    })
)

>>> r = Rule("[ cfg for cfg in config if cfg.created > $today ]", context = ctx)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/Users/andrew.woodward/.pyenv/versions/3.8.16/lib/python3.8/site-packages/rule_engine/engine.py", line 578, in __init__
    self.statement = self.parser.parse(text, context)
  File "/Users/andrew.woodward/.pyenv/versions/3.8.16/lib/python3.8/site-packages/rule_engine/parser.py", line 106, in parse
    return result.build()
  File "/Users/andrew.woodward/.pyenv/versions/3.8.16/lib/python3.8/site-packages/rule_engine/parser.py", line 60, in build
    return constructor(*self.args, **self.kwargs)
  File "/Users/andrew.woodward/.pyenv/versions/3.8.16/lib/python3.8/site-packages/rule_engine/ast.py", line 1053, in build
    return cls(context, expression.build(), **kwargs).reduce()
  File "/Users/andrew.woodward/.pyenv/versions/3.8.16/lib/python3.8/site-packages/rule_engine/parser.py", line 60, in build
    return constructor(*self.args, **self.kwargs)
  File "/Users/andrew.woodward/.pyenv/versions/3.8.16/lib/python3.8/site-packages/rule_engine/ast.py", line 672, in build
    iterable = iterable.build()
  File "/Users/andrew.woodward/.pyenv/versions/3.8.16/lib/python3.8/site-packages/rule_engine/parser.py", line 60, in build
    return constructor(*self.args, **self.kwargs)
  File "/Users/andrew.woodward/.pyenv/versions/3.8.16/lib/python3.8/site-packages/rule_engine/ast.py", line 102, in build
    return cls(*args, **kwargs).reduce()
  File "/Users/andrew.woodward/.pyenv/versions/3.8.16/lib/python3.8/site-packages/rule_engine/ast.py", line 991, in __init__
    type_hint = context.resolve_type(name, scope=scope)
  File "/Users/andrew.woodward/.pyenv/versions/3.8.16/lib/python3.8/site-packages/rule_engine/engine.py", line 555, in resolve_type
    return self.__type_resolver(name)
  File "/Users/andrew.woodward/.pyenv/versions/3.8.16/lib/python3.8/site-packages/rule_engine/engine.py", line 96, in _type_resolver
    raise errors.SymbolResolutionError(name, suggestion=suggest_symbol(name, type_map.keys()))
rule_engine.errors.SymbolResolutionError: config
>>> 

Escape Charachters

Hello zeroSteiner, thank you for this excellent engine.
I wander how it is possilbe to add escaple charachters for special charachters like @ or -
for example this rule RULE= '@ffice365-AuditData_Operation == "New Account" ' is returning rule_engine.errors.RuleSyntaxError: ("syntax error (illegal character '@')", LexToken(error,'@AuditData_Operation == "New Account" ',1,9))

and this rule 'Office365-AuditData_Operation == "New Account" ' doesn't work unless we replace the - with _ such as 'Office365_AuditData_Operation == "New Account" '

Thank you for you work,

Is it possible to access str key with dot?

Is it possible to use a key with a dot instead of trying to browse a dictionary? For example:

r = rule_engine.Rule("field_1=='aze' and result.field_2 == 'qsd'")
in = {"field_1": "aze", "result.field_2": "qsd"}

r.matches(in)
SymbolResolutionError: result

I tried 'result.field_2' == \"qsd\" but I think it's interpreted as a string instead to use the key name "result.field_2".

We received some flat JSON, so what's the best way to do it?

request for help for weird problem: incomplete things may result in rule.matches

Hi @zeroSteiner,

love your work in this open source contribution. Thanks!

I currently work on evaluating purely "boolean" expressions. a simplified example is: (a in [1,2] and c=='abc') or b==1. My "things" are not always fully defined. e.g. rule.match({'b':1}) the "thing" is not completly defined and will throw the SymbolResolutionError rightly so.

If I define default_value=None in the context as work around, I get passed the SymbolResolutionError and in the above example will receive a "true" as the OR will never evaluate to anything else but true. Actually it would not require to evaluate the left-hand-side, as the right-hand-side of the OR already fully defines the result.

def test_rule_partial_evaluate():
    context = rule_engine.Context(default_value=None)
    rule = rule_engine.Rule("""(a in [1,2] and c=='abc') or b==1""", context=context)
    assert rule.matches({'b':1})==True    # correct result, because a=None, c=None... and will not change anything anway.
    assert rule.matches({'a':3})==False   # false and will only get true with providing a value for b. value for c is not needed
    assert rule.matches({'a':1})==False   # false can become true if values for c or b are provided. 
    assert rule.matches({'a':1, 'c':'abc'})==True # correct result, because b does not matter

My problem with the Default_Value=None work around is, that I cannot determine easily if the "False" is really false or false because it could not completely evaluate.

After a match I would love to have list of symbols that tell me, if I need more attributes defined in the "thing".

Can I convice the rule-engine to "partially" evaluate a rule with a incomplete thing (maybe with the reduce function) and see if either the rule is reduced to a literal or some expression is left over? That would of course require that if b is "1" in thing, the reduced rule will be "true" literally.

Hope you understand what I am getting at?

Cheers
Carsten

forward chaining

Hey, thanks for creating and sharing this library! Are there any plans in the future to implement forward chaining or backward chaining?

Rule compare

May be interesting make Rule comparison

r1 = rule_engine.Rule("a == 'HELLO' and b == 'BYE'")
r2 = rule_engine.Rule("b == 'BYE' and a == 'HELLO'")
r1 == r2
True

Mypy support

This package could benefit from type annotations, so that it can be type-checked when used in other software.

Negative numbers not supported

This is the first time I'm facing this issue. When the context contains a negative number, the matches method throws the error SymbolResolutionError: field_amount in this scenario.

Is there any way to work with negative values or the engine does not support them?

import rule_engine


def type_resolver(name):
    if name == "field_age":
        return rule_engine.DataType.FLOAT
    elif name == "field_amount":
        return rule_engine.DataType.FLOAT
    raise rule_engine.errors.SymbolResolutionError(name)


engine_context = rule_engine.Context(type_resolver=type_resolver)

context = {"field_age": -1}

sentence = "field_age < 0 and field_amount != 4"

rule = rule_engine.Rule(sentence, context=engine_context)
result = rule.matches(context)

Stable version for rule_engine

Please suggest the stable version for rule_engine to install.
When I install the latest version 3.3.1 for my existing project, I get timezone issues and also some features are not working.

Rule Help for List in List

Question for @zeroSteiner regarding nested lists. I have the following block of code:

datum = {
        "locations": "",
        "package": {
            "Package": {
                "pm": "npm",
                "group": None,
                "name": "minimist",
                "version": "1.2.0",
                "vendor": None,
                "fixVersions": ["[1.2.6]"],
                "impactPaths": [["npm://covert:1.0.0", "npm://minimist:1.2.0"], ["npm://some_package:1.0.0", "npm://some_dependency:1.2.0"]]
            },
            "Vulnerabilities": [{
                "id": "XRAY-256849",
                "title": "Critical vulnerability found in component temp_react_core",
                "description": "Prototype pollution vulnerability in function parseQuery in parseQuery.js in webpack loader-utils 2.0.0 via the name variable in parseQuery.js.",
                "cvssScore": "9.8",
                "cvssVector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
                "cve": "CVE-2022-37601"
            }]
        }
    }

and I am having a time of it trying to create the rule that checks the impact paths at package.Package.impactPaths. I have a scratch file with the following code

complex_list = [["list1_1", "list1_2"], ["list2_1", "list2_2"]]

print('list1_1' in [val for sublist in complex_list for val in sublist])

single_list = [["list1_1", "list1_2"]]

print('list1_1' in [val for sublist in single_list for val in sublist])

Both of those print True, but if I create a rule with the same code

rule_engine.Rule("'some_dependency' in [path for subpath in package.Package.impactPaths for path in subpath]")

I get the following errors:

Traceback (most recent call last):
  File "C:\Program Files (x86)\JetBrains\PyCharm 2022.3.1\plugins\python\helpers\pydev\_pydevd_bundle\pydevd_exec2.py", line 3, in Exec
    exec(exp, global_vars, local_vars)
  File "<input>", line 1, in <module>
  File "C:\Users\bparr\.virtualenvs\cvss-rescore-04yqQuLz\lib\site-packages\rule_engine\engine.py", line 578, in __init__
    self.statement = self.parser.parse(text, context)
  File "C:\Users\bparr\.virtualenvs\cvss-rescore-04yqQuLz\lib\site-packages\rule_engine\parser.py", line 102, in parse
    result = self._parser.parse(text, **kwargs)
  File "C:\Users\bparr\.virtualenvs\cvss-rescore-04yqQuLz\lib\site-packages\ply\yacc.py", line 333, in parse
    return self.parseopt_notrack(input, lexer, debug, tracking, tokenfunc)
  File "C:\Users\bparr\.virtualenvs\cvss-rescore-04yqQuLz\lib\site-packages\ply\yacc.py", line 1201, in parseopt_notrack
    tok = call_errorfunc(self.errorfunc, errtoken, self)
  File "C:\Users\bparr\.virtualenvs\cvss-rescore-04yqQuLz\lib\site-packages\ply\yacc.py", line 192, in call_errorfunc
    r = errorfunc(token)
  File "C:\Users\bparr\.virtualenvs\cvss-rescore-04yqQuLz\lib\site-packages\rule_engine\parser.py", line 287, in p_error
    raise errors.RuleSyntaxError('syntax error', token)
rule_engine.errors.RuleSyntaxError: ('syntax error', LexToken(FOR,'for',1,70))

Any suggestions? Any help is greatly appreciated

Arithmetic expressions with powers

Hi @zeroSteiner

I was going through some expressions and noticed something for arithmetic expressions using powers. The parser parses powers as a float type (mostly because it uses the math.pow method) versus other operators which which result in decimal.Decimal.

_op_pow = functools.partialmethod(__op_arithmetic, math.pow)

this results in TypeError: unsupported operand type(s) for /: 'decimal.Decimal' and 'float'.

I think this can be resolved by using operator.pow instead of math.pow.

To recreate the error,

python -m rule_engine.debug_repl --edit-console
edit the 'context' and 'thing' objects as necessary
>>> thing = dict(a=8.0,b=2.0)
>>> exit()
exiting the edit console...
rule > a/(b**2)
TypeError: unsupported operand type(s) for /: 'decimal.Decimal' and 'float'

Datetime operations and Timedeltas

According to the documentation, there doesn't seem to be any support for timedeltas, or any mathematical operations on datetime objects. This would be super useful for implementing rules such as "older than 30 days" without having to calculate what "30 days before now" is manually.

For example, ideally I could write a rule that looks like this to implement the above:

created_at < now - P30D

Critical issue - No attribute Builtins

I noticed a critical issue in 4.0.0 - after the upgrade, we noticed that we lost existing functionality of the rule engine, and we are seeing this error while rule engine is executed.
module 'rule_engine.engine' has no attribute 'Builtins'

None type arithmetic checks do not work if a type resolver is provided

The workarounds given in #13 do not work in case the types are provided:

con = rule_engine.Context(
            type_resolver=rule_engine.type_resolver_from_dict(
                {
                    "TEST_FLOAT": rule_engine.DataType.FLOAT,
                }
            ),
            default_value=None,
        )
        rul = rule_engine.Rule(
            # "TEST_FLOAT != null and TEST_FLOAT < 42",
            "(TEST_FLOAT == null ? 0 : TEST_FLOAT) < 42",
            context=con,
        )
        test = rul.matches({"TEST_FLOAT": None})

This will produce an errors.EvaluationError('data type mismatch')
Wouldn't it make sense to add a check whether any side is None to __op_arithmetic_values, i.e. a

elif left_value is None or right_value is None:
    return False

right after checking if both are None?

Working around this in Python by normalizing the input value is not an elegant option since it would require to typedef the values twice - once for the Rule Engine context and once for the normalization.

EDIT: Ok, worked around it using rule.condition.context.resolve_type(mapping) == rule_engine.DataType.FLOAT now, but it still seems adding the check to the library would make this task easier, albeit arithmetic operators are undefined for None.

Decimal support

Would it be possible to add support for decimal.Decimal DataTypes?

I'm happy to make a PR as well, if you agree.

How to use the whole project?

Hi,
I am just beginning with rule engines, honestly, it's the first time I am coming across it, I see you have added examples in the repository but can you please elaborate the readme file on how to use them and what do they actually do?It would be really helpful for beginners like me.
And sorry for putting it up as an issue, didn't know where else to ask you for the help.
Thank you!

String to Integer casting

My required condition -> 'to_integer(some_column) > 180'

Unfortunately the I am receiving source_column as a string data and do not want to put the casting logic in python directly in code. I am planning to put the conditions on the json where I am trying to store the condition and syphoning those from the code.

Please advise how I can implement the saem.

Is it possible to have a function within a rule or call a function in a rule ?

For example;

import datetime

def time_in_range(start, end, current):
"""Returns whether current is in the range [start, end]"""
return start <= current <= end
start = datetime.time(0, 0, 0)
end = datetime.time(23, 55, 0)
current = datetime.datetime.now().time()
print(time_in_range(start, end, current))

Can I call this function in a rule ? or can I make it into a rule ?

Support `sum`

Given,

scores = [
  {"name": "do x": "score": 10},
  {"name": "do 7": "score": 10},
]

If it is possible to support rule like

sum([score['score'] for score in scores]) > 10
or,
[score['score'] for score in scores].sum > 10

Thank you,

Automatic type resolver extraction from dataclasses

I was wondering if the project could benefit from an automatic type resolver extraction feature.

I have an example implementation that I created for my own use-case, but I found it quite generic, and I believe it might make sense to add it to the core library.

The type resolver implementation looks like this:

import datetime as dt
import types
import typing
from decimal import Decimal

import rule_engine

PYTYPE_TO_ENGINETYPE = {
    list: rule_engine.DataType.ARRAY,
    tuple: rule_engine.DataType.ARRAY,
    dt.datetime: rule_engine.DataType.DATETIME,
    dt.date: rule_engine.DataType.DATETIME,
    int: rule_engine.DataType.FLOAT,
    float: rule_engine.DataType.FLOAT,
    Decimal: rule_engine.DataType.FLOAT,
    types.NoneType: rule_engine.DataType.NULL,
    set: rule_engine.DataType.SET,
    str: rule_engine.DataType.STRING,
    dict: rule_engine.DataType.MAPPING,
    None: rule_engine.DataType.UNDEFINED,
}


def parse_compound_array_enginetype(type_alias):
    main_pytype = type_alias.__origin__
    if hasattr(type_alias, "__args__") and main_pytype is not tuple:
        sub_pytype = type_alias.__args__[0]
    else:
        sub_pytype = None

    main_enginetype = PYTYPE_TO_ENGINETYPE[main_pytype]
    if sub_pytype not in PYTYPE_TO_ENGINETYPE:
        sub_enginetype = parse_compound_enginetype(sub_pytype)
    else:
        sub_enginetype = PYTYPE_TO_ENGINETYPE[sub_pytype]

    return main_enginetype(sub_enginetype)


def parse_compound_mapping_enginetype(type_alias):
    main_pytype = type_alias.__origin__
    if hasattr(type_alias, "__args__"):
        key_pytype = type_alias.__args__[0]
        value_pytype = type_alias.__args__[1]
    else:
        key_pytype = None
        value_pytype = None

    main_enginetype = PYTYPE_TO_ENGINETYPE[main_pytype]

    if key_pytype in PYTYPE_TO_ENGINETYPE:
        key_enginetype = PYTYPE_TO_ENGINETYPE[key_pytype]
    else:
        key_enginetype = parse_compound_enginetype(key_pytype)

    if value_pytype in PYTYPE_TO_ENGINETYPE:
        value_enginetype = PYTYPE_TO_ENGINETYPE[value_pytype]
    else:
        value_enginetype = parse_compound_enginetype(value_pytype)

    return main_enginetype(key_enginetype, value_enginetype)


def parse_compound_enginetype(type_alias):
    if hasattr(type_alias, "__origin__"):
        if type_alias.__origin__ in (list, tuple, set):
            return parse_compound_array_enginetype(type_alias)
        if type_alias.__origin__ is dict:
            return parse_compound_mapping_enginetype(type_alias)
    if isinstance(type_alias, typing._LiteralGenericAlias):
        python_type = type(type_alias.__args__[0])
        return PYTYPE_TO_ENGINETYPE[python_type]

    return rule_engine.DataType.UNDEFINED


def type_resolver_from_dataclass(cls: type):
    if not dataclasses.is_dataclass(cls):
        raise Exception

    type_resolver = {}
    for fieldname, fieldtype in cls.__annotations__.items():
        if fieldtype in PYTYPE_TO_ENGINETYPE:
            type_resolver[fieldname] = PYTYPE_TO_ENGINETYPE[fieldtype]
        else:
            compound_type = parse_compound_enginetype(fieldtype)
            type_resolver[fieldname] = compound_type
    return type_resolver

And to test it one can do:

import dataclasses
import datetime as dt
import typing

ChoiceText = typing.Literal["one", "two", "three"]
Order = typing.Literal[1, 2, 3]

@dataclasses.dataclass
class Model:
    id: int
    title: str
    tags: list[str]
    shares: typing.Dict[str, typing.List]
    index: typing.Dict
    uniques: set[float]
    created: dt.datetime
    undefined: str | int | float
    customers: typing.List[Decimal]
    singles: tuple[int]
    pairs: tuple[str, int]
    choice: ChoiceText
    orders: list[Order]

type_resolver = type_resolver_from_dataclass(Model)

It should produce a type resolver like the below:

{
 'choice': <_DataTypeDef name=STRING python_type=str >,
 'created': <_DataTypeDef name=DATETIME python_type=datetime >,
 'customers': <_ArrayDataTypeDef name=ARRAY python_type=tuple value_type=FLOAT >,
 'id': <_DataTypeDef name=FLOAT python_type=Decimal >,
 'index': <_MappingDataTypeDef name=MAPPING python_type=dict key_type=UNDEFINED value_type=UNDEFINED >,
 'orders': <_ArrayDataTypeDef name=ARRAY python_type=tuple value_type=FLOAT >,
 'pairs': <_ArrayDataTypeDef name=ARRAY python_type=tuple value_type=UNDEFINED >,
 'shares': <_MappingDataTypeDef name=MAPPING python_type=dict key_type=STRING value_type=ARRAY >,
 'singles': <_ArrayDataTypeDef name=ARRAY python_type=tuple value_type=UNDEFINED >,
 'tags': <_ArrayDataTypeDef name=ARRAY python_type=tuple value_type=STRING >,
 'title': <_DataTypeDef name=STRING python_type=str >,
 'undefined': <_DataTypeDef name=UNDEFINED python_type=UNDEFINED >,
 'uniques': <_SetDataTypeDef name=SET python_type=set value_type=FLOAT >
}

Please tell me if you think it makes sense to add it to the lib and I can work it out more.

Bad behaviour with wrong rule that does not make errors and returns bad results

Hello,

While writing rules, I realized the following result when I wrote "int_variable==01" instead of "int_variable==1".

When this condition is the only one in the rule, I get an error but when it is followed by other conditions, there is no more error and the returned results are invalid.

The results are invalid because what is before this condition does not seem to be taken into account.

Here is an example:

import rule_engine

data = {'other': 'other', 'test': 'test', 'count':1}

# Invalid rule that raise an error
rule_str = "count==01"
rule = rule_engine.Rule(rule_str) # Return an error

# Invalid rule that does not return an error
rule_str = "test=='NOTTEST' and count==01 and other=='other'"
rule = rule_engine.Rule(rule_str)

rule.matches(data) # True

In this case, the "test=='NOTTEST' " part is ignored because before the invalid condition (the invalid condition is also ignored).

Enhance Min/Max function

Example:

age = 18
rule1 = rule_engine.Rule("$min(age, 21, 20)")
rule2 = rule_engine.Rule("$max(age, 20, 19)")

Rule().matches stuck, no result returned

rule_engine is a great feature to use, but I'm running into some issues.
The following code will get stuck, no error will be reported, and there will be no return value
Looking forward to your reply and good luck

for myrule in [
Rule('TargetFilename.as_lower =~ "c:\\\windows\\\system32\\(.?)+.exe"',
context=Context(default_value=None)),
Rule('TargetFilename.as_lower =~ "c:\\\windows\\\system32\\(.
?)+.wncryt"',
context=Context(default_value=None)),
Rule('TargetFilename.as_lower =~ "c:\\\windows\\\system32\\(.?)+.ini"',
context=Context(default_value=None)),
Rule('TargetFilename.as_lower =~ "c:\\\windows\\\system32\\(.
?)+.bat"',
context=Context(default_value=None)),
Rule('TargetFilename.as_lower =~ "c:\\\windows\\\system32\\(.*?)+.bin"',
context=Context(default_value=None)),
]:
print(myrule.matches({'RuleName': '-', 'UtcTime': '2023-05-29 08:59:49.311', 'ProcessGuid': '{8ec1d6e5-fad3-6473-4300-00000000b000}', 'ProcessId': '3972', 'Image': 'C:\WINDOWS\System32\svchost.exe', 'TargetFilename': 'c:\windows\system32\winevt\logs\archive-microsoft-windows-sysmon%4operational', 'CreationUtcTime': '2023-05-29 08:59:49.311', 'User': 'NT AUTHORITY\LOCAL SERVICE'}))

Support for INTERSECT?

Hi, is it possible to add support for intersect?
eg:

 rule_engine.Rule('role INTERSECT {"R1", "R2"}').matches({"role": ["R1"]})

Nested rules

I have a rule like the following:

(prior.measurement_1 > 100 or prior.measurment_2 > 25) and (current.measurement_1 > 100 or current.measurement_2 > 25)

I'd like to create a rule for just the measurements and another rule for it matching on prior and current events toghether:

measurement_rule = 'measurement_1 > 100 or measurement_2 > 25'
event_rule = 'measurement_rule.matches(prior) and measurement_rule.matches(current)'
event_rule.matches(prior_and_current_event_object)

It doesn't have to be structured like this, but I'd prefer to keep the whole rule as one, leveraging other rules within, to avoid coding up the logic in Python. Is that possible? I can't find anything like this in your examples.

It's trivial to code, but redefining the same rule (like the measurement_rule above) is something I'm going to have to do for hundreds or more. It would be great to mix and match rules.

Does rule engine support functions?

Was wondering if it's possible to implement custom functions to the rule engine, something of the sort of:

get_hostname("https://example.com/path") == "github.com"

Does it support the bracket symbol

Excuse me, I would like to use "(" or ")" to control the privilege of this expression block. Will it support?
(mysql.cpu_usage.feature in ["spike", "shift_up", "trend_up"] or mysql.cpu_usage.value > 90) and (mysql.active_session.feature in ["shift_up", "trend_up"] or mysql.active_session.value > 16)

TypeError: can't compare offset-naive and offset-aware datetimes

Hello Spencer,

Seems like the below exception is currently not being handled by the rule-engine. We are currently handling the TypeError at our end. Thought of reporting it.
Rule Syntax --> facts['effectiveTMS'] > d'2021-08-12'
The effectiveTMS is stored in mongo as "2021-01-01T00:00:00.000+00:00" (Date), retrieved from mongo as 'effectiveTMS': datetime.datetime(2021, 1, 1, 0, 0).

File "/loc/lib/python3.9/site-packages/rule_engine/engine.py", line 620, in matches return bool(self.evaluate(thing)) File "/loc/lib/python3.9/site-packages/rule_engine/engine.py", line 609, in evaluate return self.statement.evaluate(thing) File "/loc/lib/python3.9/site-packages/rule_engine/ast.py", line 944, in evaluate return self.expression.evaluate(thing) File "/loc/lib/python3.9/site-packages/rule_engine/ast.py", line 348, in evaluate return self._evaluator(thing) File "/loc/lib/python3.9/site-packages/rule_engine/ast.py", line 478, in __op_arithmetic return self.__op_arithmetic_values(op, left_value, right_value) File "/loc/lib/python3.9/site-packages/rule_engine/ast.py", line 495, in __op_arithmetic_values return op(left_value, right_value) TypeError: can't compare offset-naive and offset-aware datetimes

Should this be a candidate for DatetimeSyntaxError?

Thanks
Priyank Mavani

Raise regex failure details

While looking at #59 I noticed that there aren't any details shown when there's a regex failure. The details should be propagated so the user can see why the regex is invalid. The regex itself should also probably be included in the exception for inspection.

Integrate this into the debug repl with exception handling to print those details for debugging purposes.

Missing copyright notice line on license file

Hi there,

You guys are missing a copyright notice line in your license file.
This has been pointed out by our legal department as we were considering your project to use in Eventbrite's stack.

Could you guys add one?
Thanks for your work and for sharing it!

How to Run a list of Rules

If I had a list of rules how do I run them altogether

e.g

rule_1 = rule_engine.Rule(
'vehicle_type == "minibus" or vehicle_type == "bus" or vehicle_type == "coach" and relation_to_claim == "Passenger" and injured_physical == "True" and party_id == driver_id', context=context
)

rule_2= rule_engine.Rule(
'no_injuries_at_fnol == "True" and injured_physical == "True"', context=context
)

rule_3 = rule_engine.Rule(
'occupation =~ "taxi driver\w+" or occupation =~ "private hire driver\w+" and vehicle_usage =~ "taxi driver\w+" or vehicle_usage =~ "private hire driver\w+" and third_party == "True"', context=context
)

against a dataset

Get varible name from rule

After building a rule like

rule = rule_engine.Rule('a>0.5')

Is there a build-in method that can reture the varible name of the rule, like

rule.get_var()
>>> 'a'

Key existing rule

Is there any way i can base a rule on the existence of a key?
I'm fetching data from external api and i want to implement a rule that checks if a key is exist in it
Let's say for example that i received the following data:

{
    "name": "example",
    "description": "rule-engine is great"
}

I want a rule that will return true if reference is in this data and false otherwise.
In every way that i tried to implement it i came up with rule_engine.errors.SymbolResolutionError: reference (obviously).

Thanks in advance

No match when using default_value for nested objects

Hello,

I was playing with this cool lib when i stumbled upon the following issue.

When i set default_value to None (or any other value) in a rule's context, i don't get any matches for nested objects.
Here is a code example :

#! /usr/bin/env python3 

import rule_engine

dic = {
    "a": 1,
    "b": "test1",
    "c": {
        "c1": 2,
        "c2": "test2"
    }
}

rule_text = 'a == 1'

rule1 = rule_engine.Rule(rule_text, context=rule_engine.Context())
rule2 = rule_engine.Rule(rule_text, context=rule_engine.Context(default_value=None))

a_match1 = rule1.matches(dic)
a_match2 = rule2.matches(dic)

assert a_match1 == a_match2

rule_text = 'c.c1 == 2'

rule1 = rule_engine.Rule(rule_text, context=rule_engine.Context())
rule2 = rule_engine.Rule(rule_text, context=rule_engine.Context(default_value=None))

c_match1 = rule1.matches(dic)
c_match2 = rule2.matches(dic)

assert c_match1 == c_match2

Only c_match assertion fails.

I think what happens here is c.c1 is replaced with None.
The docs says that default_value is used for missing attributes only. c.c1 is not missing therefore it shouldn't be replaced with None (or any set default_value).

Great lib by the way.

Thanks in advance!

Add support for default_value resolver

Is there any possibility of adding something like we have to resolve the types but for values?

context = rule_engine.Context(type_resolver=rule_engine.type_resolver_from_dict({
    'first_name': rule_engine.DataType.STRING,
    'age': rule_engine.DataType.FLOAT
}))
context = rule_engine.Context(type_resolver=rule_engine.value_resolver_from_dict({
    'first_name': "default",
    'age': 0
}))

Min(symbol) doesnot work

Example:

items = [1, 2, 3, 4, 5, 6, 7, 8, 9]
rule = rule_engine.Rule("$min(items)")

failed with TypeError:
can not map python type 'SymbolExpression' to a compatible data type

No github release for v3.5.0

It looks mildly confusing because the Github release for v3.4.0 was created right around the same time as the actual release of v3.5.0.

Semantic version comparison

Hi @zeroSteiner ! Love the library, I've used it in a couple of personal projects.

Wondering if there's any thought or TODO item regarding semantic version comparison? Only the numbers, ignoring the label extensions (like -beta or -rc).

This can be done using using pure python:

(forgive my hasty two line example)

>>> [int(x) for x in "1.0.0".split(".")] <= [int(x) for x in "1.0.1".split(".")]
True
>>> [int(x) for x in "1.1.0".split(".")] <= [int(x) for x in "1.0.1".split(".")]
False

or using packaging.version.parse which is packaged with setuptools.

I believe I can accomplish the same goal using custom resolvers (which I am planning on doing for my current project), but am curious as to the status of a first-class solution.

RuleSyntaxError

I am trying to do something like rule_engine.Rule('number in (1, 2)') and I am getting the following error: RuleSyntaxError: ('syntax error', LexToken(COMMA,',',1,14)) and according to documentation tuples can be used as array structures.

How to join strings?

Hey Spencer (and community),

Is it possible to join strings? I've tried the obvious method of 'a'+'b' but that results in datatype mismatch from this func

def __op_arithmetic(self, op, thing):
left_value = self.left.evaluate(thing)
_assert_is_numeric(left_value)
right_value = self.right.evaluate(thing)
_assert_is_numeric(right_value)
return op(left_value, right_value)

Couldn't find any custom operators for text joining. Happy to submit a PR if there is no way to do this currently. Strategy would be to remove operator.add from this class into its own, changing the validation method to either combination of string and string or num and num

class ArithmeticExpression(LeftOperatorRightExpressionBase):
"""A class for representing arithmetic expressions from the grammar text such as addition and subtraction."""
compatible_types = (DataType.FLOAT,)
result_type = DataType.FLOAT
def __op_arithmetic(self, op, thing):
left_value = self.left.evaluate(thing)
_assert_is_numeric(left_value)
right_value = self.right.evaluate(thing)
_assert_is_numeric(right_value)
return op(left_value, right_value)
_op_add = functools.partialmethod(__op_arithmetic, operator.add)
_op_sub = functools.partialmethod(__op_arithmetic, operator.sub)
_op_fdiv = functools.partialmethod(__op_arithmetic, operator.floordiv)
_op_tdiv = functools.partialmethod(__op_arithmetic, operator.truediv)
_op_mod = functools.partialmethod(__op_arithmetic, operator.mod)
_op_mul = functools.partialmethod(__op_arithmetic, operator.mul)
_op_pow = functools.partialmethod(__op_arithmetic, operator.pow)

Type Resolver for Nested Dict doesn't work

Hello zeroSteiner,

I am trying to use the type resolver in the below given method.

user_input = {
    "facts": {
        "abc": False,
        "ghf": 0
    },
    "api_response": {
        "aaa": {
            "type": "derma",
            "value": "high"
        },
        "bbb": {
            "type": "onco",
            "value": "low"
        }
    }

}

pf_dtypes = {
    "facts": rule_engine.DataType.MAPPING, 
    "facts.abc": rule_engine.DataType.BOOLEAN,
    "facts.ghf": rule_engine.DataType.FLOAT,
    "api_response": rule_engine.DataType.MAPPING}
con = rule_engine.Context(type_resolver=rule_engine.type_resolver_from_dict(pf_dtypes))
rule_new = rule_engine.Rule("facts.abc == false", context=con)
print(rule_new.matches(user_input))

I am getting the below error (last few lines of the error)-->
engine.py", line 156, in _get_resolver raise errors.AttributeResolutionError(name, object_type, thing=thing) rule_engine.errors.AttributeResolutionError: ('abc', <_MappingDataTypeDef name=MAPPING python_type=dict key_type=UNDEFINED value_type=UNDEFINED >)

I have also tried it with the below type resolver dict. But still get a similar error.

pf_dtypes = {
    "facts": rule_engine.DataType.MAPPING, 
    "facts['abc']": rule_engine.DataType.BOOLEAN,
    "facts['ghf']": rule_engine.DataType.FLOAT,
    "api_response": rule_engine.DataType.MAPPING}

The rule works if i change the rule to "facts['abc'] == false", however, it has no impact of the type resolver whatsoever. If the rule is "facts['abc'] == false" and i change "facts.abc": rule_engine.DataType.BOOLEAN to "facts.abc": rule_engine.DataType.STRING, it doesn't correctly throw the TypeError.

Better support for arrays reduction functions

I'm in the bandwagon requesting the ability to call functions in the engine, but I think my use case is not as involved as some of the other users.

I'm trying to check if an any or all values in an array match a certain rule.
How hard would it be to add just these 2 methods, which operate on arrays alone and their result is a single value.

One example:

items = [1, 2, 3, 4, 5, 6, 7, 8, 9]
rule = rule_engine.Rule("any(items > 3)")

You could probably achieve the same effect by doing something like this, which is valid

rule = rule_engine.Rule("[x for x in items if x > 3]"

and then checking if there are any values left, but that's harder to read imo and harder to chain multiple checks.

Complex object with dicts and arrays

Came about this great library while searching for python rule engines, this project would fit my needs completly if I could traverse arrays with the dot-syntax (like issue #30 but more deep and nested).
This is because this kind of syntax would be easy enough to give other people the chance to write some rules for specific actions without having to know python.

I have dicts with array of dicts that have varying content and I need to evaluate if a specific path is present, ignoring the positin in the arrays:

{"event": {
"title": "1 computer made a problem",
"startDate": "20220502",
"endDate": "20220502",
"created": 1651528631972,
"creatorId": None,
"internaldata": [
{
"type": "USER",
"details": {
"firstName": "first1",
"lastName": "last2"
}
},
{
"type": "COMPUTER",
"details": {
"fqdn": "computer1.domain.net",
"lansite": "Munich"
}
}
],
"items": [
{
"type": "EVENT",
"computerinfo": {
"resources": [
{"userassigments": "data1"},
{"companyassigned": "Yes"},
{"otherdata": "data2"}
]
}
]
}

I could do that with your library now:
Rule('event.title =~ ".*made a problem$" and event.items[0].computerinfo.resources[3].companyassigned == "Yes"')

Because the data is not always the same and the position of the dicts within the arrays change,
I would need to somehow traverse the arrays within the dicts to check if specific data is present (dict keys are always named the same), e.g.:

Rule('event.title =~ ".*made a problem$" and event.items[*].computerinfo.resources[*].companyassigned == "Yes"')

Is that possible somehow or could be added to the library?

Arithmetic comparison with None/null types

Hi @zeroSteiner,

I had a question regarding how I can handle cases in which there are arithmetic comparisons (Or more generally speaking, any operations) with NoneTypes. For example, what is the expected behaviour here

rule_engine.Rule('a > 10').matches({'a': None})

This results in an exception

rule_engine.errors.EvaluationError: data type mismatch

but I was wondering if there is a way of suppressing that and evaluating it to False.

BTW, great project and looking forward to future features especially support for sets, kudos! 👍

Does it support running rules on complex objects?

Does it support running rules on complex objects? Like running rules on address.country == 'USA"?

{ "name": "test_user", "email_address": "[email protected]", "address": { "address_1": "high st", "address_2": "unit-4", "state": "AZ", "country": "USA" } }

Can you give an example as i see complex types are supported

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.