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,106 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from pydantic import BaseModel, ConfigDict, Field
6
+ from shared_plugins.models import IngressModel
7
+
8
+
9
+ class LocalizedLabelIngress(IngressModel):
10
+ # Verified 2026-05-03 against customer tenant with `Prefer: odata.include-annotations="*"`:
11
+ # no @odata.* decoration on this complex type, so extra="forbid" via IngressModel is safe.
12
+ label: str | None = Field(default=None, alias="Label")
13
+ language_code: int | None = Field(default=None, alias="LanguageCode")
14
+ is_managed: bool | None = Field(default=None, alias="IsManaged")
15
+ metadata_id: str | None = Field(default=None, alias="MetadataId")
16
+ has_changed: bool | None = Field(default=None, alias="HasChanged")
17
+
18
+
19
+ class LabelIngress(IngressModel):
20
+ # Verified 2026-05-03 against customer tenant with `Prefer: odata.include-annotations="*"`:
21
+ # no @odata.* decoration on this complex type, so extra="forbid" via IngressModel is safe.
22
+ localized_labels: list[LocalizedLabelIngress] | None = Field(
23
+ default=None,
24
+ alias="LocalizedLabels",
25
+ )
26
+ user_localized_label: LocalizedLabelIngress | None = Field(
27
+ default=None,
28
+ alias="UserLocalizedLabel",
29
+ )
30
+
31
+
32
+ class AttributeTypeNameIngress(IngressModel):
33
+ # Verified 2026-05-03 against customer tenant with `Prefer: odata.include-annotations="*"`:
34
+ # no @odata.* decoration on this complex type, so extra="forbid" via IngressModel is safe.
35
+ value: str | None = Field(default=None, alias="Value")
36
+
37
+
38
+ class AttributeMetadataIngress(BaseModel):
39
+ """Per-attribute metadata returned by EntityDefinitions/<n>/Attributes.
40
+
41
+ Uses extra="ignore" (rather than the codebase default extra="forbid")
42
+ because we $select only a subset of fields and Microsoft includes
43
+ @odata.* meta + a wide range of type-specific fields on every attribute.
44
+ The strict-rejection model would fail on every record. The trade-off is
45
+ documented and accepted at this single boundary.
46
+ """
47
+
48
+ model_config = ConfigDict(extra="ignore", populate_by_name=True)
49
+
50
+ logical_name: str = Field(alias="LogicalName")
51
+ attribute_type: str | None = Field(default=None, alias="AttributeType")
52
+ attribute_type_name: AttributeTypeNameIngress | None = Field(
53
+ default=None,
54
+ alias="AttributeTypeName",
55
+ )
56
+ attribute_of: str | None = Field(default=None, alias="AttributeOf")
57
+ is_valid_for_read: bool | None = Field(default=None, alias="IsValidForRead")
58
+ is_custom_attribute: bool = Field(default=False, alias="IsCustomAttribute")
59
+ is_primary_id: bool = Field(default=False, alias="IsPrimaryId")
60
+ description: LabelIngress | None = Field(default=None, alias="Description")
61
+ display_name: LabelIngress | None = Field(default=None, alias="DisplayName")
62
+ precision: int | None = Field(default=None, alias="Precision")
63
+ targets: list[str] | None = Field(default=None, alias="Targets")
64
+ format: str | None = Field(default=None, alias="Format")
65
+ date_time_behavior: dict[str, Any] | None = Field(
66
+ default=None,
67
+ alias="DateTimeBehavior",
68
+ )
69
+
70
+ @property
71
+ def type_name_value(self) -> str | None:
72
+ return (
73
+ None if self.attribute_type_name is None else self.attribute_type_name.value
74
+ )
75
+
76
+
77
+ class EntityMetadataIngress(IngressModel):
78
+ metadata_id: str | None = Field(default=None, alias="MetadataId")
79
+ logical_name: str = Field(alias="LogicalName")
80
+ entity_set_name: str = Field(alias="EntitySetName")
81
+ primary_id_attribute: str = Field(alias="PrimaryIdAttribute")
82
+
83
+
84
+ class MetadataListResponseIngress(IngressModel):
85
+ odata_context: str | None = Field(default=None, alias="@odata.context")
86
+ value: list[dict[str, Any]] = Field(default_factory=list)
87
+
88
+
89
+ def extract_label(label: LabelIngress | None) -> str | None:
90
+ """Pull the most useful localized label, or None.
91
+
92
+ Tries UserLocalizedLabel first (the calling principal's preferred locale).
93
+ Falls back to the first non-empty LocalizedLabels entry for tenants where
94
+ service-principal access doesn't populate UserLocalizedLabel. Treats empty
95
+ and whitespace-only strings as absent.
96
+ """
97
+ if label is None:
98
+ return None
99
+ if label.user_localized_label is not None:
100
+ text = label.user_localized_label.label
101
+ if text and text.strip():
102
+ return text
103
+ for localized in label.localized_labels or ():
104
+ if localized.label and localized.label.strip():
105
+ return localized.label
106
+ return None
@@ -0,0 +1,332 @@
1
+ """Per-table schema construction at warmup.
2
+
3
+ Fetches Dataverse entity + attribute metadata, builds RuntimeFieldSpecs,
4
+ validates 63-byte postgres identifier limits, and assembles per-table
5
+ runtime CtxModel subclasses + dlt column hints. The plugin's source factory
6
+ calls `build_runtime_table_schemas(...)` once per asset run.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Iterable
12
+ from dataclasses import dataclass
13
+ from decimal import Decimal
14
+ from typing import Annotated, Any
15
+
16
+ from pydantic import BeforeValidator, Field, create_model
17
+ from shared_plugins.exceptions import PluginConfigurationError
18
+ from shared_plugins.models import IdStr
19
+
20
+ from .ctx import DataverseRowBase
21
+ from .identifiers import (
22
+ escape_odata_string,
23
+ pascal_case,
24
+ safe_identifier,
25
+ validate_identifier,
26
+ )
27
+ from .metadata import (
28
+ AttributeMetadataIngress,
29
+ EntityMetadataIngress,
30
+ MetadataListResponseIngress,
31
+ )
32
+ from .tables import DataverseSyncMode, DataverseTableSpec
33
+ from .types import (
34
+ RuntimeFieldSpec,
35
+ build_dlt_column_hints,
36
+ field_spec_for_attribute,
37
+ )
38
+
39
+ # Field names already declared on DataverseRowBase (and CtxModel via inheritance).
40
+ # A runtime field whose Python name lands in this set would silently shadow the
41
+ # parent contract via pydantic.create_model, breaking tombstone semantics or the
42
+ # _ctx_binding_id invariant. The collision check in _create_runtime_record_model
43
+ # rejects such attributes loudly at warmup.
44
+ _RESERVED_ROW_BASE_FIELDS: frozenset[str] = frozenset(
45
+ DataverseRowBase.model_fields.keys()
46
+ )
47
+
48
+ ENTITY_SELECT = "LogicalName,EntitySetName,PrimaryIdAttribute"
49
+ # Only base-type fields. Subtype fields (Precision, Targets, Format,
50
+ # DateTimeBehavior) live on Microsoft.Dynamics.CRM.{Decimal,Lookup,DateTime,...}
51
+ # AttributeMetadata, NOT on the base type. Microsoft's OData rejects
52
+ # $select=Precision,... here with HTTP 400 because the cast is implicit.
53
+ # Decimal columns therefore default to numeric(28, 4); DateTime columns default
54
+ # to timestamptz (UserLocal); Lookup loses Targets metadata. See
55
+ # internal/planned/2026-05-03-dataverse-subtype-metadata-fetch.md for the
56
+ # upgrade path (per-subtype cast queries, ~5x metadata round-trips at warmup,
57
+ # tenant-specific precision + Format + DateTimeBehavior fidelity).
58
+ ATTRIBUTE_SELECT = (
59
+ "LogicalName,"
60
+ "AttributeType,"
61
+ "AttributeTypeName,"
62
+ "AttributeOf,"
63
+ "IsValidForRead,"
64
+ "IsCustomAttribute,"
65
+ "IsPrimaryId,"
66
+ "Description,"
67
+ "DisplayName"
68
+ )
69
+
70
+
71
+ @dataclass(frozen=True)
72
+ class RuntimeDataverseTableSchema:
73
+ spec: DataverseTableSpec
74
+ fields: tuple[RuntimeFieldSpec, ...]
75
+ record_model: type[DataverseRowBase]
76
+ select_columns: tuple[str, ...]
77
+ dlt_columns: dict[str, dict[str, Any]]
78
+
79
+
80
+ def build_runtime_table_schemas(
81
+ *,
82
+ client: Any,
83
+ specs: Iterable[DataverseTableSpec],
84
+ ) -> dict[str, RuntimeDataverseTableSchema]:
85
+ return {
86
+ schema.spec.entity_set: schema
87
+ for schema in (
88
+ _build_runtime_table_schema(client=client, spec=spec) for spec in specs
89
+ )
90
+ }
91
+
92
+
93
+ def _build_runtime_table_schema(
94
+ *,
95
+ client: Any,
96
+ spec: DataverseTableSpec,
97
+ ) -> RuntimeDataverseTableSchema:
98
+ entity = _fetch_entity_metadata(client=client, entity_set=spec.entity_set)
99
+ attributes = _fetch_attribute_metadata(
100
+ client=client,
101
+ logical_name=entity.logical_name,
102
+ )
103
+
104
+ fields: list[RuntimeFieldSpec] = []
105
+ for attribute in attributes:
106
+ spec_field = field_spec_for_attribute(attribute)
107
+ if spec_field is None:
108
+ continue
109
+
110
+ validate_identifier(
111
+ spec_field.logical_name,
112
+ context=f"{spec.entity_set}.{spec_field.logical_name}",
113
+ )
114
+ for ann in spec_field.annotation_columns:
115
+ validate_identifier(
116
+ ann.column_name,
117
+ context=f"{spec.entity_set}.{ann.column_name}",
118
+ )
119
+
120
+ fields.append(spec_field)
121
+
122
+ select_columns = _compute_select_columns(
123
+ fields=fields,
124
+ primary_key=entity.primary_id_attribute,
125
+ )
126
+ record_model = _create_runtime_record_model(
127
+ class_name=f"{pascal_case(entity.entity_set_name)}Row",
128
+ fields=fields,
129
+ primary_key=entity.primary_id_attribute,
130
+ entity_set=spec.entity_set,
131
+ )
132
+ dlt_columns = build_dlt_column_hints(
133
+ tuple(fields),
134
+ sync_mode_is_delta=spec.sync_mode is DataverseSyncMode.DELTA,
135
+ )
136
+
137
+ return RuntimeDataverseTableSchema(
138
+ spec=spec,
139
+ fields=tuple(fields),
140
+ record_model=record_model,
141
+ select_columns=select_columns,
142
+ dlt_columns=dlt_columns,
143
+ )
144
+
145
+
146
+ def _compute_select_columns(
147
+ *,
148
+ fields: list[RuntimeFieldSpec],
149
+ primary_key: str,
150
+ ) -> tuple[str, ...]:
151
+ columns = [field.logical_name for field in fields]
152
+ if primary_key not in columns:
153
+ # PK injection here mirrors the model-side fallback in
154
+ # _create_runtime_record_model; keep the two consistent.
155
+ columns.insert(0, primary_key)
156
+ # Preserve order while deduping.
157
+ seen: set[str] = set()
158
+ deduped: list[str] = []
159
+ for col in columns:
160
+ if col not in seen:
161
+ seen.add(col)
162
+ deduped.append(col)
163
+ return tuple(deduped)
164
+
165
+
166
+ def _create_runtime_record_model(
167
+ *,
168
+ class_name: str,
169
+ fields: list[RuntimeFieldSpec],
170
+ primary_key: str,
171
+ entity_set: str,
172
+ ) -> type[DataverseRowBase]:
173
+ """Construct a per-table CtxModel subclass via pydantic.create_model.
174
+
175
+ Each Dataverse attribute becomes a Pydantic field with a lenient ingress
176
+ type (string for date/timestamp/uuid, Decimal for numeric, native Python
177
+ scalars for others — dlt's normalizer handles the destination cast).
178
+ Field aliases match the Dataverse logical name so record validation can
179
+ take the raw OData payload directly.
180
+
181
+ Raises PluginConfigurationError if any attribute's Python field name
182
+ collides with a field on DataverseRowBase. pydantic.create_model would
183
+ silently override the parent field, breaking the row metadata contract.
184
+ """
185
+ field_definitions: dict[str, tuple[Any, Any]] = {}
186
+ field_logical_names = {f.logical_name for f in fields}
187
+ pk_present_in_fields = primary_key in field_logical_names
188
+
189
+ if not pk_present_in_fields:
190
+ # Add the PK as a string field so the schema knows about it for
191
+ # OData $select ordering. Most tenants always include the PK; this
192
+ # mirrors the select-side fallback in _compute_select_columns.
193
+ pk_py_name = safe_identifier(primary_key)
194
+ _check_no_row_base_collision(
195
+ python_name=pk_py_name,
196
+ logical_name=primary_key,
197
+ entity_set=entity_set,
198
+ )
199
+ field_definitions[pk_py_name] = (
200
+ IdStr | None,
201
+ Field(default=None, alias=primary_key),
202
+ )
203
+
204
+ for field in fields:
205
+ py_name = safe_identifier(field.logical_name)
206
+ _check_no_row_base_collision(
207
+ python_name=py_name,
208
+ logical_name=field.logical_name,
209
+ entity_set=entity_set,
210
+ )
211
+ annotation = _ingress_type_for(field.pg_type)
212
+ alias = field.logical_name if py_name != field.logical_name else None
213
+ default_kwargs: dict[str, Any] = {"default": None}
214
+ if alias is not None:
215
+ default_kwargs["alias"] = alias
216
+ field_definitions[py_name] = (
217
+ annotation | None,
218
+ Field(**default_kwargs),
219
+ )
220
+
221
+ for ann in field.annotation_columns:
222
+ ann_py_name = safe_identifier(ann.column_name)
223
+ _check_no_row_base_collision(
224
+ python_name=ann_py_name,
225
+ logical_name=ann.column_name,
226
+ entity_set=entity_set,
227
+ )
228
+ ann_alias = ann.column_name if ann_py_name != ann.column_name else None
229
+ ann_kwargs: dict[str, Any] = {"default": None}
230
+ if ann_alias is not None:
231
+ ann_kwargs["alias"] = ann_alias
232
+ field_definitions[ann_py_name] = (
233
+ str | None,
234
+ Field(**ann_kwargs),
235
+ )
236
+
237
+ return create_model( # type: ignore[no-any-return]
238
+ class_name,
239
+ __base__=DataverseRowBase,
240
+ __module__=__name__,
241
+ **field_definitions,
242
+ )
243
+
244
+
245
+ def _check_no_row_base_collision(
246
+ *,
247
+ python_name: str,
248
+ logical_name: str,
249
+ entity_set: str,
250
+ ) -> None:
251
+ if python_name in _RESERVED_ROW_BASE_FIELDS:
252
+ raise PluginConfigurationError(
253
+ f"Dataverse attribute {logical_name!r} on entity_set "
254
+ f"{entity_set!r} produces field name {python_name!r}, which "
255
+ f"would silently shadow reserved {python_name!r} on "
256
+ "DataverseRowBase. This collision is unhandled."
257
+ )
258
+
259
+
260
+ def _ingress_type_for(pg_type: str) -> Any:
261
+ """Map dlt pg_type back to the lenient Python ingress type.
262
+
263
+ Most scalars arrive from OData as JSON-typed values; pydantic + dlt's
264
+ normalizer coerce on the way to postgres.
265
+ """
266
+ if pg_type == "bigint":
267
+ return int
268
+ if pg_type == "double":
269
+ return float
270
+ if pg_type == "bool":
271
+ return bool
272
+ if pg_type == "bigint[]":
273
+ # Dataverse v9.2 Web API serializes MultiSelectPicklist as a
274
+ # comma-separated string of option-set integers (e.g.
275
+ # "975620006,975620013"), not a JSON array. The attribute metadata
276
+ # describes the conceptual type (list of options) but is silent on
277
+ # wire format; verified empirically against a real tenant on
278
+ # 2026-05-12. A pre-validator splits the CSV before pydantic
279
+ # validates against list[int]; if Microsoft ever ships an actual
280
+ # JSON array, the passthrough branch keeps it working.
281
+ return Annotated[list[int], BeforeValidator(_coerce_multiselect_picklist_csv)]
282
+ if pg_type == "numeric":
283
+ # Decimal/Money values arrive as JSON numbers (e.g. 0.5, 8.0), not
284
+ # strings. Pydantic auto-coerces JSON numbers to Decimal in lenient
285
+ # mode (CtxModel inherits extra="forbid" but not strict=True).
286
+ return Decimal
287
+ # text, uuid, date, timestamp arrive as strings from OData.
288
+ return str
289
+
290
+
291
+ def _coerce_multiselect_picklist_csv(value: Any) -> Any:
292
+ if isinstance(value, str):
293
+ return [int(part) for part in value.split(",")]
294
+ return value
295
+
296
+
297
+ def _fetch_entity_metadata(
298
+ *,
299
+ client: Any,
300
+ entity_set: str,
301
+ ) -> EntityMetadataIngress:
302
+ payload = client.get_json(
303
+ "EntityDefinitions",
304
+ params={
305
+ "$select": ENTITY_SELECT,
306
+ "$filter": f"EntitySetName eq '{escape_odata_string(entity_set)}'",
307
+ },
308
+ )
309
+ response = MetadataListResponseIngress.model_validate(payload)
310
+ if len(response.value) != 1:
311
+ raise PluginConfigurationError(
312
+ f"Expected exactly one EntityDefinitions match for {entity_set}, "
313
+ f"got {len(response.value)}. Confirm the entity_set name in TABLE_SPECS "
314
+ "exists in the tenant's Dataverse schema."
315
+ )
316
+ return EntityMetadataIngress.model_validate(response.value[0])
317
+
318
+
319
+ def _fetch_attribute_metadata(
320
+ *,
321
+ client: Any,
322
+ logical_name: str,
323
+ ) -> tuple[AttributeMetadataIngress, ...]:
324
+ payload = client.get_json(
325
+ f"EntityDefinitions(LogicalName='{escape_odata_string(logical_name)}')"
326
+ "/Attributes",
327
+ params={"$select": ATTRIBUTE_SELECT},
328
+ )
329
+ response = MetadataListResponseIngress.model_validate(payload)
330
+ return tuple(
331
+ AttributeMetadataIngress.model_validate(item) for item in response.value
332
+ )