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,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