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.
- contextbase_shared_plugins-0.0.0a1.dist-info/METADATA +27 -0
- contextbase_shared_plugins-0.0.0a1.dist-info/RECORD +41 -0
- contextbase_shared_plugins-0.0.0a1.dist-info/WHEEL +4 -0
- shared_plugins/__init__.py +8 -0
- shared_plugins/automation.py +11 -0
- shared_plugins/base_platform.py +105 -0
- shared_plugins/bindings.py +260 -0
- shared_plugins/dlt.py +89 -0
- shared_plugins/env.py +104 -0
- shared_plugins/exceptions.py +10 -0
- shared_plugins/google_client/__init__.py +1 -0
- shared_plugins/google_client/auth.py +108 -0
- shared_plugins/google_client/batch_retry.py +308 -0
- shared_plugins/google_client/http_errors.py +27 -0
- shared_plugins/machine_token.py +419 -0
- shared_plugins/microsoft_dataverse/__init__.py +27 -0
- shared_plugins/microsoft_dataverse/annotations.py +61 -0
- shared_plugins/microsoft_dataverse/auth.py +26 -0
- shared_plugins/microsoft_dataverse/binding_config.py +35 -0
- shared_plugins/microsoft_dataverse/client.py +468 -0
- shared_plugins/microsoft_dataverse/ctx.py +21 -0
- shared_plugins/microsoft_dataverse/identifiers.py +62 -0
- shared_plugins/microsoft_dataverse/ingress.py +53 -0
- shared_plugins/microsoft_dataverse/metadata.py +106 -0
- shared_plugins/microsoft_dataverse/runtime_schema.py +332 -0
- shared_plugins/microsoft_dataverse/source.py +299 -0
- shared_plugins/microsoft_dataverse/tables.py +34 -0
- shared_plugins/microsoft_dataverse/translators.py +133 -0
- shared_plugins/microsoft_dataverse/types.py +355 -0
- shared_plugins/microsoft_graph.py +250 -0
- shared_plugins/models.py +91 -0
- shared_plugins/naming.py +83 -0
- shared_plugins/pg_column_comments.py +59 -0
- shared_plugins/provider_token.py +238 -0
- shared_plugins/pyairbyte.py +485 -0
- shared_plugins/resources.py +179 -0
- shared_plugins/scratch.py +127 -0
- shared_plugins/sentry.py +117 -0
- shared_plugins/sqlalchemy_types.py +225 -0
- shared_plugins/sqlite.py +123 -0
- 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()
|