detectkit 0.63.0__tar.gz → 0.65.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 (172) hide show
  1. {detectkit-0.63.0/detectkit.egg-info → detectkit-0.65.0}/PKG-INFO +12 -2
  2. {detectkit-0.63.0 → detectkit-0.65.0}/README.md +3 -1
  3. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/__init__.py +1 -1
  4. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/cli/assets/claude/CLAUDE.section.md +1 -1
  5. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/cli/assets/claude/rules/metrics.md +3 -0
  6. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/cli/assets/claude/rules/overview.md +4 -1
  7. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/cli/assets/claude/rules/project.md +70 -3
  8. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/cli/commands/run.py +37 -0
  9. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/config/profile.py +241 -17
  10. detectkit-0.65.0/detectkit/database/bigquery_manager.py +229 -0
  11. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/database/manager.py +7 -2
  12. detectkit-0.65.0/detectkit/database/snowflake_manager.py +182 -0
  13. detectkit-0.65.0/detectkit/database/source_manager.py +65 -0
  14. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/loaders/metric_loader.py +8 -6
  15. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/orchestration/task_manager/_base.py +5 -1
  16. {detectkit-0.63.0 → detectkit-0.65.0/detectkit.egg-info}/PKG-INFO +12 -2
  17. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit.egg-info/SOURCES.txt +3 -0
  18. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit.egg-info/requires.txt +10 -0
  19. {detectkit-0.63.0 → detectkit-0.65.0}/pyproject.toml +12 -0
  20. {detectkit-0.63.0 → detectkit-0.65.0}/LICENSE +0 -0
  21. {detectkit-0.63.0 → detectkit-0.65.0}/MANIFEST.in +0 -0
  22. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/alerting/__init__.py +0 -0
  23. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/alerting/channels/__init__.py +0 -0
  24. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/alerting/channels/base.py +0 -0
  25. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/alerting/channels/branding.py +0 -0
  26. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/alerting/channels/discord.py +0 -0
  27. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/alerting/channels/email.py +0 -0
  28. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/alerting/channels/factory.py +0 -0
  29. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/alerting/channels/googlechat.py +0 -0
  30. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/alerting/channels/mattermost.py +0 -0
  31. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/alerting/channels/ntfy.py +0 -0
  32. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/alerting/channels/slack.py +0 -0
  33. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/alerting/channels/teams.py +0 -0
  34. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/alerting/channels/telegram.py +0 -0
  35. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/alerting/channels/webhook.py +0 -0
  36. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/alerting/orchestrator/__init__.py +0 -0
  37. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/alerting/orchestrator/_base.py +0 -0
  38. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/alerting/orchestrator/_cooldown.py +0 -0
  39. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/alerting/orchestrator/_decision.py +0 -0
  40. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/alerting/orchestrator/_dispatch.py +0 -0
  41. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/alerting/orchestrator/_recovery.py +0 -0
  42. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/alerting/orchestrator/_replay.py +0 -0
  43. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/alerting/orchestrator/_types.py +0 -0
  44. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/alerting/orchestrator/orchestrator.py +0 -0
  45. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/autotune/__init__.py +0 -0
  46. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/autotune/_base.py +0 -0
  47. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/autotune/_types.py +0 -0
  48. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/autotune/autotuner.py +0 -0
  49. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/autotune/axis_spec.py +0 -0
  50. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/autotune/config_emitter.py +0 -0
  51. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/autotune/crossval.py +0 -0
  52. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/autotune/detector_select.py +0 -0
  53. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/autotune/distribution.py +0 -0
  54. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/autotune/grid_search.py +0 -0
  55. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/autotune/labels.py +0 -0
  56. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/autotune/result.py +0 -0
  57. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/autotune/runner.py +0 -0
  58. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/autotune/scoring.py +0 -0
  59. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/autotune/seasonality_search.py +0 -0
  60. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/autotune/settings.py +0 -0
  61. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/autotune/window_select.py +0 -0
  62. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/cli/__init__.py +0 -0
  63. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/cli/_output.py +0 -0
  64. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/cli/assets/claude/rules/alerting.md +0 -0
  65. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/cli/assets/claude/rules/autotune.md +0 -0
  66. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/cli/assets/claude/rules/cli.md +0 -0
  67. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/cli/assets/claude/rules/detectors.md +0 -0
  68. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/cli/assets/claude/skills/dtk-autotune/SKILL.md +0 -0
  69. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/cli/assets/claude/skills/dtk-feedback/SKILL.md +0 -0
  70. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/cli/assets/claude/skills/dtk-new-metric/SKILL.md +0 -0
  71. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/cli/assets/claude/skills/dtk-setup-project/SKILL.md +0 -0
  72. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/cli/assets/claude/skills/dtk-tune/SKILL.md +0 -0
  73. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/cli/commands/__init__.py +0 -0
  74. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/cli/commands/autotune.py +0 -0
  75. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/cli/commands/clean.py +0 -0
  76. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/cli/commands/init.py +0 -0
  77. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/cli/commands/init_claude.py +0 -0
  78. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/cli/commands/mcp.py +0 -0
  79. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/cli/commands/osi.py +0 -0
  80. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/cli/commands/test_alert.py +0 -0
  81. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/cli/commands/tune.py +0 -0
  82. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/cli/commands/ui.py +0 -0
  83. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/cli/commands/unlock.py +0 -0
  84. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/cli/main.py +0 -0
  85. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/config/__init__.py +0 -0
  86. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/config/metric_config.py +0 -0
  87. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/config/metric_io.py +0 -0
  88. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/config/project_config.py +0 -0
  89. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/config/validator.py +0 -0
  90. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/core/__init__.py +0 -0
  91. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/core/interval.py +0 -0
  92. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/core/models.py +0 -0
  93. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/database/__init__.py +0 -0
  94. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/database/_sql_manager.py +0 -0
  95. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/database/clickhouse_manager.py +0 -0
  96. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/database/duckdb_manager.py +0 -0
  97. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/database/internal_tables/__init__.py +0 -0
  98. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/database/internal_tables/_alert_states.py +0 -0
  99. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/database/internal_tables/_autotune_runs.py +0 -0
  100. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/database/internal_tables/_base.py +0 -0
  101. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/database/internal_tables/_datapoints.py +0 -0
  102. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/database/internal_tables/_detections.py +0 -0
  103. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/database/internal_tables/_maintenance.py +0 -0
  104. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/database/internal_tables/_metrics.py +0 -0
  105. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/database/internal_tables/_schema.py +0 -0
  106. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/database/internal_tables/_tasks.py +0 -0
  107. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/database/internal_tables/manager.py +0 -0
  108. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/database/mysql_manager.py +0 -0
  109. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/database/postgres_manager.py +0 -0
  110. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/database/tables.py +0 -0
  111. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/detectors/__init__.py +0 -0
  112. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/detectors/base.py +0 -0
  113. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/detectors/factory.py +0 -0
  114. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/detectors/seasonality.py +0 -0
  115. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/detectors/statistical/__init__.py +0 -0
  116. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/detectors/statistical/_windowed.py +0 -0
  117. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/detectors/statistical/autoreg.py +0 -0
  118. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/detectors/statistical/iqr.py +0 -0
  119. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/detectors/statistical/mad.py +0 -0
  120. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/detectors/statistical/manual_bounds.py +0 -0
  121. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/detectors/statistical/zscore.py +0 -0
  122. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/loaders/__init__.py +0 -0
  123. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/loaders/errors.py +0 -0
  124. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/loaders/query_template.py +0 -0
  125. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/mcp/__init__.py +0 -0
  126. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/mcp/context.py +0 -0
  127. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/mcp/errors.py +0 -0
  128. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/mcp/serialize.py +0 -0
  129. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/mcp/server.py +0 -0
  130. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/mcp/tools.py +0 -0
  131. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/orchestration/__init__.py +0 -0
  132. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/orchestration/error_dispatch.py +0 -0
  133. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/orchestration/task_manager/__init__.py +0 -0
  134. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/orchestration/task_manager/_alert_step.py +0 -0
  135. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/orchestration/task_manager/_detect_step.py +0 -0
  136. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/orchestration/task_manager/_load_step.py +0 -0
  137. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/orchestration/task_manager/_types.py +0 -0
  138. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/orchestration/task_manager/manager.py +0 -0
  139. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/reporting/__init__.py +0 -0
  140. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/reporting/assets/report.js +0 -0
  141. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/reporting/builder.py +0 -0
  142. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/reporting/html_report.py +0 -0
  143. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/semantic/__init__.py +0 -0
  144. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/semantic/errors.py +0 -0
  145. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/semantic/exporter.py +0 -0
  146. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/semantic/importer.py +0 -0
  147. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/semantic/osi_model.py +0 -0
  148. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/semantic/query_gen.py +0 -0
  149. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/tuning/__init__.py +0 -0
  150. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/tuning/assets/tune.js +0 -0
  151. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/tuning/config_writer.py +0 -0
  152. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/tuning/html.py +0 -0
  153. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/tuning/payload.py +0 -0
  154. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/tuning/server.py +0 -0
  155. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/ui/__init__.py +0 -0
  156. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/ui/assets/ui.js +0 -0
  157. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/ui/html.py +0 -0
  158. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/ui/jobs.py +0 -0
  159. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/ui/metric_files.py +0 -0
  160. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/ui/overview.py +0 -0
  161. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/ui/server.py +0 -0
  162. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/utils/__init__.py +0 -0
  163. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/utils/datetime_utils.py +0 -0
  164. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/utils/env_interpolation.py +0 -0
  165. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/utils/json_utils.py +0 -0
  166. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit/utils/stats.py +0 -0
  167. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit.egg-info/dependency_links.txt +0 -0
  168. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit.egg-info/entry_points.txt +0 -0
  169. {detectkit-0.63.0 → detectkit-0.65.0}/detectkit.egg-info/top_level.txt +0 -0
  170. {detectkit-0.63.0 → detectkit-0.65.0}/requirements.txt +0 -0
  171. {detectkit-0.63.0 → detectkit-0.65.0}/setup.cfg +0 -0
  172. {detectkit-0.63.0 → detectkit-0.65.0}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: detectkit
3
- Version: 0.63.0
3
+ Version: 0.65.0
4
4
  Summary: Metric monitoring with automatic anomaly detection
5
5
  Author: detectkit team
6
6
  License: MIT
@@ -40,11 +40,17 @@ Provides-Extra: mariadb
40
40
  Requires-Dist: pymysql>=1.0.0; extra == "mariadb"
41
41
  Provides-Extra: duckdb
42
42
  Requires-Dist: duckdb>=1.1; extra == "duckdb"
43
+ Provides-Extra: snowflake
44
+ Requires-Dist: snowflake-connector-python>=3.12; extra == "snowflake"
45
+ Provides-Extra: bigquery
46
+ Requires-Dist: google-cloud-bigquery>=3.15; extra == "bigquery"
43
47
  Provides-Extra: all-db
44
48
  Requires-Dist: clickhouse-driver>=0.2.0; extra == "all-db"
45
49
  Requires-Dist: psycopg2-binary>=2.9.0; extra == "all-db"
46
50
  Requires-Dist: pymysql>=1.0.0; extra == "all-db"
47
51
  Requires-Dist: duckdb>=1.1; extra == "all-db"
52
+ Requires-Dist: snowflake-connector-python>=3.12; extra == "all-db"
53
+ Requires-Dist: google-cloud-bigquery>=3.15; extra == "all-db"
48
54
  Provides-Extra: prophet
49
55
  Requires-Dist: prophet>=1.1.0; extra == "prophet"
50
56
  Provides-Extra: timesfm
@@ -61,6 +67,8 @@ Requires-Dist: clickhouse-driver>=0.2.0; extra == "all"
61
67
  Requires-Dist: psycopg2-binary>=2.9.0; extra == "all"
62
68
  Requires-Dist: pymysql>=1.0.0; extra == "all"
63
69
  Requires-Dist: duckdb>=1.1; extra == "all"
70
+ Requires-Dist: snowflake-connector-python>=3.12; extra == "all"
71
+ Requires-Dist: google-cloud-bigquery>=3.15; extra == "all"
64
72
  Requires-Dist: prophet>=1.1.0; extra == "all"
65
73
  Requires-Dist: timesfm>=0.1.0; extra == "all"
66
74
  Requires-Dist: sqlglot>=20.0; extra == "all"
@@ -106,7 +114,7 @@ Dynamic: license-file
106
114
  - **@mentions** — tag users/groups in alerts, each channel formats natively
107
115
  - **Alert lifecycle** — consecutive anomalies, fraction-of-window rule (`anomaly_window` + `min_anomaly_share`), cooldown, recovery notifications, no-data alerts
108
116
  - **Project-level error alerts** — catch DB outages and pipeline crashes once per run
109
- - **Database agnostic** — ClickHouse, PostgreSQL, MySQL/MariaDB, DuckDB (no server needed)
117
+ - **Database agnostic** — ClickHouse, PostgreSQL, MySQL/MariaDB, DuckDB (no server needed); Snowflake and BigQuery as hybrid-mode sources
110
118
  - **Hybrid mode** — read metric SQL from a billed-per-query warehouse while all pipeline state lives in a cheap local database
111
119
  - **Idempotent** — resume from interruptions, no duplicate processing
112
120
  - **CLI** — `dtk init`, `dtk run --select`, `dtk unlock`, `dtk clean`, tag-based selectors
@@ -123,6 +131,8 @@ With database drivers:
123
131
  ```bash
124
132
  pip install detectkit[clickhouse] # ClickHouse
125
133
  pip install detectkit[duckdb] # DuckDB - single file, no server
134
+ pip install detectkit[snowflake] # Snowflake - source-only (hybrid mode)
135
+ pip install detectkit[bigquery] # BigQuery - source-only (hybrid mode)
126
136
  pip install detectkit[all-db] # All databases
127
137
  ```
128
138
 
@@ -24,7 +24,7 @@
24
24
  - **@mentions** — tag users/groups in alerts, each channel formats natively
25
25
  - **Alert lifecycle** — consecutive anomalies, fraction-of-window rule (`anomaly_window` + `min_anomaly_share`), cooldown, recovery notifications, no-data alerts
26
26
  - **Project-level error alerts** — catch DB outages and pipeline crashes once per run
27
- - **Database agnostic** — ClickHouse, PostgreSQL, MySQL/MariaDB, DuckDB (no server needed)
27
+ - **Database agnostic** — ClickHouse, PostgreSQL, MySQL/MariaDB, DuckDB (no server needed); Snowflake and BigQuery as hybrid-mode sources
28
28
  - **Hybrid mode** — read metric SQL from a billed-per-query warehouse while all pipeline state lives in a cheap local database
29
29
  - **Idempotent** — resume from interruptions, no duplicate processing
30
30
  - **CLI** — `dtk init`, `dtk run --select`, `dtk unlock`, `dtk clean`, tag-based selectors
@@ -41,6 +41,8 @@ With database drivers:
41
41
  ```bash
42
42
  pip install detectkit[clickhouse] # ClickHouse
43
43
  pip install detectkit[duckdb] # DuckDB - single file, no server
44
+ pip install detectkit[snowflake] # Snowflake - source-only (hybrid mode)
45
+ pip install detectkit[bigquery] # BigQuery - source-only (hybrid mode)
44
46
  pip install detectkit[all-db] # All databases
45
47
  ```
46
48
 
@@ -4,7 +4,7 @@ detectk - Anomaly Detection for Time-Series Metrics
4
4
  A Python library for data analysts and engineers to monitor metrics with automatic anomaly detection.
5
5
  """
6
6
 
7
- __version__ = "0.63.0"
7
+ __version__ = "0.65.0"
8
8
 
9
9
  from detectkit.core.interval import Interval
10
10
  from detectkit.core.models import ColumnDefinition, TableModel
@@ -14,7 +14,7 @@ conventions.
14
14
  **Database access for _you_ (recommended, not required).** detectkit itself
15
15
  connects to the database directly via its drivers — it **never** needs an MCP to
16
16
  run. But you assist far better with **read access to the same database** (e.g. a
17
- database MCP for the project's ClickHouse / PostgreSQL / MySQL / MariaDB / DuckDB): you can inspect
17
+ database MCP for the project's ClickHouse / PostgreSQL / MySQL / MariaDB / DuckDB / Snowflake / BigQuery (the last two source-only, hybrid mode)): you can inspect
18
18
  a metric's series, find real incidents to label for `dtk autotune`, sanity-check
19
19
  a metric query before running it, and confirm detections — instead of asking the
20
20
  user to run every query by hand. Without it, fall back to marking incidents
@@ -79,6 +79,9 @@ not applied at runtime by `dtk run`; `source_profile` is live). Only the
79
79
  metric's **load** step ever connects to it — detect/alert and every other
80
80
  command don't — though every `dtk run` fail-fast validates the resolved name
81
81
  up front regardless of `--steps` (a typo exits 1 before any pipeline work).
82
+ The `source_profile` may point at a **source-only** warehouse profile such as
83
+ `snowflake` or `bigquery` (usable *only* as a source, never for `_dtk_*` state
84
+ — see `project.md`), which is exactly the case hybrid mode is built for.
82
85
  See the [Hybrid Mode guide](https://dtk.pipelab.dev/guides/hybrid-mode/).
83
86
 
84
87
  `ai_context` is **OSI-compatible grounding** you can add to any metric with **no
@@ -6,7 +6,10 @@ metrics with automatic anomaly detection and multi-channel alerting. It is
6
6
  them with one command. Core logic is pure numpy (no pandas). **ClickHouse,
7
7
  PostgreSQL, MySQL/MariaDB and DuckDB are all fully supported** — only the
8
8
  connection and the SQL dialect of your metric queries differ between them
9
- (DuckDB additionally needs no server at all — just a local file).
9
+ (DuckDB additionally needs no server at all — just a local file); **Snowflake**
10
+ and **BigQuery** are supported as **source-only** backends (hybrid mode — they
11
+ run a metric's load SQL, but `_dtk_*` state must live in one of the full
12
+ backends).
10
13
 
11
14
  ## The pipeline: load → detect → alert
12
15
 
@@ -57,7 +57,9 @@ profile whose database runs metric SQL, while *every* `_dtk_*` table
57
57
  **state** profile — the one `dtk run` is already connected to for everything
58
58
  else. A per-metric `source_profile` overrides it (`metrics.md`); resolves
59
59
  **metric → project → unset**, same precedence as `loading_delay`. Unset on
60
- both = today's behavior (one profile does everything). Only the LOAD step's
60
+ both = today's behavior (one profile does everything). The source profile can
61
+ be a full backend **or** a **source-only** type (`snowflake`, `bigquery`) —
62
+ valid only here, never as the state profile. Only the LOAD step's
61
63
  metric-SQL query is affected — detect/alert and every other command
62
64
  (`dtk autotune`/`tune`/`ui`/`clean`/`unlock`) only ever touch the state
63
65
  profile. A source-side failure raises `SourceDatabaseError` (message leads
@@ -137,12 +139,15 @@ alert_channels:
137
139
 
138
140
  ### Database profiles
139
141
 
140
- > ClickHouse, PostgreSQL, MySQL, MariaDB and DuckDB are all supported.
142
+ > ClickHouse, PostgreSQL, MySQL, MariaDB and DuckDB are all supported as
143
+ > **state** backends (they hold the `_dtk_*` tables); **Snowflake** and
144
+ > **BigQuery** are **source-only** — see their blocks below.
141
145
  > ClickHouse/MySQL/MariaDB use two *databases*; PostgreSQL connects to one
142
146
  > `database` and uses two *schemas*; DuckDB is a single *file* (or `:memory:`
143
147
  > for tests) holding two *schemas*.
144
148
  > `dtk init --db-type {clickhouse,postgres,mysql,mariadb}` scaffolds the right
145
- > shape — DuckDB profiles are written by hand (not yet a `--db-type` choice).
149
+ > shape — DuckDB, Snowflake and BigQuery profiles are written by hand (not yet
150
+ > a `--db-type` choice).
146
151
 
147
152
  **ClickHouse**:
148
153
  ```yaml
@@ -215,6 +220,68 @@ profiles:
215
220
  > process restarts — use it for tests/scratch only, never a real project.
216
221
  > `pip install 'detectkit[duckdb]'`.
217
222
 
223
+ **Snowflake** (**source-only** — hybrid mode; runs a metric's load SQL, never
224
+ holds `_dtk_*` state):
225
+ ```yaml
226
+ profiles:
227
+ warehouse:
228
+ type: snowflake
229
+ account: "{{ env_var('SNOWFLAKE_ACCOUNT') }}" # required — e.g. ab12345.eu-central-1
230
+ user: svc_detectkit # required
231
+ # key-pair auth (recommended — Snowflake retires single-factor passwords for
232
+ # service accounts through 2026); use ONE of key-pair or password:
233
+ private_key_path: /etc/detectkit/snowflake.p8 # PEM private key file
234
+ private_key_passphrase: "{{ env_var('SNOWFLAKE_KEY_PASSPHRASE') }}" # optional
235
+ # password: "{{ env_var('SNOWFLAKE_PASSWORD') }}" # alternative to key-pair
236
+ warehouse: COMPUTE_WH # required — the virtual warehouse to run on
237
+ database: ANALYTICS # required — where the metric SQL reads from
238
+ schema: PUBLIC # optional — default schema (aliases schema_name)
239
+ role: DETECTKIT_RO # optional — Snowflake role
240
+ settings: {} # optional — extra session parameters
241
+ ```
242
+ > **Source-only**: a `snowflake` profile is valid **only** as a metric's or the
243
+ > project's `source_profile` (hybrid mode) — `dtk run` refuses it as a state
244
+ > profile (`--profile`/`default_profile`), so pair it with a full backend
245
+ > (DuckDB/Postgres/ClickHouse) that holds the `_dtk_*` tables. See `metrics.md`
246
+ > and the [Hybrid Mode guide](https://dtk.pipelab.dev/guides/hybrid-mode/).
247
+ > Key-pair auth is first-class and recommended; a plain `password` works too.
248
+ > The session `TIMEZONE` is pinned to UTC (override via `settings`). Billing
249
+ > note: every query resumes the warehouse with a **60-second minimum bill**, so
250
+ > hybrid mode (load from Snowflake, keep cheap local state) is the point.
251
+ > `pip install 'detectkit[snowflake]'`.
252
+
253
+ **BigQuery** (**source-only** — hybrid mode; runs a metric's load SQL, never
254
+ holds `_dtk_*` state):
255
+ ```yaml
256
+ profiles:
257
+ warehouse:
258
+ type: bigquery
259
+ project: my-analytics-project # required — GCP project id billed for the queries
260
+ credentials_json_path: /etc/detectkit/bq-sa.json # optional — service-account JSON key file
261
+ # # (unset -> Application Default Credentials)
262
+ location: EU # optional — job location (e.g. EU / US)
263
+ dataset: analytics # optional — default dataset so unqualified table names resolve
264
+ api_endpoint: null # optional — endpoint override; plain-http (the emulator) -> anonymous
265
+ # # auth when no key file; https endpoints authenticate normally
266
+ settings: # optional — extra QueryJobConfig attributes applied to every query
267
+ maximum_bytes_billed: 1000000000
268
+ ```
269
+ > **Source-only**: a `bigquery` profile is valid **only** as a metric's or the
270
+ > project's `source_profile` (hybrid mode) — `dtk run` refuses it as a state
271
+ > profile (`--profile`/`default_profile`), so pair it with a full backend
272
+ > (DuckDB/Postgres/ClickHouse) that holds the `_dtk_*` tables. See `metrics.md`
273
+ > and the [Hybrid Mode guide](https://dtk.pipelab.dev/guides/hybrid-mode/).
274
+ > Auth: `credentials_json_path` (a service-account key file) when set, else
275
+ > **Application Default Credentials** (gcloud ADC / an attached service account
276
+ > / Workload Identity); host/port/user/password are unused. Timestamps: a
277
+ > BigQuery `TIMESTAMP` column comes back tz-aware UTC (handled by the loader),
278
+ > `DATETIME` comes back naive — prefer `TIMESTAMP` (or a cast) for the metric's
279
+ > timestamp column. Billing note: on-demand queries bill a **10 MiB minimum**
280
+ > of bytes processed per query per referenced table, so frequent small
281
+ > monitoring queries are disproportionately expensive — load from BigQuery,
282
+ > keep cheap local state; `settings: {maximum_bytes_billed: ...}` caps what a
283
+ > single query may scan. `pip install 'detectkit[bigquery]'`.
284
+
218
285
  ### Alert channels
219
286
 
220
287
  Defined once in `profiles.yml`, referenced by name in each metric's
@@ -341,6 +341,16 @@ def _run_impl(
341
341
  summary["error"] = f"Error: {source_profile_error}"
342
342
  return 1
343
343
 
344
+ # Fail-fast: the STATE profile must be a state-capable type. Like the
345
+ # source_profile check above this is a config error, not an outage —
346
+ # exit 1 without paging error_alerting (create_manager would raise the
347
+ # same refusal, but through the path that fires the error alert).
348
+ state_type_error = _validate_state_profile_type(profiles_config, profile)
349
+ if state_type_error:
350
+ click.echo(click.style(f"Error: {state_type_error}", fg="red", bold=True))
351
+ summary["error"] = f"Error: {state_type_error}"
352
+ return 1
353
+
344
354
  # Create database manager
345
355
  try:
346
356
  db_manager = profiles_config.create_manager(profile)
@@ -519,6 +529,33 @@ def _validate_source_profiles(
519
529
  )
520
530
 
521
531
 
532
+ def _validate_state_profile_type(
533
+ profiles_config: ProfilesConfig, profile_name: str | None
534
+ ) -> str | None:
535
+ """Cheap, connection-free check that the STATE profile is state-capable.
536
+
537
+ A source-only profile type (e.g. ``snowflake``) pointed at by
538
+ ``--profile``/``default_profile`` is a config error, not an outage, so it
539
+ must exit 1 *without* paging ``error_alerting`` — same spirit as the
540
+ source_profile name check above. An unknown/unset profile name returns
541
+ ``None`` here so the existing ``create_manager`` path reports it exactly
542
+ as before. Duck-typed via ``getattr`` like ``_validate_source_profiles``,
543
+ so test doubles need not model the full ProfilesConfig surface.
544
+ """
545
+ known_profiles = getattr(profiles_config, "profiles", {}) or {}
546
+ name = profile_name or getattr(profiles_config, "default_profile", None)
547
+ profile_config = known_profiles.get(name) if name else None
548
+ if profile_config is None or not getattr(profile_config, "is_source_only", False):
549
+ return None
550
+ state_types = ", ".join(sorted(profile_config.STATE_TYPES))
551
+ return (
552
+ f"Profile type '{profile_config.type}' is source-only: it can serve "
553
+ f"as a metric/project 'source_profile' (hybrid mode), but it cannot "
554
+ f"hold detectkit state. Point --profile/default_profile at one of: "
555
+ f"{state_types}."
556
+ )
557
+
558
+
522
559
  def _resolve_report_path(report_path: str, project_root: Path, metric_name: str) -> Path:
523
560
  """Map the ``--report`` value to a concrete output file for a metric.
524
561
 
@@ -5,13 +5,14 @@ Manages database connections and locations (similar to dbt profiles).
5
5
  """
6
6
 
7
7
  from pathlib import Path
8
- from typing import Any
8
+ from typing import Any, ClassVar
9
9
 
10
10
  import yaml
11
- from pydantic import BaseModel, Field, field_validator, model_validator
11
+ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
12
12
 
13
13
  from detectkit.database.clickhouse_manager import ClickHouseDatabaseManager
14
14
  from detectkit.database.manager import BaseDatabaseManager
15
+ from detectkit.database.source_manager import SourceDatabaseManager
15
16
  from detectkit.utils.env_interpolation import interpolate_env_vars
16
17
 
17
18
 
@@ -22,24 +23,74 @@ class ProfileConfig(BaseModel):
22
23
  Defines connection parameters and database locations for a specific
23
24
  environment (dev, prod, etc.).
24
25
 
26
+ Two classes of profile types exist:
27
+
28
+ - **Full backends** (``STATE_TYPES``: clickhouse/postgres/mysql/mariadb/
29
+ duckdb) can hold detectkit state (the ``_dtk_*`` tables) — and, since
30
+ state managers also expose ``execute_query``, double as hybrid-mode
31
+ sources.
32
+ - **Source-only backends** (``SOURCE_ONLY_TYPES``: snowflake, bigquery)
33
+ implement only connect + read query. They are valid **only** as a
34
+ metric/project ``source_profile``; :meth:`create_manager` refuses them
35
+ as a state profile with a clear error.
36
+
25
37
  Attributes:
26
- type: Database type ("clickhouse", "postgres", "mysql", "mariadb", "duckdb")
38
+ type: Database type ("clickhouse", "postgres", "mysql", "mariadb",
39
+ "duckdb"; source-only: "snowflake", "bigquery")
27
40
  host: Database host (unused for DuckDB — it is an in-process, file-backed
28
- database with no network endpoint)
29
- port: Database port (unused for DuckDB; required for every other backend)
30
- user: Database user (unused for DuckDB)
31
- password: Database password (unused for DuckDB)
32
- database: Connection-target database (PostgreSQL/MySQL/MariaDB; unused
33
- for DuckDB — use `path` instead)
41
+ database with no network endpoint — and for Snowflake/BigQuery,
42
+ which connect by `account` / `project`)
43
+ port: Database port (unused for DuckDB/Snowflake/BigQuery; required
44
+ for every other backend)
45
+ user: Database user (unused for DuckDB/BigQuery; required explicitly
46
+ for Snowflake)
47
+ password: Database password (unused for DuckDB/BigQuery; for Snowflake
48
+ either `password` or `private_key_path` must be set)
49
+ database: Connection-target database (PostgreSQL/MySQL/MariaDB; the
50
+ session default database for Snowflake; unused for DuckDB — use
51
+ `path` instead)
34
52
  path: Path to the DuckDB database file, or ":memory:" for a transient
35
53
  in-process database (DuckDB only)
54
+ account: Snowflake account identifier (e.g. "myorg-myaccount")
55
+ warehouse: Snowflake virtual warehouse to run load queries on
56
+ role: Snowflake role for the session
57
+ schema_name: Session default schema (YAML key: `schema`; Snowflake only)
58
+ private_key_path: Path to a PEM private key for Snowflake key-pair
59
+ auth (recommended for service accounts)
60
+ private_key_passphrase: Passphrase of the private key, if encrypted
61
+ project: GCP project id billed for the queries (BigQuery only)
62
+ credentials_json_path: Path to a service-account JSON key file
63
+ (BigQuery only; unset -> Application Default Credentials)
64
+ location: Job location, e.g. "EU" (BigQuery only; unset -> inferred
65
+ from the referenced datasets)
66
+ dataset: Default dataset for unqualified table names in load queries
67
+ (BigQuery only)
68
+ api_endpoint: API endpoint override — the BigQuery emulator or a
69
+ private endpoint (BigQuery only)
36
70
  internal_database: Database/schema for internal tables
37
71
  internal_schema: Schema for internal tables (PostgreSQL/DuckDB only)
38
72
  data_database: Database for user data tables
39
73
  data_schema: Schema for user data (PostgreSQL/DuckDB only)
40
- settings: Additional database-specific settings
74
+ settings: Additional database-specific settings (for Snowflake these
75
+ merge into the session parameters)
41
76
  """
42
77
 
78
+ # `schema_name` carries the YAML key `schema` via its alias (a field
79
+ # literally named `schema` would shadow pydantic's BaseModel attribute).
80
+ model_config = ConfigDict(populate_by_name=True)
81
+
82
+ # Full backends: may hold detectkit state (_dtk_* tables), and therefore
83
+ # also work as hybrid-mode sources.
84
+ STATE_TYPES: ClassVar[frozenset[str]] = frozenset(
85
+ {"clickhouse", "postgres", "mysql", "mariadb", "duckdb"}
86
+ )
87
+ # Source-only backends: connect + execute_query only (no DDL/upserts/
88
+ # locks); valid ONLY as a metric/project `source_profile` (hybrid mode).
89
+ SOURCE_ONLY_TYPES: ClassVar[frozenset[str]] = frozenset({"snowflake", "bigquery"})
90
+ # Backends with no network port to validate (in-process file DB;
91
+ # account/project-resolved cloud drivers).
92
+ _PORTLESS_TYPES: ClassVar[frozenset[str]] = frozenset({"duckdb", "snowflake", "bigquery"})
93
+
43
94
  type: str = Field(..., description="Database type")
44
95
  host: str = Field(default="localhost", description="Database host (unused for DuckDB)")
45
96
  # No default: required for every backend except DuckDB (enforced below by
@@ -70,6 +121,64 @@ class ProfileConfig(BaseModel):
70
121
  ),
71
122
  )
72
123
 
124
+ # Snowflake-only (source-only backend). host/port are meaningless for it:
125
+ # the driver resolves the endpoint from `account`.
126
+ account: str | None = Field(
127
+ default=None,
128
+ description="Snowflake account identifier, e.g. 'myorg-myaccount' (Snowflake only)",
129
+ )
130
+ warehouse: str | None = Field(
131
+ default=None,
132
+ description="Virtual warehouse to run load queries on (Snowflake only; "
133
+ "falls back to the user's default warehouse when unset)",
134
+ )
135
+ role: str | None = Field(
136
+ default=None,
137
+ description="Role for the session (Snowflake only; user's default role when unset)",
138
+ )
139
+ schema_name: str | None = Field(
140
+ default=None,
141
+ alias="schema",
142
+ description="Session default schema (Snowflake only; YAML key: 'schema')",
143
+ )
144
+ private_key_path: str | None = Field(
145
+ default=None,
146
+ description="Path to a PEM private key for key-pair auth (Snowflake only; "
147
+ "recommended for service accounts over 'password')",
148
+ )
149
+ private_key_passphrase: str | None = Field(
150
+ default=None,
151
+ description="Passphrase of the private key, when it is encrypted (Snowflake only)",
152
+ )
153
+
154
+ # BigQuery-only (source-only backend). host/port/user/password are
155
+ # meaningless for it: the client resolves the endpoint from `project` and
156
+ # authenticates via a service-account key file or ADC.
157
+ project: str | None = Field(
158
+ default=None,
159
+ description="GCP project id billed for the queries, e.g. 'my-project' (BigQuery only)",
160
+ )
161
+ credentials_json_path: str | None = Field(
162
+ default=None,
163
+ description="Path to a service-account JSON key file (BigQuery only; "
164
+ "unset -> Application Default Credentials)",
165
+ )
166
+ location: str | None = Field(
167
+ default=None,
168
+ description="Job location, e.g. 'EU' (BigQuery only; unset -> inferred "
169
+ "from the referenced datasets)",
170
+ )
171
+ dataset: str | None = Field(
172
+ default=None,
173
+ description="Default dataset for unqualified table names in load queries (BigQuery only)",
174
+ )
175
+ api_endpoint: str | None = Field(
176
+ default=None,
177
+ description="API endpoint override (BigQuery only) — a plain-http endpoint "
178
+ "(the BigQuery emulator) uses anonymous credentials when no key file is "
179
+ "set; https endpoints (regional/private) authenticate normally",
180
+ )
181
+
73
182
  # Internal location for _dtk_* tables
74
183
  internal_database: str | None = Field(
75
184
  default=None, description="Database for internal tables (ClickHouse/MySQL/MariaDB)"
@@ -90,14 +199,19 @@ class ProfileConfig(BaseModel):
90
199
  default_factory=dict, description="Additional database settings"
91
200
  )
92
201
 
202
+ @property
203
+ def is_source_only(self) -> bool:
204
+ """True for profile types valid only as a hybrid-mode ``source_profile``."""
205
+ return self.type in self.SOURCE_ONLY_TYPES
206
+
93
207
  @field_validator("type")
94
208
  @classmethod
95
209
  def validate_type(cls, v: str) -> str:
96
210
  """Validate database type."""
97
- allowed_types = {"clickhouse", "postgres", "mysql", "mariadb", "duckdb"}
211
+ allowed_types = cls.STATE_TYPES | cls.SOURCE_ONLY_TYPES
98
212
  if v not in allowed_types:
99
213
  raise ValueError(
100
- f"Invalid database type: {v}. " f"Allowed types: {', '.join(allowed_types)}"
214
+ f"Invalid database type: {v}. " f"Allowed types: {', '.join(sorted(allowed_types))}"
101
215
  )
102
216
  return v
103
217
 
@@ -113,11 +227,49 @@ class ProfileConfig(BaseModel):
113
227
 
114
228
  @model_validator(mode="after")
115
229
  def _validate_port_required(self) -> "ProfileConfig":
116
- """Every backend but DuckDB needs a port; DuckDB has no network endpoint."""
117
- if self.type != "duckdb" and self.port is None:
230
+ """Server backends need a port; DuckDB is in-process, Snowflake connects by account."""
231
+ if self.type not in self._PORTLESS_TYPES and self.port is None:
118
232
  raise ValueError(f"port is required for database type '{self.type}'")
119
233
  return self
120
234
 
235
+ @model_validator(mode="after")
236
+ def _validate_snowflake_fields(self) -> "ProfileConfig":
237
+ """Snowflake profiles need an account, an explicit user, and one auth method."""
238
+ if self.type != "snowflake":
239
+ return self
240
+ if not self.account:
241
+ raise ValueError(
242
+ "snowflake profiles must set 'account' "
243
+ "(the account identifier, e.g. 'myorg-myaccount')"
244
+ )
245
+ if "user" not in self.model_fields_set:
246
+ # `user` carries a generic default ("default") that would silently
247
+ # reach the Snowflake driver; require it spelled out.
248
+ raise ValueError("snowflake profiles must set 'user' explicitly")
249
+ if not self.password and not self.private_key_path:
250
+ raise ValueError(
251
+ "snowflake profiles must set 'password' or 'private_key_path' "
252
+ "(key-pair auth — recommended for service accounts)"
253
+ )
254
+ return self
255
+
256
+ @model_validator(mode="after")
257
+ def _validate_bigquery_fields(self) -> "ProfileConfig":
258
+ """BigQuery profiles need an explicit GCP project id.
259
+
260
+ Auth needs nothing further: `credentials_json_path` when set, else
261
+ Application Default Credentials (gcloud ADC / attached service
262
+ account / Workload Identity).
263
+ """
264
+ if self.type != "bigquery":
265
+ return self
266
+ if not self.project:
267
+ raise ValueError(
268
+ "bigquery profiles must set 'project' (the GCP project id billed "
269
+ "for the queries, e.g. 'my-project')"
270
+ )
271
+ return self
272
+
121
273
  def get_internal_location(self) -> str:
122
274
  """
123
275
  Get internal location (database or schema).
@@ -193,11 +345,19 @@ class ProfileConfig(BaseModel):
193
345
  Database manager instance
194
346
 
195
347
  Raises:
196
- ValueError: If the database type is unsupported, or required
197
- connection fields (e.g. PostgreSQL ``database``, DuckDB
198
- ``path``) are missing
348
+ ValueError: If the database type is unsupported or source-only,
349
+ or required connection fields (e.g. PostgreSQL ``database``,
350
+ DuckDB ``path``) are missing
199
351
  ImportError: If the backend's driver is not installed
200
352
  """
353
+ if self.type in self.SOURCE_ONLY_TYPES:
354
+ raise ValueError(
355
+ f"Profile type '{self.type}' is source-only: it can serve as a "
356
+ f"metric/project 'source_profile' (hybrid mode — the metric's load "
357
+ f"SQL runs there while _dtk_* state stays in the state profile), "
358
+ f"but it cannot hold detectkit state. Point --profile/"
359
+ f"default_profile at one of: {', '.join(sorted(self.STATE_TYPES))}."
360
+ )
201
361
  if self.type == "clickhouse":
202
362
  # `port` is guaranteed non-None here by `_validate_port_required`
203
363
  # (every type but "duckdb" requires it at construction time).
@@ -273,6 +433,53 @@ class ProfileConfig(BaseModel):
273
433
  else:
274
434
  raise ValueError(f"Unsupported database type: {self.type}")
275
435
 
436
+ def create_source_manager(self) -> SourceDatabaseManager:
437
+ """
438
+ Create a manager for use as a hybrid-mode SOURCE (read-only load queries).
439
+
440
+ Source-only types build their lightweight query-only manager here;
441
+ full backends route through :meth:`create_manager` unchanged, so any
442
+ state-capable profile keeps working as a source exactly as before.
443
+
444
+ Returns:
445
+ A manager satisfying the minimal execute_query + close contract
446
+
447
+ Raises:
448
+ ValueError: If required connection fields are missing
449
+ ImportError: If the backend's driver is not installed
450
+ """
451
+ if self.type == "snowflake":
452
+ from detectkit.database.snowflake_manager import SnowflakeSourceManager
453
+
454
+ # Guaranteed by `_validate_snowflake_fields` at construction time.
455
+ assert self.account is not None
456
+ return SnowflakeSourceManager(
457
+ account=self.account,
458
+ user=self.user,
459
+ password=self.password or None,
460
+ private_key_path=self.private_key_path,
461
+ private_key_passphrase=self.private_key_passphrase,
462
+ warehouse=self.warehouse,
463
+ database=self.database,
464
+ schema=self.schema_name,
465
+ role=self.role,
466
+ settings=self.settings,
467
+ )
468
+ if self.type == "bigquery":
469
+ from detectkit.database.bigquery_manager import BigQuerySourceManager
470
+
471
+ # Guaranteed by `_validate_bigquery_fields` at construction time.
472
+ assert self.project is not None
473
+ return BigQuerySourceManager(
474
+ project=self.project,
475
+ credentials_json_path=self.credentials_json_path,
476
+ location=self.location,
477
+ dataset=self.dataset,
478
+ api_endpoint=self.api_endpoint,
479
+ settings=self.settings,
480
+ )
481
+ return self.create_manager()
482
+
276
483
 
277
484
  class ProfilesConfig(BaseModel):
278
485
  """
@@ -383,6 +590,23 @@ class ProfilesConfig(BaseModel):
383
590
  profile = self.get_profile(profile_name)
384
591
  return profile.create_manager(ensure_locations=ensure_locations)
385
592
 
593
+ def create_source_manager(self, profile_name: str | None = None) -> SourceDatabaseManager:
594
+ """
595
+ Create a manager for a profile used as a hybrid-mode SOURCE.
596
+
597
+ Unlike :meth:`create_manager`, source-only profile types (e.g.
598
+ Snowflake) are valid here; full backends behave exactly as with
599
+ :meth:`create_manager`.
600
+
601
+ Args:
602
+ profile_name: Profile name (if None, use default)
603
+
604
+ Returns:
605
+ A manager satisfying the minimal execute_query + close contract
606
+ """
607
+ profile = self.get_profile(profile_name)
608
+ return profile.create_source_manager()
609
+
386
610
  def get_alert_channel_config(self, channel_name: str) -> dict[str, Any]:
387
611
  """
388
612
  Get alert channel configuration by name.