dte-airflow 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dte/__init__.py +17 -0
- dte/airflow/__init__.py +0 -0
- dte/airflow/assets.py +57 -0
- dte/airflow/auth.py +42 -0
- dte/airflow/connection_store.py +53 -0
- dte/airflow/connections.py +205 -0
- dte/airflow/dag_factory.py +112 -0
- dte/airflow/dag_runs.py +32 -0
- dte/airflow/db.py +46 -0
- dte/airflow/notify.py +102 -0
- dte/airflow/operators.py +158 -0
- dte/airflow/plugin.py +40 -0
- dte/airflow/provider.py +7 -0
- dte/airflow/results.py +36 -0
- dte/airflow/runner.py +294 -0
- dte/airflow/trigger.py +88 -0
- dte/alembic_util.py +101 -0
- dte/api/CLAUDE.md +65 -0
- dte/api/__init__.py +0 -0
- dte/api/deps.py +84 -0
- dte/api/error_handlers.py +39 -0
- dte/api/http_metrics.py +104 -0
- dte/api/interactive.py +48 -0
- dte/api/main.py +75 -0
- dte/api/middleware.py +47 -0
- dte/api/routes/__init__.py +0 -0
- dte/api/routes/artifacts.py +149 -0
- dte/api/routes/connections.py +83 -0
- dte/api/routes/dashboards.py +96 -0
- dte/api/routes/destinations.py +147 -0
- dte/api/routes/evals.py +1215 -0
- dte/api/routes/health.py +26 -0
- dte/api/routes/lookup_tables.py +126 -0
- dte/api/routes/me.py +15 -0
- dte/api/routes/notifications.py +67 -0
- dte/api/routes/pipelines.py +1354 -0
- dte/api/routes/providers.py +73 -0
- dte/api/routes/runs.py +123 -0
- dte/api/routes/schedules.py +165 -0
- dte/api/routes/schema.py +133 -0
- dte/api/routes/sources.py +230 -0
- dte/artifacts/__init__.py +43 -0
- dte/artifacts/local.py +99 -0
- dte/artifacts/store.py +108 -0
- dte/automap.py +201 -0
- dte/cli.py +125 -0
- dte/compiler/__init__.py +0 -0
- dte/compiler/dialect_notes.py +205 -0
- dte/compiler/ibis_compiler.py +1089 -0
- dte/compiler/interpolate.py +285 -0
- dte/config.py +82 -0
- dte/docker/__init__.py +0 -0
- dte/docker/entrypoint.py +56 -0
- dte/engine/__init__.py +12 -0
- dte/engine/backend_registry.py +243 -0
- dte/engine/base.py +139 -0
- dte/engine/connect.py +21 -0
- dte/engine/duckdb_engine.py +113 -0
- dte/engine/registry.py +74 -0
- dte/engine/trino_engine.py +136 -0
- dte/engine/writers/__init__.py +52 -0
- dte/engine/writers/_copy.py +127 -0
- dte/engine/writers/csv_file.py +18 -0
- dte/engine/writers/excel_file.py +28 -0
- dte/engine/writers/json_file.py +23 -0
- dte/engine/writers/parquet.py +23 -0
- dte/engine/writers/postgres.py +57 -0
- dte/engine/writers/provenance.py +121 -0
- dte/engine/writers/sql_database.py +114 -0
- dte/errors.py +53 -0
- dte/evals/CLAUDE.md +266 -0
- dte/evals/__init__.py +39 -0
- dte/evals/bootstrap.py +150 -0
- dte/evals/comparators.py +258 -0
- dte/evals/comparison.py +296 -0
- dte/evals/cost.py +147 -0
- dte/evals/derive.py +124 -0
- dte/evals/digest.py +61 -0
- dte/evals/judge.py +91 -0
- dte/evals/regression.py +53 -0
- dte/evals/runner.py +191 -0
- dte/evals/scoring.py +220 -0
- dte/evals/sweep.py +326 -0
- dte/logging.py +93 -0
- dte/metrics.py +53 -0
- dte/migrations/alembic/README +1 -0
- dte/migrations/alembic/env.py +81 -0
- dte/migrations/alembic/script.py.mako +28 -0
- dte/migrations/alembic/versions/001_initial_schema.py +40 -0
- dte/migrations/alembic/versions/002_add_run_location.py +20 -0
- dte/migrations/alembic/versions/003_run_finished_at_trigger.py +37 -0
- dte/migrations/alembic/versions/004_sources_and_destinations.py +39 -0
- dte/migrations/alembic/versions/005_connections.py +28 -0
- dte/migrations/alembic/versions/006_pipeline_columns.py +48 -0
- dte/migrations/alembic/versions/007_source_dest_connection_id.py +23 -0
- dte/migrations/alembic/versions/008_backfill_local_fs_connection.py +75 -0
- dte/migrations/alembic/versions/009_source_format_column.py +48 -0
- dte/migrations/alembic/versions/010_users.py +49 -0
- dte/migrations/alembic/versions/011_lookup_tables.py +29 -0
- dte/migrations/alembic/versions/012_rbac.py +105 -0
- dte/migrations/alembic/versions/013_run_events.py +35 -0
- dte/migrations/alembic/versions/014_pipeline_revisions.py +43 -0
- dte/migrations/alembic/versions/015_tenant_id.py +91 -0
- dte/migrations/alembic/versions/016_rls_policies.py +61 -0
- dte/migrations/alembic/versions/017_tenant_secret_keys.py +82 -0
- dte/migrations/alembic/versions/018_pipeline_engine_and_quality_contracts.py +38 -0
- dte/migrations/alembic/versions/019_run_params.py +33 -0
- dte/migrations/alembic/versions/020_connection_name.py +37 -0
- dte/migrations/alembic/versions/021_source_name.py +32 -0
- dte/migrations/alembic/versions/022_destination_name.py +32 -0
- dte/migrations/alembic/versions/023_lookup_table_name.py +33 -0
- dte/migrations/alembic/versions/024_pipeline_name_unique.py +50 -0
- dte/migrations/alembic/versions/025_run_quality_checks.py +26 -0
- dte/migrations/alembic/versions/026_pipeline_type.py +36 -0
- dte/migrations/alembic/versions/027_run_sql_query.py +26 -0
- dte/migrations/alembic/versions/028_destination_type_rename.py +71 -0
- dte/migrations/alembic/versions/029_procrastinate_schema.py +53 -0
- dte/migrations/alembic/versions/030_run_job_id.py +31 -0
- dte/migrations/alembic/versions/031_run_heartbeat.py +29 -0
- dte/migrations/alembic/versions/032_schedules.py +65 -0
- dte/migrations/alembic/versions/033_run_schedule_id.py +30 -0
- dte/migrations/alembic/versions/034_evals.py +143 -0
- dte/migrations/alembic/versions/035_schedule_eval_set.py +36 -0
- dte/migrations/alembic/versions/036_pipeline_matrix.py +37 -0
- dte/migrations/alembic/versions/037_pipeline_source_refs.py +65 -0
- dte/migrations/alembic/versions/038_dashboards.py +88 -0
- dte/migrations/alembic/versions/039_airflow_cut.py +96 -0
- dte/migrations/alembic/versions/040_pipeline_schedule.py +60 -0
- dte/migrations/alembic/versions/041_pipeline_notifications.py +23 -0
- dte/migrations/alembic/versions/091_drop_tenant_id.py +50 -0
- dte/migrations/alembic.ini +149 -0
- dte/models/__init__.py +0 -0
- dte/models/dashboard.py +133 -0
- dte/models/eval.py +208 -0
- dte/models/pipeline.py +1984 -0
- dte/models/quality.py +83 -0
- dte/models/user.py +12 -0
- dte/pipelines/__init__.py +0 -0
- dte/pipelines/diff.py +170 -0
- dte/providers/__init__.py +83 -0
- dte/providers/base.py +117 -0
- dte/providers/pdf.py +212 -0
- dte/providers/schema.py +104 -0
- dte/providers/structure.py +235 -0
- dte/quality/__init__.py +0 -0
- dte/quality/evaluator.py +234 -0
- dte/scheduling/__init__.py +0 -0
- dte/scheduling/cron.py +74 -0
- dte/schema/__init__.py +0 -0
- dte/schema/inferrer.py +74 -0
- dte/security/__init__.py +1 -0
- dte/security/permissions.py +49 -0
- dte/sources/CLAUDE.md +136 -0
- dte/sources/__init__.py +0 -0
- dte/sources/connection_test.py +46 -0
- dte/sources/dlt_loader.py +11 -0
- dte/sources/duckdb_secrets.py +63 -0
- dte/sources/loader.py +487 -0
- dte/sources/metered.py +174 -0
- dte/sources/object_store.py +54 -0
- dte/sources/pools/__init__.py +4 -0
- dte/sources/pools/engine.py +49 -0
- dte/sources/pools/mongo.py +71 -0
- dte/sources/sample.py +146 -0
- dte/store/CLAUDE.md +72 -0
- dte/store/__init__.py +0 -0
- dte/store/base.py +162 -0
- dte/store/files.py +184 -0
- dte/store/postgres.py +680 -0
- dte/tracing.py +33 -0
- dte/ui/dte.umd.js +116 -0
- dte/ui/icon-dark.svg +1 -0
- dte/ui/icon.svg +1 -0
- dte/ui/pdfjs/pdf.worker.min.mjs +29 -0
- dte_airflow-0.1.0.dist-info/METADATA +323 -0
- dte_airflow-0.1.0.dist-info/RECORD +180 -0
- dte_airflow-0.1.0.dist-info/WHEEL +4 -0
- dte_airflow-0.1.0.dist-info/entry_points.txt +8 -0
- dte_airflow-0.1.0.dist-info/licenses/LICENSE +201 -0
- dte_airflow-0.1.0.dist-info/licenses/NOTICE +4 -0
dte/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""DTE. Importing the package applies the one dependency shim below."""
|
|
2
|
+
import sqlglot.expressions as _sge
|
|
3
|
+
|
|
4
|
+
# Airflow 3.3's constraints pin sqlglot 30, which moved Drop's target from
|
|
5
|
+
# `this` to `tables`. ibis 12 still builds `Drop(this=...)` in every backend,
|
|
6
|
+
# so each DROP (temp views, overwrite, drop_table) lost its name. Map it here,
|
|
7
|
+
# once, for all of them (plan 073).
|
|
8
|
+
# ponytail: global shim; delete when an ibis release builds `Drop(tables=...)`.
|
|
9
|
+
if "tables" in _sge.Drop.arg_types and "this" not in _sge.Drop.arg_types:
|
|
10
|
+
_drop_init = _sge.Drop.__init__
|
|
11
|
+
|
|
12
|
+
def _init(self, **args):
|
|
13
|
+
if "this" in args and "tables" not in args:
|
|
14
|
+
args["tables"] = [args.pop("this")]
|
|
15
|
+
_drop_init(self, **args)
|
|
16
|
+
|
|
17
|
+
_sge.Drop.__init__ = _init
|
dte/airflow/__init__.py
ADDED
|
File without changes
|
dte/airflow/assets.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Airflow Asset URIs for what a pipeline reads and writes (plan 089).
|
|
2
|
+
|
|
3
|
+
Airflow's Assets view is the lineage graph, and an "after upstream" schedule
|
|
4
|
+
triggers on these URIs, so a destination and a source naming the same table
|
|
5
|
+
must produce the same string.
|
|
6
|
+
|
|
7
|
+
- Files keep their own URL (`s3://…`, `gs://…`); a local path becomes `file://`.
|
|
8
|
+
- Tables are `dte://<conn_id>/<schema>/<table>`. Not `postgres://`: provider
|
|
9
|
+
URI normalizers (postgres wants database/schema/table, mysql database/table)
|
|
10
|
+
would reject a conn_id-based URI at parse time and break the DAG, and a
|
|
11
|
+
source doesn't know its dialect without opening the connection.
|
|
12
|
+
- Only the conn_id ever goes in, never a URL from a connection secret; userinfo
|
|
13
|
+
in a configured URL is dropped. No airflow import: the API lists these too.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from urllib.parse import urlsplit, urlunsplit
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _location(path: str) -> str:
|
|
21
|
+
if "://" not in path:
|
|
22
|
+
return "file://" + ("" if path.startswith("/") else "/") + path
|
|
23
|
+
p = urlsplit(path)
|
|
24
|
+
return urlunsplit(p._replace(netloc=p.netloc.rpartition("@")[2]))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def asset_uri(obj, table: str | None = None) -> str | None:
|
|
28
|
+
"""URI for a `Destination`, or a `Source` plus the table a pipeline reads
|
|
29
|
+
from it. None when there is nothing addressable (rest_api)."""
|
|
30
|
+
cfg = obj.config
|
|
31
|
+
cfg = cfg.model_dump(by_alias=True) if hasattr(cfg, "model_dump") else dict(cfg or {})
|
|
32
|
+
kind = cfg.get("type") or getattr(obj, "source_type", None)
|
|
33
|
+
if kind in ("postgres", "sql_database"):
|
|
34
|
+
return f"dte://{obj.connection_id}/{cfg.get('schema') or 'default'}/{table or cfg.get('table') or obj.table}"
|
|
35
|
+
if kind == "mongodb":
|
|
36
|
+
return f"dte://{obj.connection_id}/{cfg['database']}/{cfg['collection']}"
|
|
37
|
+
if cfg.get("path"):
|
|
38
|
+
return _location(cfg["path"])
|
|
39
|
+
if cfg.get("bucket_url"):
|
|
40
|
+
return _location(f"{cfg['bucket_url'].rstrip('/')}/{cfg.get('file_glob', '')}")
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def lineage(config: dict, sources: dict, destinations: dict) -> tuple[list[str], list[str]]:
|
|
45
|
+
"""(inlets, outlets) URIs for a pipeline config, given Sources and
|
|
46
|
+
Destinations by id. Missing ones are skipped: the run fails on them, the
|
|
47
|
+
DAG shouldn't."""
|
|
48
|
+
from dte.models.pipeline import coerce_source_refs
|
|
49
|
+
|
|
50
|
+
inlets = [u for ref in coerce_source_refs(config.get("sources") or [])
|
|
51
|
+
if (s := sources.get(ref.source_id)) and (u := asset_uri(s, ref.table))]
|
|
52
|
+
outlets = []
|
|
53
|
+
if (config.get("pipeline_type") or "transform") == "transform":
|
|
54
|
+
d = destinations.get(config.get("output_id"))
|
|
55
|
+
if d and (u := asset_uri(d)):
|
|
56
|
+
outlets.append(u)
|
|
57
|
+
return inlets, outlets
|
dte/airflow/auth.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Airflow-backed auth for the DTE API (plan 065).
|
|
2
|
+
|
|
3
|
+
Login, sessions and users are Airflow's. The caller is resolved with Airflow's
|
|
4
|
+
own `get_user` (Bearer token or the `_token` cookie the UI sets), and DTE
|
|
5
|
+
permissions map onto Airflow DAG access: read -> GET, write -> PUT,
|
|
6
|
+
delete -> DELETE, trigger/test -> POST.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from fastapi import Request
|
|
11
|
+
|
|
12
|
+
from dte.models.user import User
|
|
13
|
+
|
|
14
|
+
_METHOD = {"read": "GET", "write": "PUT", "delete": "DELETE", "trigger": "POST", "test": "POST"}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _resolver():
|
|
18
|
+
from airflow.api_fastapi.app import get_auth_manager
|
|
19
|
+
from airflow.api_fastapi.core_api.security import get_user
|
|
20
|
+
|
|
21
|
+
return get_auth_manager, get_user
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
async def get_current_user(request: Request) -> User:
|
|
25
|
+
"""FastAPI dependency. Airflow imports are lazy so DTE's API still
|
|
26
|
+
imports (and tests override this) outside an Airflow environment."""
|
|
27
|
+
from dte.security.permissions import ALL_PERMISSIONS
|
|
28
|
+
|
|
29
|
+
get_auth_manager, get_user = _resolver()
|
|
30
|
+
# Positional: Airflow 3.3 renamed `oauth_token` to `_oauth_token` (unused
|
|
31
|
+
# at runtime); the order (request, oauth token, bearer) is stable.
|
|
32
|
+
user = await get_user(request, None, await _bearer(request))
|
|
33
|
+
mgr = get_auth_manager()
|
|
34
|
+
allowed = {m: mgr.is_authorized_dag(method=m, user=user) for m in set(_METHOD.values())}
|
|
35
|
+
perms = [p for p in ALL_PERMISSIONS if allowed[_METHOD[p.split(":")[1]]]]
|
|
36
|
+
return User(id=str(user.get_id()), email=user.get_name(), permissions=perms)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
async def _bearer(request: Request):
|
|
40
|
+
from fastapi.security import HTTPBearer
|
|
41
|
+
|
|
42
|
+
return await HTTPBearer(auto_error=False)(request)
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""ConnectionStore over Airflow Connections (read-only, plan 074).
|
|
2
|
+
|
|
3
|
+
`get` resolves like any Airflow task does (secrets backends, `AIRFLOW_CONN_*`
|
|
4
|
+
env vars, then the metadata DB). `list` shows the metadata DB plus env-var
|
|
5
|
+
connections; secrets backends can't be enumerated. Only connections whose
|
|
6
|
+
provider is installed are offered.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import os
|
|
12
|
+
|
|
13
|
+
from dte.airflow.connections import available_kinds, from_airflow
|
|
14
|
+
from dte.models.pipeline import Connection
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _get(conn_id: str):
|
|
18
|
+
from airflow.hooks.base import BaseHook
|
|
19
|
+
|
|
20
|
+
return BaseHook.get_connection(conn_id)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _all_conn_ids() -> list[str]:
|
|
24
|
+
from airflow.models.connection import Connection as AConn
|
|
25
|
+
from airflow.utils.session import create_session
|
|
26
|
+
from sqlalchemy import select
|
|
27
|
+
|
|
28
|
+
with create_session() as s:
|
|
29
|
+
ids = set(s.scalars(select(AConn.conn_id)))
|
|
30
|
+
ids |= {k[len("AIRFLOW_CONN_"):].lower() for k in os.environ if k.startswith("AIRFLOW_CONN_")}
|
|
31
|
+
return sorted(ids)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _resolve(conn_id: str) -> Connection | None:
|
|
35
|
+
try:
|
|
36
|
+
c = from_airflow(_get(conn_id))
|
|
37
|
+
except Exception: # unknown id, unmapped type (redis, ...), or a provider that won't build a hook
|
|
38
|
+
return None
|
|
39
|
+
return c if c.connection_type in available_kinds() else None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class AirflowConnectionStore:
|
|
43
|
+
async def get(self, id: str) -> Connection | None:
|
|
44
|
+
return await asyncio.to_thread(_resolve, id)
|
|
45
|
+
|
|
46
|
+
async def list(self, limit: int = 50, offset: int = 0) -> list[Connection]:
|
|
47
|
+
def _list():
|
|
48
|
+
rows = (_resolve(i) for i in _all_conn_ids())
|
|
49
|
+
return [c for c in rows if c][offset:offset + limit]
|
|
50
|
+
|
|
51
|
+
return [Connection.model_construct(id=c.id, name=c.name, connection_type=c.connection_type,
|
|
52
|
+
credentials={})
|
|
53
|
+
for c in await asyncio.to_thread(_list)]
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Airflow Connection -> DTE Connection (plan 074).
|
|
2
|
+
|
|
3
|
+
The installed provider does the auth. SQL URLs come from the provider hook;
|
|
4
|
+
object storage carries only the conn_id and is opened through the provider's
|
|
5
|
+
filesystem (`dte.sources.object_store`). A kind is offered only when its
|
|
6
|
+
provider (and, for storage, the provider's filesystem) is installed.
|
|
7
|
+
|
|
8
|
+
`google_cloud_platform` is ambiguous: extra `dte_type` picks "gcs" or "bigquery"
|
|
9
|
+
(default gcs). The google provider's own `gcpbigquery` type is always BigQuery.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
from functools import lru_cache
|
|
15
|
+
|
|
16
|
+
from dte.models.pipeline import Connection
|
|
17
|
+
from dte.sources.metered import METERED
|
|
18
|
+
|
|
19
|
+
_SQL = {"postgres": "postgres", "mysql": "mysql", "mssql": "mssql", "sqlite": "sqlite", "snowflake": "snowflake"}
|
|
20
|
+
_STORAGE = {"aws": "s3", "google_cloud_platform": "gcs", "wasb": "azure", "adls": "azure",
|
|
21
|
+
"azure_data_lake": "azure", "sftp": "sftp"}
|
|
22
|
+
# Airflow connection extra -> credential key the opener reads (plan 093).
|
|
23
|
+
# BigQuery enforces the bytes cap; Snowflake (no bytes cap) the timeout.
|
|
24
|
+
_CAPS = {"dte_max_bytes_billed": "max_bytes_billed",
|
|
25
|
+
"dte_statement_timeout_seconds": "statement_timeout_seconds",
|
|
26
|
+
"dte_confirm_above_bytes": "confirm_above_bytes"}
|
|
27
|
+
# The fsspec scheme each storage kind needs the provider to supply.
|
|
28
|
+
STORAGE_SCHEME = {"s3": "s3", "gcs": "gs", "azure": "abfs", "sftp": "sftp"}
|
|
29
|
+
# Per DTE kind: the Airflow conn_types that map to it, and what to install
|
|
30
|
+
# when none is available (shown in the UI).
|
|
31
|
+
KINDS: dict[str, tuple[set[str], str]] = {
|
|
32
|
+
"local_fs": ({"fs"}, "apache-airflow-providers-standard"),
|
|
33
|
+
"postgres": ({"postgres"}, "apache-airflow-providers-postgres"),
|
|
34
|
+
"mysql": ({"mysql"}, "apache-airflow-providers-mysql"),
|
|
35
|
+
"mssql": ({"mssql"}, "apache-airflow-providers-microsoft-mssql"),
|
|
36
|
+
"sqlite": ({"sqlite"}, "apache-airflow-providers-sqlite"),
|
|
37
|
+
"snowflake": ({"snowflake"}, "apache-airflow-providers-snowflake"),
|
|
38
|
+
"bigquery": ({"google_cloud_platform", "gcpbigquery"}, "apache-airflow-providers-google"),
|
|
39
|
+
"mongodb": ({"mongo"}, "apache-airflow-providers-mongo"),
|
|
40
|
+
"rest_api": ({"http"}, "apache-airflow-providers-http"),
|
|
41
|
+
"s3": ({"aws"}, "apache-airflow-providers-amazon[s3fs]"),
|
|
42
|
+
"gcs": ({"google_cloud_platform"}, "apache-airflow-providers-google"),
|
|
43
|
+
"azure": ({"wasb", "adls", "azure_data_lake"}, "apache-airflow-providers-microsoft-azure"),
|
|
44
|
+
"sftp": ({"sftp"}, "apache-airflow-providers-sftp sshfs"),
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _plain_scheme(url: str) -> str:
|
|
49
|
+
"""`postgresql+psycopg2://…` -> `postgresql://…`: DTE's openers and dlt
|
|
50
|
+
writers want the dialect, not SQLAlchemy's driver."""
|
|
51
|
+
return re.sub(r"^(\w+)\+\w+://", r"\1://", url)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _hook_url(hook) -> str:
|
|
55
|
+
try:
|
|
56
|
+
url = hook.sqlalchemy_url
|
|
57
|
+
except (AttributeError, NotImplementedError):
|
|
58
|
+
return _plain_scheme(hook.get_uri())
|
|
59
|
+
s = url.render_as_string(hide_password=False) if hasattr(url, "render_as_string") else str(url)
|
|
60
|
+
return _plain_scheme(s)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def from_airflow(c) -> Connection:
|
|
64
|
+
"""`c` is an Airflow Connection (or anything with the same attributes)."""
|
|
65
|
+
x, t = c.extra_dejson, c.conn_type
|
|
66
|
+
if t in _SQL:
|
|
67
|
+
kind, creds = _SQL[t], {"url": _hook_url(c.get_hook())}
|
|
68
|
+
if t == "snowflake":
|
|
69
|
+
# The provider keeps key-pair and OAuth auth out of the URL; the
|
|
70
|
+
# opener and writer build their clients from the hook instead.
|
|
71
|
+
creds["conn_id"] = c.conn_id
|
|
72
|
+
if x.get("warehouse"):
|
|
73
|
+
creds["warehouse"] = x["warehouse"] # named in the run estimate
|
|
74
|
+
elif t == "mongo":
|
|
75
|
+
kind, creds = "mongodb", {"url": c.get_hook().uri}
|
|
76
|
+
elif t == "gcpbigquery" or (t == "google_cloud_platform" and x.get("dte_type") == "bigquery"):
|
|
77
|
+
# The project comes from the hook; credentials are fetched from it at open time.
|
|
78
|
+
kind = "bigquery"
|
|
79
|
+
creds = {"url": f"bigquery://{c.get_hook().project_id}", "conn_id": c.conn_id}
|
|
80
|
+
elif t in _STORAGE:
|
|
81
|
+
kind, creds = _STORAGE[t], {"conn_id": c.conn_id}
|
|
82
|
+
elif t == "http":
|
|
83
|
+
kind = "rest_api"
|
|
84
|
+
auth = ({"type": "http_basic", "username": c.login, "password": c.password} if c.login
|
|
85
|
+
else {"type": "bearer", "token": c.password} if c.password else {})
|
|
86
|
+
creds = {"auth": auth, "headers": x.get("headers") or {}}
|
|
87
|
+
elif t == "fs":
|
|
88
|
+
kind, creds = "local_fs", {}
|
|
89
|
+
else:
|
|
90
|
+
raise ValueError(f"airflow connection type {t!r} has no DTE mapping (conn_id={c.conn_id!r})")
|
|
91
|
+
if kind in METERED:
|
|
92
|
+
# Plan 093: per-connection caps, applied by the backend_registry openers.
|
|
93
|
+
for extra_key, cred_key in _CAPS.items():
|
|
94
|
+
if x.get(extra_key):
|
|
95
|
+
creds[cred_key] = int(x[extra_key])
|
|
96
|
+
return Connection.model_construct(id=c.conn_id, name=c.conn_id, connection_type=kind,
|
|
97
|
+
credentials=creds)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@lru_cache(maxsize=1)
|
|
101
|
+
def _installed() -> tuple[set[str], set[str]]:
|
|
102
|
+
"""(conn_types with a provider hook, fsspec schemes a provider can open)."""
|
|
103
|
+
import fsspec
|
|
104
|
+
from airflow.providers_manager import ProvidersManager
|
|
105
|
+
|
|
106
|
+
conn_types = set(ProvidersManager().hooks)
|
|
107
|
+
schemes = set()
|
|
108
|
+
for scheme in STORAGE_SCHEME.values():
|
|
109
|
+
try:
|
|
110
|
+
fsspec.get_filesystem_class(scheme) # s3 needs s3fs, gs gcsfs, abfs adlfs, sftp sshfs
|
|
111
|
+
schemes.add(scheme)
|
|
112
|
+
except (ImportError, ValueError):
|
|
113
|
+
pass
|
|
114
|
+
return conn_types, schemes
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def available_kinds() -> set[str]:
|
|
118
|
+
conn_types, schemes = _installed()
|
|
119
|
+
return {k for k, (types, _) in KINDS.items()
|
|
120
|
+
if types & conn_types and (k not in STORAGE_SCHEME or STORAGE_SCHEME[k] in schemes)}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def to_dte(conn_id: str) -> tuple[Connection, dict]:
|
|
124
|
+
from airflow.hooks.base import BaseHook # lazy: airflow is an optional extra
|
|
125
|
+
|
|
126
|
+
conn = from_airflow(BaseHook.get_connection(conn_id))
|
|
127
|
+
return conn, conn.credentials
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def http_headers(conn_id: str) -> dict[str, str]:
|
|
131
|
+
"""Request headers for an `http_lookup` step (plan 085) from an Airflow
|
|
132
|
+
connection: `headers` in its extras, plus the password as a Bearer token.
|
|
133
|
+
A missing connection raises `ModelConnectionMissing`."""
|
|
134
|
+
from airflow.exceptions import AirflowNotFoundException
|
|
135
|
+
from airflow.hooks.base import BaseHook
|
|
136
|
+
|
|
137
|
+
try:
|
|
138
|
+
conn = BaseHook.get_connection(conn_id)
|
|
139
|
+
except AirflowNotFoundException:
|
|
140
|
+
raise ModelConnectionMissing(
|
|
141
|
+
f"airflow connection {conn_id!r} not found — create it in Airflow → "
|
|
142
|
+
"Admin → Connections (type 'http', token as password)"
|
|
143
|
+
) from None
|
|
144
|
+
headers = {str(k): str(v) for k, v in (conn.extra_dejson.get("headers") or {}).items()}
|
|
145
|
+
if conn.password:
|
|
146
|
+
headers.setdefault("Authorization", f"Bearer {conn.password}")
|
|
147
|
+
return headers
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# Model keys (plan 072). The official providers' hooks own the conn type, the
|
|
151
|
+
# default conn id and client construction (password = key, host = base_url,
|
|
152
|
+
# Bedrock/Vertex via extras); DTE only picks the hook.
|
|
153
|
+
_MODEL_HOOKS = {
|
|
154
|
+
"claude": ("airflow.providers.anthropic.hooks.anthropic", "AnthropicHook"),
|
|
155
|
+
"openai": ("airflow.providers.openai.hooks.openai", "OpenAIHook"),
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class ModelConnectionMissing(RuntimeError):
|
|
160
|
+
"""The step's model connection does not exist in Airflow."""
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _hook_class(provider: str):
|
|
164
|
+
import importlib
|
|
165
|
+
|
|
166
|
+
module, name = _MODEL_HOOKS[provider]
|
|
167
|
+
return getattr(importlib.import_module(module), name)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def model_client(provider: str, conn_id: str | None):
|
|
171
|
+
"""The SDK client for `provider`, built from an Airflow connection."""
|
|
172
|
+
from airflow.exceptions import AirflowNotFoundException
|
|
173
|
+
|
|
174
|
+
hook_cls = _hook_class(provider)
|
|
175
|
+
conn_id = conn_id or hook_cls.default_conn_name
|
|
176
|
+
try:
|
|
177
|
+
return hook_cls(conn_id=conn_id).get_conn()
|
|
178
|
+
except AirflowNotFoundException:
|
|
179
|
+
raise ModelConnectionMissing(
|
|
180
|
+
f"airflow connection {conn_id!r} not found — create it in Airflow → "
|
|
181
|
+
f"Admin → Connections (type {hook_cls.conn_type!r}, API key as password)"
|
|
182
|
+
) from None
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def model_connections(provider: str) -> list[str]:
|
|
186
|
+
"""Conn ids usable by `provider`: every DB connection of its type, plus the
|
|
187
|
+
default when it resolves elsewhere (env var or secrets backend)."""
|
|
188
|
+
from airflow.exceptions import AirflowNotFoundException
|
|
189
|
+
from airflow.hooks.base import BaseHook
|
|
190
|
+
from airflow.models.connection import Connection as AConn
|
|
191
|
+
from airflow.utils.session import create_session
|
|
192
|
+
from sqlalchemy import select
|
|
193
|
+
|
|
194
|
+
hook_cls = _hook_class(provider)
|
|
195
|
+
with create_session() as s:
|
|
196
|
+
ids = list(s.scalars(
|
|
197
|
+
select(AConn.conn_id).where(AConn.conn_type == hook_cls.conn_type).order_by(AConn.conn_id)
|
|
198
|
+
))
|
|
199
|
+
if hook_cls.default_conn_name not in ids:
|
|
200
|
+
try:
|
|
201
|
+
BaseHook.get_connection(hook_cls.default_conn_name)
|
|
202
|
+
ids.insert(0, hook_cls.default_conn_name)
|
|
203
|
+
except AirflowNotFoundException:
|
|
204
|
+
pass
|
|
205
|
+
return ids
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""One DAG per DTE pipeline, built from its latest revision (plan 068).
|
|
2
|
+
|
|
3
|
+
Pipeline revision = DAG version: the revision id is a DAG tag, so every save
|
|
4
|
+
changes the serialized DAG and Airflow records a new DagVersion. The
|
|
5
|
+
pipeline's one schedule (if any, and enabled) is the DAG's cron.
|
|
6
|
+
|
|
7
|
+
`DTE_PIPELINE_STORE=postgres`: read from the `dte` schema at parse time.
|
|
8
|
+
`file` (plan 090): read `DTE_PIPELINES_DIR/*.yaml`, no DB; each file is its
|
|
9
|
+
own revision (id = content hash) and parses in its own try/except, so a
|
|
10
|
+
broken file drops only its own DAG.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
import re
|
|
16
|
+
from datetime import datetime, timedelta
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from sqlmodel import Session, select
|
|
20
|
+
|
|
21
|
+
log = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
_SAFE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def build_dags() -> dict:
|
|
27
|
+
from dte.airflow.assets import lineage
|
|
28
|
+
from dte.config import get_settings
|
|
29
|
+
|
|
30
|
+
settings = get_settings()
|
|
31
|
+
if settings.pipeline_store == "file":
|
|
32
|
+
return _dags_from_files(Path(settings.pipelines_dir))
|
|
33
|
+
latest, sources, dests = _latest_revisions()
|
|
34
|
+
dags = (_dag_for(rev, *lineage(rev.config, sources, dests)) for rev in latest if _SAFE.match(rev.pipeline_id))
|
|
35
|
+
return {d.dag_id: d for d in dags}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _latest_revisions() -> tuple[list, dict, dict]:
|
|
39
|
+
from dte.airflow.db import sync_engine
|
|
40
|
+
from dte.models.pipeline import Destination, PipelineRevision, Source
|
|
41
|
+
|
|
42
|
+
R = PipelineRevision
|
|
43
|
+
with Session(sync_engine()) as s:
|
|
44
|
+
# Revisions cascade-delete with their pipeline, so this is "every pipeline".
|
|
45
|
+
latest = list(s.exec(select(R).distinct(R.pipeline_id).order_by(R.pipeline_id, R.changed_at.desc())).all())
|
|
46
|
+
sources = {x.id: x for x in s.exec(select(Source)).all()}
|
|
47
|
+
dests = {x.id: x for x in s.exec(select(Destination)).all()}
|
|
48
|
+
s.expunge_all()
|
|
49
|
+
return latest, sources, dests
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _dags_from_files(root: Path) -> dict:
|
|
53
|
+
from dte.store.files import read_revision
|
|
54
|
+
|
|
55
|
+
dags = {}
|
|
56
|
+
for path in sorted(root.glob("*.yaml")):
|
|
57
|
+
if not _SAFE.match(path.stem):
|
|
58
|
+
continue
|
|
59
|
+
try:
|
|
60
|
+
# ponytail: no inlets/outlets in file mode (sources/destinations live
|
|
61
|
+
# in Postgres, and parse reads no DB); fix before defaulting to file.
|
|
62
|
+
dag = _dag_for(read_revision(path))
|
|
63
|
+
except Exception:
|
|
64
|
+
log.exception("DTE pipeline file %s skipped: no DAG until it is fixed", path)
|
|
65
|
+
continue
|
|
66
|
+
dags[dag.dag_id] = dag
|
|
67
|
+
return dags
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _dag_for(rev, inlets=(), outlets=()):
|
|
71
|
+
from airflow.sdk import DAG, Asset
|
|
72
|
+
from airflow.timetables.trigger import CronTriggerTimetable
|
|
73
|
+
|
|
74
|
+
from dte.airflow.notify import callbacks
|
|
75
|
+
from dte.airflow.operators import DtePipelineOperator
|
|
76
|
+
from dte.airflow.trigger import dag_id_for, revision_tag
|
|
77
|
+
from dte.config import get_settings
|
|
78
|
+
from dte.store.files import dump_yaml
|
|
79
|
+
|
|
80
|
+
p, pid = rev.config, rev.pipeline_id
|
|
81
|
+
sc = p.get("schedule") or {}
|
|
82
|
+
cron = None
|
|
83
|
+
if sc and sc.get("enabled", True):
|
|
84
|
+
if sc.get("mode") == "assets":
|
|
85
|
+
cron = [Asset(u) for u in sc["assets"]]
|
|
86
|
+
else:
|
|
87
|
+
cron = CronTriggerTimetable(sc["cron_expr"], timezone=sc.get("timezone", "UTC"))
|
|
88
|
+
ptype = p.get("pipeline_type") or "transform"
|
|
89
|
+
with DAG(
|
|
90
|
+
dag_id=dag_id_for(pid), schedule=cron, start_date=datetime(2024, 1, 1), catchup=False,
|
|
91
|
+
max_active_runs=16 if sc.get("overlap") == "allow" else 1,
|
|
92
|
+
params=dict(sc.get("params") or {}),
|
|
93
|
+
tags=["dte", f"dte-pipeline:{pid}", f"dte-type:{ptype}", revision_tag(rev.id)],
|
|
94
|
+
dag_display_name=p.get("name") or pid,
|
|
95
|
+
# The DAG page's Docs: the pipeline as YAML (what `dte export` writes)
|
|
96
|
+
# and a way back to the editor. Relative, like the UI's Airflow links,
|
|
97
|
+
# so Airflow's <base href> resolves it under any subpath.
|
|
98
|
+
doc_md=f"[Open in DTE](plugin/dte/pipelines/detail?id={pid})\n\n```yaml\n{dump_yaml(p)}```\n",
|
|
99
|
+
# A paused DAG queues manual runs forever. Per DAG, so an existing
|
|
100
|
+
# Airflow needn't change its global default (plan 073).
|
|
101
|
+
is_paused_upon_creation=False,
|
|
102
|
+
) as dag:
|
|
103
|
+
# Manual and scheduled runs share this one task (plan 068), so retries
|
|
104
|
+
# and notifications apply to both.
|
|
105
|
+
DtePipelineOperator(
|
|
106
|
+
task_id="run", pipeline_id=pid, revision_id=rev.id, eval_set_id=sc.get("eval_set_id"),
|
|
107
|
+
retries=sc.get("retries", 0), retry_delay=timedelta(seconds=sc.get("retry_delay_s", 300)),
|
|
108
|
+
execution_timeout=timedelta(seconds=sc.get("timeout_s") or get_settings().run_timeout_seconds),
|
|
109
|
+
inlets=[Asset(u) for u in inlets], outlets=[Asset(u) for u in outlets],
|
|
110
|
+
**callbacks(p.get("notifications") or []),
|
|
111
|
+
)
|
|
112
|
+
return dag
|
dte/airflow/dag_runs.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Dag runs of DTE DAGs, read with Airflow's own session (plan 075).
|
|
2
|
+
|
|
3
|
+
api-server only: Airflow hides its metadata DB from DAG parsing and tasks.
|
|
4
|
+
Reading through Airflow (not DTE's engine) is what lets `DTE_DATABASE_URL`
|
|
5
|
+
point at a database that has no `dag_run` table.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
|
|
11
|
+
_COLS = ("run_id", "dag_id", "state", "queued_at", "start_date", "end_date", "run_type", "conf")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _read(dag_ids: list[str] | None, run_id: str | None) -> list[dict]:
|
|
15
|
+
from airflow.models.dagrun import DagRun
|
|
16
|
+
from airflow.utils.session import create_session
|
|
17
|
+
from sqlalchemy import select
|
|
18
|
+
|
|
19
|
+
q = select(*(getattr(DagRun, c) for c in _COLS))
|
|
20
|
+
q = q.where(DagRun.dag_id.in_(dag_ids)) if dag_ids is not None else q.where(DagRun.dag_id.like(r"dte\_\_%", escape="\\"))
|
|
21
|
+
if run_id is not None:
|
|
22
|
+
q = q.where(DagRun.run_id == run_id)
|
|
23
|
+
with create_session() as s:
|
|
24
|
+
rows = [dict(r._mapping) for r in s.execute(q)]
|
|
25
|
+
for r in rows: # DagRunState / DagRunType are str enums
|
|
26
|
+
for c in ("state", "run_type"):
|
|
27
|
+
r[c] = getattr(r[c], "value", r[c])
|
|
28
|
+
return rows
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
async def dag_runs(dag_ids: list[str] | None = None, run_id: str | None = None) -> list[dict]:
|
|
32
|
+
return await asyncio.to_thread(_read, dag_ids, run_id)
|
dte/airflow/db.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Engines for DTE's `dte` schema (plans 065, 075).
|
|
2
|
+
|
|
3
|
+
URL: `DTE_DATABASE_URL`, any Postgres (a dedicated `dte` database is the
|
|
4
|
+
recommended setup), else Airflow's `sql_alchemy_conn`. DTE never reads
|
|
5
|
+
Airflow's tables through these engines; dag runs come from `dag_runs.py`.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
|
|
12
|
+
from sqlalchemy import create_engine
|
|
13
|
+
from sqlalchemy.ext.asyncio import create_async_engine
|
|
14
|
+
|
|
15
|
+
SEARCH_PATH = "dte,public"
|
|
16
|
+
_PG = re.compile(r"^postgres(ql)?(\+\w+)?://")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _base_url() -> str:
|
|
20
|
+
url = os.getenv("DTE_DATABASE_URL")
|
|
21
|
+
if not url:
|
|
22
|
+
from airflow.configuration import conf
|
|
23
|
+
|
|
24
|
+
url = conf.get("database", "sql_alchemy_conn")
|
|
25
|
+
if not _PG.match(url):
|
|
26
|
+
# Airflow 3 blanks its metadata DB URL in DAG parsing and task
|
|
27
|
+
# processes, which is exactly where DTE reads its schema (plan 073).
|
|
28
|
+
raise RuntimeError("DTE needs DTE_DATABASE_URL (a Postgres database for DTE's tables) set on every "
|
|
29
|
+
"Airflow component; Airflow hides its own URL from DAG parsing and tasks")
|
|
30
|
+
return url
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def sync_url() -> str:
|
|
34
|
+
return _PG.sub("postgresql+psycopg2://", _base_url())
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def async_url() -> str:
|
|
38
|
+
return _PG.sub("postgresql+asyncpg://", _base_url())
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def sync_engine():
|
|
42
|
+
return create_engine(sync_url(), connect_args={"options": f"-csearch_path={SEARCH_PATH}"})
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def async_engine():
|
|
46
|
+
return create_async_engine(async_url(), connect_args={"server_settings": {"search_path": SEARCH_PATH}})
|
dte/airflow/notify.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Pipeline notifications through Airflow's own notifiers (plan 070).
|
|
2
|
+
|
|
3
|
+
A notification is `{notifier, params, on}`: `notifier` is the class path of
|
|
4
|
+
any notifier an installed provider registers (Slack, SMTP, Teams, SNS,
|
|
5
|
+
PagerDuty, ...), `params` its constructor kwargs, `on` the run events that
|
|
6
|
+
fire it. Templated params can use `{{ dte_message }}` and `{{ dte_event }}`.
|
|
7
|
+
|
|
8
|
+
Only registered notifiers are ever imported, so a saved class path can't
|
|
9
|
+
reach arbitrary code. Sending never raises: a broken notification must not
|
|
10
|
+
mask the run's own outcome.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import importlib
|
|
15
|
+
import inspect
|
|
16
|
+
import logging
|
|
17
|
+
from functools import partial
|
|
18
|
+
|
|
19
|
+
log = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
_CALLBACK = {"failure": "on_failure_callback", "success": "on_success_callback", "retry": "on_retry_callback"}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def available_notifiers() -> list[str]:
|
|
25
|
+
from airflow.providers_manager import ProvidersManager
|
|
26
|
+
|
|
27
|
+
return sorted(ProvidersManager().notification)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def get_connection(conn_id: str):
|
|
31
|
+
from airflow.hooks.base import BaseHook
|
|
32
|
+
|
|
33
|
+
return BaseHook.get_connection(conn_id)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _load(path: str):
|
|
37
|
+
if path not in available_notifiers():
|
|
38
|
+
raise ValueError(f"{path!r} is not an installed Airflow notifier")
|
|
39
|
+
module, _, name = path.rpartition(".")
|
|
40
|
+
return getattr(importlib.import_module(module), name)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def describe(path: str) -> dict:
|
|
44
|
+
"""The notifier's constructor params, for the UI to prefill."""
|
|
45
|
+
cls = _load(path)
|
|
46
|
+
params = [
|
|
47
|
+
{"name": p.name, "required": p.default is inspect.Parameter.empty,
|
|
48
|
+
"default": None if p.default is inspect.Parameter.empty else repr(p.default)}
|
|
49
|
+
for p in list(inspect.signature(cls.__init__).parameters.values())[1:]
|
|
50
|
+
if p.kind not in (p.VAR_POSITIONAL, p.VAR_KEYWORD)
|
|
51
|
+
]
|
|
52
|
+
return {"notifier": path, "name": cls.__name__, "params": params,
|
|
53
|
+
"doc": (inspect.getdoc(cls) or "").split("\n\n")[0]}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def problem(n: dict) -> str | None:
|
|
57
|
+
"""Why notification `n` can't send, or None. Checked at save time."""
|
|
58
|
+
from airflow.exceptions import AirflowNotFoundException
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
cls = _load(n["notifier"])
|
|
62
|
+
except (ValueError, ImportError) as e:
|
|
63
|
+
return str(e)
|
|
64
|
+
params = n.get("params") or {}
|
|
65
|
+
try:
|
|
66
|
+
cls(**params) # notifiers only store their args; this catches missing/unknown ones
|
|
67
|
+
except TypeError as e:
|
|
68
|
+
return f"{cls.__name__}: {e}"
|
|
69
|
+
for k, v in params.items():
|
|
70
|
+
if k.endswith("_conn_id") and isinstance(v, str) and "{{" not in v:
|
|
71
|
+
try:
|
|
72
|
+
get_connection(v)
|
|
73
|
+
except AirflowNotFoundException:
|
|
74
|
+
return f"no Airflow connection {v!r} (param {k}); create it under Admin > Connections"
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _message(event: str, context) -> str:
|
|
79
|
+
dag = context["dag"]
|
|
80
|
+
name = getattr(dag, "dag_display_name", None) or dag.dag_id
|
|
81
|
+
msg = f"DTE pipeline {name}: {event}\nDAG: {dag.dag_id}\nRun: {context['run_id']}"
|
|
82
|
+
if event != "success" and context.get("exception"):
|
|
83
|
+
msg += f"\nError: {context['exception']}"
|
|
84
|
+
return msg
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def send(n: dict, event: str, context) -> None:
|
|
88
|
+
try:
|
|
89
|
+
notifier = _load(n["notifier"])(**(n.get("params") or {}))
|
|
90
|
+
# Passed as variables, so text in the error is never itself rendered.
|
|
91
|
+
notifier({**context, "dte_message": _message(event, context), "dte_event": event})
|
|
92
|
+
except Exception as e:
|
|
93
|
+
log.warning("notification %s on %s failed: %s", n.get("notifier"), event, e)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def callbacks(notifications: list[dict]) -> dict:
|
|
97
|
+
"""Operator kwargs: `on_<event>_callback` lists for the given notifications."""
|
|
98
|
+
out: dict[str, list] = {}
|
|
99
|
+
for n in notifications or []:
|
|
100
|
+
for event in n.get("on") or ["failure"]:
|
|
101
|
+
out.setdefault(_CALLBACK[event], []).append(partial(send, n, event))
|
|
102
|
+
return out
|