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.
- lorebase_core/__init__.py +1 -0
- lorebase_core/canonical/__init__.py +35 -0
- lorebase_core/canonical/hashing.py +128 -0
- lorebase_core/canonical/jcs.py +162 -0
- lorebase_core/expr/__init__.py +118 -0
- lorebase_core/expr/ast.py +443 -0
- lorebase_core/expr/checker.py +428 -0
- lorebase_core/expr/compiler.py +87 -0
- lorebase_core/expr/context.py +82 -0
- lorebase_core/expr/derivations.py +306 -0
- lorebase_core/expr/environment.py +176 -0
- lorebase_core/expr/errors.py +98 -0
- lorebase_core/expr/evaluator.py +414 -0
- lorebase_core/expr/functions.py +898 -0
- lorebase_core/expr/limits.py +52 -0
- lorebase_core/expr/operations.py +479 -0
- lorebase_core/expr/ops.py +243 -0
- lorebase_core/expr/resolver.py +96 -0
- lorebase_core/expr/rows.py +148 -0
- lorebase_core/expr/sensitivity.py +337 -0
- lorebase_core/expr/types.py +227 -0
- lorebase_core/expr/validation.py +51 -0
- lorebase_core/expr/values.py +169 -0
- lorebase_core/expr/visibility.py +363 -0
- lorebase_core/infer/__init__.py +36 -0
- lorebase_core/infer/columns.py +621 -0
- lorebase_core/infer/engine.py +546 -0
- lorebase_core/infer/keys.py +164 -0
- lorebase_core/infer/names.py +145 -0
- lorebase_core/infer/options.py +290 -0
- lorebase_core/infer/profile.py +54 -0
- lorebase_core/infer/proposal.py +122 -0
- lorebase_core/infer/references.py +314 -0
- lorebase_core/infer/suggestions.py +731 -0
- lorebase_core/infer/tables.py +81 -0
- lorebase_core/merge/__init__.py +89 -0
- lorebase_core/merge/conflicts.py +262 -0
- lorebase_core/merge/consistency.py +440 -0
- lorebase_core/merge/data_merge.py +522 -0
- lorebase_core/merge/engine.py +476 -0
- lorebase_core/merge/index.py +100 -0
- lorebase_core/merge/objects.py +204 -0
- lorebase_core/merge/policies.py +322 -0
- lorebase_core/merge/resolve.py +321 -0
- lorebase_core/merge/schema_merge.py +429 -0
- lorebase_core/merge/state.py +443 -0
- lorebase_core/model/__init__.py +245 -0
- lorebase_core/model/attribute.py +72 -0
- lorebase_core/model/base.py +61 -0
- lorebase_core/model/derivation.py +95 -0
- lorebase_core/model/domain.py +71 -0
- lorebase_core/model/embedding.py +193 -0
- lorebase_core/model/ids.py +78 -0
- lorebase_core/model/kind.py +85 -0
- lorebase_core/model/lineage.py +114 -0
- lorebase_core/model/operation.py +161 -0
- lorebase_core/model/paths.py +141 -0
- lorebase_core/model/record.py +127 -0
- lorebase_core/model/relation.py +83 -0
- lorebase_core/model/schema.py +219 -0
- lorebase_core/model/source.py +204 -0
- lorebase_core/model/trait.py +37 -0
- lorebase_core/model/vault.py +43 -0
- lorebase_core/model/yaml_schema.py +1058 -0
- lorebase_core/query/__init__.py +97 -0
- lorebase_core/query/dsl.py +227 -0
- lorebase_core/query/resolve.py +794 -0
- lorebase_core/revision/__init__.py +59 -0
- lorebase_core/revision/ancestry.py +272 -0
- lorebase_core/revision/changeset.py +62 -0
- lorebase_core/revision/revision.py +121 -0
- lorebase_core/tree/__init__.py +76 -0
- lorebase_core/tree/diff.py +119 -0
- lorebase_core/tree/hamt.py +333 -0
- lorebase_core/tree/node.py +280 -0
- lorebase_core/tree/params.py +33 -0
- lorebase_core/tree/root.py +70 -0
- lorebase_core/tree/store.py +98 -0
- lorebase_core/types/__init__.py +71 -0
- lorebase_core/types/casts.py +271 -0
- lorebase_core/types/constraints.py +299 -0
- lorebase_core/types/encoding.py +538 -0
- lorebase_core/types/errors.py +41 -0
- lorebase_core/types/matching.py +40 -0
- lorebase_core/types/types.py +187 -0
- lorebase_core/validate/__init__.py +49 -0
- lorebase_core/validate/changes.py +70 -0
- lorebase_core/validate/changeset.py +551 -0
- lorebase_core/validate/data_rules.py +291 -0
- lorebase_core/validate/issues.py +123 -0
- lorebase_core/validate/schema_rules.py +1304 -0
- lorebase_core-0.2.3.dist-info/METADATA +9 -0
- lorebase_core-0.2.3.dist-info/RECORD +94 -0
- 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
|