detectkit 0.2.4__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.
- detectkit/__init__.py +17 -0
- detectkit/alerting/__init__.py +13 -0
- detectkit/alerting/channels/__init__.py +21 -0
- detectkit/alerting/channels/base.py +193 -0
- detectkit/alerting/channels/email.py +146 -0
- detectkit/alerting/channels/factory.py +193 -0
- detectkit/alerting/channels/mattermost.py +53 -0
- detectkit/alerting/channels/slack.py +55 -0
- detectkit/alerting/channels/telegram.py +110 -0
- detectkit/alerting/channels/webhook.py +139 -0
- detectkit/alerting/orchestrator.py +369 -0
- detectkit/cli/__init__.py +1 -0
- detectkit/cli/commands/__init__.py +1 -0
- detectkit/cli/commands/init.py +282 -0
- detectkit/cli/commands/run.py +486 -0
- detectkit/cli/commands/test_alert.py +184 -0
- detectkit/cli/main.py +186 -0
- detectkit/config/__init__.py +30 -0
- detectkit/config/metric_config.py +499 -0
- detectkit/config/profile.py +285 -0
- detectkit/config/project_config.py +164 -0
- detectkit/config/validator.py +124 -0
- detectkit/core/__init__.py +6 -0
- detectkit/core/interval.py +132 -0
- detectkit/core/models.py +106 -0
- detectkit/database/__init__.py +27 -0
- detectkit/database/clickhouse_manager.py +393 -0
- detectkit/database/internal_tables.py +724 -0
- detectkit/database/manager.py +324 -0
- detectkit/database/tables.py +138 -0
- detectkit/detectors/__init__.py +6 -0
- detectkit/detectors/base.py +441 -0
- detectkit/detectors/factory.py +138 -0
- detectkit/detectors/statistical/__init__.py +8 -0
- detectkit/detectors/statistical/iqr.py +508 -0
- detectkit/detectors/statistical/mad.py +478 -0
- detectkit/detectors/statistical/manual_bounds.py +206 -0
- detectkit/detectors/statistical/zscore.py +491 -0
- detectkit/loaders/__init__.py +6 -0
- detectkit/loaders/metric_loader.py +470 -0
- detectkit/loaders/query_template.py +164 -0
- detectkit/orchestration/__init__.py +9 -0
- detectkit/orchestration/task_manager.py +746 -0
- detectkit/utils/__init__.py +17 -0
- detectkit/utils/stats.py +196 -0
- detectkit-0.2.4.dist-info/METADATA +237 -0
- detectkit-0.2.4.dist-info/RECORD +51 -0
- detectkit-0.2.4.dist-info/WHEEL +5 -0
- detectkit-0.2.4.dist-info/entry_points.txt +2 -0
- detectkit-0.2.4.dist-info/licenses/LICENSE +21 -0
- detectkit-0.2.4.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Metric data loader.
|
|
3
|
+
|
|
4
|
+
Loads time-series data from databases with:
|
|
5
|
+
- SQL query execution (with Jinja2 templating)
|
|
6
|
+
- Gap filling for missing timestamps
|
|
7
|
+
- Seasonality feature extraction
|
|
8
|
+
- Batch processing
|
|
9
|
+
- Integration with InternalTablesManager
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from datetime import datetime, timedelta, timezone
|
|
13
|
+
from typing import Dict, List, Optional
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
import orjson
|
|
19
|
+
HAS_ORJSON = True
|
|
20
|
+
except ImportError:
|
|
21
|
+
import json
|
|
22
|
+
HAS_ORJSON = False
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def json_dumps_sorted(obj):
|
|
26
|
+
"""JSON dumps with sorted keys - handles both orjson and standard json."""
|
|
27
|
+
if HAS_ORJSON:
|
|
28
|
+
return orjson.dumps(obj, option=orjson.OPT_SORT_KEYS).decode('utf-8')
|
|
29
|
+
else:
|
|
30
|
+
return json.dumps(obj, sort_keys=True)
|
|
31
|
+
|
|
32
|
+
from detectkit.config.metric_config import MetricConfig
|
|
33
|
+
from detectkit.database.internal_tables import InternalTablesManager
|
|
34
|
+
from detectkit.database.manager import BaseDatabaseManager
|
|
35
|
+
from detectkit.loaders.query_template import QueryTemplate
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class MetricLoader:
|
|
39
|
+
"""
|
|
40
|
+
Loads metric data from database with preprocessing.
|
|
41
|
+
|
|
42
|
+
Features:
|
|
43
|
+
- Execute SQL queries with Jinja2 templating
|
|
44
|
+
- Fill gaps in time series
|
|
45
|
+
- Extract seasonality features (hour, day_of_week, etc.)
|
|
46
|
+
- Save to _dtk_datapoints table
|
|
47
|
+
- Batch processing for large datasets
|
|
48
|
+
|
|
49
|
+
Example:
|
|
50
|
+
>>> config = MetricConfig.from_yaml_file("metrics/cpu_usage.yml")
|
|
51
|
+
>>> manager = ClickHouseDatabaseManager(...)
|
|
52
|
+
>>> internal = InternalTablesManager(manager)
|
|
53
|
+
>>>
|
|
54
|
+
>>> loader = MetricLoader(config, manager, internal)
|
|
55
|
+
>>> data = loader.load(
|
|
56
|
+
... from_date=datetime(2024, 1, 1),
|
|
57
|
+
... to_date=datetime(2024, 1, 2)
|
|
58
|
+
... )
|
|
59
|
+
>>> loader.save(data)
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def __init__(
|
|
63
|
+
self,
|
|
64
|
+
config: MetricConfig,
|
|
65
|
+
db_manager: BaseDatabaseManager,
|
|
66
|
+
internal_manager: InternalTablesManager,
|
|
67
|
+
):
|
|
68
|
+
"""
|
|
69
|
+
Initialize metric loader.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
config: Metric configuration
|
|
73
|
+
db_manager: Database manager for executing queries
|
|
74
|
+
internal_manager: Internal tables manager for saving data
|
|
75
|
+
"""
|
|
76
|
+
self.config = config
|
|
77
|
+
self.db_manager = db_manager
|
|
78
|
+
self.internal_manager = internal_manager
|
|
79
|
+
self.query_template = QueryTemplate()
|
|
80
|
+
|
|
81
|
+
def load(
|
|
82
|
+
self,
|
|
83
|
+
from_date: datetime,
|
|
84
|
+
to_date: datetime,
|
|
85
|
+
fill_gaps: bool = True,
|
|
86
|
+
) -> Dict[str, np.ndarray]:
|
|
87
|
+
"""
|
|
88
|
+
Load metric data from database.
|
|
89
|
+
|
|
90
|
+
Steps:
|
|
91
|
+
1. Render SQL query with Jinja2
|
|
92
|
+
2. Execute query
|
|
93
|
+
3. Extract seasonality features
|
|
94
|
+
4. Fill gaps (if enabled)
|
|
95
|
+
5. Return as numpy arrays
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
from_date: Start date (inclusive)
|
|
99
|
+
to_date: End date (exclusive)
|
|
100
|
+
fill_gaps: Whether to fill missing timestamps with NULL
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
Dictionary with keys:
|
|
104
|
+
- timestamp: np.array of datetime64[ms]
|
|
105
|
+
- value: np.array of float64 (nullable)
|
|
106
|
+
- seasonality_data: np.array of JSON strings
|
|
107
|
+
- seasonality_columns: list of column names
|
|
108
|
+
|
|
109
|
+
Raises:
|
|
110
|
+
ValueError: If query returns invalid data
|
|
111
|
+
Exception: If query execution fails
|
|
112
|
+
|
|
113
|
+
Example:
|
|
114
|
+
>>> data = loader.load(
|
|
115
|
+
... datetime(2024, 1, 1),
|
|
116
|
+
... datetime(2024, 1, 2)
|
|
117
|
+
... )
|
|
118
|
+
>>> print(data["timestamp"])
|
|
119
|
+
>>> print(data["value"])
|
|
120
|
+
"""
|
|
121
|
+
# Normalize datetimes to naive (remove timezone info)
|
|
122
|
+
# ClickHouse returns naive datetimes, so we need to compare with naive
|
|
123
|
+
if from_date.tzinfo is not None:
|
|
124
|
+
from_date = from_date.replace(tzinfo=None)
|
|
125
|
+
if to_date.tzinfo is not None:
|
|
126
|
+
to_date = to_date.replace(tzinfo=None)
|
|
127
|
+
|
|
128
|
+
# Get interval
|
|
129
|
+
interval = self.config.get_interval()
|
|
130
|
+
interval_seconds = interval.seconds
|
|
131
|
+
|
|
132
|
+
# Render SQL query
|
|
133
|
+
query_text = self.config.get_query_text()
|
|
134
|
+
rendered_query = self.query_template.render(
|
|
135
|
+
query_text,
|
|
136
|
+
dtk_start_time=from_date,
|
|
137
|
+
dtk_end_time=to_date,
|
|
138
|
+
interval_seconds=interval_seconds,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
# Execute query
|
|
142
|
+
results = self.db_manager.execute_query(rendered_query)
|
|
143
|
+
|
|
144
|
+
if not results:
|
|
145
|
+
# No data - return empty arrays
|
|
146
|
+
return self._create_empty_result()
|
|
147
|
+
|
|
148
|
+
# Get column names from config (with defaults)
|
|
149
|
+
if self.config.query_columns:
|
|
150
|
+
timestamp_col = self.config.query_columns.timestamp
|
|
151
|
+
value_col = self.config.query_columns.metric
|
|
152
|
+
else:
|
|
153
|
+
# Default column names
|
|
154
|
+
timestamp_col = "timestamp"
|
|
155
|
+
value_col = "value"
|
|
156
|
+
|
|
157
|
+
# Filter results to exclude to_date (exclusive end)
|
|
158
|
+
# SQL queries often use BETWEEN which includes both boundaries,
|
|
159
|
+
# but our semantics are [from_date, to_date) - exclusive end
|
|
160
|
+
filtered_results = []
|
|
161
|
+
for row in results:
|
|
162
|
+
if timestamp_col not in row:
|
|
163
|
+
raise ValueError(
|
|
164
|
+
f"Query must return '{timestamp_col}' column "
|
|
165
|
+
f"(configured as timestamp column). "
|
|
166
|
+
f"Got columns: {list(row.keys())}"
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
# Filter by timestamp
|
|
170
|
+
row_ts = row[timestamp_col]
|
|
171
|
+
if isinstance(row_ts, datetime):
|
|
172
|
+
# Already datetime - compare directly
|
|
173
|
+
if row_ts >= to_date:
|
|
174
|
+
continue
|
|
175
|
+
else:
|
|
176
|
+
# Convert to datetime for comparison
|
|
177
|
+
row_dt = np.datetime64(row_ts, "ms").astype(datetime)
|
|
178
|
+
if row_dt >= to_date:
|
|
179
|
+
continue
|
|
180
|
+
|
|
181
|
+
filtered_results.append(row)
|
|
182
|
+
|
|
183
|
+
results = filtered_results
|
|
184
|
+
|
|
185
|
+
if not results:
|
|
186
|
+
# No data after filtering - return empty arrays
|
|
187
|
+
return self._create_empty_result()
|
|
188
|
+
|
|
189
|
+
# Convert results to numpy arrays
|
|
190
|
+
timestamps = []
|
|
191
|
+
values = []
|
|
192
|
+
|
|
193
|
+
for row in results:
|
|
194
|
+
if value_col not in row:
|
|
195
|
+
raise ValueError(
|
|
196
|
+
f"Query must return '{value_col}' column "
|
|
197
|
+
f"(configured as metric value column). "
|
|
198
|
+
f"Got columns: {list(row.keys())}"
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
timestamps.append(row[timestamp_col])
|
|
202
|
+
values.append(row[value_col])
|
|
203
|
+
|
|
204
|
+
# Convert to numpy
|
|
205
|
+
timestamp_array = np.array(timestamps, dtype="datetime64[ms]")
|
|
206
|
+
value_array = np.array(values, dtype=np.float64)
|
|
207
|
+
|
|
208
|
+
# Extract seasonality data BEFORE gap filling (from query results)
|
|
209
|
+
# This is needed because gap filling may add rows that don't exist in query results
|
|
210
|
+
seasonality_from_query = None
|
|
211
|
+
seasonality_columns_from_query = []
|
|
212
|
+
|
|
213
|
+
if self.config.query_columns and self.config.query_columns.seasonality:
|
|
214
|
+
# Query returns custom seasonality columns - extract them
|
|
215
|
+
seasonality_columns_from_query = self.config.query_columns.seasonality
|
|
216
|
+
seasonality_from_query = []
|
|
217
|
+
|
|
218
|
+
for row in results:
|
|
219
|
+
features = {}
|
|
220
|
+
for col in seasonality_columns_from_query:
|
|
221
|
+
if col not in row:
|
|
222
|
+
raise ValueError(
|
|
223
|
+
f"Query must return seasonality column '{col}' "
|
|
224
|
+
f"(configured in query_columns.seasonality). "
|
|
225
|
+
f"Got columns: {list(row.keys())}"
|
|
226
|
+
)
|
|
227
|
+
features[col] = row[col]
|
|
228
|
+
|
|
229
|
+
# Convert to JSON
|
|
230
|
+
seasonality_from_query.append(json_dumps_sorted(features))
|
|
231
|
+
|
|
232
|
+
seasonality_from_query = np.array(seasonality_from_query, dtype=object)
|
|
233
|
+
|
|
234
|
+
# Fill gaps if needed
|
|
235
|
+
if fill_gaps:
|
|
236
|
+
timestamp_array, value_array = self._fill_gaps(
|
|
237
|
+
timestamp_array, value_array, from_date, to_date, interval_seconds
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
# If we have seasonality from query, we need to fill gaps in it too
|
|
241
|
+
if seasonality_from_query is not None:
|
|
242
|
+
# For gap-filled rows, seasonality will be extracted from timestamp
|
|
243
|
+
# This is a simplified approach - we just use empty JSON for gaps
|
|
244
|
+
# In production, you might want to interpolate or use timestamp-based features
|
|
245
|
+
original_length = len(seasonality_from_query)
|
|
246
|
+
new_length = len(timestamp_array)
|
|
247
|
+
if new_length > original_length:
|
|
248
|
+
# We have gaps - pad with empty JSON
|
|
249
|
+
empty_json = json_dumps_sorted({})
|
|
250
|
+
seasonality_from_query = np.pad(
|
|
251
|
+
seasonality_from_query,
|
|
252
|
+
(0, new_length - original_length),
|
|
253
|
+
mode='constant',
|
|
254
|
+
constant_values=empty_json
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
# Determine final seasonality data and columns
|
|
258
|
+
if seasonality_from_query is not None:
|
|
259
|
+
# Use seasonality from query
|
|
260
|
+
seasonality_data = seasonality_from_query
|
|
261
|
+
seasonality_columns = seasonality_columns_from_query
|
|
262
|
+
else:
|
|
263
|
+
# Extract seasonality features from timestamps (standard behavior)
|
|
264
|
+
seasonality_data = self._extract_seasonality(
|
|
265
|
+
timestamp_array, self.config.seasonality_columns
|
|
266
|
+
)
|
|
267
|
+
seasonality_columns = self.config.seasonality_columns
|
|
268
|
+
|
|
269
|
+
return {
|
|
270
|
+
"timestamp": timestamp_array,
|
|
271
|
+
"value": value_array,
|
|
272
|
+
"seasonality_data": seasonality_data,
|
|
273
|
+
"seasonality_columns": seasonality_columns,
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
def save(self, data: Dict[str, np.ndarray]) -> int:
|
|
277
|
+
"""
|
|
278
|
+
Save loaded data to _dtk_datapoints table.
|
|
279
|
+
|
|
280
|
+
Args:
|
|
281
|
+
data: Data dictionary from load()
|
|
282
|
+
|
|
283
|
+
Returns:
|
|
284
|
+
Number of rows inserted
|
|
285
|
+
|
|
286
|
+
Example:
|
|
287
|
+
>>> data = loader.load(from_date, to_date)
|
|
288
|
+
>>> rows = loader.save(data)
|
|
289
|
+
>>> print(f"Saved {rows} data points")
|
|
290
|
+
"""
|
|
291
|
+
if len(data["timestamp"]) == 0:
|
|
292
|
+
return 0
|
|
293
|
+
|
|
294
|
+
interval = self.config.get_interval()
|
|
295
|
+
|
|
296
|
+
return self.internal_manager.save_datapoints(
|
|
297
|
+
metric_name=self.config.name,
|
|
298
|
+
data=data,
|
|
299
|
+
interval_seconds=interval.seconds,
|
|
300
|
+
seasonality_columns=data["seasonality_columns"],
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
def load_and_save(
|
|
304
|
+
self,
|
|
305
|
+
from_date: Optional[datetime] = None,
|
|
306
|
+
to_date: Optional[datetime] = None,
|
|
307
|
+
) -> int:
|
|
308
|
+
"""
|
|
309
|
+
Load and save data in one operation with batching.
|
|
310
|
+
|
|
311
|
+
If from_date is None, loads from last saved timestamp.
|
|
312
|
+
If to_date is None, loads until now.
|
|
313
|
+
|
|
314
|
+
Args:
|
|
315
|
+
from_date: Start date (if None, use last saved timestamp)
|
|
316
|
+
to_date: End date (if None, use now)
|
|
317
|
+
|
|
318
|
+
Returns:
|
|
319
|
+
Total number of rows inserted
|
|
320
|
+
|
|
321
|
+
Example:
|
|
322
|
+
>>> # Load from last saved point until now
|
|
323
|
+
>>> rows = loader.load_and_save()
|
|
324
|
+
>>>
|
|
325
|
+
>>> # Load specific range
|
|
326
|
+
>>> rows = loader.load_and_save(
|
|
327
|
+
... from_date=datetime(2024, 1, 1),
|
|
328
|
+
... to_date=datetime(2024, 1, 2)
|
|
329
|
+
... )
|
|
330
|
+
"""
|
|
331
|
+
# Determine date range
|
|
332
|
+
if from_date is None:
|
|
333
|
+
# Get last saved timestamp
|
|
334
|
+
last_ts = self.internal_manager.get_last_datapoint_timestamp(
|
|
335
|
+
self.config.name
|
|
336
|
+
)
|
|
337
|
+
if last_ts:
|
|
338
|
+
# Start from next interval after last timestamp
|
|
339
|
+
interval = self.config.get_interval()
|
|
340
|
+
from_date = last_ts + timedelta(seconds=interval.seconds)
|
|
341
|
+
else:
|
|
342
|
+
# No data yet - use loading_start_time from config if available
|
|
343
|
+
if self.config.loading_start_time:
|
|
344
|
+
# Parse loading_start_time string (format: "YYYY-MM-DD HH:MM:SS" in UTC)
|
|
345
|
+
from_date = datetime.strptime(
|
|
346
|
+
self.config.loading_start_time, "%Y-%m-%d %H:%M:%S"
|
|
347
|
+
).replace(tzinfo=timezone.utc)
|
|
348
|
+
else:
|
|
349
|
+
# No data and no loading_start_time - need to specify from_date
|
|
350
|
+
raise ValueError(
|
|
351
|
+
"No existing data for metric and no loading_start_time configured. "
|
|
352
|
+
"Please specify from_date for initial load or set loading_start_time in config."
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
if to_date is None:
|
|
356
|
+
to_date = datetime.now(timezone.utc)
|
|
357
|
+
|
|
358
|
+
# Load and save
|
|
359
|
+
data = self.load(from_date, to_date, fill_gaps=True)
|
|
360
|
+
return self.save(data)
|
|
361
|
+
|
|
362
|
+
def _create_empty_result(self) -> Dict[str, np.ndarray]:
|
|
363
|
+
"""Create empty result dictionary."""
|
|
364
|
+
return {
|
|
365
|
+
"timestamp": np.array([], dtype="datetime64[ms]"),
|
|
366
|
+
"value": np.array([], dtype=np.float64),
|
|
367
|
+
"seasonality_data": np.array([], dtype=object),
|
|
368
|
+
"seasonality_columns": self.config.seasonality_columns,
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
def _fill_gaps(
|
|
372
|
+
self,
|
|
373
|
+
timestamps: np.ndarray,
|
|
374
|
+
values: np.ndarray,
|
|
375
|
+
from_date: datetime,
|
|
376
|
+
to_date: datetime,
|
|
377
|
+
interval_seconds: int,
|
|
378
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
379
|
+
"""
|
|
380
|
+
Fill missing timestamps with NULL values.
|
|
381
|
+
|
|
382
|
+
Generates full timestamp range based on interval and fills
|
|
383
|
+
missing points.
|
|
384
|
+
|
|
385
|
+
Args:
|
|
386
|
+
timestamps: Existing timestamps
|
|
387
|
+
values: Existing values
|
|
388
|
+
from_date: Range start
|
|
389
|
+
to_date: Range end
|
|
390
|
+
interval_seconds: Interval in seconds
|
|
391
|
+
|
|
392
|
+
Returns:
|
|
393
|
+
Tuple of (filled_timestamps, filled_values)
|
|
394
|
+
"""
|
|
395
|
+
# Generate full timestamp range
|
|
396
|
+
start_ts = np.datetime64(from_date, "ms")
|
|
397
|
+
end_ts = np.datetime64(to_date, "ms")
|
|
398
|
+
interval_delta = np.timedelta64(interval_seconds, "s")
|
|
399
|
+
|
|
400
|
+
# Create full range
|
|
401
|
+
full_timestamps = np.arange(start_ts, end_ts, interval_delta)
|
|
402
|
+
|
|
403
|
+
if len(timestamps) == 0:
|
|
404
|
+
# No data at all - return full range with NaN
|
|
405
|
+
return full_timestamps, np.full(len(full_timestamps), np.nan)
|
|
406
|
+
|
|
407
|
+
# Create mapping from existing timestamps to values
|
|
408
|
+
ts_to_value = dict(zip(timestamps, values))
|
|
409
|
+
|
|
410
|
+
# Fill values for full range
|
|
411
|
+
filled_values = np.array(
|
|
412
|
+
[ts_to_value.get(ts, np.nan) for ts in full_timestamps],
|
|
413
|
+
dtype=np.float64,
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
return full_timestamps, filled_values
|
|
417
|
+
|
|
418
|
+
def _extract_seasonality(
|
|
419
|
+
self,
|
|
420
|
+
timestamps: np.ndarray,
|
|
421
|
+
seasonality_columns: List[str],
|
|
422
|
+
) -> np.ndarray:
|
|
423
|
+
"""
|
|
424
|
+
Extract seasonality features from timestamps.
|
|
425
|
+
|
|
426
|
+
Args:
|
|
427
|
+
timestamps: Array of datetime64 timestamps
|
|
428
|
+
seasonality_columns: List of features to extract
|
|
429
|
+
|
|
430
|
+
Returns:
|
|
431
|
+
Array of JSON strings with seasonality data
|
|
432
|
+
|
|
433
|
+
Supported features:
|
|
434
|
+
- hour: Hour of day (0-23)
|
|
435
|
+
- day_of_week: Day of week (0=Monday, 6=Sunday)
|
|
436
|
+
- day_of_month: Day of month (1-31)
|
|
437
|
+
- month: Month (1-12)
|
|
438
|
+
- is_weekend: Boolean (Saturday=5, Sunday=6)
|
|
439
|
+
- is_holiday: Boolean (requires holiday calendar - not implemented)
|
|
440
|
+
"""
|
|
441
|
+
if len(timestamps) == 0:
|
|
442
|
+
return np.array([], dtype=object)
|
|
443
|
+
|
|
444
|
+
seasonality_data = []
|
|
445
|
+
|
|
446
|
+
for ts in timestamps:
|
|
447
|
+
# Convert numpy datetime64 to Python datetime
|
|
448
|
+
ts_datetime = ts.astype("datetime64[s]").astype(datetime)
|
|
449
|
+
|
|
450
|
+
features = {}
|
|
451
|
+
|
|
452
|
+
for col in seasonality_columns:
|
|
453
|
+
if col == "hour":
|
|
454
|
+
features["hour"] = ts_datetime.hour
|
|
455
|
+
elif col == "day_of_week":
|
|
456
|
+
features["day_of_week"] = ts_datetime.weekday() # 0=Monday
|
|
457
|
+
elif col == "day_of_month":
|
|
458
|
+
features["day_of_month"] = ts_datetime.day
|
|
459
|
+
elif col == "month":
|
|
460
|
+
features["month"] = ts_datetime.month
|
|
461
|
+
elif col == "is_weekend":
|
|
462
|
+
features["is_weekend"] = ts_datetime.weekday() >= 5
|
|
463
|
+
elif col == "is_holiday":
|
|
464
|
+
# TODO: Implement holiday calendar
|
|
465
|
+
features["is_holiday"] = False
|
|
466
|
+
|
|
467
|
+
# Convert to JSON
|
|
468
|
+
seasonality_data.append(json_dumps_sorted(features))
|
|
469
|
+
|
|
470
|
+
return np.array(seasonality_data, dtype=object)
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SQL query templating with Jinja2.
|
|
3
|
+
|
|
4
|
+
Provides templating capabilities for SQL queries with built-in variables
|
|
5
|
+
and custom context.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from typing import Any, Dict, Optional
|
|
10
|
+
|
|
11
|
+
from jinja2 import Environment, StrictUndefined, Template, TemplateSyntaxError, Undefined, UndefinedError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class QueryTemplate:
|
|
15
|
+
"""
|
|
16
|
+
SQL query template renderer using Jinja2.
|
|
17
|
+
|
|
18
|
+
Supports:
|
|
19
|
+
- Variable substitution: {{ variable }}
|
|
20
|
+
- Conditionals: {% if condition %}...{% endif %}
|
|
21
|
+
- Loops: {% for item in items %}...{% endfor %}
|
|
22
|
+
- Built-in variables: from_date, to_date, interval_seconds
|
|
23
|
+
|
|
24
|
+
Example:
|
|
25
|
+
>>> template = QueryTemplate()
|
|
26
|
+
>>> query = "SELECT * FROM metrics WHERE timestamp >= '{{ from_date }}'"
|
|
27
|
+
>>> rendered = template.render(
|
|
28
|
+
... query,
|
|
29
|
+
... {"from_date": "2024-01-01", "to_date": "2024-01-02"}
|
|
30
|
+
... )
|
|
31
|
+
>>> print(rendered)
|
|
32
|
+
SELECT * FROM metrics WHERE timestamp >= '2024-01-01'
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(self, strict: bool = True):
|
|
36
|
+
"""
|
|
37
|
+
Initialize Jinja2 environment.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
strict: If True, raise error on undefined variables
|
|
41
|
+
"""
|
|
42
|
+
self._env = Environment(
|
|
43
|
+
autoescape=False, # Don't escape SQL
|
|
44
|
+
trim_blocks=True, # Remove newlines after blocks
|
|
45
|
+
lstrip_blocks=True, # Remove leading whitespace before blocks
|
|
46
|
+
undefined=StrictUndefined if strict else Undefined, # Raise on undefined vars
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
def render(
|
|
50
|
+
self,
|
|
51
|
+
query: str,
|
|
52
|
+
context: Optional[Dict[str, Any]] = None,
|
|
53
|
+
dtk_start_time: Optional[datetime] = None,
|
|
54
|
+
dtk_end_time: Optional[datetime] = None,
|
|
55
|
+
interval_seconds: Optional[int] = None,
|
|
56
|
+
) -> str:
|
|
57
|
+
"""
|
|
58
|
+
Render SQL query template with context.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
query: SQL query template string
|
|
62
|
+
context: Custom variables for template
|
|
63
|
+
dtk_start_time: Built-in variable for time range start
|
|
64
|
+
dtk_end_time: Built-in variable for time range end
|
|
65
|
+
interval_seconds: Built-in variable for interval
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
Rendered SQL query
|
|
69
|
+
|
|
70
|
+
Raises:
|
|
71
|
+
TemplateSyntaxError: If template syntax is invalid
|
|
72
|
+
Exception: If template rendering fails
|
|
73
|
+
|
|
74
|
+
Example:
|
|
75
|
+
>>> template = QueryTemplate()
|
|
76
|
+
>>> query = '''
|
|
77
|
+
... SELECT
|
|
78
|
+
... timestamp,
|
|
79
|
+
... value
|
|
80
|
+
... FROM {{ table_name }}
|
|
81
|
+
... WHERE timestamp >= '{{ dtk_start_time }}'
|
|
82
|
+
... AND timestamp < '{{ dtk_end_time }}'
|
|
83
|
+
... {% if interval_seconds %}
|
|
84
|
+
... AND interval = {{ interval_seconds }}
|
|
85
|
+
... {% endif %}
|
|
86
|
+
... '''
|
|
87
|
+
>>> rendered = template.render(
|
|
88
|
+
... query,
|
|
89
|
+
... context={"table_name": "cpu_metrics"},
|
|
90
|
+
... dtk_start_time=datetime(2024, 1, 1),
|
|
91
|
+
... dtk_end_time=datetime(2024, 1, 2),
|
|
92
|
+
... interval_seconds=600
|
|
93
|
+
... )
|
|
94
|
+
"""
|
|
95
|
+
# Build context with built-in and custom variables
|
|
96
|
+
template_context = {}
|
|
97
|
+
|
|
98
|
+
# Add built-in variables (format datetime to string for SQL compatibility)
|
|
99
|
+
if dtk_start_time is not None:
|
|
100
|
+
# Format as ISO 8601 "YYYY-MM-DD HH:MM:SS" - works in ClickHouse, PostgreSQL, MySQL, SQLite
|
|
101
|
+
template_context["dtk_start_time"] = dtk_start_time.strftime("%Y-%m-%d %H:%M:%S")
|
|
102
|
+
if dtk_end_time is not None:
|
|
103
|
+
template_context["dtk_end_time"] = dtk_end_time.strftime("%Y-%m-%d %H:%M:%S")
|
|
104
|
+
if interval_seconds is not None:
|
|
105
|
+
template_context["interval_seconds"] = interval_seconds
|
|
106
|
+
|
|
107
|
+
# Add custom variables (overwrites built-ins if conflict)
|
|
108
|
+
if context:
|
|
109
|
+
template_context.update(context)
|
|
110
|
+
|
|
111
|
+
# Compile and render template
|
|
112
|
+
try:
|
|
113
|
+
template = self._env.from_string(query)
|
|
114
|
+
return template.render(template_context)
|
|
115
|
+
except TemplateSyntaxError as e:
|
|
116
|
+
raise TemplateSyntaxError(
|
|
117
|
+
f"Invalid template syntax: {e.message}", e.lineno
|
|
118
|
+
)
|
|
119
|
+
except Exception as e:
|
|
120
|
+
raise Exception(f"Template rendering failed: {e}")
|
|
121
|
+
|
|
122
|
+
def render_with_defaults(
|
|
123
|
+
self,
|
|
124
|
+
query: str,
|
|
125
|
+
context: Optional[Dict[str, Any]] = None,
|
|
126
|
+
dtk_start_time: Optional[datetime] = None,
|
|
127
|
+
dtk_end_time: Optional[datetime] = None,
|
|
128
|
+
interval_seconds: Optional[int] = None,
|
|
129
|
+
) -> str:
|
|
130
|
+
"""
|
|
131
|
+
Render query with sensible defaults for missing variables.
|
|
132
|
+
|
|
133
|
+
This is useful for testing and development where you might not
|
|
134
|
+
have all variables defined. Use {{ var | default('value') }} syntax
|
|
135
|
+
in templates.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
query: SQL query template
|
|
139
|
+
context: Custom variables
|
|
140
|
+
dtk_start_time: Start time
|
|
141
|
+
dtk_end_time: End time
|
|
142
|
+
interval_seconds: Interval in seconds
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
Rendered SQL query
|
|
146
|
+
|
|
147
|
+
Example:
|
|
148
|
+
>>> template = QueryTemplate()
|
|
149
|
+
>>> # Use default filter for missing variables
|
|
150
|
+
>>> query = "SELECT * FROM {{ table | default('metrics') }}"
|
|
151
|
+
>>> rendered = template.render_with_defaults(query)
|
|
152
|
+
>>> print(rendered)
|
|
153
|
+
SELECT * FROM metrics
|
|
154
|
+
"""
|
|
155
|
+
# Create non-strict environment for this render
|
|
156
|
+
non_strict_template = QueryTemplate(strict=False)
|
|
157
|
+
|
|
158
|
+
return non_strict_template.render(
|
|
159
|
+
query,
|
|
160
|
+
context=context,
|
|
161
|
+
dtk_start_time=dtk_start_time,
|
|
162
|
+
dtk_end_time=dtk_end_time,
|
|
163
|
+
interval_seconds=interval_seconds,
|
|
164
|
+
)
|