pylitmus 1.0.0__tar.gz

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.
Files changed (57) hide show
  1. pylitmus-1.0.0/CHANGELOG.md +58 -0
  2. pylitmus-1.0.0/LICENSE +21 -0
  3. pylitmus-1.0.0/PKG-INFO +459 -0
  4. pylitmus-1.0.0/README.md +420 -0
  5. pylitmus-1.0.0/docs/api-reference.md +566 -0
  6. pylitmus-1.0.0/docs/flask-integration.md +389 -0
  7. pylitmus-1.0.0/docs/quickstart.md +237 -0
  8. pylitmus-1.0.0/docs/rules-format.md +427 -0
  9. pylitmus-1.0.0/examples/basic_usage.py +139 -0
  10. pylitmus-1.0.0/examples/flask_app/app.py +82 -0
  11. pylitmus-1.0.0/examples/flask_app/requirements.txt +2 -0
  12. pylitmus-1.0.0/examples/flask_app/rules.yaml +64 -0
  13. pylitmus-1.0.0/pyproject.toml +93 -0
  14. pylitmus-1.0.0/src/pylitmus/__init__.py +76 -0
  15. pylitmus-1.0.0/src/pylitmus/conditions/__init__.py +15 -0
  16. pylitmus-1.0.0/src/pylitmus/conditions/base.py +53 -0
  17. pylitmus-1.0.0/src/pylitmus/conditions/builder.py +92 -0
  18. pylitmus-1.0.0/src/pylitmus/conditions/composite.py +52 -0
  19. pylitmus-1.0.0/src/pylitmus/conditions/simple.py +62 -0
  20. pylitmus-1.0.0/src/pylitmus/engine.py +244 -0
  21. pylitmus-1.0.0/src/pylitmus/evaluators/__init__.py +11 -0
  22. pylitmus-1.0.0/src/pylitmus/evaluators/base.py +27 -0
  23. pylitmus-1.0.0/src/pylitmus/evaluators/factory.py +372 -0
  24. pylitmus-1.0.0/src/pylitmus/exceptions.py +39 -0
  25. pylitmus-1.0.0/src/pylitmus/factory.py +179 -0
  26. pylitmus-1.0.0/src/pylitmus/integrations/__init__.py +3 -0
  27. pylitmus-1.0.0/src/pylitmus/integrations/flask/__init__.py +10 -0
  28. pylitmus-1.0.0/src/pylitmus/integrations/flask/extension.py +234 -0
  29. pylitmus-1.0.0/src/pylitmus/patterns/__init__.py +21 -0
  30. pylitmus-1.0.0/src/pylitmus/patterns/base.py +25 -0
  31. pylitmus-1.0.0/src/pylitmus/patterns/engine.py +82 -0
  32. pylitmus-1.0.0/src/pylitmus/patterns/exact.py +34 -0
  33. pylitmus-1.0.0/src/pylitmus/patterns/fuzzy.py +69 -0
  34. pylitmus-1.0.0/src/pylitmus/patterns/glob.py +38 -0
  35. pylitmus-1.0.0/src/pylitmus/patterns/range.py +53 -0
  36. pylitmus-1.0.0/src/pylitmus/patterns/regex.py +51 -0
  37. pylitmus-1.0.0/src/pylitmus/storage/__init__.py +17 -0
  38. pylitmus-1.0.0/src/pylitmus/storage/base.py +78 -0
  39. pylitmus-1.0.0/src/pylitmus/storage/cached.py +167 -0
  40. pylitmus-1.0.0/src/pylitmus/storage/database.py +181 -0
  41. pylitmus-1.0.0/src/pylitmus/storage/file.py +143 -0
  42. pylitmus-1.0.0/src/pylitmus/storage/memory.py +107 -0
  43. pylitmus-1.0.0/src/pylitmus/strategies/__init__.py +15 -0
  44. pylitmus-1.0.0/src/pylitmus/strategies/base.py +25 -0
  45. pylitmus-1.0.0/src/pylitmus/strategies/max.py +26 -0
  46. pylitmus-1.0.0/src/pylitmus/strategies/sum.py +36 -0
  47. pylitmus-1.0.0/src/pylitmus/strategies/weighted.py +45 -0
  48. pylitmus-1.0.0/src/pylitmus/types.py +93 -0
  49. pylitmus-1.0.0/tests/__init__.py +1 -0
  50. pylitmus-1.0.0/tests/test_phase1_core.py +493 -0
  51. pylitmus-1.0.0/tests/test_phase2_conditions.py +578 -0
  52. pylitmus-1.0.0/tests/test_phase3_evaluators.py +466 -0
  53. pylitmus-1.0.0/tests/test_phase4_strategies.py +438 -0
  54. pylitmus-1.0.0/tests/test_phase5_storage.py +597 -0
  55. pylitmus-1.0.0/tests/test_phase6_patterns.py +447 -0
  56. pylitmus-1.0.0/tests/test_phase7_flask.py +334 -0
  57. pylitmus-1.0.0/tests/test_phase8_factory.py +664 -0
@@ -0,0 +1,58 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [1.0.0] - 2025-01-16
11
+
12
+ ### Added
13
+ - Initial release
14
+ - Core `RuleEngine` class for evaluating business rules
15
+ - `create_engine()` factory function for easy setup
16
+ - Condition system with Composite pattern support
17
+ - `SimpleCondition` for single field evaluations
18
+ - `CompositeCondition` for AND/OR logic
19
+ - `ConditionBuilder` for creating conditions from dictionaries
20
+ - 18 built-in evaluators:
21
+ - Comparison: `equals`, `not_equals`, `greater_than`, `greater_than_or_equal`, `less_than`, `less_than_or_equal`, `between`
22
+ - Collection: `in`, `not_in`, `contains`, `not_contains`
23
+ - String: `starts_with`, `ends_with`, `matches_regex`
24
+ - Null: `is_null`, `is_not_null`
25
+ - Temporal: `within_days`, `before`, `after`
26
+ - 3 scoring strategies:
27
+ - `SumStrategy`: Sum all scores (capped at max)
28
+ - `WeightedStrategy`: Severity-weighted average
29
+ - `MaxStrategy`: Highest individual score
30
+ - Storage backends:
31
+ - `InMemoryRuleRepository`: In-memory storage
32
+ - `FileRuleRepository`: YAML/JSON file storage
33
+ - `DatabaseRuleRepository`: SQLAlchemy database storage
34
+ - `CachedRuleRepository`: Caching decorator with memory/Redis support
35
+ - Pattern matching system:
36
+ - Exact matching
37
+ - Regex matching
38
+ - Fuzzy matching (difflib)
39
+ - Range matching
40
+ - Glob matching
41
+ - Flask extension for web application integration
42
+ - Comprehensive documentation with examples
43
+ - Full test suite with >250 tests
44
+
45
+ ### Changed
46
+ - N/A
47
+
48
+ ### Deprecated
49
+ - N/A
50
+
51
+ ### Removed
52
+ - N/A
53
+
54
+ ### Fixed
55
+ - N/A
56
+
57
+ ### Security
58
+ - N/A
pylitmus-1.0.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 CMAP Team
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,459 @@
1
+ Metadata-Version: 2.4
2
+ Name: pylitmus
3
+ Version: 1.0.0
4
+ Summary: A high-performance rules engine for Python - evaluate data against configurable rules and get clear verdicts
5
+ Project-URL: Homepage, https://github.com/yourorg/pylitmus
6
+ Project-URL: Documentation, https://pylitmus.readthedocs.io/
7
+ Project-URL: Repository, https://github.com/yourorg/pylitmus.git
8
+ Project-URL: Changelog, https://github.com/yourorg/pylitmus/blob/main/CHANGELOG.md
9
+ Project-URL: Bug Tracker, https://github.com/yourorg/pylitmus/issues
10
+ Author-email: CMAP Team <team@example.com>
11
+ License: MIT
12
+ License-File: LICENSE
13
+ Keywords: business-rules,decision-engine,fraud-detection,litmus-test,risk-assessment,rules-engine,scoring-engine
14
+ Classifier: Development Status :: 4 - Beta
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
22
+ Classifier: Typing :: Typed
23
+ Requires-Python: >=3.11
24
+ Requires-Dist: pyyaml>=6.0
25
+ Provides-Extra: all
26
+ Requires-Dist: flask>=3.0.0; extra == 'all'
27
+ Requires-Dist: redis>=5.0.0; extra == 'all'
28
+ Requires-Dist: sqlalchemy>=2.0.0; extra == 'all'
29
+ Provides-Extra: database
30
+ Requires-Dist: sqlalchemy>=2.0.0; extra == 'database'
31
+ Provides-Extra: dev
32
+ Requires-Dist: pytest-cov>=4.1.0; extra == 'dev'
33
+ Requires-Dist: pytest>=7.4.0; extra == 'dev'
34
+ Provides-Extra: flask
35
+ Requires-Dist: flask>=3.0.0; extra == 'flask'
36
+ Provides-Extra: redis
37
+ Requires-Dist: redis>=5.0.0; extra == 'redis'
38
+ Description-Content-Type: text/markdown
39
+
40
+ # pylitmus
41
+
42
+ A high-performance rules engine for Python. Like a litmus test for your data - evaluate against configurable rules and get clear verdicts.
43
+
44
+ [![PyPI version](https://badge.fury.io/py/pylitmus.svg)](https://badge.fury.io/py/pylitmus)
45
+ [![Python](https://img.shields.io/pypi/pyversions/pylitmus.svg)](https://pypi.org/project/pylitmus/)
46
+ [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
47
+ [![Tests](https://github.com/org/pylitmus/workflows/Tests/badge.svg)](https://github.com/org/pylitmus/actions)
48
+ [![Coverage](https://codecov.io/gh/org/pylitmus/branch/main/graph/badge.svg)](https://codecov.io/gh/org/pylitmus)
49
+
50
+ ## Features
51
+
52
+ - **YAML/JSON rule definitions** - Business-friendly rule configuration
53
+ - **Hot-reload** - Rules can be updated without restart
54
+ - **Multiple storage backends** - Memory, database, file
55
+ - **Caching** - Redis and in-memory caching support
56
+ - **Multiple scoring strategies** - Sum, weighted, max
57
+ - **Flask integration** - Easy integration with Flask apps
58
+ - **18 built-in operators** - Comparison, collection, string, null, temporal
59
+ - **Extensible** - Custom evaluators and strategies
60
+
61
+ ## Installation
62
+
63
+ ```bash
64
+ pip install pylitmus
65
+
66
+ # With database support
67
+ pip install pylitmus[database]
68
+
69
+ # With Redis caching
70
+ pip install pylitmus[redis]
71
+
72
+ # With Flask integration
73
+ pip install pylitmus[flask]
74
+
75
+ # All extras
76
+ pip install pylitmus[all]
77
+ ```
78
+
79
+ ## Quick Start
80
+
81
+ ```python
82
+ from pylitmus import create_engine, Rule, Severity
83
+
84
+ # Create engine with inline rules
85
+ engine = create_engine(rules=[
86
+ Rule(
87
+ code='AMT_001',
88
+ name='High Amount',
89
+ description='Flag high amounts',
90
+ category='AMOUNT',
91
+ severity=Severity.HIGH,
92
+ score=60,
93
+ enabled=True,
94
+ conditions={'field': 'amount', 'operator': 'greater_than', 'value': 5000}
95
+ )
96
+ ])
97
+
98
+ # Evaluate data
99
+ result = engine.evaluate({'amount': 6000})
100
+
101
+ print(f"Score: {result.total_score}") # 60
102
+ print(f"Decision: {result.decision}") # FLAG
103
+ print(f"Triggered: {[r.rule_code for r in result.triggered_rules]}") # ['AMT_001']
104
+ ```
105
+
106
+ ## YAML Rules
107
+
108
+ Define rules in YAML files for easy management:
109
+
110
+ ```yaml
111
+ # rules.yaml
112
+ rules:
113
+ - code: "AMT_001"
114
+ name: "High Amount"
115
+ description: "Flag transactions over $5000"
116
+ category: "AMOUNT"
117
+ severity: "HIGH"
118
+ score: 60
119
+ enabled: true
120
+ conditions:
121
+ field: "amount"
122
+ operator: "greater_than"
123
+ value: 5000
124
+
125
+ - code: "RISK_001"
126
+ name: "High Risk Country"
127
+ description: "Flag transactions from high-risk countries"
128
+ category: "RISK"
129
+ severity: "CRITICAL"
130
+ score: 80
131
+ enabled: true
132
+ conditions:
133
+ field: "country"
134
+ operator: "in"
135
+ value: ["XX", "YY", "ZZ"]
136
+ ```
137
+
138
+ Load rules from file:
139
+
140
+ ```python
141
+ engine = create_engine(
142
+ storage_backend='file',
143
+ rules_file='rules.yaml'
144
+ )
145
+ ```
146
+
147
+ ## Composite Conditions
148
+
149
+ Combine conditions with AND/OR logic:
150
+
151
+ ```yaml
152
+ conditions:
153
+ all: # AND
154
+ - field: "amount"
155
+ operator: "greater_than"
156
+ value: 1000
157
+ - any: # OR
158
+ - field: "is_new_customer"
159
+ operator: "equals"
160
+ value: true
161
+ - field: "country"
162
+ operator: "in"
163
+ value: ["NG", "KE", "GH"]
164
+ ```
165
+
166
+ Or use the alternative format:
167
+
168
+ ```yaml
169
+ conditions:
170
+ type: "AND"
171
+ conditions:
172
+ - field: "amount"
173
+ operator: "greater_than"
174
+ value: 1000
175
+ - field: "is_international"
176
+ operator: "equals"
177
+ value: true
178
+ ```
179
+
180
+ ## Available Operators
181
+
182
+ | Category | Operators |
183
+ |----------|-----------|
184
+ | **Comparison** | `equals`, `not_equals`, `greater_than`, `greater_than_or_equal`, `less_than`, `less_than_or_equal`, `between` |
185
+ | **Collection** | `in`, `not_in`, `contains`, `not_contains` |
186
+ | **String** | `starts_with`, `ends_with`, `matches_regex` |
187
+ | **Null** | `is_null`, `is_not_null` |
188
+ | **Temporal** | `within_days`, `before`, `after` |
189
+
190
+ ## Scoring Strategies
191
+
192
+ ### Sum Strategy (Default)
193
+ Adds up all triggered rule scores, capped at 100.
194
+
195
+ ```python
196
+ engine = create_engine(scoring_strategy='sum')
197
+ ```
198
+
199
+ ### Weighted Strategy
200
+ Uses severity-based weights (LOW=1, MEDIUM=2, HIGH=3, CRITICAL=4).
201
+
202
+ ```python
203
+ engine = create_engine(scoring_strategy='weighted')
204
+ ```
205
+
206
+ ### Max Strategy
207
+ Takes the highest score from triggered rules.
208
+
209
+ ```python
210
+ engine = create_engine(scoring_strategy='max')
211
+ ```
212
+
213
+ ## Decision Thresholds
214
+
215
+ Customize decision boundaries:
216
+
217
+ ```python
218
+ engine = create_engine(
219
+ decision_thresholds={
220
+ 'approve': 30, # Score < 30 = APPROVE
221
+ 'review': 70 # Score 30-70 = REVIEW, >= 70 = FLAG
222
+ }
223
+ )
224
+ ```
225
+
226
+ ## Storage Backends
227
+
228
+ ### In-Memory
229
+ ```python
230
+ engine = create_engine(storage_backend='memory', rules=[...])
231
+ ```
232
+
233
+ ### File-Based
234
+ ```python
235
+ engine = create_engine(
236
+ storage_backend='file',
237
+ rules_file='rules.yaml' # or rules.json
238
+ )
239
+ ```
240
+
241
+ ### Database
242
+ ```python
243
+ engine = create_engine(
244
+ storage_backend='database',
245
+ database_url='postgresql://localhost/mydb'
246
+ )
247
+ ```
248
+
249
+ ## Caching
250
+
251
+ ### Memory Cache
252
+ ```python
253
+ engine = create_engine(
254
+ cache_backend='memory',
255
+ cache_ttl=300 # 5 minutes
256
+ )
257
+ ```
258
+
259
+ ### Redis Cache
260
+ ```python
261
+ engine = create_engine(
262
+ cache_backend='redis',
263
+ cache_url='redis://localhost:6379/0',
264
+ cache_ttl=600
265
+ )
266
+ ```
267
+
268
+ ### No Cache
269
+ ```python
270
+ engine = create_engine(cache_backend='none')
271
+ ```
272
+
273
+ ## Flask Integration
274
+
275
+ ```python
276
+ from flask import Flask
277
+ from pylitmus.integrations.flask import CmapRulesEngine, get_engine
278
+
279
+ app = Flask(__name__)
280
+ app.config['CMAP_RULES_FILE'] = 'rules.yaml'
281
+
282
+ rules_engine = CmapRulesEngine(app)
283
+
284
+ @app.route('/evaluate', methods=['POST'])
285
+ def evaluate():
286
+ data = request.json
287
+ engine = get_engine()
288
+ result = engine.evaluate(data)
289
+ return {
290
+ 'score': result.total_score,
291
+ 'decision': result.decision,
292
+ 'triggered_rules': [r.rule_code for r in result.triggered_rules]
293
+ }
294
+ ```
295
+
296
+ ## Nested Field Access
297
+
298
+ Access nested data using dot notation:
299
+
300
+ ```python
301
+ data = {
302
+ 'transaction': {
303
+ 'amount': 6000,
304
+ 'merchant': {
305
+ 'category': 'electronics'
306
+ }
307
+ }
308
+ }
309
+
310
+ # Rule condition
311
+ conditions:
312
+ field: "transaction.merchant.category"
313
+ operator: "equals"
314
+ value: "electronics"
315
+ ```
316
+
317
+ ## Pattern Matching
318
+
319
+ Advanced pattern matching capabilities:
320
+
321
+ ```python
322
+ from pylitmus import EnhancedPatternEngine
323
+
324
+ pattern_engine = EnhancedPatternEngine()
325
+
326
+ # Regex matching
327
+ pattern_engine.add_pattern('email', r'^[\w.-]+@[\w.-]+\.\w+$', 'regex')
328
+
329
+ # Fuzzy matching
330
+ pattern_engine.add_pattern('name', 'John Smith', 'fuzzy', threshold=0.8)
331
+
332
+ # Range matching
333
+ pattern_engine.add_pattern('age', {'min': 18, 'max': 65}, 'range')
334
+
335
+ # Check matches
336
+ result = pattern_engine.match_all({
337
+ 'email': 'user@example.com',
338
+ 'name': 'Jon Smith',
339
+ 'age': 25
340
+ })
341
+ ```
342
+
343
+ ## API Reference
344
+
345
+ ### create_engine()
346
+
347
+ ```python
348
+ def create_engine(
349
+ storage_backend: str = 'memory',
350
+ database_url: str = None,
351
+ rules_file: str = None,
352
+ rules: List[Rule] = None,
353
+ repository: RuleRepository = None,
354
+ cache_backend: str = 'memory',
355
+ cache_url: str = None,
356
+ cache_ttl: int = 300,
357
+ scoring_strategy: str = 'sum',
358
+ decision_thresholds: Dict[str, int] = None,
359
+ ) -> RuleEngine
360
+ ```
361
+
362
+ ### RuleEngine.evaluate()
363
+
364
+ ```python
365
+ def evaluate(
366
+ self,
367
+ data: Dict[str, Any],
368
+ context: Dict[str, Any] = None,
369
+ filters: Dict[str, Any] = None
370
+ ) -> AssessmentResult
371
+ ```
372
+
373
+ ### AssessmentResult
374
+
375
+ ```python
376
+ @dataclass
377
+ class AssessmentResult:
378
+ total_score: int # Total calculated score
379
+ decision: str # APPROVE, REVIEW, or FLAG
380
+ triggered_rules: List[RuleResult] # Rules that matched
381
+ processing_time_ms: float # Processing time in ms
382
+ ```
383
+
384
+ ## Full Example
385
+
386
+ ```python
387
+ from pylitmus import (
388
+ create_engine,
389
+ Rule,
390
+ Severity,
391
+ InMemoryRuleRepository,
392
+ WeightedStrategy,
393
+ )
394
+
395
+ # Define rules
396
+ rules = [
397
+ Rule(
398
+ code='AMT_HIGH',
399
+ name='High Amount',
400
+ description='Flag high-value transactions',
401
+ category='AMOUNT',
402
+ severity=Severity.HIGH,
403
+ score=60,
404
+ enabled=True,
405
+ conditions={'field': 'amount', 'operator': 'greater_than', 'value': 5000}
406
+ ),
407
+ Rule(
408
+ code='NEW_CUSTOMER',
409
+ name='New Customer',
410
+ description='Flag new customer transactions',
411
+ category='CUSTOMER',
412
+ severity=Severity.MEDIUM,
413
+ score=30,
414
+ enabled=True,
415
+ conditions={'field': 'is_new_customer', 'operator': 'equals', 'value': True}
416
+ ),
417
+ Rule(
418
+ code='INTL_TXN',
419
+ name='International Transaction',
420
+ description='Flag international transactions',
421
+ category='GEOGRAPHY',
422
+ severity=Severity.LOW,
423
+ score=20,
424
+ enabled=True,
425
+ conditions={'field': 'is_international', 'operator': 'equals', 'value': True}
426
+ ),
427
+ ]
428
+
429
+ # Create engine with weighted scoring
430
+ engine = create_engine(
431
+ rules=rules,
432
+ scoring_strategy='weighted',
433
+ decision_thresholds={'approve': 25, 'review': 60}
434
+ )
435
+
436
+ # Evaluate transaction
437
+ result = engine.evaluate({
438
+ 'amount': 6000,
439
+ 'is_new_customer': True,
440
+ 'is_international': False
441
+ })
442
+
443
+ print(f"Total Score: {result.total_score}")
444
+ print(f"Decision: {result.decision}")
445
+ print(f"Triggered Rules: {[r.rule_code for r in result.triggered_rules]}")
446
+ print(f"Processing Time: {result.processing_time_ms:.2f}ms")
447
+ ```
448
+
449
+ ## Documentation
450
+
451
+ - [Quick Start Guide](docs/quickstart.md)
452
+ - [Rule Format](docs/rules-format.md)
453
+ - [API Reference](docs/api-reference.md)
454
+ - [Flask Integration](docs/flask-integration.md)
455
+ - [Examples](examples/)
456
+
457
+ ## License
458
+
459
+ MIT License - see [LICENSE](LICENSE) for details.