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,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
|
+
)
|