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,271 @@
|
|
|
1
|
+
"""Cast rules between Attribute types (design 5.4, "Type change rules").
|
|
2
|
+
|
|
3
|
+
Every pair of base types has a declared rule:
|
|
4
|
+
|
|
5
|
+
- ``safe``: always succeeds for every valid value of the source type;
|
|
6
|
+
- ``checked``: may fail per value (for example ``string`` to ``integer``);
|
|
7
|
+
- ``forbidden``: never allowed (for example ``geo_point`` to ``boolean``).
|
|
8
|
+
|
|
9
|
+
Arrays derive their rule from their element type: ``array<T> -> array<U>`` and
|
|
10
|
+
``T -> array<U>`` (wrap) take the rule of ``T -> U``; ``array<T> -> U`` (unwrap, at most one
|
|
11
|
+
element) is ``checked`` unless ``T -> U`` is forbidden.
|
|
12
|
+
|
|
13
|
+
Parameters can only make a rule stricter: a ``safe`` base rule becomes ``checked`` when the
|
|
14
|
+
target parameters are narrower (for example ``decimal(12,2) -> decimal(8,2)``, an ``enum``
|
|
15
|
+
losing values, or a ``ref`` to a different Kind).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from decimal import Decimal
|
|
21
|
+
from enum import StrEnum
|
|
22
|
+
|
|
23
|
+
from lorebase_core.types.encoding import encode_value, float_text, to_text
|
|
24
|
+
from lorebase_core.types.errors import CastError, EncodingError, TypeSystemError
|
|
25
|
+
from lorebase_core.types.types import AttributeType, CanonicalValue, TypeSpec
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class CastRule(StrEnum):
|
|
29
|
+
SAFE = "safe"
|
|
30
|
+
CHECKED = "checked"
|
|
31
|
+
FORBIDDEN = "forbidden"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
#: Marker in the matrix for pairs whose rule derives from the element rule (arrays).
|
|
35
|
+
ELEMENT_RULE = "element"
|
|
36
|
+
|
|
37
|
+
_T = AttributeType
|
|
38
|
+
|
|
39
|
+
# Rows: source type. Columns: target type. Abbreviations are listed in ``_ABBREVIATIONS``.
|
|
40
|
+
# S = safe, C = checked, F = forbidden, E = derived from the element rule (arrays).
|
|
41
|
+
_MATRIX_TABLE = """
|
|
42
|
+
str txt uri enm int dec flt bool date ts dur json geo ref arr
|
|
43
|
+
str S S C C C C C C C C C F C C E
|
|
44
|
+
txt S S C C C C C C C C C F C C E
|
|
45
|
+
uri S S S C F F F F F F F F F C E
|
|
46
|
+
enm S S C S C C C C C C C F F C E
|
|
47
|
+
int S S F C S S C C F F F F F C E
|
|
48
|
+
dec S S F C C S C F F F F F F F E
|
|
49
|
+
flt S S F C C C S F F F F F F F E
|
|
50
|
+
bool S S F C S S S S F F F F F F E
|
|
51
|
+
date S S F C F F F F S S F F F F E
|
|
52
|
+
ts S S F C F F F F C S F F F F E
|
|
53
|
+
dur S S F C F F F F F F S F F F E
|
|
54
|
+
json F F F F F F F F F F F S F F F
|
|
55
|
+
geo S S F F F F F F F F F F S F E
|
|
56
|
+
ref S S F C C F F F F F F F F S E
|
|
57
|
+
arr F F E E E E E E E E E F E E E
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
_ABBREVIATIONS: dict[str, AttributeType] = {
|
|
61
|
+
"str": _T.STRING,
|
|
62
|
+
"txt": _T.TEXT,
|
|
63
|
+
"uri": _T.URI,
|
|
64
|
+
"enm": _T.ENUM,
|
|
65
|
+
"int": _T.INTEGER,
|
|
66
|
+
"dec": _T.DECIMAL,
|
|
67
|
+
"flt": _T.FLOAT,
|
|
68
|
+
"bool": _T.BOOLEAN,
|
|
69
|
+
"date": _T.DATE,
|
|
70
|
+
"ts": _T.TIMESTAMP,
|
|
71
|
+
"dur": _T.DURATION,
|
|
72
|
+
"json": _T.JSON,
|
|
73
|
+
"geo": _T.GEO_POINT,
|
|
74
|
+
"ref": _T.REF,
|
|
75
|
+
"arr": _T.ARRAY,
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
_CODES: dict[str, CastRule | str] = {
|
|
79
|
+
"S": CastRule.SAFE,
|
|
80
|
+
"C": CastRule.CHECKED,
|
|
81
|
+
"F": CastRule.FORBIDDEN,
|
|
82
|
+
"E": ELEMENT_RULE,
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _parse_matrix(table: str) -> dict[tuple[AttributeType, AttributeType], CastRule | str]:
|
|
87
|
+
lines = [line.split() for line in table.strip().splitlines()]
|
|
88
|
+
header = [_ABBREVIATIONS[name] for name in lines[0]]
|
|
89
|
+
matrix: dict[tuple[AttributeType, AttributeType], CastRule | str] = {}
|
|
90
|
+
for row in lines[1:]:
|
|
91
|
+
source = _ABBREVIATIONS[row[0]]
|
|
92
|
+
if len(row) - 1 != len(header):
|
|
93
|
+
raise ValueError(f"cast matrix row {source} has {len(row) - 1} entries")
|
|
94
|
+
for target, code in zip(header, row[1:], strict=True):
|
|
95
|
+
matrix[(source, target)] = _CODES[code]
|
|
96
|
+
return matrix
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
#: The declared base rule of every ``(from, to)`` pair of base types.
|
|
100
|
+
CAST_MATRIX = _parse_matrix(_MATRIX_TABLE)
|
|
101
|
+
|
|
102
|
+
#: Integer digits needed to hold any int64 value.
|
|
103
|
+
_INT64_DIGITS = 19
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _integer_digits(spec: TypeSpec) -> int:
|
|
107
|
+
precision, scale = spec.precision, spec.scale
|
|
108
|
+
assert precision is not None
|
|
109
|
+
assert scale is not None
|
|
110
|
+
return precision - scale
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _refine(source: TypeSpec, target: TypeSpec) -> CastRule:
|
|
114
|
+
"""Downgrade a ``safe`` base rule to ``checked`` when target parameters are narrower."""
|
|
115
|
+
if target.name is _T.DECIMAL:
|
|
116
|
+
if source.name is _T.DECIMAL:
|
|
117
|
+
source_scale, target_scale = source.scale, target.scale
|
|
118
|
+
assert source_scale is not None
|
|
119
|
+
assert target_scale is not None
|
|
120
|
+
widening = target_scale >= source_scale
|
|
121
|
+
widening = widening and _integer_digits(target) >= _integer_digits(source)
|
|
122
|
+
return CastRule.SAFE if widening else CastRule.CHECKED
|
|
123
|
+
if source.name is _T.INTEGER:
|
|
124
|
+
return CastRule.SAFE if _integer_digits(target) >= _INT64_DIGITS else CastRule.CHECKED
|
|
125
|
+
if source.name is _T.BOOLEAN:
|
|
126
|
+
return CastRule.SAFE if _integer_digits(target) >= 1 else CastRule.CHECKED
|
|
127
|
+
if source.name is _T.ENUM and target.name is _T.ENUM:
|
|
128
|
+
source_values, target_values = source.enum_values, target.enum_values
|
|
129
|
+
assert source_values is not None
|
|
130
|
+
assert target_values is not None
|
|
131
|
+
return CastRule.SAFE if set(source_values) <= set(target_values) else CastRule.CHECKED
|
|
132
|
+
if source.name is _T.REF and target.name is _T.REF:
|
|
133
|
+
return CastRule.SAFE if source.kind_ref == target.kind_ref else CastRule.CHECKED
|
|
134
|
+
return CastRule.SAFE
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def cast_rule(source: TypeSpec, target: TypeSpec) -> CastRule:
|
|
138
|
+
"""The rule for casting values of ``source`` to ``target``, parameters included."""
|
|
139
|
+
if source.element is not None and target.element is not None:
|
|
140
|
+
return cast_rule(source.element, target.element)
|
|
141
|
+
if source.element is not None:
|
|
142
|
+
inner = cast_rule(source.element, target)
|
|
143
|
+
return CastRule.FORBIDDEN if inner is CastRule.FORBIDDEN else CastRule.CHECKED
|
|
144
|
+
if target.element is not None:
|
|
145
|
+
if source.name is _T.JSON:
|
|
146
|
+
return CastRule.FORBIDDEN
|
|
147
|
+
return cast_rule(source, target.element)
|
|
148
|
+
base = CAST_MATRIX[(source.name, target.name)]
|
|
149
|
+
assert isinstance(base, CastRule)
|
|
150
|
+
if base is not CastRule.SAFE:
|
|
151
|
+
return base
|
|
152
|
+
return _refine(source, target)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def cast(source: TypeSpec, target: TypeSpec, value: CanonicalValue) -> CanonicalValue:
|
|
156
|
+
"""Cast a canonical value of ``source`` into the canonical value of ``target``.
|
|
157
|
+
|
|
158
|
+
``None`` (absent) casts to ``None`` unless the rule is forbidden. Raises ``CastError`` when
|
|
159
|
+
the rule is forbidden, when ``value`` is not a valid value of ``source``, or when a checked
|
|
160
|
+
cast fails.
|
|
161
|
+
"""
|
|
162
|
+
if cast_rule(source, target) is CastRule.FORBIDDEN:
|
|
163
|
+
raise CastError(source.describe(), target.describe(), "the cast rule is forbidden")
|
|
164
|
+
if value is None:
|
|
165
|
+
return None
|
|
166
|
+
try:
|
|
167
|
+
canonical = encode_value(source, value)
|
|
168
|
+
except EncodingError as exc:
|
|
169
|
+
raise CastError(
|
|
170
|
+
source.describe(), target.describe(), f"invalid source value: {exc.message}"
|
|
171
|
+
) from exc
|
|
172
|
+
try:
|
|
173
|
+
return _cast(source, target, canonical)
|
|
174
|
+
except CastError:
|
|
175
|
+
raise
|
|
176
|
+
except TypeSystemError as exc:
|
|
177
|
+
message = exc.message if isinstance(exc, EncodingError) else str(exc)
|
|
178
|
+
raise CastError(source.describe(), target.describe(), message) from exc
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _fail(source: TypeSpec, target: TypeSpec, message: str) -> CastError:
|
|
182
|
+
return CastError(source.describe(), target.describe(), message)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
_TEXT_SOURCES = frozenset({_T.STRING, _T.TEXT, _T.URI, _T.ENUM, _T.REF})
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _cast(source: TypeSpec, target: TypeSpec, value: CanonicalValue) -> CanonicalValue:
|
|
189
|
+
if value is None:
|
|
190
|
+
return None
|
|
191
|
+
if source.element is not None and target.element is not None:
|
|
192
|
+
assert isinstance(value, list)
|
|
193
|
+
return [_cast(source.element, target.element, item) for item in value]
|
|
194
|
+
if source.element is not None:
|
|
195
|
+
assert isinstance(value, list)
|
|
196
|
+
if not value:
|
|
197
|
+
return None
|
|
198
|
+
if len(value) > 1:
|
|
199
|
+
raise _fail(
|
|
200
|
+
source, target, f"array has {len(value)} elements; at most one can be unwrapped"
|
|
201
|
+
)
|
|
202
|
+
return _cast(source.element, target, value[0])
|
|
203
|
+
if target.element is not None:
|
|
204
|
+
return [_cast(source, target.element, value)]
|
|
205
|
+
|
|
206
|
+
if source == target:
|
|
207
|
+
return value
|
|
208
|
+
src, dst = source.name, target.name
|
|
209
|
+
if dst in (_T.STRING, _T.TEXT):
|
|
210
|
+
return encode_value(target, to_text(source, value))
|
|
211
|
+
if src is dst:
|
|
212
|
+
return encode_value(target, value)
|
|
213
|
+
if dst in (_T.ENUM, _T.REF):
|
|
214
|
+
return encode_value(target, to_text(source, value))
|
|
215
|
+
if src in _TEXT_SOURCES:
|
|
216
|
+
assert isinstance(value, str)
|
|
217
|
+
return _cast_from_text(source, target, value)
|
|
218
|
+
if src is _T.INTEGER:
|
|
219
|
+
assert isinstance(value, str)
|
|
220
|
+
if dst is _T.BOOLEAN:
|
|
221
|
+
if value not in ("0", "1"):
|
|
222
|
+
raise _fail(source, target, f"only 0 and 1 cast to boolean, got {value}")
|
|
223
|
+
return value == "1"
|
|
224
|
+
if dst is _T.FLOAT:
|
|
225
|
+
return encode_value(target, int(value))
|
|
226
|
+
return encode_value(target, value)
|
|
227
|
+
if src is _T.DECIMAL:
|
|
228
|
+
assert isinstance(value, str)
|
|
229
|
+
number = Decimal(value)
|
|
230
|
+
if dst is _T.FLOAT:
|
|
231
|
+
as_float = float(number)
|
|
232
|
+
if Decimal(float_text(as_float)) != number:
|
|
233
|
+
raise _fail(source, target, f"{value} is not exactly representable as float")
|
|
234
|
+
return encode_value(target, as_float)
|
|
235
|
+
return encode_value(target, number)
|
|
236
|
+
if src is _T.FLOAT:
|
|
237
|
+
assert isinstance(value, int | float)
|
|
238
|
+
number_f = float(value)
|
|
239
|
+
if dst is _T.INTEGER:
|
|
240
|
+
if not number_f.is_integer():
|
|
241
|
+
raise _fail(source, target, f"{float_text(number_f)} is not integral")
|
|
242
|
+
return encode_value(target, int(number_f))
|
|
243
|
+
return encode_value(target, number_f)
|
|
244
|
+
if src is _T.BOOLEAN:
|
|
245
|
+
assert isinstance(value, bool)
|
|
246
|
+
return encode_value(target, 1 if value else 0)
|
|
247
|
+
if src is _T.DATE and dst is _T.TIMESTAMP:
|
|
248
|
+
assert isinstance(value, str)
|
|
249
|
+
return f"{value}T00:00:00.000000Z"
|
|
250
|
+
if src is _T.TIMESTAMP and dst is _T.DATE:
|
|
251
|
+
assert isinstance(value, str)
|
|
252
|
+
day, time = value[:10], value[10:]
|
|
253
|
+
if time != "T00:00:00.000000Z":
|
|
254
|
+
raise _fail(source, target, f"{value} is not at midnight UTC")
|
|
255
|
+
return day
|
|
256
|
+
raise _fail(source, target, "no conversion defined")
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _cast_from_text(source: TypeSpec, target: TypeSpec, text: str) -> CanonicalValue:
|
|
260
|
+
if target.name is _T.BOOLEAN:
|
|
261
|
+
if text == "true":
|
|
262
|
+
return True
|
|
263
|
+
if text == "false":
|
|
264
|
+
return False
|
|
265
|
+
raise _fail(source, target, f"only 'true' and 'false' cast to boolean, got {text!r}")
|
|
266
|
+
if target.name is _T.GEO_POINT:
|
|
267
|
+
parts = [part.strip() for part in text.split(",")]
|
|
268
|
+
if len(parts) != 2:
|
|
269
|
+
raise _fail(source, target, f"expected 'lat,lon', got {text!r}")
|
|
270
|
+
return encode_value(target, (parts[0], parts[1]))
|
|
271
|
+
return encode_value(target, text)
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
"""Attribute constraints (design 5.4): ``min``, ``max``, ``min_length``, ``max_length``,
|
|
2
|
+
``pattern`` and ``enum_values``.
|
|
3
|
+
|
|
4
|
+
Applicability:
|
|
5
|
+
|
|
6
|
+
- ``min`` and ``max`` apply to ordered types (integer, decimal, float, date, timestamp,
|
|
7
|
+
duration); bounds are inclusive and written as values of the Attribute type.
|
|
8
|
+
- ``min_length`` and ``max_length`` count Unicode code points of text values; on arrays they
|
|
9
|
+
bound the number of elements.
|
|
10
|
+
- ``pattern`` applies to text values. It uses the RE2 subset (no lookaround, no
|
|
11
|
+
backreferences) and matches anywhere in the value unless anchored with ``^`` and ``$``.
|
|
12
|
+
- ``enum_values`` restricts text, integer and decimal values to a closed list (the ``enum``
|
|
13
|
+
type carries its own values instead).
|
|
14
|
+
|
|
15
|
+
On arrays, every constraint other than the length bounds applies to each element.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import re
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from enum import StrEnum
|
|
23
|
+
from typing import Any, Self
|
|
24
|
+
|
|
25
|
+
from pydantic import BaseModel, ConfigDict, Field, JsonValue, model_validator
|
|
26
|
+
|
|
27
|
+
from lorebase_core.types.encoding import decode_value, encode_value
|
|
28
|
+
from lorebase_core.types.errors import EncodingError
|
|
29
|
+
from lorebase_core.types.types import (
|
|
30
|
+
ORDERED_TYPES,
|
|
31
|
+
TEXTUAL_TYPES,
|
|
32
|
+
AttributeType,
|
|
33
|
+
CanonicalValue,
|
|
34
|
+
TypeSpec,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
#: Types accepting ``enum_values`` as a constraint.
|
|
38
|
+
ENUM_CONSTRAINT_TYPES = TEXTUAL_TYPES | {AttributeType.INTEGER, AttributeType.DECIMAL}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ConstraintName(StrEnum):
|
|
42
|
+
MIN = "min"
|
|
43
|
+
MAX = "max"
|
|
44
|
+
MIN_LENGTH = "min_length"
|
|
45
|
+
MAX_LENGTH = "max_length"
|
|
46
|
+
PATTERN = "pattern"
|
|
47
|
+
ENUM_VALUES = "enum_values"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Constraints(BaseModel):
|
|
51
|
+
"""Value constraints of an Attribute. Unset constraints are ``None``."""
|
|
52
|
+
|
|
53
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
54
|
+
|
|
55
|
+
min: JsonValue = None
|
|
56
|
+
max: JsonValue = None
|
|
57
|
+
min_length: int | None = Field(default=None, ge=0)
|
|
58
|
+
max_length: int | None = Field(default=None, ge=0)
|
|
59
|
+
pattern: str | None = None
|
|
60
|
+
enum_values: tuple[str, ...] | None = None
|
|
61
|
+
|
|
62
|
+
@model_validator(mode="after")
|
|
63
|
+
def _check_lengths(self) -> Self:
|
|
64
|
+
if (
|
|
65
|
+
self.min_length is not None
|
|
66
|
+
and self.max_length is not None
|
|
67
|
+
and self.min_length > self.max_length
|
|
68
|
+
):
|
|
69
|
+
raise ValueError("min_length must not exceed max_length")
|
|
70
|
+
return self
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def is_empty(self) -> bool:
|
|
74
|
+
return all(getattr(self, name.value) is None for name in ConstraintName)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True, slots=True)
|
|
78
|
+
class ConstraintViolation:
|
|
79
|
+
"""A value that breaks one constraint. ``index`` locates the array element, if any."""
|
|
80
|
+
|
|
81
|
+
constraint: ConstraintName
|
|
82
|
+
message: str
|
|
83
|
+
index: int | None = None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# RE2 compatibility -------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
_UNSUPPORTED_GROUPS = ("(?=", "(?!", "(?<=", "(?<!", "(?>", "(?(", "(?P=")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def re2_incompatibility(pattern: str) -> str | None:
|
|
92
|
+
"""Return why ``pattern`` uses syntax outside RE2, or ``None`` when it does not."""
|
|
93
|
+
in_class = False
|
|
94
|
+
index = 0
|
|
95
|
+
while index < len(pattern):
|
|
96
|
+
char = pattern[index]
|
|
97
|
+
if char == "\\":
|
|
98
|
+
following = pattern[index + 1 : index + 2]
|
|
99
|
+
if following and following in "123456789" and not in_class:
|
|
100
|
+
return "backreferences are not supported"
|
|
101
|
+
index += 2
|
|
102
|
+
continue
|
|
103
|
+
if in_class:
|
|
104
|
+
if char == "]":
|
|
105
|
+
in_class = False
|
|
106
|
+
elif char == "[":
|
|
107
|
+
in_class = True
|
|
108
|
+
if pattern[index + 1 : index + 2] == "]":
|
|
109
|
+
index += 1
|
|
110
|
+
elif char == "(":
|
|
111
|
+
for construct in _UNSUPPORTED_GROUPS:
|
|
112
|
+
if pattern.startswith(construct, index):
|
|
113
|
+
return f"{construct} groups are not supported"
|
|
114
|
+
index += 1
|
|
115
|
+
return None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def compile_pattern(pattern: str) -> re.Pattern[str]:
|
|
119
|
+
"""Compile a constraint pattern, rejecting syntax outside RE2 with ``ValueError``."""
|
|
120
|
+
problem = re2_incompatibility(pattern)
|
|
121
|
+
if problem is not None:
|
|
122
|
+
raise ValueError(problem)
|
|
123
|
+
try:
|
|
124
|
+
return re.compile(pattern)
|
|
125
|
+
except re.error as exc:
|
|
126
|
+
raise ValueError(f"invalid pattern: {exc}") from exc
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# Static checks ----------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _bound(scalar: TypeSpec, raw: JsonValue) -> Any:
|
|
133
|
+
return decode_value(scalar, encode_value(scalar, raw))
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def constraint_problems(
|
|
137
|
+
spec: TypeSpec, constraints: Constraints
|
|
138
|
+
) -> list[tuple[ConstraintName, str]]:
|
|
139
|
+
"""Static problems of ``constraints`` for an Attribute of type ``spec``."""
|
|
140
|
+
problems: list[tuple[ConstraintName, str]] = []
|
|
141
|
+
scalar = spec.scalar
|
|
142
|
+
bounds: dict[ConstraintName, Any] = {}
|
|
143
|
+
for name, raw in ((ConstraintName.MIN, constraints.min), (ConstraintName.MAX, constraints.max)):
|
|
144
|
+
if raw is None:
|
|
145
|
+
continue
|
|
146
|
+
if scalar.name not in ORDERED_TYPES:
|
|
147
|
+
problems.append((name, f"{name} does not apply to {spec.describe()}"))
|
|
148
|
+
continue
|
|
149
|
+
try:
|
|
150
|
+
bounds[name] = _bound(scalar, raw)
|
|
151
|
+
except EncodingError as exc:
|
|
152
|
+
problems.append((name, f"{name} is not a valid {scalar.describe()}: {exc.message}"))
|
|
153
|
+
if len(bounds) == 2 and bounds[ConstraintName.MIN] > bounds[ConstraintName.MAX]:
|
|
154
|
+
problems.append((ConstraintName.MIN, "min must not exceed max"))
|
|
155
|
+
|
|
156
|
+
length_applies = spec.is_array or scalar.name in TEXTUAL_TYPES
|
|
157
|
+
for name, length in (
|
|
158
|
+
(ConstraintName.MIN_LENGTH, constraints.min_length),
|
|
159
|
+
(ConstraintName.MAX_LENGTH, constraints.max_length),
|
|
160
|
+
):
|
|
161
|
+
if length is not None and not length_applies:
|
|
162
|
+
problems.append((name, f"{name} does not apply to {spec.describe()}"))
|
|
163
|
+
|
|
164
|
+
if constraints.pattern is not None:
|
|
165
|
+
if scalar.name not in TEXTUAL_TYPES:
|
|
166
|
+
problems.append(
|
|
167
|
+
(ConstraintName.PATTERN, f"pattern does not apply to {spec.describe()}")
|
|
168
|
+
)
|
|
169
|
+
else:
|
|
170
|
+
try:
|
|
171
|
+
compile_pattern(constraints.pattern)
|
|
172
|
+
except ValueError as exc:
|
|
173
|
+
problems.append((ConstraintName.PATTERN, str(exc)))
|
|
174
|
+
|
|
175
|
+
if constraints.enum_values is not None:
|
|
176
|
+
if scalar.name not in ENUM_CONSTRAINT_TYPES:
|
|
177
|
+
problems.append(
|
|
178
|
+
(ConstraintName.ENUM_VALUES, f"enum_values does not apply to {spec.describe()}")
|
|
179
|
+
)
|
|
180
|
+
elif not constraints.enum_values:
|
|
181
|
+
problems.append((ConstraintName.ENUM_VALUES, "enum_values must not be empty"))
|
|
182
|
+
else:
|
|
183
|
+
for raw_value in constraints.enum_values:
|
|
184
|
+
try:
|
|
185
|
+
encode_value(scalar, raw_value)
|
|
186
|
+
except EncodingError as exc:
|
|
187
|
+
problems.append(
|
|
188
|
+
(
|
|
189
|
+
ConstraintName.ENUM_VALUES,
|
|
190
|
+
f"{raw_value!r} is not a valid {scalar.describe()}: {exc.message}",
|
|
191
|
+
)
|
|
192
|
+
)
|
|
193
|
+
return problems
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
# Value checks -----------------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _check_scalar(
|
|
200
|
+
scalar: TypeSpec,
|
|
201
|
+
constraints: Constraints,
|
|
202
|
+
value: CanonicalValue,
|
|
203
|
+
index: int | None,
|
|
204
|
+
*,
|
|
205
|
+
check_length: bool,
|
|
206
|
+
) -> list[ConstraintViolation]:
|
|
207
|
+
violations: list[ConstraintViolation] = []
|
|
208
|
+
if scalar.name in ORDERED_TYPES and (
|
|
209
|
+
constraints.min is not None or constraints.max is not None
|
|
210
|
+
):
|
|
211
|
+
decoded = decode_value(scalar, value)
|
|
212
|
+
if constraints.min is not None and decoded < _bound(scalar, constraints.min):
|
|
213
|
+
violations.append(
|
|
214
|
+
ConstraintViolation(
|
|
215
|
+
ConstraintName.MIN, f"{value} is below min {constraints.min}", index
|
|
216
|
+
)
|
|
217
|
+
)
|
|
218
|
+
if constraints.max is not None and decoded > _bound(scalar, constraints.max):
|
|
219
|
+
violations.append(
|
|
220
|
+
ConstraintViolation(
|
|
221
|
+
ConstraintName.MAX, f"{value} is above max {constraints.max}", index
|
|
222
|
+
)
|
|
223
|
+
)
|
|
224
|
+
if isinstance(value, str) and scalar.name in TEXTUAL_TYPES:
|
|
225
|
+
if (
|
|
226
|
+
check_length
|
|
227
|
+
and constraints.min_length is not None
|
|
228
|
+
and len(value) < constraints.min_length
|
|
229
|
+
):
|
|
230
|
+
violations.append(
|
|
231
|
+
ConstraintViolation(
|
|
232
|
+
ConstraintName.MIN_LENGTH,
|
|
233
|
+
f"length {len(value)} is below min_length {constraints.min_length}",
|
|
234
|
+
index,
|
|
235
|
+
)
|
|
236
|
+
)
|
|
237
|
+
if (
|
|
238
|
+
check_length
|
|
239
|
+
and constraints.max_length is not None
|
|
240
|
+
and len(value) > constraints.max_length
|
|
241
|
+
):
|
|
242
|
+
violations.append(
|
|
243
|
+
ConstraintViolation(
|
|
244
|
+
ConstraintName.MAX_LENGTH,
|
|
245
|
+
f"length {len(value)} is above max_length {constraints.max_length}",
|
|
246
|
+
index,
|
|
247
|
+
)
|
|
248
|
+
)
|
|
249
|
+
if (
|
|
250
|
+
constraints.pattern is not None
|
|
251
|
+
and compile_pattern(constraints.pattern).search(value) is None
|
|
252
|
+
):
|
|
253
|
+
violations.append(
|
|
254
|
+
ConstraintViolation(
|
|
255
|
+
ConstraintName.PATTERN,
|
|
256
|
+
f"{value!r} does not match {constraints.pattern!r}",
|
|
257
|
+
index,
|
|
258
|
+
)
|
|
259
|
+
)
|
|
260
|
+
if constraints.enum_values is not None and scalar.name in ENUM_CONSTRAINT_TYPES:
|
|
261
|
+
allowed = {encode_value(scalar, raw) for raw in constraints.enum_values}
|
|
262
|
+
if value not in allowed:
|
|
263
|
+
violations.append(
|
|
264
|
+
ConstraintViolation(
|
|
265
|
+
ConstraintName.ENUM_VALUES,
|
|
266
|
+
f"{value!r} is not one of {list(constraints.enum_values)}",
|
|
267
|
+
index,
|
|
268
|
+
)
|
|
269
|
+
)
|
|
270
|
+
return violations
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def check_constraints(
|
|
274
|
+
spec: TypeSpec, constraints: Constraints, value: CanonicalValue
|
|
275
|
+
) -> list[ConstraintViolation]:
|
|
276
|
+
"""Constraint violations of a canonical ``value`` of type ``spec`` (absent values pass)."""
|
|
277
|
+
if value is None or constraints.is_empty:
|
|
278
|
+
return []
|
|
279
|
+
if not spec.is_array:
|
|
280
|
+
return _check_scalar(spec, constraints, value, None, check_length=True)
|
|
281
|
+
assert isinstance(value, list)
|
|
282
|
+
violations: list[ConstraintViolation] = []
|
|
283
|
+
if constraints.min_length is not None and len(value) < constraints.min_length:
|
|
284
|
+
violations.append(
|
|
285
|
+
ConstraintViolation(
|
|
286
|
+
ConstraintName.MIN_LENGTH,
|
|
287
|
+
f"{len(value)} elements, below min_length {constraints.min_length}",
|
|
288
|
+
)
|
|
289
|
+
)
|
|
290
|
+
if constraints.max_length is not None and len(value) > constraints.max_length:
|
|
291
|
+
violations.append(
|
|
292
|
+
ConstraintViolation(
|
|
293
|
+
ConstraintName.MAX_LENGTH,
|
|
294
|
+
f"{len(value)} elements, above max_length {constraints.max_length}",
|
|
295
|
+
)
|
|
296
|
+
)
|
|
297
|
+
for index, item in enumerate(value):
|
|
298
|
+
violations.extend(_check_scalar(spec.scalar, constraints, item, index, check_length=False))
|
|
299
|
+
return violations
|