rule-engine-core 0.0.1__py3-none-any.whl

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.
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,10 @@
1
+ from enum import Enum
2
+ from rule_engine_core.parser import Node
3
+ class EntityEnum(Enum):
4
+ RULE = "rule"
5
+ FILTER = "filter"
6
+
7
+ class EntityNode:
8
+ def __init__(self, node: Node, entity_type: EntityEnum):
9
+ self.entity_type = entity_type
10
+ self.node = node
@@ -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,12 @@
1
+ rule_engine_core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ rule_engine_core/entity_base.py,sha256=fBxO3o5I6lVfAmhABEUtrnNw4OQyMK0r3lLDFLP-qY4,3569
3
+ rule_engine_core/entity_dao.py,sha256=luDAqOUfk_A9tt5M9vRxAgNX78tqO8YYsiLWAcopGk4,7457
4
+ rule_engine_core/entity_types.py,sha256=KQ5x3NsVy1HYukWJvPiSsMe-BgDgAUwz76U3IqEAXPs,271
5
+ rule_engine_core/filter.py,sha256=4bcL2W2UWby_L_5zN-jcsYD3g590mL74ksfFR1O9MfI,8647
6
+ rule_engine_core/metadata_store.py,sha256=d-i-7RAN-DOCSpwkARDWoM4OPgJDNDCqH1TXz8bSWj8,2148
7
+ rule_engine_core/parser.py,sha256=FLBXlE_72bmMCrjlrQnOgqDxI5HO4E0aeIP8p-rpQS8,8069
8
+ rule_engine_core/rule.py,sha256=pnKRm3GxLrumm0byKCQcXH3YwT6OAGtbSDBjm6uCbjQ,9160
9
+ rule_engine_core-0.0.1.dist-info/METADATA,sha256=HttQ90_OcKDZ60QRfaN96sgLWxz4Ilg16_56D3M3YTA,992
10
+ rule_engine_core-0.0.1.dist-info/WHEEL,sha256=CmyFI0kx5cdEMTLiONQRbGQwjIoR1aIYB7eCAQ4KPJ0,91
11
+ rule_engine_core-0.0.1.dist-info/top_level.txt,sha256=cAKgKEaQs3beemyeE5dLxLJ37qvZTVXc5gz0yuP2KrA,17
12
+ rule_engine_core-0.0.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (78.1.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ rule_engine_core