Skip to content

Commit

Permalink
Merge pull request #9 from fyndiq/adds-messages-to-rules
Browse files Browse the repository at this point in the history
feat: adds messages to rules engine and tests
  • Loading branch information
nf1s authored Feb 2, 2024
2 parents 7322e77 + 8780d4c commit eef2018
Show file tree
Hide file tree
Showing 4 changed files with 107 additions and 38 deletions.
2 changes: 1 addition & 1 deletion setup.cfg
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[metadata]
name = rules-engine
version = 0.2.5
version = 0.3.0
author = Fyndiq AB
author_email = [email protected]
description = Rules engine
Expand Down
43 changes: 34 additions & 9 deletions src/rules_engine/__init__.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,39 @@
from typing import Any, Callable, TypeVar
from typing import Any, Callable, TypeVar, Optional
from dataclasses import dataclass

T = TypeVar('T')


class NoMatch(Exception):
message = "No conditions matched"


@dataclass
class Result:
value: Any
message: Optional[str]


class Rule:
def __init__(self, condition: Callable[..., bool], action: Callable[..., Any]) -> None:
def __init__(
self,
condition: Callable[..., bool],
action: Callable[..., Any],
message: Optional[str] = None,
) -> None:
self.condition = condition
self.action = action
self.message = message


class Otherwise(Rule):
def __init__(self, action):
super().__init__(when(True), action)
def __init__(self, action, message=None) -> None:
super().__init__(when(True), action, message)


class NoAction(Rule):
def __init__(self, condition):
super().__init__(condition, then(None))
def __init__(self, condition, message=None):
super().__init__(condition, then(None), message)


class RulesEngine:
Expand All @@ -26,13 +43,21 @@ def __init__(self, *rules: Rule) -> None:
def run(self, *args: Any, **kwargs: Any) -> Any:
for rule in self.rules:
if rule.condition(*args, **kwargs):
return rule.action(*args, **kwargs)
return Result(rule.action(*args, **kwargs), rule.message)

raise NoMatch

def run_all(self, *args: Any, **kwargs: Any) -> list:
return [
rule.action(*args, **kwargs) for rule in self.rules if rule.condition(*args, **kwargs)
results = [
Result(rule.action(*args, **kwargs), rule.message)
for rule in self.rules
if rule.condition(*args, **kwargs)
]

if not results:
raise NoMatch
return results


def when(state: bool) -> Callable[..., bool]:
return lambda *args, **kwargs: state
Expand Down
51 changes: 36 additions & 15 deletions tests/test_article_completed_example.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import pytest

from src.rules_engine import Otherwise, Rule, RulesEngine, not_, then
from src.rules_engine import Otherwise, Rule, RulesEngine, not_, then, Result

Article = namedtuple("Article", "title price image_url stock")

Expand All @@ -20,63 +20,84 @@ def article_image_missing(article):


@pytest.mark.parametrize(
"article, result",
"article, expected_result, message",
[
(
Article(
title="Iphone Case", price=1000, image_url="http://localhost/image", stock=None
),
False,
"article stock missing",
),
(
Article(title="Iphone Case", price=None, image_url="http://image", stock=10),
False,
"article_price_missing",
),
(
Article(title="Iphone Case", price=1000, image_url="", stock=10),
False,
"article_image_missing",
),
(
Article(title="Iphone Case", price=1000, image_url="http://image", stock=10),
True,
None,
),
],
)
def test_article_complete_rules(article, result):
assert result == RulesEngine(
Rule(article_stock_missing, then(False)),
def test_article_complete_rules(article, expected_result, message):
result = RulesEngine(
Rule(article_stock_missing, then(False), message="article stock missing"),
Rule(article_price_missing, then(False)),
Rule(article_image_missing, then(False)),
Otherwise(then(True)),
).run(article)

result.value = expected_result
result.message = message


@pytest.mark.parametrize(
"article, result",
"article, expected_result",
[
(
Article(
title="Iphone Case", price=1000, image_url="http://localhost/image", stock=None
),
["B", "C"],
[
Result(value='B', message='article price missing'),
Result(value='C', message='article image missing'),
],
),
(
Article(title="Iphone Case", price=None, image_url="http://image", stock=10),
["A", "C"],
[
Result(value='A', message='article stock missing'),
Result(value='C', message='article image missing'),
],
),
(
Article(title="Iphone Case", price=1000, image_url="", stock=10),
["A", "B"],
[
Result(value='A', message='article stock missing'),
Result(value='B', message='article price missing'),
],
),
(
Article(title="Iphone Case", price=1000, image_url="http://image", stock=10),
["A", "B", "C"],
[
Result(value='A', message='article stock missing'),
Result(value='B', message='article price missing'),
Result(value='C', message='article image missing'),
],
),
],
)
def test_article_complete_all_rules(article, result):
assert result == RulesEngine(
Rule(not_(article_stock_missing), then("A")),
Rule(not_(article_price_missing), then("B")),
Rule(not_(article_image_missing), then("C")),
def test_article_complete_all_rules(article, expected_result):
result = RulesEngine(
Rule(not_(article_stock_missing), then("A"), message="article stock missing"),
Rule(not_(article_price_missing), then("B"), message="article price missing"),
Rule(not_(article_image_missing), then("C"), message="article image missing"),
).run_all(article)
assert result == expected_result
49 changes: 36 additions & 13 deletions tests/test_operators.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import pytest

from src.rules_engine import Rule, RulesEngine, all_, any_, not_, then, when
from src.rules_engine import NoMatch, Rule, RulesEngine, all_, any_, not_, then, when


def raise_cannot_be_none_error(obj):
Expand All @@ -13,48 +13,71 @@ def test_when_then_operator():
with pytest.raises(ValueError):
RulesEngine(Rule(when(obj is None), raise_cannot_be_none_error)).run(obj)

assert RulesEngine(Rule(when(obj is not None), then(True))).run(obj) is None
with pytest.raises(NoMatch):
RulesEngine(Rule(when(obj is not None), raise_cannot_be_none_error)).run(obj)


@pytest.mark.parametrize(
"condition,action,result",
[
(True, True, None),
(False, "A", "A"),
(False, "B", "B"),
],
)
def test_not_operator(condition, action, result):
obj = None

assert RulesEngine(Rule(not_(when(condition)), then(action))).run(obj) is result
assert RulesEngine(Rule(not_(when(condition)), then(action))).run(obj).value is result


@pytest.mark.parametrize(
"conditions,action,result",
[
([when(False), when(False), when(False)], "A", None),
([when(True), when(False), when(False)], "A", "A"),
([when(True), when(True), when(False)], "A", "A"),
([when(True), when(True), when(True)], "A", "A"),
],
)
def test_any_operator(conditions, action, result):
obj = None
assert RulesEngine(Rule(any_(*conditions), then(action))).run(obj).value is result

assert RulesEngine(Rule(any_(*conditions), then(action))).run(obj) is result

@pytest.mark.parametrize(
"conditions,action",
[
([when(False), when(False), when(False)], "A"),
],
)
def test_any_operator_no_match(conditions, action):
obj = None
with pytest.raises(NoMatch):
RulesEngine(Rule(any_(*conditions), then(action))).run(obj)


@pytest.mark.parametrize(
"conditions,action,result",
"conditions,action,value,message",
[
([when(False), when(False), when(False)], "A", None),
([when(True), when(False), when(False)], "A", None),
([when(True), when(True), when(False)], "A", None),
([when(True), when(True), when(True)], "A", "A"),
([when(True), when(True), when(True)], "A", "A", None),
],
)
def test_all_operator(conditions, action, result):
def test_all_operator(conditions, action, value, message):
obj = None

assert RulesEngine(Rule(all_(*conditions), then(action))).run(obj) is result
result = RulesEngine(Rule(all_(*conditions), then(action))).run(obj)
assert result.value == value
assert result.message == message


@pytest.mark.parametrize(
"conditions,action,value,message",
[
([when(False), when(False), when(False)], "A", None, "No conditions matched"),
([when(True), when(False), when(False)], "A", None, "No conditions matched"),
([when(True), when(True), when(False)], "A", None, "No conditions matched"),
],
)
def test_all_operator_no_match(conditions, action, value, message):
obj = None
with pytest.raises(NoMatch):
RulesEngine(Rule(all_(*conditions), then(action))).run(obj)

0 comments on commit eef2018

Please sign in to comment.