lorebase-scribe 0.2.3__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.
- lorebase_scribe/__init__.py +3 -0
- lorebase_scribe/__main__.py +156 -0
- lorebase_scribe/checkpoints.py +324 -0
- lorebase_scribe/cron.py +117 -0
- lorebase_scribe/embeddings.py +570 -0
- lorebase_scribe/extraction/__init__.py +17 -0
- lorebase_scribe/extraction/assemble.py +339 -0
- lorebase_scribe/extraction/candidates.py +136 -0
- lorebase_scribe/extraction/discovery.py +567 -0
- lorebase_scribe/extraction/llm_discovery.py +271 -0
- lorebase_scribe/extraction/matching.py +285 -0
- lorebase_scribe/extraction/ontology.py +432 -0
- lorebase_scribe/extraction/parsing.py +114 -0
- lorebase_scribe/extraction/pipeline.py +1091 -0
- lorebase_scribe/extraction/relations.py +194 -0
- lorebase_scribe/extraction/sandbox.py +269 -0
- lorebase_scribe/extraction/settings.py +293 -0
- lorebase_scribe/extraction/text.py +179 -0
- lorebase_scribe/extraction/tier2.py +573 -0
- lorebase_scribe/extraction/values.py +282 -0
- lorebase_scribe/jobs.py +418 -0
- lorebase_scribe/scheduler.py +318 -0
- lorebase_scribe/service.py +146 -0
- lorebase_scribe/settings.py +521 -0
- lorebase_scribe/sources/__init__.py +60 -0
- lorebase_scribe/sources/access.py +241 -0
- lorebase_scribe/sources/connectors.py +320 -0
- lorebase_scribe/sources/delta.py +175 -0
- lorebase_scribe/sources/http_json.py +283 -0
- lorebase_scribe/sources/mapping.py +429 -0
- lorebase_scribe/sources/profile.py +36 -0
- lorebase_scribe/sources/records.py +298 -0
- lorebase_scribe/sources/sql.py +332 -0
- lorebase_scribe/sources/sync.py +730 -0
- lorebase_scribe/telemetry.py +113 -0
- lorebase_scribe/webhooks.py +99 -0
- lorebase_scribe/worker.py +254 -0
- lorebase_scribe-0.2.3.dist-info/METADATA +23 -0
- lorebase_scribe-0.2.3.dist-info/RECORD +41 -0
- lorebase_scribe-0.2.3.dist-info/WHEEL +4 -0
- lorebase_scribe-0.2.3.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
"""Row mapping of a Source (design 5.9): transforms, type coercion, keys and Link targets.
|
|
2
|
+
|
|
3
|
+
A `RowMapper` is compiled once per run from the schema and the Source, which also checks
|
|
4
|
+
everything a single row cannot fix (unknown columns are a row problem, an unknown Relation is
|
|
5
|
+
a definition problem). `map` turns one source row into a `MappedRow`:
|
|
6
|
+
|
|
7
|
+
- transforms run in order on the column value: `trim`, `lower`, `upper`, `null_if`
|
|
8
|
+
(`value` or `values`), `constant` (`value`), `concat` (`columns`, `separator`), `map`
|
|
9
|
+
(`values`, optional `default`), `parse_date` (`format`, strptime syntax) and `cast` (`to`:
|
|
10
|
+
`string`, `integer`, `decimal`, `float`, `boolean`, `date` or `timestamp`);
|
|
11
|
+
- the result is coerced to the Attribute type through the core encoding: text is cast from
|
|
12
|
+
`string` with the core cast rules, native values (numbers, dates, booleans from Postgres)
|
|
13
|
+
are encoded directly, empty text is an absent value and naive timestamps are read as UTC;
|
|
14
|
+
- the key comes from `key_mapping`, one column per primary key Attribute, coerced to that
|
|
15
|
+
Attribute's type; the primary key Attributes are set from it;
|
|
16
|
+
- each Relation mapping names the columns holding the primary key of the `to` Record.
|
|
17
|
+
|
|
18
|
+
An Attribute mapped by `expression` instead of `column` takes the value of a CEL expression over
|
|
19
|
+
the row (`lorebase_core.expr.rows`: columns are variables, `row` holds them all); the transforms
|
|
20
|
+
and the coercion then apply to that value like to a column's. Expressions are compiled once over
|
|
21
|
+
the Source's columns (`use_columns`: the header, or every key of the rows read); a column a row
|
|
22
|
+
lacks is null.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import json
|
|
28
|
+
from collections.abc import Mapping, Sequence
|
|
29
|
+
from dataclasses import dataclass, field
|
|
30
|
+
from datetime import UTC, date, datetime
|
|
31
|
+
from decimal import Decimal, InvalidOperation
|
|
32
|
+
from typing import Any
|
|
33
|
+
|
|
34
|
+
from pydantic import JsonValue
|
|
35
|
+
|
|
36
|
+
from lorebase_core.expr import ExprError, ExprLimits, RowExpressions
|
|
37
|
+
from lorebase_core.model import (
|
|
38
|
+
Attribute,
|
|
39
|
+
Kind,
|
|
40
|
+
Record,
|
|
41
|
+
Relation,
|
|
42
|
+
RelationBacking,
|
|
43
|
+
Schema,
|
|
44
|
+
Source,
|
|
45
|
+
record_key,
|
|
46
|
+
)
|
|
47
|
+
from lorebase_core.model.source import Transform, TransformName
|
|
48
|
+
from lorebase_core.types import AttributeType, TypeSpec, TypeSystemError, cast, encode_value
|
|
49
|
+
from lorebase_core.types.encoding import format_date, format_timestamp
|
|
50
|
+
|
|
51
|
+
Row = Mapping[str, Any]
|
|
52
|
+
"""One source row: column name to raw value (text for CSV, native values for Postgres)."""
|
|
53
|
+
|
|
54
|
+
_TEXTUAL = frozenset(
|
|
55
|
+
{
|
|
56
|
+
AttributeType.STRING,
|
|
57
|
+
AttributeType.TEXT,
|
|
58
|
+
AttributeType.URI,
|
|
59
|
+
AttributeType.ENUM,
|
|
60
|
+
AttributeType.REF,
|
|
61
|
+
}
|
|
62
|
+
)
|
|
63
|
+
_STRING = TypeSpec.of(AttributeType.STRING)
|
|
64
|
+
_TRUE = frozenset({"true", "t", "yes", "y", "1"})
|
|
65
|
+
_FALSE = frozenset({"false", "f", "no", "n", "0"})
|
|
66
|
+
_CAST_TARGETS = frozenset(
|
|
67
|
+
{"string", "text", "integer", "decimal", "float", "boolean", "date", "timestamp"}
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class SourceDefinitionError(ValueError):
|
|
72
|
+
"""The Source cannot be synchronized as defined; no row can fix it."""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class RowError(ValueError):
|
|
76
|
+
"""One row cannot be mapped."""
|
|
77
|
+
|
|
78
|
+
def __init__(self, locator: str, message: str, *, column: str | None = None) -> None:
|
|
79
|
+
super().__init__(message)
|
|
80
|
+
self.locator = locator
|
|
81
|
+
self.column = column
|
|
82
|
+
|
|
83
|
+
def as_dict(self) -> dict[str, JsonValue]:
|
|
84
|
+
return {"row": self.locator, "column": self.column, "message": str(self)}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass(frozen=True)
|
|
88
|
+
class MappedRow:
|
|
89
|
+
"""A mapped row: Record key, canonical values of the Attributes the Source owns, and the
|
|
90
|
+
`to` keys of each mapped Relation (an empty set: no Link)."""
|
|
91
|
+
|
|
92
|
+
key: str
|
|
93
|
+
values: dict[str, JsonValue]
|
|
94
|
+
links: dict[str, frozenset[str]] = field(default_factory=dict)
|
|
95
|
+
locator: str = ""
|
|
96
|
+
tombstone: bool = False
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def text_of(value: Any) -> str:
|
|
100
|
+
"""Text form of a native value, used for textual Attributes and string transforms."""
|
|
101
|
+
if isinstance(value, str):
|
|
102
|
+
return value
|
|
103
|
+
if isinstance(value, bool):
|
|
104
|
+
return "true" if value else "false"
|
|
105
|
+
if isinstance(value, datetime):
|
|
106
|
+
return format_timestamp(value if value.tzinfo else value.replace(tzinfo=UTC))
|
|
107
|
+
if isinstance(value, date):
|
|
108
|
+
return format_date(value)
|
|
109
|
+
if isinstance(value, Decimal):
|
|
110
|
+
return format(value, "f")
|
|
111
|
+
if isinstance(value, dict | list):
|
|
112
|
+
return json.dumps(value, sort_keys=True)
|
|
113
|
+
return str(value)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _is_empty(value: Any) -> bool:
|
|
117
|
+
return value is None or (isinstance(value, str) and value.strip() == "")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def coerce(value: Any, spec: TypeSpec) -> JsonValue:
|
|
121
|
+
"""Canonical value of `spec` for a raw value; raises `TypeSystemError` or `ValueError`."""
|
|
122
|
+
if _is_empty(value):
|
|
123
|
+
return None
|
|
124
|
+
if spec.name in _TEXTUAL:
|
|
125
|
+
return encode_value(spec, text_of(value))
|
|
126
|
+
if isinstance(value, str):
|
|
127
|
+
if spec.name is AttributeType.JSON:
|
|
128
|
+
return encode_value(spec, json.loads(value))
|
|
129
|
+
return cast(_STRING, spec, value.strip())
|
|
130
|
+
if isinstance(value, datetime) and value.tzinfo is None:
|
|
131
|
+
value = value.replace(tzinfo=UTC)
|
|
132
|
+
if isinstance(value, float) and spec.name is AttributeType.DECIMAL:
|
|
133
|
+
value = Decimal(repr(value))
|
|
134
|
+
return encode_value(spec, value)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _cast_native(value: Any, target: str) -> Any:
|
|
138
|
+
if target in ("string", "text"):
|
|
139
|
+
return text_of(value)
|
|
140
|
+
text = text_of(value).strip() if not isinstance(value, int | float | Decimal) else None
|
|
141
|
+
if target == "integer":
|
|
142
|
+
if text is None:
|
|
143
|
+
number = Decimal(str(value))
|
|
144
|
+
if number != number.to_integral_value():
|
|
145
|
+
raise ValueError(f"{value} is not an integer")
|
|
146
|
+
return int(number)
|
|
147
|
+
return int(Decimal(text)) if "." in text else int(text)
|
|
148
|
+
if target == "decimal":
|
|
149
|
+
return Decimal(text if text is not None else str(value))
|
|
150
|
+
if target == "float":
|
|
151
|
+
return float(text if text is not None else value)
|
|
152
|
+
if target == "boolean":
|
|
153
|
+
if isinstance(value, bool):
|
|
154
|
+
return value
|
|
155
|
+
lowered = text_of(value).strip().lower()
|
|
156
|
+
if lowered in _TRUE:
|
|
157
|
+
return True
|
|
158
|
+
if lowered in _FALSE:
|
|
159
|
+
return False
|
|
160
|
+
raise ValueError(f"not a boolean: {value!r}")
|
|
161
|
+
if target == "date":
|
|
162
|
+
if isinstance(value, datetime):
|
|
163
|
+
return value.date()
|
|
164
|
+
if isinstance(value, date):
|
|
165
|
+
return value
|
|
166
|
+
return date.fromisoformat(text_of(value).strip())
|
|
167
|
+
if isinstance(value, datetime):
|
|
168
|
+
return value
|
|
169
|
+
parsed = datetime.fromisoformat(text_of(value).strip())
|
|
170
|
+
return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _check_transform(transform: Transform, where: str) -> None:
|
|
174
|
+
args = transform.args
|
|
175
|
+
name = transform.name
|
|
176
|
+
if name is TransformName.CAST and args.get("to") not in _CAST_TARGETS:
|
|
177
|
+
raise SourceDefinitionError(
|
|
178
|
+
f"{where}: cast needs `to`, one of {', '.join(sorted(_CAST_TARGETS))}"
|
|
179
|
+
)
|
|
180
|
+
if name is TransformName.PARSE_DATE and not isinstance(args.get("format"), str):
|
|
181
|
+
raise SourceDefinitionError(f"{where}: parse_date needs a `format`")
|
|
182
|
+
if name is TransformName.MAP and not isinstance(args.get("values"), dict):
|
|
183
|
+
raise SourceDefinitionError(f"{where}: map needs `values` (an object)")
|
|
184
|
+
if name is TransformName.CONSTANT and "value" not in args:
|
|
185
|
+
raise SourceDefinitionError(f"{where}: constant needs a `value`")
|
|
186
|
+
if name is TransformName.NULL_IF and "value" not in args and "values" not in args:
|
|
187
|
+
raise SourceDefinitionError(f"{where}: null_if needs `value` or `values`")
|
|
188
|
+
if name is TransformName.CONCAT and not isinstance(args.get("columns"), list):
|
|
189
|
+
raise SourceDefinitionError(f"{where}: concat needs `columns` (a list)")
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def apply_transform(transform: Transform, value: Any, row: Row) -> Any:
|
|
193
|
+
"""One transform on a value of `row` (pure)."""
|
|
194
|
+
args = transform.args
|
|
195
|
+
name = transform.name
|
|
196
|
+
if name is TransformName.CONSTANT:
|
|
197
|
+
return args["value"]
|
|
198
|
+
if name is TransformName.CONCAT:
|
|
199
|
+
separator = str(args.get("separator", ""))
|
|
200
|
+
columns = args.get("columns")
|
|
201
|
+
assert isinstance(columns, list)
|
|
202
|
+
parts = [value, *(row.get(str(c)) for c in columns)]
|
|
203
|
+
return separator.join(text_of(p) for p in parts if not _is_empty(p))
|
|
204
|
+
if value is None:
|
|
205
|
+
return None
|
|
206
|
+
if name is TransformName.TRIM:
|
|
207
|
+
return value.strip() if isinstance(value, str) else value
|
|
208
|
+
if name is TransformName.LOWER:
|
|
209
|
+
return text_of(value).lower()
|
|
210
|
+
if name is TransformName.UPPER:
|
|
211
|
+
return text_of(value).upper()
|
|
212
|
+
if name is TransformName.NULL_IF:
|
|
213
|
+
candidates = args.get("values", [args.get("value")])
|
|
214
|
+
assert isinstance(candidates, list)
|
|
215
|
+
return None if text_of(value) in {text_of(c) for c in candidates} else value
|
|
216
|
+
if name is TransformName.MAP:
|
|
217
|
+
values = args["values"]
|
|
218
|
+
assert isinstance(values, dict)
|
|
219
|
+
found = values.get(text_of(value))
|
|
220
|
+
if found is not None:
|
|
221
|
+
return found
|
|
222
|
+
return args.get("default", value)
|
|
223
|
+
if name is TransformName.PARSE_DATE:
|
|
224
|
+
if isinstance(value, datetime):
|
|
225
|
+
return value.date()
|
|
226
|
+
if isinstance(value, date):
|
|
227
|
+
return value
|
|
228
|
+
return datetime.strptime(text_of(value).strip(), str(args["format"])).date()
|
|
229
|
+
return _cast_native(value, str(args["to"]))
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
@dataclass(frozen=True)
|
|
233
|
+
class _AttributeRule:
|
|
234
|
+
attribute: Attribute
|
|
235
|
+
column: str | None
|
|
236
|
+
expression: str | None
|
|
237
|
+
transforms: tuple[Transform, ...]
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
@dataclass(frozen=True)
|
|
241
|
+
class _LinkRule:
|
|
242
|
+
relation: Relation
|
|
243
|
+
columns: tuple[str, ...]
|
|
244
|
+
to_kind: Kind
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
class RowMapper:
|
|
248
|
+
"""Maps rows of one Source onto its target Kind."""
|
|
249
|
+
|
|
250
|
+
def __init__(self, schema: Schema, source: Source, *, limits: ExprLimits | None = None) -> None:
|
|
251
|
+
self._expressions = RowExpressions(schema, limits=limits)
|
|
252
|
+
kind = schema.kinds.get(source.target_kind)
|
|
253
|
+
if kind is None:
|
|
254
|
+
raise SourceDefinitionError(f"unknown target Kind {source.target_kind}")
|
|
255
|
+
if not kind.has_natural_key:
|
|
256
|
+
raise SourceDefinitionError(f"Kind {kind.api_name!r} has no primary key")
|
|
257
|
+
if len(source.key_mapping) != len(kind.primary_key):
|
|
258
|
+
raise SourceDefinitionError(
|
|
259
|
+
f"key_mapping has {len(source.key_mapping)} columns, the primary key of "
|
|
260
|
+
f"{kind.api_name!r} has {len(kind.primary_key)}"
|
|
261
|
+
)
|
|
262
|
+
self.source = source
|
|
263
|
+
self.kind = kind
|
|
264
|
+
self._keys = [
|
|
265
|
+
(kind.attributes[attribute_id], column)
|
|
266
|
+
for attribute_id, column in zip(kind.primary_key, source.key_mapping, strict=True)
|
|
267
|
+
]
|
|
268
|
+
self._rules = self._attribute_rules(kind, source)
|
|
269
|
+
self._links = self._link_rules(schema, kind, source)
|
|
270
|
+
|
|
271
|
+
@staticmethod
|
|
272
|
+
def _attribute_rules(kind: Kind, source: Source) -> list[_AttributeRule]:
|
|
273
|
+
rules = []
|
|
274
|
+
for attribute_id, mapping in source.attribute_mapping.items():
|
|
275
|
+
attribute = kind.attributes.get(attribute_id)
|
|
276
|
+
if attribute is None:
|
|
277
|
+
raise SourceDefinitionError(f"{attribute_id} is not an Attribute of the Kind")
|
|
278
|
+
if attribute.is_derived:
|
|
279
|
+
raise SourceDefinitionError(f"derived Attribute {attribute.api_name!r} mapped")
|
|
280
|
+
if attribute_id in kind.primary_key:
|
|
281
|
+
continue # primary key Attributes come from key_mapping
|
|
282
|
+
for transform in mapping.transforms:
|
|
283
|
+
_check_transform(transform, attribute.api_name)
|
|
284
|
+
rules.append(
|
|
285
|
+
_AttributeRule(attribute, mapping.column, mapping.expression, mapping.transforms)
|
|
286
|
+
)
|
|
287
|
+
return rules
|
|
288
|
+
|
|
289
|
+
@staticmethod
|
|
290
|
+
def _link_rules(schema: Schema, kind: Kind, source: Source) -> list[_LinkRule]:
|
|
291
|
+
rules = []
|
|
292
|
+
for mapping in source.relation_mapping:
|
|
293
|
+
relation = schema.relations.get(mapping.relation)
|
|
294
|
+
if relation is None:
|
|
295
|
+
raise SourceDefinitionError(f"unknown Relation {mapping.relation}")
|
|
296
|
+
if relation.backing is not RelationBacking.EXPLICIT:
|
|
297
|
+
raise SourceDefinitionError(
|
|
298
|
+
f"Relation {relation.api_name!r} derives its Links from an Attribute; map "
|
|
299
|
+
"the Attribute instead"
|
|
300
|
+
)
|
|
301
|
+
if relation.from_kind != kind.id:
|
|
302
|
+
raise SourceDefinitionError(
|
|
303
|
+
f"Relation {relation.api_name!r} does not start at Kind {kind.api_name!r}"
|
|
304
|
+
)
|
|
305
|
+
to_kind = schema.kinds.get(relation.to_kind)
|
|
306
|
+
if to_kind is None or not to_kind.has_natural_key:
|
|
307
|
+
raise SourceDefinitionError(
|
|
308
|
+
f"the target Kind of Relation {relation.api_name!r} has no primary key"
|
|
309
|
+
)
|
|
310
|
+
if len(mapping.columns) != len(to_kind.primary_key):
|
|
311
|
+
raise SourceDefinitionError(
|
|
312
|
+
f"Relation {relation.api_name!r}: {len(mapping.columns)} columns for a "
|
|
313
|
+
f"primary key of {len(to_kind.primary_key)}"
|
|
314
|
+
)
|
|
315
|
+
rules.append(_LinkRule(relation, mapping.columns, to_kind))
|
|
316
|
+
return rules
|
|
317
|
+
|
|
318
|
+
@property
|
|
319
|
+
def owned_attributes(self) -> frozenset[str]:
|
|
320
|
+
"""Attributes the Source writes: its mapped ones and the primary key."""
|
|
321
|
+
return frozenset([*(rule.attribute.id for rule in self._rules), *self.kind.primary_key])
|
|
322
|
+
|
|
323
|
+
@property
|
|
324
|
+
def relations(self) -> tuple[str, ...]:
|
|
325
|
+
"""Ids of the mapped Relations."""
|
|
326
|
+
return tuple(rule.relation.id for rule in self._links)
|
|
327
|
+
|
|
328
|
+
@property
|
|
329
|
+
def columns(self) -> frozenset[str]:
|
|
330
|
+
"""Every column the mapping reads."""
|
|
331
|
+
names = {column for _, column in self._keys}
|
|
332
|
+
names.update(rule.column for rule in self._rules if rule.column is not None)
|
|
333
|
+
for rule in self._rules:
|
|
334
|
+
for transform in rule.transforms:
|
|
335
|
+
if transform.name is TransformName.CONCAT:
|
|
336
|
+
extra = transform.args.get("columns")
|
|
337
|
+
assert isinstance(extra, list)
|
|
338
|
+
names.update(str(c) for c in extra)
|
|
339
|
+
for link in self._links:
|
|
340
|
+
names.update(link.columns)
|
|
341
|
+
return frozenset(names)
|
|
342
|
+
|
|
343
|
+
@property
|
|
344
|
+
def has_expressions(self) -> bool:
|
|
345
|
+
"""Whether an Attribute is mapped by an expression."""
|
|
346
|
+
return any(rule.expression is not None for rule in self._rules)
|
|
347
|
+
|
|
348
|
+
def use_columns(self, columns: Sequence[str]) -> None:
|
|
349
|
+
"""Declare the Source's columns, which expressions are compiled over. Columns of a row
|
|
350
|
+
not declared yet are added when it is mapped."""
|
|
351
|
+
self._expressions.declare(columns)
|
|
352
|
+
|
|
353
|
+
def check_columns(self, available: Sequence[str]) -> None:
|
|
354
|
+
"""Refuse a source whose header lacks columns the mapping reads."""
|
|
355
|
+
missing = sorted(self.columns - set(available))
|
|
356
|
+
if missing:
|
|
357
|
+
raise SourceDefinitionError(f"columns missing from the source: {', '.join(missing)}")
|
|
358
|
+
|
|
359
|
+
def _key(self, row: Row, locator: str) -> tuple[str, dict[str, JsonValue]]:
|
|
360
|
+
values: dict[str, JsonValue] = {}
|
|
361
|
+
for attribute, column in self._keys:
|
|
362
|
+
raw = row.get(column)
|
|
363
|
+
if _is_empty(raw):
|
|
364
|
+
raise RowError(locator, f"key column {column!r} is empty", column=column)
|
|
365
|
+
try:
|
|
366
|
+
values[attribute.id] = coerce(
|
|
367
|
+
raw.strip() if isinstance(raw, str) else raw, attribute.type
|
|
368
|
+
)
|
|
369
|
+
except (TypeSystemError, ValueError) as error:
|
|
370
|
+
raise RowError(locator, f"key {column!r}: {error}", column=column) from None
|
|
371
|
+
key = record_key(self.kind, values)
|
|
372
|
+
if key is None:
|
|
373
|
+
raise RowError(locator, "the key cannot be derived")
|
|
374
|
+
return key, values
|
|
375
|
+
|
|
376
|
+
def _input(self, rule: _AttributeRule, row: Row) -> Any:
|
|
377
|
+
"""The value a rule starts from: its column, or the expression over the row."""
|
|
378
|
+
if rule.expression is None:
|
|
379
|
+
return row.get(str(rule.column))
|
|
380
|
+
return self._expressions.evaluate(rule.expression, row, now=datetime.now(UTC))
|
|
381
|
+
|
|
382
|
+
def map(self, row: Row, locator: str) -> MappedRow:
|
|
383
|
+
"""Map one row; raises `RowError` when a value does not fit."""
|
|
384
|
+
key, values = self._key(row, locator)
|
|
385
|
+
tombstone_column = self.source.tombstone_column
|
|
386
|
+
if tombstone_column is not None and not _is_empty(row.get(tombstone_column)):
|
|
387
|
+
return MappedRow(key=key, values=values, locator=locator, tombstone=True)
|
|
388
|
+
for rule in self._rules:
|
|
389
|
+
try:
|
|
390
|
+
value = self._input(rule, row)
|
|
391
|
+
for transform in rule.transforms:
|
|
392
|
+
value = apply_transform(transform, value, row)
|
|
393
|
+
values[rule.attribute.id] = coerce(value, rule.attribute.type)
|
|
394
|
+
except (
|
|
395
|
+
TypeSystemError,
|
|
396
|
+
ValueError,
|
|
397
|
+
InvalidOperation,
|
|
398
|
+
ArithmeticError,
|
|
399
|
+
ExprError,
|
|
400
|
+
) as error:
|
|
401
|
+
raise RowError(
|
|
402
|
+
locator, f"{rule.attribute.api_name}: {error}", column=rule.column
|
|
403
|
+
) from None
|
|
404
|
+
links: dict[str, frozenset[str]] = {}
|
|
405
|
+
for link in self._links:
|
|
406
|
+
raw = [row.get(column) for column in link.columns]
|
|
407
|
+
if all(_is_empty(v) for v in raw):
|
|
408
|
+
links[link.relation.id] = frozenset()
|
|
409
|
+
continue
|
|
410
|
+
if any(_is_empty(v) for v in raw):
|
|
411
|
+
raise RowError(locator, f"{link.relation.api_name}: incomplete target key")
|
|
412
|
+
target: dict[str, JsonValue] = {}
|
|
413
|
+
try:
|
|
414
|
+
for attribute_id, value in zip(link.to_kind.primary_key, raw, strict=True):
|
|
415
|
+
target[attribute_id] = coerce(value, link.to_kind.attributes[attribute_id].type)
|
|
416
|
+
except (TypeSystemError, ValueError) as error:
|
|
417
|
+
raise RowError(locator, f"{link.relation.api_name}: {error}") from None
|
|
418
|
+
to_key = record_key(link.to_kind, target)
|
|
419
|
+
if to_key is None:
|
|
420
|
+
raise RowError(locator, f"{link.relation.api_name}: no target key")
|
|
421
|
+
links[link.relation.id] = frozenset({to_key})
|
|
422
|
+
return MappedRow(key=key, values=values, links=links, locator=locator)
|
|
423
|
+
|
|
424
|
+
def record(self, mapped: MappedRow, current: Mapping[str, JsonValue] | None) -> Record:
|
|
425
|
+
"""The new Record: values of Attributes the Source does not own are kept."""
|
|
426
|
+
owned = self.owned_attributes
|
|
427
|
+
kept = {a: v for a, v in (current or {}).items() if a not in owned}
|
|
428
|
+
fresh = {a: v for a, v in mapped.values.items() if v is not None}
|
|
429
|
+
return Record(kind=self.kind.id, key=mapped.key, values={**kept, **fresh})
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Profiles of Sources for schema inference (`lorebase_core.infer`), through their connectors.
|
|
2
|
+
|
|
3
|
+
Any Source a connector reads (CSV, JSON, JSON Lines, Parquet, HTTP JSON, Postgres, MySQL, SQL
|
|
4
|
+
Server) becomes a `TableProfile` named after the last segment of its api name: every row
|
|
5
|
+
is read and counted, `store.profiling.records` decides which rows are kept, and the columns are the
|
|
6
|
+
connector's header followed by every other key seen. The connection rules and limits are those of a
|
|
7
|
+
synchronization; no watermark applies.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from lorebase_core.infer import TableProfile
|
|
13
|
+
from lorebase_core.model import Source
|
|
14
|
+
from lorebase_scribe.settings import SourceSettings
|
|
15
|
+
from lorebase_scribe.sources.connectors import ConnectorFactory, default_connector
|
|
16
|
+
from lorebase_scribe.sources.mapping import SourceDefinitionError
|
|
17
|
+
from lorebase_store.profiling import RecordProfileError, RecordProfiler
|
|
18
|
+
from lorebase_store.settings import RecordProfilingSettings
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
async def profile_source(
|
|
22
|
+
source: Source,
|
|
23
|
+
settings: SourceSettings,
|
|
24
|
+
profiling: RecordProfilingSettings,
|
|
25
|
+
connectors: ConnectorFactory = default_connector,
|
|
26
|
+
) -> TableProfile:
|
|
27
|
+
"""The profile of every row of `source`."""
|
|
28
|
+
connector = connectors(source, settings)
|
|
29
|
+
profiler = RecordProfiler(profiling)
|
|
30
|
+
name = source.api_name.rsplit(".", 1)[-1]
|
|
31
|
+
try:
|
|
32
|
+
async for _, row in connector.rows(None):
|
|
33
|
+
profiler.add(row)
|
|
34
|
+
return profiler.profile(name, connector.columns)
|
|
35
|
+
except RecordProfileError as error:
|
|
36
|
+
raise SourceDefinitionError(f"{name}: {error}") from None
|