lorebase-core 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 (94) hide show
  1. lorebase_core/__init__.py +1 -0
  2. lorebase_core/canonical/__init__.py +35 -0
  3. lorebase_core/canonical/hashing.py +128 -0
  4. lorebase_core/canonical/jcs.py +162 -0
  5. lorebase_core/expr/__init__.py +118 -0
  6. lorebase_core/expr/ast.py +443 -0
  7. lorebase_core/expr/checker.py +428 -0
  8. lorebase_core/expr/compiler.py +87 -0
  9. lorebase_core/expr/context.py +82 -0
  10. lorebase_core/expr/derivations.py +306 -0
  11. lorebase_core/expr/environment.py +176 -0
  12. lorebase_core/expr/errors.py +98 -0
  13. lorebase_core/expr/evaluator.py +414 -0
  14. lorebase_core/expr/functions.py +898 -0
  15. lorebase_core/expr/limits.py +52 -0
  16. lorebase_core/expr/operations.py +479 -0
  17. lorebase_core/expr/ops.py +243 -0
  18. lorebase_core/expr/resolver.py +96 -0
  19. lorebase_core/expr/rows.py +148 -0
  20. lorebase_core/expr/sensitivity.py +337 -0
  21. lorebase_core/expr/types.py +227 -0
  22. lorebase_core/expr/validation.py +51 -0
  23. lorebase_core/expr/values.py +169 -0
  24. lorebase_core/expr/visibility.py +363 -0
  25. lorebase_core/infer/__init__.py +36 -0
  26. lorebase_core/infer/columns.py +621 -0
  27. lorebase_core/infer/engine.py +546 -0
  28. lorebase_core/infer/keys.py +164 -0
  29. lorebase_core/infer/names.py +145 -0
  30. lorebase_core/infer/options.py +290 -0
  31. lorebase_core/infer/profile.py +54 -0
  32. lorebase_core/infer/proposal.py +122 -0
  33. lorebase_core/infer/references.py +314 -0
  34. lorebase_core/infer/suggestions.py +731 -0
  35. lorebase_core/infer/tables.py +81 -0
  36. lorebase_core/merge/__init__.py +89 -0
  37. lorebase_core/merge/conflicts.py +262 -0
  38. lorebase_core/merge/consistency.py +440 -0
  39. lorebase_core/merge/data_merge.py +522 -0
  40. lorebase_core/merge/engine.py +476 -0
  41. lorebase_core/merge/index.py +100 -0
  42. lorebase_core/merge/objects.py +204 -0
  43. lorebase_core/merge/policies.py +322 -0
  44. lorebase_core/merge/resolve.py +321 -0
  45. lorebase_core/merge/schema_merge.py +429 -0
  46. lorebase_core/merge/state.py +443 -0
  47. lorebase_core/model/__init__.py +245 -0
  48. lorebase_core/model/attribute.py +72 -0
  49. lorebase_core/model/base.py +61 -0
  50. lorebase_core/model/derivation.py +95 -0
  51. lorebase_core/model/domain.py +71 -0
  52. lorebase_core/model/embedding.py +193 -0
  53. lorebase_core/model/ids.py +78 -0
  54. lorebase_core/model/kind.py +85 -0
  55. lorebase_core/model/lineage.py +114 -0
  56. lorebase_core/model/operation.py +161 -0
  57. lorebase_core/model/paths.py +141 -0
  58. lorebase_core/model/record.py +127 -0
  59. lorebase_core/model/relation.py +83 -0
  60. lorebase_core/model/schema.py +219 -0
  61. lorebase_core/model/source.py +204 -0
  62. lorebase_core/model/trait.py +37 -0
  63. lorebase_core/model/vault.py +43 -0
  64. lorebase_core/model/yaml_schema.py +1058 -0
  65. lorebase_core/query/__init__.py +97 -0
  66. lorebase_core/query/dsl.py +227 -0
  67. lorebase_core/query/resolve.py +794 -0
  68. lorebase_core/revision/__init__.py +59 -0
  69. lorebase_core/revision/ancestry.py +272 -0
  70. lorebase_core/revision/changeset.py +62 -0
  71. lorebase_core/revision/revision.py +121 -0
  72. lorebase_core/tree/__init__.py +76 -0
  73. lorebase_core/tree/diff.py +119 -0
  74. lorebase_core/tree/hamt.py +333 -0
  75. lorebase_core/tree/node.py +280 -0
  76. lorebase_core/tree/params.py +33 -0
  77. lorebase_core/tree/root.py +70 -0
  78. lorebase_core/tree/store.py +98 -0
  79. lorebase_core/types/__init__.py +71 -0
  80. lorebase_core/types/casts.py +271 -0
  81. lorebase_core/types/constraints.py +299 -0
  82. lorebase_core/types/encoding.py +538 -0
  83. lorebase_core/types/errors.py +41 -0
  84. lorebase_core/types/matching.py +40 -0
  85. lorebase_core/types/types.py +187 -0
  86. lorebase_core/validate/__init__.py +49 -0
  87. lorebase_core/validate/changes.py +70 -0
  88. lorebase_core/validate/changeset.py +551 -0
  89. lorebase_core/validate/data_rules.py +291 -0
  90. lorebase_core/validate/issues.py +123 -0
  91. lorebase_core/validate/schema_rules.py +1304 -0
  92. lorebase_core-0.2.3.dist-info/METADATA +9 -0
  93. lorebase_core-0.2.3.dist-info/RECORD +94 -0
  94. lorebase_core-0.2.3.dist-info/WHEEL +4 -0
@@ -0,0 +1,538 @@
1
+ """Canonical value encoding (design 6.1).
2
+
3
+ This module is the single source of truth for how a value of each Attribute type is
4
+ represented in canonical content:
5
+
6
+ - ``integer`` and ``decimal``: JSON strings; decimals normalized to the declared scale.
7
+ - ``float``: a finite JSON number (negative zero becomes zero).
8
+ - ``timestamp``: RFC 3339 UTC with exactly six fractional digits and a ``Z`` suffix.
9
+ - ``date``: ``YYYY-MM-DD``.
10
+ - ``duration``: ``[-]P{d}DT{h}H{m}M{s}.{ffffff}S`` with every component present.
11
+ - ``geo_point``: ``{"lat": float, "lon": float}``.
12
+ - strings: NFC-normalized; NUL and unpaired surrogates are rejected.
13
+ - ``json``: any JSON value with NFC strings and integers within the IEEE 754 exact range.
14
+ - ``array<T>``: an ordered list of canonical ``T`` values (no nulls).
15
+ - null and absent are the same: ``None`` encodes to ``None`` (omitted from content).
16
+
17
+ ``encode_value`` accepts API values (Python natives or their string forms) and returns the
18
+ canonical value. ``decode_value`` turns a canonical value back into a Python native.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import math
24
+ import re
25
+ import unicodedata
26
+ from collections.abc import Callable, Mapping
27
+ from datetime import UTC, date, datetime, timedelta, timezone
28
+ from decimal import Decimal, DecimalException, localcontext
29
+
30
+ from lorebase_core.types.errors import EncodingError, EncodingReason, TypeSystemError
31
+ from lorebase_core.types.types import (
32
+ INT64_MAX,
33
+ INT64_MIN,
34
+ JSON_SAFE_INTEGER_MAX,
35
+ AttributeType,
36
+ CanonicalValue,
37
+ GeoPoint,
38
+ TypeSpec,
39
+ )
40
+
41
+ _INTEGER_RE = re.compile(r"[+-]?[0-9]+")
42
+ _NUMBER_RE = re.compile(r"[+-]?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)(?:[eE][+-]?[0-9]+)?")
43
+ _DATE_RE = re.compile(r"[0-9]{4}-[0-9]{2}-[0-9]{2}")
44
+ _TIMESTAMP_RE = re.compile(
45
+ r"([0-9]{4})-([0-9]{2})-([0-9]{2})[Tt]([0-9]{2}):([0-9]{2}):([0-9]{2})"
46
+ r"(?:\.([0-9]+))?([Zz]|[+-][0-9]{2}:[0-9]{2})"
47
+ )
48
+ _DURATION_RE = re.compile(
49
+ r"(-)?P(?:([0-9]+)D)?(?:T(?:([0-9]+)H)?(?:([0-9]+)M)?(?:([0-9]+)(?:\.([0-9]+))?S)?)?"
50
+ )
51
+ _URI_RE = re.compile(r"[A-Za-z][A-Za-z0-9+.\-]*:\S*")
52
+
53
+ _MICROS_PER_SECOND = 1_000_000
54
+ _MICROS_PER_MINUTE = 60 * _MICROS_PER_SECOND
55
+ _MICROS_PER_HOUR = 60 * _MICROS_PER_MINUTE
56
+ _MICROS_PER_DAY = 24 * _MICROS_PER_HOUR
57
+ _FRACTION_DIGITS = 6
58
+ #: Digits beyond which an integer string cannot be an int64 (avoids parsing huge strings).
59
+ _INT64_MAX_DIGITS = len(str(INT64_MAX))
60
+ _GEO_LAT_RANGE = (-90.0, 90.0)
61
+ _GEO_LON_RANGE = (-180.0, 180.0)
62
+
63
+
64
+ def _fail(spec_or_name: TypeSpec | str, reason: EncodingReason, message: str) -> EncodingError:
65
+ name = spec_or_name.describe() if isinstance(spec_or_name, TypeSpec) else spec_or_name
66
+ return EncodingError(name, reason, message)
67
+
68
+
69
+ # Text -------------------------------------------------------------------------
70
+
71
+
72
+ def normalize_text(raw: object, type_name: str = "string") -> str:
73
+ """NFC-normalize a string; reject non-strings, NUL characters and unpaired surrogates."""
74
+ if not isinstance(raw, str):
75
+ raise _fail(
76
+ type_name, EncodingReason.WRONG_TYPE, f"expected a string, got {type(raw).__name__}"
77
+ )
78
+ try:
79
+ raw.encode("utf-8")
80
+ except UnicodeEncodeError as exc:
81
+ raise _fail(
82
+ type_name, EncodingReason.INVALID_TEXT, "string contains unpaired surrogates"
83
+ ) from exc
84
+ if "\x00" in raw:
85
+ raise _fail(type_name, EncodingReason.INVALID_TEXT, "string contains a NUL character")
86
+ return unicodedata.normalize("NFC", raw)
87
+
88
+
89
+ def normalize_key(raw: object, type_name: str = "key") -> str:
90
+ """Normalize a Record key (design 5.1): non-empty NFC text without control characters."""
91
+ text = normalize_text(raw, type_name)
92
+ if text == "":
93
+ raise _fail(type_name, EncodingReason.FORMAT, "key must not be empty")
94
+ if any(unicodedata.category(ch) == "Cc" for ch in text):
95
+ raise _fail(
96
+ type_name, EncodingReason.INVALID_TEXT, "key must not contain control characters"
97
+ )
98
+ return text
99
+
100
+
101
+ # Numbers ----------------------------------------------------------------------
102
+
103
+
104
+ def _encode_integer(spec: TypeSpec, raw: object) -> str:
105
+ if isinstance(raw, bool):
106
+ raise _fail(spec, EncodingReason.WRONG_TYPE, "booleans are not integers")
107
+ if isinstance(raw, int):
108
+ value = raw
109
+ elif isinstance(raw, str):
110
+ if not _INTEGER_RE.fullmatch(raw):
111
+ raise _fail(spec, EncodingReason.FORMAT, f"not an integer: {raw!r}")
112
+ if len(raw.lstrip("+-").lstrip("0")) > _INT64_MAX_DIGITS:
113
+ raise _fail(spec, EncodingReason.OUT_OF_RANGE, "integer outside the int64 range")
114
+ value = int(raw)
115
+ elif isinstance(raw, Decimal):
116
+ if not raw.is_finite() or raw != raw.to_integral_value():
117
+ raise _fail(spec, EncodingReason.FORMAT, f"not an integer: {raw}")
118
+ if not raw.is_zero() and raw.adjusted() >= _INT64_MAX_DIGITS:
119
+ raise _fail(spec, EncodingReason.OUT_OF_RANGE, "integer outside the int64 range")
120
+ value = int(raw)
121
+ else:
122
+ raise _fail(
123
+ spec, EncodingReason.WRONG_TYPE, f"expected an integer, got {type(raw).__name__}"
124
+ )
125
+ if not INT64_MIN <= value <= INT64_MAX:
126
+ raise _fail(spec, EncodingReason.OUT_OF_RANGE, "integer outside the int64 range")
127
+ return str(value)
128
+
129
+
130
+ def to_decimal(raw: object, type_name: str = "decimal") -> Decimal:
131
+ """Parse a number (int, float, Decimal or numeric string) into an exact finite Decimal."""
132
+ if isinstance(raw, bool):
133
+ raise _fail(type_name, EncodingReason.WRONG_TYPE, "booleans are not numbers")
134
+ if isinstance(raw, int):
135
+ return Decimal(raw)
136
+ if isinstance(raw, float):
137
+ if not math.isfinite(raw):
138
+ raise _fail(type_name, EncodingReason.OUT_OF_RANGE, "number must be finite")
139
+ return Decimal(repr(raw))
140
+ if isinstance(raw, Decimal):
141
+ if not raw.is_finite():
142
+ raise _fail(type_name, EncodingReason.OUT_OF_RANGE, "number must be finite")
143
+ return raw
144
+ if isinstance(raw, str):
145
+ if not _NUMBER_RE.fullmatch(raw):
146
+ raise _fail(type_name, EncodingReason.FORMAT, f"not a number: {raw!r}")
147
+ try:
148
+ return Decimal(raw)
149
+ except DecimalException as exc:
150
+ raise _fail(type_name, EncodingReason.FORMAT, f"not a number: {raw!r}") from exc
151
+ raise _fail(
152
+ type_name, EncodingReason.WRONG_TYPE, f"expected a number, got {type(raw).__name__}"
153
+ )
154
+
155
+
156
+ def _encode_decimal(spec: TypeSpec, raw: object) -> str:
157
+ precision, scale = spec.precision, spec.scale
158
+ assert precision is not None
159
+ assert scale is not None
160
+ value = to_decimal(raw, spec.describe())
161
+ if not value.is_zero() and value.adjusted() + 1 > precision - scale:
162
+ raise _fail(
163
+ spec,
164
+ EncodingReason.OUT_OF_RANGE,
165
+ f"{value} has more than {precision - scale} integer digits",
166
+ )
167
+ with localcontext() as ctx:
168
+ ctx.prec = max(precision, len(value.as_tuple().digits)) + 2
169
+ quantized = value.quantize(Decimal(1).scaleb(-scale))
170
+ if quantized != value:
171
+ raise _fail(
172
+ spec, EncodingReason.PRECISION, f"{value} has more than {scale} fractional digits"
173
+ )
174
+ if quantized.is_zero():
175
+ quantized = abs(quantized)
176
+ return format(quantized, "f")
177
+
178
+
179
+ def _encode_float(spec: TypeSpec | str, raw: object) -> float:
180
+ if isinstance(raw, bool):
181
+ raise _fail(spec, EncodingReason.WRONG_TYPE, "booleans are not numbers")
182
+ if isinstance(raw, float):
183
+ value = raw
184
+ elif isinstance(raw, int):
185
+ try:
186
+ value = float(raw)
187
+ except OverflowError as exc:
188
+ raise _fail(spec, EncodingReason.OUT_OF_RANGE, "integer too large for float") from exc
189
+ if int(value) != raw:
190
+ raise _fail(
191
+ spec, EncodingReason.PRECISION, f"{raw} is not exactly representable as float"
192
+ )
193
+ elif isinstance(raw, Decimal | str):
194
+ name = spec.describe() if isinstance(spec, TypeSpec) else spec
195
+ value = float(to_decimal(raw, name))
196
+ else:
197
+ raise _fail(spec, EncodingReason.WRONG_TYPE, f"expected a number, got {type(raw).__name__}")
198
+ if not math.isfinite(value):
199
+ raise _fail(spec, EncodingReason.OUT_OF_RANGE, "float must be finite")
200
+ if value == 0.0:
201
+ value = 0.0
202
+ return value
203
+
204
+
205
+ # Dates and times --------------------------------------------------------------
206
+
207
+
208
+ def format_date(value: date) -> str:
209
+ return f"{value.year:04d}-{value.month:02d}-{value.day:02d}"
210
+
211
+
212
+ def format_timestamp(value: datetime) -> str:
213
+ """Format an aware datetime as canonical RFC 3339 UTC with six fractional digits."""
214
+ utc = value.astimezone(UTC)
215
+ clock = f"{utc.hour:02d}:{utc.minute:02d}:{utc.second:02d}.{utc.microsecond:06d}"
216
+ return f"{format_date(utc)}T{clock}Z"
217
+
218
+
219
+ def format_duration(value: timedelta) -> str:
220
+ total = (value.days * 86_400 + value.seconds) * _MICROS_PER_SECOND + value.microseconds
221
+ sign = "-" if total < 0 else ""
222
+ rest = abs(total)
223
+ days, rest = divmod(rest, _MICROS_PER_DAY)
224
+ hours, rest = divmod(rest, _MICROS_PER_HOUR)
225
+ minutes, rest = divmod(rest, _MICROS_PER_MINUTE)
226
+ seconds, micros = divmod(rest, _MICROS_PER_SECOND)
227
+ return f"{sign}P{days}DT{hours}H{minutes}M{seconds}.{micros:06d}S"
228
+
229
+
230
+ def _parse_fraction(spec: TypeSpec, digits: str | None) -> int:
231
+ if not digits:
232
+ return 0
233
+ if len(digits) > _FRACTION_DIGITS and digits[_FRACTION_DIGITS:].strip("0"):
234
+ raise _fail(spec, EncodingReason.PRECISION, "precision finer than microseconds")
235
+ return int(digits[:_FRACTION_DIGITS].ljust(_FRACTION_DIGITS, "0"))
236
+
237
+
238
+ def _encode_date(spec: TypeSpec, raw: object) -> str:
239
+ if isinstance(raw, datetime):
240
+ raise _fail(spec, EncodingReason.WRONG_TYPE, "expected a date, got a datetime")
241
+ if isinstance(raw, date):
242
+ return format_date(raw)
243
+ if not isinstance(raw, str):
244
+ raise _fail(spec, EncodingReason.WRONG_TYPE, f"expected a date, got {type(raw).__name__}")
245
+ if not _DATE_RE.fullmatch(raw):
246
+ raise _fail(spec, EncodingReason.FORMAT, f"not a YYYY-MM-DD date: {raw!r}")
247
+ try:
248
+ return format_date(date.fromisoformat(raw))
249
+ except ValueError as exc:
250
+ raise _fail(spec, EncodingReason.FORMAT, f"invalid date: {raw!r}") from exc
251
+
252
+
253
+ def _parse_timestamp(spec: TypeSpec, raw: str) -> datetime:
254
+ match = _TIMESTAMP_RE.fullmatch(raw)
255
+ if match is None:
256
+ raise _fail(spec, EncodingReason.FORMAT, f"not an RFC 3339 timestamp with offset: {raw!r}")
257
+ year, month, day, hour, minute, second = (int(match.group(i)) for i in range(1, 7))
258
+ micros = _parse_fraction(spec, match.group(7))
259
+ offset_text = match.group(8)
260
+ if offset_text in ("Z", "z"):
261
+ tz = UTC
262
+ else:
263
+ offset_hours, offset_minutes = int(offset_text[1:3]), int(offset_text[4:6])
264
+ if offset_hours > 23 or offset_minutes > 59:
265
+ raise _fail(spec, EncodingReason.FORMAT, f"invalid UTC offset: {offset_text}")
266
+ delta = timedelta(hours=offset_hours, minutes=offset_minutes)
267
+ tz = timezone(-delta if offset_text[0] == "-" else delta)
268
+ try:
269
+ return datetime(year, month, day, hour, minute, second, micros, tzinfo=tz)
270
+ except ValueError as exc:
271
+ raise _fail(spec, EncodingReason.FORMAT, f"invalid timestamp: {raw!r}") from exc
272
+
273
+
274
+ def _encode_timestamp(spec: TypeSpec, raw: object) -> str:
275
+ if isinstance(raw, datetime):
276
+ value = raw
277
+ if value.tzinfo is None or value.utcoffset() is None:
278
+ raise _fail(spec, EncodingReason.FORMAT, "timestamp must carry a UTC offset")
279
+ elif isinstance(raw, str):
280
+ value = _parse_timestamp(spec, raw)
281
+ else:
282
+ raise _fail(
283
+ spec, EncodingReason.WRONG_TYPE, f"expected a timestamp, got {type(raw).__name__}"
284
+ )
285
+ try:
286
+ return format_timestamp(value)
287
+ except OverflowError as exc:
288
+ raise _fail(
289
+ spec, EncodingReason.OUT_OF_RANGE, "timestamp outside the supported range in UTC"
290
+ ) from exc
291
+
292
+
293
+ def _parse_duration(spec: TypeSpec, raw: str) -> timedelta:
294
+ match = _DURATION_RE.fullmatch(raw)
295
+ if match is None or all(match.group(i) is None for i in range(2, 6)):
296
+ raise _fail(
297
+ spec, EncodingReason.FORMAT, f"not an ISO 8601 duration (days to seconds): {raw!r}"
298
+ )
299
+ if "T" in raw and all(match.group(i) is None for i in range(3, 6)):
300
+ raise _fail(spec, EncodingReason.FORMAT, f"duration has an empty time part: {raw!r}")
301
+ parts = [match.group(i) or "0" for i in range(2, 6)]
302
+ if any(len(part) > _INT64_MAX_DIGITS for part in parts):
303
+ raise _fail(spec, EncodingReason.OUT_OF_RANGE, "duration outside the supported range")
304
+ days, hours, minutes, seconds = (int(part) for part in parts)
305
+ total = (
306
+ days * _MICROS_PER_DAY
307
+ + hours * _MICROS_PER_HOUR
308
+ + minutes * _MICROS_PER_MINUTE
309
+ + seconds * _MICROS_PER_SECOND
310
+ + _parse_fraction(spec, match.group(6))
311
+ )
312
+ if match.group(1):
313
+ total = -total
314
+ try:
315
+ return timedelta(microseconds=total)
316
+ except OverflowError as exc:
317
+ raise _fail(
318
+ spec, EncodingReason.OUT_OF_RANGE, "duration outside the supported range"
319
+ ) from exc
320
+
321
+
322
+ def _encode_duration(spec: TypeSpec, raw: object) -> str:
323
+ if isinstance(raw, timedelta):
324
+ return format_duration(raw)
325
+ if isinstance(raw, str):
326
+ return format_duration(_parse_duration(spec, raw))
327
+ raise _fail(spec, EncodingReason.WRONG_TYPE, f"expected a duration, got {type(raw).__name__}")
328
+
329
+
330
+ # Structured values ------------------------------------------------------------
331
+
332
+
333
+ def _encode_geo_point(spec: TypeSpec, raw: object) -> dict[str, CanonicalValue]:
334
+ if isinstance(raw, Mapping):
335
+ if set(raw.keys()) != {"lat", "lon"}:
336
+ raise _fail(spec, EncodingReason.FORMAT, "geo_point needs exactly the keys lat and lon")
337
+ lat_raw, lon_raw = raw["lat"], raw["lon"]
338
+ elif isinstance(raw, list | tuple) and len(raw) == 2:
339
+ lat_raw, lon_raw = raw
340
+ else:
341
+ raise _fail(spec, EncodingReason.WRONG_TYPE, "expected {lat, lon} or a (lat, lon) pair")
342
+ lat = _encode_float(spec, lat_raw)
343
+ lon = _encode_float(spec, lon_raw)
344
+ if not _GEO_LAT_RANGE[0] <= lat <= _GEO_LAT_RANGE[1]:
345
+ raise _fail(spec, EncodingReason.OUT_OF_RANGE, f"latitude {lat} outside [-90, 90]")
346
+ if not _GEO_LON_RANGE[0] <= lon <= _GEO_LON_RANGE[1]:
347
+ raise _fail(spec, EncodingReason.OUT_OF_RANGE, f"longitude {lon} outside [-180, 180]")
348
+ return {"lat": lat, "lon": lon}
349
+
350
+
351
+ def _encode_json(spec: TypeSpec, raw: object) -> CanonicalValue:
352
+ if raw is None or isinstance(raw, bool):
353
+ return raw
354
+ if isinstance(raw, int):
355
+ if abs(raw) > JSON_SAFE_INTEGER_MAX:
356
+ raise _fail(
357
+ spec, EncodingReason.OUT_OF_RANGE, "json integers must be within +/-(2^53 - 1)"
358
+ )
359
+ return raw
360
+ if isinstance(raw, float):
361
+ return _encode_float(spec, raw)
362
+ if isinstance(raw, str):
363
+ return normalize_text(raw, spec.describe())
364
+ if isinstance(raw, list | tuple):
365
+ return [_encode_json(spec, item) for item in raw]
366
+ if isinstance(raw, Mapping):
367
+ result: dict[str, CanonicalValue] = {}
368
+ for key, item in raw.items():
369
+ norm_key = normalize_text(key, spec.describe())
370
+ if norm_key in result:
371
+ raise _fail(
372
+ spec,
373
+ EncodingReason.FORMAT,
374
+ f"duplicate object key after normalization: {norm_key!r}",
375
+ )
376
+ result[norm_key] = _encode_json(spec, item)
377
+ return result
378
+ raise _fail(spec, EncodingReason.WRONG_TYPE, f"not a JSON value: {type(raw).__name__}")
379
+
380
+
381
+ def _encode_array(spec: TypeSpec, raw: object) -> list[CanonicalValue]:
382
+ if not isinstance(raw, list | tuple):
383
+ raise _fail(spec, EncodingReason.WRONG_TYPE, f"expected a list, got {type(raw).__name__}")
384
+ element = spec.scalar
385
+ result: list[CanonicalValue] = []
386
+ for index, item in enumerate(raw):
387
+ if item is None:
388
+ raise _fail(
389
+ spec, EncodingReason.FORMAT, f"element {index} is null; arrays hold no nulls"
390
+ )
391
+ try:
392
+ result.append(encode_value(element, item))
393
+ except EncodingError as exc:
394
+ raise EncodingError(
395
+ spec.describe(), exc.reason, f"element {index}: {exc.message}"
396
+ ) from exc
397
+ return result
398
+
399
+
400
+ def _encode_enum(spec: TypeSpec, raw: object) -> str:
401
+ text = normalize_text(raw, spec.describe())
402
+ assert spec.enum_values is not None
403
+ if text not in spec.enum_values:
404
+ raise _fail(
405
+ spec, EncodingReason.NOT_A_MEMBER, f"{text!r} is not one of {list(spec.enum_values)}"
406
+ )
407
+ return text
408
+
409
+
410
+ def _encode_uri(spec: TypeSpec, raw: object) -> str:
411
+ text = normalize_text(raw, spec.describe())
412
+ if not _URI_RE.fullmatch(text):
413
+ raise _fail(spec, EncodingReason.FORMAT, f"not an absolute URI: {text!r}")
414
+ return text
415
+
416
+
417
+ def _encode_boolean(spec: TypeSpec, raw: object) -> bool:
418
+ if not isinstance(raw, bool):
419
+ raise _fail(
420
+ spec, EncodingReason.WRONG_TYPE, f"expected a boolean, got {type(raw).__name__}"
421
+ )
422
+ return raw
423
+
424
+
425
+ _ENCODERS: dict[AttributeType, Callable[[TypeSpec, object], CanonicalValue]] = {
426
+ AttributeType.STRING: lambda spec, raw: normalize_text(raw, spec.describe()),
427
+ AttributeType.TEXT: lambda spec, raw: normalize_text(raw, spec.describe()),
428
+ AttributeType.URI: _encode_uri,
429
+ AttributeType.ENUM: _encode_enum,
430
+ AttributeType.INTEGER: _encode_integer,
431
+ AttributeType.DECIMAL: _encode_decimal,
432
+ AttributeType.FLOAT: _encode_float,
433
+ AttributeType.BOOLEAN: _encode_boolean,
434
+ AttributeType.DATE: _encode_date,
435
+ AttributeType.TIMESTAMP: _encode_timestamp,
436
+ AttributeType.DURATION: _encode_duration,
437
+ AttributeType.JSON: _encode_json,
438
+ AttributeType.GEO_POINT: _encode_geo_point,
439
+ AttributeType.REF: lambda spec, raw: normalize_key(raw, spec.describe()),
440
+ AttributeType.ARRAY: _encode_array,
441
+ }
442
+
443
+
444
+ def encode_value(spec: TypeSpec, raw: object) -> CanonicalValue:
445
+ """Encode an API value into the canonical value of ``spec``.
446
+
447
+ ``None`` means absent and encodes to ``None``. Raises ``EncodingError`` when the value
448
+ is not valid for the type. Encoding is idempotent: encoding a canonical value returns it.
449
+ """
450
+ if raw is None:
451
+ return None
452
+ return _ENCODERS[spec.name](spec, raw)
453
+
454
+
455
+ def is_canonical(spec: TypeSpec, value: object) -> bool:
456
+ """True when ``value`` is a valid canonical value of ``spec`` (``None`` included)."""
457
+ try:
458
+ return encode_value(spec, value) == value
459
+ except EncodingError:
460
+ return False
461
+
462
+
463
+ def decode_value(spec: TypeSpec, value: object) -> object:
464
+ """Decode a canonical value of ``spec`` into a Python native value.
465
+
466
+ ``integer`` -> ``int``, ``decimal`` -> ``Decimal``, ``float`` -> ``float``, ``date`` ->
467
+ ``date``, ``timestamp`` -> aware ``datetime`` in UTC, ``duration`` -> ``timedelta``,
468
+ ``geo_point`` -> ``GeoPoint``, ``array`` -> ``list``; text types and ``json`` decode to
469
+ themselves. Raises ``EncodingError`` with reason ``not_canonical`` for non-canonical input.
470
+ """
471
+ if value is None:
472
+ return None
473
+ canonical = encode_value(spec, value)
474
+ if canonical != value:
475
+ raise _fail(
476
+ spec, EncodingReason.NOT_CANONICAL, f"value is not in canonical form: {value!r}"
477
+ )
478
+ return _decode_canonical(spec, canonical)
479
+
480
+
481
+ def _decode_canonical(spec: TypeSpec, canonical: CanonicalValue) -> object:
482
+ name = spec.name
483
+ if name is AttributeType.INTEGER:
484
+ assert isinstance(canonical, str)
485
+ return int(canonical)
486
+ if name is AttributeType.DECIMAL:
487
+ assert isinstance(canonical, str)
488
+ return Decimal(canonical)
489
+ if name is AttributeType.FLOAT:
490
+ assert isinstance(canonical, int | float)
491
+ return float(canonical)
492
+ if name is AttributeType.DATE:
493
+ assert isinstance(canonical, str)
494
+ return date.fromisoformat(canonical)
495
+ if name is AttributeType.TIMESTAMP:
496
+ assert isinstance(canonical, str)
497
+ return _parse_timestamp(spec, canonical).astimezone(UTC)
498
+ if name is AttributeType.DURATION:
499
+ assert isinstance(canonical, str)
500
+ return _parse_duration(spec, canonical)
501
+ if name is AttributeType.GEO_POINT:
502
+ assert isinstance(canonical, dict)
503
+ lat, lon = canonical["lat"], canonical["lon"]
504
+ assert isinstance(lat, int | float)
505
+ assert isinstance(lon, int | float)
506
+ return GeoPoint(float(lat), float(lon))
507
+ if name is AttributeType.ARRAY:
508
+ assert isinstance(canonical, list)
509
+ return [_decode_canonical(spec.scalar, item) for item in canonical]
510
+ return canonical
511
+
512
+
513
+ def float_text(value: float) -> str:
514
+ """Shortest round-trip text of a float (``repr``), with negative zero as ``0.0``."""
515
+ return repr(0.0 if value == 0.0 else value)
516
+
517
+
518
+ def to_text(spec: TypeSpec, value: CanonicalValue) -> str:
519
+ """Text form of a canonical scalar value, used by casts to text types and by key rendering.
520
+
521
+ Raises ``TypeSystemError`` for ``json`` and ``array`` values, which have no text form.
522
+ """
523
+ name = spec.name
524
+ if name in (AttributeType.JSON, AttributeType.ARRAY):
525
+ raise TypeSystemError(f"{spec.describe()} values have no text form")
526
+ if name is AttributeType.BOOLEAN:
527
+ return "true" if value else "false"
528
+ if name is AttributeType.FLOAT:
529
+ assert isinstance(value, int | float)
530
+ return float_text(float(value))
531
+ if name is AttributeType.GEO_POINT:
532
+ assert isinstance(value, dict)
533
+ lat, lon = value["lat"], value["lon"]
534
+ assert isinstance(lat, int | float)
535
+ assert isinstance(lon, int | float)
536
+ return f"{float_text(float(lat))},{float_text(float(lon))}"
537
+ assert isinstance(value, str)
538
+ return value
@@ -0,0 +1,41 @@
1
+ """Typed errors of the type system."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import StrEnum
6
+
7
+
8
+ class EncodingReason(StrEnum):
9
+ """Why a value was rejected by a type."""
10
+
11
+ WRONG_TYPE = "wrong_type"
12
+ FORMAT = "format"
13
+ OUT_OF_RANGE = "out_of_range"
14
+ PRECISION = "precision"
15
+ NOT_A_MEMBER = "not_a_member"
16
+ INVALID_TEXT = "invalid_text"
17
+ NOT_CANONICAL = "not_canonical"
18
+
19
+
20
+ class TypeSystemError(ValueError):
21
+ """Base class of all type system errors."""
22
+
23
+
24
+ class EncodingError(TypeSystemError):
25
+ """A value is not valid for a type."""
26
+
27
+ def __init__(self, type_name: str, reason: EncodingReason, message: str) -> None:
28
+ super().__init__(f"{type_name}: {message}")
29
+ self.type_name = type_name
30
+ self.reason = reason
31
+ self.message = message
32
+
33
+
34
+ class CastError(TypeSystemError):
35
+ """A value cannot be cast between two types (forbidden rule or failed checked cast)."""
36
+
37
+ def __init__(self, from_type: str, to_type: str, message: str) -> None:
38
+ super().__init__(f"cannot cast {from_type} to {to_type}: {message}")
39
+ self.from_type = from_type
40
+ self.to_type = to_type
41
+ self.message = message
@@ -0,0 +1,40 @@
1
+ """Matching form of canonical values (design 13.5): the form two values are compared in when
2
+ erasure looks for "the same value" in other Records.
3
+
4
+ The canonical encoding of each Attribute type already makes equal values equal: numbers,
5
+ decimals, dates, timestamps and durations have one spelling, text is NFC. People write the same
6
+ personal value in more ways than that, so the matching form also folds every string in the
7
+ value (top level, array elements, members of `json` values): compatibility characters (NFKC),
8
+ letter case and runs of white space are ignored. Two values with the same matching form are
9
+ erased together. The form does not depend on the Attribute's type, so a fingerprint made at
10
+ write time and one made again later (key rotation) always agree.
11
+
12
+ The matching form is only ever hashed (fingerprints): it is never stored or shown.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import re
18
+ import unicodedata
19
+
20
+ from lorebase_core.types.types import CanonicalValue
21
+
22
+ _SPACES = re.compile(r"\s+")
23
+
24
+
25
+ def fold_text(text: str) -> str:
26
+ """Text without the differences matching ignores: compatibility forms, case and runs of
27
+ white space (trimmed at both ends)."""
28
+ folded = unicodedata.normalize("NFKC", text).casefold()
29
+ return _SPACES.sub(" ", folded).strip()
30
+
31
+
32
+ def matching_form(value: CanonicalValue) -> CanonicalValue:
33
+ """The matching form of a canonical value (module doc)."""
34
+ if isinstance(value, str):
35
+ return fold_text(value)
36
+ if isinstance(value, list):
37
+ return [matching_form(item) for item in value]
38
+ if isinstance(value, dict):
39
+ return {key: matching_form(item) for key, item in value.items()}
40
+ return value