contextbase-shared-plugins 0.0.0a1__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 (41) hide show
  1. contextbase_shared_plugins-0.0.0a1.dist-info/METADATA +27 -0
  2. contextbase_shared_plugins-0.0.0a1.dist-info/RECORD +41 -0
  3. contextbase_shared_plugins-0.0.0a1.dist-info/WHEEL +4 -0
  4. shared_plugins/__init__.py +8 -0
  5. shared_plugins/automation.py +11 -0
  6. shared_plugins/base_platform.py +105 -0
  7. shared_plugins/bindings.py +260 -0
  8. shared_plugins/dlt.py +89 -0
  9. shared_plugins/env.py +104 -0
  10. shared_plugins/exceptions.py +10 -0
  11. shared_plugins/google_client/__init__.py +1 -0
  12. shared_plugins/google_client/auth.py +108 -0
  13. shared_plugins/google_client/batch_retry.py +308 -0
  14. shared_plugins/google_client/http_errors.py +27 -0
  15. shared_plugins/machine_token.py +419 -0
  16. shared_plugins/microsoft_dataverse/__init__.py +27 -0
  17. shared_plugins/microsoft_dataverse/annotations.py +61 -0
  18. shared_plugins/microsoft_dataverse/auth.py +26 -0
  19. shared_plugins/microsoft_dataverse/binding_config.py +35 -0
  20. shared_plugins/microsoft_dataverse/client.py +468 -0
  21. shared_plugins/microsoft_dataverse/ctx.py +21 -0
  22. shared_plugins/microsoft_dataverse/identifiers.py +62 -0
  23. shared_plugins/microsoft_dataverse/ingress.py +53 -0
  24. shared_plugins/microsoft_dataverse/metadata.py +106 -0
  25. shared_plugins/microsoft_dataverse/runtime_schema.py +332 -0
  26. shared_plugins/microsoft_dataverse/source.py +299 -0
  27. shared_plugins/microsoft_dataverse/tables.py +34 -0
  28. shared_plugins/microsoft_dataverse/translators.py +133 -0
  29. shared_plugins/microsoft_dataverse/types.py +355 -0
  30. shared_plugins/microsoft_graph.py +250 -0
  31. shared_plugins/models.py +91 -0
  32. shared_plugins/naming.py +83 -0
  33. shared_plugins/pg_column_comments.py +59 -0
  34. shared_plugins/provider_token.py +238 -0
  35. shared_plugins/pyairbyte.py +485 -0
  36. shared_plugins/resources.py +179 -0
  37. shared_plugins/scratch.py +127 -0
  38. shared_plugins/sentry.py +117 -0
  39. shared_plugins/sqlalchemy_types.py +225 -0
  40. shared_plugins/sqlite.py +123 -0
  41. shared_plugins/values.py +117 -0
@@ -0,0 +1,485 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from collections.abc import Collection, Mapping, Sequence
5
+ from datetime import datetime, timedelta, timezone
6
+ from math import isfinite
7
+ from typing import Any, ClassVar, Literal, cast
8
+
9
+ import airbyte as ab
10
+ import dagster as dg
11
+ import sqlalchemy
12
+ from dagster import AssetExecutionContext
13
+ from airbyte._processors.sql.postgres import PostgresSqlProcessor
14
+ from airbyte.caches.postgres import PostgresCache
15
+ from airbyte.progress import ProgressTracker
16
+ from airbyte.records import StreamRecordHandler
17
+ from airbyte_protocol.models import AirbyteRecordMessage
18
+ from pydantic import Field
19
+ from sqlalchemy.engine import make_url
20
+ from sqlalchemy.types import TypeEngine
21
+
22
+ from base_client import DagsterAllPlanBinding
23
+
24
+ from .env import load_shared_python_settings
25
+ from .exceptions import PluginConfigurationError
26
+ from .naming import dagster_airbyte_sync_asset_key
27
+ from .values import parse_utc_datetime_from_str
28
+
29
+ _CTX_BINDING_ID_COLUMN = "_ctx_binding_id"
30
+ _CTX_SOURCE_UPDATED_AT_COLUMN = "_ctx_source_updated_at"
31
+ _CTX_BINDING_ID_SCHEMA: dict[str, object] = {"type": "string"}
32
+ # WARNING: This *looks* like it produces a Postgres timestamptz column, but
33
+ # pyairbyte/airbyte currently drops the timezone flag in its DDL path.
34
+ #
35
+ # In airbyte/shared/sql_processor.py, `_create_table_for_loading()`,
36
+ # `_ensure_final_table_exists()`, and `_add_column_to_table()` render SQLAlchemy
37
+ # types via `f"{sql_type}"` / `f"{column_type}"` instead of dialect-aware
38
+ # compilation. For `sqlalchemy.TIMESTAMP(timezone=True)`, `str(...)` is just
39
+ # `"TIMESTAMP"`, so Postgres creates `timestamp without time zone` instead of
40
+ # `TIMESTAMP WITH TIME ZONE`.
41
+ #
42
+ # This means `_ctx_source_updated_at` can be stored incorrectly: psycopg/Postgres
43
+ # will coerce aware datetimes through the session timezone and then drop tz info.
44
+ # The value may therefore vary by session/database timezone instead of preserving a
45
+ # stable absolute instant.
46
+ #
47
+ # We are documenting this intentionally rather than fixing it right now. A future
48
+ # fix should keep the upstream catalog/row-shaping behavior, but override the DDL
49
+ # rendering path so these types compile with the Postgres dialect (for example,
50
+ # `TIMESTAMP(timezone=True)` -> `TIMESTAMP WITH TIME ZONE`) before creating or
51
+ # altering tables.
52
+ _CTX_SOURCE_UPDATED_AT_SCHEMA: dict[str, object] = {
53
+ "type": ["string", "null"],
54
+ "format": "date-time",
55
+ }
56
+
57
+
58
+ # A stream's SOURCE_UPDATED_AT_FIELDS entry: a plain field name (value coerced by
59
+ # type — numbers are strictly epoch-SECONDS, strings ISO-8601), or (field, unit)
60
+ # when the source's unit cannot be inferred. Units are declared, never guessed.
61
+ SourceUpdatedAtSpec = str | tuple[str, Literal["epoch_ms"]] | None
62
+
63
+
64
+ def _coerce_epoch_ms_timestamp(value: object) -> datetime | None:
65
+ if value is None:
66
+ return None
67
+
68
+ if isinstance(value, bool) or not isinstance(value, int):
69
+ raise RuntimeError(
70
+ "_ctx_source_updated_at declared unit 'epoch_ms' requires an integer "
71
+ f"millisecond timestamp, got {type(value).__name__}."
72
+ )
73
+
74
+ if value == 0:
75
+ return None
76
+ if value < 0:
77
+ raise RuntimeError(
78
+ "_ctx_source_updated_at does not accept negative unix timestamps."
79
+ )
80
+
81
+ # Exact integer conversion — no float division, millisecond precision preserved.
82
+ seconds, milliseconds = divmod(value, 1000)
83
+ try:
84
+ return datetime.fromtimestamp(seconds, tz=timezone.utc) + timedelta(
85
+ milliseconds=milliseconds
86
+ )
87
+ except (OverflowError, OSError, ValueError) as exc:
88
+ raise RuntimeError(
89
+ "_ctx_source_updated_at must be a valid unix timestamp."
90
+ ) from exc
91
+
92
+
93
+ def _coerce_source_updated_at(
94
+ value: object,
95
+ *,
96
+ unit: Literal["epoch_ms"] | None = None,
97
+ ) -> datetime | None:
98
+ if unit == "epoch_ms":
99
+ return _coerce_epoch_ms_timestamp(value)
100
+
101
+ if value is None:
102
+ return None
103
+
104
+ if isinstance(value, bool):
105
+ raise RuntimeError("_ctx_source_updated_at does not accept bool values.")
106
+
107
+ if isinstance(value, datetime):
108
+ if value.tzinfo is None or value.utcoffset() is None:
109
+ return value.replace(tzinfo=timezone.utc)
110
+ return value.astimezone(timezone.utc)
111
+
112
+ if isinstance(value, int):
113
+ return _coerce_unix_timestamp(value)
114
+
115
+ if isinstance(value, float):
116
+ if not isfinite(value):
117
+ raise RuntimeError(
118
+ "_ctx_source_updated_at does not accept non-finite unix timestamps."
119
+ )
120
+ return _coerce_unix_timestamp(value)
121
+
122
+ if isinstance(value, str):
123
+ try:
124
+ return parse_utc_datetime_from_str(value)
125
+ except ValueError as exc:
126
+ raise RuntimeError(
127
+ f"_ctx_source_updated_at must be an ISO-8601 timestamp, got {value!r}."
128
+ ) from exc
129
+
130
+ raise RuntimeError(
131
+ "_ctx_source_updated_at must be None, a datetime, an ISO-8601 timestamp string, "
132
+ f"or a unix timestamp, got {type(value).__name__}."
133
+ )
134
+
135
+
136
+ def _coerce_unix_timestamp(value: int | float) -> datetime | None:
137
+ if value == 0:
138
+ return None
139
+ if value < 0:
140
+ raise RuntimeError(
141
+ "_ctx_source_updated_at does not accept negative unix timestamps."
142
+ )
143
+
144
+ try:
145
+ return datetime.fromtimestamp(value, tz=timezone.utc)
146
+ except (OverflowError, OSError, ValueError) as exc:
147
+ raise RuntimeError(
148
+ "_ctx_source_updated_at must be a valid unix timestamp."
149
+ ) from exc
150
+
151
+
152
+ def _validate_source_updated_at_spec(
153
+ stream_name: str, spec: SourceUpdatedAtSpec
154
+ ) -> SourceUpdatedAtSpec:
155
+ if spec is None or isinstance(spec, str):
156
+ return spec
157
+
158
+ if (
159
+ isinstance(spec, tuple)
160
+ and len(spec) == 2
161
+ and isinstance(spec[0], str)
162
+ and spec[1] == "epoch_ms"
163
+ ):
164
+ return spec
165
+
166
+ raise RuntimeError(
167
+ f"SOURCE_UPDATED_AT_FIELDS[{stream_name!r}] must be a field name, "
168
+ f"(field name, 'epoch_ms'), or None, got {spec!r}."
169
+ )
170
+
171
+
172
+ def _build_source_updated_at_fields(
173
+ stream_names: Sequence[str],
174
+ source_updated_at_fields: Mapping[str, SourceUpdatedAtSpec] | None,
175
+ ) -> dict[str, SourceUpdatedAtSpec]:
176
+ return {
177
+ stream_name: (
178
+ None
179
+ if source_updated_at_fields is None
180
+ else _validate_source_updated_at_spec(
181
+ stream_name, source_updated_at_fields.get(stream_name)
182
+ )
183
+ )
184
+ for stream_name in stream_names
185
+ }
186
+
187
+
188
+ def _extract_source_updated_at_value(
189
+ *,
190
+ stream_name: str,
191
+ record_data: Mapping[str, Any],
192
+ source_updated_at_field: str | None,
193
+ ) -> object:
194
+ if source_updated_at_field is None:
195
+ return None
196
+
197
+ if source_updated_at_field not in record_data:
198
+ raise RuntimeError(
199
+ f"PyAirbyte stream '{stream_name}' declared SOURCE_UPDATED_AT_FIELDS[{stream_name!r}] = "
200
+ f"{source_updated_at_field!r}, but that field was not present in the record payload."
201
+ )
202
+
203
+ return record_data[source_updated_at_field]
204
+
205
+
206
+ def _with_ctx_binding_primary_key(
207
+ *,
208
+ stream_name: str,
209
+ source_primary_key: Sequence[Sequence[str]] | None,
210
+ ) -> list[list[str]]:
211
+ if not source_primary_key:
212
+ raise RuntimeError(
213
+ f"PyAirbyte stream '{stream_name}' does not define a primary key. "
214
+ "ContextBase requires source-defined keys so _ctx_binding_id can be prepended safely."
215
+ )
216
+
217
+ normalized_primary_key = [[_CTX_BINDING_ID_COLUMN]]
218
+ for field_path in source_primary_key:
219
+ if len(field_path) != 1:
220
+ raise RuntimeError(
221
+ f"PyAirbyte stream '{stream_name}' uses nested primary keys {field_path!r}. "
222
+ "ContextBase only supports top-level primary-key columns."
223
+ )
224
+
225
+ field_name = field_path[0]
226
+ if field_name == _CTX_BINDING_ID_COLUMN:
227
+ continue
228
+ normalized_primary_key.append([field_name])
229
+
230
+ return normalized_primary_key
231
+
232
+
233
+ def _apply_ctx_catalog_contract(
234
+ *,
235
+ source: Any,
236
+ selected_stream_names: Sequence[str],
237
+ ) -> None:
238
+ selected_stream_name_set = set(selected_stream_names)
239
+ applied_stream_names: set[str] = set()
240
+
241
+ for stream in source.discovered_catalog.streams:
242
+ if stream.name not in selected_stream_name_set:
243
+ continue
244
+
245
+ json_schema = stream.json_schema
246
+ properties = json_schema.setdefault("properties", {})
247
+ if not isinstance(properties, dict):
248
+ raise RuntimeError(
249
+ f"PyAirbyte stream '{stream.name}' JSON schema must define a top-level "
250
+ "properties mapping."
251
+ )
252
+
253
+ properties[_CTX_BINDING_ID_COLUMN] = dict(_CTX_BINDING_ID_SCHEMA)
254
+ properties[_CTX_SOURCE_UPDATED_AT_COLUMN] = dict(_CTX_SOURCE_UPDATED_AT_SCHEMA)
255
+ stream.source_defined_primary_key = _with_ctx_binding_primary_key(
256
+ stream_name=stream.name,
257
+ source_primary_key=stream.source_defined_primary_key,
258
+ )
259
+ applied_stream_names.add(stream.name)
260
+
261
+ missing_stream_names = selected_stream_name_set - applied_stream_names
262
+ if missing_stream_names:
263
+ missing_stream_names_str = ", ".join(sorted(missing_stream_names))
264
+ raise RuntimeError(
265
+ "PyAirbyte selected streams were not found in the discovered catalog: "
266
+ f"{missing_stream_names_str}."
267
+ )
268
+
269
+
270
+ def _normalize_record_data_for_sql_columns(
271
+ *,
272
+ record_data: dict[str, Any],
273
+ column_definitions: dict[str, TypeEngine[Any]],
274
+ ) -> None:
275
+ for key, value in record_data.items():
276
+ if not isinstance(value, (dict, list)):
277
+ continue
278
+
279
+ column_type = column_definitions.get(key)
280
+ if column_type is None:
281
+ # Undeclared field (not in the connector's stream schema): PyAirbyte
282
+ # prunes it before SQL (StreamRecordHandler prune_extra_fields=True),
283
+ # so it never lands in the warehouse — defer to that behavior rather
284
+ # than failing a sync over unstorable data.
285
+ continue
286
+ if isinstance(column_type, sqlalchemy.types.JSON):
287
+ continue
288
+ if isinstance(column_type, sqlalchemy.types.String):
289
+ record_data[key] = json.dumps(value)
290
+ continue
291
+
292
+ raise RuntimeError(
293
+ f"PyAirbyte record field {key!r} has structured "
294
+ f"{type(value).__name__} data for unsupported SQL column type "
295
+ f"{type(column_type).__name__}."
296
+ )
297
+
298
+
299
+ class CtxPostgresProcessor(PostgresSqlProcessor):
300
+ def process_record_message(
301
+ self,
302
+ record_msg: AirbyteRecordMessage,
303
+ stream_record_handler: StreamRecordHandler,
304
+ progress_tracker: ProgressTracker,
305
+ ) -> None:
306
+ cache = cast(CtxPostgresCache, self.sql_config)
307
+ record_data = record_msg.data
308
+ record_data[_CTX_BINDING_ID_COLUMN] = cache.ctx_binding_id
309
+
310
+ source_updated_at_spec = cache.ctx_source_updated_at_fields.get(
311
+ record_msg.stream
312
+ )
313
+ if isinstance(source_updated_at_spec, tuple):
314
+ source_updated_at_field, source_updated_at_unit = source_updated_at_spec
315
+ else:
316
+ source_updated_at_field, source_updated_at_unit = (
317
+ source_updated_at_spec,
318
+ None,
319
+ )
320
+ raw_source_updated_at = _extract_source_updated_at_value(
321
+ stream_name=record_msg.stream,
322
+ record_data=record_data,
323
+ source_updated_at_field=source_updated_at_field,
324
+ )
325
+ record_data[_CTX_SOURCE_UPDATED_AT_COLUMN] = _coerce_source_updated_at(
326
+ raw_source_updated_at,
327
+ unit=source_updated_at_unit,
328
+ )
329
+
330
+ column_definitions = self._get_sql_column_definitions(record_msg.stream)
331
+ _normalize_record_data_for_sql_columns(
332
+ record_data=record_data,
333
+ column_definitions=column_definitions,
334
+ )
335
+
336
+ super().process_record_message(
337
+ record_msg,
338
+ stream_record_handler,
339
+ progress_tracker,
340
+ )
341
+
342
+
343
+ class CtxPostgresCache(PostgresCache):
344
+ _sql_processor_class: ClassVar[type] = CtxPostgresProcessor
345
+
346
+ ctx_binding_id: str = ""
347
+ ctx_source_updated_at_fields: dict[str, SourceUpdatedAtSpec] = Field(
348
+ default_factory=dict
349
+ )
350
+
351
+
352
+ def _build_cache(
353
+ *,
354
+ schema_name: str,
355
+ binding_id: str,
356
+ source_updated_at_fields: dict[str, SourceUpdatedAtSpec],
357
+ ) -> CtxPostgresCache:
358
+ database_url = load_shared_python_settings().ctx_database_url
359
+ parsed_url = make_url(database_url)
360
+
361
+ return CtxPostgresCache(
362
+ host=parsed_url.host or "localhost",
363
+ port=parsed_url.port or 5432,
364
+ database=parsed_url.database,
365
+ username=parsed_url.username or "postgres",
366
+ password=parsed_url.password or "",
367
+ schema_name=schema_name,
368
+ ctx_binding_id=binding_id,
369
+ ctx_source_updated_at_fields=source_updated_at_fields,
370
+ )
371
+
372
+
373
+ def build_pyairbyte_source(
374
+ *,
375
+ docker_image: str,
376
+ connector_config: dict[str, Any],
377
+ ) -> Any:
378
+ image_name = docker_image.split(":")[0].split("/")[-1]
379
+ return ab.get_source(
380
+ image_name,
381
+ docker_image=docker_image,
382
+ config=connector_config,
383
+ )
384
+
385
+
386
+ def require_pyairbyte_selected_stream_names(
387
+ binding: DagsterAllPlanBinding,
388
+ *,
389
+ default_active: Collection[str] = (),
390
+ ) -> tuple[str, ...]:
391
+ if binding.models is not None and binding.models.filter is not None:
392
+ raise PluginConfigurationError(
393
+ f"{binding.plugin_id} models.filter is not supported for PyAirbyte connectors."
394
+ )
395
+
396
+ # `default_active` mirrors resolve_binding_models: it applies only when the
397
+ # binding carries no selection at all (models or models.active unset) — an
398
+ # explicit empty list stays a loud configuration error. Stream-name validity
399
+ # is enforced downstream by _apply_ctx_catalog_contract against the
400
+ # connector's discovered catalog.
401
+ if binding.models is None or binding.models.active is None:
402
+ selected_stream_names = tuple(default_active)
403
+ else:
404
+ selected_stream_names = tuple(binding.models.active)
405
+
406
+ if not selected_stream_names:
407
+ raise PluginConfigurationError(
408
+ f"{binding.plugin_id} models.active must include at least one stream."
409
+ )
410
+
411
+ return selected_stream_names
412
+
413
+
414
+ def run_pyairbyte_sync(
415
+ *,
416
+ context: AssetExecutionContext,
417
+ plugin_id: str,
418
+ binding_id: str,
419
+ selected_stream_names: Sequence[str],
420
+ source_updated_at_fields: Mapping[str, SourceUpdatedAtSpec] | None = None,
421
+ force_full_refresh: bool = False,
422
+ source: Any,
423
+ ) -> dg.MaterializeResult:
424
+ ordered_selected_stream_names = tuple(selected_stream_names)
425
+ context.log.info(
426
+ f"Starting {plugin_id} sync for binding_id={binding_id} "
427
+ f"selected_streams={list(ordered_selected_stream_names)}"
428
+ )
429
+
430
+ cache = _build_cache(
431
+ schema_name=plugin_id,
432
+ binding_id=binding_id,
433
+ source_updated_at_fields=_build_source_updated_at_fields(
434
+ ordered_selected_stream_names,
435
+ source_updated_at_fields,
436
+ ),
437
+ )
438
+
439
+ _apply_ctx_catalog_contract(
440
+ source=source,
441
+ selected_stream_names=ordered_selected_stream_names,
442
+ )
443
+ # Airbyte's internal sync model lives below this call. Our runtime does not
444
+ # choose per-stream incremental vs full-refresh policy; it only chooses:
445
+ # 1. which raw stream IDs to pass to Source.read(streams=[...])
446
+ # 2. whether to request force_full_refresh=True
447
+ #
448
+ # Verified against the installed PyAirbyte package in this repo:
449
+ # - `airbyte/sources/base.py:420`
450
+ # `Source.get_configured_catalog(...)` builds each configured stream and
451
+ # assigns `sync_mode`.
452
+ # - `airbyte/sources/base.py:449`
453
+ # When `force_full_refresh=True`, PyAirbyte prefers `full_refresh` if the
454
+ # stream supports it; otherwise it uses `incremental`.
455
+ # - `airbyte/sources/base.py:873`
456
+ # Full refresh also disables state loading by setting
457
+ # `state_provider = None`.
458
+ # - `airbyte/caches/_state_backend.py:31`
459
+ # Incremental state is stored in `_airbyte_state`.
460
+ #
461
+ # So the repo seam stays intentionally narrow: `binding.models.active`
462
+ # selects streams, and `force_full_refresh` is the only runtime override.
463
+ source.read(
464
+ cache=cache,
465
+ streams=list(ordered_selected_stream_names),
466
+ force_full_refresh=force_full_refresh,
467
+ )
468
+
469
+ context.log.info(
470
+ f"Completed {plugin_id} sync for binding_id={binding_id} "
471
+ f"selected_streams={list(ordered_selected_stream_names)}"
472
+ )
473
+
474
+ return dg.MaterializeResult(
475
+ asset_key=dagster_airbyte_sync_asset_key(plugin_id),
476
+ metadata={
477
+ "selected_streams": dg.MetadataValue.json(
478
+ list(ordered_selected_stream_names)
479
+ ),
480
+ "selected_stream_count": dg.MetadataValue.int(
481
+ len(ordered_selected_stream_names)
482
+ ),
483
+ "force_full_refresh": dg.MetadataValue.bool(force_full_refresh),
484
+ },
485
+ )
@@ -0,0 +1,179 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable, Iterable, Mapping, Sequence
4
+ from copy import deepcopy
5
+ from functools import wraps
6
+ from typing import Any
7
+
8
+ import dlt
9
+ from dagster_dlt import DagsterDltResource, DagsterDltTranslator
10
+
11
+ from .models import CtxModel
12
+
13
+ _CTX_METADATA_COLUMNS = (
14
+ "_ctx_binding_id",
15
+ "_ctx_source_updated_at",
16
+ )
17
+
18
+
19
+ def _normalize_primary_key(primary_key: object) -> tuple[str, ...]:
20
+ if isinstance(primary_key, str):
21
+ return (primary_key,)
22
+ if isinstance(primary_key, Sequence):
23
+ normalized = tuple(str(value) for value in primary_key)
24
+ if normalized:
25
+ return normalized
26
+ raise ValueError(
27
+ "ctx_dlt_* wrappers require a non-empty primary_key that starts with '_ctx_binding_id'."
28
+ )
29
+
30
+
31
+ def _validate_primary_key_prefix(primary_key: object) -> None:
32
+ normalized = _normalize_primary_key(primary_key)
33
+ if normalized[0] != "_ctx_binding_id":
34
+ raise ValueError(
35
+ "ctx_dlt_* wrappers require primary_key to start with '_ctx_binding_id'. "
36
+ f"Received: {normalized!r}"
37
+ )
38
+
39
+
40
+ def _apply_max_table_nesting(kwargs: dict[str, Any]) -> None:
41
+ max_table_nesting = kwargs.pop("max_table_nesting", None)
42
+ if max_table_nesting not in (None, 0):
43
+ raise ValueError(
44
+ "ctx_dlt_* wrappers enforce max_table_nesting=0. Omit the argument or set it to 0."
45
+ )
46
+ kwargs["max_table_nesting"] = 0
47
+
48
+
49
+ def _with_metadata_columns(columns: object) -> dict[str, Any]:
50
+ if columns is None:
51
+ merged_columns: dict[str, Any] = {}
52
+ elif isinstance(columns, Mapping):
53
+ merged_columns = deepcopy(dict(columns))
54
+ else:
55
+ raise TypeError("ctx_dlt_* wrappers expect 'columns' to be a mapping when set.")
56
+
57
+ merged_columns.setdefault("_ctx_source_updated_at", {"data_type": "timestamp"})
58
+ return merged_columns
59
+
60
+
61
+ def _validate_ctx_row(
62
+ row: object,
63
+ *,
64
+ wrapper_name: str,
65
+ resource_name: str,
66
+ ) -> CtxModel:
67
+ if not isinstance(row, CtxModel):
68
+ raise TypeError(
69
+ f"{wrapper_name}('{resource_name}') emitted non-CtxModel row "
70
+ f"of type {type(row).__name__}."
71
+ )
72
+
73
+ serialized = row.model_dump(by_alias=True)
74
+ missing_columns = [name for name in _CTX_METADATA_COLUMNS if name not in serialized]
75
+ if missing_columns:
76
+ raise TypeError(
77
+ f"{wrapper_name}('{resource_name}') emitted CtxModel row missing required "
78
+ f"metadata columns: {', '.join(missing_columns)}."
79
+ )
80
+
81
+ binding_id = serialized.get("_ctx_binding_id")
82
+ if not isinstance(binding_id, str) or not binding_id.strip():
83
+ raise TypeError(
84
+ f"{wrapper_name}('{resource_name}') emitted row with empty '_ctx_binding_id'."
85
+ )
86
+
87
+ return row
88
+
89
+
90
+ def _iter_validated_rows(
91
+ emitted: object,
92
+ *,
93
+ wrapper_name: str,
94
+ resource_name: str,
95
+ ) -> Iterable[CtxModel]:
96
+ if emitted is None:
97
+ return ()
98
+
99
+ if isinstance(emitted, CtxModel):
100
+ return (
101
+ _validate_ctx_row(
102
+ emitted, wrapper_name=wrapper_name, resource_name=resource_name
103
+ ),
104
+ )
105
+
106
+ if isinstance(emitted, (str, bytes, bytearray, Mapping)):
107
+ raise TypeError(
108
+ f"{wrapper_name}('{resource_name}') emitted invalid row container "
109
+ f"of type {type(emitted).__name__}."
110
+ )
111
+
112
+ if not isinstance(emitted, Iterable):
113
+ raise TypeError(
114
+ f"{wrapper_name}('{resource_name}') emitted non-iterable value "
115
+ f"of type {type(emitted).__name__}."
116
+ )
117
+
118
+ def _generator() -> Iterable[CtxModel]:
119
+ for row in emitted:
120
+ yield _validate_ctx_row(
121
+ row,
122
+ wrapper_name=wrapper_name,
123
+ resource_name=resource_name,
124
+ )
125
+
126
+ return _generator()
127
+
128
+
129
+ def _wrap_emitter(
130
+ func: Callable[..., object],
131
+ *,
132
+ wrapper_name: str,
133
+ ) -> Callable[..., Iterable[CtxModel]]:
134
+ @wraps(func)
135
+ def wrapped(*args: Any, **kwargs: Any) -> Iterable[CtxModel]:
136
+ emitted = func(*args, **kwargs)
137
+ yield from _iter_validated_rows(
138
+ emitted,
139
+ wrapper_name=wrapper_name,
140
+ resource_name=func.__name__,
141
+ )
142
+
143
+ return wrapped
144
+
145
+
146
+ def ctx_dlt_resource(**kwargs: Any) -> Callable[[Callable[..., object]], Any]:
147
+ decorator_kwargs = dict(kwargs)
148
+ _apply_max_table_nesting(decorator_kwargs)
149
+ _validate_primary_key_prefix(decorator_kwargs.get("primary_key"))
150
+ decorator_kwargs["columns"] = _with_metadata_columns(
151
+ decorator_kwargs.get("columns")
152
+ )
153
+
154
+ def decorator(func: Callable[..., object]) -> Any:
155
+ return dlt.resource(**decorator_kwargs)(
156
+ _wrap_emitter(func, wrapper_name="ctx_dlt_resource")
157
+ )
158
+
159
+ return decorator
160
+
161
+
162
+ def ctx_dlt_transformer(**kwargs: Any) -> Callable[[Callable[..., object]], Any]:
163
+ decorator_kwargs = dict(kwargs)
164
+ _apply_max_table_nesting(decorator_kwargs)
165
+ _validate_primary_key_prefix(decorator_kwargs.get("primary_key"))
166
+ decorator_kwargs["columns"] = _with_metadata_columns(
167
+ decorator_kwargs.get("columns")
168
+ )
169
+
170
+ def decorator(func: Callable[..., object]) -> Any:
171
+ return dlt.transformer(**decorator_kwargs)(
172
+ _wrap_emitter(func, wrapper_name="ctx_dlt_transformer")
173
+ )
174
+
175
+ return decorator
176
+
177
+
178
+ DLT_TRANSLATOR = DagsterDltTranslator()
179
+ DLT_RESOURCE = DagsterDltResource()