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