pylitmus 1.0.0__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.
pylitmus/__init__.py ADDED
@@ -0,0 +1,76 @@
1
+ """
2
+ pylitmus - A high-performance rules engine for Python.
3
+
4
+ Evaluate data against configurable rules and get clear verdicts.
5
+ Like a litmus test for your data.
6
+ """
7
+
8
+ from .conditions import CompositeCondition, Condition, ConditionBuilder, SimpleCondition
9
+ from .engine import RuleEngine
10
+ from .evaluators import Evaluator, EvaluatorFactory
11
+ from .exceptions import (
12
+ ConditionError,
13
+ ConfigurationError,
14
+ EvaluationError,
15
+ RuleEngineError,
16
+ StorageError,
17
+ UnknownOperatorError,
18
+ )
19
+ from .factory import create_engine, create_repository, create_strategy
20
+ from .patterns import EnhancedPatternEngine, PatternMatcher
21
+ from .storage import (
22
+ CachedRuleRepository,
23
+ DatabaseRuleRepository,
24
+ FileRuleRepository,
25
+ InMemoryRuleRepository,
26
+ RuleRepository,
27
+ )
28
+ from .strategies import MaxStrategy, ScoringStrategy, SumStrategy, WeightedStrategy
29
+ from .types import AssessmentResult, Operator, Rule, RuleResult, Severity
30
+
31
+ __version__ = "1.0.0"
32
+
33
+ __all__ = [
34
+ # Main
35
+ "RuleEngine",
36
+ "create_engine",
37
+ "create_repository",
38
+ "create_strategy",
39
+ # Types
40
+ "Rule",
41
+ "RuleResult",
42
+ "AssessmentResult",
43
+ "Operator",
44
+ "Severity",
45
+ # Conditions
46
+ "Condition",
47
+ "SimpleCondition",
48
+ "CompositeCondition",
49
+ "ConditionBuilder",
50
+ # Evaluators
51
+ "Evaluator",
52
+ "EvaluatorFactory",
53
+ # Strategies
54
+ "ScoringStrategy",
55
+ "SumStrategy",
56
+ "WeightedStrategy",
57
+ "MaxStrategy",
58
+ # Storage
59
+ "RuleRepository",
60
+ "InMemoryRuleRepository",
61
+ "DatabaseRuleRepository",
62
+ "FileRuleRepository",
63
+ "CachedRuleRepository",
64
+ # Patterns
65
+ "PatternMatcher",
66
+ "EnhancedPatternEngine",
67
+ # Exceptions
68
+ "RuleEngineError",
69
+ "UnknownOperatorError",
70
+ "ConditionError",
71
+ "StorageError",
72
+ "ConfigurationError",
73
+ "EvaluationError",
74
+ # Version
75
+ "__version__",
76
+ ]
@@ -0,0 +1,15 @@
1
+ """
2
+ Conditions module for the CMAP Rules Engine.
3
+ """
4
+
5
+ from .base import Condition
6
+ from .builder import ConditionBuilder
7
+ from .composite import CompositeCondition
8
+ from .simple import SimpleCondition
9
+
10
+ __all__ = [
11
+ "Condition",
12
+ "SimpleCondition",
13
+ "CompositeCondition",
14
+ "ConditionBuilder",
15
+ ]
@@ -0,0 +1,53 @@
1
+ """
2
+ Base condition class for the CMAP Rules Engine.
3
+ """
4
+
5
+ from abc import ABC, abstractmethod
6
+ from typing import Any, Dict
7
+
8
+
9
+ class Condition(ABC):
10
+ """Abstract base class for all conditions."""
11
+
12
+ @abstractmethod
13
+ def evaluate(self, data: Dict[str, Any]) -> bool:
14
+ """
15
+ Evaluate condition against data.
16
+
17
+ Args:
18
+ data: Data dictionary to evaluate
19
+
20
+ Returns:
21
+ True if condition is met, False otherwise
22
+ """
23
+ pass
24
+
25
+ @abstractmethod
26
+ def to_dict(self) -> Dict[str, Any]:
27
+ """
28
+ Serialize condition to dictionary.
29
+
30
+ Returns:
31
+ Dictionary representation of condition
32
+ """
33
+ pass
34
+
35
+ def get_nested_value(self, data: Dict[str, Any], field_path: str) -> Any:
36
+ """
37
+ Get value from nested dict using dot notation.
38
+
39
+ Args:
40
+ data: Data dictionary
41
+ field_path: Dot-separated path (e.g., 'provider.type')
42
+
43
+ Returns:
44
+ Value at path or None if not found
45
+ """
46
+ keys = field_path.split(".")
47
+ value = data
48
+ for key in keys:
49
+ if isinstance(value, dict):
50
+ value = value.get(key)
51
+ else:
52
+ return None
53
+ return value
@@ -0,0 +1,92 @@
1
+ """
2
+ Condition builder for creating conditions from dictionaries.
3
+ """
4
+
5
+ from typing import Any, Dict
6
+
7
+ from .base import Condition
8
+ from .composite import CompositeCondition
9
+ from .simple import SimpleCondition
10
+
11
+
12
+ class ConditionBuilder:
13
+ """Build Condition objects from dictionary definitions."""
14
+
15
+ def build(self, definition: Dict[str, Any]) -> Condition:
16
+ """
17
+ Build a Condition from a dictionary definition.
18
+
19
+ Args:
20
+ definition: Dictionary defining the condition
21
+
22
+ Returns:
23
+ Condition object
24
+
25
+ Examples:
26
+ # Simple condition
27
+ builder.build({
28
+ 'field': 'amount',
29
+ 'operator': 'greater_than',
30
+ 'value': 1000
31
+ })
32
+
33
+ # Composite condition (AND)
34
+ builder.build({
35
+ 'all': [
36
+ {'field': 'amount', 'operator': 'greater_than', 'value': 1000},
37
+ {'field': 'status', 'operator': 'equals', 'value': 'pending'}
38
+ ]
39
+ })
40
+
41
+ # Composite condition (OR)
42
+ builder.build({
43
+ 'any': [
44
+ {'field': 'type', 'operator': 'equals', 'value': 'urgent'},
45
+ {'field': 'priority', 'operator': 'equals', 'value': 'high'}
46
+ ]
47
+ })
48
+ """
49
+ if "all" in definition:
50
+ return self._build_composite("all", definition["all"])
51
+ elif "any" in definition:
52
+ return self._build_composite("any", definition["any"])
53
+ elif "type" in definition and "conditions" in definition:
54
+ # Support alternative format: {"type": "AND", "conditions": [...]}
55
+ condition_type = definition["type"].upper()
56
+ operator = "all" if condition_type == "AND" else "any"
57
+ return self._build_composite(operator, definition["conditions"])
58
+ else:
59
+ return self._build_simple(definition)
60
+
61
+ def _build_composite(
62
+ self, operator: str, conditions_data: list
63
+ ) -> CompositeCondition:
64
+ """
65
+ Build a composite condition.
66
+
67
+ Args:
68
+ operator: 'all' or 'any'
69
+ conditions_data: List of condition definitions
70
+
71
+ Returns:
72
+ CompositeCondition object
73
+ """
74
+ conditions = [self.build(c) for c in conditions_data]
75
+ return CompositeCondition(operator=operator, conditions=conditions)
76
+
77
+ def _build_simple(self, definition: Dict[str, Any]) -> SimpleCondition:
78
+ """
79
+ Build a simple condition.
80
+
81
+ Args:
82
+ definition: Dictionary with field, operator, value
83
+
84
+ Returns:
85
+ SimpleCondition object
86
+ """
87
+ return SimpleCondition(
88
+ field=definition["field"],
89
+ operator=definition["operator"],
90
+ value=definition.get("value"),
91
+ case_sensitive=definition.get("case_sensitive", True),
92
+ )
@@ -0,0 +1,52 @@
1
+ """
2
+ Composite condition implementation.
3
+ """
4
+
5
+ from typing import Any, Dict, List, Literal
6
+
7
+ from .base import Condition
8
+
9
+
10
+ class CompositeCondition(Condition):
11
+ """A composite condition combining multiple conditions with AND/OR."""
12
+
13
+ def __init__(self, operator: Literal["all", "any"], conditions: List[Condition]):
14
+ """
15
+ Initialize a composite condition.
16
+
17
+ Args:
18
+ operator: 'all' for AND logic, 'any' for OR logic
19
+ conditions: List of conditions to combine
20
+ """
21
+ self.operator = operator
22
+ self.conditions = conditions
23
+
24
+ def evaluate(self, data: Dict[str, Any]) -> bool:
25
+ """
26
+ Evaluate the composite condition against data.
27
+
28
+ Args:
29
+ data: Data dictionary to evaluate
30
+
31
+ Returns:
32
+ True if condition is met based on operator logic
33
+ """
34
+ if not self.conditions:
35
+ return True
36
+
37
+ if self.operator == "all":
38
+ return all(c.evaluate(data) for c in self.conditions)
39
+ else: # 'any'
40
+ return any(c.evaluate(data) for c in self.conditions)
41
+
42
+ def to_dict(self) -> Dict[str, Any]:
43
+ """
44
+ Serialize condition to dictionary.
45
+
46
+ Returns:
47
+ Dictionary representation of condition
48
+ """
49
+ return {self.operator: [c.to_dict() for c in self.conditions]}
50
+
51
+ def __repr__(self) -> str:
52
+ return f"CompositeCondition({self.operator}, {len(self.conditions)} conditions)"
@@ -0,0 +1,62 @@
1
+ """
2
+ Simple condition implementation.
3
+ """
4
+
5
+ from typing import Any, Dict
6
+
7
+ from ..evaluators import EvaluatorFactory
8
+ from .base import Condition
9
+
10
+
11
+ class SimpleCondition(Condition):
12
+ """A simple condition that compares a field to a value."""
13
+
14
+ def __init__(
15
+ self, field: str, operator: str, value: Any, case_sensitive: bool = True
16
+ ):
17
+ """
18
+ Initialize a simple condition.
19
+
20
+ Args:
21
+ field: Field path to evaluate (supports dot notation)
22
+ operator: Comparison operator name
23
+ value: Value to compare against
24
+ case_sensitive: Whether string comparisons are case-sensitive
25
+ """
26
+ self.field = field
27
+ self.operator = operator
28
+ self.value = value
29
+ self.case_sensitive = case_sensitive
30
+
31
+ def evaluate(self, data: Dict[str, Any]) -> bool:
32
+ """
33
+ Evaluate the condition against data.
34
+
35
+ Args:
36
+ data: Data dictionary to evaluate
37
+
38
+ Returns:
39
+ True if condition is met, False otherwise
40
+ """
41
+ field_value = self.get_nested_value(data, self.field)
42
+ evaluator = EvaluatorFactory.get(self.operator)
43
+ return evaluator.evaluate(field_value, self.value, self.case_sensitive)
44
+
45
+ def to_dict(self) -> Dict[str, Any]:
46
+ """
47
+ Serialize condition to dictionary.
48
+
49
+ Returns:
50
+ Dictionary representation of condition
51
+ """
52
+ result = {
53
+ "field": self.field,
54
+ "operator": self.operator,
55
+ "value": self.value,
56
+ }
57
+ if not self.case_sensitive:
58
+ result["case_sensitive"] = False
59
+ return result
60
+
61
+ def __repr__(self) -> str:
62
+ return f"SimpleCondition({self.field} {self.operator} {self.value})"
pylitmus/engine.py ADDED
@@ -0,0 +1,244 @@
1
+ """
2
+ Main RuleEngine class for evaluating data against configured rules.
3
+ """
4
+
5
+ import logging
6
+ import time
7
+ from typing import TYPE_CHECKING, Any, Dict, List, Optional
8
+
9
+ from .exceptions import EvaluationError
10
+ from .types import AssessmentResult, Rule, RuleResult
11
+
12
+ if TYPE_CHECKING:
13
+ from .storage.base import RuleRepository
14
+ from .strategies.base import ScoringStrategy
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ class RuleEngine:
20
+ """
21
+ Main rules engine for evaluating data against configured rules.
22
+
23
+ Usage:
24
+ engine = RuleEngine(
25
+ repository=DatabaseRuleRepository(db_url),
26
+ scoring_strategy=WeightedStrategy()
27
+ )
28
+
29
+ result = engine.evaluate(claim, context)
30
+ """
31
+
32
+ DEFAULT_THRESHOLDS = {
33
+ "approve": 30,
34
+ "review": 70,
35
+ }
36
+
37
+ def __init__(
38
+ self,
39
+ repository: "RuleRepository",
40
+ scoring_strategy: Optional["ScoringStrategy"] = None,
41
+ decision_thresholds: Optional[Dict[str, int]] = None,
42
+ condition_builder: Optional[Any] = None,
43
+ ):
44
+ """
45
+ Initialize the rule engine.
46
+
47
+ Args:
48
+ repository: Rule storage backend
49
+ scoring_strategy: Strategy for calculating scores
50
+ decision_thresholds: Custom thresholds for decisions
51
+ condition_builder: Builder for creating conditions from dicts
52
+ """
53
+ self.repository = repository
54
+ self._scoring_strategy = scoring_strategy
55
+ self._thresholds = decision_thresholds or self.DEFAULT_THRESHOLDS.copy()
56
+ self._condition_builder = condition_builder
57
+ self._rules_cache: Optional[List[Rule]] = None
58
+
59
+ logger.info("RuleEngine initialized")
60
+
61
+ @property
62
+ def scoring_strategy(self) -> "ScoringStrategy":
63
+ """Get the scoring strategy, creating default if needed."""
64
+ if self._scoring_strategy is None:
65
+ from .strategies import SumStrategy
66
+
67
+ self._scoring_strategy = SumStrategy()
68
+ return self._scoring_strategy
69
+
70
+ @property
71
+ def condition_builder(self) -> Any:
72
+ """Get the condition builder, creating default if needed."""
73
+ if self._condition_builder is None:
74
+ from .conditions import ConditionBuilder
75
+
76
+ self._condition_builder = ConditionBuilder()
77
+ return self._condition_builder
78
+
79
+ def evaluate(
80
+ self,
81
+ data: Dict[str, Any],
82
+ context: Optional[Dict[str, Any]] = None,
83
+ filters: Optional[Dict[str, Any]] = None,
84
+ ) -> AssessmentResult:
85
+ """
86
+ Evaluate data against all applicable rules.
87
+
88
+ Args:
89
+ data: Data to evaluate
90
+ context: Additional context for evaluation
91
+ filters: Filters to apply when fetching rules
92
+
93
+ Returns:
94
+ AssessmentResult with score, decision, and triggered rules
95
+ """
96
+ start_time = time.time()
97
+ context = context or {}
98
+
99
+ logger.debug(f"Starting evaluation with {len(data)} data fields")
100
+
101
+ # Get applicable rules
102
+ rules = self.repository.get_enabled(filters)
103
+
104
+ triggered_results: List[RuleResult] = []
105
+ all_results: List[RuleResult] = []
106
+
107
+ for rule in rules:
108
+ try:
109
+ result = self.evaluate_rule(rule, data, context)
110
+ all_results.append(result)
111
+
112
+ if result.triggered:
113
+ triggered_results.append(result)
114
+ logger.debug(
115
+ f"Rule {rule.code} triggered with score {result.score}"
116
+ )
117
+
118
+ except Exception as e:
119
+ logger.error(f"Error evaluating rule {rule.code}: {e}")
120
+ raise EvaluationError(
121
+ f"Failed to evaluate rule {rule.code}: {e}"
122
+ ) from e
123
+
124
+ # Calculate total score
125
+ total_score = self.scoring_strategy.calculate(triggered_results)
126
+
127
+ # Determine decision
128
+ decision = self._make_decision(total_score)
129
+
130
+ processing_time = (time.time() - start_time) * 1000
131
+
132
+ result = AssessmentResult(
133
+ total_score=total_score,
134
+ decision=decision,
135
+ triggered_rules=triggered_results,
136
+ all_rules_evaluated=len(rules),
137
+ processing_time_ms=processing_time,
138
+ metadata={"context": context, "filters": filters},
139
+ )
140
+
141
+ logger.info(
142
+ f"Evaluation complete: score={total_score}, decision={decision}, "
143
+ f"triggered={len(triggered_results)}/{len(rules)}, time={processing_time:.2f}ms"
144
+ )
145
+
146
+ return result
147
+
148
+ def evaluate_rule(
149
+ self, rule: Rule, data: Dict[str, Any], context: Optional[Dict[str, Any]] = None
150
+ ) -> RuleResult:
151
+ """
152
+ Evaluate a single rule against data.
153
+
154
+ Args:
155
+ rule: Rule to evaluate
156
+ data: Data to evaluate against
157
+ context: Additional context
158
+
159
+ Returns:
160
+ RuleResult indicating if rule triggered
161
+ """
162
+ context = context or {}
163
+
164
+ # Check if rule is effective
165
+ if not rule.is_effective():
166
+ return RuleResult(
167
+ rule_code=rule.code,
168
+ rule_name=rule.name,
169
+ triggered=False,
170
+ score=0,
171
+ severity=rule.severity,
172
+ category=rule.category,
173
+ explanation="Rule is not currently effective",
174
+ )
175
+
176
+ # Build condition from rule definition
177
+ condition = self.condition_builder.build(rule.conditions)
178
+
179
+ # Evaluate condition
180
+ triggered = condition.evaluate(data)
181
+
182
+ return RuleResult(
183
+ rule_code=rule.code,
184
+ rule_name=rule.name,
185
+ triggered=triggered,
186
+ score=rule.score if triggered else 0,
187
+ severity=rule.severity,
188
+ category=rule.category,
189
+ explanation=rule.description if triggered else "Condition not met",
190
+ )
191
+
192
+ def _make_decision(self, score: int) -> str:
193
+ """
194
+ Make a decision based on the total score.
195
+
196
+ Args:
197
+ score: Total calculated score
198
+
199
+ Returns:
200
+ Decision string: 'APPROVE', 'REVIEW', or 'FLAG'
201
+ """
202
+ approve_threshold = self._thresholds.get("approve", 30)
203
+ review_threshold = self._thresholds.get("review", 70)
204
+
205
+ if score < approve_threshold:
206
+ return "APPROVE"
207
+ elif score < review_threshold:
208
+ return "REVIEW"
209
+ else:
210
+ return "FLAG"
211
+
212
+ def reload_rules(self) -> None:
213
+ """Force reload rules from repository."""
214
+ self._rules_cache = None
215
+
216
+ # If repository has cache, invalidate it
217
+ if hasattr(self.repository, "invalidate"):
218
+ self.repository.invalidate()
219
+
220
+ logger.info("Rules cache invalidated")
221
+
222
+ def get_rules(self, filters: Optional[Dict[str, Any]] = None) -> List[Rule]:
223
+ """
224
+ Get all rules, optionally filtered.
225
+
226
+ Args:
227
+ filters: Optional filters to apply
228
+
229
+ Returns:
230
+ List of rules
231
+ """
232
+ return self.repository.get_all(filters)
233
+
234
+ def get_rule(self, code: str) -> Optional[Rule]:
235
+ """
236
+ Get a specific rule by code.
237
+
238
+ Args:
239
+ code: Rule code
240
+
241
+ Returns:
242
+ Rule if found, None otherwise
243
+ """
244
+ return self.repository.get_by_code(code)
@@ -0,0 +1,11 @@
1
+ """
2
+ Evaluators module for the CMAP Rules Engine.
3
+ """
4
+
5
+ from .base import Evaluator
6
+ from .factory import EvaluatorFactory
7
+
8
+ __all__ = [
9
+ "Evaluator",
10
+ "EvaluatorFactory",
11
+ ]
@@ -0,0 +1,27 @@
1
+ """
2
+ Base evaluator class for the CMAP Rules Engine.
3
+ """
4
+
5
+ from abc import ABC, abstractmethod
6
+ from typing import Any
7
+
8
+
9
+ class Evaluator(ABC):
10
+ """Abstract base class for condition evaluators."""
11
+
12
+ @abstractmethod
13
+ def evaluate(
14
+ self, field_value: Any, compare_value: Any, case_sensitive: bool = True
15
+ ) -> bool:
16
+ """
17
+ Evaluate a condition.
18
+
19
+ Args:
20
+ field_value: The actual value from data
21
+ compare_value: The value to compare against
22
+ case_sensitive: Whether to use case-sensitive comparison
23
+
24
+ Returns:
25
+ True if condition is met, False otherwise
26
+ """
27
+ pass