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