detectkit 0.1.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.
Files changed (49) hide show
  1. detectkit/__init__.py +17 -0
  2. detectkit/alerting/__init__.py +13 -0
  3. detectkit/alerting/channels/__init__.py +21 -0
  4. detectkit/alerting/channels/base.py +191 -0
  5. detectkit/alerting/channels/email.py +146 -0
  6. detectkit/alerting/channels/factory.py +193 -0
  7. detectkit/alerting/channels/mattermost.py +53 -0
  8. detectkit/alerting/channels/slack.py +55 -0
  9. detectkit/alerting/channels/telegram.py +110 -0
  10. detectkit/alerting/channels/webhook.py +139 -0
  11. detectkit/alerting/orchestrator.py +368 -0
  12. detectkit/cli/__init__.py +1 -0
  13. detectkit/cli/commands/__init__.py +1 -0
  14. detectkit/cli/commands/init.py +282 -0
  15. detectkit/cli/commands/run.py +427 -0
  16. detectkit/cli/commands/test_alert.py +184 -0
  17. detectkit/cli/main.py +186 -0
  18. detectkit/config/__init__.py +30 -0
  19. detectkit/config/metric_config.py +467 -0
  20. detectkit/config/profile.py +285 -0
  21. detectkit/config/project_config.py +164 -0
  22. detectkit/core/__init__.py +6 -0
  23. detectkit/core/interval.py +132 -0
  24. detectkit/core/models.py +106 -0
  25. detectkit/database/__init__.py +27 -0
  26. detectkit/database/clickhouse_manager.py +385 -0
  27. detectkit/database/internal_tables.py +581 -0
  28. detectkit/database/manager.py +324 -0
  29. detectkit/database/tables.py +134 -0
  30. detectkit/detectors/__init__.py +6 -0
  31. detectkit/detectors/base.py +222 -0
  32. detectkit/detectors/factory.py +138 -0
  33. detectkit/detectors/statistical/__init__.py +8 -0
  34. detectkit/detectors/statistical/iqr.py +230 -0
  35. detectkit/detectors/statistical/mad.py +423 -0
  36. detectkit/detectors/statistical/manual_bounds.py +177 -0
  37. detectkit/detectors/statistical/zscore.py +225 -0
  38. detectkit/loaders/__init__.py +6 -0
  39. detectkit/loaders/metric_loader.py +470 -0
  40. detectkit/loaders/query_template.py +164 -0
  41. detectkit/orchestration/__init__.py +9 -0
  42. detectkit/orchestration/task_manager.py +698 -0
  43. detectkit/utils/__init__.py +1 -0
  44. detectkit-0.1.0.dist-info/METADATA +231 -0
  45. detectkit-0.1.0.dist-info/RECORD +49 -0
  46. detectkit-0.1.0.dist-info/WHEEL +5 -0
  47. detectkit-0.1.0.dist-info/entry_points.txt +2 -0
  48. detectkit-0.1.0.dist-info/licenses/LICENSE +21 -0
  49. detectkit-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,110 @@
1
+ """
2
+ Telegram alert channel implementation.
3
+
4
+ Sends anomaly alerts via Telegram Bot API.
5
+ """
6
+
7
+ from typing import Any, Dict, Optional
8
+
9
+ import requests
10
+
11
+ from detectkit.alerting.channels.base import AlertData, BaseAlertChannel
12
+
13
+
14
+ class TelegramChannel(BaseAlertChannel):
15
+ """
16
+ Telegram alert channel using Bot API.
17
+
18
+ Sends formatted messages to Telegram chat using bot token.
19
+
20
+ Attributes:
21
+ bot_token: Telegram bot token (from @BotFather)
22
+ chat_id: Target chat ID (user, group, or channel)
23
+ parse_mode: Message parse mode ("Markdown", "HTML", or None)
24
+ disable_notification: Send silently without notification
25
+
26
+ Example:
27
+ >>> channel = TelegramChannel(
28
+ ... bot_token="123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11",
29
+ ... chat_id="-1001234567890"
30
+ ... )
31
+ >>> alert = AlertData(
32
+ ... metric_name="cpu_usage",
33
+ ... timestamp=np.datetime64("2024-01-01T10:00:00"),
34
+ ... value=95.0,
35
+ ... is_anomaly=True
36
+ ... )
37
+ >>> channel.send(alert)
38
+ """
39
+
40
+ def __init__(
41
+ self,
42
+ bot_token: str,
43
+ chat_id: str,
44
+ parse_mode: str = "Markdown",
45
+ disable_notification: bool = False,
46
+ template: Optional[str] = None,
47
+ **kwargs,
48
+ ):
49
+ """
50
+ Initialize Telegram channel.
51
+
52
+ Args:
53
+ bot_token: Telegram bot token from @BotFather
54
+ chat_id: Target chat ID (can be user_id, @channel_name, or group ID)
55
+ parse_mode: Message formatting ("Markdown", "HTML", or None)
56
+ disable_notification: Send silently without notification sound
57
+ template: Custom message template (optional)
58
+ **kwargs: Additional parameters (ignored)
59
+
60
+ Raises:
61
+ ValueError: If bot_token or chat_id is missing
62
+ """
63
+ if not bot_token:
64
+ raise ValueError("bot_token is required for TelegramChannel")
65
+ if not chat_id:
66
+ raise ValueError("chat_id is required for TelegramChannel")
67
+
68
+ self.bot_token = bot_token
69
+ self.chat_id = chat_id
70
+ self.parse_mode = parse_mode
71
+ self.disable_notification = disable_notification
72
+ self.template = template
73
+
74
+ def send(self, alert_data: AlertData) -> None:
75
+ """
76
+ Send alert to Telegram.
77
+
78
+ Args:
79
+ alert_data: Alert information to send
80
+
81
+ Raises:
82
+ requests.RequestException: If request fails
83
+ requests.HTTPError: If Telegram API returns error
84
+
85
+ Example:
86
+ >>> channel.send(alert_data)
87
+ """
88
+ message = self.format_message(alert_data, self.template)
89
+
90
+ # Telegram Bot API URL
91
+ url = f"https://api.telegram.org/bot{self.bot_token}/sendMessage"
92
+
93
+ payload = {
94
+ "chat_id": self.chat_id,
95
+ "text": message,
96
+ "disable_notification": self.disable_notification,
97
+ }
98
+
99
+ if self.parse_mode:
100
+ payload["parse_mode"] = self.parse_mode
101
+
102
+ try:
103
+ response = requests.post(url, json=payload, timeout=10)
104
+ response.raise_for_status()
105
+ except requests.RequestException as e:
106
+ raise requests.RequestException(f"Failed to send Telegram alert: {e}")
107
+
108
+ def __repr__(self) -> str:
109
+ """String representation."""
110
+ return f"TelegramChannel(chat_id={self.chat_id})"
@@ -0,0 +1,139 @@
1
+ """
2
+ Generic webhook alert channel.
3
+
4
+ Sends alerts to any webhook endpoint that accepts JSON payload.
5
+ Compatible with Mattermost, Slack, and other webhook-based systems.
6
+ """
7
+
8
+ from typing import Dict, Optional
9
+
10
+ import requests
11
+
12
+ from detectkit.alerting.channels.base import AlertData, BaseAlertChannel
13
+
14
+
15
+ class WebhookChannel(BaseAlertChannel):
16
+ """
17
+ Generic webhook alert channel.
18
+
19
+ Sends formatted alert messages to any webhook URL with JSON payload.
20
+ Compatible with:
21
+ - Mattermost incoming webhooks
22
+ - Slack incoming webhooks
23
+ - Custom webhook endpoints
24
+
25
+ The payload format is compatible with Mattermost/Slack:
26
+ {
27
+ "text": "message",
28
+ "username": "bot_name",
29
+ "icon_emoji": ":emoji:",
30
+ "channel": "#channel" (optional)
31
+ }
32
+
33
+ Parameters:
34
+ webhook_url (str): Webhook URL to send alerts to
35
+ username (str): Bot username to display (default: "detectk")
36
+ icon_emoji (str): Bot emoji icon (default: ":warning:")
37
+ channel (str): Target channel (optional, for Slack/Mattermost)
38
+ timeout (int): Request timeout in seconds (default: 10)
39
+ extra_headers (dict): Additional HTTP headers (optional)
40
+
41
+ Example:
42
+ >>> # Mattermost
43
+ >>> channel = WebhookChannel(
44
+ ... webhook_url="https://mattermost.example.com/hooks/xxx"
45
+ ... )
46
+ >>>
47
+ >>> # Slack
48
+ >>> channel = WebhookChannel(
49
+ ... webhook_url="https://hooks.slack.com/services/xxx",
50
+ ... channel="#alerts"
51
+ ... )
52
+ >>>
53
+ >>> # Custom webhook
54
+ >>> channel = WebhookChannel(
55
+ ... webhook_url="https://custom.example.com/webhook",
56
+ ... extra_headers={"Authorization": "Bearer token"}
57
+ ... )
58
+ """
59
+
60
+ def __init__(
61
+ self,
62
+ webhook_url: str,
63
+ username: str = "detectk",
64
+ icon_emoji: str = ":warning:",
65
+ channel: Optional[str] = None,
66
+ timeout: int = 10,
67
+ extra_headers: Optional[Dict[str, str]] = None,
68
+ ):
69
+ """Initialize webhook channel."""
70
+ if not webhook_url:
71
+ raise ValueError("webhook_url is required")
72
+
73
+ self.webhook_url = webhook_url
74
+ self.username = username
75
+ self.icon_emoji = icon_emoji
76
+ self.channel = channel
77
+ self.timeout = timeout
78
+ self.extra_headers = extra_headers or {}
79
+
80
+ def send(
81
+ self,
82
+ alert_data: AlertData,
83
+ template: Optional[str] = None,
84
+ ) -> bool:
85
+ """
86
+ Send alert to webhook.
87
+
88
+ Args:
89
+ alert_data: Alert data to send
90
+ template: Optional custom message template
91
+
92
+ Returns:
93
+ True if sent successfully, False otherwise
94
+
95
+ Raises:
96
+ requests.RequestException: If request fails critically
97
+
98
+ Example:
99
+ >>> channel = WebhookChannel(webhook_url="https://...")
100
+ >>> success = channel.send(alert_data)
101
+ """
102
+ # Format message
103
+ message = self.format_message(alert_data, template)
104
+
105
+ # Prepare payload (Mattermost/Slack compatible format)
106
+ payload = {
107
+ "text": message,
108
+ "username": self.username,
109
+ "icon_emoji": self.icon_emoji,
110
+ }
111
+
112
+ # Add channel if specified (for Slack)
113
+ if self.channel:
114
+ payload["channel"] = self.channel
115
+
116
+ # Prepare headers
117
+ headers = {"Content-Type": "application/json"}
118
+ headers.update(self.extra_headers)
119
+
120
+ # Send to webhook
121
+ try:
122
+ response = requests.post(
123
+ self.webhook_url,
124
+ json=payload,
125
+ headers=headers,
126
+ timeout=self.timeout,
127
+ )
128
+ response.raise_for_status()
129
+ return True
130
+ except requests.RequestException as e:
131
+ # Log error but don't crash
132
+ print(f"Failed to send webhook alert: {e}")
133
+ return False
134
+
135
+ def __repr__(self) -> str:
136
+ """String representation."""
137
+ url_preview = self.webhook_url[:30] + "..." if len(self.webhook_url) > 30 else self.webhook_url
138
+ channel_info = f", channel='{self.channel}'" if self.channel else ""
139
+ return f"WebhookChannel(url='{url_preview}', username='{self.username}'{channel_info})"
@@ -0,0 +1,368 @@
1
+ """
2
+ Alert orchestrator for coordinating detection and alerting.
3
+
4
+ Handles:
5
+ - Checking consecutive anomaly logic
6
+ - Direction matching
7
+ - Multiple detector aggregation (min_detectors)
8
+ - Loading recent detection results
9
+ - Coordinating alert sending
10
+ """
11
+
12
+ from dataclasses import dataclass
13
+ from datetime import datetime, timezone
14
+ from typing import Dict, List, Optional
15
+
16
+ import numpy as np
17
+
18
+ from detectkit.alerting.channels.base import AlertData, BaseAlertChannel
19
+ from detectkit.core.interval import Interval
20
+
21
+
22
+ @dataclass
23
+ class AlertConditions:
24
+ """Alert conditions configuration."""
25
+
26
+ min_detectors: int = 1 # Minimum detectors needed for alert
27
+ direction: str = "any" # "any", "same", "up", "down"
28
+ consecutive_anomalies: int = 1 # Number of consecutive anomalies required
29
+
30
+
31
+ @dataclass
32
+ class DetectionRecord:
33
+ """Record of a detection result from database."""
34
+
35
+ timestamp: np.datetime64
36
+ detector_name: str
37
+ detector_id: str
38
+ value: float
39
+ is_anomaly: bool
40
+ confidence_lower: Optional[float]
41
+ confidence_upper: Optional[float]
42
+ direction: str # "up", "down", "none"
43
+ severity: float
44
+ detection_metadata: Dict
45
+
46
+
47
+ class AlertOrchestrator:
48
+ """
49
+ Orchestrates the alert decision and sending process.
50
+
51
+ Responsibilities:
52
+ - Load recent detection results from database
53
+ - Check consecutive anomaly conditions
54
+ - Check direction matching
55
+ - Aggregate multiple detectors (min_detectors)
56
+ - Send alerts through configured channels
57
+
58
+ Example:
59
+ >>> orchestrator = AlertOrchestrator(
60
+ ... metric_name="cpu_usage",
61
+ ... interval=Interval.parse("10min"),
62
+ ... conditions=AlertConditions(consecutive_anomalies=3, direction="same")
63
+ ... )
64
+ >>> should_alert, alert_data = orchestrator.should_alert(recent_detections)
65
+ >>> if should_alert:
66
+ ... orchestrator.send_alerts(alert_data, channels)
67
+ """
68
+
69
+ def __init__(
70
+ self,
71
+ metric_name: str,
72
+ interval: Interval,
73
+ conditions: Optional[AlertConditions] = None,
74
+ timezone_display: str = "UTC",
75
+ ):
76
+ """
77
+ Initialize alert orchestrator.
78
+
79
+ Args:
80
+ metric_name: Name of the metric
81
+ interval: Metric interval
82
+ conditions: Alert conditions (defaults to AlertConditions())
83
+ timezone_display: Timezone for alert display (default: UTC)
84
+ """
85
+ self.metric_name = metric_name
86
+ self.interval = interval
87
+ self.conditions = conditions or AlertConditions()
88
+ self.timezone_display = timezone_display
89
+
90
+ def should_alert(
91
+ self,
92
+ recent_detections: List[DetectionRecord],
93
+ ) -> tuple[bool, Optional[AlertData]]:
94
+ """
95
+ Determine if alert should be sent based on recent detections.
96
+
97
+ Args:
98
+ recent_detections: List of recent detection records (sorted by time, newest first)
99
+
100
+ Returns:
101
+ Tuple of (should_alert, alert_data)
102
+ - should_alert: True if alert should be sent
103
+ - alert_data: AlertData if should_alert=True, None otherwise
104
+
105
+ Logic:
106
+ 1. Check if enough detectors triggered (min_detectors)
107
+ 2. Check consecutive anomalies with direction matching
108
+ 3. Return decision and formatted AlertData
109
+ """
110
+ if not recent_detections:
111
+ return False, None
112
+
113
+ # Group detections by timestamp
114
+ detections_by_time = self._group_by_timestamp(recent_detections)
115
+
116
+ # Check from newest to oldest
117
+ timestamps_sorted = sorted(detections_by_time.keys(), reverse=True)
118
+
119
+ # Check min_detectors for the latest point
120
+ latest_timestamp = timestamps_sorted[0]
121
+ latest_detections = detections_by_time[latest_timestamp]
122
+
123
+ # Filter anomalies
124
+ latest_anomalies = [d for d in latest_detections if d.is_anomaly]
125
+
126
+ if len(latest_anomalies) < self.conditions.min_detectors:
127
+ return False, None
128
+
129
+ # Check consecutive anomalies
130
+ consecutive_count = self._count_consecutive_anomalies(
131
+ detections_by_time, timestamps_sorted
132
+ )
133
+
134
+ if consecutive_count < self.conditions.consecutive_anomalies:
135
+ return False, None
136
+
137
+ # Build AlertData from latest anomalies
138
+ # If multiple detectors, aggregate them
139
+ alert_data = self._build_alert_data(
140
+ latest_anomalies, consecutive_count
141
+ )
142
+
143
+ return True, alert_data
144
+
145
+ def _group_by_timestamp(
146
+ self, detections: List[DetectionRecord]
147
+ ) -> Dict[np.datetime64, List[DetectionRecord]]:
148
+ """Group detection records by timestamp."""
149
+ grouped = {}
150
+ for detection in detections:
151
+ if detection.timestamp not in grouped:
152
+ grouped[detection.timestamp] = []
153
+ grouped[detection.timestamp].append(detection)
154
+ return grouped
155
+
156
+ def _count_consecutive_anomalies(
157
+ self,
158
+ detections_by_time: Dict[np.datetime64, List[DetectionRecord]],
159
+ timestamps_sorted: List[np.datetime64],
160
+ ) -> int:
161
+ """
162
+ Count consecutive anomalies matching direction condition.
163
+
164
+ Args:
165
+ detections_by_time: Detections grouped by timestamp
166
+ timestamps_sorted: Timestamps in descending order (newest first)
167
+
168
+ Returns:
169
+ Number of consecutive anomalies
170
+
171
+ Logic:
172
+ - direction="any": Count any anomalies
173
+ - direction="same": Count anomalies in same direction (resets on change)
174
+ - direction="up": Count only "up" anomalies
175
+ - direction="down": Count only "down" anomalies
176
+ """
177
+ direction_condition = self.conditions.direction
178
+ consecutive = 0
179
+ prev_direction = None
180
+
181
+ for timestamp in timestamps_sorted:
182
+ detections = detections_by_time[timestamp]
183
+
184
+ # Check if enough detectors found anomaly
185
+ anomalies = [d for d in detections if d.is_anomaly]
186
+ if len(anomalies) < self.conditions.min_detectors:
187
+ break
188
+
189
+ # Determine dominant direction (use first detector's direction)
190
+ current_direction = anomalies[0].direction
191
+
192
+ # Check direction matching
193
+ if direction_condition == "any":
194
+ consecutive += 1
195
+ elif direction_condition == "same":
196
+ if prev_direction is None:
197
+ consecutive = 1
198
+ prev_direction = current_direction
199
+ elif current_direction == prev_direction:
200
+ consecutive += 1
201
+ else:
202
+ # Direction changed, stop counting
203
+ break
204
+ elif direction_condition == "up":
205
+ if current_direction == "up":
206
+ consecutive += 1
207
+ else:
208
+ break
209
+ elif direction_condition == "down":
210
+ if current_direction == "down":
211
+ consecutive += 1
212
+ else:
213
+ break
214
+ else:
215
+ # Unknown direction condition
216
+ consecutive += 1
217
+
218
+ return consecutive
219
+
220
+ def _build_alert_data(
221
+ self,
222
+ anomalies: List[DetectionRecord],
223
+ consecutive_count: int,
224
+ ) -> AlertData:
225
+ """
226
+ Build AlertData from anomalous detections.
227
+
228
+ Args:
229
+ anomalies: List of anomalous detections for the latest point
230
+ consecutive_count: Number of consecutive anomalies
231
+
232
+ Returns:
233
+ AlertData for sending
234
+ """
235
+ # Use first detector for primary info (if multiple, we'll note it)
236
+ primary = anomalies[0]
237
+
238
+ # If multiple detectors, aggregate info
239
+ if len(anomalies) > 1:
240
+ # Take the worst severity
241
+ max_severity = max(d.severity for d in anomalies)
242
+ detector_names = [d.detector_name for d in anomalies]
243
+ detector_name = f"{len(anomalies)} detectors"
244
+ detector_params_list = [
245
+ f"{d.detector_name}({d.detector_id[:8]})" for d in anomalies
246
+ ]
247
+ detector_params = ", ".join(detector_params_list)
248
+
249
+ # Combine metadata
250
+ combined_metadata = {
251
+ "detectors": detector_names,
252
+ "count": len(anomalies),
253
+ }
254
+ for i, d in enumerate(anomalies):
255
+ combined_metadata[f"detector_{i}_metadata"] = d.detection_metadata
256
+ else:
257
+ max_severity = primary.severity
258
+ detector_name = primary.detector_name
259
+ detector_params = f"{primary.detector_id[:16]}"
260
+ combined_metadata = primary.detection_metadata
261
+
262
+ # Convert numpy timestamp for AlertData
263
+ timestamp = primary.timestamp
264
+
265
+ return AlertData(
266
+ metric_name=self.metric_name,
267
+ timestamp=timestamp,
268
+ timezone=self.timezone_display,
269
+ value=primary.value,
270
+ confidence_lower=primary.confidence_lower,
271
+ confidence_upper=primary.confidence_upper,
272
+ detector_name=detector_name,
273
+ detector_params=detector_params,
274
+ direction=primary.direction,
275
+ severity=max_severity,
276
+ detection_metadata=combined_metadata,
277
+ consecutive_count=consecutive_count,
278
+ )
279
+
280
+ def send_alerts(
281
+ self,
282
+ alert_data: AlertData,
283
+ channels: List[BaseAlertChannel],
284
+ template: Optional[str] = None,
285
+ ) -> Dict[str, bool]:
286
+ """
287
+ Send alerts through all configured channels.
288
+
289
+ Args:
290
+ alert_data: Alert data to send
291
+ channels: List of alert channels
292
+ template: Optional custom message template
293
+
294
+ Returns:
295
+ Dict mapping channel name to success status
296
+
297
+ Example:
298
+ >>> results = orchestrator.send_alerts(
299
+ ... alert_data,
300
+ ... channels=[mattermost, slack],
301
+ ... template="ALERT: {metric_name} = {value}"
302
+ ... )
303
+ >>> print(results)
304
+ {'MattermostChannel': True, 'SlackChannel': True}
305
+ """
306
+ results = {}
307
+
308
+ for channel in channels:
309
+ try:
310
+ success = channel.send(alert_data, template)
311
+ channel_name = channel.__class__.__name__
312
+ results[channel_name] = success
313
+ except Exception as e:
314
+ channel_name = channel.__class__.__name__
315
+ print(f"Error sending alert via {channel_name}: {e}")
316
+ results[channel_name] = False
317
+
318
+ return results
319
+
320
+ def get_last_complete_point(self, now: Optional[datetime] = None) -> datetime:
321
+ """
322
+ Determine the last complete time point for the metric.
323
+
324
+ Args:
325
+ now: Current time (default: datetime.now(timezone.utc))
326
+
327
+ Returns:
328
+ Last complete timestamp
329
+
330
+ Logic:
331
+ - Floor current time to interval boundary
332
+ - Subtract one interval to get last complete point
333
+ - Example: now=13:23, interval=10min -> 13:10
334
+
335
+ Example:
336
+ >>> orchestrator = AlertOrchestrator("metric", Interval.parse("10min"))
337
+ >>> now = datetime(2024, 1, 1, 13, 23, 0, tzinfo=timezone.utc)
338
+ >>> last_point = orchestrator.get_last_complete_point(now)
339
+ >>> print(last_point)
340
+ 2024-01-01 13:10:00+00:00
341
+ """
342
+ if now is None:
343
+ now = datetime.now(timezone.utc)
344
+
345
+ # Ensure UTC
346
+ if now.tzinfo is None:
347
+ now = now.replace(tzinfo=timezone.utc)
348
+
349
+ # Floor to interval
350
+ interval_seconds = self.interval.seconds
351
+ timestamp_seconds = int(now.timestamp())
352
+ floored_seconds = (timestamp_seconds // interval_seconds) * interval_seconds
353
+
354
+ # Subtract one interval to get last complete point
355
+ last_complete_seconds = floored_seconds - interval_seconds
356
+
357
+ return datetime.fromtimestamp(last_complete_seconds, tz=timezone.utc)
358
+
359
+ def __repr__(self) -> str:
360
+ """String representation."""
361
+ return (
362
+ f"AlertOrchestrator("
363
+ f"metric='{self.metric_name}', "
364
+ f"interval={self.interval}, "
365
+ f"min_detectors={self.conditions.min_detectors}, "
366
+ f"direction='{self.conditions.direction}', "
367
+ f"consecutive={self.conditions.consecutive_anomalies})"
368
+ )
@@ -0,0 +1 @@
1
+ """Command-line interface for detectk."""
@@ -0,0 +1 @@
1
+ """CLI command implementations."""