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.
@@ -0,0 +1,372 @@
1
+ """
2
+ Evaluator factory for creating condition evaluators.
3
+ """
4
+
5
+ import re
6
+ from datetime import datetime, timedelta
7
+ from functools import lru_cache
8
+ from typing import Any, Dict, Optional, Type, Union
9
+
10
+ from ..exceptions import UnknownOperatorError
11
+ from .base import Evaluator
12
+
13
+
14
+ class EqualsEvaluator(Evaluator):
15
+ """Evaluator for equals comparison."""
16
+
17
+ def evaluate(self, field_value, compare_value, case_sensitive=True):
18
+ if isinstance(field_value, str) and isinstance(compare_value, str):
19
+ if not case_sensitive:
20
+ return field_value.lower() == compare_value.lower()
21
+ return field_value == compare_value
22
+
23
+
24
+ class NotEqualsEvaluator(Evaluator):
25
+ """Evaluator for not equals comparison."""
26
+
27
+ def evaluate(self, field_value, compare_value, case_sensitive=True):
28
+ if isinstance(field_value, str) and isinstance(compare_value, str):
29
+ if not case_sensitive:
30
+ return field_value.lower() != compare_value.lower()
31
+ return field_value != compare_value
32
+
33
+
34
+ class GreaterThanEvaluator(Evaluator):
35
+ """Evaluator for greater than comparison."""
36
+
37
+ def evaluate(self, field_value, compare_value, case_sensitive=True):
38
+ try:
39
+ return field_value > compare_value
40
+ except TypeError:
41
+ return False
42
+
43
+
44
+ class GreaterThanOrEqualEvaluator(Evaluator):
45
+ """Evaluator for greater than or equal comparison."""
46
+
47
+ def evaluate(self, field_value, compare_value, case_sensitive=True):
48
+ try:
49
+ return field_value >= compare_value
50
+ except TypeError:
51
+ return False
52
+
53
+
54
+ class LessThanEvaluator(Evaluator):
55
+ """Evaluator for less than comparison."""
56
+
57
+ def evaluate(self, field_value, compare_value, case_sensitive=True):
58
+ try:
59
+ return field_value < compare_value
60
+ except TypeError:
61
+ return False
62
+
63
+
64
+ class LessThanOrEqualEvaluator(Evaluator):
65
+ """Evaluator for less than or equal comparison."""
66
+
67
+ def evaluate(self, field_value, compare_value, case_sensitive=True):
68
+ try:
69
+ return field_value <= compare_value
70
+ except TypeError:
71
+ return False
72
+
73
+
74
+ class InEvaluator(Evaluator):
75
+ """Evaluator for in collection comparison."""
76
+
77
+ def evaluate(self, field_value, compare_value, case_sensitive=True):
78
+ if not isinstance(compare_value, (list, tuple, set)):
79
+ return False
80
+ if isinstance(field_value, str) and not case_sensitive:
81
+ return field_value.lower() in [
82
+ v.lower() if isinstance(v, str) else v for v in compare_value
83
+ ]
84
+ return field_value in compare_value
85
+
86
+
87
+ class NotInEvaluator(Evaluator):
88
+ """Evaluator for not in collection comparison."""
89
+
90
+ def evaluate(self, field_value, compare_value, case_sensitive=True):
91
+ if not isinstance(compare_value, (list, tuple, set)):
92
+ return True
93
+ if isinstance(field_value, str) and not case_sensitive:
94
+ return field_value.lower() not in [
95
+ v.lower() if isinstance(v, str) else v for v in compare_value
96
+ ]
97
+ return field_value not in compare_value
98
+
99
+
100
+ class ContainsEvaluator(Evaluator):
101
+ """Evaluator for contains (substring) comparison."""
102
+
103
+ def evaluate(self, field_value, compare_value, case_sensitive=True):
104
+ if field_value is None or compare_value is None:
105
+ return False
106
+ field_str = str(field_value)
107
+ compare_str = str(compare_value)
108
+ if not case_sensitive:
109
+ return compare_str.lower() in field_str.lower()
110
+ return compare_str in field_str
111
+
112
+
113
+ class StartsWithEvaluator(Evaluator):
114
+ """Evaluator for starts with comparison."""
115
+
116
+ def evaluate(self, field_value, compare_value, case_sensitive=True):
117
+ if field_value is None or compare_value is None:
118
+ return False
119
+ field_str = str(field_value)
120
+ compare_str = str(compare_value)
121
+ if not case_sensitive:
122
+ return field_str.lower().startswith(compare_str.lower())
123
+ return field_str.startswith(compare_str)
124
+
125
+
126
+ class EndsWithEvaluator(Evaluator):
127
+ """Evaluator for ends with comparison."""
128
+
129
+ def evaluate(self, field_value, compare_value, case_sensitive=True):
130
+ if field_value is None or compare_value is None:
131
+ return False
132
+ field_str = str(field_value)
133
+ compare_str = str(compare_value)
134
+ if not case_sensitive:
135
+ return field_str.lower().endswith(compare_str.lower())
136
+ return field_str.endswith(compare_str)
137
+
138
+
139
+ class IsNullEvaluator(Evaluator):
140
+ """Evaluator for null check."""
141
+
142
+ def evaluate(self, field_value, compare_value, case_sensitive=True):
143
+ return field_value is None
144
+
145
+
146
+ class IsNotNullEvaluator(Evaluator):
147
+ """Evaluator for not null check."""
148
+
149
+ def evaluate(self, field_value, compare_value, case_sensitive=True):
150
+ return field_value is not None
151
+
152
+
153
+ class MatchesRegexEvaluator(Evaluator):
154
+ """Evaluator for regex pattern matching."""
155
+
156
+ @staticmethod
157
+ @lru_cache(maxsize=1000)
158
+ def _compile_pattern(pattern: str, flags: int = 0) -> re.Pattern:
159
+ """Compile and cache regex pattern."""
160
+ return re.compile(pattern, flags)
161
+
162
+ def evaluate(self, field_value, compare_value, case_sensitive=True):
163
+ if field_value is None:
164
+ return False
165
+
166
+ try:
167
+ flags = 0 if case_sensitive else re.IGNORECASE
168
+ pattern = self._compile_pattern(compare_value, flags)
169
+ return bool(pattern.search(str(field_value)))
170
+ except (re.error, TypeError):
171
+ return False
172
+
173
+
174
+ class BetweenEvaluator(Evaluator):
175
+ """Evaluator for range check (inclusive)."""
176
+
177
+ def evaluate(self, field_value, compare_value, case_sensitive=True):
178
+ if field_value is None:
179
+ return False
180
+
181
+ try:
182
+ # compare_value should be [min, max] or {"min": x, "max": y}
183
+ if isinstance(compare_value, (list, tuple)) and len(compare_value) == 2:
184
+ min_val, max_val = compare_value
185
+ elif isinstance(compare_value, dict):
186
+ min_val = compare_value.get("min")
187
+ max_val = compare_value.get("max")
188
+ else:
189
+ return False
190
+
191
+ return min_val <= field_value <= max_val
192
+ except (TypeError, KeyError):
193
+ return False
194
+
195
+
196
+ class WithinDaysEvaluator(Evaluator):
197
+ """Evaluator for checking if date is within N days from now."""
198
+
199
+ def _parse_date(self, value: Any) -> Optional[datetime]:
200
+ """Parse various date formats."""
201
+ if value is None:
202
+ return None
203
+
204
+ if isinstance(value, datetime):
205
+ return value
206
+
207
+ if isinstance(value, str):
208
+ # Try common formats
209
+ formats = [
210
+ "%Y-%m-%d",
211
+ "%Y-%m-%dT%H:%M:%S",
212
+ "%Y-%m-%dT%H:%M:%S.%f",
213
+ "%Y-%m-%dT%H:%M:%SZ",
214
+ "%Y-%m-%dT%H:%M:%S.%fZ",
215
+ "%d/%m/%Y",
216
+ "%m/%d/%Y",
217
+ ]
218
+ for fmt in formats:
219
+ try:
220
+ return datetime.strptime(value, fmt)
221
+ except ValueError:
222
+ continue
223
+
224
+ return None
225
+
226
+ def evaluate(self, field_value, compare_value, case_sensitive=True):
227
+ field_date = self._parse_date(field_value)
228
+ if not field_date:
229
+ return False
230
+
231
+ try:
232
+ days = int(compare_value)
233
+ cutoff = datetime.utcnow() - timedelta(days=days)
234
+ return field_date >= cutoff
235
+ except (ValueError, TypeError):
236
+ return False
237
+
238
+
239
+ class BeforeEvaluator(Evaluator):
240
+ """Evaluator for checking if date is before another date."""
241
+
242
+ def _parse_date(self, value: Any) -> Optional[datetime]:
243
+ """Parse various date formats."""
244
+ if value is None:
245
+ return None
246
+
247
+ if isinstance(value, datetime):
248
+ return value
249
+
250
+ if isinstance(value, str):
251
+ formats = [
252
+ "%Y-%m-%d",
253
+ "%Y-%m-%dT%H:%M:%S",
254
+ "%Y-%m-%dT%H:%M:%S.%f",
255
+ "%Y-%m-%dT%H:%M:%SZ",
256
+ ]
257
+ for fmt in formats:
258
+ try:
259
+ return datetime.strptime(value, fmt)
260
+ except ValueError:
261
+ continue
262
+
263
+ return None
264
+
265
+ def evaluate(self, field_value, compare_value, case_sensitive=True):
266
+ field_date = self._parse_date(field_value)
267
+ compare_date = self._parse_date(compare_value)
268
+
269
+ if field_date is None or compare_date is None:
270
+ return False
271
+
272
+ return field_date < compare_date
273
+
274
+
275
+ class AfterEvaluator(Evaluator):
276
+ """Evaluator for checking if date is after another date."""
277
+
278
+ def _parse_date(self, value: Any) -> Optional[datetime]:
279
+ """Parse various date formats."""
280
+ if value is None:
281
+ return None
282
+
283
+ if isinstance(value, datetime):
284
+ return value
285
+
286
+ if isinstance(value, str):
287
+ formats = [
288
+ "%Y-%m-%d",
289
+ "%Y-%m-%dT%H:%M:%S",
290
+ "%Y-%m-%dT%H:%M:%S.%f",
291
+ "%Y-%m-%dT%H:%M:%SZ",
292
+ ]
293
+ for fmt in formats:
294
+ try:
295
+ return datetime.strptime(value, fmt)
296
+ except ValueError:
297
+ continue
298
+
299
+ return None
300
+
301
+ def evaluate(self, field_value, compare_value, case_sensitive=True):
302
+ field_date = self._parse_date(field_value)
303
+ compare_date = self._parse_date(compare_value)
304
+
305
+ if field_date is None or compare_date is None:
306
+ return False
307
+
308
+ return field_date > compare_date
309
+
310
+
311
+ class EvaluatorFactory:
312
+ """Factory for creating condition evaluators."""
313
+
314
+ _evaluators: Dict[str, Type[Evaluator]] = {
315
+ # Comparison
316
+ "equals": EqualsEvaluator,
317
+ "not_equals": NotEqualsEvaluator,
318
+ "greater_than": GreaterThanEvaluator,
319
+ "greater_than_or_equal": GreaterThanOrEqualEvaluator,
320
+ "less_than": LessThanEvaluator,
321
+ "less_than_or_equal": LessThanOrEqualEvaluator,
322
+ "between": BetweenEvaluator,
323
+ # Collection
324
+ "in": InEvaluator,
325
+ "not_in": NotInEvaluator,
326
+ # String
327
+ "contains": ContainsEvaluator,
328
+ "starts_with": StartsWithEvaluator,
329
+ "ends_with": EndsWithEvaluator,
330
+ "matches_regex": MatchesRegexEvaluator,
331
+ # Null
332
+ "is_null": IsNullEvaluator,
333
+ "is_not_null": IsNotNullEvaluator,
334
+ # Temporal
335
+ "within_days": WithinDaysEvaluator,
336
+ "before": BeforeEvaluator,
337
+ "after": AfterEvaluator,
338
+ }
339
+
340
+ @classmethod
341
+ def get(cls, operator: str) -> Evaluator:
342
+ """
343
+ Get an evaluator for the given operator.
344
+
345
+ Args:
346
+ operator: Operator name
347
+
348
+ Returns:
349
+ Evaluator instance
350
+
351
+ Raises:
352
+ UnknownOperatorError: If operator is not registered
353
+ """
354
+ if operator not in cls._evaluators:
355
+ raise UnknownOperatorError(f"Unknown operator: {operator}")
356
+ return cls._evaluators[operator]()
357
+
358
+ @classmethod
359
+ def register(cls, operator: str, evaluator_class: Type[Evaluator]) -> None:
360
+ """
361
+ Register a custom evaluator.
362
+
363
+ Args:
364
+ operator: Operator name
365
+ evaluator_class: Evaluator class
366
+ """
367
+ cls._evaluators[operator] = evaluator_class
368
+
369
+ @classmethod
370
+ def get_supported_operators(cls) -> list:
371
+ """Get list of supported operators."""
372
+ return list(cls._evaluators.keys())
pylitmus/exceptions.py ADDED
@@ -0,0 +1,39 @@
1
+ """
2
+ Custom exceptions for the CMAP Rules Engine.
3
+ """
4
+
5
+
6
+ class RuleEngineError(Exception):
7
+ """Base exception for rule engine errors."""
8
+
9
+ pass
10
+
11
+
12
+ class UnknownOperatorError(RuleEngineError):
13
+ """Raised when an unknown operator is used."""
14
+
15
+ pass
16
+
17
+
18
+ class ConditionError(RuleEngineError):
19
+ """Raised when condition evaluation fails."""
20
+
21
+ pass
22
+
23
+
24
+ class StorageError(RuleEngineError):
25
+ """Raised when storage operations fail."""
26
+
27
+ pass
28
+
29
+
30
+ class ConfigurationError(RuleEngineError):
31
+ """Raised when configuration is invalid."""
32
+
33
+ pass
34
+
35
+
36
+ class EvaluationError(RuleEngineError):
37
+ """Raised when rule evaluation fails."""
38
+
39
+ pass
pylitmus/factory.py ADDED
@@ -0,0 +1,179 @@
1
+ """
2
+ Factory functions for creating RuleEngine instances with sensible defaults.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from typing import TYPE_CHECKING, Dict, List, Optional, Union
8
+
9
+ from .engine import RuleEngine
10
+ from .storage import (
11
+ CachedRuleRepository,
12
+ DatabaseRuleRepository,
13
+ FileRuleRepository,
14
+ InMemoryRuleRepository,
15
+ RuleRepository,
16
+ )
17
+ from .strategies import MaxStrategy, ScoringStrategy, SumStrategy, WeightedStrategy
18
+
19
+ if TYPE_CHECKING:
20
+ from .types import Rule
21
+
22
+
23
+ def create_engine(
24
+ # Storage
25
+ storage_backend: str = "memory",
26
+ database_url: Optional[str] = None,
27
+ rules_file: Optional[str] = None,
28
+ rules: Optional[List["Rule"]] = None,
29
+ repository: Optional[RuleRepository] = None,
30
+ # Caching
31
+ cache_backend: str = "memory",
32
+ cache_url: Optional[str] = None,
33
+ cache_ttl: int = 300,
34
+ # Scoring
35
+ scoring_strategy: Union[str, ScoringStrategy] = "sum",
36
+ # Decision
37
+ decision_thresholds: Optional[Dict[str, int]] = None,
38
+ ) -> RuleEngine:
39
+ """
40
+ Create a configured RuleEngine instance.
41
+
42
+ This is the recommended way to create a RuleEngine with
43
+ sensible defaults for most use cases.
44
+
45
+ Args:
46
+ storage_backend: 'memory', 'database', or 'file'
47
+ database_url: Database connection URL (for 'database' backend)
48
+ rules_file: Path to rules file (for 'file' backend)
49
+ rules: List of Rule objects (for 'memory' backend)
50
+ repository: Pre-configured repository (overrides other storage options)
51
+ cache_backend: 'memory', 'redis', or 'none'
52
+ cache_url: Redis URL (for 'redis' cache)
53
+ cache_ttl: Cache TTL in seconds
54
+ scoring_strategy: 'sum', 'weighted', 'max', or ScoringStrategy instance
55
+ decision_thresholds: Custom thresholds {'approve': 30, 'review': 70}
56
+
57
+ Returns:
58
+ Configured RuleEngine instance
59
+
60
+ Examples:
61
+ # Simple in-memory engine
62
+ engine = create_engine()
63
+
64
+ # In-memory with predefined rules
65
+ engine = create_engine(rules=[rule1, rule2])
66
+
67
+ # Database-backed with Redis cache
68
+ engine = create_engine(
69
+ storage_backend='database',
70
+ database_url='postgresql://localhost/mydb',
71
+ cache_backend='redis',
72
+ cache_url='redis://localhost:6379/0'
73
+ )
74
+
75
+ # File-based rules
76
+ engine = create_engine(
77
+ storage_backend='file',
78
+ rules_file='./rules/fraud_rules.yaml'
79
+ )
80
+
81
+ # With weighted scoring
82
+ engine = create_engine(
83
+ scoring_strategy='weighted',
84
+ decision_thresholds={'approve': 25, 'review': 60}
85
+ )
86
+ """
87
+
88
+ # Build or use provided repository
89
+ if repository is not None:
90
+ repo = repository
91
+ else:
92
+ repo = create_repository(
93
+ backend=storage_backend,
94
+ database_url=database_url,
95
+ rules_file=rules_file,
96
+ rules=rules,
97
+ )
98
+
99
+ # Wrap with cache if needed
100
+ if cache_backend != "none" and not isinstance(repo, CachedRuleRepository):
101
+ repo = CachedRuleRepository(
102
+ repository=repo,
103
+ cache_backend=cache_backend,
104
+ cache_url=cache_url,
105
+ ttl_seconds=cache_ttl,
106
+ )
107
+
108
+ # Build or use provided strategy
109
+ strategy = create_strategy(scoring_strategy)
110
+
111
+ # Create engine
112
+ return RuleEngine(
113
+ repository=repo,
114
+ scoring_strategy=strategy,
115
+ decision_thresholds=decision_thresholds,
116
+ )
117
+
118
+
119
+ def create_repository(
120
+ backend: str = "memory",
121
+ database_url: Optional[str] = None,
122
+ rules_file: Optional[str] = None,
123
+ rules: Optional[List["Rule"]] = None,
124
+ **kwargs,
125
+ ) -> RuleRepository:
126
+ """
127
+ Create a rule repository.
128
+
129
+ Args:
130
+ backend: 'memory', 'database', or 'file'
131
+ database_url: Database URL for 'database' backend
132
+ rules_file: File path for 'file' backend
133
+ rules: Rules list for 'memory' backend
134
+
135
+ Returns:
136
+ RuleRepository instance
137
+ """
138
+ if backend == "memory":
139
+ return InMemoryRuleRepository(rules=rules or [])
140
+
141
+ elif backend == "database":
142
+ if not database_url:
143
+ raise ValueError("database_url required for 'database' backend")
144
+ return DatabaseRuleRepository(database_url)
145
+
146
+ elif backend == "file":
147
+ if not rules_file:
148
+ raise ValueError("rules_file required for 'file' backend")
149
+ return FileRuleRepository(rules_file)
150
+
151
+ else:
152
+ raise ValueError(f"Unknown storage backend: {backend}")
153
+
154
+
155
+ def create_strategy(strategy: Union[str, ScoringStrategy]) -> ScoringStrategy:
156
+ """
157
+ Create a scoring strategy.
158
+
159
+ Args:
160
+ strategy: Strategy name ('sum', 'weighted', 'max') or instance
161
+
162
+ Returns:
163
+ ScoringStrategy instance
164
+ """
165
+ if isinstance(strategy, ScoringStrategy):
166
+ return strategy
167
+
168
+ strategies = {
169
+ "sum": SumStrategy,
170
+ "weighted": WeightedStrategy,
171
+ "max": MaxStrategy,
172
+ }
173
+
174
+ if strategy not in strategies:
175
+ raise ValueError(
176
+ f"Unknown strategy: {strategy}. Use: {list(strategies.keys())}"
177
+ )
178
+
179
+ return strategies[strategy]()
@@ -0,0 +1,3 @@
1
+ """
2
+ Framework integrations for the CMAP Rules Engine.
3
+ """
@@ -0,0 +1,10 @@
1
+ """
2
+ Flask integration for the CMAP Rules Engine.
3
+ """
4
+
5
+ from .extension import CmapRulesEngine, get_engine
6
+
7
+ __all__ = [
8
+ "CmapRulesEngine",
9
+ "get_engine",
10
+ ]