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.
Files changed (51) 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 +193 -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 +369 -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 +486 -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 +499 -0
  20. detectkit/config/profile.py +285 -0
  21. detectkit/config/project_config.py +164 -0
  22. detectkit/config/validator.py +124 -0
  23. detectkit/core/__init__.py +6 -0
  24. detectkit/core/interval.py +132 -0
  25. detectkit/core/models.py +106 -0
  26. detectkit/database/__init__.py +27 -0
  27. detectkit/database/clickhouse_manager.py +393 -0
  28. detectkit/database/internal_tables.py +724 -0
  29. detectkit/database/manager.py +324 -0
  30. detectkit/database/tables.py +138 -0
  31. detectkit/detectors/__init__.py +6 -0
  32. detectkit/detectors/base.py +441 -0
  33. detectkit/detectors/factory.py +138 -0
  34. detectkit/detectors/statistical/__init__.py +8 -0
  35. detectkit/detectors/statistical/iqr.py +508 -0
  36. detectkit/detectors/statistical/mad.py +478 -0
  37. detectkit/detectors/statistical/manual_bounds.py +206 -0
  38. detectkit/detectors/statistical/zscore.py +491 -0
  39. detectkit/loaders/__init__.py +6 -0
  40. detectkit/loaders/metric_loader.py +470 -0
  41. detectkit/loaders/query_template.py +164 -0
  42. detectkit/orchestration/__init__.py +9 -0
  43. detectkit/orchestration/task_manager.py +746 -0
  44. detectkit/utils/__init__.py +17 -0
  45. detectkit/utils/stats.py +196 -0
  46. detectkit-0.2.4.dist-info/METADATA +237 -0
  47. detectkit-0.2.4.dist-info/RECORD +51 -0
  48. detectkit-0.2.4.dist-info/WHEEL +5 -0
  49. detectkit-0.2.4.dist-info/entry_points.txt +2 -0
  50. detectkit-0.2.4.dist-info/licenses/LICENSE +21 -0
  51. detectkit-0.2.4.dist-info/top_level.txt +1 -0
@@ -0,0 +1,285 @@
1
+ """
2
+ Profile configuration for detectk.
3
+
4
+ Manages database connections and locations (similar to dbt profiles).
5
+ """
6
+
7
+ from pathlib import Path
8
+ from typing import Any, Dict, Optional
9
+
10
+ import yaml
11
+ from pydantic import BaseModel, Field, field_validator
12
+
13
+ from detectkit.database.clickhouse_manager import ClickHouseDatabaseManager
14
+ from detectkit.database.manager import BaseDatabaseManager
15
+
16
+
17
+ class ProfileConfig(BaseModel):
18
+ """
19
+ Single profile configuration.
20
+
21
+ Defines connection parameters and database locations for a specific
22
+ environment (dev, prod, etc.).
23
+
24
+ Attributes:
25
+ type: Database type ("clickhouse", "postgres", "mysql")
26
+ host: Database host
27
+ port: Database port
28
+ user: Database user
29
+ password: Database password
30
+ internal_database: Database/schema for internal tables
31
+ internal_schema: Schema for internal tables (PostgreSQL only)
32
+ data_database: Database for user data tables
33
+ data_schema: Schema for user data (PostgreSQL only)
34
+ settings: Additional database-specific settings
35
+ """
36
+
37
+ type: str = Field(..., description="Database type")
38
+ host: str = Field(default="localhost", description="Database host")
39
+ port: int = Field(..., description="Database port")
40
+ user: str = Field(default="default", description="Database user")
41
+ password: str = Field(default="", description="Database password")
42
+
43
+ # Internal location for _dtk_* tables
44
+ internal_database: Optional[str] = Field(
45
+ default=None,
46
+ description="Database for internal tables (ClickHouse/MySQL)"
47
+ )
48
+ internal_schema: Optional[str] = Field(
49
+ default=None,
50
+ description="Schema for internal tables (PostgreSQL)"
51
+ )
52
+
53
+ # Data location for user tables
54
+ data_database: Optional[str] = Field(
55
+ default=None,
56
+ description="Database for user data tables (ClickHouse/MySQL)"
57
+ )
58
+ data_schema: Optional[str] = Field(
59
+ default=None,
60
+ description="Schema for user data (PostgreSQL)"
61
+ )
62
+
63
+ settings: Dict[str, Any] = Field(
64
+ default_factory=dict,
65
+ description="Additional database settings"
66
+ )
67
+
68
+ @field_validator("type")
69
+ @classmethod
70
+ def validate_type(cls, v: str) -> str:
71
+ """Validate database type."""
72
+ allowed_types = {"clickhouse", "postgres", "mysql"}
73
+ if v not in allowed_types:
74
+ raise ValueError(
75
+ f"Invalid database type: {v}. "
76
+ f"Allowed types: {', '.join(allowed_types)}"
77
+ )
78
+ return v
79
+
80
+ @field_validator("port")
81
+ @classmethod
82
+ def validate_port(cls, v: int) -> int:
83
+ """Validate port number."""
84
+ if not (1 <= v <= 65535):
85
+ raise ValueError(f"Port must be between 1 and 65535, got {v}")
86
+ return v
87
+
88
+ def get_internal_location(self) -> str:
89
+ """
90
+ Get internal location (database or schema).
91
+
92
+ Returns:
93
+ Internal database/schema name
94
+
95
+ Raises:
96
+ ValueError: If location not configured
97
+ """
98
+ if self.type == "clickhouse":
99
+ if not self.internal_database:
100
+ raise ValueError("internal_database must be set for ClickHouse")
101
+ return self.internal_database
102
+ elif self.type == "postgres":
103
+ if not self.internal_schema:
104
+ raise ValueError("internal_schema must be set for PostgreSQL")
105
+ return self.internal_schema
106
+ elif self.type == "mysql":
107
+ if not self.internal_database:
108
+ raise ValueError("internal_database must be set for MySQL")
109
+ return self.internal_database
110
+ else:
111
+ raise ValueError(f"Unsupported database type: {self.type}")
112
+
113
+ def get_data_location(self) -> str:
114
+ """
115
+ Get data location (database or schema).
116
+
117
+ Returns:
118
+ Data database/schema name
119
+
120
+ Raises:
121
+ ValueError: If location not configured
122
+ """
123
+ if self.type == "clickhouse":
124
+ if not self.data_database:
125
+ raise ValueError("data_database must be set for ClickHouse")
126
+ return self.data_database
127
+ elif self.type == "postgres":
128
+ if not self.data_schema:
129
+ raise ValueError("data_schema must be set for PostgreSQL")
130
+ return self.data_schema
131
+ elif self.type == "mysql":
132
+ if not self.data_database:
133
+ raise ValueError("data_database must be set for MySQL")
134
+ return self.data_database
135
+ else:
136
+ raise ValueError(f"Unsupported database type: {self.type}")
137
+
138
+ def create_manager(self) -> BaseDatabaseManager:
139
+ """
140
+ Create database manager from profile configuration.
141
+
142
+ Returns:
143
+ Database manager instance
144
+
145
+ Raises:
146
+ NotImplementedError: If database type not yet implemented
147
+ """
148
+ if self.type == "clickhouse":
149
+ return ClickHouseDatabaseManager(
150
+ host=self.host,
151
+ port=self.port,
152
+ user=self.user,
153
+ password=self.password,
154
+ internal_database=self.get_internal_location(),
155
+ data_database=self.get_data_location(),
156
+ settings=self.settings,
157
+ )
158
+ elif self.type == "postgres":
159
+ raise NotImplementedError("PostgreSQL support coming soon")
160
+ elif self.type == "mysql":
161
+ raise NotImplementedError("MySQL support coming soon")
162
+ else:
163
+ raise ValueError(f"Unsupported database type: {self.type}")
164
+
165
+
166
+ class ProfilesConfig(BaseModel):
167
+ """
168
+ Container for multiple profile configurations.
169
+
170
+ Loaded from profiles.yml file.
171
+
172
+ Attributes:
173
+ profiles: Dictionary mapping profile names to configurations
174
+ default_profile: Name of default profile to use
175
+ alert_channels: Dictionary mapping channel names to configurations
176
+ """
177
+
178
+ profiles: Dict[str, ProfileConfig]
179
+ default_profile: Optional[str] = None
180
+ alert_channels: Dict[str, Dict[str, Any]] = Field(
181
+ default_factory=dict, description="Alert channel configurations"
182
+ )
183
+
184
+ @field_validator("default_profile")
185
+ @classmethod
186
+ def validate_default_profile(cls, v: Optional[str], info) -> Optional[str]:
187
+ """Validate default profile exists."""
188
+ if v is not None:
189
+ profiles = info.data.get("profiles", {})
190
+ if v not in profiles:
191
+ raise ValueError(
192
+ f"default_profile '{v}' not found in profiles. "
193
+ f"Available profiles: {', '.join(profiles.keys())}"
194
+ )
195
+ return v
196
+
197
+ @classmethod
198
+ def from_yaml(cls, path: Path) -> "ProfilesConfig":
199
+ """
200
+ Load profiles from YAML file.
201
+
202
+ Args:
203
+ path: Path to profiles.yml
204
+
205
+ Returns:
206
+ ProfilesConfig instance
207
+
208
+ Raises:
209
+ FileNotFoundError: If file doesn't exist
210
+ ValueError: If YAML is invalid
211
+ """
212
+ if not path.exists():
213
+ raise FileNotFoundError(f"Profiles file not found: {path}")
214
+
215
+ with open(path, "r") as f:
216
+ data = yaml.safe_load(f)
217
+
218
+ if not data:
219
+ raise ValueError("Profiles file is empty")
220
+
221
+ return cls.model_validate(data)
222
+
223
+ def get_profile(self, name: Optional[str] = None) -> ProfileConfig:
224
+ """
225
+ Get profile configuration by name.
226
+
227
+ Args:
228
+ name: Profile name (if None, use default_profile)
229
+
230
+ Returns:
231
+ ProfileConfig instance
232
+
233
+ Raises:
234
+ ValueError: If profile not found or no default set
235
+ """
236
+ if name is None:
237
+ if self.default_profile is None:
238
+ raise ValueError(
239
+ "No profile name specified and no default_profile set. "
240
+ f"Available profiles: {', '.join(self.profiles.keys())}"
241
+ )
242
+ name = self.default_profile
243
+
244
+ if name not in self.profiles:
245
+ raise ValueError(
246
+ f"Profile '{name}' not found. "
247
+ f"Available profiles: {', '.join(self.profiles.keys())}"
248
+ )
249
+
250
+ return self.profiles[name]
251
+
252
+ def create_manager(self, profile_name: Optional[str] = None) -> BaseDatabaseManager:
253
+ """
254
+ Create database manager for a profile.
255
+
256
+ Args:
257
+ profile_name: Profile name (if None, use default)
258
+
259
+ Returns:
260
+ Database manager instance
261
+ """
262
+ profile = self.get_profile(profile_name)
263
+ return profile.create_manager()
264
+
265
+ def get_alert_channel_config(self, channel_name: str) -> Dict[str, Any]:
266
+ """
267
+ Get alert channel configuration by name.
268
+
269
+ Args:
270
+ channel_name: Channel name
271
+
272
+ Returns:
273
+ Channel configuration dictionary
274
+
275
+ Raises:
276
+ ValueError: If channel not found
277
+ """
278
+ if channel_name not in self.alert_channels:
279
+ available = ", ".join(sorted(self.alert_channels.keys()))
280
+ raise ValueError(
281
+ f"Alert channel '{channel_name}' not found. "
282
+ f"Available channels: {available}"
283
+ )
284
+
285
+ return self.alert_channels[channel_name]
@@ -0,0 +1,164 @@
1
+ """
2
+ Project configuration models.
3
+
4
+ Defines configuration structure for detectkit_project.yml.
5
+ """
6
+
7
+ from pathlib import Path
8
+ from typing import Dict, Optional
9
+
10
+ from pydantic import BaseModel, Field, field_validator
11
+
12
+
13
+ class ProjectPathsConfig(BaseModel):
14
+ """
15
+ Project directory paths configuration.
16
+
17
+ Attributes:
18
+ metrics: Directory containing metric YAML files
19
+ sql: Directory containing SQL query files
20
+ templates: Directory containing alert templates
21
+ """
22
+
23
+ metrics: str = Field(default="metrics", description="Metrics directory")
24
+ sql: str = Field(default="sql", description="SQL files directory")
25
+ templates: str = Field(default="templates", description="Templates directory")
26
+
27
+
28
+ class ProjectTablesConfig(BaseModel):
29
+ """
30
+ Default internal table names for the project.
31
+
32
+ Attributes:
33
+ datapoints: Default datapoints table name
34
+ detections: Default detections table name
35
+ tasks: Default tasks table name
36
+ """
37
+
38
+ datapoints: str = Field(
39
+ default="_dtk_datapoints", description="Default datapoints table"
40
+ )
41
+ detections: str = Field(
42
+ default="_dtk_detections", description="Default detections table"
43
+ )
44
+ tasks: str = Field(default="_dtk_tasks", description="Default tasks table")
45
+
46
+
47
+ class ProjectTimeoutsConfig(BaseModel):
48
+ """
49
+ Default timeout values for operations (in seconds).
50
+
51
+ Attributes:
52
+ load: Timeout for data loading operations
53
+ detect: Timeout for detection operations
54
+ alert: Timeout for alerting operations
55
+ """
56
+
57
+ load: int = Field(default=3600, description="Load timeout (seconds)")
58
+ detect: int = Field(default=7200, description="Detect timeout (seconds)")
59
+ alert: int = Field(default=300, description="Alert timeout (seconds)")
60
+
61
+ @field_validator("load", "detect", "alert")
62
+ @classmethod
63
+ def validate_timeout(cls, v: int) -> int:
64
+ """Validate timeout value."""
65
+ if v < 1:
66
+ raise ValueError("Timeout must be at least 1 second")
67
+ if v > 86400: # 24 hours
68
+ raise ValueError("Timeout cannot exceed 24 hours (86400 seconds)")
69
+ return v
70
+
71
+
72
+ class ProjectConfig(BaseModel):
73
+ """
74
+ Project configuration loaded from detectkit_project.yml.
75
+
76
+ Attributes:
77
+ name: Project name
78
+ version: Project version
79
+ paths: Directory paths configuration
80
+ tables: Default table names
81
+ timeouts: Operation timeouts
82
+ default_profile: Default database profile to use
83
+
84
+ Example YAML:
85
+ ```yaml
86
+ name: "my_analytics_project"
87
+ version: "1.0"
88
+
89
+ paths:
90
+ metrics: "metrics"
91
+ sql: "sql"
92
+ templates: "templates"
93
+
94
+ tables:
95
+ datapoints: "_dtk_datapoints"
96
+ detections: "_dtk_detections"
97
+ tasks: "_dtk_tasks"
98
+
99
+ timeouts:
100
+ load: 3600
101
+ detect: 7200
102
+ alert: 300
103
+
104
+ default_profile: "clickhouse_prod"
105
+ ```
106
+ """
107
+
108
+ name: str = Field(..., description="Project name")
109
+ version: str = Field(default="1.0", description="Project version")
110
+ paths: ProjectPathsConfig = Field(
111
+ default_factory=ProjectPathsConfig, description="Directory paths"
112
+ )
113
+ tables: ProjectTablesConfig = Field(
114
+ default_factory=ProjectTablesConfig, description="Default table names"
115
+ )
116
+ timeouts: ProjectTimeoutsConfig = Field(
117
+ default_factory=ProjectTimeoutsConfig, description="Operation timeouts"
118
+ )
119
+ default_profile: str = Field(..., description="Default database profile")
120
+
121
+ @field_validator("name")
122
+ @classmethod
123
+ def validate_name(cls, v: str) -> str:
124
+ """Validate project name."""
125
+ if not v:
126
+ raise ValueError("Project name cannot be empty")
127
+ # Allow alphanumeric, underscore, dash, space
128
+ if not all(c.isalnum() or c in ("_", "-", " ") for c in v):
129
+ raise ValueError(
130
+ "Project name can only contain alphanumeric characters, "
131
+ "underscores, dashes, and spaces"
132
+ )
133
+ return v
134
+
135
+ @classmethod
136
+ def from_yaml_file(cls, path: Path) -> "ProjectConfig":
137
+ """
138
+ Load project configuration from YAML file.
139
+
140
+ Args:
141
+ path: Path to detectkit_project.yml
142
+
143
+ Returns:
144
+ ProjectConfig instance
145
+
146
+ Raises:
147
+ FileNotFoundError: If file doesn't exist
148
+ ValueError: If YAML is invalid
149
+
150
+ Example:
151
+ >>> config = ProjectConfig.from_yaml_file(Path("detectkit_project.yml"))
152
+ """
153
+ import yaml
154
+
155
+ if not path.exists():
156
+ raise FileNotFoundError(f"Project config file not found: {path}")
157
+
158
+ with open(path, "r") as f:
159
+ data = yaml.safe_load(f)
160
+
161
+ if not data:
162
+ raise ValueError(f"Empty project config file: {path}")
163
+
164
+ return cls.model_validate(data)
@@ -0,0 +1,124 @@
1
+ """
2
+ Metric configuration validation.
3
+
4
+ This module provides validation functions for metric configurations,
5
+ ensuring data integrity and preventing configuration errors.
6
+ """
7
+
8
+ from pathlib import Path
9
+ from typing import List, Tuple
10
+
11
+ from detectkit.config.metric_config import MetricConfig
12
+
13
+
14
+ def validate_metric_uniqueness(metric_paths: List[Path]) -> List[Tuple[Path, MetricConfig]]:
15
+ """
16
+ Load all metrics and validate that metric names are unique.
17
+
18
+ This validation is CRITICAL for data integrity because duplicate metric names
19
+ would cause:
20
+ - Data corruption (mixed data in _dtk_datapoints table)
21
+ - Task blocking (lock conflicts in _dtk_tasks table)
22
+ - Wrong anomaly detection (detectors receive mixed data from different sources)
23
+ - Data loss (ReplacingMergeTree ignores duplicate inserts)
24
+
25
+ Args:
26
+ metric_paths: List of paths to metric YAML files
27
+
28
+ Returns:
29
+ List of (path, config) tuples for all valid metrics
30
+
31
+ Raises:
32
+ ValueError: If duplicate metric names are found, with clear error message
33
+ showing which files have conflicting names
34
+ ValidationError: If any metric config fails to parse
35
+
36
+ Example:
37
+ >>> paths = [Path("metrics/api/cpu.yml"), Path("metrics/system/cpu.yml")]
38
+ >>> validate_metric_uniqueness(paths)
39
+ ValueError: Duplicate metric name 'cpu_usage' found:
40
+ - metrics/api/cpu.yml
41
+ - metrics/system/cpu.yml
42
+
43
+ Metric names must be unique across the project.
44
+ Please rename one of the metrics.
45
+ """
46
+ configs: List[Tuple[Path, MetricConfig]] = []
47
+ seen_names: dict[str, Path] = {}
48
+
49
+ for metric_path in metric_paths:
50
+ # Load and parse config
51
+ try:
52
+ config = MetricConfig.from_yaml_file(metric_path)
53
+ except Exception as e:
54
+ raise ValueError(
55
+ f"Failed to parse metric config at {metric_path}:\n{e}"
56
+ ) from e
57
+
58
+ # Check for duplicate metric names
59
+ if config.name in seen_names:
60
+ conflicting_path = seen_names[config.name]
61
+ raise ValueError(
62
+ f"Duplicate metric name '{config.name}' found:\n"
63
+ f" - {conflicting_path}\n"
64
+ f" - {metric_path}\n\n"
65
+ f"Metric names must be unique across the project.\n"
66
+ f"Please rename one of the metrics to avoid data corruption."
67
+ )
68
+
69
+ seen_names[config.name] = metric_path
70
+ configs.append((metric_path, config))
71
+
72
+ return configs
73
+
74
+
75
+ def validate_project_metrics(project_root: Path) -> List[Tuple[Path, MetricConfig]]:
76
+ """
77
+ Load and validate all metrics in the project.
78
+
79
+ This is a convenience function that:
80
+ 1. Finds all *.yml and *.yaml files in the metrics/ directory (recursively)
81
+ 2. Validates uniqueness of metric names
82
+ 3. Returns validated list of (path, config) tuples
83
+
84
+ Args:
85
+ project_root: Path to project root directory (contains metrics/ folder)
86
+
87
+ Returns:
88
+ List of (path, config) tuples for all valid metrics
89
+
90
+ Raises:
91
+ ValueError: If duplicate metric names found or configs fail validation
92
+ FileNotFoundError: If metrics/ directory doesn't exist
93
+
94
+ Example:
95
+ >>> from pathlib import Path
96
+ >>> project_root = Path("/path/to/project")
97
+ >>> metrics = validate_project_metrics(project_root)
98
+ >>> for path, config in metrics:
99
+ ... print(f"{config.name}: {path}")
100
+ """
101
+ metrics_dir = project_root / "metrics"
102
+
103
+ if not metrics_dir.exists():
104
+ raise FileNotFoundError(
105
+ f"Metrics directory not found: {metrics_dir}\n"
106
+ f"Expected structure:\n"
107
+ f" {project_root}/\n"
108
+ f" metrics/\n"
109
+ f" your_metric.yml\n"
110
+ )
111
+
112
+ # Find all metric files recursively
113
+ metric_paths = []
114
+ for pattern in ["**/*.yml", "**/*.yaml"]:
115
+ metric_paths.extend(metrics_dir.glob(pattern))
116
+
117
+ if not metric_paths:
118
+ raise ValueError(
119
+ f"No metric files found in {metrics_dir}\n"
120
+ f"Expected at least one *.yml or *.yaml file."
121
+ )
122
+
123
+ # Validate uniqueness
124
+ return validate_metric_uniqueness(metric_paths)
@@ -0,0 +1,6 @@
1
+ """Core functionality for detectk."""
2
+
3
+ from detectkit.core.interval import Interval
4
+ from detectkit.core.models import ColumnDefinition, TableModel
5
+
6
+ __all__ = ["Interval", "ColumnDefinition", "TableModel"]