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,108 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable, Collection
4
+ from datetime import datetime, timezone
5
+ from typing import Any
6
+
7
+ from google.oauth2.credentials import Credentials
8
+ from googleapiclient.discovery import build
9
+ from base_client import BindingAuth_AuthenticatedAccount
10
+ from shared_plugins.provider_token import (
11
+ ProviderTokenClient,
12
+ ProviderTokenLease,
13
+ )
14
+
15
+
16
+ def _normalize_google_expiry(expires_at: datetime | None) -> datetime:
17
+ # Google leases always carry an expiry; None means web returned a
18
+ # no-declared-expiry lease (a notion-shaped provider) for a google account —
19
+ # a contract violation, not a case to tolerate.
20
+ if expires_at is None:
21
+ raise ValueError("google lease must carry an expiry.")
22
+ if expires_at.tzinfo is None or expires_at.utcoffset() is None:
23
+ raise ValueError("expires_at must be timezone-aware.")
24
+
25
+ # google.oauth2.credentials.Credentials expects a naive UTC datetime for expiry.
26
+ return expires_at.astimezone(timezone.utc).replace(tzinfo=None)
27
+
28
+
29
+ def _fetch_provider_access_token(
30
+ auth: BindingAuth_AuthenticatedAccount,
31
+ provider_token_client: ProviderTokenClient,
32
+ *,
33
+ refresh: bool = False,
34
+ ) -> ProviderTokenLease:
35
+ # Redeem the account's provider (Google) access token server-side: web's
36
+ # /provider-token mints/refreshes it from the stored OAuth grant. This is the
37
+ # credentials keystone — it replaces the in-process control plane removed in the
38
+ # ctxb thin-client collapse (Chunk 5). `refresh=True` triggers real rotation
39
+ # (google-auth only invokes the refresh handler when the credential is already
40
+ # near expiry, so rotating there is always warranted). The client is the Dagster
41
+ # `provider_token` resource, injected at the asset boundary. Returns a
42
+ # ProviderTokenLease — `.access_token` (str) + `.access_token_expires_at`
43
+ # (tz-aware datetime | None; always set for google).
44
+ return provider_token_client.redeem(
45
+ provider_id=auth.provider_id,
46
+ account_id=auth.account_id,
47
+ refresh=refresh,
48
+ )
49
+
50
+
51
+ def _build_refresh_handler(
52
+ *,
53
+ auth: BindingAuth_AuthenticatedAccount,
54
+ provider_token_client: ProviderTokenClient,
55
+ ) -> Callable[..., tuple[str, datetime]]:
56
+ def _refresh_handler(
57
+ request: Any,
58
+ *,
59
+ scopes: Collection[str] | None = None,
60
+ ) -> tuple[str, datetime]:
61
+ del request
62
+ del scopes
63
+
64
+ # google-auth only invokes this handler when it already considers the
65
+ # credential stale (within REFRESH_THRESHOLD = 3m45s of expiry).
66
+ # `refresh=True` maps to web's real rotation path (auth.api.refreshToken),
67
+ # so this first stale-threshold call rotates for real and the handler goes
68
+ # quiet until the fresh token nears ITS expiry.
69
+ lease = _fetch_provider_access_token(auth, provider_token_client, refresh=True)
70
+ return (
71
+ lease.access_token,
72
+ _normalize_google_expiry(lease.access_token_expires_at),
73
+ )
74
+
75
+ return _refresh_handler
76
+
77
+
78
+ def _build_google_credentials(
79
+ *,
80
+ auth: BindingAuth_AuthenticatedAccount,
81
+ provider_token_client: ProviderTokenClient,
82
+ ) -> Credentials:
83
+ lease = _fetch_provider_access_token(auth, provider_token_client)
84
+ return Credentials(
85
+ token=lease.access_token,
86
+ expiry=_normalize_google_expiry(lease.access_token_expires_at),
87
+ refresh_handler=_build_refresh_handler(
88
+ auth=auth, provider_token_client=provider_token_client
89
+ ),
90
+ )
91
+
92
+
93
+ def build_google_service(
94
+ *,
95
+ api_name: str,
96
+ api_version: str,
97
+ auth: BindingAuth_AuthenticatedAccount,
98
+ provider_token_client: ProviderTokenClient,
99
+ ) -> Any:
100
+ credentials = _build_google_credentials(
101
+ auth=auth, provider_token_client=provider_token_client
102
+ )
103
+ return build(
104
+ api_name,
105
+ api_version,
106
+ credentials=credentials,
107
+ cache_discovery=False,
108
+ )
@@ -0,0 +1,308 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import logging
5
+ import random
6
+ import time
7
+ from collections.abc import Callable, Mapping
8
+ from dataclasses import dataclass
9
+ from datetime import datetime, timezone
10
+ from email.utils import parsedate_to_datetime
11
+ from typing import Any
12
+
13
+ from .http_errors import extract_http_status_code
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ _DEFAULT_RETRYABLE_FORBIDDEN_REASONS = frozenset(
18
+ {"rateLimitExceeded", "userRateLimitExceeded"}
19
+ )
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class BatchRetryPolicy:
24
+ max_attempts: int = 5
25
+ base_backoff_seconds: float = 1.0
26
+ max_backoff_seconds: float = 30.0
27
+ retryable_forbidden_reasons: frozenset[str] = _DEFAULT_RETRYABLE_FORBIDDEN_REASONS
28
+
29
+ def __post_init__(self) -> None:
30
+ if self.max_attempts < 1:
31
+ raise ValueError("max_attempts must be >= 1")
32
+ if self.base_backoff_seconds < 0:
33
+ raise ValueError("base_backoff_seconds must be >= 0")
34
+ if self.max_backoff_seconds < 0:
35
+ raise ValueError("max_backoff_seconds must be >= 0")
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class BatchSubrequestFailure:
40
+ request_id: str
41
+ exception: Exception
42
+ status_code: int | None
43
+ reasons: tuple[str, ...]
44
+
45
+
46
+ class BatchRetryExhaustedError(RuntimeError):
47
+ def __init__(
48
+ self,
49
+ *,
50
+ attempts: int,
51
+ failures: Mapping[str, BatchSubrequestFailure],
52
+ ) -> None:
53
+ self.attempts = attempts
54
+ self.failures = dict(failures)
55
+ failed_ids = sorted(self.failures.keys())
56
+ sample = ", ".join(failed_ids[:5])
57
+ super().__init__(
58
+ "Batch subrequest retries exhausted after "
59
+ f"{attempts} attempts for {len(failed_ids)} request(s). "
60
+ f"Sample request IDs: {sample}"
61
+ )
62
+
63
+ @property
64
+ def failed_request_ids(self) -> tuple[str, ...]:
65
+ return tuple(sorted(self.failures.keys()))
66
+
67
+
68
+ class BatchTerminalSubrequestError(RuntimeError):
69
+ def __init__(
70
+ self,
71
+ *,
72
+ request_id: str,
73
+ exception: Exception,
74
+ ) -> None:
75
+ self.request_id = request_id
76
+ self.exception = exception
77
+ super().__init__(
78
+ f"Batch subrequest {request_id} failed with a non-retryable error: {exception}"
79
+ )
80
+
81
+
82
+ RequestFactory = Callable[[], Any]
83
+ NewBatchFactory = Callable[[Callable[[str, Any, Exception | None], None]], Any]
84
+ SleepFn = Callable[[float], None]
85
+ RandomFn = Callable[[], float]
86
+
87
+
88
+ def execute_batch_with_failed_subset_retries(
89
+ *,
90
+ request_factories: Mapping[str, RequestFactory],
91
+ new_batch: NewBatchFactory,
92
+ policy: BatchRetryPolicy | None = None,
93
+ sleep_fn: SleepFn = time.sleep,
94
+ random_fn: RandomFn = random.random,
95
+ ) -> dict[str, Any]:
96
+ """Execute a callback-based Google batch and retry only failed subrequests."""
97
+ if not request_factories:
98
+ return {}
99
+
100
+ retry_policy = policy or BatchRetryPolicy()
101
+ pending_ids = list(request_factories.keys())
102
+ responses: dict[str, Any] = {}
103
+ last_retryable_failures: dict[str, BatchSubrequestFailure] = {}
104
+
105
+ for attempt in range(1, retry_policy.max_attempts + 1):
106
+ attempt_failures: dict[str, Exception] = {}
107
+ retry_after_values: list[float] = []
108
+
109
+ def _callback(
110
+ request_id: str,
111
+ response: Any,
112
+ exception: Exception | None,
113
+ ) -> None:
114
+ if exception is not None:
115
+ attempt_failures[request_id] = exception
116
+ retry_after = _extract_retry_after_seconds(exception)
117
+ if retry_after is not None:
118
+ retry_after_values.append(retry_after)
119
+ return
120
+ responses[request_id] = response
121
+
122
+ batch = new_batch(_callback)
123
+ for request_id in pending_ids:
124
+ batch.add(request_factories[request_id](), request_id=request_id)
125
+
126
+ try:
127
+ batch.execute()
128
+ except Exception as exc:
129
+ # Batch-level transport failures have no per-subrequest callback path.
130
+ # Treat as a transient failure for all pending requests when retryable.
131
+ if _is_retryable_exception(exc, retry_policy):
132
+ for request_id in pending_ids:
133
+ attempt_failures.setdefault(request_id, exc)
134
+ retry_after = _extract_retry_after_seconds(exc)
135
+ if retry_after is not None:
136
+ retry_after_values.append(retry_after)
137
+ else:
138
+ raise
139
+
140
+ if not attempt_failures:
141
+ return responses
142
+
143
+ status_histogram: dict[int | None, int] = {}
144
+ reason_histogram: dict[str, int] = {}
145
+ retry_after_present = 0
146
+ retryable_ids: list[str] = []
147
+ for request_id, exception in attempt_failures.items():
148
+ status_code = extract_http_status_code(exception)
149
+ reasons = tuple(sorted(_extract_error_reasons(exception)))
150
+ status_histogram[status_code] = status_histogram.get(status_code, 0) + 1
151
+ for reason in reasons:
152
+ reason_histogram[reason] = reason_histogram.get(reason, 0) + 1
153
+ if _extract_retry_after_seconds(exception) is not None:
154
+ retry_after_present += 1
155
+ if _is_retryable_exception(exception, retry_policy):
156
+ retryable_ids.append(request_id)
157
+ last_retryable_failures[request_id] = BatchSubrequestFailure(
158
+ request_id=request_id,
159
+ exception=exception,
160
+ status_code=status_code,
161
+ reasons=reasons,
162
+ )
163
+ continue
164
+ raise BatchTerminalSubrequestError(
165
+ request_id=request_id,
166
+ exception=exception,
167
+ ) from exception
168
+
169
+ if attempt >= retry_policy.max_attempts:
170
+ unresolved = {
171
+ request_id: last_retryable_failures[request_id]
172
+ for request_id in retryable_ids
173
+ if request_id in last_retryable_failures
174
+ }
175
+ raise BatchRetryExhaustedError(
176
+ attempts=retry_policy.max_attempts,
177
+ failures=unresolved,
178
+ )
179
+
180
+ pending_ids = retryable_ids
181
+ sleep_seconds = _compute_backoff_seconds(
182
+ attempt=attempt,
183
+ policy=retry_policy,
184
+ retry_after_values=retry_after_values,
185
+ random_fn=random_fn,
186
+ )
187
+ if sleep_seconds > 0:
188
+ logger.info(
189
+ "Retrying %d failed batch subrequest(s) after %.2fs (attempt %d/%d). "
190
+ "status_codes=%s reasons=%s retry_after_present=%d/%d",
191
+ len(pending_ids),
192
+ sleep_seconds,
193
+ attempt + 1,
194
+ retry_policy.max_attempts,
195
+ status_histogram,
196
+ reason_histogram,
197
+ retry_after_present,
198
+ len(attempt_failures),
199
+ )
200
+ sleep_fn(sleep_seconds)
201
+
202
+ return responses
203
+
204
+
205
+ def _compute_backoff_seconds(
206
+ *,
207
+ attempt: int,
208
+ policy: BatchRetryPolicy,
209
+ retry_after_values: list[float],
210
+ random_fn: RandomFn,
211
+ ) -> float:
212
+ # Exponential backoff with jitter. attempt=1 is the first retry wait.
213
+ base = min(
214
+ policy.max_backoff_seconds,
215
+ policy.base_backoff_seconds * (2 ** (attempt - 1)),
216
+ )
217
+ jittered = min(policy.max_backoff_seconds, base * (1 + random_fn()))
218
+ retry_after = max(retry_after_values) if retry_after_values else 0.0
219
+ return max(jittered, retry_after)
220
+
221
+
222
+ def _extract_error_reasons(exc: Exception) -> set[str]:
223
+ reasons: set[str] = set()
224
+
225
+ error_details = getattr(exc, "error_details", None)
226
+ _collect_reasons_from_value(error_details, reasons)
227
+
228
+ content = getattr(exc, "content", None)
229
+ if isinstance(content, bytes):
230
+ try:
231
+ payload = json.loads(content.decode("utf-8"))
232
+ except (UnicodeDecodeError, ValueError):
233
+ payload = None
234
+ _collect_reasons_from_value(payload, reasons)
235
+
236
+ return reasons
237
+
238
+
239
+ def _collect_reasons_from_value(value: Any, out: set[str]) -> None:
240
+ if isinstance(value, dict):
241
+ reason = value.get("reason")
242
+ if isinstance(reason, str):
243
+ out.add(reason)
244
+ for nested in value.values():
245
+ _collect_reasons_from_value(nested, out)
246
+ return
247
+
248
+ if isinstance(value, list):
249
+ for nested in value:
250
+ _collect_reasons_from_value(nested, out)
251
+
252
+
253
+ def _extract_retry_after_seconds(exc: Exception) -> float | None:
254
+ header_value = _extract_retry_after_header(exc)
255
+ if header_value is None:
256
+ return None
257
+
258
+ text = str(header_value).strip()
259
+ if not text:
260
+ return None
261
+
262
+ try:
263
+ seconds = float(text)
264
+ except ValueError:
265
+ seconds = _retry_after_http_date_to_seconds(text)
266
+ if seconds is None:
267
+ return None
268
+ return max(0.0, seconds)
269
+
270
+
271
+ def _extract_retry_after_header(exc: Exception) -> str | None:
272
+ for attr in ("resp", "response"):
273
+ candidate = getattr(exc, attr, None)
274
+ if candidate is None:
275
+ continue
276
+ if hasattr(candidate, "get"):
277
+ for key in ("retry-after", "Retry-After"):
278
+ value = candidate.get(key)
279
+ if value is not None:
280
+ return str(value)
281
+ return None
282
+
283
+
284
+ def _retry_after_http_date_to_seconds(value: str) -> float | None:
285
+ try:
286
+ retry_dt = parsedate_to_datetime(value)
287
+ except (TypeError, ValueError, IndexError):
288
+ return None
289
+
290
+ if retry_dt.tzinfo is None:
291
+ retry_dt = retry_dt.replace(tzinfo=timezone.utc)
292
+ now = datetime.now(timezone.utc)
293
+ return (retry_dt - now).total_seconds()
294
+
295
+
296
+ def _is_retryable_exception(exc: Exception, policy: BatchRetryPolicy) -> bool:
297
+ status_code = extract_http_status_code(exc)
298
+ if status_code is None:
299
+ return isinstance(exc, (TimeoutError, ConnectionError, OSError))
300
+
301
+ if status_code >= 500:
302
+ return True
303
+ if status_code in {408, 429}:
304
+ return True
305
+ if status_code == 403:
306
+ reasons = _extract_error_reasons(exc)
307
+ return any(reason in policy.retryable_forbidden_reasons for reason in reasons)
308
+ return False
@@ -0,0 +1,27 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ def extract_http_status_code(exc: Exception) -> int | None:
5
+ status_code = _coerce_status_code(getattr(exc, "status_code", None))
6
+ if status_code is not None:
7
+ return status_code
8
+
9
+ response = getattr(exc, "response", None)
10
+ status_code = _coerce_status_code(getattr(response, "status_code", None))
11
+ if status_code is not None:
12
+ return status_code
13
+
14
+ resp = getattr(exc, "resp", None)
15
+ return _coerce_status_code(getattr(resp, "status", None))
16
+
17
+
18
+ def _coerce_status_code(value: object) -> int | None:
19
+ if isinstance(value, bool):
20
+ return None
21
+ if isinstance(value, int):
22
+ return value
23
+ if isinstance(value, str):
24
+ text = value.strip()
25
+ if text.isdigit():
26
+ return int(text)
27
+ return None