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.
Files changed (41) hide show
  1. lorebase_scribe/__init__.py +3 -0
  2. lorebase_scribe/__main__.py +156 -0
  3. lorebase_scribe/checkpoints.py +324 -0
  4. lorebase_scribe/cron.py +117 -0
  5. lorebase_scribe/embeddings.py +570 -0
  6. lorebase_scribe/extraction/__init__.py +17 -0
  7. lorebase_scribe/extraction/assemble.py +339 -0
  8. lorebase_scribe/extraction/candidates.py +136 -0
  9. lorebase_scribe/extraction/discovery.py +567 -0
  10. lorebase_scribe/extraction/llm_discovery.py +271 -0
  11. lorebase_scribe/extraction/matching.py +285 -0
  12. lorebase_scribe/extraction/ontology.py +432 -0
  13. lorebase_scribe/extraction/parsing.py +114 -0
  14. lorebase_scribe/extraction/pipeline.py +1091 -0
  15. lorebase_scribe/extraction/relations.py +194 -0
  16. lorebase_scribe/extraction/sandbox.py +269 -0
  17. lorebase_scribe/extraction/settings.py +293 -0
  18. lorebase_scribe/extraction/text.py +179 -0
  19. lorebase_scribe/extraction/tier2.py +573 -0
  20. lorebase_scribe/extraction/values.py +282 -0
  21. lorebase_scribe/jobs.py +418 -0
  22. lorebase_scribe/scheduler.py +318 -0
  23. lorebase_scribe/service.py +146 -0
  24. lorebase_scribe/settings.py +521 -0
  25. lorebase_scribe/sources/__init__.py +60 -0
  26. lorebase_scribe/sources/access.py +241 -0
  27. lorebase_scribe/sources/connectors.py +320 -0
  28. lorebase_scribe/sources/delta.py +175 -0
  29. lorebase_scribe/sources/http_json.py +283 -0
  30. lorebase_scribe/sources/mapping.py +429 -0
  31. lorebase_scribe/sources/profile.py +36 -0
  32. lorebase_scribe/sources/records.py +298 -0
  33. lorebase_scribe/sources/sql.py +332 -0
  34. lorebase_scribe/sources/sync.py +730 -0
  35. lorebase_scribe/telemetry.py +113 -0
  36. lorebase_scribe/webhooks.py +99 -0
  37. lorebase_scribe/worker.py +254 -0
  38. lorebase_scribe-0.2.3.dist-info/METADATA +23 -0
  39. lorebase_scribe-0.2.3.dist-info/RECORD +41 -0
  40. lorebase_scribe-0.2.3.dist-info/WHEEL +4 -0
  41. 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