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.
Files changed (180) hide show
  1. dte/__init__.py +17 -0
  2. dte/airflow/__init__.py +0 -0
  3. dte/airflow/assets.py +57 -0
  4. dte/airflow/auth.py +42 -0
  5. dte/airflow/connection_store.py +53 -0
  6. dte/airflow/connections.py +205 -0
  7. dte/airflow/dag_factory.py +112 -0
  8. dte/airflow/dag_runs.py +32 -0
  9. dte/airflow/db.py +46 -0
  10. dte/airflow/notify.py +102 -0
  11. dte/airflow/operators.py +158 -0
  12. dte/airflow/plugin.py +40 -0
  13. dte/airflow/provider.py +7 -0
  14. dte/airflow/results.py +36 -0
  15. dte/airflow/runner.py +294 -0
  16. dte/airflow/trigger.py +88 -0
  17. dte/alembic_util.py +101 -0
  18. dte/api/CLAUDE.md +65 -0
  19. dte/api/__init__.py +0 -0
  20. dte/api/deps.py +84 -0
  21. dte/api/error_handlers.py +39 -0
  22. dte/api/http_metrics.py +104 -0
  23. dte/api/interactive.py +48 -0
  24. dte/api/main.py +75 -0
  25. dte/api/middleware.py +47 -0
  26. dte/api/routes/__init__.py +0 -0
  27. dte/api/routes/artifacts.py +149 -0
  28. dte/api/routes/connections.py +83 -0
  29. dte/api/routes/dashboards.py +96 -0
  30. dte/api/routes/destinations.py +147 -0
  31. dte/api/routes/evals.py +1215 -0
  32. dte/api/routes/health.py +26 -0
  33. dte/api/routes/lookup_tables.py +126 -0
  34. dte/api/routes/me.py +15 -0
  35. dte/api/routes/notifications.py +67 -0
  36. dte/api/routes/pipelines.py +1354 -0
  37. dte/api/routes/providers.py +73 -0
  38. dte/api/routes/runs.py +123 -0
  39. dte/api/routes/schedules.py +165 -0
  40. dte/api/routes/schema.py +133 -0
  41. dte/api/routes/sources.py +230 -0
  42. dte/artifacts/__init__.py +43 -0
  43. dte/artifacts/local.py +99 -0
  44. dte/artifacts/store.py +108 -0
  45. dte/automap.py +201 -0
  46. dte/cli.py +125 -0
  47. dte/compiler/__init__.py +0 -0
  48. dte/compiler/dialect_notes.py +205 -0
  49. dte/compiler/ibis_compiler.py +1089 -0
  50. dte/compiler/interpolate.py +285 -0
  51. dte/config.py +82 -0
  52. dte/docker/__init__.py +0 -0
  53. dte/docker/entrypoint.py +56 -0
  54. dte/engine/__init__.py +12 -0
  55. dte/engine/backend_registry.py +243 -0
  56. dte/engine/base.py +139 -0
  57. dte/engine/connect.py +21 -0
  58. dte/engine/duckdb_engine.py +113 -0
  59. dte/engine/registry.py +74 -0
  60. dte/engine/trino_engine.py +136 -0
  61. dte/engine/writers/__init__.py +52 -0
  62. dte/engine/writers/_copy.py +127 -0
  63. dte/engine/writers/csv_file.py +18 -0
  64. dte/engine/writers/excel_file.py +28 -0
  65. dte/engine/writers/json_file.py +23 -0
  66. dte/engine/writers/parquet.py +23 -0
  67. dte/engine/writers/postgres.py +57 -0
  68. dte/engine/writers/provenance.py +121 -0
  69. dte/engine/writers/sql_database.py +114 -0
  70. dte/errors.py +53 -0
  71. dte/evals/CLAUDE.md +266 -0
  72. dte/evals/__init__.py +39 -0
  73. dte/evals/bootstrap.py +150 -0
  74. dte/evals/comparators.py +258 -0
  75. dte/evals/comparison.py +296 -0
  76. dte/evals/cost.py +147 -0
  77. dte/evals/derive.py +124 -0
  78. dte/evals/digest.py +61 -0
  79. dte/evals/judge.py +91 -0
  80. dte/evals/regression.py +53 -0
  81. dte/evals/runner.py +191 -0
  82. dte/evals/scoring.py +220 -0
  83. dte/evals/sweep.py +326 -0
  84. dte/logging.py +93 -0
  85. dte/metrics.py +53 -0
  86. dte/migrations/alembic/README +1 -0
  87. dte/migrations/alembic/env.py +81 -0
  88. dte/migrations/alembic/script.py.mako +28 -0
  89. dte/migrations/alembic/versions/001_initial_schema.py +40 -0
  90. dte/migrations/alembic/versions/002_add_run_location.py +20 -0
  91. dte/migrations/alembic/versions/003_run_finished_at_trigger.py +37 -0
  92. dte/migrations/alembic/versions/004_sources_and_destinations.py +39 -0
  93. dte/migrations/alembic/versions/005_connections.py +28 -0
  94. dte/migrations/alembic/versions/006_pipeline_columns.py +48 -0
  95. dte/migrations/alembic/versions/007_source_dest_connection_id.py +23 -0
  96. dte/migrations/alembic/versions/008_backfill_local_fs_connection.py +75 -0
  97. dte/migrations/alembic/versions/009_source_format_column.py +48 -0
  98. dte/migrations/alembic/versions/010_users.py +49 -0
  99. dte/migrations/alembic/versions/011_lookup_tables.py +29 -0
  100. dte/migrations/alembic/versions/012_rbac.py +105 -0
  101. dte/migrations/alembic/versions/013_run_events.py +35 -0
  102. dte/migrations/alembic/versions/014_pipeline_revisions.py +43 -0
  103. dte/migrations/alembic/versions/015_tenant_id.py +91 -0
  104. dte/migrations/alembic/versions/016_rls_policies.py +61 -0
  105. dte/migrations/alembic/versions/017_tenant_secret_keys.py +82 -0
  106. dte/migrations/alembic/versions/018_pipeline_engine_and_quality_contracts.py +38 -0
  107. dte/migrations/alembic/versions/019_run_params.py +33 -0
  108. dte/migrations/alembic/versions/020_connection_name.py +37 -0
  109. dte/migrations/alembic/versions/021_source_name.py +32 -0
  110. dte/migrations/alembic/versions/022_destination_name.py +32 -0
  111. dte/migrations/alembic/versions/023_lookup_table_name.py +33 -0
  112. dte/migrations/alembic/versions/024_pipeline_name_unique.py +50 -0
  113. dte/migrations/alembic/versions/025_run_quality_checks.py +26 -0
  114. dte/migrations/alembic/versions/026_pipeline_type.py +36 -0
  115. dte/migrations/alembic/versions/027_run_sql_query.py +26 -0
  116. dte/migrations/alembic/versions/028_destination_type_rename.py +71 -0
  117. dte/migrations/alembic/versions/029_procrastinate_schema.py +53 -0
  118. dte/migrations/alembic/versions/030_run_job_id.py +31 -0
  119. dte/migrations/alembic/versions/031_run_heartbeat.py +29 -0
  120. dte/migrations/alembic/versions/032_schedules.py +65 -0
  121. dte/migrations/alembic/versions/033_run_schedule_id.py +30 -0
  122. dte/migrations/alembic/versions/034_evals.py +143 -0
  123. dte/migrations/alembic/versions/035_schedule_eval_set.py +36 -0
  124. dte/migrations/alembic/versions/036_pipeline_matrix.py +37 -0
  125. dte/migrations/alembic/versions/037_pipeline_source_refs.py +65 -0
  126. dte/migrations/alembic/versions/038_dashboards.py +88 -0
  127. dte/migrations/alembic/versions/039_airflow_cut.py +96 -0
  128. dte/migrations/alembic/versions/040_pipeline_schedule.py +60 -0
  129. dte/migrations/alembic/versions/041_pipeline_notifications.py +23 -0
  130. dte/migrations/alembic/versions/091_drop_tenant_id.py +50 -0
  131. dte/migrations/alembic.ini +149 -0
  132. dte/models/__init__.py +0 -0
  133. dte/models/dashboard.py +133 -0
  134. dte/models/eval.py +208 -0
  135. dte/models/pipeline.py +1984 -0
  136. dte/models/quality.py +83 -0
  137. dte/models/user.py +12 -0
  138. dte/pipelines/__init__.py +0 -0
  139. dte/pipelines/diff.py +170 -0
  140. dte/providers/__init__.py +83 -0
  141. dte/providers/base.py +117 -0
  142. dte/providers/pdf.py +212 -0
  143. dte/providers/schema.py +104 -0
  144. dte/providers/structure.py +235 -0
  145. dte/quality/__init__.py +0 -0
  146. dte/quality/evaluator.py +234 -0
  147. dte/scheduling/__init__.py +0 -0
  148. dte/scheduling/cron.py +74 -0
  149. dte/schema/__init__.py +0 -0
  150. dte/schema/inferrer.py +74 -0
  151. dte/security/__init__.py +1 -0
  152. dte/security/permissions.py +49 -0
  153. dte/sources/CLAUDE.md +136 -0
  154. dte/sources/__init__.py +0 -0
  155. dte/sources/connection_test.py +46 -0
  156. dte/sources/dlt_loader.py +11 -0
  157. dte/sources/duckdb_secrets.py +63 -0
  158. dte/sources/loader.py +487 -0
  159. dte/sources/metered.py +174 -0
  160. dte/sources/object_store.py +54 -0
  161. dte/sources/pools/__init__.py +4 -0
  162. dte/sources/pools/engine.py +49 -0
  163. dte/sources/pools/mongo.py +71 -0
  164. dte/sources/sample.py +146 -0
  165. dte/store/CLAUDE.md +72 -0
  166. dte/store/__init__.py +0 -0
  167. dte/store/base.py +162 -0
  168. dte/store/files.py +184 -0
  169. dte/store/postgres.py +680 -0
  170. dte/tracing.py +33 -0
  171. dte/ui/dte.umd.js +116 -0
  172. dte/ui/icon-dark.svg +1 -0
  173. dte/ui/icon.svg +1 -0
  174. dte/ui/pdfjs/pdf.worker.min.mjs +29 -0
  175. dte_airflow-0.1.0.dist-info/METADATA +323 -0
  176. dte_airflow-0.1.0.dist-info/RECORD +180 -0
  177. dte_airflow-0.1.0.dist-info/WHEEL +4 -0
  178. dte_airflow-0.1.0.dist-info/entry_points.txt +8 -0
  179. dte_airflow-0.1.0.dist-info/licenses/LICENSE +201 -0
  180. 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
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
@@ -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