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,78 @@
1
+ """
2
+ Base repository class for rule storage.
3
+ """
4
+
5
+ from abc import ABC, abstractmethod
6
+ from typing import Any, Dict, List, Optional
7
+
8
+ from ..types import Rule
9
+
10
+
11
+ class RuleRepository(ABC):
12
+ """Abstract base for rule storage backends."""
13
+
14
+ @abstractmethod
15
+ def get_all(self, filters: Optional[Dict[str, Any]] = None) -> List[Rule]:
16
+ """
17
+ Get all rules, optionally filtered.
18
+
19
+ Args:
20
+ filters: Optional filters to apply
21
+
22
+ Returns:
23
+ List of rules
24
+ """
25
+ pass
26
+
27
+ @abstractmethod
28
+ def get_by_code(self, code: str) -> Optional[Rule]:
29
+ """
30
+ Get a specific rule by code.
31
+
32
+ Args:
33
+ code: Rule code
34
+
35
+ Returns:
36
+ Rule if found, None otherwise
37
+ """
38
+ pass
39
+
40
+ @abstractmethod
41
+ def save(self, rule: Rule) -> Rule:
42
+ """
43
+ Save a rule (create or update).
44
+
45
+ Args:
46
+ rule: Rule to save
47
+
48
+ Returns:
49
+ Saved rule
50
+ """
51
+ pass
52
+
53
+ @abstractmethod
54
+ def delete(self, code: str) -> bool:
55
+ """
56
+ Delete a rule by code.
57
+
58
+ Args:
59
+ code: Rule code
60
+
61
+ Returns:
62
+ True if deleted, False otherwise
63
+ """
64
+ pass
65
+
66
+ def get_enabled(self, filters: Optional[Dict[str, Any]] = None) -> List[Rule]:
67
+ """
68
+ Get only enabled rules.
69
+
70
+ Args:
71
+ filters: Additional filters
72
+
73
+ Returns:
74
+ List of enabled rules
75
+ """
76
+ all_filters = filters.copy() if filters else {}
77
+ all_filters["enabled"] = True
78
+ return self.get_all(all_filters)
@@ -0,0 +1,167 @@
1
+ """
2
+ Cached rule repository decorator implementation.
3
+ """
4
+
5
+ import json
6
+ import time
7
+ from typing import Any, Dict, List, Optional
8
+
9
+ from ..types import Rule
10
+ from .base import RuleRepository
11
+
12
+
13
+ class CachedRuleRepository(RuleRepository):
14
+ """Decorator that adds caching to any repository."""
15
+
16
+ def __init__(
17
+ self,
18
+ repository: RuleRepository,
19
+ cache_backend: str = "memory",
20
+ cache_url: Optional[str] = None,
21
+ ttl_seconds: int = 300,
22
+ ):
23
+ """
24
+ Initialize cached repository.
25
+
26
+ Args:
27
+ repository: The underlying repository to cache
28
+ cache_backend: 'memory' or 'redis'
29
+ cache_url: Redis URL (for redis backend)
30
+ ttl_seconds: Cache TTL in seconds
31
+ """
32
+ self._repository = repository
33
+ self._ttl = ttl_seconds
34
+ self._cache_backend = cache_backend
35
+
36
+ if cache_backend == "redis":
37
+ try:
38
+ import redis
39
+
40
+ self._cache = redis.from_url(cache_url or "redis://localhost:6379/0")
41
+ self._is_redis = True
42
+ except ImportError:
43
+ raise ImportError("redis package required for redis cache backend")
44
+ else:
45
+ self._cache: Dict[str, Any] = {}
46
+ self._cache_expiry: Dict[str, float] = {}
47
+ self._is_redis = False
48
+
49
+ def _make_key(
50
+ self, operation: str, filters: Optional[Dict[str, Any]] = None
51
+ ) -> str:
52
+ """Create cache key."""
53
+ key = f"rules:{operation}"
54
+ if filters:
55
+ key += f":{json.dumps(filters, sort_keys=True)}"
56
+ return key
57
+
58
+ def _get_cached(self, key: str) -> Optional[List[Rule]]:
59
+ """Get value from cache."""
60
+ if self._is_redis:
61
+ data = self._cache.get(key)
62
+ if data:
63
+ return self._deserialize_rules(json.loads(data))
64
+ return None
65
+ else:
66
+ # Check expiry for memory cache
67
+ if key in self._cache:
68
+ if time.time() < self._cache_expiry.get(key, 0):
69
+ return self._cache[key]
70
+ else:
71
+ # Expired
72
+ del self._cache[key]
73
+ if key in self._cache_expiry:
74
+ del self._cache_expiry[key]
75
+ return None
76
+
77
+ def _set_cached(self, key: str, rules: List[Rule]) -> None:
78
+ """Set value in cache."""
79
+ if self._is_redis:
80
+ data = json.dumps(self._serialize_rules(rules))
81
+ self._cache.setex(key, self._ttl, data)
82
+ else:
83
+ self._cache[key] = rules
84
+ self._cache_expiry[key] = time.time() + self._ttl
85
+
86
+ def _serialize_rules(self, rules: List[Rule]) -> List[Dict[str, Any]]:
87
+ """Serialize rules for caching."""
88
+ return [
89
+ {
90
+ "code": r.code,
91
+ "name": r.name,
92
+ "description": r.description,
93
+ "category": r.category,
94
+ "severity": r.severity.value,
95
+ "score": r.score,
96
+ "enabled": r.enabled,
97
+ "conditions": r.conditions,
98
+ "version": r.version,
99
+ "metadata": r.metadata,
100
+ }
101
+ for r in rules
102
+ ]
103
+
104
+ def _deserialize_rules(self, data: List[Dict[str, Any]]) -> List[Rule]:
105
+ """Deserialize rules from cache."""
106
+ from ..types import Severity
107
+
108
+ return [
109
+ Rule(
110
+ code=r["code"],
111
+ name=r["name"],
112
+ description=r["description"],
113
+ category=r["category"],
114
+ severity=Severity(r["severity"]),
115
+ score=r["score"],
116
+ enabled=r["enabled"],
117
+ conditions=r["conditions"],
118
+ version=r["version"],
119
+ metadata=r["metadata"],
120
+ )
121
+ for r in data
122
+ ]
123
+
124
+ def get_all(self, filters: Optional[Dict[str, Any]] = None) -> List[Rule]:
125
+ """Get all rules, with caching."""
126
+ cache_key = self._make_key("all", filters)
127
+
128
+ cached = self._get_cached(cache_key)
129
+ if cached is not None:
130
+ return cached
131
+
132
+ rules = self._repository.get_all(filters)
133
+ self._set_cached(cache_key, rules)
134
+ return rules
135
+
136
+ def get_by_code(self, code: str) -> Optional[Rule]:
137
+ """Get a specific rule by code (not cached, delegates to repository)."""
138
+ return self._repository.get_by_code(code)
139
+
140
+ def save(self, rule: Rule) -> Rule:
141
+ """Save a rule and invalidate cache."""
142
+ result = self._repository.save(rule)
143
+ self.invalidate()
144
+ return result
145
+
146
+ def delete(self, code: str) -> bool:
147
+ """Delete a rule and invalidate cache."""
148
+ result = self._repository.delete(code)
149
+ if result:
150
+ self.invalidate()
151
+ return result
152
+
153
+ def invalidate(self) -> None:
154
+ """Clear the cache."""
155
+ if self._is_redis:
156
+ # Scan and delete all rules:* keys
157
+ for key in self._cache.scan_iter(match="rules:*"):
158
+ self._cache.delete(key)
159
+ else:
160
+ self._cache.clear()
161
+ self._cache_expiry.clear()
162
+
163
+ def get_enabled(self, filters: Optional[Dict[str, Any]] = None) -> List[Rule]:
164
+ """Get only enabled rules, with caching."""
165
+ all_filters = filters.copy() if filters else {}
166
+ all_filters["enabled"] = True
167
+ return self.get_all(all_filters)
@@ -0,0 +1,181 @@
1
+ """
2
+ Database rule repository implementation using SQLAlchemy.
3
+ """
4
+
5
+ from typing import Any, Callable, Dict, List, Optional, Type
6
+
7
+ from ..exceptions import StorageError
8
+ from ..types import Rule, Severity
9
+ from .base import RuleRepository
10
+
11
+
12
+ class DatabaseRuleRepository(RuleRepository):
13
+ """SQLAlchemy-based rule storage."""
14
+
15
+ def __init__(
16
+ self,
17
+ session_factory: Callable,
18
+ model_class: Optional[Type] = None,
19
+ table_name: str = "rules",
20
+ ):
21
+ """
22
+ Initialize database repository.
23
+
24
+ Args:
25
+ session_factory: Callable that returns a database session
26
+ model_class: SQLAlchemy model class for rules (optional)
27
+ table_name: Name of rules table (if not using model_class)
28
+ """
29
+ self.session_factory = session_factory
30
+ self.model_class = model_class
31
+ self.table_name = table_name
32
+
33
+ def get_all(self, filters: Optional[Dict[str, Any]] = None) -> List[Rule]:
34
+ """Get all rules, optionally filtered."""
35
+ try:
36
+ with self.session_factory() as session:
37
+ if self.model_class:
38
+ query = session.query(self.model_class)
39
+ if filters:
40
+ query = self._apply_model_filters(query, filters)
41
+ return [self._model_to_rule(m) for m in query.all()]
42
+ else:
43
+ # Raw SQL fallback
44
+ return self._get_all_raw(session, filters)
45
+ except Exception as e:
46
+ raise StorageError(f"Failed to get rules from database: {e}") from e
47
+
48
+ def get_by_code(self, code: str) -> Optional[Rule]:
49
+ """Get a specific rule by code."""
50
+ try:
51
+ with self.session_factory() as session:
52
+ if self.model_class:
53
+ model = session.query(self.model_class).filter_by(code=code).first()
54
+ return self._model_to_rule(model) if model else None
55
+ else:
56
+ return self._get_by_code_raw(session, code)
57
+ except Exception as e:
58
+ raise StorageError(f"Failed to get rule {code} from database: {e}") from e
59
+
60
+ def save(self, rule: Rule) -> Rule:
61
+ """Save a rule (create or update)."""
62
+ try:
63
+ with self.session_factory() as session:
64
+ if self.model_class:
65
+ existing = (
66
+ session.query(self.model_class)
67
+ .filter_by(code=rule.code)
68
+ .first()
69
+ )
70
+ if existing:
71
+ self._update_model(existing, rule)
72
+ else:
73
+ model = self._rule_to_model(rule)
74
+ session.add(model)
75
+ session.commit()
76
+ else:
77
+ self._save_raw(session, rule)
78
+ return rule
79
+ except Exception as e:
80
+ raise StorageError(
81
+ f"Failed to save rule {rule.code} to database: {e}"
82
+ ) from e
83
+
84
+ def delete(self, code: str) -> bool:
85
+ """Delete a rule by code."""
86
+ try:
87
+ with self.session_factory() as session:
88
+ if self.model_class:
89
+ model = session.query(self.model_class).filter_by(code=code).first()
90
+ if model:
91
+ session.delete(model)
92
+ session.commit()
93
+ return True
94
+ return False
95
+ else:
96
+ return self._delete_raw(session, code)
97
+ except Exception as e:
98
+ raise StorageError(
99
+ f"Failed to delete rule {code} from database: {e}"
100
+ ) from e
101
+
102
+ def _model_to_rule(self, model: Any) -> Rule:
103
+ """Convert SQLAlchemy model to Rule object."""
104
+ severity_value = model.severity
105
+ if isinstance(severity_value, str):
106
+ severity = Severity(severity_value.upper())
107
+ else:
108
+ severity = severity_value
109
+
110
+ return Rule(
111
+ code=model.code,
112
+ name=model.name,
113
+ description=getattr(model, "description", ""),
114
+ category=getattr(model, "category", "DEFAULT"),
115
+ severity=severity,
116
+ score=getattr(model, "score", 0),
117
+ enabled=getattr(model, "enabled", True),
118
+ conditions=getattr(model, "conditions", {}),
119
+ version=getattr(model, "version", 1),
120
+ metadata=getattr(model, "metadata", {}),
121
+ )
122
+
123
+ def _rule_to_model(self, rule: Rule) -> Any:
124
+ """Convert Rule to SQLAlchemy model."""
125
+ if not self.model_class:
126
+ raise StorageError("No model class configured")
127
+
128
+ return self.model_class(
129
+ code=rule.code,
130
+ name=rule.name,
131
+ description=rule.description,
132
+ category=rule.category,
133
+ severity=rule.severity.value,
134
+ score=rule.score,
135
+ enabled=rule.enabled,
136
+ conditions=rule.conditions,
137
+ version=rule.version,
138
+ metadata=rule.metadata,
139
+ )
140
+
141
+ def _update_model(self, model: Any, rule: Rule) -> None:
142
+ """Update model from Rule."""
143
+ model.name = rule.name
144
+ model.description = rule.description
145
+ model.category = rule.category
146
+ model.severity = rule.severity.value
147
+ model.score = rule.score
148
+ model.enabled = rule.enabled
149
+ model.conditions = rule.conditions
150
+ model.version = rule.version
151
+ model.metadata = rule.metadata
152
+
153
+ def _apply_model_filters(self, query: Any, filters: Dict[str, Any]) -> Any:
154
+ """Apply filters to SQLAlchemy query."""
155
+ if "enabled" in filters:
156
+ query = query.filter(self.model_class.enabled == filters["enabled"])
157
+ if "category" in filters:
158
+ query = query.filter(self.model_class.category == filters["category"])
159
+ if "severity" in filters:
160
+ query = query.filter(self.model_class.severity == filters["severity"].value)
161
+ return query
162
+
163
+ # Raw SQL fallback methods (for when no model class is provided)
164
+ def _get_all_raw(
165
+ self, session: Any, filters: Optional[Dict[str, Any]]
166
+ ) -> List[Rule]:
167
+ """Get all rules using raw SQL."""
168
+ # This is a placeholder - actual implementation depends on DB setup
169
+ raise NotImplementedError("Raw SQL queries require model_class to be specified")
170
+
171
+ def _get_by_code_raw(self, session: Any, code: str) -> Optional[Rule]:
172
+ """Get rule by code using raw SQL."""
173
+ raise NotImplementedError("Raw SQL queries require model_class to be specified")
174
+
175
+ def _save_raw(self, session: Any, rule: Rule) -> None:
176
+ """Save rule using raw SQL."""
177
+ raise NotImplementedError("Raw SQL queries require model_class to be specified")
178
+
179
+ def _delete_raw(self, session: Any, code: str) -> bool:
180
+ """Delete rule using raw SQL."""
181
+ raise NotImplementedError("Raw SQL queries require model_class to be specified")
@@ -0,0 +1,143 @@
1
+ """
2
+ File-based rule repository implementation.
3
+ """
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any, Dict, List, Optional
8
+
9
+ import yaml
10
+
11
+ from ..exceptions import StorageError
12
+ from ..types import Rule, Severity
13
+ from .base import RuleRepository
14
+
15
+
16
+ class FileRuleRepository(RuleRepository):
17
+ """YAML/JSON file-based rule storage."""
18
+
19
+ def __init__(self, file_path: str):
20
+ """
21
+ Initialize file repository.
22
+
23
+ Args:
24
+ file_path: Path to rules file (YAML or JSON)
25
+ """
26
+ self.file_path = Path(file_path)
27
+ self._rules: Dict[str, Rule] = {}
28
+ self._load_rules()
29
+
30
+ def _load_rules(self) -> None:
31
+ """Load rules from file."""
32
+ if not self.file_path.exists():
33
+ return
34
+
35
+ try:
36
+ with open(self.file_path) as f:
37
+ if self.file_path.suffix in [".yaml", ".yml"]:
38
+ data = yaml.safe_load(f)
39
+ else:
40
+ data = json.load(f)
41
+
42
+ if data and "rules" in data:
43
+ for rule_data in data["rules"]:
44
+ rule = self._dict_to_rule(rule_data)
45
+ self._rules[rule.code] = rule
46
+ except Exception as e:
47
+ raise StorageError(
48
+ f"Failed to load rules from {self.file_path}: {e}"
49
+ ) from e
50
+
51
+ def _dict_to_rule(self, data: Dict[str, Any]) -> Rule:
52
+ """Convert dictionary to Rule object."""
53
+ severity_str = data.get("severity", "MEDIUM")
54
+ if isinstance(severity_str, str):
55
+ severity = Severity(severity_str.upper())
56
+ else:
57
+ severity = severity_str
58
+
59
+ return Rule(
60
+ code=data["code"],
61
+ name=data["name"],
62
+ description=data.get("description", ""),
63
+ category=data.get("category", "DEFAULT"),
64
+ severity=severity,
65
+ score=data.get("score", 0),
66
+ enabled=data.get("enabled", True),
67
+ conditions=data.get("conditions", {}),
68
+ version=data.get("version", 1),
69
+ metadata=data.get("metadata", {}),
70
+ )
71
+
72
+ def _rule_to_dict(self, rule: Rule) -> Dict[str, Any]:
73
+ """Convert Rule object to dictionary."""
74
+ return {
75
+ "code": rule.code,
76
+ "name": rule.name,
77
+ "description": rule.description,
78
+ "category": rule.category,
79
+ "severity": rule.severity.value,
80
+ "score": rule.score,
81
+ "enabled": rule.enabled,
82
+ "conditions": rule.conditions,
83
+ "version": rule.version,
84
+ "metadata": rule.metadata,
85
+ }
86
+
87
+ def get_all(self, filters: Optional[Dict[str, Any]] = None) -> List[Rule]:
88
+ """Get all rules, optionally filtered."""
89
+ rules = list(self._rules.values())
90
+ if filters:
91
+ rules = self._apply_filters(rules, filters)
92
+ return rules
93
+
94
+ def get_by_code(self, code: str) -> Optional[Rule]:
95
+ """Get a specific rule by code."""
96
+ return self._rules.get(code)
97
+
98
+ def save(self, rule: Rule) -> Rule:
99
+ """Save a rule (updates in-memory, writes to file)."""
100
+ self._rules[rule.code] = rule
101
+ self._save_to_file()
102
+ return rule
103
+
104
+ def delete(self, code: str) -> bool:
105
+ """Delete a rule by code."""
106
+ if code in self._rules:
107
+ del self._rules[code]
108
+ self._save_to_file()
109
+ return True
110
+ return False
111
+
112
+ def _save_to_file(self) -> None:
113
+ """Save rules to file."""
114
+ data = {"rules": [self._rule_to_dict(r) for r in self._rules.values()]}
115
+
116
+ try:
117
+ with open(self.file_path, "w") as f:
118
+ if self.file_path.suffix in [".yaml", ".yml"]:
119
+ yaml.safe_dump(data, f, default_flow_style=False)
120
+ else:
121
+ json.dump(data, f, indent=2)
122
+ except Exception as e:
123
+ raise StorageError(f"Failed to save rules to {self.file_path}: {e}") from e
124
+
125
+ def _apply_filters(self, rules: List[Rule], filters: Dict[str, Any]) -> List[Rule]:
126
+ """Apply filters to rule list."""
127
+ result = rules
128
+
129
+ if "enabled" in filters:
130
+ result = [r for r in result if r.enabled == filters["enabled"]]
131
+
132
+ if "category" in filters:
133
+ result = [r for r in result if r.category == filters["category"]]
134
+
135
+ if "severity" in filters:
136
+ result = [r for r in result if r.severity == filters["severity"]]
137
+
138
+ return result
139
+
140
+ def reload(self) -> None:
141
+ """Reload rules from file."""
142
+ self._rules.clear()
143
+ self._load_rules()
@@ -0,0 +1,107 @@
1
+ """
2
+ In-memory rule repository implementation.
3
+ """
4
+
5
+ from typing import Any, Dict, List, Optional
6
+
7
+ from ..types import Rule
8
+ from .base import RuleRepository
9
+
10
+
11
+ class InMemoryRuleRepository(RuleRepository):
12
+ """In-memory rule storage for testing and simple use cases."""
13
+
14
+ def __init__(self, rules: Optional[List[Rule]] = None):
15
+ """
16
+ Initialize in-memory repository.
17
+
18
+ Args:
19
+ rules: Optional list of rules to initialize with
20
+ """
21
+ self._rules: Dict[str, Rule] = {}
22
+ if rules:
23
+ for rule in rules:
24
+ self._rules[rule.code] = rule
25
+
26
+ def get_all(self, filters: Optional[Dict[str, Any]] = None) -> List[Rule]:
27
+ """
28
+ Get all rules, optionally filtered.
29
+
30
+ Args:
31
+ filters: Optional filters to apply
32
+
33
+ Returns:
34
+ List of rules
35
+ """
36
+ rules = list(self._rules.values())
37
+ if filters:
38
+ rules = self._apply_filters(rules, filters)
39
+ return rules
40
+
41
+ def get_by_code(self, code: str) -> Optional[Rule]:
42
+ """
43
+ Get a specific rule by code.
44
+
45
+ Args:
46
+ code: Rule code
47
+
48
+ Returns:
49
+ Rule if found, None otherwise
50
+ """
51
+ return self._rules.get(code)
52
+
53
+ def save(self, rule: Rule) -> Rule:
54
+ """
55
+ Save a rule (create or update).
56
+
57
+ Args:
58
+ rule: Rule to save
59
+
60
+ Returns:
61
+ Saved rule
62
+ """
63
+ self._rules[rule.code] = rule
64
+ return rule
65
+
66
+ def delete(self, code: str) -> bool:
67
+ """
68
+ Delete a rule by code.
69
+
70
+ Args:
71
+ code: Rule code
72
+
73
+ Returns:
74
+ True if deleted, False otherwise
75
+ """
76
+ if code in self._rules:
77
+ del self._rules[code]
78
+ return True
79
+ return False
80
+
81
+ def _apply_filters(self, rules: List[Rule], filters: Dict[str, Any]) -> List[Rule]:
82
+ """
83
+ Apply filters to rule list.
84
+
85
+ Args:
86
+ rules: List of rules to filter
87
+ filters: Filters to apply
88
+
89
+ Returns:
90
+ Filtered list of rules
91
+ """
92
+ result = rules
93
+
94
+ if "enabled" in filters:
95
+ result = [r for r in result if r.enabled == filters["enabled"]]
96
+
97
+ if "category" in filters:
98
+ result = [r for r in result if r.category == filters["category"]]
99
+
100
+ if "severity" in filters:
101
+ result = [r for r in result if r.severity == filters["severity"]]
102
+
103
+ return result
104
+
105
+ def clear(self) -> None:
106
+ """Clear all rules from the repository."""
107
+ self._rules.clear()
@@ -0,0 +1,15 @@
1
+ """
2
+ Scoring strategies module for the CMAP Rules Engine.
3
+ """
4
+
5
+ from .base import ScoringStrategy
6
+ from .max import MaxStrategy
7
+ from .sum import SumStrategy
8
+ from .weighted import WeightedStrategy
9
+
10
+ __all__ = [
11
+ "ScoringStrategy",
12
+ "SumStrategy",
13
+ "WeightedStrategy",
14
+ "MaxStrategy",
15
+ ]