duckstring 0.3.0__tar.gz → 0.5.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 (288) hide show
  1. duckstring-0.5.0/CHANGELOG.md +93 -0
  2. {duckstring-0.3.0 → duckstring-0.5.0}/MANIFEST.in +2 -0
  3. {duckstring-0.3.0/src/duckstring.egg-info → duckstring-0.5.0}/PKG-INFO +18 -2
  4. {duckstring-0.3.0 → duckstring-0.5.0}/README.md +4 -1
  5. {duckstring-0.3.0 → duckstring-0.5.0}/pyproject.toml +25 -1
  6. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/__init__.py +0 -2
  7. duckstring-0.5.0/src/duckstring/alerts/__init__.py +21 -0
  8. duckstring-0.5.0/src/duckstring/alerts/base.py +94 -0
  9. duckstring-0.5.0/src/duckstring/alerts/email.py +106 -0
  10. duckstring-0.5.0/src/duckstring/alerts/event.py +108 -0
  11. duckstring-0.5.0/src/duckstring/alerts/webhook.py +49 -0
  12. duckstring-0.5.0/src/duckstring/catchment/alert_worker.py +58 -0
  13. duckstring-0.5.0/src/duckstring/catchment/app.py +266 -0
  14. duckstring-0.5.0/src/duckstring/catchment/asgi.py +39 -0
  15. duckstring-0.5.0/src/duckstring/catchment/auth.py +220 -0
  16. duckstring-0.5.0/src/duckstring/catchment/cloud.py +156 -0
  17. duckstring-0.5.0/src/duckstring/catchment/cloud_backends.py +156 -0
  18. duckstring-0.5.0/src/duckstring/catchment/cloud_deploy.py +77 -0
  19. duckstring-0.5.0/src/duckstring/catchment/data_lease.py +159 -0
  20. duckstring-0.5.0/src/duckstring/catchment/dialback.py +41 -0
  21. duckstring-0.5.0/src/duckstring/catchment/driver.py +3917 -0
  22. duckstring-0.5.0/src/duckstring/catchment/ec2_launcher.py +372 -0
  23. duckstring-0.5.0/src/duckstring/catchment/egress_worker.py +125 -0
  24. duckstring-0.5.0/src/duckstring/catchment/fargate_launcher.py +298 -0
  25. duckstring-0.5.0/src/duckstring/catchment/flight_sql.py +98 -0
  26. duckstring-0.5.0/src/duckstring/catchment/launcher.py +332 -0
  27. duckstring-0.5.0/src/duckstring/catchment/pg_wire.py +317 -0
  28. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/catchment/poller.py +32 -21
  29. duckstring-0.5.0/src/duckstring/catchment/pool_launcher.py +397 -0
  30. duckstring-0.5.0/src/duckstring/catchment/registry.py +85 -0
  31. duckstring-0.5.0/src/duckstring/catchment/relay.py +217 -0
  32. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/catchment/routes/__init__.py +8 -0
  33. duckstring-0.5.0/src/duckstring/catchment/routes/alerts.py +77 -0
  34. duckstring-0.5.0/src/duckstring/catchment/routes/catchment.py +476 -0
  35. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/catchment/routes/data.py +266 -38
  36. duckstring-0.5.0/src/duckstring/catchment/routes/deploy.py +431 -0
  37. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/catchment/routes/draw.py +50 -16
  38. duckstring-0.5.0/src/duckstring/catchment/routes/duck.py +68 -0
  39. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/catchment/routes/duct.py +8 -6
  40. duckstring-0.5.0/src/duckstring/catchment/routes/metrics.py +135 -0
  41. duckstring-0.5.0/src/duckstring/catchment/routes/orchestrate.py +579 -0
  42. duckstring-0.5.0/src/duckstring/catchment/routes/pool.py +38 -0
  43. duckstring-0.5.0/src/duckstring/catchment/routes/secrets.py +59 -0
  44. duckstring-0.5.0/src/duckstring/catchment/routes/serving.py +104 -0
  45. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/catchment/routes/view.py +3 -1
  46. duckstring-0.5.0/src/duckstring/catchment/schema/007_catchment_key.sql +9 -0
  47. duckstring-0.5.0/src/duckstring/catchment/schema/008_spout.sql +18 -0
  48. duckstring-0.5.0/src/duckstring/catchment/schema/009_spout_state.sql +10 -0
  49. duckstring-0.5.0/src/duckstring/catchment/schema/010_spout_wake.sql +6 -0
  50. duckstring-0.5.0/src/duckstring/catchment/schema/011_spout_window.sql +17 -0
  51. duckstring-0.5.0/src/duckstring/catchment/schema/012_spout_node.sql +20 -0
  52. duckstring-0.5.0/src/duckstring/catchment/schema/013_changed_f.sql +14 -0
  53. duckstring-0.5.0/src/duckstring/catchment/schema/014_alert.sql +38 -0
  54. duckstring-0.5.0/src/duckstring/catchment/schema/016_alert_scope_major.sql +15 -0
  55. duckstring-0.5.0/src/duckstring/catchment/schema/017_alert_renotify.sql +7 -0
  56. duckstring-0.5.0/src/duckstring/catchment/schema/018_duck.sql +11 -0
  57. duckstring-0.5.0/src/duckstring/catchment/schema/019_lineage.sql +18 -0
  58. duckstring-0.5.0/src/duckstring/catchment/schema/020_column_lineage.sql +15 -0
  59. duckstring-0.5.0/src/duckstring/catchment/schema/021_cloud.sql +44 -0
  60. duckstring-0.5.0/src/duckstring/catchment/schema/022_retire_duck_size.sql +25 -0
  61. duckstring-0.5.0/src/duckstring/catchment/schema/023_pool_provider.sql +7 -0
  62. duckstring-0.5.0/src/duckstring/catchment/schema/024_serving.sql +29 -0
  63. duckstring-0.5.0/src/duckstring/catchment/schema/025_dbt.sql +3 -0
  64. duckstring-0.5.0/src/duckstring/catchment/schema/026_deploy_config.sql +7 -0
  65. duckstring-0.5.0/src/duckstring/catchment/schema/027_persist.sql +14 -0
  66. duckstring-0.5.0/src/duckstring/catchment/schema/028_persist_timing.sql +3 -0
  67. duckstring-0.5.0/src/duckstring/catchment/secrets.py +72 -0
  68. duckstring-0.5.0/src/duckstring/catchment/serving.py +209 -0
  69. duckstring-0.5.0/src/duckstring/catchment/state_sync.py +182 -0
  70. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/catchment/static/404.html +1 -1
  71. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/catchment/static/__next.__PAGE__.txt +2 -2
  72. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/catchment/static/__next._full.txt +3 -3
  73. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/catchment/static/__next._head.txt +1 -1
  74. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/catchment/static/__next._index.txt +2 -2
  75. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/catchment/static/__next._tree.txt +2 -2
  76. duckstring-0.5.0/src/duckstring/catchment/static/_next/static/chunks/0_8xp0_1~w7l7.css +1 -0
  77. duckstring-0.5.0/src/duckstring/catchment/static/_next/static/chunks/15o1yi9qh60on.js +2 -0
  78. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/catchment/static/_not-found/__next._full.txt +2 -2
  79. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/catchment/static/_not-found/__next._head.txt +1 -1
  80. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/catchment/static/_not-found/__next._index.txt +2 -2
  81. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/catchment/static/_not-found/__next._not-found.__PAGE__.txt +1 -1
  82. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/catchment/static/_not-found/__next._not-found.txt +1 -1
  83. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/catchment/static/_not-found/__next._tree.txt +2 -2
  84. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/catchment/static/_not-found.html +1 -1
  85. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/catchment/static/_not-found.txt +2 -2
  86. duckstring-0.5.0/src/duckstring/catchment/static/index.html +1 -0
  87. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/catchment/static/index.txt +3 -3
  88. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/cli/__init__.py +36 -2
  89. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/cli/_http.py +4 -0
  90. duckstring-0.5.0/src/duckstring/cli/alert.py +152 -0
  91. duckstring-0.5.0/src/duckstring/cli/bulk.py +131 -0
  92. duckstring-0.5.0/src/duckstring/cli/catchment.py +612 -0
  93. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/cli/config.py +19 -1
  94. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/cli/control.py +52 -0
  95. duckstring-0.5.0/src/duckstring/cli/data.py +266 -0
  96. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/cli/deploy.py +41 -2
  97. duckstring-0.5.0/src/duckstring/cli/duck.py +185 -0
  98. duckstring-0.5.0/src/duckstring/cli/lineage.py +111 -0
  99. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/cli/pond.py +50 -7
  100. duckstring-0.5.0/src/duckstring/cli/secret.py +72 -0
  101. duckstring-0.5.0/src/duckstring/cli/serve.py +94 -0
  102. duckstring-0.5.0/src/duckstring/cli/spout.py +142 -0
  103. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/core.py +227 -24
  104. duckstring-0.5.0/src/duckstring/dataplane.py +994 -0
  105. duckstring-0.5.0/src/duckstring/dbt_mode.py +149 -0
  106. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/demo/catalog/src/pond.py +4 -1
  107. duckstring-0.5.0/src/duckstring/demo/gh_actors/README.md +10 -0
  108. duckstring-0.5.0/src/duckstring/demo/gh_actors/pond.toml +6 -0
  109. duckstring-0.5.0/src/duckstring/demo/gh_actors/src/pond.py +25 -0
  110. duckstring-0.5.0/src/duckstring/demo/gh_events/README.md +21 -0
  111. duckstring-0.5.0/src/duckstring/demo/gh_events/pond.toml +4 -0
  112. duckstring-0.5.0/src/duckstring/demo/gh_events/src/pond.py +109 -0
  113. duckstring-0.5.0/src/duckstring/demo/gh_pushes/README.md +10 -0
  114. duckstring-0.5.0/src/duckstring/demo/gh_pushes/pond.toml +7 -0
  115. duckstring-0.5.0/src/duckstring/demo/gh_pushes/src/pond.py +23 -0
  116. duckstring-0.5.0/src/duckstring/demo/gh_repo_activity/README.md +14 -0
  117. duckstring-0.5.0/src/duckstring/demo/gh_repo_activity/pond.toml +7 -0
  118. duckstring-0.5.0/src/duckstring/demo/gh_repo_activity/src/pond.py +24 -0
  119. duckstring-0.5.0/src/duckstring/demo/gh_stars/README.md +10 -0
  120. duckstring-0.5.0/src/duckstring/demo/gh_stars/pond.toml +6 -0
  121. duckstring-0.5.0/src/duckstring/demo/gh_stars/src/pond.py +19 -0
  122. duckstring-0.5.0/src/duckstring/demo/gh_trending/README.md +10 -0
  123. duckstring-0.5.0/src/duckstring/demo/gh_trending/pond.toml +7 -0
  124. duckstring-0.5.0/src/duckstring/demo/gh_trending/src/pond.py +23 -0
  125. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/demo/priced/src/pond.py +1 -1
  126. duckstring-0.5.0/src/duckstring/demo/shop_analytics/.gitignore +8 -0
  127. duckstring-0.5.0/src/duckstring/demo/shop_analytics/README.md +24 -0
  128. duckstring-0.5.0/src/duckstring/demo/shop_analytics/dbt/dbt_project.yml +12 -0
  129. duckstring-0.5.0/src/duckstring/demo/shop_analytics/dbt/models/orders_clean.sql +8 -0
  130. duckstring-0.5.0/src/duckstring/demo/shop_analytics/dbt/models/revenue_by_product.sql +8 -0
  131. duckstring-0.5.0/src/duckstring/demo/shop_analytics/dbt/models/sources.yml +9 -0
  132. duckstring-0.5.0/src/duckstring/demo/shop_analytics/dbt/models/top_products.sql +8 -0
  133. duckstring-0.5.0/src/duckstring/demo/shop_analytics/pond.toml +11 -0
  134. duckstring-0.5.0/src/duckstring/demo/shop_orders/.gitignore +3 -0
  135. duckstring-0.5.0/src/duckstring/demo/shop_orders/README.md +9 -0
  136. duckstring-0.5.0/src/duckstring/demo/shop_orders/pond.toml +4 -0
  137. duckstring-0.5.0/src/duckstring/demo/shop_orders/src/pond.py +32 -0
  138. duckstring-0.5.0/src/duckstring/demo/tpcds_category_revenue/README.md +13 -0
  139. duckstring-0.5.0/src/duckstring/demo/tpcds_category_revenue/pond.toml +7 -0
  140. duckstring-0.5.0/src/duckstring/demo/tpcds_category_revenue/src/pond.py +26 -0
  141. duckstring-0.5.0/src/duckstring/demo/tpcds_items/README.md +11 -0
  142. duckstring-0.5.0/src/duckstring/demo/tpcds_items/pond.toml +4 -0
  143. duckstring-0.5.0/src/duckstring/demo/tpcds_items/src/pond.py +80 -0
  144. duckstring-0.5.0/src/duckstring/demo/tpcds_priced/README.md +11 -0
  145. duckstring-0.5.0/src/duckstring/demo/tpcds_priced/pond.toml +8 -0
  146. duckstring-0.5.0/src/duckstring/demo/tpcds_priced/src/pond.py +26 -0
  147. duckstring-0.5.0/src/duckstring/demo/tpcds_sales/README.md +22 -0
  148. duckstring-0.5.0/src/duckstring/demo/tpcds_sales/pond.toml +4 -0
  149. duckstring-0.5.0/src/duckstring/demo/tpcds_sales/src/pond.py +76 -0
  150. duckstring-0.5.0/src/duckstring/demo/tpcds_store_revenue/README.md +10 -0
  151. duckstring-0.5.0/src/duckstring/demo/tpcds_store_revenue/pond.toml +7 -0
  152. duckstring-0.5.0/src/duckstring/demo/tpcds_store_revenue/src/pond.py +25 -0
  153. duckstring-0.5.0/src/duckstring/demo/tpcds_stores/README.md +10 -0
  154. duckstring-0.5.0/src/duckstring/demo/tpcds_stores/pond.toml +4 -0
  155. duckstring-0.5.0/src/duckstring/demo/tpcds_stores/src/pond.py +48 -0
  156. duckstring-0.5.0/src/duckstring/duck/__main__.py +309 -0
  157. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/duck/client.py +8 -0
  158. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/duck/core.py +74 -9
  159. duckstring-0.5.0/src/duckstring/duck/dbt_executor.py +170 -0
  160. duckstring-0.5.0/src/duckstring/duck/executor.py +298 -0
  161. duckstring-0.5.0/src/duckstring/duck/pool_agent.py +151 -0
  162. duckstring-0.5.0/src/duckstring/egress/__init__.py +19 -0
  163. duckstring-0.5.0/src/duckstring/egress/base.py +101 -0
  164. duckstring-0.5.0/src/duckstring/egress/credentials.py +93 -0
  165. duckstring-0.5.0/src/duckstring/egress/destination.py +64 -0
  166. duckstring-0.5.0/src/duckstring/egress/object_store.py +244 -0
  167. duckstring-0.5.0/src/duckstring/egress/postgres.py +170 -0
  168. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/engine/__init__.py +8 -0
  169. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/engine/catchment.py +235 -28
  170. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/engine/core.py +37 -0
  171. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/engine/worker.py +6 -3
  172. duckstring-0.5.0/src/duckstring/flock/__init__.py +298 -0
  173. duckstring-0.5.0/src/duckstring/flock/engines/__init__.py +0 -0
  174. duckstring-0.5.0/src/duckstring/flock/engines/athena.py +297 -0
  175. duckstring-0.5.0/src/duckstring/flock/equivalence.py +75 -0
  176. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/iceberg_catalog.py +31 -8
  177. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/iceberg_plane.py +66 -57
  178. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/local/runner.py +12 -1
  179. duckstring-0.5.0/src/duckstring/objects.py +225 -0
  180. duckstring-0.5.0/src/duckstring/schema_contract.py +145 -0
  181. duckstring-0.5.0/src/duckstring/storage.py +664 -0
  182. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/trickle/builder.py +98 -20
  183. duckstring-0.5.0/src/duckstring/trickle/capture.py +384 -0
  184. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/trickle/io.py +153 -43
  185. duckstring-0.5.0/src/duckstring/trickle/lineage.py +305 -0
  186. {duckstring-0.3.0 → duckstring-0.5.0/src/duckstring.egg-info}/PKG-INFO +18 -2
  187. duckstring-0.5.0/src/duckstring.egg-info/SOURCES.txt +266 -0
  188. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring.egg-info/requires.txt +16 -0
  189. duckstring-0.3.0/src/duckstring/catchment/app.py +0 -123
  190. duckstring-0.3.0/src/duckstring/catchment/asgi.py +0 -30
  191. duckstring-0.3.0/src/duckstring/catchment/driver.py +0 -1498
  192. duckstring-0.3.0/src/duckstring/catchment/launcher.py +0 -94
  193. duckstring-0.3.0/src/duckstring/catchment/registry.py +0 -27
  194. duckstring-0.3.0/src/duckstring/catchment/routes/catchment.py +0 -129
  195. duckstring-0.3.0/src/duckstring/catchment/routes/deploy.py +0 -267
  196. duckstring-0.3.0/src/duckstring/catchment/routes/duck.py +0 -31
  197. duckstring-0.3.0/src/duckstring/catchment/routes/orchestrate.py +0 -302
  198. duckstring-0.3.0/src/duckstring/catchment/static/_next/static/chunks/0d-69yy-ja-ef.css +0 -1
  199. duckstring-0.3.0/src/duckstring/catchment/static/_next/static/chunks/0l6vjq3ae5vov.js +0 -2
  200. duckstring-0.3.0/src/duckstring/catchment/static/index.html +0 -1
  201. duckstring-0.3.0/src/duckstring/cli/catchment.py +0 -368
  202. duckstring-0.3.0/src/duckstring/cli/data.py +0 -133
  203. duckstring-0.3.0/src/duckstring/dataplane.py +0 -518
  204. duckstring-0.3.0/src/duckstring/duck/__main__.py +0 -178
  205. duckstring-0.3.0/src/duckstring/duck/executor.py +0 -187
  206. duckstring-0.3.0/src/duckstring/schema_contract.py +0 -67
  207. duckstring-0.3.0/src/duckstring/utils.py +0 -3
  208. duckstring-0.3.0/src/duckstring.egg-info/SOURCES.txt +0 -146
  209. {duckstring-0.3.0 → duckstring-0.5.0}/LICENSE +0 -0
  210. {duckstring-0.3.0 → duckstring-0.5.0}/setup.cfg +0 -0
  211. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/__main__.py +0 -0
  212. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/acc.py +0 -0
  213. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/agg.py +0 -0
  214. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/catchment/__init__.py +0 -0
  215. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/catchment/dag.py +0 -0
  216. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/catchment/db.py +0 -0
  217. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/catchment/schema/001_init.sql +0 -0
  218. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/catchment/schema/002_ducts.sql +0 -0
  219. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/catchment/schema/003_pull_m.sql +0 -0
  220. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/catchment/schema/004_identity.sql +0 -0
  221. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/catchment/schema/005_pond_version_schema.sql +0 -0
  222. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/catchment/schema/006_refresh.sql +0 -0
  223. {duckstring-0.3.0/src/duckstring/catchment/static/_next/static/Z066xVKlXhWUYRNRq4sUP → duckstring-0.5.0/src/duckstring/catchment/static/_next/static/7b3BoSoT7Zw6j1d3GFlHs}/_buildManifest.js +0 -0
  224. {duckstring-0.3.0/src/duckstring/catchment/static/_next/static/Z066xVKlXhWUYRNRq4sUP → duckstring-0.5.0/src/duckstring/catchment/static/_next/static/7b3BoSoT7Zw6j1d3GFlHs}/_clientMiddlewareManifest.js +0 -0
  225. {duckstring-0.3.0/src/duckstring/catchment/static/_next/static/Z066xVKlXhWUYRNRq4sUP → duckstring-0.5.0/src/duckstring/catchment/static/_next/static/7b3BoSoT7Zw6j1d3GFlHs}/_ssgManifest.js +0 -0
  226. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/catchment/static/_next/static/chunks/03~yq9q893hmn.js +0 -0
  227. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/catchment/static/_next/static/chunks/07lhk_q6pmm3r.js +0 -0
  228. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/catchment/static/_next/static/chunks/0dbhjjzl8qfwv.js +0 -0
  229. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/catchment/static/_next/static/chunks/0jvmviuftg5e2.css +0 -0
  230. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/catchment/static/_next/static/chunks/0kczw6usu-y-f.js +0 -0
  231. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/catchment/static/_next/static/chunks/11kjahy2ntf0n.js +0 -0
  232. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/catchment/static/_next/static/chunks/turbopack-03~mbvk_uplk_.js +0 -0
  233. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/catchment/static/favicon.svg +0 -0
  234. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/catchment/static/logo-mark.svg +0 -0
  235. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/catchment/static/logo.svg +0 -0
  236. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/cli/duct.py +0 -0
  237. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/cli/puddle.py +0 -0
  238. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/cli/status.py +0 -0
  239. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/cli/trigger.py +0 -0
  240. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/cli/window.py +0 -0
  241. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/demo/catalog/.gitignore +0 -0
  242. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/demo/catalog/README.md +0 -0
  243. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/demo/catalog/pond.toml +0 -0
  244. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/demo/orders/.gitignore +0 -0
  245. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/demo/orders/README.md +0 -0
  246. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/demo/orders/pond.toml +0 -0
  247. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/demo/orders/src/pond.py +0 -0
  248. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/demo/priced/.gitignore +0 -0
  249. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/demo/priced/README.md +0 -0
  250. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/demo/priced/pond.toml +0 -0
  251. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/demo/priced/src/puddles.py +0 -0
  252. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/demo/products/.gitignore +0 -0
  253. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/demo/products/README.md +0 -0
  254. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/demo/products/pond.toml +0 -0
  255. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/demo/products/src/pond.py +0 -0
  256. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/demo/reports/.gitignore +0 -0
  257. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/demo/reports/README.md +0 -0
  258. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/demo/reports/pond.toml +0 -0
  259. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/demo/reports/src/pond.py +0 -0
  260. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/demo/revenue/.gitignore +0 -0
  261. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/demo/revenue/README.md +0 -0
  262. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/demo/revenue/pond.toml +0 -0
  263. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/demo/revenue/src/pond.py +0 -0
  264. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/demo/revenue/src/puddles.py +0 -0
  265. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/demo/sales/.gitignore +0 -0
  266. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/demo/sales/README.md +0 -0
  267. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/demo/sales/pond.toml +0 -0
  268. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/demo/sales/src/pond.py +0 -0
  269. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/demo/sales/src/puddles.py +0 -0
  270. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/demo/transactions/.gitignore +0 -0
  271. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/demo/transactions/README.md +0 -0
  272. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/demo/transactions/pond.toml +0 -0
  273. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/demo/transactions/src/pond.py +0 -0
  274. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/duck/__init__.py +0 -0
  275. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/engine/pond.py +0 -0
  276. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/keys.py +0 -0
  277. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/local/__init__.py +0 -0
  278. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/local/hydrate.py +0 -0
  279. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/local/project.py +0 -0
  280. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/trickle/__init__.py +0 -0
  281. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/trickle/acc.py +0 -0
  282. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/trickle/agg.py +0 -0
  283. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/trickle/context.py +0 -0
  284. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/trickle_builder.py +0 -0
  285. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/trickle_io.py +0 -0
  286. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring.egg-info/dependency_links.txt +0 -0
  287. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring.egg-info/entry_points.txt +0 -0
  288. {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring.egg-info/top_level.txt +0 -0
@@ -0,0 +1,93 @@
1
+ # Changelog
2
+
3
+ Notable changes per release. Versions before 0.5.0 are recorded in the git history and the `v*` tags.
4
+
5
+ ## 0.5.0 — 2026-08-03
6
+
7
+ The cloud release: a Catchment can now run its Ducks on AWS, keep its data on S3, and serve that data
8
+ over standard wire protocols. Everything below is opt-in — a Catchment with no cloud configuration
9
+ behaves exactly as it did in 0.4.0, and a `pond.toml` asking for remote compute still runs anywhere.
10
+
11
+ ### Cloud compute
12
+
13
+ - **Duck launchers**: a Pond's Duck runs on the Catchment's own box (default), on **Fargate** (the
14
+ default remote backend — serverless containers, task-role IAM), or on **EC2** (the escape hatch, for
15
+ sizes and GPUs Fargate does not offer). Built-in **S/M/L/XL preset pools** resolve with zero setup.
16
+ - **Duck Pools**: named remote-compute pools (`duckstring duck pool add`), with per-Pond compute
17
+ declared in `pond.toml` and overridable operationally (`duckstring duck set`).
18
+ - **The Pool agent**: one shared machine per named Pool hosting many co-resident Ducks.
19
+ - **Auto-relay**: a Catchment on a laptop behind NAT can run cloud Ducks with no manual tunnel — the
20
+ first remote spawn provisions a small always-reachable box and holds a reverse tunnel to it.
21
+ - **Remote failures are observable**: boot output is teed to the console (EC2) / CloudWatch (Fargate)
22
+ and the tail is attached to the Pond's failure, so a Duck that dies before dialling back still says
23
+ why. Spawn-failure reasons (missing image, missing AMI, bad IAM) surface on the Pond rather than as a
24
+ generic crash.
25
+ - Per-provider **startup grace** so a cold machine is not judged by the steady-state silence window.
26
+
27
+ ### Data plane on S3
28
+
29
+ - A Catchment's data root can be an object store (`duckstring catchment settings --data-root s3://…`),
30
+ with an S3-compatible **endpoint override** for MinIO/Ceph/R2.
31
+ - **Switch, adopt, or migrate** an existing plane, with a background copy and live progress.
32
+ - **Local-first publish + async Persist**: a run publishes locally and mirrors durably in the
33
+ background, so compute never waits on the object store; `persisted_f` is the durable watermark and
34
+ freshness gating is Pool-aware.
35
+ - **S3-resident state**: a merge base is read as a view rather than hydrated, and retention pruning is
36
+ floor-anchored rather than inferred from absence.
37
+
38
+ ### The Flock — over-envelope compute
39
+
40
+ A comprehensive `pond.trickle(...)` recompute that exceeds the Duck's memory envelope can be dispatched
41
+ to a serverless engine (**Athena** ships first), while the Duck keeps merge/diff/publish.
42
+
43
+ **DuckDB is the authority.** Dispatch decides *where* work runs, never *what* is published, enforced
44
+ structurally: an engine-owned allow-list of expressions proven equivalent on both engines (division and
45
+ CAST are excluded — the two engines genuinely disagree), a `conform` step that casts the engine's result
46
+ to DuckDB's own schema and rejects a differing column set, and degradation to local compute on any
47
+ failure. Dispatch counters ride `/metrics`, since a silently degrading Flock is otherwise invisible.
48
+
49
+ ### Data serving
50
+
51
+ - A sandboxed, warm, read-only query surface over published data: catalog = catchment, schema = pond.
52
+ - **Postgres wire** and **Arrow Flight SQL** adapters, plus `/api/serve` and a CLI.
53
+ - A **Catalog UI** unifying the query surface, with role-governed access.
54
+
55
+ ### Lineage
56
+
57
+ - Table-level (observed per run), column-level (static per version, with a sqlglot upgrade for SQL
58
+ outputs), and row-level temporal provenance (`duckstring trace`).
59
+ - **OpenLineage** events emitted on run completion for catalog integration.
60
+
61
+ ### dbt-mode Ponds
62
+
63
+ Deploy a dbt project as a Pond with no `@ripple` code: each dbt model becomes a Ripple with its own
64
+ freshness, failure and retry tracking, and `ref()` becomes the intra-Pond graph. Opt in with the
65
+ `duckstring[dbt]` extra.
66
+
67
+ ### Correctness and operability
68
+
69
+ - **Version contract**: a lossless widening of a column type is accepted (a data-dependent branch could
70
+ legitimately wedge a live Pond permanently); `reset-contract` is the escape hatch for a genuine
71
+ narrowing; contract failures carry a dedicated sub-reason end-to-end.
72
+ - **Reads reject a stale local publish** the Catchment knows is behind, rather than serving it.
73
+ - Pond Runs the Pond has moved past are closed rather than stranded at `running`.
74
+ - A Pool machine that disappears is detected and relaunched, instead of wedging every Pond on it.
75
+ - The **serving sandbox** no longer spills into the shared system temp — DuckDB exempts its temp
76
+ directory from the external-access lock, which on Linux exposed everything under `/tmp` to a
77
+ read-level user.
78
+ - An idle Duck backs off instead of polling ten times a second.
79
+ - Opt-in **re-notify cadence** for alert channels while a failure or freshness episode persists.
80
+ - Incremental object-store egress (`mode=append`) mirrors the published collection per delivery.
81
+
82
+ ### Testing
83
+
84
+ - The object-store data plane runs against a **real S3 API** in CI (MinIO, which rejects unsigned
85
+ requests — the class of bug moto cannot catch).
86
+ - Real-data demo sets: **TPC-DS** and **GHArchive** (the latter offline against a committed fixture).
87
+ - Python matrix, the dbt extra, real-Postgres egress, and frontend tests in CI.
88
+
89
+ ### Documentation
90
+
91
+ - A full **AWS guide** (`guides/cloud.md`): the cloud-enable gate, the three IAM roles, security-group
92
+ asymmetry, the AMI Python constraint, the build-your-own-image policy, and the Flock's authority rules.
93
+ - New guides for lineage, dbt-mode, and querying data.
@@ -1,5 +1,7 @@
1
1
  # The sdist ships the installable package only — keep internal design notes and the test suite
2
2
  # out of the PyPI source tarball (they're in the git repo for contributors).
3
+ include CHANGELOG.md
4
+
3
5
  prune tests
4
6
  prune plans
5
7
  prune experiment
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: duckstring
3
- Version: 0.3.0
3
+ Version: 0.5.0
4
4
  Summary: Build data pipelines the way you build software: version each transform, declare its dependencies, and Duckstring resolves the execution DAG automatically.
5
5
  Author-email: Duckstring <dev@duckstring.com>
6
6
  License: Apache License
@@ -225,15 +225,29 @@ Requires-Dist: pyiceberg>=0.7
225
225
  Requires-Dist: pyarrow>=14
226
226
  Requires-Dist: pytz
227
227
  Provides-Extra: iceberg
228
+ Provides-Extra: dbt
229
+ Requires-Dist: dbt-duckdb>=1.9; extra == "dbt"
230
+ Provides-Extra: lineage
231
+ Requires-Dist: sqlglot>=23; extra == "lineage"
232
+ Provides-Extra: aws
233
+ Requires-Dist: s3fs>=2023.1; extra == "aws"
234
+ Requires-Dist: boto3>=1.34; extra == "aws"
228
235
  Provides-Extra: dev
229
236
  Requires-Dist: pytest>=8.0; extra == "dev"
237
+ Requires-Dist: moto[server]>=5.0; extra == "dev"
230
238
  Requires-Dist: pytest-timeout>=0.5; extra == "dev"
231
239
  Requires-Dist: ruff>=0.3; extra == "dev"
232
240
  Requires-Dist: mypy>=1.8; extra == "dev"
241
+ Requires-Dist: dbt-duckdb>=1.9; extra == "dev"
242
+ Requires-Dist: sqlglot>=23; extra == "dev"
243
+ Requires-Dist: s3fs>=2023.1; extra == "dev"
244
+ Requires-Dist: boto3>=1.34; extra == "dev"
245
+ Requires-Dist: pg8000>=1.31; extra == "dev"
233
246
  Dynamic: license-file
234
247
 
235
248
  # Duckstring
236
- *There is no DAG.*
249
+
250
+ Build data pipelines the way you build software: version each transform, declare its dependencies, and Duckstring resolves the execution DAG automatically.
237
251
 
238
252
  Duckstring treats data transformations as software packages. Upstream dependencies are declared per Pond (unit operation), defining the DAG without the need for its direct management.
239
253
 
@@ -297,6 +311,8 @@ Ponds execute on demand signals sent to an Outlet, in two flavours — **push**
297
311
 
298
312
  A Tide keeps an Outlet no staler than a bound (`duckstring trigger tide reports 1d`); a Wave keeps it as fresh as the pipeline can supply. See [Triggers](https://docs.duckstring.com/guides/triggers) for the full semantics.
299
313
 
314
+ *There is no DAG.*
315
+
300
316
  ## Going further
301
317
 
302
318
  Full documentation lives at **[docs.duckstring.com](https://docs.duckstring.com)**:
@@ -1,5 +1,6 @@
1
1
  # Duckstring
2
- *There is no DAG.*
2
+
3
+ Build data pipelines the way you build software: version each transform, declare its dependencies, and Duckstring resolves the execution DAG automatically.
3
4
 
4
5
  Duckstring treats data transformations as software packages. Upstream dependencies are declared per Pond (unit operation), defining the DAG without the need for its direct management.
5
6
 
@@ -63,6 +64,8 @@ Ponds execute on demand signals sent to an Outlet, in two flavours — **push**
63
64
 
64
65
  A Tide keeps an Outlet no staler than a bound (`duckstring trigger tide reports 1d`); a Wave keeps it as fresh as the pipeline can supply. See [Triggers](https://docs.duckstring.com/guides/triggers) for the full semantics.
65
66
 
67
+ *There is no DAG.*
68
+
66
69
  ## Going further
67
70
 
68
71
  Full documentation lives at **[docs.duckstring.com](https://docs.duckstring.com)**:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "duckstring"
3
- version = "0.3.0"
3
+ version = "0.5.0"
4
4
  description = "Build data pipelines the way you build software: version each transform, declare its dependencies, and Duckstring resolves the execution DAG automatically."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -35,11 +35,35 @@ dependencies = [
35
35
  [project.optional-dependencies]
36
36
  # Retained for back-compat: the Iceberg deps are in core now (Iceberg is the default plane).
37
37
  iceberg = []
38
+ # dbt-mode Ponds (plans/dbt.md): deploy a dbt project as a Pond, each model a Ripple. Heavyweight and
39
+ # opt-in — only pulled when a team actually runs dbt-mode. dbt-duckdb pulls dbt-core transitively.
40
+ dbt = [
41
+ "dbt-duckdb>=1.9",
42
+ ]
43
+ # Column lineage through .sql() escape hatches (plans/lineage.md Phase 3): a real SQL parser, opt-in --
44
+ # without it those outputs are honestly "opaque", never guessed. Pure python, light.
45
+ lineage = [
46
+ "sqlglot>=23",
47
+ ]
48
+ # AWS / cloud (plans/cloud-config.md): the object-store data plane + egress (s3fs — fsspec's small-file
49
+ # work; DuckDB does the bulk Parquet I/O over httpfs on its own), and the remote-compute launchers
50
+ # (EC2/Fargate/relay) + the Athena Flock engine (boto3). Opt-in — a local/offline Catchment needs none.
51
+ aws = [
52
+ "s3fs>=2023.1",
53
+ "boto3>=1.34",
54
+ ]
38
55
  dev = [
39
56
  "pytest>=8.0",
57
+ # A local S3 API, so the object-store data plane is a normal test rather than a live-AWS session.
58
+ "moto[server]>=5.0",
40
59
  "pytest-timeout>=0.5",
41
60
  "ruff>=0.3",
42
61
  "mypy>=1.8",
62
+ "dbt-duckdb>=1.9",
63
+ "sqlglot>=23",
64
+ "s3fs>=2023.1",
65
+ "boto3>=1.34",
66
+ "pg8000>=1.31",
43
67
  ]
44
68
 
45
69
  [tool.setuptools]
@@ -5,7 +5,6 @@ from .core import (
5
5
  Catchment,
6
6
  Pond,
7
7
  Puddle,
8
- Ripple,
9
8
  puddle,
10
9
  ripple,
11
10
  )
@@ -19,7 +18,6 @@ __all__ = [
19
18
  "Catchment",
20
19
  "Pond",
21
20
  "Puddle",
22
- "Ripple",
23
21
  "puddle",
24
22
  "ripple",
25
23
  "__version__",
@@ -0,0 +1,21 @@
1
+ """Alerts — failure & freshness notifications to external channels. See plans/alerts.md.
2
+
3
+ The observability sibling of a Spout: operational config + a scheme-selected notifier seam +
4
+ ``${env:}``/``${secret:}`` credentials + an async delivery worker that never cascades a failure back into
5
+ the engine. Alerting *observes* the state the engine already computes; it adds no orchestration state.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from .base import Notifier, NotifierError, get_notifier, parse_notifier_destination
11
+ from .event import KNOWN_EVENTS, AlertEvent, normalise_events
12
+
13
+ __all__ = [
14
+ "AlertEvent",
15
+ "KNOWN_EVENTS",
16
+ "Notifier",
17
+ "NotifierError",
18
+ "get_notifier",
19
+ "normalise_events",
20
+ "parse_notifier_destination",
21
+ ]
@@ -0,0 +1,94 @@
1
+ """The notifier seam (see plans/alerts.md) — mirrors :mod:`duckstring.egress.base`.
2
+
3
+ A small, scheme-selected interface the alert worker delivers an :class:`AlertEvent` through. The channel
4
+ destination is a URI whose scheme picks the notifier; ``get_notifier(destination)`` resolves it, exactly
5
+ like ``get_egress``. Credentials travel inside the URI as ``${env:NAME}``/``${secret:NAME}`` references and
6
+ are resolved only at send time (:mod:`duckstring.egress.credentials`) — never persisted or logged resolved.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+ from typing import Callable, Protocol, runtime_checkable
13
+ from urllib.parse import urlparse
14
+
15
+ from ..egress import credentials
16
+ from .event import AlertEvent
17
+
18
+ # Schemes a channel may target. http/https → a webhook (Slack-incoming-webhook compatible); mailto → SMTP.
19
+ WEBHOOK_SCHEMES = {"http", "https"}
20
+ EMAIL_SCHEMES = {"mailto"}
21
+ KNOWN_SCHEMES = WEBHOOK_SCHEMES | EMAIL_SCHEMES
22
+
23
+
24
+ class NotifierError(ValueError):
25
+ """An invalid channel destination, or a delivery/connectivity failure (sanitised — never a credential)."""
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class Destination:
30
+ scheme: str
31
+ raw: str # the original URI, with ${...} references intact (resolved only at send time)
32
+
33
+
34
+ def parse_notifier_destination(uri: str) -> Destination:
35
+ """Validate a channel destination URI: a known scheme + well-formed ``${...}`` references. Does **not**
36
+ resolve credentials (so a channel can be created before its secrets are present). Raises NotifierError."""
37
+ if not uri or not uri.strip():
38
+ raise NotifierError("destination must not be empty")
39
+ try:
40
+ credentials.references(uri) # validates ${env:}/${secret:} syntax
41
+ except credentials.CredentialError as exc:
42
+ raise NotifierError(str(exc)) from exc
43
+ scheme = urlparse(uri).scheme.lower()
44
+ if not scheme:
45
+ raise NotifierError(f"destination {uri!r} has no scheme — expected e.g. https://…, mailto:…")
46
+ if scheme not in KNOWN_SCHEMES:
47
+ raise NotifierError(
48
+ f"unsupported alert destination scheme {scheme!r} — supported: {', '.join(sorted(KNOWN_SCHEMES))}"
49
+ )
50
+ return Destination(scheme=scheme, raw=uri)
51
+
52
+
53
+ @runtime_checkable
54
+ class Notifier(Protocol):
55
+ def send(self, event: AlertEvent) -> None:
56
+ """Deliver ``event`` to the destination. Raises :class:`NotifierError` (sanitised) on failure."""
57
+ ...
58
+
59
+ def test(self) -> None:
60
+ """Probe connectivity/credentials without delivering a real alert (the ``alert test`` command).
61
+ Returns on success; raises :class:`NotifierError` (sanitised) on failure."""
62
+ ...
63
+
64
+
65
+ _REGISTRY: dict[str, Callable[[Destination], Notifier]] = {}
66
+
67
+
68
+ def register(scheme: str, factory: Callable[[Destination], Notifier]) -> None:
69
+ _REGISTRY[scheme] = factory
70
+
71
+
72
+ def get_notifier(destination: str) -> Notifier:
73
+ """Resolve the notifier for a channel destination by its scheme. Raises :class:`NotifierError` for an
74
+ unknown scheme, or a known scheme whose notifier is not built."""
75
+ dest = parse_notifier_destination(destination)
76
+ factory = _REGISTRY.get(dest.scheme)
77
+ if factory is None:
78
+ raise NotifierError(
79
+ f"notifier for scheme {dest.scheme!r} is not implemented yet (built: "
80
+ f"{', '.join(sorted(_REGISTRY)) or 'none'})"
81
+ )
82
+ return factory(dest)
83
+
84
+
85
+ def _register_builtins() -> None:
86
+ from .email import EmailNotifier
87
+ from .webhook import WebhookNotifier
88
+
89
+ for driver in (WebhookNotifier, EmailNotifier):
90
+ for scheme in driver.SCHEMES:
91
+ register(scheme, driver)
92
+
93
+
94
+ _register_builtins()
@@ -0,0 +1,106 @@
1
+ """The email notifier (``mailto:``) — the simplest floor.
2
+
3
+ ``mailto:ops@x.com,dev@x.com?smtp=host:587&from=alerts@x.com&user=${env:SMTP_USER}&password=${secret:SMTP_PASS}&tls=1``
4
+
5
+ SMTP host/port/user/password/from come from the URI query, or the ``DUCKSTRING_SMTP_*`` environment as a
6
+ fallback (so a Catchment can carry one SMTP config for every mailto channel). Credentials are resolved from
7
+ ``${env:}``/``${secret:}`` only at send time. Uses the stdlib ``smtplib`` — no new dependency.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ from email.message import EmailMessage
14
+ from urllib.parse import parse_qs, unquote, urlparse
15
+
16
+ from .base import Destination, NotifierError
17
+ from .event import AlertEvent
18
+
19
+ _TIMEOUT = 20.0
20
+
21
+
22
+ class EmailNotifier:
23
+ SCHEMES = ("mailto",)
24
+
25
+ def __init__(self, dest: Destination):
26
+ parsed = urlparse(dest.raw)
27
+ self.recipients = [r.strip() for r in unquote(parsed.path).split(",") if r.strip()]
28
+ if not self.recipients:
29
+ raise NotifierError("mailto: destination has no recipient — use mailto:you@example.com")
30
+ q = {k: v[0] for k, v in parse_qs(parsed.query).items()}
31
+ self._raw = q # references intact; resolved at send time
32
+ self.smtp = q.get("smtp") or os.environ.get("DUCKSTRING_SMTP_HOST", "")
33
+ self.sender = q.get("from") or os.environ.get("DUCKSTRING_SMTP_FROM", "duckstring@localhost")
34
+ tls = q.get("tls")
35
+ self.tls = (tls not in ("0", "false", "no")) if tls is not None else \
36
+ (os.environ.get("DUCKSTRING_SMTP_TLS", "1") not in ("0", "false", "no"))
37
+ if not self.smtp:
38
+ raise NotifierError(
39
+ "mailto: destination needs an SMTP server — add ?smtp=host:port or set DUCKSTRING_SMTP_HOST"
40
+ )
41
+
42
+ def _host_port(self) -> tuple[str, int]:
43
+ host, _, port = self.smtp.partition(":")
44
+ return host, int(port) if port else 587
45
+
46
+ def _credentials(self) -> tuple[str | None, str | None]:
47
+ from ..egress import credentials
48
+
49
+ user = self._raw.get("user") or os.environ.get("DUCKSTRING_SMTP_USER")
50
+ password = self._raw.get("password") or os.environ.get("DUCKSTRING_SMTP_PASSWORD")
51
+ # Resolve ${env:}/${secret:} refs at call time — never persisted/logged resolved.
52
+ user = credentials.resolve(user) if user else None
53
+ password = credentials.resolve(password) if password else None
54
+ return user, password
55
+
56
+ def _connect(self):
57
+ import smtplib
58
+
59
+ host, port = self._host_port()
60
+ server = smtplib.SMTP(host, port, timeout=_TIMEOUT)
61
+ try:
62
+ server.ehlo()
63
+ if self.tls:
64
+ server.starttls()
65
+ server.ehlo()
66
+ user, password = self._credentials()
67
+ if user and password:
68
+ server.login(user, password)
69
+ except Exception:
70
+ server.close()
71
+ raise
72
+ return server
73
+
74
+ def send(self, event: AlertEvent) -> None:
75
+ msg = EmailMessage()
76
+ msg["Subject"] = event.summary()
77
+ msg["From"] = self.sender
78
+ msg["To"] = ", ".join(self.recipients)
79
+ lines = [event.message]
80
+ if event.pond:
81
+ lines.append(f"\nPond: {event.pond}")
82
+ if event.f:
83
+ lines.append(f"Freshness: {event.f}")
84
+ if event.detail:
85
+ for k, v in event.detail.items():
86
+ lines.append(f"{k}: {v}")
87
+ msg.set_content("\n".join(lines))
88
+ try:
89
+ server = self._connect()
90
+ try:
91
+ server.send_message(msg)
92
+ finally:
93
+ server.quit()
94
+ except NotifierError:
95
+ raise
96
+ except Exception as exc: # noqa: BLE001 — sanitise: type only, never the credential/host detail
97
+ raise NotifierError(f"email delivery failed: {type(exc).__name__}") from None
98
+
99
+ def test(self) -> None:
100
+ try:
101
+ server = self._connect() # connect + STARTTLS + login, deliver nothing
102
+ server.quit()
103
+ except NotifierError:
104
+ raise
105
+ except Exception as exc: # noqa: BLE001
106
+ raise NotifierError(f"SMTP connection failed: {type(exc).__name__}") from None
@@ -0,0 +1,108 @@
1
+ """The alert event model + the event-kind vocabulary (see plans/alerts.md).
2
+
3
+ An :class:`AlertEvent` is the rendered, **sanitised** payload a notifier delivers. It reuses what
4
+ ``/api/runs`` surfaces (error message, freshness, pond) but never a raw traceback — a channel destination
5
+ can be third-party, and a traceback can leak paths/connection strings (the same concern behind the API's
6
+ ``_redact_tracebacks``). Keep it JSON-serialisable: it is stored verbatim in the ``alert_delivery`` outbox.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass, field
12
+ from datetime import datetime, timezone
13
+
14
+ # The event vocabulary. `freshness` is tick-driven (the SLA sweep); the rest are transition-driven.
15
+ KNOWN_EVENTS = ("failure", "contract", "spout", "recovery", "freshness")
16
+
17
+ # Machine-consumer kinds: valid subscriptions, but deliberately EXCLUDED from the `all` expansion —
18
+ # an ops Slack channel subscribed to `all` must never receive raw catalog events. `openlineage` carries
19
+ # a standard OpenLineage RunEvent per completed Pond Run (plans/lineage.md Phase 4); the webhook
20
+ # notifier posts it verbatim (no Slack `text` wrapper).
21
+ EXPLICIT_EVENTS = ("openlineage",)
22
+
23
+ # Default severity per kind (a channel filters by kind, not severity — this is only for the payload/label).
24
+ SEVERITY = {
25
+ "failure": "error",
26
+ "contract": "error",
27
+ "spout": "error",
28
+ "freshness": "warning",
29
+ "recovery": "info",
30
+ "openlineage": "info",
31
+ }
32
+
33
+
34
+ def normalise_events(events: str | None) -> tuple[str, ...]:
35
+ """Parse a channel's ``events`` CSV (or ``all``/empty) into a validated tuple of known kinds.
36
+
37
+ ``all`` / empty → every *notification* kind (machine-consumer kinds like ``openlineage`` need an
38
+ explicit subscription). Raises :class:`ValueError` naming an unknown kind."""
39
+ if not events or events.strip().lower() == "all":
40
+ return KNOWN_EVENTS
41
+ out = []
42
+ for raw in events.split(","):
43
+ kind = raw.strip().lower()
44
+ if not kind:
45
+ continue
46
+ if kind not in KNOWN_EVENTS and kind not in EXPLICIT_EVENTS:
47
+ raise ValueError(
48
+ f"unknown alert event {kind!r} — choose from "
49
+ f"{', '.join(KNOWN_EVENTS + EXPLICIT_EVENTS)} (or 'all')"
50
+ )
51
+ out.append(kind)
52
+ if not out:
53
+ return KNOWN_EVENTS
54
+ return tuple(dict.fromkeys(out)) # de-duplicated, order preserved
55
+
56
+
57
+ @dataclass
58
+ class AlertEvent:
59
+ """A rendered notification. ``detail`` carries kind-specific extras (e.g. the blocked-downstream blast
60
+ radius for a failure); never put a traceback in it."""
61
+
62
+ kind: str
63
+ pond: str | None
64
+ title: str
65
+ message: str
66
+ severity: str = ""
67
+ f: str | None = None
68
+ catchment: str | None = None
69
+ ts: str = ""
70
+ detail: dict = field(default_factory=dict)
71
+
72
+ def __post_init__(self) -> None:
73
+ if not self.severity:
74
+ self.severity = SEVERITY.get(self.kind, "info")
75
+ if not self.ts:
76
+ self.ts = datetime.now(timezone.utc).isoformat()
77
+
78
+ def summary(self) -> str:
79
+ """A one-line human summary (the webhook ``text`` / the email subject line)."""
80
+ where = f" [{self.catchment}]" if self.catchment else ""
81
+ return f"{self.severity.upper()}{where}: {self.title}"
82
+
83
+ def to_payload(self) -> dict:
84
+ return {
85
+ "kind": self.kind,
86
+ "severity": self.severity,
87
+ "pond": self.pond,
88
+ "f": self.f,
89
+ "title": self.title,
90
+ "message": self.message,
91
+ "catchment": self.catchment,
92
+ "ts": self.ts,
93
+ "detail": self.detail,
94
+ }
95
+
96
+ @classmethod
97
+ def from_payload(cls, payload: dict) -> "AlertEvent":
98
+ return cls(
99
+ kind=payload.get("kind", ""),
100
+ pond=payload.get("pond"),
101
+ title=payload.get("title", ""),
102
+ message=payload.get("message", ""),
103
+ severity=payload.get("severity", ""),
104
+ f=payload.get("f"),
105
+ catchment=payload.get("catchment"),
106
+ ts=payload.get("ts", ""),
107
+ detail=payload.get("detail") or {},
108
+ )
@@ -0,0 +1,49 @@
1
+ """The webhook notifier (``http``/``https``) — the highest-leverage channel.
2
+
3
+ POSTs a JSON body that is both a plain structured event **and** Slack-incoming-webhook compatible: a
4
+ top-level ``text`` summary (which Slack renders, and a generic receiver can read) plus the full structured
5
+ event. So one driver covers Slack, a generic webhook receiver, and (via a proxy) PagerDuty's Events API.
6
+ Any credential/token in the URL is a ``${env:}``/``${secret:}`` reference, resolved only at send time.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from .base import Destination, NotifierError
12
+ from .event import AlertEvent
13
+
14
+ _TIMEOUT = 15.0
15
+
16
+
17
+ class WebhookNotifier:
18
+ SCHEMES = ("http", "https")
19
+
20
+ def __init__(self, dest: Destination):
21
+ self.dest = dest
22
+
23
+ def _post(self, event: AlertEvent) -> None:
24
+ import httpx
25
+
26
+ from ..egress import credentials
27
+
28
+ url = credentials.resolve(self.dest.raw) # resolve any ${env:}/${secret:} token — never logged
29
+ if event.kind == "openlineage" and event.detail.get("event"):
30
+ body = event.detail["event"] # a standard OpenLineage RunEvent — posted verbatim, no wrapper
31
+ else:
32
+ body = {"text": event.summary(), **event.to_payload()} # `text` for Slack; the rest generic
33
+ try:
34
+ resp = httpx.post(url, json=body, timeout=_TIMEOUT)
35
+ resp.raise_for_status()
36
+ except httpx.HTTPStatusError as exc:
37
+ # Sanitise: report status + reason, never the URL (it may carry a token in the path/query).
38
+ raise NotifierError(f"webhook returned {exc.response.status_code}") from None
39
+ except httpx.HTTPError as exc:
40
+ raise NotifierError(f"webhook delivery failed: {type(exc).__name__}") from None
41
+
42
+ def send(self, event: AlertEvent) -> None:
43
+ self._post(event)
44
+
45
+ def test(self) -> None:
46
+ self._post(AlertEvent(
47
+ kind="recovery", pond=None, title="Duckstring alert channel test",
48
+ message="This is a test notification — your alert channel is configured correctly.",
49
+ ))
@@ -0,0 +1,58 @@
1
+ """The alert worker — delivers queued notifications to their channels. See plans/alerts.md.
2
+
3
+ One async task in the Catchment process (outbound I/O, the exact shape of the egress worker): each pass it
4
+ drains the pending ``alert_delivery`` rows the engine enqueued (:meth:`Driver.take_alert_deliveries`),
5
+ resolves each channel's notifier by scheme, ``send``s it in a threadpool with a per-send timeout, and marks
6
+ the row sent (:meth:`Driver.mark_delivery_sent`) or bumps attempts / parks it failed at the cap
7
+ (:meth:`Driver.mark_delivery_failed`).
8
+
9
+ **A send failure never propagates into the engine** — it is recorded on the delivery row (auditable via
10
+ ``alert log``) and retried on the next tick until it succeeds or hits ``MAX_ATTEMPTS``. A permanently-broken
11
+ channel therefore stops retrying but leaves a visible failed row, never a Pond failure.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import asyncio
17
+
18
+ from fastapi.concurrency import run_in_threadpool
19
+
20
+ _RECONCILE_INTERVAL = 5.0 # self-healing tick — retries pending deliveries a previous send left behind
21
+ _PER_SEND_TIMEOUT = 30.0 # ceiling on one delivery, so a slow channel can't starve the others
22
+ MAX_ATTEMPTS = 6 # after this many failed sends, park the delivery 'failed' (stop retrying)
23
+
24
+
25
+ def _deliver(destination: str, payload: dict) -> None:
26
+ """Send one notification (blocking — runs in the thread pool). Raises on any failure."""
27
+ from ..alerts import AlertEvent, get_notifier
28
+
29
+ notifier = get_notifier(destination) # resolves the scheme's driver (+ validates the URI)
30
+ notifier.send(AlertEvent.from_payload(payload))
31
+
32
+
33
+ async def _drain(driver) -> None:
34
+ for row in driver.take_alert_deliveries():
35
+ try:
36
+ await asyncio.wait_for(
37
+ run_in_threadpool(_deliver, row["destination"], row["payload"]),
38
+ timeout=_PER_SEND_TIMEOUT,
39
+ )
40
+ except Exception as exc: # noqa: BLE001 — any delivery error is recorded, never raised into the engine
41
+ driver.mark_delivery_failed(row["id"], f"{type(exc).__name__}: {exc}", MAX_ATTEMPTS)
42
+ else:
43
+ driver.mark_delivery_sent(row["id"])
44
+
45
+
46
+ async def run_alert_worker(driver, wake: asyncio.Event) -> None:
47
+ """Drain queued alert deliveries on each wake (a delivery was enqueued) or the reconcile tick.
48
+ Cancelled on shutdown."""
49
+ while True:
50
+ try:
51
+ await asyncio.wait_for(wake.wait(), timeout=_RECONCILE_INTERVAL)
52
+ except asyncio.TimeoutError:
53
+ pass # periodic reconcile — retry anything still pending
54
+ wake.clear()
55
+ try:
56
+ await _drain(driver)
57
+ except Exception as exc: # keep the loop alive
58
+ print(f"[catchment] alert worker error: {exc}", flush=True)