duckstring 0.2.0__tar.gz → 0.4.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.
- duckstring-0.4.0/MANIFEST.in +8 -0
- {duckstring-0.2.0 → duckstring-0.4.0}/PKG-INFO +6 -5
- {duckstring-0.2.0 → duckstring-0.4.0}/README.md +1 -4
- {duckstring-0.2.0 → duckstring-0.4.0}/pyproject.toml +14 -1
- {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/__init__.py +0 -2
- duckstring-0.4.0/src/duckstring/acc.py +13 -0
- duckstring-0.4.0/src/duckstring/agg.py +12 -0
- duckstring-0.4.0/src/duckstring/alerts/__init__.py +21 -0
- duckstring-0.4.0/src/duckstring/alerts/base.py +94 -0
- duckstring-0.4.0/src/duckstring/alerts/email.py +106 -0
- duckstring-0.4.0/src/duckstring/alerts/event.py +97 -0
- duckstring-0.4.0/src/duckstring/alerts/webhook.py +46 -0
- duckstring-0.4.0/src/duckstring/catchment/alert_worker.py +58 -0
- duckstring-0.4.0/src/duckstring/catchment/app.py +199 -0
- duckstring-0.4.0/src/duckstring/catchment/asgi.py +39 -0
- duckstring-0.4.0/src/duckstring/catchment/auth.py +203 -0
- duckstring-0.4.0/src/duckstring/catchment/data_lease.py +159 -0
- {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/catchment/db.py +18 -0
- duckstring-0.4.0/src/duckstring/catchment/driver.py +2538 -0
- duckstring-0.4.0/src/duckstring/catchment/egress_worker.py +102 -0
- {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/catchment/launcher.py +13 -4
- duckstring-0.4.0/src/duckstring/catchment/poller.py +249 -0
- duckstring-0.4.0/src/duckstring/catchment/registry.py +40 -0
- {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/catchment/routes/__init__.py +10 -0
- duckstring-0.4.0/src/duckstring/catchment/routes/alerts.py +75 -0
- {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/catchment/routes/catchment.py +44 -4
- duckstring-0.4.0/src/duckstring/catchment/routes/data.py +614 -0
- {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/catchment/routes/deploy.py +104 -15
- duckstring-0.4.0/src/duckstring/catchment/routes/draw.py +144 -0
- {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/catchment/routes/duck.py +3 -2
- duckstring-0.4.0/src/duckstring/catchment/routes/duct.py +100 -0
- duckstring-0.4.0/src/duckstring/catchment/routes/metrics.py +99 -0
- duckstring-0.4.0/src/duckstring/catchment/routes/orchestrate.py +497 -0
- duckstring-0.4.0/src/duckstring/catchment/routes/secrets.py +43 -0
- duckstring-0.4.0/src/duckstring/catchment/routes/view.py +99 -0
- duckstring-0.4.0/src/duckstring/catchment/schema/002_ducts.sql +41 -0
- duckstring-0.4.0/src/duckstring/catchment/schema/003_pull_m.sql +5 -0
- duckstring-0.4.0/src/duckstring/catchment/schema/004_identity.sql +15 -0
- duckstring-0.4.0/src/duckstring/catchment/schema/005_pond_version_schema.sql +12 -0
- duckstring-0.4.0/src/duckstring/catchment/schema/006_refresh.sql +7 -0
- duckstring-0.4.0/src/duckstring/catchment/schema/007_catchment_key.sql +9 -0
- duckstring-0.4.0/src/duckstring/catchment/schema/008_spout.sql +18 -0
- duckstring-0.4.0/src/duckstring/catchment/schema/009_spout_state.sql +10 -0
- duckstring-0.4.0/src/duckstring/catchment/schema/010_spout_wake.sql +6 -0
- duckstring-0.4.0/src/duckstring/catchment/schema/011_spout_window.sql +17 -0
- duckstring-0.4.0/src/duckstring/catchment/schema/012_spout_node.sql +20 -0
- duckstring-0.4.0/src/duckstring/catchment/schema/013_changed_f.sql +14 -0
- duckstring-0.4.0/src/duckstring/catchment/schema/014_alert.sql +38 -0
- duckstring-0.4.0/src/duckstring/catchment/schema/016_alert_scope_major.sql +15 -0
- duckstring-0.4.0/src/duckstring/catchment/secrets.py +72 -0
- duckstring-0.4.0/src/duckstring/catchment/state_sync.py +182 -0
- {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/catchment/static/404.html +1 -1
- {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/catchment/static/__next.__PAGE__.txt +2 -2
- {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/catchment/static/__next._full.txt +3 -3
- {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/catchment/static/__next._head.txt +1 -1
- {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/catchment/static/__next._index.txt +2 -2
- {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/catchment/static/__next._tree.txt +2 -2
- duckstring-0.4.0/src/duckstring/catchment/static/_next/static/chunks/0p30fzde51na5.css +1 -0
- duckstring-0.4.0/src/duckstring/catchment/static/_next/static/chunks/0qdnkaffkg.2-.js +2 -0
- {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/catchment/static/_not-found/__next._full.txt +2 -2
- {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/catchment/static/_not-found/__next._head.txt +1 -1
- {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/catchment/static/_not-found/__next._index.txt +2 -2
- {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/catchment/static/_not-found/__next._not-found.__PAGE__.txt +1 -1
- {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/catchment/static/_not-found/__next._not-found.txt +1 -1
- {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/catchment/static/_not-found/__next._tree.txt +2 -2
- {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/catchment/static/_not-found.html +1 -1
- {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/catchment/static/_not-found.txt +2 -2
- duckstring-0.4.0/src/duckstring/catchment/static/index.html +1 -0
- {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/catchment/static/index.txt +3 -3
- {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/cli/__init__.py +24 -2
- {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/cli/_http.py +4 -0
- duckstring-0.4.0/src/duckstring/cli/alert.py +146 -0
- duckstring-0.4.0/src/duckstring/cli/bulk.py +131 -0
- {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/cli/catchment.py +251 -41
- {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/cli/config.py +19 -1
- {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/cli/control.py +61 -0
- duckstring-0.4.0/src/duckstring/cli/data.py +266 -0
- {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/cli/deploy.py +41 -2
- duckstring-0.4.0/src/duckstring/cli/duct.py +190 -0
- {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/cli/pond.py +33 -11
- duckstring-0.4.0/src/duckstring/cli/secret.py +72 -0
- duckstring-0.4.0/src/duckstring/cli/spout.py +142 -0
- duckstring-0.4.0/src/duckstring/core.py +678 -0
- duckstring-0.4.0/src/duckstring/dataplane.py +563 -0
- duckstring-0.4.0/src/duckstring/demo/catalog/README.md +12 -0
- duckstring-0.4.0/src/duckstring/demo/catalog/pond.toml +4 -0
- duckstring-0.4.0/src/duckstring/demo/catalog/src/pond.py +61 -0
- duckstring-0.4.0/src/duckstring/demo/orders/README.md +11 -0
- duckstring-0.4.0/src/duckstring/demo/orders/pond.toml +4 -0
- duckstring-0.4.0/src/duckstring/demo/orders/src/pond.py +52 -0
- duckstring-0.4.0/src/duckstring/demo/priced/README.md +20 -0
- duckstring-0.4.0/src/duckstring/demo/priced/pond.toml +7 -0
- duckstring-0.4.0/src/duckstring/demo/priced/src/pond.py +23 -0
- duckstring-0.4.0/src/duckstring/demo/priced/src/puddles.py +59 -0
- duckstring-0.4.0/src/duckstring/demo/reports/.gitignore +8 -0
- duckstring-0.4.0/src/duckstring/demo/revenue/.gitignore +8 -0
- duckstring-0.4.0/src/duckstring/demo/revenue/README.md +12 -0
- duckstring-0.4.0/src/duckstring/demo/revenue/pond.toml +7 -0
- duckstring-0.4.0/src/duckstring/demo/revenue/src/pond.py +25 -0
- duckstring-0.4.0/src/duckstring/demo/revenue/src/puddles.py +35 -0
- duckstring-0.4.0/src/duckstring/demo/sales/.gitignore +8 -0
- duckstring-0.4.0/src/duckstring/demo/transactions/.gitignore +8 -0
- {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/duck/__main__.py +30 -6
- {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/duck/core.py +88 -10
- duckstring-0.4.0/src/duckstring/duck/executor.py +208 -0
- duckstring-0.4.0/src/duckstring/egress/__init__.py +19 -0
- duckstring-0.4.0/src/duckstring/egress/base.py +96 -0
- duckstring-0.4.0/src/duckstring/egress/credentials.py +93 -0
- duckstring-0.4.0/src/duckstring/egress/destination.py +64 -0
- duckstring-0.4.0/src/duckstring/egress/object_store.py +167 -0
- duckstring-0.4.0/src/duckstring/egress/postgres.py +170 -0
- {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/engine/__init__.py +10 -0
- {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/engine/catchment.py +275 -35
- {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/engine/core.py +40 -0
- {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/engine/worker.py +6 -3
- duckstring-0.4.0/src/duckstring/iceberg_catalog.py +227 -0
- duckstring-0.4.0/src/duckstring/iceberg_plane.py +286 -0
- {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/keys.py +6 -0
- {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/local/runner.py +58 -13
- duckstring-0.4.0/src/duckstring/objects.py +225 -0
- duckstring-0.4.0/src/duckstring/schema_contract.py +67 -0
- duckstring-0.4.0/src/duckstring/storage.py +543 -0
- duckstring-0.4.0/src/duckstring/trickle/__init__.py +71 -0
- duckstring-0.4.0/src/duckstring/trickle/acc.py +139 -0
- duckstring-0.4.0/src/duckstring/trickle/agg.py +209 -0
- duckstring-0.4.0/src/duckstring/trickle/builder.py +1091 -0
- duckstring-0.4.0/src/duckstring/trickle/context.py +53 -0
- duckstring-0.4.0/src/duckstring/trickle/io.py +2376 -0
- duckstring-0.4.0/src/duckstring/trickle_builder.py +13 -0
- duckstring-0.4.0/src/duckstring/trickle_io.py +15 -0
- {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring.egg-info/PKG-INFO +6 -5
- {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring.egg-info/SOURCES.txt +83 -27
- {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring.egg-info/requires.txt +5 -0
- duckstring-0.2.0/src/duckstring/catchment/app.py +0 -110
- duckstring-0.2.0/src/duckstring/catchment/asgi.py +0 -30
- duckstring-0.2.0/src/duckstring/catchment/driver.py +0 -836
- duckstring-0.2.0/src/duckstring/catchment/registry.py +0 -27
- duckstring-0.2.0/src/duckstring/catchment/routes/data.py +0 -143
- duckstring-0.2.0/src/duckstring/catchment/routes/orchestrate.py +0 -209
- duckstring-0.2.0/src/duckstring/catchment/static/_next/static/chunks/0pgf-_4l3d1gl.css +0 -1
- duckstring-0.2.0/src/duckstring/catchment/static/_next/static/chunks/0qh0m742pxib~.js +0 -2
- duckstring-0.2.0/src/duckstring/catchment/static/index.html +0 -1
- duckstring-0.2.0/src/duckstring/cli/data.py +0 -133
- duckstring-0.2.0/src/duckstring/core.py +0 -368
- duckstring-0.2.0/src/duckstring/duck/executor.py +0 -142
- duckstring-0.2.0/tests/test_auth.py +0 -258
- duckstring-0.2.0/tests/test_catchment.py +0 -289
- duckstring-0.2.0/tests/test_catchment_app.py +0 -674
- duckstring-0.2.0/tests/test_config.py +0 -70
- duckstring-0.2.0/tests/test_core.py +0 -94
- duckstring-0.2.0/tests/test_data.py +0 -148
- duckstring-0.2.0/tests/test_deploy.py +0 -146
- duckstring-0.2.0/tests/test_download.py +0 -77
- duckstring-0.2.0/tests/test_duck.py +0 -175
- duckstring-0.2.0/tests/test_engine.py +0 -591
- duckstring-0.2.0/tests/test_engine_split.py +0 -228
- duckstring-0.2.0/tests/test_execute.py +0 -149
- duckstring-0.2.0/tests/test_multimajor.py +0 -188
- duckstring-0.2.0/tests/test_platform.py +0 -155
- duckstring-0.2.0/tests/test_pond.py +0 -156
- duckstring-0.2.0/tests/test_puddle.py +0 -392
- duckstring-0.2.0/tests/test_restart.py +0 -98
- duckstring-0.2.0/tests/test_runtime.py +0 -233
- duckstring-0.2.0/tests/test_static.py +0 -23
- duckstring-0.2.0/tests/test_status.py +0 -198
- duckstring-0.2.0/tests/test_status_api.py +0 -233
- duckstring-0.2.0/tests/test_window.py +0 -193
- {duckstring-0.2.0 → duckstring-0.4.0}/LICENSE +0 -0
- {duckstring-0.2.0 → duckstring-0.4.0}/setup.cfg +0 -0
- {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/__main__.py +0 -0
- {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/catchment/__init__.py +0 -0
- {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/catchment/dag.py +0 -0
- {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/catchment/schema/001_init.sql +0 -0
- {duckstring-0.2.0/src/duckstring/catchment/static/_next/static/uFzbxKIUibAOY83mw7FJ7 → duckstring-0.4.0/src/duckstring/catchment/static/_next/static/K01gZbd5BTz315upsZeJJ}/_buildManifest.js +0 -0
- {duckstring-0.2.0/src/duckstring/catchment/static/_next/static/uFzbxKIUibAOY83mw7FJ7 → duckstring-0.4.0/src/duckstring/catchment/static/_next/static/K01gZbd5BTz315upsZeJJ}/_clientMiddlewareManifest.js +0 -0
- {duckstring-0.2.0/src/duckstring/catchment/static/_next/static/uFzbxKIUibAOY83mw7FJ7 → duckstring-0.4.0/src/duckstring/catchment/static/_next/static/K01gZbd5BTz315upsZeJJ}/_ssgManifest.js +0 -0
- {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/catchment/static/_next/static/chunks/03~yq9q893hmn.js +0 -0
- {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/catchment/static/_next/static/chunks/07lhk_q6pmm3r.js +0 -0
- {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/catchment/static/_next/static/chunks/0dbhjjzl8qfwv.js +0 -0
- {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/catchment/static/_next/static/chunks/0jvmviuftg5e2.css +0 -0
- {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/catchment/static/_next/static/chunks/0kczw6usu-y-f.js +0 -0
- {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/catchment/static/_next/static/chunks/11kjahy2ntf0n.js +0 -0
- {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/catchment/static/_next/static/chunks/turbopack-03~mbvk_uplk_.js +0 -0
- {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/catchment/static/favicon.svg +0 -0
- {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/catchment/static/logo-mark.svg +0 -0
- {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/catchment/static/logo.svg +0 -0
- {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/cli/puddle.py +0 -0
- {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/cli/status.py +0 -0
- {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/cli/trigger.py +0 -0
- {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/cli/window.py +0 -0
- {duckstring-0.2.0/src/duckstring/demo/products → duckstring-0.4.0/src/duckstring/demo/catalog}/.gitignore +0 -0
- {duckstring-0.2.0/src/duckstring/demo/reports → duckstring-0.4.0/src/duckstring/demo/orders}/.gitignore +0 -0
- {duckstring-0.2.0/src/duckstring/demo/sales → duckstring-0.4.0/src/duckstring/demo/priced}/.gitignore +0 -0
- {duckstring-0.2.0/src/duckstring/demo/transactions → duckstring-0.4.0/src/duckstring/demo/products}/.gitignore +0 -0
- {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/demo/products/README.md +0 -0
- {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/demo/products/pond.toml +0 -0
- {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/demo/products/src/pond.py +0 -0
- {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/demo/reports/README.md +0 -0
- {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/demo/reports/pond.toml +0 -0
- {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/demo/reports/src/pond.py +0 -0
- {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/demo/sales/README.md +0 -0
- {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/demo/sales/pond.toml +0 -0
- {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/demo/sales/src/pond.py +0 -0
- {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/demo/sales/src/puddles.py +0 -0
- {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/demo/transactions/README.md +0 -0
- {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/demo/transactions/pond.toml +0 -0
- {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/demo/transactions/src/pond.py +0 -0
- {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/duck/__init__.py +0 -0
- {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/duck/client.py +0 -0
- {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/engine/pond.py +0 -0
- {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/local/__init__.py +0 -0
- {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/local/hydrate.py +0 -0
- {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/local/project.py +0 -0
- {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring/utils.py +0 -0
- {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring.egg-info/dependency_links.txt +0 -0
- {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring.egg-info/entry_points.txt +0 -0
- {duckstring-0.2.0 → duckstring-0.4.0}/src/duckstring.egg-info/top_level.txt +0 -0
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# The sdist ships the installable package only — keep internal design notes and the test suite
|
|
2
|
+
# out of the PyPI source tarball (they're in the git repo for contributors).
|
|
3
|
+
prune tests
|
|
4
|
+
prune plans
|
|
5
|
+
prune experiment
|
|
6
|
+
prune docs
|
|
7
|
+
prune frontend
|
|
8
|
+
prune playground
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: duckstring
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.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
|
|
@@ -221,6 +221,10 @@ Requires-Dist: uvicorn[standard]>=0.29
|
|
|
221
221
|
Requires-Dist: httpx>=0.25
|
|
222
222
|
Requires-Dist: tomli>=2.0; python_version < "3.11"
|
|
223
223
|
Requires-Dist: tomli-w>=1.0
|
|
224
|
+
Requires-Dist: pyiceberg>=0.7
|
|
225
|
+
Requires-Dist: pyarrow>=14
|
|
226
|
+
Requires-Dist: pytz
|
|
227
|
+
Provides-Extra: iceberg
|
|
224
228
|
Provides-Extra: dev
|
|
225
229
|
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
226
230
|
Requires-Dist: pytest-timeout>=0.5; extra == "dev"
|
|
@@ -244,6 +248,7 @@ The main elements:
|
|
|
244
248
|
- **Catchment**: Control environment (FastAPI + UI + CLI)
|
|
245
249
|
- **Pond**: Versioned container with declared upstream dependencies
|
|
246
250
|
- **Ripple**: Unit operation within a Pond (e.g. a single transformation producing a table)
|
|
251
|
+
- **Trickle**: DBSP-based incremental engine operating within a Ripple
|
|
247
252
|
|
|
248
253
|
Ponds are typed or referred to in context:
|
|
249
254
|
|
|
@@ -292,10 +297,6 @@ Ponds execute on demand signals sent to an Outlet, in two flavours — **push**
|
|
|
292
297
|
|
|
293
298
|
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.
|
|
294
299
|
|
|
295
|
-
## Scope & maturity
|
|
296
|
-
|
|
297
|
-
Duckstring targets single-node workloads (DuckDB under the hood) — pipelines in the tens of millions of rows, not big-data scale. Incremental transforms (the **Trickle** concept) are not yet implemented: transforms recompute their tables each run, with incremental state via self-reads.
|
|
298
|
-
|
|
299
300
|
## Going further
|
|
300
301
|
|
|
301
302
|
Full documentation lives at **[docs.duckstring.com](https://docs.duckstring.com)**:
|
|
@@ -14,6 +14,7 @@ The main elements:
|
|
|
14
14
|
- **Catchment**: Control environment (FastAPI + UI + CLI)
|
|
15
15
|
- **Pond**: Versioned container with declared upstream dependencies
|
|
16
16
|
- **Ripple**: Unit operation within a Pond (e.g. a single transformation producing a table)
|
|
17
|
+
- **Trickle**: DBSP-based incremental engine operating within a Ripple
|
|
17
18
|
|
|
18
19
|
Ponds are typed or referred to in context:
|
|
19
20
|
|
|
@@ -62,10 +63,6 @@ Ponds execute on demand signals sent to an Outlet, in two flavours — **push**
|
|
|
62
63
|
|
|
63
64
|
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.
|
|
64
65
|
|
|
65
|
-
## Scope & maturity
|
|
66
|
-
|
|
67
|
-
Duckstring targets single-node workloads (DuckDB under the hood) — pipelines in the tens of millions of rows, not big-data scale. Incremental transforms (the **Trickle** concept) are not yet implemented: transforms recompute their tables each run, with incremental state via self-reads.
|
|
68
|
-
|
|
69
66
|
## Going further
|
|
70
67
|
|
|
71
68
|
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
|
+
version = "0.4.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"
|
|
@@ -19,9 +19,22 @@ dependencies = [
|
|
|
19
19
|
"httpx>=0.25",
|
|
20
20
|
"tomli>=2.0; python_version < '3.11'",
|
|
21
21
|
"tomli-w>=1.0",
|
|
22
|
+
# The default data plane: an Apache Iceberg base layer (snapshots + schema metadata over the
|
|
23
|
+
# Parquet files we already write). Core deps because Iceberg is default-on; set
|
|
24
|
+
# DUCKSTRING_DATA_PLANE=parquet to opt out (the lighter fallback, also for offline Catchments that
|
|
25
|
+
# can't fetch DuckDB's iceberg extension). NB: we use our own file-backed catalog
|
|
26
|
+
# (duckstring.iceberg_catalog) rather than pyiceberg's SqlCatalog, so SQLAlchemy is NOT pulled in.
|
|
27
|
+
"pyiceberg>=0.7",
|
|
28
|
+
"pyarrow>=14",
|
|
29
|
+
# DuckDB requires pytz to convert TIMESTAMPTZ columns to Python datetimes on fetch
|
|
30
|
+
# (our _duckstring_f freshness column is TIMESTAMPTZ, read back in dataplane/trickle export).
|
|
31
|
+
# It is otherwise only present transitively (pandas), so a clean install lacks it.
|
|
32
|
+
"pytz",
|
|
22
33
|
]
|
|
23
34
|
|
|
24
35
|
[project.optional-dependencies]
|
|
36
|
+
# Retained for back-compat: the Iceberg deps are in core now (Iceberg is the default plane).
|
|
37
|
+
iceberg = []
|
|
25
38
|
dev = [
|
|
26
39
|
"pytest>=8.0",
|
|
27
40
|
"pytest-timeout>=0.5",
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Backwards-compatible alias — scan (order-dependent) metric specs live in :mod:`duckstring.trickle.acc`.
|
|
2
|
+
|
|
3
|
+
Kept so ``from duckstring import acc`` works alongside ``from duckstring import agg``. See
|
|
4
|
+
:mod:`duckstring.trickle_io` for the rationale.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from .trickle import acc as _acc
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def __getattr__(name): # PEP 562: forward all names to the relocated module
|
|
13
|
+
return getattr(_acc, name)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Backwards-compatible alias — aggregate metric specs now live in :mod:`duckstring.trickle.agg`.
|
|
2
|
+
|
|
3
|
+
Kept so ``from duckstring import agg`` continues to work. See :mod:`duckstring.trickle_io` for the rationale.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from .trickle import agg as _agg
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def __getattr__(name): # PEP 562: forward all names to the relocated module
|
|
12
|
+
return getattr(_agg, name)
|
|
@@ -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,97 @@
|
|
|
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
|
+
# Default severity per kind (a channel filters by kind, not severity — this is only for the payload/label).
|
|
18
|
+
SEVERITY = {
|
|
19
|
+
"failure": "error",
|
|
20
|
+
"contract": "error",
|
|
21
|
+
"spout": "error",
|
|
22
|
+
"freshness": "warning",
|
|
23
|
+
"recovery": "info",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def normalise_events(events: str | None) -> tuple[str, ...]:
|
|
28
|
+
"""Parse a channel's ``events`` CSV (or ``all``/empty) into a validated tuple of known kinds.
|
|
29
|
+
|
|
30
|
+
``all`` / empty → every kind. Raises :class:`ValueError` naming an unknown kind."""
|
|
31
|
+
if not events or events.strip().lower() == "all":
|
|
32
|
+
return KNOWN_EVENTS
|
|
33
|
+
out = []
|
|
34
|
+
for raw in events.split(","):
|
|
35
|
+
kind = raw.strip().lower()
|
|
36
|
+
if not kind:
|
|
37
|
+
continue
|
|
38
|
+
if kind not in KNOWN_EVENTS:
|
|
39
|
+
raise ValueError(f"unknown alert event {kind!r} — choose from {', '.join(KNOWN_EVENTS)} (or 'all')")
|
|
40
|
+
out.append(kind)
|
|
41
|
+
if not out:
|
|
42
|
+
return KNOWN_EVENTS
|
|
43
|
+
return tuple(dict.fromkeys(out)) # de-duplicated, order preserved
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class AlertEvent:
|
|
48
|
+
"""A rendered notification. ``detail`` carries kind-specific extras (e.g. the blocked-downstream blast
|
|
49
|
+
radius for a failure); never put a traceback in it."""
|
|
50
|
+
|
|
51
|
+
kind: str
|
|
52
|
+
pond: str | None
|
|
53
|
+
title: str
|
|
54
|
+
message: str
|
|
55
|
+
severity: str = ""
|
|
56
|
+
f: str | None = None
|
|
57
|
+
catchment: str | None = None
|
|
58
|
+
ts: str = ""
|
|
59
|
+
detail: dict = field(default_factory=dict)
|
|
60
|
+
|
|
61
|
+
def __post_init__(self) -> None:
|
|
62
|
+
if not self.severity:
|
|
63
|
+
self.severity = SEVERITY.get(self.kind, "info")
|
|
64
|
+
if not self.ts:
|
|
65
|
+
self.ts = datetime.now(timezone.utc).isoformat()
|
|
66
|
+
|
|
67
|
+
def summary(self) -> str:
|
|
68
|
+
"""A one-line human summary (the webhook ``text`` / the email subject line)."""
|
|
69
|
+
where = f" [{self.catchment}]" if self.catchment else ""
|
|
70
|
+
return f"{self.severity.upper()}{where}: {self.title}"
|
|
71
|
+
|
|
72
|
+
def to_payload(self) -> dict:
|
|
73
|
+
return {
|
|
74
|
+
"kind": self.kind,
|
|
75
|
+
"severity": self.severity,
|
|
76
|
+
"pond": self.pond,
|
|
77
|
+
"f": self.f,
|
|
78
|
+
"title": self.title,
|
|
79
|
+
"message": self.message,
|
|
80
|
+
"catchment": self.catchment,
|
|
81
|
+
"ts": self.ts,
|
|
82
|
+
"detail": self.detail,
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
@classmethod
|
|
86
|
+
def from_payload(cls, payload: dict) -> "AlertEvent":
|
|
87
|
+
return cls(
|
|
88
|
+
kind=payload.get("kind", ""),
|
|
89
|
+
pond=payload.get("pond"),
|
|
90
|
+
title=payload.get("title", ""),
|
|
91
|
+
message=payload.get("message", ""),
|
|
92
|
+
severity=payload.get("severity", ""),
|
|
93
|
+
f=payload.get("f"),
|
|
94
|
+
catchment=payload.get("catchment"),
|
|
95
|
+
ts=payload.get("ts", ""),
|
|
96
|
+
detail=payload.get("detail") or {},
|
|
97
|
+
)
|
|
@@ -0,0 +1,46 @@
|
|
|
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
|
+
body = {"text": event.summary(), **event.to_payload()} # `text` for Slack; the rest for generic receivers
|
|
30
|
+
try:
|
|
31
|
+
resp = httpx.post(url, json=body, timeout=_TIMEOUT)
|
|
32
|
+
resp.raise_for_status()
|
|
33
|
+
except httpx.HTTPStatusError as exc:
|
|
34
|
+
# Sanitise: report status + reason, never the URL (it may carry a token in the path/query).
|
|
35
|
+
raise NotifierError(f"webhook returned {exc.response.status_code}") from None
|
|
36
|
+
except httpx.HTTPError as exc:
|
|
37
|
+
raise NotifierError(f"webhook delivery failed: {type(exc).__name__}") from None
|
|
38
|
+
|
|
39
|
+
def send(self, event: AlertEvent) -> None:
|
|
40
|
+
self._post(event)
|
|
41
|
+
|
|
42
|
+
def test(self) -> None:
|
|
43
|
+
self._post(AlertEvent(
|
|
44
|
+
kind="recovery", pond=None, title="Duckstring alert channel test",
|
|
45
|
+
message="This is a test notification — your alert channel is configured correctly.",
|
|
46
|
+
))
|
|
@@ -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)
|