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,468 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import time
5
+ from collections.abc import Callable, Mapping
6
+ from dataclasses import dataclass
7
+ from typing import Any, Iterator
8
+
9
+ import httpx
10
+ import tenacity
11
+ from pydantic import ValidationError
12
+ from shared_plugins.exceptions import PluginConfigurationError
13
+ from shared_plugins.models import format_validation_error
14
+
15
+ from .auth import ClientSecretTokenProvider
16
+ from .ingress import (
17
+ DataverseListEnvelopeIngress,
18
+ DataverseListResponseIngress,
19
+ DataverseRecordIngress,
20
+ )
21
+ from .tables import DataverseTableSpec
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ API_VERSION = "v9.2"
26
+ REQUEST_TIMEOUT_SECONDS = 60.0
27
+ # Dataverse only emits OData annotations (display labels for option-sets and
28
+ # lookups, lookup target-entity names) when the request asks for them via
29
+ # `odata.include-annotations`. The translator already routes these keys to
30
+ # the `_formatted_value` / `_lookup_logical_name` companion columns the
31
+ # schema reserves — they just never arrive without this Prefer.
32
+ INCLUDE_ANNOTATIONS_PREFERENCE = (
33
+ 'odata.include-annotations="'
34
+ "OData.Community.Display.V1.FormattedValue,"
35
+ 'Microsoft.Dynamics.CRM.lookuplogicalname"'
36
+ )
37
+ # Dataverse returns up to 5000 rows per page by default. Wide entities with
38
+ # large text columns (e.g. email HTML bodies) can exceed REQUEST_TIMEOUT_SECONDS
39
+ # on a full 5000-row page. Bounding the page size keeps each request small; dlt
40
+ # follows the @odata.nextLink chain, so this only changes the number of
41
+ # round-trips, not the rows drained.
42
+ PAGE_SIZE = 500
43
+ _PAGE_SIZE_PREFERENCE = f"odata.maxpagesize={PAGE_SIZE}"
44
+ # Every list request bounds the page size and asks for display-label annotations.
45
+ LIST_PREFERENCE = f"{_PAGE_SIZE_PREFERENCE},{INCLUDE_ANNOTATIONS_PREFERENCE}"
46
+ # The initial change-tracking enumeration also opts into track-changes. Order
47
+ # matters: Dataverse rejects the Prefer header ("Invalid prefer header",
48
+ # 0x80060888) when odata.maxpagesize sits directly between odata.track-changes
49
+ # and the quoted odata.include-annotations value (verified empirically against
50
+ # the tenant), so maxpagesize must lead.
51
+ TRACK_CHANGES_PREFERENCE = (
52
+ f"{_PAGE_SIZE_PREFERENCE},odata.track-changes,{INCLUDE_ANNOTATIONS_PREFERENCE}"
53
+ )
54
+
55
+
56
+ # Microsoft's documented Dataverse retry policy: 408 + 429 + every 5xx, plus
57
+ # transport-level errors. Mirrors Polly's `HandleTransientHttpError().OrResult(429)`
58
+ # from the official sample.
59
+ # https://learn.microsoft.com/en-us/power-apps/developer/data-platform/api-limits
60
+ def _is_retryable_status(status_code: int) -> bool:
61
+ return status_code == 408 or status_code == 429 or 500 <= status_code < 600
62
+
63
+
64
+ AccessTokenProvider = Callable[[], str]
65
+ SleepFn = Callable[[float], None]
66
+
67
+
68
+ @dataclass(frozen=True)
69
+ class DataverseRetryPolicy:
70
+ max_attempts: int = 5
71
+ max_backoff_seconds: float = 30.0
72
+ total_budget_seconds: float = 120.0
73
+
74
+ def __post_init__(self) -> None:
75
+ if self.max_attempts < 1:
76
+ raise ValueError("max_attempts must be >= 1")
77
+ if self.max_backoff_seconds < 0:
78
+ raise ValueError("max_backoff_seconds must be >= 0")
79
+ if self.total_budget_seconds < 0:
80
+ raise ValueError("total_budget_seconds must be >= 0")
81
+
82
+
83
+ _DEFAULT_RETRY_POLICY = DataverseRetryPolicy()
84
+
85
+
86
+ class _TransientResponseError(Exception):
87
+ """Internal sentinel for retryable HTTP status codes.
88
+
89
+ Carries the failing response so the wait callable can read Retry-After
90
+ and so the boundary can convert exhausted retries into the user-facing
91
+ `httpx.HTTPStatusError`. Not exported from this module.
92
+ """
93
+
94
+ def __init__(
95
+ self,
96
+ response: httpx.Response,
97
+ retry_after_seconds: float | None,
98
+ status_error: httpx.HTTPStatusError,
99
+ ) -> None:
100
+ super().__init__(f"transient Dataverse status {response.status_code}")
101
+ self.response = response
102
+ self.retry_after_seconds = retry_after_seconds
103
+ self.status_error = status_error
104
+
105
+
106
+ class DataverseClient:
107
+ def __init__(
108
+ self,
109
+ *,
110
+ org_url: str,
111
+ tenant_id: str | None = None,
112
+ client_id: str | None = None,
113
+ client_secret: str | None = None,
114
+ access_token_provider: AccessTokenProvider | None = None,
115
+ http_client: httpx.Client | None = None,
116
+ retry_policy: DataverseRetryPolicy | None = None,
117
+ sleep_fn: SleepFn = time.sleep,
118
+ ) -> None:
119
+ self.org_url = org_url.rstrip("/")
120
+ if not self.org_url.startswith("https://"):
121
+ raise PluginConfigurationError(
122
+ "Dataverse org_url must start with https://."
123
+ )
124
+
125
+ if access_token_provider is None:
126
+ if not tenant_id or not client_id or not client_secret:
127
+ raise PluginConfigurationError(
128
+ "DataverseClient requires tenant_id, client_id, and "
129
+ "client_secret when access_token_provider is not supplied."
130
+ )
131
+ access_token_provider = ClientSecretTokenProvider(
132
+ tenant_id=tenant_id,
133
+ client_id=client_id,
134
+ client_secret=client_secret,
135
+ scope=f"{self.org_url}/.default",
136
+ )
137
+ self._owns_access_token_provider = True
138
+ else:
139
+ self._owns_access_token_provider = False
140
+
141
+ self._access_token_provider = access_token_provider
142
+ self._http_client = http_client or httpx.Client(
143
+ timeout=REQUEST_TIMEOUT_SECONDS,
144
+ )
145
+ self._owns_http_client = http_client is None
146
+ self._retry_policy = retry_policy or _DEFAULT_RETRY_POLICY
147
+ self._sleep_fn = sleep_fn
148
+ self._retrying = _build_retrying(self._retry_policy, self._sleep_fn)
149
+
150
+ def close(self) -> None:
151
+ if self._owns_http_client:
152
+ self._http_client.close()
153
+ if self._owns_access_token_provider:
154
+ close = getattr(self._access_token_provider, "close", None)
155
+ if close is not None:
156
+ close()
157
+
158
+ def __enter__(self) -> DataverseClient:
159
+ return self
160
+
161
+ def __exit__(self, *args: object) -> None:
162
+ self.close()
163
+
164
+ def iter_change_tracking_pages(
165
+ self,
166
+ spec: DataverseTableSpec,
167
+ *,
168
+ delta_link: str | None = None,
169
+ record_model: type[DataverseRecordIngress] | None = None,
170
+ select: tuple[str, ...] | None = None,
171
+ ) -> Iterator[DataverseListResponseIngress]:
172
+ yield from self._iter_table_pages(
173
+ spec,
174
+ delta_link=delta_link,
175
+ prefer=(LIST_PREFERENCE if delta_link else TRACK_CHANGES_PREFERENCE),
176
+ record_model=record_model,
177
+ select=select,
178
+ )
179
+
180
+ def iter_snapshot_pages(
181
+ self,
182
+ spec: DataverseTableSpec,
183
+ *,
184
+ record_model: type[DataverseRecordIngress] | None = None,
185
+ select: tuple[str, ...] | None = None,
186
+ ) -> Iterator[DataverseListResponseIngress]:
187
+ yield from self._iter_table_pages(
188
+ spec,
189
+ delta_link=None,
190
+ prefer=LIST_PREFERENCE,
191
+ record_model=record_model,
192
+ select=select,
193
+ )
194
+
195
+ def get_json(
196
+ self,
197
+ path: str,
198
+ *,
199
+ params: Mapping[str, str] | None = None,
200
+ ) -> Mapping[str, Any]:
201
+ payload = self._get_json(
202
+ self._api_url(path),
203
+ params=params,
204
+ prefer=None,
205
+ )
206
+ if not isinstance(payload, Mapping):
207
+ raise RuntimeError("Dataverse API returned a non-object response payload.")
208
+ return payload
209
+
210
+ def _iter_table_pages(
211
+ self,
212
+ spec: DataverseTableSpec,
213
+ *,
214
+ delta_link: str | None,
215
+ prefer: str | None,
216
+ record_model: type[DataverseRecordIngress] | None,
217
+ select: tuple[str, ...] | None,
218
+ ) -> Iterator[DataverseListResponseIngress]:
219
+ url = delta_link or self._api_url(spec.entity_set)
220
+ params: dict[str, str] | None = None
221
+ if delta_link is None:
222
+ if select is None:
223
+ raise RuntimeError(
224
+ f"DataverseClient._iter_table_pages requires `select` for "
225
+ f"entity_set={spec.entity_set!r} initial fetch (delta_link=None)."
226
+ )
227
+ params = {"$select": ",".join(select)}
228
+
229
+ while True:
230
+ page = self._get_list(
231
+ url,
232
+ params=params,
233
+ prefer=prefer,
234
+ record_model=record_model,
235
+ )
236
+ yield page
237
+
238
+ if not page.next_link:
239
+ break
240
+
241
+ url = page.next_link
242
+ params = None
243
+
244
+ def _get_list(
245
+ self,
246
+ url: str,
247
+ *,
248
+ params: Mapping[str, str] | None,
249
+ prefer: str | None,
250
+ record_model: type[DataverseRecordIngress] | None,
251
+ ) -> DataverseListResponseIngress:
252
+ payload = self._get_json(url, params=params, prefer=prefer)
253
+
254
+ try:
255
+ envelope = DataverseListEnvelopeIngress.model_validate(payload)
256
+ resolved_record_model = record_model or DataverseRecordIngress
257
+ records = [
258
+ resolved_record_model.model_validate(item) for item in envelope.value
259
+ ]
260
+ return DataverseListResponseIngress.model_construct(
261
+ odata_context=envelope.odata_context,
262
+ value=records,
263
+ next_link=envelope.next_link,
264
+ delta_link=envelope.delta_link,
265
+ )
266
+ except ValidationError as exc:
267
+ raise RuntimeError(
268
+ "Dataverse API response did not match the expected list shape: "
269
+ f"{format_validation_error(exc)}"
270
+ ) from exc
271
+
272
+ def _get_json(
273
+ self,
274
+ url: str,
275
+ *,
276
+ params: Mapping[str, str] | None,
277
+ prefer: str | None,
278
+ ) -> Any:
279
+ # `reraise=True` lets the last attempt's exception propagate. For
280
+ # transport errors that exception is already user-facing; for
281
+ # `_TransientResponseError` we convert at this boundary so callers
282
+ # see the same `httpx.HTTPStatusError` they did before tenacity.
283
+ try:
284
+ for attempt in self._retrying:
285
+ with attempt:
286
+ return self._get_json_once(url, params=params, prefer=prefer)
287
+ except _TransientResponseError as exc:
288
+ raise exc.status_error from exc
289
+
290
+ # `Retrying.__iter__` either yields successful results via the
291
+ # generator or raises through `reraise=True`. It does not exit
292
+ # cleanly without a result.
293
+ raise RuntimeError("Dataverse retry loop exited without a result.")
294
+
295
+ def _get_json_once(
296
+ self,
297
+ url: str,
298
+ *,
299
+ params: Mapping[str, str] | None,
300
+ prefer: str | None,
301
+ ) -> Any:
302
+ response = self._http_client.get(
303
+ url,
304
+ params=params,
305
+ headers=self._headers(prefer=prefer),
306
+ )
307
+ payload = _response_json(response)
308
+
309
+ if response.is_error:
310
+ status_error = httpx.HTTPStatusError(
311
+ _format_error_message(response, payload),
312
+ request=response.request,
313
+ response=response,
314
+ )
315
+ if _is_retryable_status(response.status_code):
316
+ raise _TransientResponseError(
317
+ response=response,
318
+ retry_after_seconds=_retry_after_seconds(response),
319
+ status_error=status_error,
320
+ )
321
+ raise status_error
322
+
323
+ return payload
324
+
325
+ def _headers(self, *, prefer: str | None) -> dict[str, str]:
326
+ headers = {
327
+ "Authorization": f"Bearer {self._access_token_provider()}",
328
+ "OData-Version": "4.0",
329
+ "OData-MaxVersion": "4.0",
330
+ "Accept": "application/json",
331
+ }
332
+ if prefer:
333
+ headers["Prefer"] = prefer
334
+ return headers
335
+
336
+ def _api_url(self, path: str) -> str:
337
+ return f"{self.org_url}/api/data/{API_VERSION}/{path.lstrip('/')}"
338
+
339
+
340
+ def _build_retrying(
341
+ policy: DataverseRetryPolicy,
342
+ sleep_fn: SleepFn,
343
+ ) -> tenacity.Retrying:
344
+ """Build a per-instance tenacity controller from the retry policy.
345
+
346
+ Tenacity's bundled stops and waits don't cover what we need here:
347
+
348
+ * `stop_after_delay` measures real wall-clock time. Tests inject a fake
349
+ `sleep_fn` that records intended delays without burning real time, so
350
+ the wall clock never advances. We need to budget against the *injected
351
+ sleeps* (`retry_state.idle_for`), not real elapsed time.
352
+ * `wait_exponential` doesn't read `Retry-After` from the failure.
353
+ * Both need to clamp the next sleep so a single huge `Retry-After` can't
354
+ blow past the total budget.
355
+
356
+ So we hand-write small `wait` and `stop` callables that share the policy
357
+ and read the sentinel exception. Everything else (attempt iteration,
358
+ sleep dispatch, reraise) is delegated to tenacity.
359
+ """
360
+
361
+ def is_retryable(exc: BaseException) -> bool:
362
+ return isinstance(exc, (_TransientResponseError, httpx.RequestError))
363
+
364
+ def compute_wait(retry_state: tenacity.RetryCallState) -> float:
365
+ outcome = retry_state.outcome
366
+ exc = outcome.exception() if outcome is not None else None
367
+
368
+ retry_after: float | None = None
369
+ if isinstance(exc, _TransientResponseError):
370
+ retry_after = exc.retry_after_seconds
371
+
372
+ # Match Microsoft's sample: `Math.Pow(2, count)` where count is the
373
+ # 1-indexed retry number. In tenacity, the just-failed attempt number
374
+ # is the same value when `compute_wait` runs.
375
+ backoff = min(
376
+ policy.max_backoff_seconds,
377
+ float(2**retry_state.attempt_number),
378
+ )
379
+ wait_seconds = max(backoff, retry_after) if retry_after is not None else backoff
380
+
381
+ # Clamp the *next* sleep to whatever budget remains. Mirrors the old
382
+ # `min(wait_seconds, remaining_budget)` line.
383
+ remaining_budget = policy.total_budget_seconds - retry_state.idle_for
384
+ if remaining_budget <= 0:
385
+ return 0.0
386
+ return min(wait_seconds, remaining_budget)
387
+
388
+ def should_stop(retry_state: tenacity.RetryCallState) -> bool:
389
+ # Budget is measured against accumulated injected sleeps so tests
390
+ # that fake `sleep_fn` get the same "abort before next sleep"
391
+ # semantics production gets with real `time.sleep`.
392
+ if retry_state.idle_for >= policy.total_budget_seconds:
393
+ return True
394
+ return retry_state.attempt_number >= policy.max_attempts
395
+
396
+ def log_before_sleep(retry_state: tenacity.RetryCallState) -> None:
397
+ outcome = retry_state.outcome
398
+ exc = outcome.exception() if outcome is not None else None
399
+ status_code: int | None = None
400
+ transport_name: str | None = None
401
+ if isinstance(exc, _TransientResponseError):
402
+ status_code = exc.response.status_code
403
+ elif exc is not None:
404
+ transport_name = type(exc).__name__
405
+ logger.warning(
406
+ "Dataverse request transient failure (attempt %d/%d), "
407
+ "retrying after %.2fs. status=%s transport=%s",
408
+ retry_state.attempt_number,
409
+ policy.max_attempts,
410
+ retry_state.upcoming_sleep,
411
+ status_code,
412
+ transport_name,
413
+ )
414
+
415
+ return tenacity.Retrying(
416
+ retry=tenacity.retry_if_exception(is_retryable),
417
+ wait=compute_wait,
418
+ stop=should_stop,
419
+ before_sleep=log_before_sleep,
420
+ sleep=sleep_fn,
421
+ reraise=True,
422
+ )
423
+
424
+
425
+ def _response_json(response: httpx.Response) -> Any:
426
+ if not response.content:
427
+ return None
428
+ try:
429
+ return response.json()
430
+ except ValueError as exc:
431
+ raise RuntimeError("Dataverse API returned a non-JSON response.") from exc
432
+
433
+
434
+ def _format_error_message(response: httpx.Response, payload: Any) -> str:
435
+ message = _dataverse_error_message(payload)
436
+ if message:
437
+ return f"Dataverse request failed with status {response.status_code}: {message}"
438
+ return f"Dataverse request failed with status {response.status_code}."
439
+
440
+
441
+ def _dataverse_error_message(payload: Any) -> str:
442
+ if not isinstance(payload, Mapping):
443
+ return ""
444
+
445
+ error = payload.get("error")
446
+ if not isinstance(error, Mapping):
447
+ return ""
448
+
449
+ code = error.get("code")
450
+ message = error.get("message")
451
+ parts = [part for part in (code, message) if isinstance(part, str) and part]
452
+ return ": ".join(parts)
453
+
454
+
455
+ def _retry_after_seconds(response: httpx.Response) -> float | None:
456
+ """Parse the Dataverse Retry-After header.
457
+
458
+ Microsoft documents this as an integer number of seconds and the
459
+ official sample uses `int.Parse` on the value:
460
+ https://learn.microsoft.com/en-us/power-apps/developer/data-platform/api-limits
461
+
462
+ Returns None when the header is absent. Raises if the header is present
463
+ but not an integer (matches the Microsoft sample's behavior).
464
+ """
465
+ header_value = response.headers.get("Retry-After")
466
+ if header_value is None:
467
+ return None
468
+ return float(int(header_value))
@@ -0,0 +1,21 @@
1
+ from __future__ import annotations
2
+
3
+ from shared_plugins.models import CtxModel
4
+
5
+
6
+ class DataverseRowBase(CtxModel):
7
+ """Common ctx fields on every Dataverse row beyond _ctx_*.
8
+
9
+ Every Dataverse row, regardless of which table it comes from, carries:
10
+ - etag: the OData @odata.etag value, used for downstream change detection.
11
+ - is_deleted: set by tombstone detection on delta drains.
12
+ - delete_reason: the OData reason field on tombstones.
13
+
14
+ Per-table runtime models extend this with one field per Dataverse attribute
15
+ that survived filtering, plus the per-table primary-key field. The
16
+ _ctx_binding_id and _ctx_source_updated_at fields come from CtxModel.
17
+ """
18
+
19
+ etag: str | None = None
20
+ is_deleted: bool = False
21
+ delete_reason: str | None = None
@@ -0,0 +1,62 @@
1
+ """Postgres identifier construction and validation for Dataverse columns.
2
+
3
+ Postgres caps identifiers at NAMEDATALEN-1 = 63 bytes. With the verbose
4
+ annotation suffixes (_formatted_value, _lookup_logical_name), Dataverse
5
+ attribute logical names can produce columns that overflow. Silent
6
+ truncation would risk collisions and lost data, so we validate at
7
+ warmup and raise loudly on overflow. Resolution is human-driven (skip
8
+ the underlying attribute via plugin override or shorten the suffix).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import keyword
14
+ import re
15
+
16
+ from shared_plugins.exceptions import PluginConfigurationError
17
+
18
+ PG_IDENTIFIER_BYTE_LIMIT = 63
19
+
20
+
21
+ def validate_identifier(name: str, *, context: str) -> None:
22
+ """Raise PluginConfigurationError if name exceeds postgres's 63-byte limit.
23
+
24
+ `context` is included in the error message so the operator can locate the
25
+ offending attribute (e.g. "msdyn_projects.foo_lookup_logical_name").
26
+ """
27
+ encoded = name.encode("utf-8")
28
+ if len(encoded) > PG_IDENTIFIER_BYTE_LIMIT:
29
+ raise PluginConfigurationError(
30
+ f"Postgres identifier {name!r} ({len(encoded)} bytes) exceeds postgres "
31
+ f"NAMEDATALEN-1 limit of {PG_IDENTIFIER_BYTE_LIMIT} bytes. "
32
+ f"Context: {context}. "
33
+ "Resolution: skip the underlying attribute via the plugin's "
34
+ "skipped_logical_names override, or shorten the annotation suffix "
35
+ "in shared_plugins.microsoft_dataverse.annotations."
36
+ )
37
+
38
+
39
+ def annotation_column_name(base_column: str, suffix: str, *, context: str) -> str:
40
+ """Compose `f"{base_column}{suffix}"` and validate the result fits in 63 bytes."""
41
+ name = f"{base_column}{suffix}"
42
+ validate_identifier(name, context=context)
43
+ return name
44
+
45
+
46
+ def safe_identifier(value: str) -> str:
47
+ candidate = re.sub(r"\W+", "_", value).strip("_").lower()
48
+ if not candidate:
49
+ candidate = "value"
50
+ if candidate[0].isdigit():
51
+ candidate = f"field_{candidate}"
52
+ if keyword.iskeyword(candidate):
53
+ candidate = f"{candidate}_field"
54
+ return candidate
55
+
56
+
57
+ def pascal_case(value: str) -> str:
58
+ return "".join(part.capitalize() for part in re.split(r"[^a-zA-Z0-9]+", value))
59
+
60
+
61
+ def escape_odata_string(value: str) -> str:
62
+ return value.replace("'", "''")
@@ -0,0 +1,53 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from pydantic import ConfigDict, Field
6
+ from shared_plugins.models import IdStr, IngressModel
7
+
8
+ from .tables import DataverseTableSpec
9
+
10
+
11
+ class DataverseRecordIngress(IngressModel):
12
+ # Real OData rows carry per-table typed columns (msdyn_*, statecode, etc.)
13
+ # that this envelope-only model does not declare. `extra="allow"` keeps
14
+ # those fields on the model and round-trips them through model_dump(...)
15
+ # so the translator's raw_payload dict still contains every column. Strict
16
+ # forbid would reject every real row; ignore would silently drop typed
17
+ # columns before model_dump emits them. Per-table validation of typed
18
+ # columns happens at the row-layer CtxModel built in runtime_schema.py.
19
+ model_config = ConfigDict(extra="allow", populate_by_name=True)
20
+
21
+ odata_etag: str | None = Field(default=None, alias="@odata.etag")
22
+ odata_context: str | None = Field(default=None, alias="@odata.context")
23
+ odata_id: str | None = Field(default=None, alias="@odata.id")
24
+ odata_type: str | None = Field(default=None, alias="@odata.type")
25
+ id: IdStr | None = None
26
+ reason: str | None = None
27
+
28
+
29
+ class DataverseListEnvelopeIngress(IngressModel):
30
+ odata_context: str | None = Field(default=None, alias="@odata.context")
31
+ value: list[dict[str, Any]] = Field(default_factory=list)
32
+ next_link: str | None = Field(default=None, alias="@odata.nextLink")
33
+ delta_link: str | None = Field(default=None, alias="@odata.deltaLink")
34
+
35
+
36
+ class DataverseListResponseIngress(IngressModel):
37
+ odata_context: str | None = Field(default=None, alias="@odata.context")
38
+ value: list[DataverseRecordIngress] = Field(default_factory=list)
39
+ next_link: str | None = Field(default=None, alias="@odata.nextLink")
40
+ delta_link: str | None = Field(default=None, alias="@odata.deltaLink")
41
+
42
+
43
+ def is_deleted_record(
44
+ record: DataverseRecordIngress,
45
+ spec: DataverseTableSpec,
46
+ *,
47
+ payload: dict[str, Any],
48
+ ) -> bool:
49
+ # Dataverse delta tombstones can arrive as a partial record carrying only
50
+ # @odata.id, with the selected primary-key column absent from the payload.
51
+ return bool(record.reason) or (
52
+ isinstance(record.id, str) and spec.primary_key not in payload
53
+ )