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,419 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import fcntl
5
+ import json
6
+ import os
7
+ import threading
8
+ import typing
9
+ from collections.abc import Iterator
10
+ from contextlib import contextmanager
11
+ from datetime import datetime, timedelta, timezone
12
+ from pathlib import Path
13
+
14
+ import httpx
15
+ from pydantic import (
16
+ BaseModel,
17
+ ConfigDict,
18
+ Field,
19
+ ValidationError,
20
+ field_validator,
21
+ model_validator,
22
+ )
23
+ from pydantic_settings import BaseSettings, SettingsConfigDict
24
+
25
+ from shared_plugins.env import (
26
+ CTXB_SCRATCH_DIR_ENV_VAR,
27
+ normalize_optional_absolute_path,
28
+ )
29
+
30
+ # The platform machine identity (the Python parallel of the TS
31
+ # `shared/lib/ctx-machine-token.ts`): a confidential OAuth client that mints
32
+ # short-lived `aud=ctx` machine tokens against web's OAuth2 token endpoint with
33
+ # its long-lived client credentials. There is no refresh token — "re-mint" is
34
+ # simply re-authenticating with the client secret. This module is the ONE
35
+ # audited copy of the mint + cache; both the provider-token redemption
36
+ # (`provider_token.py`) and the base-platform SDK auth consume it.
37
+ #
38
+ # The cache is PER-DEPLOYMENT, not per-process: Dagster runs this code in a
39
+ # swarm of short-lived processes (sensor evaluations, run workers, step
40
+ # workers, recycled code servers), and a resource-scoped in-memory cache lives
41
+ # for one tick or one step — so without shared state every tick and step minted
42
+ # its own token, and a first-boot burst rate-limited web's token endpoint
43
+ # (2026-07-08, codecreators v3: better-auth 429s starved every sync until the
44
+ # processes went quiet). The shared JSON file under the ctxb scratch dir turns
45
+ # the whole deployment into one token client (~1 mint/hour), with the flock
46
+ # giving cross-process single-flight and the recorded backoff making every
47
+ # process fail fast — without re-contacting the endpoint — after a 429.
48
+ #
49
+ # Host-side only: the machine secret and the minted token never enter the
50
+ # synthesis guest, where a prompt-injected synthesizer could exfiltrate them.
51
+ # The scratch dir is never mounted into guests and the state file is 0600.
52
+
53
+ CTX_WEB_URL_ENV_VAR = "CTX_WEB_URL"
54
+ CTX_MACHINE_CLIENT_ID_ENV_VAR = "CTX_MACHINE_CLIENT_ID"
55
+ CTX_MACHINE_CLIENT_SECRET_ENV_VAR = "CTX_MACHINE_CLIENT_SECRET"
56
+
57
+ # Re-mint this long before the stated expiry so a request never rides a token that
58
+ # lapses in flight (clock skew + the round-trip to the RS). The machine token's TTL
59
+ # is ~1h, so a minute of headroom costs nothing.
60
+ _EXPIRY_SKEW = timedelta(seconds=60)
61
+
62
+ # After a 429 with no parseable Retry-After, keep the whole deployment off the
63
+ # endpoint this long. better-auth's limiter only resets after an idle gap, so
64
+ # the backoff must comfortably exceed that gap for the window to clear.
65
+ _BACKOFF_FALLBACK = timedelta(seconds=60)
66
+ # The longest backoff any Retry-After is allowed to impose (see _retry_after).
67
+ _BACKOFF_CAP = timedelta(minutes=15)
68
+
69
+ _SHARED_STATE_FILENAME = "machine-token.json"
70
+ _SHARED_LOCK_FILENAME = "machine-token.lock"
71
+
72
+
73
+ def raise_on_error(response: httpx.Response, *, context: str) -> None:
74
+ """Fail loud on an HTTP error, surfacing the response body. The error
75
+ payload (e.g. ``{"error":"invalid_client"}`` when the machine creds are
76
+ wrong or the azp is not allowlisted) is the one field that says WHY — which
77
+ httpx's bare ``raise_for_status`` discards. Mirrors the TS mint's error path
78
+ (``shared/lib/ctx-machine-token.ts``)."""
79
+ if response.is_error:
80
+ detail = response.text.strip()
81
+ raise RuntimeError(
82
+ f"{context}: {response.status_code} {response.reason_phrase}"
83
+ + (f" — {detail}" if detail else "")
84
+ )
85
+
86
+
87
+ class _MintResponse(BaseModel):
88
+ """The standard OAuth2 token response, parsed strictly so a malformed or opaque
89
+ (non-`expires_in`) response fails loud at the mint, not later as a stale token."""
90
+
91
+ access_token: str = Field(min_length=1)
92
+ expires_in: float = Field(gt=0)
93
+
94
+
95
+ class _SharedTokenState(BaseModel):
96
+ """The on-disk shape of the deployment-shared token state: a token with its
97
+ absolute expiry, or a 429 backoff deadline — exactly one of the two, enforced
98
+ below so any other shape parses as corrupt and gets rebuilt by the next mint."""
99
+
100
+ model_config = ConfigDict(extra="forbid")
101
+
102
+ access_token: str | None = Field(default=None, min_length=1)
103
+ expires_at: datetime | None = None
104
+ backoff_until: datetime | None = None
105
+
106
+ @field_validator("expires_at", "backoff_until")
107
+ @classmethod
108
+ def _require_tz_aware(cls, value: datetime | None) -> datetime | None:
109
+ # A naive datetime would raise TypeError at every comparison — and since
110
+ # the file persists, that would crash-loop every process of the
111
+ # deployment. Rejecting it here routes any such state through the
112
+ # corrupt-file path (discard and rebuild by minting).
113
+ if value is not None and (value.tzinfo is None or value.utcoffset() is None):
114
+ raise ValueError("shared token state timestamps must be timezone-aware")
115
+ return value
116
+
117
+ @model_validator(mode="after")
118
+ def _require_exactly_one_state(self) -> _SharedTokenState:
119
+ has_token = self.access_token is not None
120
+ if has_token != (self.expires_at is not None):
121
+ raise ValueError("a shared token must carry its expiry (and vice versa)")
122
+ if has_token == (self.backoff_until is not None):
123
+ raise ValueError(
124
+ "shared token state must be exactly one of token-with-expiry or backoff"
125
+ )
126
+ return self
127
+
128
+
129
+ class MachineTokenMinter:
130
+ """Mints an `aud=ctx` machine token via `client_credentials` and caches it
131
+ until just before expiry. With a `shared_cache_dir`, the cache (and any 429
132
+ backoff) lives in a flock-guarded file shared by every process of the
133
+ deployment — cross-process single-flight, one mint per token lifetime.
134
+ Without one, the cache is instance-memory only (tests, ad-hoc callers).
135
+ `invalidate()` drops this instance's token and, if the shared state still
136
+ holds that same failed token, clears it too — so the next `token()` either
137
+ adopts a replacement another process already minted or re-mints. The hook
138
+ for callers that observe a downstream 401 (a lapsed/revoked token)."""
139
+
140
+ def __init__(
141
+ self,
142
+ *,
143
+ web_url: str,
144
+ client_id: str,
145
+ client_secret: str,
146
+ http_client: httpx.Client | None = None,
147
+ shared_cache_dir: Path | None = None,
148
+ ) -> None:
149
+ self._token_url = f"{web_url}/api/auth/oauth2/token"
150
+ basic = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode()
151
+ self._basic_auth = f"Basic {basic}"
152
+ self._http = http_client or httpx.Client(timeout=30.0)
153
+ self._lock = threading.Lock()
154
+ self._cached: tuple[str, datetime] | None = None
155
+ self._shared_cache_dir = shared_cache_dir
156
+
157
+ def token(self) -> str:
158
+ now = datetime.now(timezone.utc)
159
+ with self._lock:
160
+ cached = self._cached
161
+ if cached is not None and now < cached[1] - _EXPIRY_SKEW:
162
+ return cached[0]
163
+ cache_dir = self._shared_cache_dir
164
+ if cache_dir is None:
165
+ return self._mint(now, None)
166
+
167
+ state = self._read_shared_state(cache_dir)
168
+ token = self._usable_token(state, now)
169
+ if token is not None:
170
+ return token
171
+ self._raise_if_backing_off(state, now)
172
+
173
+ with self._shared_flock(cache_dir):
174
+ # Another process may have minted (or hit a 429) while this one
175
+ # waited on the lock — re-read before touching the endpoint.
176
+ state = self._read_shared_state(cache_dir)
177
+ token = self._usable_token(state, now)
178
+ if token is not None:
179
+ return token
180
+ self._raise_if_backing_off(state, now)
181
+ return self._mint(now, cache_dir)
182
+
183
+ def invalidate(self) -> None:
184
+ with self._lock:
185
+ failed = self._cached
186
+ self._cached = None
187
+ cache_dir = self._shared_cache_dir
188
+ if cache_dir is None:
189
+ return
190
+ with self._shared_flock(cache_dir):
191
+ # Clear the shared state only if it still holds the token that
192
+ # just failed. After a revocation every process observes its own
193
+ # 401; unconditional deletion would let each of them destroy the
194
+ # fresh token the first one already re-minted (a thundering herd
195
+ # of mints against a rate-limited endpoint). If the file moved
196
+ # on, dropping this instance's memory is enough — the next
197
+ # token() serves the newer shared token.
198
+ state = self._read_shared_state(cache_dir)
199
+ if state is not None and (
200
+ failed is None or state.access_token != failed[0]
201
+ ):
202
+ return
203
+ state_path = cache_dir / _SHARED_STATE_FILENAME
204
+ if state_path.exists():
205
+ state_path.unlink()
206
+
207
+ def _mint(self, now: datetime, cache_dir: Path | None) -> str:
208
+ response = self._http.post(
209
+ self._token_url,
210
+ data={"grant_type": "client_credentials", "resource": "ctx"},
211
+ headers={"authorization": self._basic_auth},
212
+ )
213
+ if (
214
+ response.status_code == httpx.codes.TOO_MANY_REQUESTS
215
+ and cache_dir is not None
216
+ ):
217
+ self._write_shared_state(
218
+ cache_dir,
219
+ _SharedTokenState(backoff_until=now + self._retry_after(response)),
220
+ )
221
+ raise_on_error(response, context="Failed to mint the ctx machine token")
222
+ parsed = _MintResponse.model_validate(response.json())
223
+ expires_at = now + timedelta(seconds=parsed.expires_in)
224
+ self._cached = (parsed.access_token, expires_at)
225
+ if cache_dir is not None:
226
+ self._write_shared_state(
227
+ cache_dir,
228
+ _SharedTokenState(
229
+ access_token=parsed.access_token, expires_at=expires_at
230
+ ),
231
+ )
232
+ return parsed.access_token
233
+
234
+ def _usable_token(
235
+ self, state: _SharedTokenState | None, now: datetime
236
+ ) -> str | None:
237
+ if state is None or state.access_token is None or state.expires_at is None:
238
+ return None
239
+ if now >= state.expires_at - _EXPIRY_SKEW:
240
+ return None
241
+ self._cached = (state.access_token, state.expires_at)
242
+ return state.access_token
243
+
244
+ def _raise_if_backing_off(
245
+ self, state: _SharedTokenState | None, now: datetime
246
+ ) -> None:
247
+ if state is None or state.backoff_until is None or now >= state.backoff_until:
248
+ return
249
+ raise RuntimeError(
250
+ "The ctx machine-token mint is backing off after a 429 until "
251
+ f"{state.backoff_until.isoformat()} (shared across this deployment's "
252
+ "processes); not contacting the endpoint."
253
+ )
254
+
255
+ @staticmethod
256
+ def _retry_after(response: httpx.Response) -> timedelta:
257
+ header = response.headers.get("retry-after", "").strip()
258
+ if not header.isdigit():
259
+ return _BACKOFF_FALLBACK
260
+ # Capped: an absurd Retry-After (buggy or hostile server) must not
261
+ # write a backoff that keeps every process off the endpoint for days —
262
+ # the shared state would need hand-deletion to recover.
263
+ return min(timedelta(seconds=int(header)), _BACKOFF_CAP)
264
+
265
+ @staticmethod
266
+ def _read_shared_state(cache_dir: Path) -> _SharedTokenState | None:
267
+ state_path = cache_dir / _SHARED_STATE_FILENAME
268
+ if not state_path.exists():
269
+ return None
270
+ try:
271
+ return _SharedTokenState.model_validate_json(state_path.read_text())
272
+ except (ValidationError, json.JSONDecodeError, UnicodeDecodeError):
273
+ # The file is a CACHE of state whose source of truth is the mint
274
+ # endpoint — a corrupt file is rebuilt by the next mint (which
275
+ # overwrites it atomically), not a reason to crash-loop every
276
+ # process of the deployment. Not a swallowed error: the unreadable
277
+ # state is discarded and replaced, never acted on.
278
+ return None
279
+
280
+ @staticmethod
281
+ def _write_shared_state(cache_dir: Path, state: _SharedTokenState) -> None:
282
+ cache_dir.mkdir(parents=True, exist_ok=True)
283
+ tmp_path = cache_dir / f"{_SHARED_STATE_FILENAME}.{os.getpid()}.tmp"
284
+ fd = os.open(tmp_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
285
+ try:
286
+ os.write(fd, state.model_dump_json().encode())
287
+ finally:
288
+ os.close(fd)
289
+ os.replace(tmp_path, cache_dir / _SHARED_STATE_FILENAME)
290
+
291
+ @staticmethod
292
+ @contextmanager
293
+ def _shared_flock(cache_dir: Path) -> Iterator[None]:
294
+ cache_dir.mkdir(parents=True, exist_ok=True)
295
+ fd = os.open(cache_dir / _SHARED_LOCK_FILENAME, os.O_CREAT | os.O_RDWR, 0o600)
296
+ try:
297
+ fcntl.flock(fd, fcntl.LOCK_EX)
298
+ yield
299
+ finally:
300
+ fcntl.flock(fd, fcntl.LOCK_UN)
301
+ os.close(fd)
302
+
303
+
304
+ class CtxMachineTokenAuth(httpx.Auth):
305
+ """Per-request Bearer injection for the platform APIs: stamps the minted
306
+ machine token on every request; on a 401 it invalidates the failed token and
307
+ retries once with a fresh one — re-minted, or adopted from the deployment's
308
+ shared cache when another process already replaced it. The minter must NOT
309
+ share an httpx client that carries this auth — the mint POST would recurse
310
+ through its own Bearer injection."""
311
+
312
+ def __init__(self, minter: MachineTokenMinter) -> None:
313
+ self._minter = minter
314
+
315
+ def auth_flow(
316
+ self, request: httpx.Request
317
+ ) -> typing.Generator[httpx.Request, httpx.Response, None]:
318
+ request.headers["authorization"] = f"Bearer {self._minter.token()}"
319
+ response = yield request
320
+ if response.status_code == httpx.codes.UNAUTHORIZED:
321
+ self._minter.invalidate()
322
+ request.headers["authorization"] = f"Bearer {self._minter.token()}"
323
+ yield request
324
+
325
+
326
+ class _MachineAuthEnv(BaseSettings):
327
+ """The machine-cred presence probe: all fields optional, blank == absent (the
328
+ switch semantics live in `resolve_machine_token_auth`, not here). Contrast
329
+ `ProviderTokenSettings`, which requires every one of its vars — provider-token
330
+ redemption is always platform-managed, while the base-platform SDK also runs
331
+ tokenless in local dev."""
332
+
333
+ model_config = SettingsConfigDict(extra="ignore")
334
+
335
+ ctx_web_url: str | None = Field(default=None, alias=CTX_WEB_URL_ENV_VAR)
336
+ ctx_machine_client_id: str | None = Field(
337
+ default=None, alias=CTX_MACHINE_CLIENT_ID_ENV_VAR
338
+ )
339
+ ctx_machine_client_secret: str | None = Field(
340
+ default=None, alias=CTX_MACHINE_CLIENT_SECRET_ENV_VAR
341
+ )
342
+ ctx_scratch_dir: Path | None = Field(default=None, alias=CTXB_SCRATCH_DIR_ENV_VAR)
343
+
344
+ @field_validator(
345
+ "ctx_web_url",
346
+ "ctx_machine_client_id",
347
+ "ctx_machine_client_secret",
348
+ mode="before",
349
+ )
350
+ @classmethod
351
+ def _blank_is_absent(cls, value: object) -> object:
352
+ if isinstance(value, str) and not value.strip():
353
+ return None
354
+ return value.strip() if isinstance(value, str) else value
355
+
356
+ @field_validator("ctx_scratch_dir", mode="before")
357
+ @classmethod
358
+ def _validate_scratch_dir(cls, value: object) -> Path | None:
359
+ # Blank == absent, like the creds; a non-blank value goes through the
360
+ # one canonical CTXB_SCRATCH_DIR normalization (absolute, resolved) so
361
+ # this probe cannot accept a scratch path env.py would reject.
362
+ if isinstance(value, str) and not value.strip():
363
+ return None
364
+ return normalize_optional_absolute_path(
365
+ value, env_var_name=CTXB_SCRATCH_DIR_ENV_VAR
366
+ )
367
+
368
+ @field_validator("ctx_web_url")
369
+ @classmethod
370
+ def _validate_web_url(cls, value: str | None) -> str | None:
371
+ if value is None:
372
+ return None
373
+ if not value.startswith(("http://", "https://")):
374
+ raise ValueError(f"{CTX_WEB_URL_ENV_VAR} must use http:// or https://.")
375
+ return value.rstrip("/")
376
+
377
+
378
+ def resolve_machine_token_auth() -> CtxMachineTokenAuth | None:
379
+ """Select the platform-managed machine backing. Presence of the machine creds
380
+ is the switch (the twin of TS `resolveMachineTokenProvider`, and the same
381
+ pattern as the RS's `CTX_AUTH_SUB_ID`): creds present + a web origin + the
382
+ scratch dir for the deployment-shared token cache -> a `client_credentials`
383
+ Bearer auth; no creds -> None (no bearer; local-dev authorizes via the
384
+ `CTX_AUTH_SUB_ID` RS bypass, which needs no token). Partial config is a
385
+ deployment mistake, not a half-on mode -> fail loud."""
386
+ env = _MachineAuthEnv()
387
+
388
+ if env.ctx_machine_client_id is None and env.ctx_machine_client_secret is None:
389
+ return None
390
+
391
+ if (
392
+ env.ctx_machine_client_id is None
393
+ or env.ctx_machine_client_secret is None
394
+ or env.ctx_web_url is None
395
+ ):
396
+ raise RuntimeError(
397
+ f"Incomplete machine-auth config: set {CTX_MACHINE_CLIENT_ID_ENV_VAR}, "
398
+ f"{CTX_MACHINE_CLIENT_SECRET_ENV_VAR}, and {CTX_WEB_URL_ENV_VAR} "
399
+ "together (or none)."
400
+ )
401
+
402
+ if env.ctx_scratch_dir is None:
403
+ # Machine mode without the shared scratch dir would silently regress to
404
+ # the per-process cache that rate-limited web — same posture as the
405
+ # partial-creds guard above.
406
+ raise RuntimeError(
407
+ f"{CTXB_SCRATCH_DIR_ENV_VAR} is not set; machine-token minting needs "
408
+ "the shared scratch dir for its cross-process token cache (run via "
409
+ "ctxb so scratch paths are configured)."
410
+ )
411
+
412
+ return CtxMachineTokenAuth(
413
+ MachineTokenMinter(
414
+ web_url=env.ctx_web_url,
415
+ client_id=env.ctx_machine_client_id,
416
+ client_secret=env.ctx_machine_client_secret,
417
+ shared_cache_dir=env.ctx_scratch_dir,
418
+ )
419
+ )
@@ -0,0 +1,27 @@
1
+ """Shared Microsoft Dataverse client + dlt source factory."""
2
+
3
+ from shared_plugins.microsoft_dataverse.auth import ClientSecretTokenProvider
4
+ from shared_plugins.microsoft_dataverse.binding_config import (
5
+ DataverseBindingConfigBase,
6
+ )
7
+ from shared_plugins.microsoft_dataverse.client import (
8
+ DataverseClient,
9
+ DataverseRetryPolicy,
10
+ )
11
+ from shared_plugins.microsoft_dataverse.ctx import DataverseRowBase
12
+ from shared_plugins.microsoft_dataverse.source import build_dataverse_dlt_source
13
+ from shared_plugins.microsoft_dataverse.tables import (
14
+ DataverseSyncMode,
15
+ DataverseTableSpec,
16
+ )
17
+
18
+ __all__ = (
19
+ "ClientSecretTokenProvider",
20
+ "DataverseBindingConfigBase",
21
+ "DataverseClient",
22
+ "DataverseRetryPolicy",
23
+ "DataverseRowBase",
24
+ "DataverseSyncMode",
25
+ "DataverseTableSpec",
26
+ "build_dataverse_dlt_source",
27
+ )
@@ -0,0 +1,61 @@
1
+ """Centralized policy for Dataverse OData response annotations.
2
+
3
+ Three registries:
4
+
5
+ - ODATA_ANNOTATION_COLUMN_SUFFIXES: annotations we keep, with the
6
+ postgres column-name suffix to use.
7
+ - DROPPED_ODATA_ANNOTATIONS: annotations we have triaged and chosen
8
+ to drop, with the reason in an inline comment.
9
+ - DROPPED_RESPONSE_FIELDS: plain (non-annotation) fields Dataverse
10
+ returns unsolicited that the runtime schema does not model, triaged
11
+ and dropped with the reason in an inline comment.
12
+
13
+ Postgres identifier limit: column names cap at 63 bytes. With the
14
+ verbose suffixes below, "<attribute>_lookup_logical_name" can exceed
15
+ the cap for long attribute names (e.g.
16
+ _msdyn_resourceassignmentcomputedrequirement_value would land at
17
+ ~70 chars). The runtime ingress flow MUST validate this at
18
+ metadata-fetch time and raise loudly on overflow — silent truncation
19
+ risks collisions and lost data, neither acceptable.
20
+
21
+ Unknown annotations: any annotation present in a response that is in
22
+ NEITHER registry raises a loud error from the translator. New
23
+ Microsoft annotations must be triaged into one of these lists
24
+ explicitly. We do not silently drop or include unknown annotations.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ ODATA_ANNOTATION_COLUMN_SUFFIXES: dict[str, str] = {
30
+ "@OData.Community.Display.V1.FormattedValue": "_formatted_value",
31
+ "@Microsoft.Dynamics.CRM.lookuplogicalname": "_lookup_logical_name",
32
+ }
33
+
34
+ DROPPED_ODATA_ANNOTATIONS: frozenset[str] = frozenset(
35
+ {
36
+ # Pure OData $expand traversal name (e.g. "msdyn_Project" for
37
+ # _msdyn_project_value). No postgres-query use case — agents query
38
+ # by the GUID + label, not the navigation property.
39
+ "@Microsoft.Dynamics.CRM.associatednavigationproperty",
40
+ }
41
+ )
42
+
43
+ # Plain field names Dataverse returns in a response even though we did not
44
+ # $select them and the runtime schema (built from EntityDefinitions metadata)
45
+ # does not model them. Unlike unknown annotations — which raise — these are
46
+ # triaged and dropped with a reason. Any plain field NOT listed here still
47
+ # reaches the per-table model's extra="forbid" and fails loudly, so genuine
48
+ # schema gaps continue to surface.
49
+ DROPPED_RESPONSE_FIELDS: frozenset[str] = frozenset(
50
+ {
51
+ # Memo sub-attribute (AttributeOf="description") carrying a sanitized
52
+ # copy of `description`. Dataverse emits it alongside `description`;
53
+ # the schema builder excludes AttributeOf children, so it is unmodeled.
54
+ # We keep the real `description`; the sanitized dup is redundant.
55
+ "safedescription",
56
+ # systemuser returns a plain `ownerid` GUID that is absent from the
57
+ # entity's attribute metadata, so it cannot be typed or modeled. The
58
+ # owner is exposed elsewhere as the modeled `_ownerid_value` lookup.
59
+ "ownerid",
60
+ }
61
+ )
@@ -0,0 +1,26 @@
1
+ from __future__ import annotations
2
+
3
+ from azure.identity import ClientSecretCredential
4
+
5
+
6
+ class ClientSecretTokenProvider:
7
+ def __init__(
8
+ self,
9
+ *,
10
+ tenant_id: str,
11
+ client_id: str,
12
+ client_secret: str,
13
+ scope: str,
14
+ ) -> None:
15
+ self._scope = scope
16
+ self._credential = ClientSecretCredential(
17
+ tenant_id=tenant_id,
18
+ client_id=client_id,
19
+ client_secret=client_secret,
20
+ )
21
+
22
+ def __call__(self) -> str:
23
+ return self._credential.get_token(self._scope).token
24
+
25
+ def close(self) -> None:
26
+ self._credential.close()
@@ -0,0 +1,35 @@
1
+ from __future__ import annotations
2
+
3
+ from pydantic import Field, field_validator
4
+ from shared_plugins.bindings import BaseBindingConfigModel, NonEmptyText
5
+
6
+
7
+ class DataverseBindingConfigBase(BaseBindingConfigModel):
8
+ """Base for any plugin syncing from a Microsoft Dataverse instance.
9
+
10
+ Plugins extend this and add plugin-specific fields if any. tenant_id and
11
+ org_url are the only fields required for Dataverse Web API access.
12
+
13
+ Credentials (client_id / client_secret) come through
14
+ BindingAuth_ClientCredentials via shared_plugins.bindings.require_client_credentials,
15
+ NOT through BindingConfig — operator-level secrets stay out of binding.config
16
+ per repo policy.
17
+ """
18
+
19
+ tenant_id: NonEmptyText = Field(
20
+ description="Microsoft Entra tenant id used for Dataverse client credentials.",
21
+ )
22
+ org_url: NonEmptyText = Field(
23
+ description=(
24
+ "Dataverse organization URL, for example "
25
+ "https://org1c9f9fa0.crm3.dynamics.com."
26
+ ),
27
+ )
28
+
29
+ @field_validator("org_url")
30
+ @classmethod
31
+ def _normalize_org_url(cls, value: str) -> str:
32
+ normalized = value.rstrip("/")
33
+ if not normalized.startswith("https://"):
34
+ raise ValueError("Dataverse org_url must start with https://.")
35
+ return normalized