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,299 @@
1
+ """Dlt source factory for any Dataverse-backed plugin.
2
+
3
+ Plugin asset bodies call `build_dataverse_dlt_source(plugin_id, job, ...)`
4
+ to produce a `@dlt.source`-wrapped tuple of resources. One resource per
5
+ active table (delta or snapshot mode). The plugin's component.py is
6
+ agnostic to OData/Pydantic/runtime-schema concerns -- those all live here.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ import time
13
+ from collections.abc import Iterator, Mapping
14
+ from typing import Any
15
+
16
+ import dlt
17
+ from shared_plugins.bindings import ResolvedBindingModels, iter_active_model_rows
18
+ from shared_plugins.naming import dlt_resource_name, dlt_source_name
19
+ from shared_plugins.resources import ctx_dlt_resource
20
+
21
+ from .client import DataverseClient
22
+ from .ctx import DataverseRowBase
23
+ from .runtime_schema import RuntimeDataverseTableSchema, build_runtime_table_schemas
24
+ from .tables import DataverseSyncMode, DataverseTableSpec
25
+ from .translators import dataverse_record_to_row
26
+
27
+ LOGGER = logging.getLogger(__name__)
28
+ # State key for the per-table change-tracking cursor. The stored value is a
29
+ # Dataverse change-tracking link: a mid-enumeration @odata.nextLink ($skiptoken)
30
+ # while an initial drain is still in flight, or a terminal @odata.deltaLink
31
+ # ($deltatoken) once it completes. The literal key stays "delta_link" so
32
+ # bindings already carrying a deltatoken under that key keep resuming instead of
33
+ # re-draining from scratch.
34
+ CHANGE_TRACKING_CURSOR_KEY = "delta_link"
35
+ # Upper bound on rows drained from a single table per pipeline run. dlt buffers
36
+ # each run's extract+normalize+load, and the memory peak is during normalize,
37
+ # driven by *wide* rows (e.g. emails with full HTML bodies) more than by row
38
+ # count. An unbounded initial drain OOM-killed an 8 GB host at ~6 GB; a 30k cap
39
+ # did NOT help, because the byte-heavy tables hold only a few thousand rows (well
40
+ # under 30k) so it never chunked them. cap=2000 ran comfortably under budget.
41
+ # A later raise to 5000 (to backfill activityparties in fewer runs) let the
42
+ # content tables emails (~5.5k) and crff1_emaillogses (~5.1k) drain in roughly
43
+ # one run each and OOM-killed a fresh 8 GB deployment on every cycle: the step
44
+ # peaked at ~5.8 GB in normalize, the kill landed before the load committed, so
45
+ # the $skiptoken never persisted and each */15 run re-pulled the same rows and
46
+ # re-died (codecreators V-6, 2026-07-09). 2000 is the empirically proven value
47
+ # for 8 GB hosts — do NOT raise it to speed up backfills; the cap only sets how
48
+ # many runs a drain takes, never what data ultimately lands. When the cap is
49
+ # reached mid-enumeration the resource persists the change-tracking $skiptoken
50
+ # and returns; the next run resumes from it and the `merge` disposition upserts
51
+ # the chunks together, so a table backfills across several scheduled runs before
52
+ # settling into incremental ($deltatoken) syncs.
53
+ # Operator-level tuning knob, hence a module constant rather than a BindingConfig
54
+ # field; size it against real-host memory, not row count.
55
+ MAX_DRAIN_ROWS_PER_RUN = 2000
56
+ SNAPSHOT_WRITE_DISPOSITION = {
57
+ "disposition": "merge",
58
+ "strategy": "delete-insert",
59
+ }
60
+ SNAPSHOT_MERGE_KEY = ("_ctx_binding_id",)
61
+
62
+
63
+ def build_dataverse_dlt_source(
64
+ *,
65
+ plugin_id: str,
66
+ job: str,
67
+ binding_id: str,
68
+ client: DataverseClient,
69
+ binding_models: ResolvedBindingModels,
70
+ specs: tuple[DataverseTableSpec, ...],
71
+ ) -> Any:
72
+ active_specs = tuple(
73
+ spec for spec in specs if spec.resource_name in binding_models.active
74
+ )
75
+ runtime_schemas: Mapping[str, RuntimeDataverseTableSchema] = (
76
+ build_runtime_table_schemas(client=client, specs=active_specs)
77
+ )
78
+
79
+ @dlt.source(name=dlt_source_name(plugin_id, job))
80
+ def _source() -> tuple[Any, ...]:
81
+ resources: list[Any] = []
82
+ for spec in active_specs:
83
+ schema = runtime_schemas[spec.entity_set]
84
+ if spec.sync_mode is DataverseSyncMode.DELTA:
85
+ resources.append(
86
+ _build_delta_resource(
87
+ binding_id=binding_id,
88
+ client=client,
89
+ schema=schema,
90
+ binding_models=binding_models,
91
+ )
92
+ )
93
+ else:
94
+ resources.append(
95
+ _build_snapshot_resource(
96
+ binding_id=binding_id,
97
+ client=client,
98
+ schema=schema,
99
+ binding_models=binding_models,
100
+ )
101
+ )
102
+
103
+ if not resources:
104
+ LOGGER.info(
105
+ "%s.%s.empty_source binding_id=%s active_models=%s",
106
+ plugin_id,
107
+ job,
108
+ binding_id,
109
+ tuple(binding_models.active),
110
+ )
111
+
112
+ return tuple(resources)
113
+
114
+ return _source()
115
+
116
+
117
+ def _build_delta_resource(
118
+ *,
119
+ binding_id: str,
120
+ client: DataverseClient,
121
+ schema: RuntimeDataverseTableSchema,
122
+ binding_models: ResolvedBindingModels,
123
+ ) -> Any:
124
+ spec = schema.spec
125
+
126
+ @ctx_dlt_resource(
127
+ name=dlt_resource_name(spec.resource_name),
128
+ write_disposition="merge",
129
+ primary_key=("_ctx_binding_id", spec.primary_key),
130
+ columns=schema.dlt_columns,
131
+ )
132
+ def delta_resource() -> Iterator[DataverseRowBase]:
133
+ state = dlt.current.resource_state()
134
+ initial_cursor = state.get(CHANGE_TRACKING_CURSOR_KEY)
135
+ if initial_cursor is not None and not isinstance(initial_cursor, str):
136
+ raise RuntimeError(
137
+ f"Stored Dataverse change-tracking cursor for {spec.entity_set} "
138
+ "is not a string."
139
+ )
140
+ next_cursor: dict[str, str] = {}
141
+ rows = _iter_delta_rows(
142
+ binding_id=binding_id,
143
+ client=client,
144
+ schema=schema,
145
+ initial_cursor=initial_cursor,
146
+ next_cursor=next_cursor,
147
+ )
148
+ yield from iter_active_model_rows(
149
+ model_name=spec.resource_name,
150
+ rows=rows,
151
+ binding_models=binding_models,
152
+ )
153
+ state[CHANGE_TRACKING_CURSOR_KEY] = next_cursor[CHANGE_TRACKING_CURSOR_KEY]
154
+
155
+ return delta_resource
156
+
157
+
158
+ def _build_snapshot_resource(
159
+ *,
160
+ binding_id: str,
161
+ client: DataverseClient,
162
+ schema: RuntimeDataverseTableSchema,
163
+ binding_models: ResolvedBindingModels,
164
+ ) -> Any:
165
+ spec = schema.spec
166
+
167
+ @ctx_dlt_resource(
168
+ name=dlt_resource_name(spec.resource_name),
169
+ write_disposition=SNAPSHOT_WRITE_DISPOSITION,
170
+ merge_key=SNAPSHOT_MERGE_KEY,
171
+ primary_key=("_ctx_binding_id", spec.primary_key),
172
+ columns=schema.dlt_columns,
173
+ )
174
+ def snapshot_resource() -> Iterator[DataverseRowBase]:
175
+ rows = _iter_snapshot_rows(
176
+ binding_id=binding_id,
177
+ client=client,
178
+ schema=schema,
179
+ )
180
+ yield from iter_active_model_rows(
181
+ model_name=spec.resource_name,
182
+ rows=rows,
183
+ binding_models=binding_models,
184
+ )
185
+
186
+ return snapshot_resource
187
+
188
+
189
+ def _iter_delta_rows(
190
+ *,
191
+ binding_id: str,
192
+ client: DataverseClient,
193
+ schema: RuntimeDataverseTableSchema,
194
+ initial_cursor: str | None,
195
+ next_cursor: dict[str, str],
196
+ ) -> Iterator[DataverseRowBase]:
197
+ spec = schema.spec
198
+ # An initial drain may already be partway through (resuming from a stored
199
+ # $skiptoken); only a stored $deltatoken means we are in steady-state
200
+ # incremental sync. `iter_change_tracking_pages` resumes from either link.
201
+ incremental = initial_cursor is not None and "$deltatoken" in initial_cursor
202
+ row_count = 0
203
+ page_count = 0
204
+ saved_cursor: str | None = None
205
+ t0 = time.monotonic()
206
+
207
+ LOGGER.info(
208
+ "dataverse.delta.start entity_set=%s mode=%s",
209
+ spec.entity_set,
210
+ "incremental" if incremental else "initial",
211
+ )
212
+
213
+ for page in client.iter_change_tracking_pages(
214
+ spec,
215
+ delta_link=initial_cursor,
216
+ select=schema.select_columns,
217
+ ):
218
+ page_count += 1
219
+ for record in page.value:
220
+ raw_payload = record.model_dump(by_alias=True, exclude_none=True)
221
+ row_count += 1
222
+ yield dataverse_record_to_row(
223
+ binding_id=binding_id,
224
+ schema=schema,
225
+ record=record,
226
+ raw_payload=raw_payload,
227
+ )
228
+
229
+ if page.delta_link is not None:
230
+ # Enumeration complete: the deltaLink is the incremental cursor for
231
+ # the next run.
232
+ saved_cursor = page.delta_link
233
+ break
234
+ if row_count >= MAX_DRAIN_ROWS_PER_RUN:
235
+ # Per-run cap reached while the initial drain is still in flight.
236
+ # Persist this page's nextLink ($skiptoken) so the next run resumes
237
+ # here; merge upserts accumulate the chunks across runs.
238
+ if page.next_link is None:
239
+ raise RuntimeError(
240
+ f"Dataverse change-tracking page for {spec.entity_set} had "
241
+ "neither a nextLink to resume from nor a deltaLink."
242
+ )
243
+ saved_cursor = page.next_link
244
+ break
245
+
246
+ if saved_cursor is None:
247
+ raise RuntimeError(
248
+ f"Dataverse change-tracking enumeration for {spec.entity_set} ended "
249
+ "without a deltaLink or a resumable nextLink."
250
+ )
251
+ next_cursor[CHANGE_TRACKING_CURSOR_KEY] = saved_cursor
252
+
253
+ LOGGER.info(
254
+ "dataverse.delta.done entity_set=%s mode=%s pages=%d rows=%d "
255
+ "more_pending=%s elapsed=%.1fs",
256
+ spec.entity_set,
257
+ "incremental" if incremental else "initial",
258
+ page_count,
259
+ row_count,
260
+ "$skiptoken" in saved_cursor,
261
+ time.monotonic() - t0,
262
+ )
263
+
264
+
265
+ def _iter_snapshot_rows(
266
+ *,
267
+ binding_id: str,
268
+ client: DataverseClient,
269
+ schema: RuntimeDataverseTableSchema,
270
+ ) -> Iterator[DataverseRowBase]:
271
+ spec = schema.spec
272
+ row_count = 0
273
+ page_count = 0
274
+ t0 = time.monotonic()
275
+
276
+ LOGGER.info("dataverse.snapshot.start entity_set=%s", spec.entity_set)
277
+
278
+ for page in client.iter_snapshot_pages(
279
+ spec,
280
+ select=schema.select_columns,
281
+ ):
282
+ page_count += 1
283
+ for record in page.value:
284
+ raw_payload = record.model_dump(by_alias=True, exclude_none=True)
285
+ row_count += 1
286
+ yield dataverse_record_to_row(
287
+ binding_id=binding_id,
288
+ schema=schema,
289
+ record=record,
290
+ raw_payload=raw_payload,
291
+ )
292
+
293
+ LOGGER.info(
294
+ "dataverse.snapshot.done entity_set=%s pages=%d rows=%d elapsed=%.1fs",
295
+ spec.entity_set,
296
+ page_count,
297
+ row_count,
298
+ time.monotonic() - t0,
299
+ )
@@ -0,0 +1,34 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from enum import Enum
5
+
6
+
7
+ class DataverseSyncMode(str, Enum):
8
+ DELTA = "delta"
9
+ SNAPSHOT = "snapshot"
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class DataverseTableSpec:
14
+ """Identifies one Dataverse entity set and how it should be synced.
15
+
16
+ Fields:
17
+ - entity_set: the OData entity-set name (e.g. "msdyn_projects").
18
+ - primary_key: the Dataverse logical name of the table's PK column
19
+ (e.g. "msdyn_projectid"). Used as the dlt primary_key trailing
20
+ element after _ctx_binding_id. Plugin authors look up this value
21
+ from Dataverse EntityDefinitions.PrimaryIdAttribute and hard-code
22
+ it in the spec — keeps the spec list self-documenting.
23
+ - sync_mode: DELTA (Prefer: odata.track-changes) or SNAPSHOT (full pull).
24
+ - resource_name: the dlt resource name. Conventionally equal to
25
+ entity_set, but kept distinct so plugins can rename if needed.
26
+
27
+ Note: $select is NOT on the spec. Selected columns are computed at
28
+ warmup from per-tenant attribute metadata (see runtime_schema.py).
29
+ """
30
+
31
+ entity_set: str
32
+ primary_key: str
33
+ sync_mode: DataverseSyncMode
34
+ resource_name: str
@@ -0,0 +1,133 @@
1
+ """Per-record translation: Dataverse OData payload → per-table CtxModel instance.
2
+
3
+ The translator walks the raw OData payload, splits annotation keys
4
+ (`<col>@<annotation>`) per the registry policy, and constructs an instance
5
+ of the per-table runtime CtxModel subclass. Returns a CtxModel — NOT a dict
6
+ — because @ctx_dlt_resource validates emitted rows are CtxModel instances.
7
+
8
+ Unknown annotations raise PluginConfigurationError. Dropped annotations are
9
+ silently omitted (they're triaged and intentional). Kept annotations land
10
+ on the companion column suffix.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import Any
16
+
17
+ from shared_plugins.exceptions import PluginConfigurationError
18
+
19
+ from .annotations import (
20
+ DROPPED_ODATA_ANNOTATIONS,
21
+ DROPPED_RESPONSE_FIELDS,
22
+ ODATA_ANNOTATION_COLUMN_SUFFIXES,
23
+ )
24
+ from .ctx import DataverseRowBase
25
+ from .ingress import DataverseRecordIngress, is_deleted_record
26
+ from .runtime_schema import RuntimeDataverseTableSchema
27
+
28
+ # Keys that DataverseRecordIngress emits via model_dump(by_alias=True): the
29
+ # @odata.* aliases plus the non-aliased tombstone fields (`id`, `reason`).
30
+ # The column-flatten step skips these so only column data (extras under
31
+ # DataverseRecordIngress's extra="allow") reaches the per-table row model.
32
+ # Derived from the ingress model so new envelope fields are picked up
33
+ # automatically without touching the flatten filter.
34
+ _DATAVERSE_ENVELOPE_KEYS: frozenset[str] = frozenset(
35
+ (field.alias or name) for name, field in DataverseRecordIngress.model_fields.items()
36
+ )
37
+
38
+
39
+ def dataverse_record_to_row(
40
+ *,
41
+ binding_id: str,
42
+ schema: RuntimeDataverseTableSchema,
43
+ record: DataverseRecordIngress,
44
+ raw_payload: dict[str, Any],
45
+ ) -> DataverseRowBase:
46
+ """Convert one Dataverse record into a per-table CtxModel instance.
47
+
48
+ `raw_payload` is the same OData record dict that produced `record` — the
49
+ strict envelope view (`record`) carries only the validated envelope
50
+ fields, while the full raw view (`raw_payload`) keeps every key from the
51
+ source response, including annotation keys (`<col>@<ann>`) that the
52
+ strict envelope does not preserve.
53
+
54
+ Tombstones (delta-stream delete entries) arrive without column data and
55
+ without the entity's PK column; the deleted row's identifier is on
56
+ `record.id`. This translator injects it as the PK column so dlt's
57
+ `hard_delete` hint on `is_deleted` (see build_dlt_column_hints in
58
+ types.py) issues a DELETE for the matching destination row during merge.
59
+ """
60
+ deleted = is_deleted_record(record, schema.spec, payload=raw_payload)
61
+ ctx_source_updated_at = raw_payload.get("modifiedon")
62
+
63
+ base_values: dict[str, Any] = {
64
+ "_ctx_binding_id": binding_id,
65
+ "_ctx_source_updated_at": ctx_source_updated_at,
66
+ "etag": record.odata_etag,
67
+ "is_deleted": deleted,
68
+ "delete_reason": record.reason,
69
+ }
70
+
71
+ column_values = _flatten_columns_and_annotations(
72
+ raw_payload=raw_payload,
73
+ schema=schema,
74
+ )
75
+ base_values.update(column_values)
76
+
77
+ if deleted:
78
+ if not isinstance(record.id, str):
79
+ raise PluginConfigurationError(
80
+ f"Dataverse tombstone for entity_set "
81
+ f"{schema.spec.entity_set!r} has reason={record.reason!r} "
82
+ "but no 'id' field on the envelope; cannot identify the "
83
+ "deleted row."
84
+ )
85
+ base_values[schema.spec.primary_key] = record.id
86
+
87
+ return schema.record_model.model_validate(base_values)
88
+
89
+
90
+ def _flatten_columns_and_annotations(
91
+ *,
92
+ raw_payload: dict[str, Any],
93
+ schema: RuntimeDataverseTableSchema,
94
+ ) -> dict[str, Any]:
95
+ """Walk raw payload keys, returning a {column_name: value} dict.
96
+
97
+ - Envelope keys (declared on DataverseRecordIngress: @odata.* aliases
98
+ plus `id` and `reason`) are skipped — those are read from `record`
99
+ directly by the caller.
100
+ - Plain keys in DROPPED_RESPONSE_FIELDS (unsolicited, unmodeled fields
101
+ Dataverse volunteers) are dropped per the triaged registry.
102
+ - Other plain keys land under their logical name.
103
+ - Annotation keys (`<col>@<annotation>`) land under
104
+ `<col><suffix>` per ODATA_ANNOTATION_COLUMN_SUFFIXES, or are dropped
105
+ per DROPPED_ODATA_ANNOTATIONS, or raise on unknown.
106
+ """
107
+ out: dict[str, Any] = {}
108
+ for key, value in raw_payload.items():
109
+ if key in _DATAVERSE_ENVELOPE_KEYS:
110
+ continue
111
+
112
+ if "@" not in key:
113
+ if key in DROPPED_RESPONSE_FIELDS:
114
+ continue
115
+ out[key] = value
116
+ continue
117
+
118
+ base, _, annotation_token = key.partition("@")
119
+ annotation = f"@{annotation_token}"
120
+ if annotation in DROPPED_ODATA_ANNOTATIONS:
121
+ continue
122
+ suffix = ODATA_ANNOTATION_COLUMN_SUFFIXES.get(annotation)
123
+ if suffix is None:
124
+ raise PluginConfigurationError(
125
+ f"Encountered unknown OData annotation {annotation!r} on "
126
+ f"column {base!r} in entity_set {schema.spec.entity_set!r}. "
127
+ "Triage by adding it to ODATA_ANNOTATION_COLUMN_SUFFIXES "
128
+ "(keep) or DROPPED_ODATA_ANNOTATIONS (drop) in "
129
+ "shared_plugins.microsoft_dataverse.annotations."
130
+ )
131
+ out[f"{base}{suffix}"] = value
132
+
133
+ return out