rule-engine-core 0.0.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- rule_engine_core-0.0.1/PKG-INFO +36 -0
- rule_engine_core-0.0.1/README.md +13 -0
- rule_engine_core-0.0.1/pyproject.toml +3 -0
- rule_engine_core-0.0.1/rule_engine_core/__init__.py +0 -0
- rule_engine_core-0.0.1/rule_engine_core/entity_base.py +94 -0
- rule_engine_core-0.0.1/rule_engine_core/entity_dao.py +179 -0
- rule_engine_core-0.0.1/rule_engine_core/entity_types.py +10 -0
- rule_engine_core-0.0.1/rule_engine_core/filter.py +145 -0
- rule_engine_core-0.0.1/rule_engine_core/metadata_store.py +49 -0
- rule_engine_core-0.0.1/rule_engine_core/parser.py +222 -0
- rule_engine_core-0.0.1/rule_engine_core/rule.py +137 -0
- rule_engine_core-0.0.1/rule_engine_core.egg-info/PKG-INFO +36 -0
- rule_engine_core-0.0.1/rule_engine_core.egg-info/SOURCES.txt +18 -0
- rule_engine_core-0.0.1/rule_engine_core.egg-info/dependency_links.txt +1 -0
- rule_engine_core-0.0.1/rule_engine_core.egg-info/requires.txt +1 -0
- rule_engine_core-0.0.1/rule_engine_core.egg-info/top_level.txt +1 -0
- rule_engine_core-0.0.1/setup.cfg +4 -0
- rule_engine_core-0.0.1/setup.py +33 -0
- rule_engine_core-0.0.1/tests/test_rule_creation_storage.py +34 -0
- rule_engine_core-0.0.1/tests/test_rule_evaluation.py +115 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: rule-engine-core
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: A rule engine core library for Python.
|
|
5
|
+
Home-page: https://github.com/temp-noob/rule-engine
|
|
6
|
+
Author: Mohit Tripathi
|
|
7
|
+
Author-email: tripathimohit051@gmail.com
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Requires-Python: >=3.10
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
Requires-Dist: psycopg2-binary
|
|
14
|
+
Dynamic: author
|
|
15
|
+
Dynamic: author-email
|
|
16
|
+
Dynamic: classifier
|
|
17
|
+
Dynamic: description
|
|
18
|
+
Dynamic: description-content-type
|
|
19
|
+
Dynamic: home-page
|
|
20
|
+
Dynamic: requires-dist
|
|
21
|
+
Dynamic: requires-python
|
|
22
|
+
Dynamic: summary
|
|
23
|
+
|
|
24
|
+
## Build
|
|
25
|
+
-> create venv and activate
|
|
26
|
+
(install meta)
|
|
27
|
+
-> pip install steuptools wheel twine
|
|
28
|
+
(build package)
|
|
29
|
+
-> python setup.py sdist bdist_wheel
|
|
30
|
+
-> pip install dist/rule-engine-core-0.1.0-py3-none-any.whl
|
|
31
|
+
(Publish to pypi)
|
|
32
|
+
-> pip install twine
|
|
33
|
+
-> twine upload dist/*
|
|
34
|
+
|
|
35
|
+
### Docker
|
|
36
|
+
-> `psql --username=$POSTGRES_USER -d $POSTGRES_DB`
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
## Build
|
|
2
|
+
-> create venv and activate
|
|
3
|
+
(install meta)
|
|
4
|
+
-> pip install steuptools wheel twine
|
|
5
|
+
(build package)
|
|
6
|
+
-> python setup.py sdist bdist_wheel
|
|
7
|
+
-> pip install dist/rule-engine-core-0.1.0-py3-none-any.whl
|
|
8
|
+
(Publish to pypi)
|
|
9
|
+
-> pip install twine
|
|
10
|
+
-> twine upload dist/*
|
|
11
|
+
|
|
12
|
+
### Docker
|
|
13
|
+
-> `psql --username=$POSTGRES_USER -d $POSTGRES_DB`
|
|
File without changes
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This is the base class for filter and rule entities.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from rule_engine_core.parser import Node, Parser
|
|
7
|
+
|
|
8
|
+
from rule_engine_core.entity_types import EntityEnum, EntityNode
|
|
9
|
+
|
|
10
|
+
class EntityBase(ABC):
|
|
11
|
+
"""
|
|
12
|
+
Base class for entities like Filter and Rule.
|
|
13
|
+
Provides a common implementation for evaluate and enforces validate implementation.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def __init__(self, expression: str, node_type: EntityEnum, name: str):
|
|
17
|
+
"""
|
|
18
|
+
Initializes the entity with an expression string and parses it into an expression tree.
|
|
19
|
+
"""
|
|
20
|
+
self.expression = expression
|
|
21
|
+
self._expression_tree: Node = self._parse_expression(expression)
|
|
22
|
+
self.node_type = node_type
|
|
23
|
+
self.name = name
|
|
24
|
+
|
|
25
|
+
def validate(self) -> bool:
|
|
26
|
+
"""
|
|
27
|
+
Validates whether the operands of the entity are valid.
|
|
28
|
+
_validate_node must be implemented by subclasses
|
|
29
|
+
"""
|
|
30
|
+
return self._validate_node(self._expression_tree)
|
|
31
|
+
|
|
32
|
+
@abstractmethod
|
|
33
|
+
def _validate_node(self, node: Node) -> bool:
|
|
34
|
+
"""
|
|
35
|
+
Validates the operands of the entity.
|
|
36
|
+
Must be implemented by subclasses.
|
|
37
|
+
"""
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
@abstractmethod
|
|
41
|
+
def _evaluate_node(self, node: Node, data: dict, entity_cache: dict, entity_eval_cache) -> float|int|bool:
|
|
42
|
+
"""
|
|
43
|
+
Evaluates a single node of the expression tree.
|
|
44
|
+
Must be implemented by subclasses.
|
|
45
|
+
"""
|
|
46
|
+
pass
|
|
47
|
+
"""
|
|
48
|
+
During eval, entity_cache should not be modified, if any lock is to be acquired, it should be acquired
|
|
49
|
+
before evaluation. Similarly, entity_eval_cache should not be modified during eval.
|
|
50
|
+
"""
|
|
51
|
+
def evaluate(self, pos_data: dict, entity_eval_cache: dict, entity_cache: dict[EntityEnum, dict[str, Node]]) -> float|int|bool:
|
|
52
|
+
"""
|
|
53
|
+
Evaluates the entity against the given data.
|
|
54
|
+
The evaluation is done by traversing the expression tree and evaluating each node.
|
|
55
|
+
Arguments:
|
|
56
|
+
pos_data: Dictionary of data where keys are position id and the value is a dictionary of fields and their resepective values for the position.
|
|
57
|
+
entity_eval_cache: Dictionary of cached evaluation results for entities. We do a dfs sort of thing and keep on populating the cache with the results.
|
|
58
|
+
entity_cache: Dictionary of cached entities. This is used to get entity object by entity id.
|
|
59
|
+
"""
|
|
60
|
+
return self._evaluate_node(EntityNode(self._expression_tree, self.node_type), pos_data, entity_eval_cache, entity_cache)
|
|
61
|
+
|
|
62
|
+
def _parse_expression(self, expression: str) -> Node:
|
|
63
|
+
"""
|
|
64
|
+
Parses the expression string into an expression tree.
|
|
65
|
+
"""
|
|
66
|
+
return Parser.parse(expression)
|
|
67
|
+
|
|
68
|
+
def _check_if_node_is_number(self, node_ident: str) -> bool:
|
|
69
|
+
"""
|
|
70
|
+
Check if the node is a number (int/float)
|
|
71
|
+
"""
|
|
72
|
+
try:
|
|
73
|
+
int(node_ident)
|
|
74
|
+
return True
|
|
75
|
+
except ValueError:
|
|
76
|
+
try:
|
|
77
|
+
float(node_ident)
|
|
78
|
+
return True
|
|
79
|
+
except ValueError:
|
|
80
|
+
return False
|
|
81
|
+
|
|
82
|
+
def __getstate__(self):
|
|
83
|
+
"""Control what gets pickled, excluding connection objects and other unpicklable items"""
|
|
84
|
+
# only copy expression tree, expression, name and node_type
|
|
85
|
+
# rest everything is not needed
|
|
86
|
+
state = {
|
|
87
|
+
'expression': self.expression,
|
|
88
|
+
'node_type': self.node_type,
|
|
89
|
+
'name': self.name,
|
|
90
|
+
'expression_tree': self._expression_tree,
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
return state
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This module defines the EntityDao class.
|
|
3
|
+
This is responsible for storing and managing filter and rule entities.
|
|
4
|
+
We will store these in a postgres database.
|
|
5
|
+
"""
|
|
6
|
+
import json
|
|
7
|
+
from psycopg2.extensions import connection as PgConnection
|
|
8
|
+
import psycopg2
|
|
9
|
+
from rule_engine_core.parser import Node
|
|
10
|
+
import os, logging
|
|
11
|
+
from typing import Optional
|
|
12
|
+
import pickle
|
|
13
|
+
import sys
|
|
14
|
+
from rule_engine_core.entity_types import EntityEnum, EntityNode
|
|
15
|
+
|
|
16
|
+
# Add an alias for backwards compatibility with pickled data
|
|
17
|
+
# This allows pickle to find the module at the old path
|
|
18
|
+
sys.modules['entity_types'] = sys.modules['rule_engine_core.entity_types']
|
|
19
|
+
sys.modules['parser'] = sys.modules['rule_engine_core.parser']
|
|
20
|
+
|
|
21
|
+
from rule_engine_core.entity_base import EntityBase
|
|
22
|
+
|
|
23
|
+
_logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
def create_connection(config: Optional[dict] = None) -> PgConnection:
|
|
26
|
+
if config is None:
|
|
27
|
+
# Load the config from the environment variables
|
|
28
|
+
# and assume db is on the same host as the application
|
|
29
|
+
# using the default port: 5432
|
|
30
|
+
config = {
|
|
31
|
+
"database": os.getenv("POSTGRES_DB"),
|
|
32
|
+
"user": os.getenv("POSTGRES_USER"),
|
|
33
|
+
"password": os.getenv("POSTGRES_PASSWORD"),
|
|
34
|
+
"host": os.getenv("POSTGRES_HOST", "localhost"),
|
|
35
|
+
# "port": os.getenv("POSTGRES_PORT", 5432),
|
|
36
|
+
}
|
|
37
|
+
connection = psycopg2.connect(
|
|
38
|
+
dbname=config["database"],
|
|
39
|
+
user=config["user"],
|
|
40
|
+
password=config["password"],
|
|
41
|
+
host=config["host"],
|
|
42
|
+
)
|
|
43
|
+
# check and log if the connection is successful
|
|
44
|
+
if connection.closed == 0:
|
|
45
|
+
_logger.info("Connection to the database was successful")
|
|
46
|
+
return connection
|
|
47
|
+
else:
|
|
48
|
+
_logger.error("Connection to the database failed")
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
class EntityDao:
|
|
52
|
+
"""
|
|
53
|
+
This class is a dao for the postgres database which stores the filters and rules.
|
|
54
|
+
"""
|
|
55
|
+
def __init__(self, config_file_path: Optional[str] = None):
|
|
56
|
+
"""
|
|
57
|
+
Initializes the MetadataStore object with an empty dictionary to store metadata.
|
|
58
|
+
"""
|
|
59
|
+
if config_file_path is not None:
|
|
60
|
+
with open(config_file_path, 'r') as f:
|
|
61
|
+
config = json.load(f)
|
|
62
|
+
self._connection = create_connection(config if config_file_path is not None else None)
|
|
63
|
+
if self._connection is None:
|
|
64
|
+
raise RuntimeError("Failed to create a connection to the database")
|
|
65
|
+
|
|
66
|
+
def get_all_entities(self, entity_type: EntityEnum) -> dict[str, EntityBase]:
|
|
67
|
+
"""
|
|
68
|
+
Retrieves all entities of the specified type from the store.
|
|
69
|
+
"""
|
|
70
|
+
# if entity_type is filter, read from the filter table
|
|
71
|
+
# if entity_type is rule, read from the rule table
|
|
72
|
+
# and then return the result as a dictionary. Where the key maps to rule_name/filter_name and the value maps to rule_data/filter_data
|
|
73
|
+
if entity_type == EntityEnum.FILTER:
|
|
74
|
+
query = "SELECT * FROM filters"
|
|
75
|
+
elif entity_type == EntityEnum.RULE:
|
|
76
|
+
query = "SELECT * FROM rules"
|
|
77
|
+
else:
|
|
78
|
+
raise ValueError(f"Invalid entity type: {entity_type}")
|
|
79
|
+
with self._connection.cursor() as cursor:
|
|
80
|
+
cursor.execute(query)
|
|
81
|
+
rows = cursor.fetchall()
|
|
82
|
+
entities = {}
|
|
83
|
+
for row in rows:
|
|
84
|
+
entity_id = row[0]
|
|
85
|
+
entity_data = row[1]
|
|
86
|
+
try:
|
|
87
|
+
# convert the entity data to a Node object
|
|
88
|
+
node = pickle.loads(entity_data)
|
|
89
|
+
entities[entity_id] = node
|
|
90
|
+
except Exception as e:
|
|
91
|
+
_logger.error(f"Failed to unpickle entity {entity_id}: {e}")
|
|
92
|
+
# Skip this entity and continue
|
|
93
|
+
continue
|
|
94
|
+
return entities
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def store_entity(self, entity_enum: EntityEnum, entity: EntityBase) -> bool:
|
|
98
|
+
"""
|
|
99
|
+
Stores the entity and the expression in the store.
|
|
100
|
+
"""
|
|
101
|
+
print(f"Storing entity: {entity.name} of type: {entity_enum}")
|
|
102
|
+
# if entity_enum is filter, store in the filter table
|
|
103
|
+
# if entity_enum is rule, store in the rule table
|
|
104
|
+
# and then return True if the operation was successful, False otherwise
|
|
105
|
+
|
|
106
|
+
# Compare by value instead of direct equality
|
|
107
|
+
if entity_enum.value == EntityEnum.FILTER.value:
|
|
108
|
+
query = "INSERT INTO filters (filter_name, filter_data) VALUES (%s, %s)"
|
|
109
|
+
elif entity_enum.value == EntityEnum.RULE.value:
|
|
110
|
+
query = "INSERT INTO rules (rule_name, rule_data) VALUES (%s, %s)"
|
|
111
|
+
else:
|
|
112
|
+
raise ValueError(f"Invalid entity type: {entity_enum}")
|
|
113
|
+
|
|
114
|
+
with self._connection.cursor() as cursor:
|
|
115
|
+
try:
|
|
116
|
+
# there shouldnt be a need to store the expression tree in the database
|
|
117
|
+
# todo: change it later
|
|
118
|
+
cursor.execute(query, (entity.name, pickle.dumps(entity)))
|
|
119
|
+
self._connection.commit()
|
|
120
|
+
return True
|
|
121
|
+
except Exception as e:
|
|
122
|
+
_logger.error(f"Failed to store entity: {e}")
|
|
123
|
+
self._connection.rollback()
|
|
124
|
+
return False
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def entity_exists(self, entity_enum: EntityEnum, entity_id: str) -> bool:
|
|
128
|
+
"""
|
|
129
|
+
Checks if the entity exists in the store.
|
|
130
|
+
"""
|
|
131
|
+
# if entity_enum is filter, check in the filter table
|
|
132
|
+
# if entity_enum is rule, check in the rule table
|
|
133
|
+
# and then return True if the entity exists, False otherwise
|
|
134
|
+
if entity_enum.value == EntityEnum.FILTER.value:
|
|
135
|
+
query = "SELECT * FROM filters WHERE filter_name = %s"
|
|
136
|
+
elif entity_enum.value == EntityEnum.RULE.value:
|
|
137
|
+
query = "SELECT * FROM rules WHERE rule_name = %s"
|
|
138
|
+
else:
|
|
139
|
+
raise ValueError(f"Invalid entity type: {entity_enum}")
|
|
140
|
+
with self._connection.cursor() as cursor:
|
|
141
|
+
cursor.execute(query, (entity_id,))
|
|
142
|
+
rows = cursor.fetchall()
|
|
143
|
+
if len(rows) > 0:
|
|
144
|
+
return True
|
|
145
|
+
else:
|
|
146
|
+
return False
|
|
147
|
+
# close the connection
|
|
148
|
+
|
|
149
|
+
def delete_entity(self, entity_enum: EntityEnum, entity_id: str) -> bool:
|
|
150
|
+
"""
|
|
151
|
+
Deletes the entity from the store.
|
|
152
|
+
"""
|
|
153
|
+
# if entity_enum is filter, delete from the filter table
|
|
154
|
+
# if entity_enum is rule, delete from the rule table
|
|
155
|
+
# and then return True if the operation was successful, False otherwise
|
|
156
|
+
if entity_enum == EntityEnum.FILTER:
|
|
157
|
+
query = "DELETE FROM filters WHERE filter_name = %s"
|
|
158
|
+
elif entity_enum == EntityEnum.RULE:
|
|
159
|
+
query = "DELETE FROM rules WHERE rule_name = %s"
|
|
160
|
+
else:
|
|
161
|
+
raise ValueError(f"Invalid entity type: {entity_enum}")
|
|
162
|
+
|
|
163
|
+
with self._connection.cursor() as cursor:
|
|
164
|
+
try:
|
|
165
|
+
cursor.execute(query, (entity_id,))
|
|
166
|
+
self._connection.commit()
|
|
167
|
+
return True
|
|
168
|
+
except Exception as e:
|
|
169
|
+
_logger.error(f"Failed to delete entity: {e}")
|
|
170
|
+
self._connection.rollback()
|
|
171
|
+
return False
|
|
172
|
+
|
|
173
|
+
# create destructor for EntityDao class to close the connection
|
|
174
|
+
def __del__(self):
|
|
175
|
+
if self._connection is not None:
|
|
176
|
+
self._connection.close()
|
|
177
|
+
_logger.info("Connection to the database closed")
|
|
178
|
+
else:
|
|
179
|
+
_logger.error("Connection to the database was not established")
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This module defines the Filter class, which represents a filter in the rule engine.
|
|
3
|
+
Each filter is a single condition that can be evaluated against a given data.
|
|
4
|
+
Filters can be combined with binary operators to create complex rules.
|
|
5
|
+
Example of unary filter: !x: which means !(bool(x))
|
|
6
|
+
Example of binary filter: x > 5 and y < 10: which means (x > 5) and (y < 10)
|
|
7
|
+
The filter operands are a set of variables from financial metadata, which are used to evaluate the filter. The various operands from the metadata are used to define various aspects of a financial transaction.
|
|
8
|
+
The Filter class has following methods:
|
|
9
|
+
-> validate: validates whether the operands of the filter are valid. The operands of filter should be either present in our finance metadata or filters database if the filer is composed of more leaf level filters . It queries the underlying store to check if filter operands exist either in the financial metadata or the filters database.
|
|
10
|
+
-> evaluate: evaluates the filter against the given data. The evaluation is done by evaluating each operand and combining the results using the operator.
|
|
11
|
+
-> __init__: initializes the Filter object with a string representing the expression string of the filter. The expression string is parsed to create a expression tree. The nodes of the tree are created using the Filter class. The tree is then used to evaluate the filter.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from rule_engine_core.parser import Node, OPERATOR_SET
|
|
15
|
+
from rule_engine_core.metadata_store import MetadataStore
|
|
16
|
+
from rule_engine_core.entity_dao import EntityDao
|
|
17
|
+
from rule_engine_core.entity_types import EntityEnum, EntityNode
|
|
18
|
+
from rule_engine_core.entity_base import EntityBase
|
|
19
|
+
import logging
|
|
20
|
+
|
|
21
|
+
_logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
class Filter(EntityBase):
|
|
24
|
+
"""
|
|
25
|
+
This class represents a filter in the rule engine.
|
|
26
|
+
Each filter is a single condition that can be evaluated against a given data.
|
|
27
|
+
Filters can be combined with binary operators to create complex rules.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, filter_name: str, expression: str, metadata_store: MetadataStore, entity_dao: EntityDao):
|
|
31
|
+
"""
|
|
32
|
+
Initializes the Filter object with a string representing the expression string of the filter.
|
|
33
|
+
The expression string is parsed to create a expression tree. The nodes of the tree are created using the Filter class. The tree is then used to evaluate the filter.
|
|
34
|
+
"""
|
|
35
|
+
super().__init__(expression, EntityEnum.FILTER, filter_name)
|
|
36
|
+
self.filter_name = filter_name
|
|
37
|
+
self._metadata_store = metadata_store
|
|
38
|
+
self._entity_dao = entity_dao
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _validate_node(self, node: Node) -> bool:
|
|
42
|
+
"""
|
|
43
|
+
Recursively validates the operands of the filter.
|
|
44
|
+
"""
|
|
45
|
+
if node is None:
|
|
46
|
+
return True
|
|
47
|
+
|
|
48
|
+
if node.value in OPERATOR_SET:
|
|
49
|
+
return self._validate_node(node.left) and self._validate_node(node.right)
|
|
50
|
+
|
|
51
|
+
# make a debug log to check if the node is a number or a field in the metadata store or an existing filter in the database
|
|
52
|
+
_logger.debug(f"Node: {node.value} is node a number {self._check_if_node_is_number(node.value)} or a field in the metadata store {self._metadata_store.does_field_exist(node.value)} or an existing filter in the database {self._entity_dao.entity_exists(EntityEnum.FILTER, node.value)}")
|
|
53
|
+
# check if node value is a number or a field in the metadata store or an existing filter in the database
|
|
54
|
+
return self._check_if_node_is_number(node.value) or \
|
|
55
|
+
self._metadata_store.does_field_exist(node.value) or \
|
|
56
|
+
self._entity_dao.entity_exists(EntityEnum.FILTER, node.value)
|
|
57
|
+
|
|
58
|
+
def _evaluate_node(self, node: EntityNode, pos_data: dict[str, str|int|float], entity_eval_cache: dict[EntityEnum, dict[str, int|float|bool]], entity_cache: dict[EntityEnum, dict[str, EntityBase]]) -> float|int|bool:
|
|
59
|
+
"""
|
|
60
|
+
Evaluates the node against the given data. And updates the cache with the result.
|
|
61
|
+
"""
|
|
62
|
+
_logger.info(f"Evaluating filter {self.filter_name} with value {node.node.value} and node type {node.entity_type}")
|
|
63
|
+
# check is filter_name is cached in entity_eval_cache
|
|
64
|
+
if self.filter_name in entity_eval_cache[EntityEnum.FILTER]:
|
|
65
|
+
return entity_eval_cache[EntityEnum.FILTER][self.filter_name]
|
|
66
|
+
if node.entity_type != EntityEnum.FILTER or node.node is None:
|
|
67
|
+
raise ValueError(f"Invalid node type: {node.entity_type} or invalide node: {node.node}")
|
|
68
|
+
|
|
69
|
+
if node.node.value not in OPERATOR_SET:
|
|
70
|
+
raise ValueError(f"Invalid operator: {node.node.value}")
|
|
71
|
+
|
|
72
|
+
def evaluate_node_or_leaf(node_ident: str) -> float|int|bool:
|
|
73
|
+
# First check if the node_ident is a number (int/float)
|
|
74
|
+
# then check is it is a metadata field and evaluate the value
|
|
75
|
+
# from pos_data
|
|
76
|
+
# then finally check if it is a filter in the database
|
|
77
|
+
try:
|
|
78
|
+
return int(node_ident)
|
|
79
|
+
except ValueError:
|
|
80
|
+
try:
|
|
81
|
+
return float(node_ident)
|
|
82
|
+
except ValueError:
|
|
83
|
+
if self._metadata_store.does_field_exist(node_ident):
|
|
84
|
+
return pos_data[node_ident]
|
|
85
|
+
else:
|
|
86
|
+
# if not found in metadata store, evaluate the filter from struct
|
|
87
|
+
# since the filter has not been evaluated yet
|
|
88
|
+
if entity_cache[EntityEnum.FILTER].get(node_ident) is None:
|
|
89
|
+
err_msg = f"Filter {node_ident} is not a valid entity for this rule evaluation cycle"
|
|
90
|
+
_logger.error(err_msg)
|
|
91
|
+
# propagata the error to the rule evaluation
|
|
92
|
+
raise ValueError(err_msg)
|
|
93
|
+
if entity_cache[EntityEnum.FILTER].get(node_ident).node_type is not EntityEnum.FILTER:
|
|
94
|
+
err_msg = f"Filter {node_ident}: has bad expression tree"
|
|
95
|
+
_logger.error(err_msg)
|
|
96
|
+
# propagata the error to the rule evaluation
|
|
97
|
+
raise ValueError(err_msg)
|
|
98
|
+
return entity_cache[EntityEnum.FILTER][node_ident].evaluate(pos_data, entity_eval_cache, entity_cache)
|
|
99
|
+
|
|
100
|
+
match node.node.value:
|
|
101
|
+
case "!":
|
|
102
|
+
# Unary not operator
|
|
103
|
+
result = not evaluate_node_or_leaf(node.node.left.value)
|
|
104
|
+
case "&":
|
|
105
|
+
# Binary and operator
|
|
106
|
+
result = evaluate_node_or_leaf(node.node.left.value) and \
|
|
107
|
+
evaluate_node_or_leaf(node.node.right.value)
|
|
108
|
+
case "|":
|
|
109
|
+
# Binary or operator
|
|
110
|
+
result = evaluate_node_or_leaf(node.node.left.value) or \
|
|
111
|
+
evaluate_node_or_leaf(node.node.right.value)
|
|
112
|
+
case ">":
|
|
113
|
+
# bonary greater than operator
|
|
114
|
+
result = evaluate_node_or_leaf(node.node.left.value) > \
|
|
115
|
+
evaluate_node_or_leaf(node.node.right.value)
|
|
116
|
+
case "<":
|
|
117
|
+
# Binary less than operator
|
|
118
|
+
result = evaluate_node_or_leaf(node.node.left.value) < \
|
|
119
|
+
evaluate_node_or_leaf(node.node.right.value)
|
|
120
|
+
case ">=":
|
|
121
|
+
# Binary greater than or equal to operator
|
|
122
|
+
result = evaluate_node_or_leaf(node.node.left.value) >= \
|
|
123
|
+
evaluate_node_or_leaf(node.node.right.value)
|
|
124
|
+
case "<=":
|
|
125
|
+
# Binary less than or equal to operator
|
|
126
|
+
result = evaluate_node_or_leaf(node.node.left.value) <= \
|
|
127
|
+
evaluate_node_or_leaf(node.node.right.value)
|
|
128
|
+
case "+":
|
|
129
|
+
# Binary addition operator
|
|
130
|
+
result = evaluate_node_or_leaf(node.node.left.value) + \
|
|
131
|
+
evaluate_node_or_leaf(node.node.right.value)
|
|
132
|
+
case "-":
|
|
133
|
+
# Binary subtraction operator
|
|
134
|
+
result = evaluate_node_or_leaf(node.node.left.value) - \
|
|
135
|
+
evaluate_node_or_leaf(node.node.right.value)
|
|
136
|
+
case "*":
|
|
137
|
+
# Binary multiplication operator
|
|
138
|
+
result = evaluate_node_or_leaf(node.node.left.value) * \
|
|
139
|
+
evaluate_node_or_leaf(node.node.right.value)
|
|
140
|
+
case "/":
|
|
141
|
+
# Binary division operator
|
|
142
|
+
result = evaluate_node_or_leaf(node.node.left.value) / \
|
|
143
|
+
evaluate_node_or_leaf(node.node.right.value)
|
|
144
|
+
entity_eval_cache[EntityEnum.FILTER][self.filter_name] = result
|
|
145
|
+
return result
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This module defines the MetadataStore class, which is responsible for retrieval of financial metadata.
|
|
3
|
+
The MetadataStore class provides methods for retrieval of metadata.
|
|
4
|
+
There should be no writes to this and this is expected to be a read-only store.
|
|
5
|
+
The MetadataStore class has the following methods:
|
|
6
|
+
-> get_attributes: Get the list of attributes available for the metadata.
|
|
7
|
+
-> get_all_metadata: retrieves all metadata from the store. The metadata is a list of dictionaries each defining a key value pair.
|
|
8
|
+
-> attribute_exist_for_field: checks if field exists in the store and if the attribute exists for the field.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
|
|
13
|
+
class MetadataStore:
|
|
14
|
+
def __init__(self, metadata_file_path: str):
|
|
15
|
+
"""
|
|
16
|
+
Initializes the MetadataStore object with an empty dictionary to store metadata.
|
|
17
|
+
"""
|
|
18
|
+
with open(metadata_file_path, 'r') as f:
|
|
19
|
+
self._metadata_store: dict[str, dict[str, str|int|float]] = json.load(f)
|
|
20
|
+
self._attributes = self._populate_attributes()
|
|
21
|
+
|
|
22
|
+
def _populate_attributes(self):
|
|
23
|
+
"""
|
|
24
|
+
Populates the attributes from the metadata store.
|
|
25
|
+
"""
|
|
26
|
+
attributes_set = set()
|
|
27
|
+
for _, entity_data in self._metadata_store.items():
|
|
28
|
+
for field in entity_data:
|
|
29
|
+
if field not in attributes_set:
|
|
30
|
+
attributes_set.add(field)
|
|
31
|
+
return attributes_set
|
|
32
|
+
|
|
33
|
+
def get_all_metadata(self) -> dict[str, dict[str, str|int|float]]:
|
|
34
|
+
"""
|
|
35
|
+
Retrieves all metadata from the store. The metadata is a list of dictionaries each defining a key value pair.
|
|
36
|
+
"""
|
|
37
|
+
return self._metadata_store
|
|
38
|
+
|
|
39
|
+
def does_field_exist(self, field: str) -> bool:
|
|
40
|
+
"""
|
|
41
|
+
Checks if field exists in the store.
|
|
42
|
+
"""
|
|
43
|
+
return field in self._metadata_store
|
|
44
|
+
|
|
45
|
+
def attribute_exist_for_field(self, field: str, attribute: str) -> bool:
|
|
46
|
+
"""
|
|
47
|
+
Checks if field exists in the store and if the attribute exists for the field.
|
|
48
|
+
"""
|
|
49
|
+
return field in self._metadata_store and attribute in self._metadata_store[field] and self._metadata_store[field][attribute] is not None
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This module defines the Parser class, which is responsible for parsing the expression string of a rule or filter.
|
|
3
|
+
The expression string is a string of operands and operators.
|
|
4
|
+
The Parser class has the staticmethod:
|
|
5
|
+
-> parse: parses the expression string and creates a prefix expression tree. It returns the root of the tree and raises RuntimeError if the expression is invalid.
|
|
6
|
+
"""
|
|
7
|
+
import logging
|
|
8
|
+
_logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
def _replace_words_with_symbols(expression_str):
|
|
11
|
+
"""
|
|
12
|
+
Replaces NOT by ! and AND by & and OR by | in the expression string.
|
|
13
|
+
"""
|
|
14
|
+
expression_str = expression_str.replace("NOT", "!")
|
|
15
|
+
expression_str = expression_str.replace("AND", "&")
|
|
16
|
+
expression_str = expression_str.replace("OR", "|")
|
|
17
|
+
return expression_str
|
|
18
|
+
|
|
19
|
+
import re
|
|
20
|
+
|
|
21
|
+
class Node:
|
|
22
|
+
def __init__(self, value, left=None, right=None):
|
|
23
|
+
"""
|
|
24
|
+
Represents a node in the expression tree.
|
|
25
|
+
- value: a string representing an operator or operand.
|
|
26
|
+
- left: the left child (None if not applicable).
|
|
27
|
+
- right: the right child (None for leaves or unary operators).
|
|
28
|
+
"""
|
|
29
|
+
self.value: str = value
|
|
30
|
+
self.left: Node = left
|
|
31
|
+
self.right: Node = right
|
|
32
|
+
|
|
33
|
+
def __repr__(self):
|
|
34
|
+
if self.left is None and self.right is None:
|
|
35
|
+
return f"Node({self.value})"
|
|
36
|
+
if self.right is None:
|
|
37
|
+
return f"Node({self.value}, {self.left})"
|
|
38
|
+
return f"Node({self.value}, {self.left}, {self.right})"
|
|
39
|
+
|
|
40
|
+
OPERATOR_SET = {'+', '-', '*', '/', '&', '|', '!', '<', '<=', '>', '>='}
|
|
41
|
+
|
|
42
|
+
class Parser:
|
|
43
|
+
"""
|
|
44
|
+
A recursive descent parser for infix expressions that supports:
|
|
45
|
+
- Parentheses: ( and )
|
|
46
|
+
- Operands as numbers or identifiers (like abc)
|
|
47
|
+
- Operators:
|
|
48
|
+
* Unary: ! (logical not)
|
|
49
|
+
* Binary arithmetic: +, -, *, /
|
|
50
|
+
* Relational: <, <=, >, >=
|
|
51
|
+
* Bitwise: & (and), | (or)
|
|
52
|
+
|
|
53
|
+
Operator precedence (from highest to lowest):
|
|
54
|
+
1. Unary: !
|
|
55
|
+
2. Multiplicative: *, /
|
|
56
|
+
3. Additive: +, -
|
|
57
|
+
4. Comparison: <, <=, >, >=
|
|
58
|
+
5. Bitwise AND: &
|
|
59
|
+
6. Bitwise OR: |
|
|
60
|
+
"""
|
|
61
|
+
def __init__(self, text: str):
|
|
62
|
+
self.text = text
|
|
63
|
+
self.tokens = self.tokenize(text)
|
|
64
|
+
self.pos = 0
|
|
65
|
+
|
|
66
|
+
def tokenize(self, text: str):
|
|
67
|
+
# Define token specifications.
|
|
68
|
+
token_spec = [
|
|
69
|
+
('NUMBER', r'\d+(\.\d+)?'), # Integer or decimal number
|
|
70
|
+
('RELOP', r'(<=|>=|<|>)'), # Relational operators
|
|
71
|
+
('IDENT', r'[A-Za-z_]\w*'), # Identifiers (operands)
|
|
72
|
+
('OP', r'[+\-*/&|!]'), # Other operators
|
|
73
|
+
('LPAREN', r'\('), # Left Parenthesis
|
|
74
|
+
('RPAREN', r'\)'), # Right Parenthesis
|
|
75
|
+
('SKIP', r'\s+'), # Skip whitespace
|
|
76
|
+
('MISMATCH', r'.'), # Any other character
|
|
77
|
+
]
|
|
78
|
+
token_regex = '|'.join(f'(?P<{name}>{pattern})' for name, pattern in token_spec)
|
|
79
|
+
tokens = []
|
|
80
|
+
for mo in re.finditer(token_regex, text):
|
|
81
|
+
kind = mo.lastgroup
|
|
82
|
+
value = mo.group()
|
|
83
|
+
if kind == 'NUMBER':
|
|
84
|
+
tokens.append(('NUMBER', value))
|
|
85
|
+
elif kind == 'RELOP':
|
|
86
|
+
tokens.append(('RELOP', value))
|
|
87
|
+
elif kind == 'IDENT':
|
|
88
|
+
tokens.append(('IDENT', value))
|
|
89
|
+
elif kind == 'OP':
|
|
90
|
+
tokens.append(('OP', value))
|
|
91
|
+
elif kind == 'LPAREN':
|
|
92
|
+
tokens.append(('LPAREN', value))
|
|
93
|
+
elif kind == 'RPAREN':
|
|
94
|
+
tokens.append(('RPAREN', value))
|
|
95
|
+
elif kind == 'SKIP':
|
|
96
|
+
continue
|
|
97
|
+
else:
|
|
98
|
+
raise RuntimeError(f"Unexpected token: {value}")
|
|
99
|
+
return tokens
|
|
100
|
+
|
|
101
|
+
def peek(self):
|
|
102
|
+
if self.pos < len(self.tokens):
|
|
103
|
+
return self.tokens[self.pos]
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
def consume(self, expected_type=None, expected_value=None):
|
|
107
|
+
token = self.peek()
|
|
108
|
+
if token is None:
|
|
109
|
+
return None
|
|
110
|
+
if expected_type and token[0] != expected_type:
|
|
111
|
+
raise RuntimeError(f"Expected token type '{expected_type}', got '{token[0]}' with value '{token[1]}'")
|
|
112
|
+
if expected_value and token[1] != expected_value:
|
|
113
|
+
raise RuntimeError(f"Expected token value '{expected_value}', got '{token[1]}'")
|
|
114
|
+
self.pos += 1
|
|
115
|
+
return token
|
|
116
|
+
|
|
117
|
+
def parse_expression(self):
|
|
118
|
+
# Top-level rule: start with the lowest precedence, bitwise OR.
|
|
119
|
+
return self.parse_bitwise_or()
|
|
120
|
+
|
|
121
|
+
def parse_bitwise_or(self):
|
|
122
|
+
node = self.parse_bitwise_and()
|
|
123
|
+
while True:
|
|
124
|
+
token = self.peek()
|
|
125
|
+
if token and token[0] == 'OP' and token[1] == '|':
|
|
126
|
+
self.consume('OP', '|')
|
|
127
|
+
right = self.parse_bitwise_and()
|
|
128
|
+
node = Node('|', node, right)
|
|
129
|
+
else:
|
|
130
|
+
break
|
|
131
|
+
return node
|
|
132
|
+
|
|
133
|
+
def parse_bitwise_and(self):
|
|
134
|
+
node = self.parse_comparison()
|
|
135
|
+
while True:
|
|
136
|
+
token = self.peek()
|
|
137
|
+
if token and token[0] == 'OP' and token[1] == '&':
|
|
138
|
+
self.consume('OP', '&')
|
|
139
|
+
right = self.parse_comparison()
|
|
140
|
+
node = Node('&', node, right)
|
|
141
|
+
else:
|
|
142
|
+
break
|
|
143
|
+
return node
|
|
144
|
+
|
|
145
|
+
def parse_comparison(self):
|
|
146
|
+
node = self.parse_additive()
|
|
147
|
+
token = self.peek()
|
|
148
|
+
# Allow at most one comparison operator (non-associative)
|
|
149
|
+
if token and token[0] == 'RELOP':
|
|
150
|
+
op = token[1]
|
|
151
|
+
self.consume('RELOP', op)
|
|
152
|
+
right = self.parse_additive()
|
|
153
|
+
node = Node(op, node, right)
|
|
154
|
+
return node
|
|
155
|
+
|
|
156
|
+
def parse_additive(self):
|
|
157
|
+
node = self.parse_multiplicative()
|
|
158
|
+
while True:
|
|
159
|
+
token = self.peek()
|
|
160
|
+
if token and token[0] == 'OP' and token[1] in ('+', '-'):
|
|
161
|
+
op = token[1]
|
|
162
|
+
self.consume('OP', op)
|
|
163
|
+
right = self.parse_multiplicative()
|
|
164
|
+
node = Node(op, node, right)
|
|
165
|
+
else:
|
|
166
|
+
break
|
|
167
|
+
return node
|
|
168
|
+
|
|
169
|
+
def parse_multiplicative(self):
|
|
170
|
+
node = self.parse_unary()
|
|
171
|
+
while True:
|
|
172
|
+
token = self.peek()
|
|
173
|
+
if token and token[0] == 'OP' and token[1] in ('*', '/'):
|
|
174
|
+
op = token[1]
|
|
175
|
+
self.consume('OP', op)
|
|
176
|
+
right = self.parse_unary()
|
|
177
|
+
node = Node(op, node, right)
|
|
178
|
+
else:
|
|
179
|
+
break
|
|
180
|
+
return node
|
|
181
|
+
|
|
182
|
+
def parse_unary(self):
|
|
183
|
+
token = self.peek()
|
|
184
|
+
if token and token[0] == 'OP' and token[1] == '!':
|
|
185
|
+
self.consume('OP', '!')
|
|
186
|
+
operand = self.parse_unary()
|
|
187
|
+
return Node('!', operand)
|
|
188
|
+
return self.parse_primary()
|
|
189
|
+
|
|
190
|
+
def parse_primary(self):
|
|
191
|
+
token = self.peek()
|
|
192
|
+
if token is None:
|
|
193
|
+
raise RuntimeError("Unexpected end of expression")
|
|
194
|
+
if token[0] == 'NUMBER' or token[0] == 'IDENT':
|
|
195
|
+
self.consume(token[0])
|
|
196
|
+
return Node(token[1])
|
|
197
|
+
elif token[0] == 'LPAREN':
|
|
198
|
+
self.consume('LPAREN')
|
|
199
|
+
node = self.parse_expression()
|
|
200
|
+
if self.peek() is None or self.peek()[0] != 'RPAREN':
|
|
201
|
+
raise RuntimeError("Missing closing parenthesis")
|
|
202
|
+
self.consume('RPAREN')
|
|
203
|
+
return node
|
|
204
|
+
else:
|
|
205
|
+
raise RuntimeError(f"Unexpected token: {token}")
|
|
206
|
+
|
|
207
|
+
@staticmethod
|
|
208
|
+
def parse(expr: str) -> Node:
|
|
209
|
+
"""
|
|
210
|
+
Parses the given infix expression string and returns the root
|
|
211
|
+
of the generated expression tree.
|
|
212
|
+
Raises RuntimeError if the expression is invalid.
|
|
213
|
+
"""
|
|
214
|
+
expr = _replace_words_with_symbols(expr)
|
|
215
|
+
parser = Parser(expr)
|
|
216
|
+
node = parser.parse_expression()
|
|
217
|
+
if parser.pos != len(parser.tokens):
|
|
218
|
+
errMsg = f"Invalid expression: Extra tokens present. Parser pos{parser.pos} and tokens {parser.tokens}"
|
|
219
|
+
_logger.error(errMsg)
|
|
220
|
+
raise RuntimeError(errMsg)
|
|
221
|
+
return node
|
|
222
|
+
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This module defines the Rule class, which represents a rule in the rule engine.
|
|
3
|
+
Each rule contains a combination of filters or existing rule.
|
|
4
|
+
Rules can be made in the following ways:
|
|
5
|
+
-> Rule with filters: A rule can be created with a combination of filters. Filters can be either combined with binary operators or can be used as a single filter optionally with a unary operators.
|
|
6
|
+
-> Rule with filters and existing rules: A rule can be created with a combination of filters and existing rules using binary operators.
|
|
7
|
+
-> Rule with existing rules: A rule can be created with a combination of existing rules using binary operators.
|
|
8
|
+
The Rule class has following methods:
|
|
9
|
+
-> validate: validates whether the operands of the rule are valid. The operands of rule being rules and filters. It queries the underlying store to check if filter and rule exist.
|
|
10
|
+
-> evaluate: evaluates the rule against the given data. The evaluation is done by evaluating each operand and combining the results using the operator.
|
|
11
|
+
-> __init__: initializes the Rule object with a string representing the expression string of the rule. The expression string is parsed to create a expression tree. The nodes of the tree are created using the Filter and Rule classes. The tree is then used to evaluate the rule.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from rule_engine_core.metadata_store import MetadataStore
|
|
15
|
+
from rule_engine_core.entity_dao import EntityDao
|
|
16
|
+
from rule_engine_core.entity_types import EntityEnum, EntityNode
|
|
17
|
+
from rule_engine_core.entity_base import EntityBase
|
|
18
|
+
from rule_engine_core.parser import Node, OPERATOR_SET
|
|
19
|
+
import logging
|
|
20
|
+
from rule_engine_core.filter import Filter
|
|
21
|
+
|
|
22
|
+
_logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
class Rule(EntityBase):
|
|
25
|
+
def __init__(self, rule_name: str, expression: str, metadata_store: MetadataStore, entity_dao: EntityDao):
|
|
26
|
+
"""
|
|
27
|
+
Initializes the Rule object with a string representing the expression string of the rule.
|
|
28
|
+
The expression string is parsed to create a expression tree. The nodes of the tree are created using the Filter and Rule classes. The tree is then used to evaluate the rule.
|
|
29
|
+
"""
|
|
30
|
+
super().__init__(expression, EntityEnum.RULE, rule_name)
|
|
31
|
+
self.rule_name = rule_name
|
|
32
|
+
self._metadata_store = metadata_store
|
|
33
|
+
self._entity_dao = entity_dao
|
|
34
|
+
|
|
35
|
+
def _validate_node(self, node: Node) -> bool:
|
|
36
|
+
# Recursively validates the operands of the rule.
|
|
37
|
+
if node is None:
|
|
38
|
+
return True
|
|
39
|
+
if node.value in OPERATOR_SET:
|
|
40
|
+
return self._validate_node(node.left) and self._validate_node(node.right)
|
|
41
|
+
|
|
42
|
+
# debug log if rule is a number or a field in the metadata store or an existing rule/filter in the database
|
|
43
|
+
_logger.debug(f"Node: {node.value} is node a number {self._check_if_node_is_number(node.value)} or a field in the metadata store {self._metadata_store.does_field_exist(node.value)} or an existing filter in the database {self._entity_dao.entity_exists(EntityEnum.FILTER, node.value)} or existing rule in the database {self._entity_dao.entity_exists(EntityEnum.RULE, node.value)}")
|
|
44
|
+
# todo: do we want the rules to have primary operands or just composed of rules and filters ?
|
|
45
|
+
# check if node value is a number or a field in the metadata store or an existing filter in the database
|
|
46
|
+
return self._check_if_node_is_number(node.value) or \
|
|
47
|
+
self._metadata_store.does_field_exist(node.value) or \
|
|
48
|
+
self._entity_dao.entity_exists(EntityEnum.FILTER, node.value) or \
|
|
49
|
+
self._entity_dao.entity_exists(EntityEnum.RULE, node.value)
|
|
50
|
+
|
|
51
|
+
def _evaluate_node(self, node: EntityNode, pos_data: dict[str, str|int|float], entity_eval_cache: dict[EntityEnum, dict[str, int|float|bool]], entity_cache: dict[EntityEnum, dict[str, EntityBase]]) -> float|int|bool:
|
|
52
|
+
_logger.info(f"Evaluating rule {self.rule_name} with value {node.node.value} and node type {node.entity_type}")
|
|
53
|
+
# Evaluates the node against the given data. And updates the cache with the result.
|
|
54
|
+
# check is rule_name is cached in entity_eval_cache
|
|
55
|
+
if self.rule_name in entity_eval_cache[EntityEnum.RULE]:
|
|
56
|
+
return entity_eval_cache[EntityEnum.RULE][self.rule_name]
|
|
57
|
+
if node.entity_type != EntityEnum.RULE or node.node is None:
|
|
58
|
+
raise ValueError(f"Invalid node type: {node.entity_type} or invalide node: {node.node}")
|
|
59
|
+
|
|
60
|
+
if node.node.value not in OPERATOR_SET:
|
|
61
|
+
raise ValueError(f"Invalid operator: {node.node.value}")
|
|
62
|
+
|
|
63
|
+
def evaluate_node_or_leaf(node_ident: str) -> float|int|bool:
|
|
64
|
+
try:
|
|
65
|
+
return int(node_ident)
|
|
66
|
+
except ValueError:
|
|
67
|
+
try:
|
|
68
|
+
return float(node_ident)
|
|
69
|
+
except ValueError:
|
|
70
|
+
if self._metadata_store.does_field_exist(node_ident):
|
|
71
|
+
return pos_data[node_ident]
|
|
72
|
+
else:
|
|
73
|
+
# if not found in metadata store, evaluate the filter from struct
|
|
74
|
+
# since the filter has not been evaluated yet
|
|
75
|
+
if entity_cache[EntityEnum.FILTER].get(node_ident, None) is None and \
|
|
76
|
+
entity_cache[EntityEnum.RULE].get(node_ident, None) is None:
|
|
77
|
+
err_msg = f"Filter {node_ident} is not a valid entity for this rule evaluation cycle. FIlter keys {entity_cache[EntityEnum.FILTER].keys()} Rule keys {entity_cache[EntityEnum.RULE].keys()}"
|
|
78
|
+
_logger.error(err_msg)
|
|
79
|
+
# propagata the error to the rule evaluation
|
|
80
|
+
raise ValueError(err_msg)
|
|
81
|
+
if (node_ident in entity_cache[EntityEnum.FILTER] and entity_cache[EntityEnum.FILTER].get(node_ident).node_type is not EntityEnum.FILTER) or (node_ident in entity_cache[EntityEnum.RULE] and entity_cache[EntityEnum.RULE].get(node_ident).node_type is not EntityEnum.RULE):
|
|
82
|
+
err_msg = f"Filter {node_ident}: has bad expression tree"
|
|
83
|
+
_logger.error(err_msg)
|
|
84
|
+
# propagata the error to the rule evaluation
|
|
85
|
+
raise ValueError(err_msg)
|
|
86
|
+
_logger.debug(f"Evaluating child node {node_ident} of type {EntityEnum.FILTER.value if node_ident in entity_cache[EntityEnum.FILTER] else EntityEnum.RULE.value}")
|
|
87
|
+
# import pdb; pdb.set_trace()
|
|
88
|
+
return entity_cache[EntityEnum.FILTER][node_ident].evaluate(pos_data, entity_eval_cache, entity_cache) if entity_cache[EntityEnum.FILTER][node_ident].node_type == EntityEnum.FILTER else \
|
|
89
|
+
entity_cache[EntityEnum.RULE][node_ident].evaluate(pos_data, entity_eval_cache, entity_cache)
|
|
90
|
+
|
|
91
|
+
match node.node.value:
|
|
92
|
+
case "!":
|
|
93
|
+
# Unary not operator
|
|
94
|
+
result = not evaluate_node_or_leaf(node.node.left.value)
|
|
95
|
+
case "&":
|
|
96
|
+
# Binary and operator
|
|
97
|
+
result = evaluate_node_or_leaf(node.node.left.value) and \
|
|
98
|
+
evaluate_node_or_leaf(node.node.right.value)
|
|
99
|
+
case "|":
|
|
100
|
+
# Binary or operator
|
|
101
|
+
result = evaluate_node_or_leaf(node.node.left.value) or \
|
|
102
|
+
evaluate_node_or_leaf(node.node.right.value)
|
|
103
|
+
case ">":
|
|
104
|
+
# bonary greater than operator
|
|
105
|
+
result = evaluate_node_or_leaf(node.node.left.value) > \
|
|
106
|
+
evaluate_node_or_leaf(node.node.right.value)
|
|
107
|
+
case "<":
|
|
108
|
+
# Binary less than operator
|
|
109
|
+
result = evaluate_node_or_leaf(node.node.left.value) < \
|
|
110
|
+
evaluate_node_or_leaf(node.node.right.value)
|
|
111
|
+
case ">=":
|
|
112
|
+
# Binary greater than or equal to operator
|
|
113
|
+
result = evaluate_node_or_leaf(node.node.left.value) >= \
|
|
114
|
+
evaluate_node_or_leaf(node.node.right.value)
|
|
115
|
+
case "<=":
|
|
116
|
+
# Binary less than or equal to operator
|
|
117
|
+
result = evaluate_node_or_leaf(node.node.left.value) <= \
|
|
118
|
+
evaluate_node_or_leaf(node.node.right.value)
|
|
119
|
+
case "+":
|
|
120
|
+
# Binary addition operator
|
|
121
|
+
result = evaluate_node_or_leaf(node.node.left.value) + \
|
|
122
|
+
evaluate_node_or_leaf(node.node.right.value)
|
|
123
|
+
case "-":
|
|
124
|
+
# Binary subtraction operator
|
|
125
|
+
result = evaluate_node_or_leaf(node.node.left.value) - \
|
|
126
|
+
evaluate_node_or_leaf(node.node.right.value)
|
|
127
|
+
case "*":
|
|
128
|
+
# Binary multiplication operator
|
|
129
|
+
result = evaluate_node_or_leaf(node.node.left.value) * \
|
|
130
|
+
evaluate_node_or_leaf(node.node.right.value)
|
|
131
|
+
case "/":
|
|
132
|
+
# Binary division operator
|
|
133
|
+
result = evaluate_node_or_leaf(node.node.left.value) / \
|
|
134
|
+
evaluate_node_or_leaf(node.node.right.value)
|
|
135
|
+
entity_eval_cache[EntityEnum.RULE][self.rule_name] = result
|
|
136
|
+
return result
|
|
137
|
+
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: rule-engine-core
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: A rule engine core library for Python.
|
|
5
|
+
Home-page: https://github.com/temp-noob/rule-engine
|
|
6
|
+
Author: Mohit Tripathi
|
|
7
|
+
Author-email: tripathimohit051@gmail.com
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Requires-Python: >=3.10
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
Requires-Dist: psycopg2-binary
|
|
14
|
+
Dynamic: author
|
|
15
|
+
Dynamic: author-email
|
|
16
|
+
Dynamic: classifier
|
|
17
|
+
Dynamic: description
|
|
18
|
+
Dynamic: description-content-type
|
|
19
|
+
Dynamic: home-page
|
|
20
|
+
Dynamic: requires-dist
|
|
21
|
+
Dynamic: requires-python
|
|
22
|
+
Dynamic: summary
|
|
23
|
+
|
|
24
|
+
## Build
|
|
25
|
+
-> create venv and activate
|
|
26
|
+
(install meta)
|
|
27
|
+
-> pip install steuptools wheel twine
|
|
28
|
+
(build package)
|
|
29
|
+
-> python setup.py sdist bdist_wheel
|
|
30
|
+
-> pip install dist/rule-engine-core-0.1.0-py3-none-any.whl
|
|
31
|
+
(Publish to pypi)
|
|
32
|
+
-> pip install twine
|
|
33
|
+
-> twine upload dist/*
|
|
34
|
+
|
|
35
|
+
### Docker
|
|
36
|
+
-> `psql --username=$POSTGRES_USER -d $POSTGRES_DB`
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
setup.py
|
|
4
|
+
rule_engine_core/__init__.py
|
|
5
|
+
rule_engine_core/entity_base.py
|
|
6
|
+
rule_engine_core/entity_dao.py
|
|
7
|
+
rule_engine_core/entity_types.py
|
|
8
|
+
rule_engine_core/filter.py
|
|
9
|
+
rule_engine_core/metadata_store.py
|
|
10
|
+
rule_engine_core/parser.py
|
|
11
|
+
rule_engine_core/rule.py
|
|
12
|
+
rule_engine_core.egg-info/PKG-INFO
|
|
13
|
+
rule_engine_core.egg-info/SOURCES.txt
|
|
14
|
+
rule_engine_core.egg-info/dependency_links.txt
|
|
15
|
+
rule_engine_core.egg-info/requires.txt
|
|
16
|
+
rule_engine_core.egg-info/top_level.txt
|
|
17
|
+
tests/test_rule_creation_storage.py
|
|
18
|
+
tests/test_rule_evaluation.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
psycopg2-binary
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
rule_engine_core
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from setuptools import setup, find_packages
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
def parse_requirements(filename):
|
|
5
|
+
"""Read requirements from file, removing comments and empty lines"""
|
|
6
|
+
requirements = []
|
|
7
|
+
with open(filename, "r") as file:
|
|
8
|
+
for line in file:
|
|
9
|
+
line = line.strip()
|
|
10
|
+
# Skip comments and empty lines
|
|
11
|
+
if line and not line.startswith('#'):
|
|
12
|
+
requirements.append(line)
|
|
13
|
+
return requirements
|
|
14
|
+
path = os.path.join(os.getenv('BUILD_PATH'), "requirements.txt")
|
|
15
|
+
setup(
|
|
16
|
+
name="rule-engine-core",
|
|
17
|
+
version="0.0.1",
|
|
18
|
+
author="Mohit Tripathi",
|
|
19
|
+
author_email="tripathimohit051@gmail.com",
|
|
20
|
+
description="A rule engine core library for Python.",
|
|
21
|
+
long_description=open("README.md").read(),
|
|
22
|
+
long_description_content_type="text/markdown",
|
|
23
|
+
url="https://github.com/temp-noob/rule-engine",
|
|
24
|
+
packages=find_packages(),
|
|
25
|
+
classifiers=[
|
|
26
|
+
"Programming Language :: Python :: 3",
|
|
27
|
+
"License :: OSI Approved :: MIT License",
|
|
28
|
+
"Operating System :: OS Independent",
|
|
29
|
+
],
|
|
30
|
+
python_requires=">=3.10",
|
|
31
|
+
# install_requires=parse_requirements("/home/tripathimohit051/rule-engine/rule-engine-core/requirements.txt")
|
|
32
|
+
install_requires=parse_requirements(path), # Use the function to parse requirements
|
|
33
|
+
)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Tests the rule creation and storage functionality
|
|
2
|
+
# These are integration tests that check if the rule engine can create and store rules correctly.
|
|
3
|
+
from rule_engine_core.rule import Rule
|
|
4
|
+
from rule_engine_core.entity_dao import EntityDao
|
|
5
|
+
from rule_engine_core.entity_types import EntityEnum, EntityNode
|
|
6
|
+
from rule_engine_core.filter import Filter
|
|
7
|
+
from rule_engine_core.parser import Node
|
|
8
|
+
from rule_engine_core.metadata_store import MetadataStore
|
|
9
|
+
import uuid
|
|
10
|
+
|
|
11
|
+
def test_filter_creation_validation_storage_retrieval():
|
|
12
|
+
metadata_store = MetadataStore("tests/test_metadatastore.json")
|
|
13
|
+
entity_dao = EntityDao()
|
|
14
|
+
# Create a filter
|
|
15
|
+
filter_name = str(uuid.uuid4())
|
|
16
|
+
expression = "col2 > 10"
|
|
17
|
+
filter_obj = Filter(filter_name, expression, metadata_store, entity_dao)
|
|
18
|
+
# Validate the filter
|
|
19
|
+
assert filter_obj.validate() == True
|
|
20
|
+
# Store the filter
|
|
21
|
+
entity_dao.store_entity(EntityEnum.FILTER, filter_obj)
|
|
22
|
+
# Retrieve the filter
|
|
23
|
+
does_filter_exist = entity_dao.entity_exists(EntityEnum.FILTER, filter_name)
|
|
24
|
+
assert does_filter_exist is True
|
|
25
|
+
# Validate the retrieved filter
|
|
26
|
+
all_filters = entity_dao.get_all_entities(EntityEnum.FILTER)
|
|
27
|
+
is_filter_present = any(
|
|
28
|
+
filter_obj.name == filter_name and filter_obj.expression == expression
|
|
29
|
+
for filter_obj in all_filters.values()
|
|
30
|
+
)
|
|
31
|
+
assert is_filter_present is True
|
|
32
|
+
# Clean up
|
|
33
|
+
assert entity_dao.delete_entity(EntityEnum.FILTER, filter_name)
|
|
34
|
+
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# Tests the rule evaluation
|
|
2
|
+
# These are integration tests which first create the rule and then evaluate it.
|
|
3
|
+
from rule_engine_core.rule import Rule
|
|
4
|
+
from rule_engine_core.entity_dao import EntityDao
|
|
5
|
+
from rule_engine_core.entity_types import EntityEnum, EntityNode
|
|
6
|
+
from rule_engine_core.filter import Filter
|
|
7
|
+
from rule_engine_core.parser import Node
|
|
8
|
+
from rule_engine_core.metadata_store import MetadataStore
|
|
9
|
+
import uuid
|
|
10
|
+
import logging
|
|
11
|
+
import random, string
|
|
12
|
+
|
|
13
|
+
_logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
def generate_random_alphanumeric(length=8):
|
|
16
|
+
"""
|
|
17
|
+
Generate a random alphanumeric string of specified length
|
|
18
|
+
where the first character is always a letter.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
length (int): The length of the string to generate (default: 8)
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
str: A random alphanumeric string starting with a letter
|
|
25
|
+
"""
|
|
26
|
+
if length < 1:
|
|
27
|
+
raise ValueError("Length must be at least 1")
|
|
28
|
+
|
|
29
|
+
# Generate a random letter for the first character
|
|
30
|
+
first_char = random.choice(string.ascii_letters)
|
|
31
|
+
|
|
32
|
+
# Generate random alphanumeric characters for the rest of the string
|
|
33
|
+
if length > 1:
|
|
34
|
+
rest_chars = ''.join(random.choices(
|
|
35
|
+
string.ascii_letters + string.digits,
|
|
36
|
+
k=length-1
|
|
37
|
+
))
|
|
38
|
+
return first_char + rest_chars
|
|
39
|
+
else:
|
|
40
|
+
return first_char
|
|
41
|
+
|
|
42
|
+
def test_rule_evaluation(entity_dao: EntityDao):
|
|
43
|
+
metadata_store = MetadataStore("tests/test_metadatastore.json")
|
|
44
|
+
# Create a filter with a filter name which is just alpha numeric and no dash
|
|
45
|
+
|
|
46
|
+
filter_name = generate_random_alphanumeric()
|
|
47
|
+
_logger.debug('filter1_name: %s', filter_name)
|
|
48
|
+
expression = "col2 > 10"
|
|
49
|
+
filter_obj = Filter(filter_name, expression, metadata_store, entity_dao)
|
|
50
|
+
# Validate the filter
|
|
51
|
+
assert filter_obj.validate() == True
|
|
52
|
+
# Store the filter
|
|
53
|
+
entity_dao.store_entity(EntityEnum.FILTER, filter_obj)
|
|
54
|
+
# create another filter
|
|
55
|
+
filter_name2 = generate_random_alphanumeric()
|
|
56
|
+
_logger.debug('filter2_name: %s', filter_name2)
|
|
57
|
+
expression2 = "col3 < 20"
|
|
58
|
+
filter_obj2 = Filter(filter_name2, expression2, metadata_store, entity_dao)
|
|
59
|
+
# Validate the filter
|
|
60
|
+
assert filter_obj2.validate() == True
|
|
61
|
+
# Store the filter
|
|
62
|
+
entity_dao.store_entity(EntityEnum.FILTER, filter_obj2)
|
|
63
|
+
# Create a rule
|
|
64
|
+
rule_name = generate_random_alphanumeric()
|
|
65
|
+
expression = f"{filter_name} AND {filter_name2}"
|
|
66
|
+
rule_obj = Rule(rule_name, expression, metadata_store, entity_dao)
|
|
67
|
+
# Validate the rule
|
|
68
|
+
assert rule_obj.validate() == True
|
|
69
|
+
# Store the rule
|
|
70
|
+
entity_dao.store_entity(EntityEnum.RULE, rule_obj)
|
|
71
|
+
# import pdb; pdb.set_trace()
|
|
72
|
+
# populate the entity_cache for evaluation
|
|
73
|
+
entity_cache = {}
|
|
74
|
+
rule_entities = entity_dao.get_all_entities(EntityEnum.RULE)
|
|
75
|
+
entity_cache[EntityEnum.RULE] = {}
|
|
76
|
+
for rule_name, rule_obj in rule_entities.items():
|
|
77
|
+
entity_cache[EntityEnum.RULE][rule_name] = Rule(rule_name, rule_obj.expression, metadata_store, entity_dao)
|
|
78
|
+
filter_entities = entity_dao.get_all_entities(EntityEnum.FILTER)
|
|
79
|
+
entity_cache[EntityEnum.FILTER] = {}
|
|
80
|
+
for filter_name, filter_obj in filter_entities.items():
|
|
81
|
+
entity_cache[EntityEnum.FILTER][filter_name] = Filter(filter_name, filter_obj.expression, metadata_store, entity_dao)
|
|
82
|
+
for key in entity_cache.keys():
|
|
83
|
+
_logger.info(f"entity_cache: {key.value}: {entity_cache[key].keys()}")
|
|
84
|
+
# initialise the entity_eval_cache for evaluation
|
|
85
|
+
# populate the pos_data for evaluation. pos_data is a list of all positions, each position
|
|
86
|
+
# containing the data for the columns. where columns are the fields in the metadata store
|
|
87
|
+
pos_data = [
|
|
88
|
+
{
|
|
89
|
+
"col1": "abc",
|
|
90
|
+
"col2": 15,
|
|
91
|
+
"col3": 25
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
"col1": "def",
|
|
95
|
+
"col2": 5,
|
|
96
|
+
"col3": 10
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
"col1": "ghi",
|
|
100
|
+
"col2": 12,
|
|
101
|
+
"col3": 19
|
|
102
|
+
}
|
|
103
|
+
]
|
|
104
|
+
expectations = [False, False, True]
|
|
105
|
+
for rule_name, rule_obj in entity_cache[EntityEnum.RULE].items():
|
|
106
|
+
for pos, exp in zip(pos_data, expectations):
|
|
107
|
+
entity_eval_cache = {EntityEnum.RULE: {}, EntityEnum.FILTER: {}}
|
|
108
|
+
_logger.info(f"evaluating rule {rule_name} for pos {pos}")
|
|
109
|
+
# evaluate the rule
|
|
110
|
+
result = rule_obj.evaluate(pos, entity_eval_cache, entity_cache)
|
|
111
|
+
_logger.debug(f"state of entity_eval_cache: {entity_eval_cache}")
|
|
112
|
+
_logger.debug(f"expectation: {exp}")
|
|
113
|
+
_logger.info(f"result: {result}")
|
|
114
|
+
assert result == exp
|
|
115
|
+
|