├── .circleci
└── config.yml
├── .gitignore
├── .idea
├── codeStyles
│ └── Project.xml
├── dbnavigator.xml
├── inspectionProfiles
│ └── Project_Default.xml
├── misc.xml
├── modules.xml
├── rSettings.xml
├── vcs.xml
└── workspace.xml
├── README.md
├── __pycache__
└── test_decisions.cpython-36-pytest-5.0.0.pyc
├── common
├── __init__.py
└── exception
│ ├── __init__.py
│ └── invalid_json_exception.py
├── decision_rule.lark
├── examples
├── simple_decision.json
├── simple_decision_when_any.json
└── simple_score.json
├── requirements.txt
├── services
├── __init__.py
├── adapter
│ ├── __init__.py
│ ├── simple_rule_engine_adapter.py
│ ├── simple_rule_engine_dict_adapter.py
│ └── simple_rule_engine_lark_tree_adapter.py
└── util
│ ├── __init__.py
│ ├── json_file_util.py
│ └── simple_rule_engine_util.py
├── test_custom_decision_rule_lark.py
├── test_json_file_util.py
├── test_simple_rule_engine_dict_adapter.py
└── test_simple_rule_engine_library.py
/.circleci/config.yml:
--------------------------------------------------------------------------------
1 | # Use the latest 2.1 version of CircleCI pipeline process engine.
2 | # See: https://circleci.com/docs/2.0/configuration-reference
3 | version: 2.1
4 |
5 | # Orbs are reusable packages of CircleCI configuration that you may share across projects, enabling you to create encapsulated, parameterized commands, jobs, and executors that can be used across multiple projects.
6 | # See: https://circleci.com/docs/2.0/orb-intro/
7 | orbs:
8 | # The python orb contains a set of prepackaged CircleCI configuration you can use repeatedly in your configuration files
9 | # Orb commands and jobs help you with common scripting around a language/tool
10 | # so you dont have to copy and paste it everywhere.
11 | # See the orb documentation here: https://circleci.com/developer/orbs/orb/circleci/python
12 | python: circleci/python@1.5.0
13 |
14 | # Define a job to be invoked later in a workflow.
15 | # See: https://circleci.com/docs/2.0/configuration-reference/#jobs
16 | jobs:
17 | build-and-test: # This is the name of the job, feel free to change it to better match what you're trying to do!
18 | # These next lines defines a Docker executors: https://circleci.com/docs/2.0/executor-types/
19 | # You can specify an image from Dockerhub or use one of the convenience images from CircleCI's Developer Hub
20 | # A list of available CircleCI Docker convenience images are available here: https://circleci.com/developer/images/image/cimg/python
21 | # The executor is the environment in which the steps below will be executed - below will use a python 3.10.2 container
22 | # Change the version below to your required version of python
23 | docker:
24 | - image: cimg/python:3.10.2
25 | # Checkout the code as the first step. This is a dedicated CircleCI step.
26 | # The python orb's install-packages step will install the dependencies from a Pipfile via Pipenv by default.
27 | # Here we're making sure we use just use the system-wide pip. By default it uses the project root's requirements.txt.
28 | # Then run your tests!
29 | # CircleCI will report the results back to your VCS provider.
30 | steps:
31 | - checkout
32 | - python/install-packages:
33 | pkg-manager: pip
34 | # app-dir: ~/project/package-directory/ # If you're requirements.txt isn't in the root directory.
35 | # pip-dependency-file: test-requirements.txt # if you have a different name for your requirements file, maybe one that combines your runtime and test requirements.
36 | - run:
37 | name: Run tests
38 | # This assumes pytest is installed via the install-package step above
39 | command: pytest
40 |
41 | # Invoke jobs via workflows
42 | # See: https://circleci.com/docs/2.0/configuration-reference/#workflows
43 | workflows:
44 | sample: # This is the name of the workflow, feel free to change it to better match your workflow.
45 | # Inside the workflow, you define the jobs you want to run.
46 | jobs:
47 | - build-and-test
48 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /.env/
2 | /venv/
3 | .idea/
4 | __pycache__/
5 | .idea/workspace.xml
6 | .venv/
7 | .idea
--------------------------------------------------------------------------------
/.idea/codeStyles/Project.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
--------------------------------------------------------------------------------
/.idea/dbnavigator.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
97 |
98 |
99 |
100 |
101 |
102 |
103 |
104 |
105 |
106 |
107 |
108 |
109 |
110 |
111 |
112 |
113 |
114 |
115 |
116 |
117 |
118 |
119 |
120 |
121 |
122 |
123 |
124 |
125 |
126 |
127 |
128 |
129 |
130 |
131 |
132 |
133 |
134 |
135 |
136 |
137 |
138 |
139 |
140 |
141 |
142 |
143 |
144 |
145 |
146 |
147 |
148 |
149 |
150 |
151 |
152 |
153 |
154 |
155 |
156 |
157 |
158 |
159 |
160 |
161 |
162 |
163 |
164 |
165 |
166 |
167 |
168 |
169 |
170 |
171 |
172 |
173 |
174 |
175 |
176 |
177 |
178 |
179 |
180 |
181 |
182 |
183 |
184 |
185 |
186 |
187 |
188 |
189 |
190 |
191 |
192 |
193 |
194 |
195 |
196 |
197 |
198 |
199 |
200 |
201 |
202 |
203 |
204 |
205 |
206 |
207 |
208 |
209 |
210 |
211 |
212 |
213 |
214 |
215 |
216 |
217 |
218 |
219 |
220 |
221 |
222 |
223 |
224 |
225 |
226 |
227 |
228 |
229 |
230 |
231 |
232 |
233 |
234 |
235 |
236 |
237 |
238 |
239 |
240 |
241 |
242 |
243 |
244 |
245 |
246 |
247 |
248 |
249 |
250 |
251 |
252 |
253 |
254 |
255 |
256 |
257 |
258 |
259 |
260 |
261 |
262 |
263 |
264 |
265 |
266 |
267 |
268 |
269 |
270 |
271 |
272 |
273 |
274 |
275 |
276 |
277 |
278 |
279 |
280 |
281 |
282 |
283 |
284 |
285 |
286 |
287 |
288 |
289 |
290 |
291 |
292 |
293 |
294 |
295 |
296 |
297 |
298 |
299 |
300 |
301 |
302 |
303 |
304 |
305 |
306 |
307 |
308 |
309 |
310 |
311 |
312 |
313 |
314 |
315 |
316 |
317 |
318 |
319 |
320 |
321 |
322 |
323 |
324 |
325 |
326 |
327 |
328 |
329 |
330 |
331 |
332 |
333 |
334 |
335 |
336 |
337 |
338 |
339 |
340 |
341 |
342 |
343 |
344 |
345 |
346 |
347 |
348 |
349 |
350 |
351 |
352 |
353 |
354 |
355 |
356 |
357 |
358 |
359 |
360 |
361 |
362 |
363 |
364 |
365 |
366 |
367 |
368 |
369 |
370 |
371 |
372 |
373 |
374 |
375 |
376 |
377 |
378 |
379 |
380 |
381 |
382 |
383 |
384 |
385 |
386 |
387 |
388 |
389 |
390 |
391 |
392 |
393 |
394 |
395 |
396 |
397 |
398 |
399 |
400 |
401 |
402 |
403 |
404 |
405 |
406 |
407 |
408 |
409 |
410 |
411 |
412 |
413 |
414 |
415 |
416 |
417 |
418 |
419 |
420 |
421 |
422 |
423 |
424 |
425 |
426 |
427 |
428 |
429 |
430 |
431 |
432 |
433 |
434 |
435 |
436 |
437 |
438 |
439 |
440 |
441 |
442 |
443 |
444 |
445 |
446 |
447 |
448 |
449 |
450 |
--------------------------------------------------------------------------------
/.idea/inspectionProfiles/Project_Default.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
153 |
154 |
155 |
--------------------------------------------------------------------------------
/.idea/misc.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
--------------------------------------------------------------------------------
/.idea/modules.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/.idea/rSettings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/.idea/vcs.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.idea/workspace.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
97 |
98 |
99 |
100 |
101 |
102 |
103 |
104 |
105 |
106 |
107 |
108 |
109 |
110 |
111 |
112 |
113 |
114 |
115 |
116 |
117 |
118 |
119 |
120 |
121 |
122 |
123 |
124 |
125 |
126 |
127 |
128 |
129 |
130 |
131 |
132 |
133 |
134 |
135 |
136 |
137 |
138 |
139 |
140 |
141 |
142 |
143 |
144 |
145 |
146 |
147 |
148 |
149 |
150 |
151 |
152 |
153 |
154 |
155 |
156 |
157 |
158 |
159 |
160 |
161 |
162 |
163 |
164 |
165 |
166 |
167 |
168 |
169 |
170 |
171 |
172 |
173 |
174 |
175 |
176 |
177 |
178 |
179 |
180 |
181 |
182 |
183 |
184 |
185 |
186 |
187 |
188 |
189 |
190 |
191 |
192 |
193 |
194 |
195 |
196 |
197 |
198 |
199 |
200 |
201 |
202 |
203 |
204 |
205 |
206 |
207 |
208 |
209 |
210 |
211 |
212 |
213 |
214 |
215 |
216 |
217 |
218 |
219 |
220 |
221 |
222 |
223 |
224 |
225 |
226 |
227 |
228 |
229 |
230 |
231 |
232 |
233 |
234 |
235 |
236 |
237 |
238 |
239 |
240 |
241 |
242 |
243 |
244 |
245 |
246 |
247 |
248 |
249 |
250 |
251 |
252 |
253 |
254 |
255 |
256 |
257 |
258 |
259 |
260 |
261 |
262 |
263 |
264 |
265 |
266 |
267 |
268 |
269 |
270 |
271 |
272 |
273 |
274 |
275 |
276 |
277 |
278 |
279 |
280 |
281 |
282 |
283 |
284 |
285 |
286 |
287 |
288 |
289 |
290 |
291 |
292 |
293 |
294 | 1552275418081
295 |
296 |
297 | 1552275418081
298 |
299 |
300 | 1553083843865
301 |
302 |
303 |
304 | 1553083843865
305 |
306 |
307 | 1555480493561
308 |
309 |
310 |
311 | 1555480493561
312 |
313 |
314 | 1562073624608
315 |
316 |
317 |
318 | 1562073624608
319 |
320 |
321 | 1562073890510
322 |
323 |
324 |
325 | 1562073890510
326 |
327 |
328 | 1562073995749
329 |
330 |
331 |
332 | 1562073995749
333 |
334 |
335 | 1562074488518
336 |
337 |
338 |
339 | 1562074488518
340 |
341 |
342 | 1562074977287
343 |
344 |
345 |
346 | 1562074977287
347 |
348 |
349 | 1562075142708
350 |
351 |
352 |
353 | 1562075142708
354 |
355 |
356 | 1665467655866
357 |
358 |
359 |
360 | 1665467655866
361 |
362 |
363 | 1665556158683
364 |
365 |
366 |
367 | 1665556158683
368 |
369 |
370 | 1665556469542
371 |
372 |
373 |
374 | 1665556469542
375 |
376 |
377 | 1665634951449
378 |
379 |
380 |
381 | 1665634951449
382 |
383 |
384 |
385 |
386 |
387 |
388 |
389 |
390 |
391 |
398 |
399 |
400 |
401 |
402 |
403 |
404 |
405 |
406 |
407 |
408 |
409 |
410 |
411 |
412 |
413 |
414 |
415 |
416 |
417 |
418 |
419 |
420 |
421 |
422 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # simple-rule-engine-client
2 |
3 | An extension to [Simple Rule Engine](https://github.com/jeyabalajis/simple-rule-engine) that illustrates how rules can be declaratively specified (json, yaml, custom grammar etc.), stored, and later de-serialized into simple-rule-engine constructs and executed with data.
4 |
5 | [](https://dl.circleci.com/status-badge/redirect/gh/jeyabalajis/simple-rule-engine-client/tree/main)
6 |
7 | # Table of Contents
8 |
9 | - [A Simple Decision Tree](#a-simple-decision-tree-involving-facts)
10 | - [A Simple Scoring Rule](#a-simple-scoring-rule)
11 | - [Custom SQL Like Rule Grammar](#custom-sql-like-rule-grammar)
12 |
13 | # Examples
14 |
15 | ## A simple decision tree involving facts
16 |
17 | ### Decision matrix
18 |
19 | | Bureau Score | Business Ownership | Decision
20 | | :----------: | :----------------: | --------:|
21 | | between 650 and 800 | in [Owned by Self, Owned by Family] | GO |
22 |
23 | ### JSON Rule specification
24 |
25 | ```json
26 | {
27 | "RuleDecision": {
28 | "RuleRows": [
29 | {
30 | "WhenAll": [
31 | {
32 | "NumericToken": "cibil_score",
33 | "Between": {
34 | "floor": 650,
35 | "ceiling": 800
36 | }
37 | },
38 | {
39 | "StringToken": "business_ownership",
40 | "In": [
41 | "Owned by Self",
42 | "Owned by Family"
43 | ]
44 | }
45 | ],
46 | "Consequent": "GO"
47 | }
48 | ]
49 | }
50 | }
51 | ```
52 |
53 | ### Test Harness
54 |
55 | ```python
56 | from unittest import TestCase
57 |
58 | from services.adapter.simple_rule_engine_dict_adapter import SimpleRuleEngineDictAdapter
59 | from services.util.json_file_util import JsonFileUtil
60 |
61 |
62 | class TestSimpleRuleEngineAdapter(TestCase):
63 | def test_rule_simple_decision(self):
64 | json_file_util = JsonFileUtil(file_name_with_path="./examples/simple_decision.json")
65 | decision_rule_dict = json_file_util.read_file()
66 |
67 | rule_engine_adapter = SimpleRuleEngineDictAdapter(rule_dict=decision_rule_dict)
68 | decision_rule = rule_engine_adapter.get_rule()
69 |
70 | assert type(decision_rule).__name__ == "RuleDecision"
71 |
72 | fact = dict(cibil_score=700, business_ownership="Owned by Self")
73 | assert decision_rule.execute(token_dict=fact) == "GO"
74 | ```
75 |
76 | ## A simple scoring rule
77 |
78 | ### Scoring Rule
79 |
80 | - If age >= 35 and pet in dog, score is 10, with a weight of 0.5
81 | - If domicile is in KA, score is 5, with a weight of 0.5
82 |
83 | ### JSON Rule specification
84 |
85 | ```json
86 | {
87 | "RuleScore": {
88 | "RuleSets": [
89 | {
90 | "RuleRows": [
91 | {
92 | "WhenAll": [
93 | {
94 | "NumericToken": "age",
95 | "Gte": 35
96 | },
97 | {
98 | "StringToken": "pet",
99 | "In": [
100 | "dog"
101 | ]
102 | }
103 | ],
104 | "Consequent": 10
105 | }
106 | ],
107 | "Weight": 0.5
108 | },
109 | {
110 | "RuleRows": [
111 | {
112 | "WhenAll": [
113 | {
114 | "StringToken": "domicile",
115 | "In": [
116 | "KA"
117 | ]
118 | }
119 | ],
120 | "Consequent": 5
121 | }
122 | ],
123 | "Weight": 0.5
124 | }
125 | ]
126 | }
127 | }
128 | ```
129 |
130 | ### Test Harness
131 |
132 | ```python
133 | from unittest import TestCase
134 |
135 | from services.adapter.simple_rule_engine_dict_adapter import SimpleRuleEngineDictAdapter
136 | from services.util.json_file_util import JsonFileUtil
137 |
138 |
139 | class TestSimpleRuleEngineAdapter(TestCase):
140 | def test_rule_simple_score(self):
141 | json_file_util = JsonFileUtil(file_name_with_path="./examples/simple_score.json")
142 | score_rule_dict = json_file_util.read_file()
143 |
144 | rule_engine_adapter = SimpleRuleEngineDictAdapter(rule_dict=score_rule_dict)
145 | score_rule = rule_engine_adapter.get_rule()
146 |
147 | assert type(score_rule).__name__ == "RuleScore"
148 |
149 | fact = dict(age=40, pet="dog", domicile="TN")
150 | assert score_rule.execute(token_dict=fact) == 5.0
151 |
152 | fact = dict(age=40, pet="dog", domicile="KA")
153 | assert score_rule.execute(token_dict=fact) == 7.5
154 | ```
155 |
156 | ## Custom SQL Like Rule Grammar
157 |
158 | Here's an illustration of a rule that's based on a [custom grammar](decision_rule.lark) written in [EBNF](https://en.wikipedia.org/wiki/Extended_Backus%E2%80%93Naur_form) and parsed by [Lark](https://github.com/lark-parser/lark).
159 |
160 | ### Sample Rule
161 | ```lark
162 | my_rule {
163 | when {
164 | cibil_score between 650 and 750 and
165 | age > 35 and
166 | house_ownership in (owned, rented) and
167 | (
168 | total_overdue_amount == 0 or
169 | number_of_overdue_loans < 2 or
170 | (
171 | number_of_overdue_loans >= 2 and
172 | big_shot == true
173 | )
174 | ) and
175 | pet == dog
176 | }
177 | then true
178 | when {
179 | cibil_score < 650
180 | }
181 | then false
182 | }
183 | ```
184 |
185 | ### Parse Tree
186 |
187 | ```
188 | start
189 | decisionrule
190 | my_rule
191 | rulerow
192 | when
193 | condition
194 | expression
195 | token cibil_score
196 | between
197 | number 650
198 | number 750
199 | conditional and
200 | expression
201 | token age
202 | gt
203 | number 35
204 | conditional and
205 | expression
206 | token house_ownership
207 | in
208 | word_list
209 | owned
210 | rented
211 | conditional and
212 | expression
213 | expression
214 | token total_overdue_amount
215 | eq
216 | number 0
217 | conditional or
218 | expression
219 | token number_of_overdue_loans
220 | lt
221 | number 2
222 | conditional or
223 | expression
224 | expression
225 | token number_of_overdue_loans
226 | gte
227 | number 2
228 | conditional and
229 | expression
230 | token big_shot
231 | eq
232 | boolean true
233 | conditional and
234 | expression
235 | token pet
236 | eq
237 | string dog
238 | then
239 | decision
240 | boolean true
241 | rulerow
242 | when
243 | condition
244 | expression
245 | token cibil_score
246 | lt
247 | number 650
248 | then
249 | decision
250 | boolean false
251 | ```
252 |
253 | ### Test Harness
254 |
255 | ```python
256 | import pytest
257 | from lark import Lark
258 |
259 | from services.adapter.simple_rule_engine_lark_tree_adapter import SimpleRuleEngineLarkTreeAdapter
260 |
261 |
262 | @pytest.fixture
263 | def decision_rule_grammar():
264 | with open("./decision_rule.lark") as rule_grammar_file:
265 | rule_grammar = rule_grammar_file.read()
266 |
267 | return rule_grammar
268 |
269 | def test_rule_complex_decision(decision_rule_grammar):
270 | parser = Lark(decision_rule_grammar)
271 |
272 | custom_rule = """
273 | my_rule {
274 | when {
275 | cibil_score between 650 and 750 and
276 | age > 35 and
277 | house_ownership in (owned, rented) and
278 | (
279 | total_overdue_amount == 0 or
280 | number_of_overdue_loans < 2 or
281 | (
282 | number_of_overdue_loans >= 2 and
283 | big_shot == true
284 | )
285 | ) and
286 | pet == dog
287 | }
288 | then true
289 | when {
290 | cibil_score < 650
291 | }
292 | then false
293 | }
294 | """
295 |
296 | tree = parser.parse(custom_rule)
297 | print(tree.pretty())
298 |
299 | decision_rule = SimpleRuleEngineLarkTreeAdapter(tree).get_rule()
300 |
301 | # Evaluate the Decision Rule by passing data
302 | facts = dict(
303 | cibil_score=700,
304 | age=40,
305 | house_ownership="owned",
306 | total_overdue_amount=0,
307 | pet="dog"
308 | )
309 | assert decision_rule.execute(token_dict=facts) is True
310 |
311 | facts = dict(
312 | cibil_score=700,
313 | age=40,
314 | house_ownership="owned",
315 | total_overdue_amount=100,
316 | number_of_overdue_loans=1,
317 | pet="dog"
318 | )
319 | assert decision_rule.execute(token_dict=facts) is True
320 |
321 | facts = dict(
322 | cibil_score=700,
323 | age=40,
324 | house_ownership="owned",
325 | total_overdue_amount=100,
326 | number_of_overdue_loans=2,
327 | big_shot="true",
328 | pet="dog"
329 | )
330 | assert decision_rule.execute(token_dict=facts) is True
331 |
332 | facts = dict(
333 | cibil_score=600,
334 | age=40,
335 | house_ownership="owned",
336 | total_overdue_amount=100,
337 | number_of_overdue_loans=2,
338 | big_shot="false",
339 | pet="dog"
340 | )
341 | assert decision_rule.execute(token_dict=facts) is False
342 | ```
343 |
--------------------------------------------------------------------------------
/__pycache__/test_decisions.cpython-36-pytest-5.0.0.pyc:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jeyabalajis/simple-rule-engine-client/503544c28e61ab53b8103996bfae07a5319eaefc/__pycache__/test_decisions.cpython-36-pytest-5.0.0.pyc
--------------------------------------------------------------------------------
/common/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jeyabalajis/simple-rule-engine-client/503544c28e61ab53b8103996bfae07a5319eaefc/common/__init__.py
--------------------------------------------------------------------------------
/common/exception/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jeyabalajis/simple-rule-engine-client/503544c28e61ab53b8103996bfae07a5319eaefc/common/exception/__init__.py
--------------------------------------------------------------------------------
/common/exception/invalid_json_exception.py:
--------------------------------------------------------------------------------
1 | class InvalidJsonException(Exception):
2 | pass
3 |
--------------------------------------------------------------------------------
/decision_rule.lark:
--------------------------------------------------------------------------------
1 | start: decisionrule+
2 |
3 | decisionrule: CNAME "{" rulerow+ "}"
4 |
5 | rulerow: when "{" condition "}" then decision
6 | decision: boolean
7 | | numeric
8 | | string
9 |
10 | ?when: WHEN
11 | ?then: THEN
12 |
13 | !condition: expression
14 | | [expression (conditional expression)*]
15 |
16 | conditional: CONDITIONAL
17 | expression: token in_operator word_list
18 | | token operator value
19 | | token between numeric "and" numeric
20 | | "(" [expression (conditional expression)*] ")"
21 |
22 | token: TOKEN_OR_RULE
23 |
24 | word_list: WORD
25 | | "(" [WORD ("," WORD)*] ")"
26 |
27 | between: "between"
28 |
29 | ?numeric: SIGNED_NUMBER -> number
30 |
31 | ?value: SIGNED_NUMBER -> number
32 | | boolean
33 | | string
34 |
35 | ?in_operator: INLIST
36 |
37 | ?operator: lt
38 | | gt
39 | | lte
40 | | gte
41 | | eq
42 |
43 | string: CNAME
44 | boolean: TRUE | FALSE
45 | lt: "<"
46 | gt: ">"
47 | lte: "<="
48 | gte: ">="
49 | eq: "=="
50 |
51 | TRUE: "true"
52 | FALSE: "false"
53 | WHEN: "when"
54 | THEN: "then"
55 | DECISION: ("true" | "false" | SIGNED_NUMBER | CNAME)
56 | CONDITIONAL: "and" | "or"
57 | TOKEN_OR_RULE: CNAME | ("$RULE_" CNAME)
58 | DECISION_RULE: "DecisionRule"
59 |
60 | INLIST: "in"
61 | EQ: "=="
62 |
63 | %import common.WORD
64 | %import common.INT
65 | %import common.SIGNED_INT
66 | %import common.SIGNED_NUMBER
67 | %import common.FLOAT
68 | %import common.CNAME
69 | %import common.ESCAPED_STRING
70 | %import common.WS
71 | %ignore WS
--------------------------------------------------------------------------------
/examples/simple_decision.json:
--------------------------------------------------------------------------------
1 | {
2 | "RuleDecision": {
3 | "RuleRows": [
4 | {
5 | "WhenAll": [
6 | {
7 | "NumericToken": "cibil_score",
8 | "Between": {
9 | "floor": 650,
10 | "ceiling": 800
11 | }
12 | },
13 | {
14 | "StringToken": "business_ownership",
15 | "In": [
16 | "Owned by Self",
17 | "Owned by Family"
18 | ]
19 | }
20 | ],
21 | "Consequent": "GO"
22 | }
23 | ]
24 | }
25 | }
--------------------------------------------------------------------------------
/examples/simple_decision_when_any.json:
--------------------------------------------------------------------------------
1 | {
2 | "RuleDecision": {
3 | "RuleRows": [
4 | {
5 | "WhenAny": [
6 | {
7 | "NumericToken": "age",
8 | "Gte": 35
9 | },
10 | {
11 | "StringToken": "pet",
12 | "NotIn": [
13 | "dog"
14 | ]
15 | }
16 | ],
17 | "Consequent": "GO"
18 | }
19 | ]
20 | }
21 | }
--------------------------------------------------------------------------------
/examples/simple_score.json:
--------------------------------------------------------------------------------
1 | {
2 | "RuleScore": {
3 | "RuleSets": [
4 | {
5 | "RuleRows": [
6 | {
7 | "WhenAll": [
8 | {
9 | "NumericToken": "age",
10 | "Gte": 35
11 | },
12 | {
13 | "StringToken": "pet",
14 | "In": [
15 | "dog"
16 | ]
17 | }
18 | ],
19 | "Consequent": 10
20 | }
21 | ],
22 | "Weight": 0.5
23 | },
24 | {
25 | "RuleRows": [
26 | {
27 | "WhenAll": [
28 | {
29 | "StringToken": "domicile",
30 | "In": [
31 | "KA"
32 | ]
33 | }
34 | ],
35 | "Consequent": 5
36 | }
37 | ],
38 | "Weight": 0.5
39 | }
40 | ]
41 | }
42 | }
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jeyabalajis/simple-rule-engine-client/503544c28e61ab53b8103996bfae07a5319eaefc/requirements.txt
--------------------------------------------------------------------------------
/services/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jeyabalajis/simple-rule-engine-client/503544c28e61ab53b8103996bfae07a5319eaefc/services/__init__.py
--------------------------------------------------------------------------------
/services/adapter/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jeyabalajis/simple-rule-engine-client/503544c28e61ab53b8103996bfae07a5319eaefc/services/adapter/__init__.py
--------------------------------------------------------------------------------
/services/adapter/simple_rule_engine_adapter.py:
--------------------------------------------------------------------------------
1 | from abc import ABC, abstractmethod
2 |
3 | from simpleruleengine.rule.rule import Rule
4 |
5 |
6 | class SimpleRuleEngineAdapter(ABC):
7 | def __init__(self):
8 | """instance members are defined in concrete implementations"""
9 | pass
10 |
11 | @abstractmethod
12 | def get_rule(self) -> Rule:
13 | pass
14 |
--------------------------------------------------------------------------------
/services/adapter/simple_rule_engine_dict_adapter.py:
--------------------------------------------------------------------------------
1 | from typing import Union
2 |
3 | from simpleruleengine.conditional.conditional import Conditional
4 | from simpleruleengine.conditional.when_all import WhenAll
5 | from simpleruleengine.conditional.when_any import WhenAny
6 | from simpleruleengine.expression.expression import Expression
7 | from simpleruleengine.operator.between import Between
8 | from simpleruleengine.operator.equal import Eq
9 | from simpleruleengine.operator.greater_than import Gt
10 | from simpleruleengine.operator.greater_than_equal import Gte
11 | from simpleruleengine.operator.less_than import Lt
12 | from simpleruleengine.operator.less_than_equal import Lte
13 | from simpleruleengine.operator.operator import Operator
14 | from simpleruleengine.operator.string_in import In
15 | from simpleruleengine.operator.string_not_in import NotIn
16 | from simpleruleengine.rule.rule import Rule
17 | from simpleruleengine.rule.rule_decision import RuleDecision
18 | from simpleruleengine.rule.rule_score import RuleScore
19 | from simpleruleengine.rulerow.rule_row_decision import RuleRowDecision
20 | from simpleruleengine.rulerow.rule_row_score import RuleRowScore
21 | from simpleruleengine.ruleset.rule_set_decision import RuleSetDecision
22 | from simpleruleengine.ruleset.rule_set_score import RuleSetScore
23 | from simpleruleengine.token.numeric_token import NumericToken
24 | from simpleruleengine.token.string_token import StringToken
25 | from simpleruleengine.token.token import Token
26 |
27 | from services.adapter.simple_rule_engine_adapter import SimpleRuleEngineAdapter
28 |
29 |
30 | class SimpleRuleEngineDictAdapter(SimpleRuleEngineAdapter):
31 | def __init__(self, rule_dict: dict):
32 | super().__init__()
33 | self.rule_dict = rule_dict
34 |
35 | def get_rule(self) -> Rule:
36 | assert (
37 | "RuleDecision" in self.rule_dict or
38 | "RuleScore" in self.rule_dict
39 | )
40 |
41 | # Parse as Decision
42 | if "RuleDecision" in self.rule_dict:
43 | rule_row_objects = []
44 | rule_rows = self.rule_dict.get("RuleDecision").get("RuleRows")
45 | for rule_row in rule_rows:
46 | rule_row_objects.append(
47 | _get_rule_row_decision(rule_row)
48 | )
49 |
50 | rule_set_objects = [RuleSetDecision(
51 | *tuple(rule_row_objects)
52 | )]
53 |
54 | return RuleDecision(
55 | *tuple(rule_set_objects)
56 | )
57 |
58 | # Parse as Score
59 | if "RuleScore" in self.rule_dict:
60 | rule_sets = self.rule_dict.get("RuleScore").get("RuleSets")
61 | rule_set_objects = []
62 | for rule_set in rule_sets:
63 | rule_row_objects = []
64 | rule_rows = rule_set.get("RuleRows")
65 | for rule_row in rule_rows:
66 | rule_row_objects.append(
67 | _get_rule_row_score(rule_row)
68 | )
69 |
70 | rule_set_objects.append(
71 | RuleSetScore(
72 | *tuple(rule_row_objects),
73 | weight=rule_set.get("Weight")
74 | )
75 | )
76 |
77 | return RuleScore(
78 | *tuple(rule_set_objects)
79 | )
80 |
81 |
82 | def _get_rule_row_score(rule_row: dict) -> RuleRowScore:
83 | return RuleRowScore(
84 | antecedent=_get_conditional(rule_row),
85 | consequent=rule_row.get("Consequent")
86 | )
87 |
88 |
89 | def _get_rule_row_decision(rule_row: dict) -> RuleRowDecision:
90 | return RuleRowDecision(
91 | antecedent=_get_conditional(rule_row),
92 | consequent=rule_row.get("Consequent")
93 | )
94 |
95 |
96 | def _get_conditional(rule_row: dict) -> Union[Conditional, Expression]:
97 | if "WhenAll" in rule_row:
98 | expressions = []
99 | for token in rule_row.get("WhenAll"):
100 | expressions.append(
101 | _get_conditional(token)
102 | )
103 |
104 | return WhenAll(*tuple(expressions))
105 |
106 | if "WhenAny" in rule_row:
107 | expressions = []
108 | for token in rule_row.get("WhenAny"):
109 | expressions.append(
110 | _get_conditional(token)
111 | )
112 |
113 | return WhenAny(*tuple(expressions))
114 |
115 | return _get_expression(rule_row)
116 |
117 |
118 | def _get_expression(expression_dict: dict) -> Expression:
119 | token = _get_token(expression_dict)
120 | operator = _get_operator(expression_dict)
121 |
122 | assert token is not None
123 | assert operator is not None
124 |
125 | return Expression(token=token, operator=operator)
126 |
127 |
128 | def _get_token(expression_dict: dict) -> Token:
129 | if "StringToken" in expression_dict:
130 | return StringToken(name=expression_dict.get("StringToken"))
131 |
132 | if "NumericToken" in expression_dict:
133 | return NumericToken(name=expression_dict.get("NumericToken"))
134 |
135 |
136 | def _get_operator(expression_dict: dict) -> Operator:
137 | if "In" in expression_dict:
138 | return In(*tuple(expression_dict.get("In")))
139 |
140 | if "NotIn" in expression_dict:
141 | return NotIn(*tuple(expression_dict.get("NotIn")))
142 |
143 | if "Between" in expression_dict:
144 | return Between(
145 | floor=expression_dict.get("Between").get("floor"),
146 | ceiling=expression_dict.get("Between").get("ceiling")
147 | )
148 |
149 | if "Eq" in expression_dict:
150 | return Eq(expression_dict.get("Eq"))
151 |
152 | if "Lt" in expression_dict:
153 | return Lt(expression_dict.get("Lt"))
154 |
155 | if "Lte" in expression_dict:
156 | return Lte(expression_dict.get("Lte"))
157 |
158 | if "Gt" in expression_dict:
159 | return Gt(expression_dict.get("Gt"))
160 |
161 | if "Gte" in expression_dict:
162 | return Gte(expression_dict.get("Gte"))
163 |
--------------------------------------------------------------------------------
/services/adapter/simple_rule_engine_lark_tree_adapter.py:
--------------------------------------------------------------------------------
1 | from typing import List, Union
2 |
3 | from lark import Tree, Token
4 | from simpleruleengine.conditional.conditional import Conditional
5 | from simpleruleengine.conditional.when_all import WhenAll
6 | from simpleruleengine.conditional.when_any import WhenAny
7 | from simpleruleengine.expression.expression import Expression
8 | from simpleruleengine.rule.rule_decision import RuleDecision
9 | from simpleruleengine.rulerow.rule_row_decision import RuleRowDecision
10 | from simpleruleengine.ruleset.rule_set_decision import RuleSetDecision
11 | from simpleruleengine.token.numeric_token import NumericToken
12 | from simpleruleengine.token.string_token import StringToken
13 | from simpleruleengine.token.boolean_token import BooleanToken
14 | from simpleruleengine.rule.rule import Rule
15 | from simpleruleengine.operator.operator import Operator
16 |
17 | from services.adapter.simple_rule_engine_adapter import SimpleRuleEngineAdapter
18 | from services.util import simple_rule_engine_util
19 | from queue import Queue
20 |
21 |
22 | class SimpleRuleEngineLarkTreeAdapter(SimpleRuleEngineAdapter):
23 | TREE = "Tree"
24 | TOKEN = "Token"
25 | DECISION_RULE = "decisionrule"
26 | EXPRESSION = "expression"
27 | CONDITIONAL = "conditional"
28 | NUMBER = "number"
29 | SIGNED_NUMBER = "SIGNED_NUMBER"
30 | STRING = "string"
31 | WORD = "WORD"
32 | BETWEEN = "between"
33 | EQ = "eq"
34 | LTE = "lte"
35 | LT = "lt"
36 | GTE = "gte"
37 | GT = "gt"
38 | IN_LIST = "INLIST"
39 | CONDITIONAL_AND = "and"
40 | CONDITIONAL_OR = "or"
41 |
42 | def __init__(self, tree: Tree):
43 | super().__init__()
44 | self.lark_tree = tree
45 | self._visited = {}
46 |
47 | def get_rule(self) -> Rule:
48 | """get_rule gets the root node of parse lark_tree and processes it's children.
49 | Each child of the root node can be a decision rule or a score rule."""
50 | for rule in self.lark_tree.children:
51 | if rule.data == self.DECISION_RULE:
52 | return self._get_decision_rule(rule)
53 |
54 | def _get_decision_rule(self, rule: Tree) -> RuleDecision:
55 | """_get_decision_rule gets a lark_tree composed of a decision rule.
56 | It is composed a Rule Name and 1 or more rule rows.
57 | Structure of a decision rule:
58 | decisionrule
59 | rule name
60 | rulerow
61 | rulerow
62 | rulerow...
63 | """
64 | # Decision rule contains multiple children.
65 | # The first one is the rule name, followed by one or more rule_row.
66 | rule_name = rule.children[0]
67 | if rule_name in self._visited:
68 | return self._visited[rule_name]["rule"]
69 |
70 | rule_row_objects: List[RuleRowDecision] = []
71 |
72 | # Visit each rule row and get RuleRowDecision objects
73 | for rule_row in rule.children[1:]:
74 | rule_row_objects.append(self._get_rule_row_decision(rule_row))
75 |
76 | rule_set_objects = [RuleSetDecision(
77 | *tuple(rule_row_objects)
78 | )]
79 |
80 | rule_decision: RuleDecision = RuleDecision(
81 | *tuple(rule_set_objects)
82 | )
83 |
84 | self._visited[rule_name] = dict(rule=rule_decision)
85 | return rule_decision
86 |
87 | def _get_rule_row_decision(self, rule_row: Tree) -> RuleRowDecision:
88 | """
89 | _get_rule_row_decision gets a rulerow node and returns RuleRowDecision post processing.
90 | Structure of rulerow
91 | rulerow
92 | when
93 | condition
94 | then
95 | decision
96 | """
97 |
98 | """
99 | rulerow contains 4 children namely
100 | - "when"
101 | - condition: A condition that's composed of a series of expressions strung together by and / or.
102 | - "then"
103 | - decision.
104 | out of this, decision is processed for consequent and condition is processed for antecedent
105 | """
106 | antecedent = self._get_conditional(rule_row.children[1])
107 | consequent = simple_rule_engine_util.get_consequent(rule_row.children[3])
108 |
109 | return RuleRowDecision(antecedent=antecedent, consequent=consequent)
110 |
111 | def _get_conditional(self, conditional: Tree) -> Conditional:
112 | """
113 | _get_conditional gets a condition node and returns a Conditional.
114 | Structure of condition
115 | condition
116 | expression
117 | conditional and/or (optional)
118 | expression and/or (optional)
119 | expression
120 | """
121 | conditional_queue = Queue()
122 | expression_queue = Queue()
123 | for child in conditional.children:
124 | if child.data == self.EXPRESSION:
125 | expression_queue.put(self._get_expression(child))
126 |
127 | if child.data == self.CONDITIONAL:
128 | conditional_queue.put(child.children[0].value)
129 |
130 | return self._get_composite_conditional(
131 | conditional_queue=conditional_queue,
132 | expression_queue=expression_queue
133 | )
134 |
135 | def _get_composite_conditional(self, *, conditional_queue: Queue, expression_queue: Queue) -> Conditional:
136 | """
137 | _get_composite_conditional forms a complex Conditional based on how expressions are strung together
138 | Examples:
139 | 1. a and b and c => WhenAll(a, b, c)
140 | 2. a and b or c => WhenAny(WhenAll(a, b), c)
141 | 3. a and b or (c and d) => WhenAny(WhenAll(a, b), WhenAll(c, d))
142 | 4. a and b and (c and d or (e and f)) => WhenAll(a, b, WhenAny(WhenAll(c, d), WhenAll(e, f)))
143 | :param conditional_queue:
144 | :param expression_queue:
145 | :return: Conditional
146 | """
147 | # Pop conditional queue and combine expressions into a single Conditional
148 | # Assumption: For one conditional, there would be two expressions.
149 | final_conditional = None
150 |
151 | # If this is a single expression, wrap this under WhenAll and return
152 | if conditional_queue.empty():
153 | return WhenAll(expression_queue.get())
154 |
155 | while not conditional_queue.empty():
156 | current_conditional = conditional_queue.get()
157 | if current_conditional == self.CONDITIONAL_AND:
158 | if final_conditional is None:
159 | final_conditional = WhenAll(expression_queue.get(), expression_queue.get())
160 | else:
161 | final_conditional = WhenAll(final_conditional, expression_queue.get())
162 |
163 | if current_conditional == self.CONDITIONAL_OR:
164 | if final_conditional is None:
165 | final_conditional = WhenAny(expression_queue.get(), expression_queue.get())
166 | else:
167 | final_conditional = WhenAny(final_conditional, expression_queue.get())
168 |
169 | return final_conditional
170 |
171 | def _get_expression(self, expression: Tree) -> Union[Expression, Conditional]:
172 | """
173 | _get_expression returns either an Expression or a Conditional (if the expression contains an expression)
174 | Structure of expression
175 | expression
176 | token
177 | operator
178 | base value
179 | (or)
180 | expression
181 | expression
182 | """
183 | # expression contains either 3 children or 4 children (if operator is between)
184 | # - Token name
185 | # - operator
186 | # - base value
187 | # if the operator is between, [2] is treated as floor and [3] is treated as ceiling
188 |
189 | # If an expression composes another expression, get this as a Conditional.
190 | if (
191 | type(expression.children[0]).__name__ == self.TREE and
192 | expression.children[0].data == self.EXPRESSION
193 | ):
194 | return self._get_conditional(expression)
195 |
196 | token = self._get_token(expression.children[0], expression.children[2])
197 | operator = self._get_operator(
198 | *tuple(expression.children[2:]),
199 | operator=expression.children[1],
200 | rule_engine_token_type=type(token).__name__
201 | )
202 | expression = Expression(token=token, operator=operator)
203 | return expression
204 |
205 | def _get_token(self, token: Tree, token_type: Tree):
206 | """
207 | _get_token returns a Simple Rule Engine Token from a lark Tree. Return a specific token (StringToken or
208 | NumericToken or BooleanToken) based on the base value that's composed within the expression.
209 | """
210 | token_type_str = token_type.children[0].type
211 | if token_type_str in (self.NUMBER, self.SIGNED_NUMBER):
212 | return NumericToken(name=token.children[0].value)
213 |
214 | if token_type_str in (self.STRING, self.WORD, "CNAME"):
215 | return StringToken(name=token.children[0].value)
216 |
217 | if token_type_str in ("TRUE", "FALSE"):
218 | return BooleanToken(name=token.children[0].value)
219 |
220 | def _get_operator(self, *base_value: Tree, operator: Union[Tree, Token], rule_engine_token_type: str, ) -> Operator:
221 | """
222 | _get_operator returns an Operator based on operator type and token type
223 | (StringToken, NumericToken or BooleanToken).
224 | """
225 |
226 | operator_type = operator.data if type(operator).__name__ == self.TREE else operator.type
227 |
228 | if operator_type == self.BETWEEN:
229 | return simple_rule_engine_util.get_between(*base_value)
230 |
231 | if operator_type == self.IN_LIST:
232 | return simple_rule_engine_util.get_list_in(base_value)
233 |
234 | if operator_type == self.GT:
235 | return simple_rule_engine_util.get_greater_than(base_value)
236 |
237 | if operator_type == self.GTE:
238 | return simple_rule_engine_util.get_greater_than_equal(base_value)
239 |
240 | if operator_type == self.EQ:
241 | return simple_rule_engine_util.get_equal(base_value, rule_engine_token_type)
242 |
243 | if operator_type == self.LT:
244 | return simple_rule_engine_util.get_less_than(base_value)
245 |
246 | if operator_type == self.LTE:
247 | return simple_rule_engine_util.get_less_than_equal(base_value)
248 |
--------------------------------------------------------------------------------
/services/util/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jeyabalajis/simple-rule-engine-client/503544c28e61ab53b8103996bfae07a5319eaefc/services/util/__init__.py
--------------------------------------------------------------------------------
/services/util/json_file_util.py:
--------------------------------------------------------------------------------
1 | import json
2 | from common.exception.invalid_json_exception import InvalidJsonException
3 |
4 |
5 | class JsonFileUtil:
6 | def __init__(self, *, file_name_with_path: str):
7 | self.file_name_with_path = file_name_with_path
8 |
9 | def read_file(self) -> dict:
10 | with open(self.file_name_with_path) as f:
11 | try:
12 | return json.load(f)
13 | except Exception as e:
14 | raise InvalidJsonException(e)
15 |
--------------------------------------------------------------------------------
/services/util/simple_rule_engine_util.py:
--------------------------------------------------------------------------------
1 | from simpleruleengine.conditional.conditional import Conditional
2 | from simpleruleengine.expression.expression import Expression
3 | from simpleruleengine.operator.between import Between
4 | from simpleruleengine.operator.string_in import In
5 | from simpleruleengine.operator.equal import Eq
6 | from simpleruleengine.operator.greater_than import Gt
7 | from simpleruleengine.operator.greater_than_equal import Gte
8 | from simpleruleengine.operator.less_than import Lt
9 | from simpleruleengine.operator.less_than_equal import Lte
10 | from simpleruleengine.operator.boolean_operator import BooleanOperator
11 | from lark import Tree, Token
12 |
13 |
14 | def expression_pretty(expression: Expression):
15 | expression_string = ""
16 | expression_string += expression.token.name + " "
17 | expression_string += type(expression.operator).__name__ + " "
18 | if type(expression.operator).__name__ == "Between":
19 | expression_string += str(expression.operator.floor) + ", " + str(expression.operator.ceiling)
20 | else:
21 | expression_string += str(expression.operator.base_value)
22 |
23 | return expression_string
24 |
25 |
26 | def conditional_pretty(conditional: Conditional, depth=0):
27 | conditional_string = type(conditional).__name__ + "("
28 |
29 | for child in conditional.expressions:
30 | if type(child).__name__ in ("WhenAll", "WhenAny"):
31 | conditional_string += "(" + conditional_pretty(child, depth + 1) + "), "
32 |
33 | if type(child).__name__ in "Expression":
34 | conditional_string += "{" + expression_pretty(child) + "}, "
35 |
36 | conditional_string += ")"
37 |
38 | return conditional_string
39 |
40 |
41 | def get_between(*base_value: Tree):
42 | return Between(
43 | floor=float(base_value[0].children[0].value),
44 | ceiling=float(base_value[1].children[0].value)
45 | )
46 |
47 |
48 | def get_list_in(base_value):
49 | str_in_list: List[str] = []
50 | for item in base_value:
51 | for child in item.children:
52 | str_in_list.append(child.value)
53 | return In(*tuple(str_in_list))
54 |
55 |
56 | def get_operator_value(base_value):
57 | operator_value = None
58 | for item in base_value:
59 | for child in item.children:
60 | operator_value = float(child.value)
61 | return operator_value
62 |
63 |
64 | def get_greater_than_equal(base_value):
65 | operator_value = get_operator_value(base_value)
66 | assert operator_value is not None
67 | return Gte(operator_value)
68 |
69 |
70 | def get_greater_than(base_value):
71 | operator_value = get_operator_value(base_value)
72 | assert operator_value is not None
73 | return Gt(operator_value)
74 |
75 |
76 | def get_less_than(base_value):
77 | operator_value = get_operator_value(base_value)
78 | assert operator_value is not None
79 | return Lt(operator_value)
80 |
81 |
82 | def get_less_than_equal(base_value):
83 | operator_value = get_operator_value(base_value)
84 | assert operator_value is not None
85 | return Lte(operator_value)
86 |
87 |
88 | def get_equal(base_value, rule_engine_token_type):
89 | for item in base_value:
90 | for child in item.children:
91 | if rule_engine_token_type == "NumericToken":
92 | operator_value = float(child.value)
93 | return Eq(operator_value)
94 |
95 | if rule_engine_token_type == "StringToken":
96 | operator_value = str(child.value)
97 | return In(*tuple([operator_value]))
98 |
99 | if rule_engine_token_type == "BooleanToken":
100 | operator_value = str(child.value)
101 | if operator_value == "true":
102 | return BooleanOperator(True)
103 | if operator_value == "false":
104 | return BooleanOperator(False)
105 |
106 |
107 | def get_boolean(boolean_token: Token):
108 | if boolean_token.value == "true":
109 | return True
110 | else:
111 | return False
112 |
113 |
114 | def get_number(numeric_token: Token):
115 | return float(numeric_token.value)
116 |
117 |
118 | def get_token_value(token: Tree):
119 | if token.data == "boolean":
120 | return get_boolean(token.children[0])
121 |
122 | if token.data == "number":
123 | return get_number(token.children[0])
124 |
125 |
126 | def get_consequent(consequent: Tree):
127 | return get_token_value(consequent.children[0])
128 |
--------------------------------------------------------------------------------
/test_custom_decision_rule_lark.py:
--------------------------------------------------------------------------------
1 | import pytest
2 | from lark import Lark
3 |
4 | from services.adapter.simple_rule_engine_lark_tree_adapter import SimpleRuleEngineLarkTreeAdapter
5 |
6 |
7 | @pytest.fixture
8 | def decision_rule_grammar():
9 | with open("./decision_rule.lark") as rule_grammar_file:
10 | rule_grammar = rule_grammar_file.read()
11 |
12 | return rule_grammar
13 |
14 |
15 | def test_rule_simple_decision(decision_rule_grammar):
16 | parser = Lark(decision_rule_grammar)
17 |
18 | custom_rule = """
19 | my_rule {
20 | when {
21 | cibil_score > 650 and
22 | age > 35 and
23 | house_ownership in (owned, rented) and
24 | (pet == dog or pet == parrot)
25 | }
26 | then true
27 | when {
28 | cibil_score < 650
29 | }
30 | then false
31 | }
32 | """
33 |
34 | tree = parser.parse(custom_rule)
35 | print(tree.pretty())
36 |
37 | decision_rule = SimpleRuleEngineLarkTreeAdapter(tree).get_rule()
38 |
39 | facts = dict(
40 | cibil_score=700,
41 | age=40,
42 | house_ownership="owned",
43 | pet="parrot"
44 | )
45 |
46 | assert decision_rule.execute(facts) is True
47 |
48 | facts = dict(
49 | cibil_score=700,
50 | age=40,
51 | house_ownership="owned",
52 | pet="pig"
53 | )
54 |
55 | assert decision_rule.execute(facts) is not True
56 |
57 | facts = dict(
58 | cibil_score=500,
59 | age=40,
60 | house_ownership="owned",
61 | pet="dog"
62 | )
63 |
64 | assert decision_rule.execute(facts) is not True
65 |
66 |
67 | def test_rule_complex_decision(decision_rule_grammar):
68 | parser = Lark(decision_rule_grammar)
69 |
70 | custom_rule = """
71 | my_rule {
72 | when {
73 | cibil_score between 650 and 750 and
74 | age > 35 and
75 | house_ownership in (owned, rented) and
76 | (
77 | total_overdue_amount == 0 or
78 | number_of_overdue_loans < 2 or
79 | (
80 | number_of_overdue_loans >= 2 and
81 | big_shot == true
82 | )
83 | ) and
84 | pet == dog
85 | }
86 | then true
87 | when {
88 | cibil_score < 650
89 | }
90 | then false
91 | }
92 | """
93 |
94 | tree = parser.parse(custom_rule)
95 | print(tree.pretty())
96 |
97 | decision_rule = SimpleRuleEngineLarkTreeAdapter(tree).get_rule()
98 |
99 | # Evaluate the Decision Rule by passing data
100 | facts = dict(
101 | cibil_score=700,
102 | age=40,
103 | house_ownership="owned",
104 | total_overdue_amount=0,
105 | pet="dog"
106 | )
107 | assert decision_rule.execute(token_dict=facts) is True
108 |
109 | facts = dict(
110 | cibil_score=700,
111 | age=40,
112 | house_ownership="owned",
113 | total_overdue_amount=100,
114 | number_of_overdue_loans=1,
115 | pet="dog"
116 | )
117 | assert decision_rule.execute(token_dict=facts) is True
118 |
119 | facts = dict(
120 | cibil_score=700,
121 | age=40,
122 | house_ownership="owned",
123 | total_overdue_amount=100,
124 | number_of_overdue_loans=2,
125 | big_shot=True,
126 | pet="dog"
127 | )
128 | assert decision_rule.execute(token_dict=facts) is True
129 |
130 | facts = dict(
131 | cibil_score=600,
132 | age=40,
133 | house_ownership="owned",
134 | total_overdue_amount=100,
135 | number_of_overdue_loans=2,
136 | big_shot=False,
137 | pet="dog"
138 | )
139 | assert decision_rule.execute(token_dict=facts) is False
140 |
--------------------------------------------------------------------------------
/test_json_file_util.py:
--------------------------------------------------------------------------------
1 | from unittest import TestCase
2 | from services.util.json_file_util import JsonFileUtil
3 |
4 |
5 | class TestJsonFileUtil(TestCase):
6 | def test_read_file(self):
7 | json_file_util = JsonFileUtil(file_name_with_path="./examples/simple_decision.json")
8 | decision_dict = json_file_util.read_file()
9 |
10 | assert type(decision_dict).__name__ == "dict"
11 |
12 | assert (
13 | "RuleDecision" in decision_dict and
14 | "RuleRows" in decision_dict.get("RuleDecision")
15 | )
16 |
--------------------------------------------------------------------------------
/test_simple_rule_engine_dict_adapter.py:
--------------------------------------------------------------------------------
1 | from unittest import TestCase
2 |
3 | from services.adapter.simple_rule_engine_dict_adapter import SimpleRuleEngineDictAdapter
4 | from services.util.json_file_util import JsonFileUtil
5 |
6 |
7 | class TestSimpleRuleEngineAdapter(TestCase):
8 | def test_rule_simple_decision(self):
9 | json_file_util = JsonFileUtil(file_name_with_path="./examples/simple_decision.json")
10 | decision_rule_dict = json_file_util.read_file()
11 |
12 | rule_engine_adapter = SimpleRuleEngineDictAdapter(rule_dict=decision_rule_dict)
13 | decision_rule = rule_engine_adapter.get_rule()
14 |
15 | assert type(decision_rule).__name__ == "RuleDecision"
16 |
17 | fact = dict(cibil_score=700, business_ownership="Owned by Self")
18 | assert decision_rule.execute(token_dict=fact) == "GO"
19 |
20 | def test_rule_simple_decision_when_any(self):
21 | json_file_util = JsonFileUtil(file_name_with_path="./examples/simple_decision_when_any.json")
22 | decision_rule_dict = json_file_util.read_file()
23 |
24 | rule_engine_adapter = SimpleRuleEngineDictAdapter(rule_dict=decision_rule_dict)
25 | decision_rule = rule_engine_adapter.get_rule()
26 |
27 | assert type(decision_rule).__name__ == "RuleDecision"
28 |
29 | fact = dict(age=35, pet="parrot")
30 | assert decision_rule.execute(token_dict=fact) == "GO"
31 |
32 | fact = dict(age=20, pet="dog")
33 | assert decision_rule.execute(token_dict=fact) != "GO"
34 |
35 | def test_rule_simple_score(self):
36 | json_file_util = JsonFileUtil(file_name_with_path="./examples/simple_score.json")
37 | score_rule_dict = json_file_util.read_file()
38 |
39 | rule_engine_adapter = SimpleRuleEngineDictAdapter(rule_dict=score_rule_dict)
40 | score_rule = rule_engine_adapter.get_rule()
41 |
42 | assert type(score_rule).__name__ == "RuleScore"
43 |
44 | fact = dict(age=40, pet="dog", domicile="TN")
45 | assert score_rule.execute(token_dict=fact) == 5.0
46 |
47 | fact = dict(age=40, pet="dog", domicile="KA")
48 | assert score_rule.execute(token_dict=fact) == 7.5
49 |
--------------------------------------------------------------------------------
/test_simple_rule_engine_library.py:
--------------------------------------------------------------------------------
1 | from unittest import TestCase
2 |
3 | from simpleruleengine.operator.not_equal import NotEq
4 |
5 |
6 | class TestSimpleRuleLibrary(TestCase):
7 | def test_operator_with_not_eq(self):
8 | assert NotEq(2).evaluate(3) is True
9 | assert NotEq(2.25).evaluate(2.26) is True
10 |
11 |
--------------------------------------------------------------------------------