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.
- duckstring-0.5.0/CHANGELOG.md +93 -0
- {duckstring-0.3.0 → duckstring-0.5.0}/MANIFEST.in +2 -0
- {duckstring-0.3.0/src/duckstring.egg-info → duckstring-0.5.0}/PKG-INFO +18 -2
- {duckstring-0.3.0 → duckstring-0.5.0}/README.md +4 -1
- {duckstring-0.3.0 → duckstring-0.5.0}/pyproject.toml +25 -1
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/__init__.py +0 -2
- duckstring-0.5.0/src/duckstring/alerts/__init__.py +21 -0
- duckstring-0.5.0/src/duckstring/alerts/base.py +94 -0
- duckstring-0.5.0/src/duckstring/alerts/email.py +106 -0
- duckstring-0.5.0/src/duckstring/alerts/event.py +108 -0
- duckstring-0.5.0/src/duckstring/alerts/webhook.py +49 -0
- duckstring-0.5.0/src/duckstring/catchment/alert_worker.py +58 -0
- duckstring-0.5.0/src/duckstring/catchment/app.py +266 -0
- duckstring-0.5.0/src/duckstring/catchment/asgi.py +39 -0
- duckstring-0.5.0/src/duckstring/catchment/auth.py +220 -0
- duckstring-0.5.0/src/duckstring/catchment/cloud.py +156 -0
- duckstring-0.5.0/src/duckstring/catchment/cloud_backends.py +156 -0
- duckstring-0.5.0/src/duckstring/catchment/cloud_deploy.py +77 -0
- duckstring-0.5.0/src/duckstring/catchment/data_lease.py +159 -0
- duckstring-0.5.0/src/duckstring/catchment/dialback.py +41 -0
- duckstring-0.5.0/src/duckstring/catchment/driver.py +3917 -0
- duckstring-0.5.0/src/duckstring/catchment/ec2_launcher.py +372 -0
- duckstring-0.5.0/src/duckstring/catchment/egress_worker.py +125 -0
- duckstring-0.5.0/src/duckstring/catchment/fargate_launcher.py +298 -0
- duckstring-0.5.0/src/duckstring/catchment/flight_sql.py +98 -0
- duckstring-0.5.0/src/duckstring/catchment/launcher.py +332 -0
- duckstring-0.5.0/src/duckstring/catchment/pg_wire.py +317 -0
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/catchment/poller.py +32 -21
- duckstring-0.5.0/src/duckstring/catchment/pool_launcher.py +397 -0
- duckstring-0.5.0/src/duckstring/catchment/registry.py +85 -0
- duckstring-0.5.0/src/duckstring/catchment/relay.py +217 -0
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/catchment/routes/__init__.py +8 -0
- duckstring-0.5.0/src/duckstring/catchment/routes/alerts.py +77 -0
- duckstring-0.5.0/src/duckstring/catchment/routes/catchment.py +476 -0
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/catchment/routes/data.py +266 -38
- duckstring-0.5.0/src/duckstring/catchment/routes/deploy.py +431 -0
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/catchment/routes/draw.py +50 -16
- duckstring-0.5.0/src/duckstring/catchment/routes/duck.py +68 -0
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/catchment/routes/duct.py +8 -6
- duckstring-0.5.0/src/duckstring/catchment/routes/metrics.py +135 -0
- duckstring-0.5.0/src/duckstring/catchment/routes/orchestrate.py +579 -0
- duckstring-0.5.0/src/duckstring/catchment/routes/pool.py +38 -0
- duckstring-0.5.0/src/duckstring/catchment/routes/secrets.py +59 -0
- duckstring-0.5.0/src/duckstring/catchment/routes/serving.py +104 -0
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/catchment/routes/view.py +3 -1
- duckstring-0.5.0/src/duckstring/catchment/schema/007_catchment_key.sql +9 -0
- duckstring-0.5.0/src/duckstring/catchment/schema/008_spout.sql +18 -0
- duckstring-0.5.0/src/duckstring/catchment/schema/009_spout_state.sql +10 -0
- duckstring-0.5.0/src/duckstring/catchment/schema/010_spout_wake.sql +6 -0
- duckstring-0.5.0/src/duckstring/catchment/schema/011_spout_window.sql +17 -0
- duckstring-0.5.0/src/duckstring/catchment/schema/012_spout_node.sql +20 -0
- duckstring-0.5.0/src/duckstring/catchment/schema/013_changed_f.sql +14 -0
- duckstring-0.5.0/src/duckstring/catchment/schema/014_alert.sql +38 -0
- duckstring-0.5.0/src/duckstring/catchment/schema/016_alert_scope_major.sql +15 -0
- duckstring-0.5.0/src/duckstring/catchment/schema/017_alert_renotify.sql +7 -0
- duckstring-0.5.0/src/duckstring/catchment/schema/018_duck.sql +11 -0
- duckstring-0.5.0/src/duckstring/catchment/schema/019_lineage.sql +18 -0
- duckstring-0.5.0/src/duckstring/catchment/schema/020_column_lineage.sql +15 -0
- duckstring-0.5.0/src/duckstring/catchment/schema/021_cloud.sql +44 -0
- duckstring-0.5.0/src/duckstring/catchment/schema/022_retire_duck_size.sql +25 -0
- duckstring-0.5.0/src/duckstring/catchment/schema/023_pool_provider.sql +7 -0
- duckstring-0.5.0/src/duckstring/catchment/schema/024_serving.sql +29 -0
- duckstring-0.5.0/src/duckstring/catchment/schema/025_dbt.sql +3 -0
- duckstring-0.5.0/src/duckstring/catchment/schema/026_deploy_config.sql +7 -0
- duckstring-0.5.0/src/duckstring/catchment/schema/027_persist.sql +14 -0
- duckstring-0.5.0/src/duckstring/catchment/schema/028_persist_timing.sql +3 -0
- duckstring-0.5.0/src/duckstring/catchment/secrets.py +72 -0
- duckstring-0.5.0/src/duckstring/catchment/serving.py +209 -0
- duckstring-0.5.0/src/duckstring/catchment/state_sync.py +182 -0
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/catchment/static/404.html +1 -1
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/catchment/static/__next.__PAGE__.txt +2 -2
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/catchment/static/__next._full.txt +3 -3
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/catchment/static/__next._head.txt +1 -1
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/catchment/static/__next._index.txt +2 -2
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/catchment/static/__next._tree.txt +2 -2
- duckstring-0.5.0/src/duckstring/catchment/static/_next/static/chunks/0_8xp0_1~w7l7.css +1 -0
- duckstring-0.5.0/src/duckstring/catchment/static/_next/static/chunks/15o1yi9qh60on.js +2 -0
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/catchment/static/_not-found/__next._full.txt +2 -2
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/catchment/static/_not-found/__next._head.txt +1 -1
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/catchment/static/_not-found/__next._index.txt +2 -2
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/catchment/static/_not-found/__next._not-found.__PAGE__.txt +1 -1
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/catchment/static/_not-found/__next._not-found.txt +1 -1
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/catchment/static/_not-found/__next._tree.txt +2 -2
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/catchment/static/_not-found.html +1 -1
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/catchment/static/_not-found.txt +2 -2
- duckstring-0.5.0/src/duckstring/catchment/static/index.html +1 -0
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/catchment/static/index.txt +3 -3
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/cli/__init__.py +36 -2
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/cli/_http.py +4 -0
- duckstring-0.5.0/src/duckstring/cli/alert.py +152 -0
- duckstring-0.5.0/src/duckstring/cli/bulk.py +131 -0
- duckstring-0.5.0/src/duckstring/cli/catchment.py +612 -0
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/cli/config.py +19 -1
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/cli/control.py +52 -0
- duckstring-0.5.0/src/duckstring/cli/data.py +266 -0
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/cli/deploy.py +41 -2
- duckstring-0.5.0/src/duckstring/cli/duck.py +185 -0
- duckstring-0.5.0/src/duckstring/cli/lineage.py +111 -0
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/cli/pond.py +50 -7
- duckstring-0.5.0/src/duckstring/cli/secret.py +72 -0
- duckstring-0.5.0/src/duckstring/cli/serve.py +94 -0
- duckstring-0.5.0/src/duckstring/cli/spout.py +142 -0
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/core.py +227 -24
- duckstring-0.5.0/src/duckstring/dataplane.py +994 -0
- duckstring-0.5.0/src/duckstring/dbt_mode.py +149 -0
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/demo/catalog/src/pond.py +4 -1
- duckstring-0.5.0/src/duckstring/demo/gh_actors/README.md +10 -0
- duckstring-0.5.0/src/duckstring/demo/gh_actors/pond.toml +6 -0
- duckstring-0.5.0/src/duckstring/demo/gh_actors/src/pond.py +25 -0
- duckstring-0.5.0/src/duckstring/demo/gh_events/README.md +21 -0
- duckstring-0.5.0/src/duckstring/demo/gh_events/pond.toml +4 -0
- duckstring-0.5.0/src/duckstring/demo/gh_events/src/pond.py +109 -0
- duckstring-0.5.0/src/duckstring/demo/gh_pushes/README.md +10 -0
- duckstring-0.5.0/src/duckstring/demo/gh_pushes/pond.toml +7 -0
- duckstring-0.5.0/src/duckstring/demo/gh_pushes/src/pond.py +23 -0
- duckstring-0.5.0/src/duckstring/demo/gh_repo_activity/README.md +14 -0
- duckstring-0.5.0/src/duckstring/demo/gh_repo_activity/pond.toml +7 -0
- duckstring-0.5.0/src/duckstring/demo/gh_repo_activity/src/pond.py +24 -0
- duckstring-0.5.0/src/duckstring/demo/gh_stars/README.md +10 -0
- duckstring-0.5.0/src/duckstring/demo/gh_stars/pond.toml +6 -0
- duckstring-0.5.0/src/duckstring/demo/gh_stars/src/pond.py +19 -0
- duckstring-0.5.0/src/duckstring/demo/gh_trending/README.md +10 -0
- duckstring-0.5.0/src/duckstring/demo/gh_trending/pond.toml +7 -0
- duckstring-0.5.0/src/duckstring/demo/gh_trending/src/pond.py +23 -0
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/demo/priced/src/pond.py +1 -1
- duckstring-0.5.0/src/duckstring/demo/shop_analytics/.gitignore +8 -0
- duckstring-0.5.0/src/duckstring/demo/shop_analytics/README.md +24 -0
- duckstring-0.5.0/src/duckstring/demo/shop_analytics/dbt/dbt_project.yml +12 -0
- duckstring-0.5.0/src/duckstring/demo/shop_analytics/dbt/models/orders_clean.sql +8 -0
- duckstring-0.5.0/src/duckstring/demo/shop_analytics/dbt/models/revenue_by_product.sql +8 -0
- duckstring-0.5.0/src/duckstring/demo/shop_analytics/dbt/models/sources.yml +9 -0
- duckstring-0.5.0/src/duckstring/demo/shop_analytics/dbt/models/top_products.sql +8 -0
- duckstring-0.5.0/src/duckstring/demo/shop_analytics/pond.toml +11 -0
- duckstring-0.5.0/src/duckstring/demo/shop_orders/.gitignore +3 -0
- duckstring-0.5.0/src/duckstring/demo/shop_orders/README.md +9 -0
- duckstring-0.5.0/src/duckstring/demo/shop_orders/pond.toml +4 -0
- duckstring-0.5.0/src/duckstring/demo/shop_orders/src/pond.py +32 -0
- duckstring-0.5.0/src/duckstring/demo/tpcds_category_revenue/README.md +13 -0
- duckstring-0.5.0/src/duckstring/demo/tpcds_category_revenue/pond.toml +7 -0
- duckstring-0.5.0/src/duckstring/demo/tpcds_category_revenue/src/pond.py +26 -0
- duckstring-0.5.0/src/duckstring/demo/tpcds_items/README.md +11 -0
- duckstring-0.5.0/src/duckstring/demo/tpcds_items/pond.toml +4 -0
- duckstring-0.5.0/src/duckstring/demo/tpcds_items/src/pond.py +80 -0
- duckstring-0.5.0/src/duckstring/demo/tpcds_priced/README.md +11 -0
- duckstring-0.5.0/src/duckstring/demo/tpcds_priced/pond.toml +8 -0
- duckstring-0.5.0/src/duckstring/demo/tpcds_priced/src/pond.py +26 -0
- duckstring-0.5.0/src/duckstring/demo/tpcds_sales/README.md +22 -0
- duckstring-0.5.0/src/duckstring/demo/tpcds_sales/pond.toml +4 -0
- duckstring-0.5.0/src/duckstring/demo/tpcds_sales/src/pond.py +76 -0
- duckstring-0.5.0/src/duckstring/demo/tpcds_store_revenue/README.md +10 -0
- duckstring-0.5.0/src/duckstring/demo/tpcds_store_revenue/pond.toml +7 -0
- duckstring-0.5.0/src/duckstring/demo/tpcds_store_revenue/src/pond.py +25 -0
- duckstring-0.5.0/src/duckstring/demo/tpcds_stores/README.md +10 -0
- duckstring-0.5.0/src/duckstring/demo/tpcds_stores/pond.toml +4 -0
- duckstring-0.5.0/src/duckstring/demo/tpcds_stores/src/pond.py +48 -0
- duckstring-0.5.0/src/duckstring/duck/__main__.py +309 -0
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/duck/client.py +8 -0
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/duck/core.py +74 -9
- duckstring-0.5.0/src/duckstring/duck/dbt_executor.py +170 -0
- duckstring-0.5.0/src/duckstring/duck/executor.py +298 -0
- duckstring-0.5.0/src/duckstring/duck/pool_agent.py +151 -0
- duckstring-0.5.0/src/duckstring/egress/__init__.py +19 -0
- duckstring-0.5.0/src/duckstring/egress/base.py +101 -0
- duckstring-0.5.0/src/duckstring/egress/credentials.py +93 -0
- duckstring-0.5.0/src/duckstring/egress/destination.py +64 -0
- duckstring-0.5.0/src/duckstring/egress/object_store.py +244 -0
- duckstring-0.5.0/src/duckstring/egress/postgres.py +170 -0
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/engine/__init__.py +8 -0
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/engine/catchment.py +235 -28
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/engine/core.py +37 -0
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/engine/worker.py +6 -3
- duckstring-0.5.0/src/duckstring/flock/__init__.py +298 -0
- duckstring-0.5.0/src/duckstring/flock/engines/__init__.py +0 -0
- duckstring-0.5.0/src/duckstring/flock/engines/athena.py +297 -0
- duckstring-0.5.0/src/duckstring/flock/equivalence.py +75 -0
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/iceberg_catalog.py +31 -8
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/iceberg_plane.py +66 -57
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/local/runner.py +12 -1
- duckstring-0.5.0/src/duckstring/objects.py +225 -0
- duckstring-0.5.0/src/duckstring/schema_contract.py +145 -0
- duckstring-0.5.0/src/duckstring/storage.py +664 -0
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/trickle/builder.py +98 -20
- duckstring-0.5.0/src/duckstring/trickle/capture.py +384 -0
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/trickle/io.py +153 -43
- duckstring-0.5.0/src/duckstring/trickle/lineage.py +305 -0
- {duckstring-0.3.0 → duckstring-0.5.0/src/duckstring.egg-info}/PKG-INFO +18 -2
- duckstring-0.5.0/src/duckstring.egg-info/SOURCES.txt +266 -0
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring.egg-info/requires.txt +16 -0
- duckstring-0.3.0/src/duckstring/catchment/app.py +0 -123
- duckstring-0.3.0/src/duckstring/catchment/asgi.py +0 -30
- duckstring-0.3.0/src/duckstring/catchment/driver.py +0 -1498
- duckstring-0.3.0/src/duckstring/catchment/launcher.py +0 -94
- duckstring-0.3.0/src/duckstring/catchment/registry.py +0 -27
- duckstring-0.3.0/src/duckstring/catchment/routes/catchment.py +0 -129
- duckstring-0.3.0/src/duckstring/catchment/routes/deploy.py +0 -267
- duckstring-0.3.0/src/duckstring/catchment/routes/duck.py +0 -31
- duckstring-0.3.0/src/duckstring/catchment/routes/orchestrate.py +0 -302
- duckstring-0.3.0/src/duckstring/catchment/static/_next/static/chunks/0d-69yy-ja-ef.css +0 -1
- duckstring-0.3.0/src/duckstring/catchment/static/_next/static/chunks/0l6vjq3ae5vov.js +0 -2
- duckstring-0.3.0/src/duckstring/catchment/static/index.html +0 -1
- duckstring-0.3.0/src/duckstring/cli/catchment.py +0 -368
- duckstring-0.3.0/src/duckstring/cli/data.py +0 -133
- duckstring-0.3.0/src/duckstring/dataplane.py +0 -518
- duckstring-0.3.0/src/duckstring/duck/__main__.py +0 -178
- duckstring-0.3.0/src/duckstring/duck/executor.py +0 -187
- duckstring-0.3.0/src/duckstring/schema_contract.py +0 -67
- duckstring-0.3.0/src/duckstring/utils.py +0 -3
- duckstring-0.3.0/src/duckstring.egg-info/SOURCES.txt +0 -146
- {duckstring-0.3.0 → duckstring-0.5.0}/LICENSE +0 -0
- {duckstring-0.3.0 → duckstring-0.5.0}/setup.cfg +0 -0
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/__main__.py +0 -0
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/acc.py +0 -0
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/agg.py +0 -0
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/catchment/__init__.py +0 -0
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/catchment/dag.py +0 -0
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/catchment/db.py +0 -0
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/catchment/schema/001_init.sql +0 -0
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/catchment/schema/002_ducts.sql +0 -0
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/catchment/schema/003_pull_m.sql +0 -0
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/catchment/schema/004_identity.sql +0 -0
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/catchment/schema/005_pond_version_schema.sql +0 -0
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/catchment/schema/006_refresh.sql +0 -0
- {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
- {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
- {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
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/catchment/static/_next/static/chunks/03~yq9q893hmn.js +0 -0
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/catchment/static/_next/static/chunks/07lhk_q6pmm3r.js +0 -0
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/catchment/static/_next/static/chunks/0dbhjjzl8qfwv.js +0 -0
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/catchment/static/_next/static/chunks/0jvmviuftg5e2.css +0 -0
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/catchment/static/_next/static/chunks/0kczw6usu-y-f.js +0 -0
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/catchment/static/_next/static/chunks/11kjahy2ntf0n.js +0 -0
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/catchment/static/_next/static/chunks/turbopack-03~mbvk_uplk_.js +0 -0
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/catchment/static/favicon.svg +0 -0
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/catchment/static/logo-mark.svg +0 -0
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/catchment/static/logo.svg +0 -0
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/cli/duct.py +0 -0
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/cli/puddle.py +0 -0
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/cli/status.py +0 -0
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/cli/trigger.py +0 -0
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/cli/window.py +0 -0
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/demo/catalog/.gitignore +0 -0
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/demo/catalog/README.md +0 -0
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/demo/catalog/pond.toml +0 -0
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/demo/orders/.gitignore +0 -0
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/demo/orders/README.md +0 -0
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/demo/orders/pond.toml +0 -0
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/demo/orders/src/pond.py +0 -0
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/demo/priced/.gitignore +0 -0
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/demo/priced/README.md +0 -0
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/demo/priced/pond.toml +0 -0
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/demo/priced/src/puddles.py +0 -0
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/demo/products/.gitignore +0 -0
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/demo/products/README.md +0 -0
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/demo/products/pond.toml +0 -0
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/demo/products/src/pond.py +0 -0
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/demo/reports/.gitignore +0 -0
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/demo/reports/README.md +0 -0
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/demo/reports/pond.toml +0 -0
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/demo/reports/src/pond.py +0 -0
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/demo/revenue/.gitignore +0 -0
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/demo/revenue/README.md +0 -0
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/demo/revenue/pond.toml +0 -0
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/demo/revenue/src/pond.py +0 -0
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/demo/revenue/src/puddles.py +0 -0
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/demo/sales/.gitignore +0 -0
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/demo/sales/README.md +0 -0
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/demo/sales/pond.toml +0 -0
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/demo/sales/src/pond.py +0 -0
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/demo/sales/src/puddles.py +0 -0
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/demo/transactions/.gitignore +0 -0
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/demo/transactions/README.md +0 -0
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/demo/transactions/pond.toml +0 -0
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/demo/transactions/src/pond.py +0 -0
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/duck/__init__.py +0 -0
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/engine/pond.py +0 -0
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/keys.py +0 -0
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/local/__init__.py +0 -0
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/local/hydrate.py +0 -0
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/local/project.py +0 -0
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/trickle/__init__.py +0 -0
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/trickle/acc.py +0 -0
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/trickle/agg.py +0 -0
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/trickle/context.py +0 -0
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/trickle_builder.py +0 -0
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring/trickle_io.py +0 -0
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring.egg-info/dependency_links.txt +0 -0
- {duckstring-0.3.0 → duckstring-0.5.0}/src/duckstring.egg-info/entry_points.txt +0 -0
- {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,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: duckstring
|
|
3
|
-
Version: 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
|
-
|
|
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
|
-
|
|
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
|
+
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]
|
|
@@ -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)
|