super-easy-validator-python 0.1.0__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.
- super_easy_validator_python/__init__.py +55 -0
- super_easy_validator_python/checks.py +525 -0
- super_easy_validator_python/codes.py +143 -0
- super_easy_validator_python/formats.py +316 -0
- super_easy_validator_python/paths.py +162 -0
- super_easy_validator_python/py.typed +0 -0
- super_easy_validator_python/rules.py +205 -0
- super_easy_validator_python/types.py +136 -0
- super_easy_validator_python/validator.py +685 -0
- super_easy_validator_python-0.1.0.dist-info/METADATA +280 -0
- super_easy_validator_python-0.1.0.dist-info/RECORD +14 -0
- super_easy_validator_python-0.1.0.dist-info/WHEEL +5 -0
- super_easy_validator_python-0.1.0.dist-info/licenses/LICENSE +21 -0
- super_easy_validator_python-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Validate data with rules you write as plain strings.
|
|
2
|
+
|
|
3
|
+
from super_easy_validator_python import validate
|
|
4
|
+
|
|
5
|
+
rules = {
|
|
6
|
+
"name": "fullname",
|
|
7
|
+
"email": "email",
|
|
8
|
+
"age": "optional|natural|min:18",
|
|
9
|
+
"role": "enums:admin,user,guest",
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
result = validate(rules, data)
|
|
13
|
+
if result.errors:
|
|
14
|
+
print(result.errors)
|
|
15
|
+
|
|
16
|
+
Rules are a plain dict, so a whole rule tree can be loaded from JSON or built
|
|
17
|
+
at runtime. See DOCS.md for the full reference.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from .codes import ErrorCodes
|
|
21
|
+
from .types import (
|
|
22
|
+
Config,
|
|
23
|
+
CustomRule,
|
|
24
|
+
Data,
|
|
25
|
+
Detail,
|
|
26
|
+
InvalidRuleError,
|
|
27
|
+
Result,
|
|
28
|
+
Rules,
|
|
29
|
+
ValidationError,
|
|
30
|
+
QUOTE_BACKTICK,
|
|
31
|
+
QUOTE_DOUBLE,
|
|
32
|
+
QUOTE_NONE,
|
|
33
|
+
QUOTE_SINGLE,
|
|
34
|
+
)
|
|
35
|
+
from .validator import validate
|
|
36
|
+
|
|
37
|
+
__version__ = "0.1.0"
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
"validate",
|
|
41
|
+
"ErrorCodes",
|
|
42
|
+
"Config",
|
|
43
|
+
"Result",
|
|
44
|
+
"Rules",
|
|
45
|
+
"Data",
|
|
46
|
+
"Detail",
|
|
47
|
+
"CustomRule",
|
|
48
|
+
"InvalidRuleError",
|
|
49
|
+
"ValidationError",
|
|
50
|
+
"QUOTE_NONE",
|
|
51
|
+
"QUOTE_SINGLE",
|
|
52
|
+
"QUOTE_DOUBLE",
|
|
53
|
+
"QUOTE_BACKTICK",
|
|
54
|
+
"__version__",
|
|
55
|
+
]
|
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
"""Checks for a single value against a list of rule tokens."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import re
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from typing import Any, Optional
|
|
9
|
+
|
|
10
|
+
from . import codes
|
|
11
|
+
from .formats import FORMAT_PATTERNS, translate_user_regex
|
|
12
|
+
from .paths import is_array, is_object
|
|
13
|
+
from .rules import (
|
|
14
|
+
DATA_TYPES,
|
|
15
|
+
NUMBER_TYPES,
|
|
16
|
+
STRING_FORMATS,
|
|
17
|
+
ARGUMENT_RULES,
|
|
18
|
+
custom_error,
|
|
19
|
+
custom_field,
|
|
20
|
+
has_token,
|
|
21
|
+
numeric_context,
|
|
22
|
+
split_arg,
|
|
23
|
+
string_context,
|
|
24
|
+
)
|
|
25
|
+
from .types import Detail
|
|
26
|
+
|
|
27
|
+
_MISSING = object()
|
|
28
|
+
|
|
29
|
+
#: Message fragments that differ between a number and a numeric string.
|
|
30
|
+
_NUMBER_WORD = {False: "number", True: "numeric string"}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def is_number(value: Any) -> bool:
|
|
34
|
+
"""A number is an int or float, but never a bool.
|
|
35
|
+
|
|
36
|
+
``isinstance(True, int)`` is True in Python, so booleans would otherwise
|
|
37
|
+
satisfy every numeric rule.
|
|
38
|
+
"""
|
|
39
|
+
if isinstance(value, bool):
|
|
40
|
+
return False
|
|
41
|
+
return isinstance(value, (int, float))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def to_number(value: Any) -> Optional[float]:
|
|
45
|
+
"""Coerce a value to a float for comparison, or None if it is not numeric."""
|
|
46
|
+
if isinstance(value, bool):
|
|
47
|
+
return None
|
|
48
|
+
if isinstance(value, (int, float)):
|
|
49
|
+
return float(value)
|
|
50
|
+
if isinstance(value, str):
|
|
51
|
+
try:
|
|
52
|
+
return float(value.strip())
|
|
53
|
+
except ValueError:
|
|
54
|
+
return None
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def number_text(value: Any) -> str:
|
|
59
|
+
"""Render a number the way the digit-counting rules read it."""
|
|
60
|
+
if isinstance(value, str):
|
|
61
|
+
return value.strip()
|
|
62
|
+
if isinstance(value, float) and value.is_integer() and not math.isinf(value):
|
|
63
|
+
return str(int(value))
|
|
64
|
+
return str(value)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _is_integral(value: Any) -> bool:
|
|
68
|
+
"""Whether a value has no fractional part."""
|
|
69
|
+
if isinstance(value, int) and not isinstance(value, bool):
|
|
70
|
+
return True
|
|
71
|
+
if isinstance(value, float):
|
|
72
|
+
return not math.isnan(value) and not math.isinf(value) and value.is_integer()
|
|
73
|
+
return False
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class Checker:
|
|
77
|
+
"""Accumulates failures for one field.
|
|
78
|
+
|
|
79
|
+
``start`` records how many errors the shared list already held, so
|
|
80
|
+
``failed`` asks "did this field fail", not "has anything failed so far".
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
__slots__ = ("key", "prefix", "tokens", "errors", "start")
|
|
84
|
+
|
|
85
|
+
def __init__(self, key: str, prefix: str, tokens: list[str], errors: list[Detail]) -> None:
|
|
86
|
+
self.key = key
|
|
87
|
+
self.prefix = prefix
|
|
88
|
+
self.tokens = tokens
|
|
89
|
+
self.errors = errors
|
|
90
|
+
self.start = len(errors)
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def label(self) -> str:
|
|
94
|
+
name = custom_field(self.tokens, self.key) or self.key
|
|
95
|
+
return f"{self.prefix}.{name}" if self.prefix else name
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def failed(self) -> bool:
|
|
99
|
+
return len(self.errors) > self.start
|
|
100
|
+
|
|
101
|
+
def fail(self, code: str, message: str) -> None:
|
|
102
|
+
override = custom_error(self.tokens)
|
|
103
|
+
self.errors.append(
|
|
104
|
+
{"field": self.label, "message": override if override is not None else message, "code": code}
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
def require_present(self, value: Any, present: bool) -> bool:
|
|
108
|
+
if not present or value is None:
|
|
109
|
+
self.fail(codes.REQUIRED, f'"{self.label}" is required')
|
|
110
|
+
return False
|
|
111
|
+
return True
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def check_single(
|
|
115
|
+
key: str,
|
|
116
|
+
prefix: str,
|
|
117
|
+
value: Any,
|
|
118
|
+
present: bool,
|
|
119
|
+
tokens: list[str],
|
|
120
|
+
errors: list[Detail],
|
|
121
|
+
) -> None:
|
|
122
|
+
"""Apply a token list to one value, stopping at the first failure."""
|
|
123
|
+
c = Checker(key, prefix, tokens, errors)
|
|
124
|
+
|
|
125
|
+
# optional keys off absence; nullable keys off an explicit None.
|
|
126
|
+
if has_token(tokens, "optional") and not present:
|
|
127
|
+
return
|
|
128
|
+
if has_token(tokens, "nullable") and present and value is None:
|
|
129
|
+
return
|
|
130
|
+
|
|
131
|
+
for i, token in enumerate(tokens):
|
|
132
|
+
previous = tokens[:i]
|
|
133
|
+
|
|
134
|
+
if token in ("optional", "nullable", ""):
|
|
135
|
+
continue
|
|
136
|
+
|
|
137
|
+
if token in DATA_TYPES:
|
|
138
|
+
check_data_type(c, value, present, token, previous)
|
|
139
|
+
elif token in STRING_FORMATS:
|
|
140
|
+
check_string_format(c, value, present, token, previous)
|
|
141
|
+
elif token in NUMBER_TYPES:
|
|
142
|
+
check_number_type(c, value, present, token, previous)
|
|
143
|
+
elif token.startswith("arrayof:"):
|
|
144
|
+
check_array_of(c, value, present, token, previous)
|
|
145
|
+
else:
|
|
146
|
+
parts = split_arg(token)
|
|
147
|
+
if parts is not None and parts[0] in ARGUMENT_RULES:
|
|
148
|
+
kind, arg = parts
|
|
149
|
+
if kind in ("field", "error"):
|
|
150
|
+
continue
|
|
151
|
+
check_constraint(c, value, present, kind, arg, previous)
|
|
152
|
+
|
|
153
|
+
if c.failed:
|
|
154
|
+
return
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def check_data_type(c: Checker, value: Any, present: bool, data_type: str, previous: list[str]) -> None:
|
|
158
|
+
if not c.require_present(value, present):
|
|
159
|
+
return
|
|
160
|
+
|
|
161
|
+
as_string = string_context(previous)
|
|
162
|
+
|
|
163
|
+
if data_type == "string":
|
|
164
|
+
if not isinstance(value, str):
|
|
165
|
+
c.fail(codes.NOT_STRING, f'"{c.label}" must be string')
|
|
166
|
+
return
|
|
167
|
+
|
|
168
|
+
if data_type == "number":
|
|
169
|
+
if as_string:
|
|
170
|
+
if not isinstance(value, str):
|
|
171
|
+
c.fail(codes.NOT_STRING, f'"{c.label}" must be string')
|
|
172
|
+
return
|
|
173
|
+
if to_number(value) is None:
|
|
174
|
+
c.fail(codes.NOT_NUMERIC_STRING, f'"{c.label}" must be a valid numeric string')
|
|
175
|
+
return
|
|
176
|
+
if not is_number(value):
|
|
177
|
+
c.fail(codes.NOT_NUMBER, f'"{c.label}" must be a valid number')
|
|
178
|
+
return
|
|
179
|
+
|
|
180
|
+
if data_type == "boolean":
|
|
181
|
+
if as_string:
|
|
182
|
+
if not isinstance(value, str) or value not in ("true", "false"):
|
|
183
|
+
c.fail(codes.NOT_BOOLEAN_STRING, f'"{c.label}" must be a valid boolean string')
|
|
184
|
+
return
|
|
185
|
+
if not isinstance(value, bool):
|
|
186
|
+
c.fail(codes.NOT_BOOLEAN, f'"{c.label}" must be a valid boolean')
|
|
187
|
+
return
|
|
188
|
+
|
|
189
|
+
if data_type == "array":
|
|
190
|
+
if not is_array(value):
|
|
191
|
+
c.fail(codes.NOT_ARRAY, f'"{c.label}" must be an array')
|
|
192
|
+
return
|
|
193
|
+
|
|
194
|
+
if data_type == "object":
|
|
195
|
+
if not is_object(value):
|
|
196
|
+
c.fail(codes.NOT_OBJECT, f'"{c.label}" must be an object')
|
|
197
|
+
return
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
_FORMAT_CODES = {
|
|
201
|
+
"email": (codes.NOT_EMAIL, "must be a valid email"),
|
|
202
|
+
"url": (codes.NOT_URL, "must be a valid url"),
|
|
203
|
+
"domain": (codes.NOT_DOMAIN, "must be a valid domain"),
|
|
204
|
+
"name": (codes.NOT_NAME, "must be a valid name"),
|
|
205
|
+
"fullname": (codes.NOT_FULLNAME, "must be a valid fullname"),
|
|
206
|
+
"username": (codes.NOT_USERNAME, "must be a valid username"),
|
|
207
|
+
"alpha": (codes.NOT_ALPHA, "must be a valid alpha"),
|
|
208
|
+
"alphanumeric": (codes.NOT_ALPHANUMERIC, "must be a valid alphanumeric"),
|
|
209
|
+
"phone": (codes.NOT_PHONE, "must be a valid phone"),
|
|
210
|
+
"phonecode": (codes.NOT_PHONECODE, "must be a valid phone code"),
|
|
211
|
+
"objectid": (codes.NOT_OBJECTID, "must be a valid object id"),
|
|
212
|
+
"uuid": (codes.NOT_UUID, "must be a valid uuid"),
|
|
213
|
+
"date": (codes.NOT_DATE, "must be a valid date"),
|
|
214
|
+
"dateonly": (codes.NOT_DATEONLY, "must be a valid date"),
|
|
215
|
+
"time": (codes.NOT_TIME, "must be a valid time"),
|
|
216
|
+
"lower": (codes.NOT_LOWERCASE, "must not contains upper case letters"),
|
|
217
|
+
"upper": (codes.NOT_UPPERCASE, "must not contains lower case letters"),
|
|
218
|
+
"ip": (codes.NOT_IP, "must be a valid IP address"),
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def check_string_format(c: Checker, value: Any, present: bool, fmt: str, previous: list[str]) -> None:
|
|
223
|
+
if not c.require_present(value, present):
|
|
224
|
+
return
|
|
225
|
+
|
|
226
|
+
check_data_type(c, value, present, "string", previous)
|
|
227
|
+
if c.failed:
|
|
228
|
+
return
|
|
229
|
+
|
|
230
|
+
pattern = FORMAT_PATTERNS[fmt]
|
|
231
|
+
if not pattern.search(value):
|
|
232
|
+
code, text = _FORMAT_CODES[fmt]
|
|
233
|
+
c.fail(code, f'"{c.label}" {text}')
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def check_number_type(c: Checker, value: Any, present: bool, num_type: str, previous: list[str]) -> None:
|
|
237
|
+
if not c.require_present(value, present):
|
|
238
|
+
return
|
|
239
|
+
|
|
240
|
+
check_data_type(c, value, present, "number", previous)
|
|
241
|
+
if c.failed:
|
|
242
|
+
return
|
|
243
|
+
|
|
244
|
+
as_string = string_context(previous)
|
|
245
|
+
word = _NUMBER_WORD[as_string]
|
|
246
|
+
n = to_number(value)
|
|
247
|
+
if n is None:
|
|
248
|
+
return
|
|
249
|
+
|
|
250
|
+
integral = _is_integral(float(n)) if as_string else _is_integral(value)
|
|
251
|
+
|
|
252
|
+
if num_type == "int":
|
|
253
|
+
if not integral:
|
|
254
|
+
label = "integer string" if as_string else "integer"
|
|
255
|
+
c.fail(codes.NOT_INTEGER, f'"{c.label}" must be a valid {label}')
|
|
256
|
+
return
|
|
257
|
+
|
|
258
|
+
if num_type == "positive":
|
|
259
|
+
if not n > 0:
|
|
260
|
+
c.fail(codes.NOT_POSITIVE, f'"{c.label}" must be a valid positive {word}')
|
|
261
|
+
return
|
|
262
|
+
|
|
263
|
+
if num_type == "negative":
|
|
264
|
+
if not n < 0:
|
|
265
|
+
c.fail(codes.NOT_NEGATIVE, f'"{c.label}" must be a valid negative {word}')
|
|
266
|
+
return
|
|
267
|
+
|
|
268
|
+
if num_type == "natural":
|
|
269
|
+
if not integral or n <= 0:
|
|
270
|
+
c.fail(codes.NOT_NATURAL, f'"{c.label}" must be a valid natural {word}')
|
|
271
|
+
return
|
|
272
|
+
|
|
273
|
+
if num_type == "whole":
|
|
274
|
+
if not integral or n < 0:
|
|
275
|
+
c.fail(codes.NOT_WHOLE, f'"{c.label}" must be a valid whole {word}')
|
|
276
|
+
return
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
_ISO_FORMAT = "%Y-%m-%dT%H:%M:%S.%f"
|
|
280
|
+
|
|
281
|
+
_DATE_LAYOUTS = (
|
|
282
|
+
"%Y-%m-%dT%H:%M:%S.%f",
|
|
283
|
+
"%Y-%m-%dT%H:%M:%S",
|
|
284
|
+
"%Y-%m-%d %H:%M:%S",
|
|
285
|
+
"%Y-%m-%d",
|
|
286
|
+
"%Y-%m",
|
|
287
|
+
"%Y",
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def parse_date(text: str) -> Optional[datetime]:
|
|
292
|
+
raw = text.strip()
|
|
293
|
+
try:
|
|
294
|
+
parsed = datetime.fromisoformat(raw.replace("Z", "+00:00"))
|
|
295
|
+
return parsed.replace(tzinfo=None) if parsed.tzinfo is None else parsed.astimezone(timezone.utc).replace(tzinfo=None)
|
|
296
|
+
except ValueError:
|
|
297
|
+
pass
|
|
298
|
+
for layout in _DATE_LAYOUTS:
|
|
299
|
+
try:
|
|
300
|
+
return datetime.strptime(raw, layout)
|
|
301
|
+
except ValueError:
|
|
302
|
+
continue
|
|
303
|
+
return None
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _iso(dt: datetime) -> str:
|
|
307
|
+
"""Render a date the way the reference implementation reports bounds."""
|
|
308
|
+
return dt.strftime("%Y-%m-%dT%H:%M:%S.") + f"{dt.microsecond // 1000:03d}Z"
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _decimals(text: str) -> tuple[int, bool]:
|
|
312
|
+
i = text.rfind(".")
|
|
313
|
+
if i < 0:
|
|
314
|
+
return 0, False
|
|
315
|
+
return len(text[i + 1 :]), True
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def check_constraint(
|
|
319
|
+
c: Checker, value: Any, present: bool, kind: str, arg: str, previous: list[str]
|
|
320
|
+
) -> None:
|
|
321
|
+
if not c.require_present(value, present):
|
|
322
|
+
return
|
|
323
|
+
|
|
324
|
+
is_str = isinstance(value, str)
|
|
325
|
+
is_bool = isinstance(value, bool)
|
|
326
|
+
is_num = is_number(value)
|
|
327
|
+
numeric = numeric_context(previous)
|
|
328
|
+
arr = value if is_array(value) else None
|
|
329
|
+
|
|
330
|
+
if kind == "equal":
|
|
331
|
+
if is_str and value != arg:
|
|
332
|
+
c.fail(codes.NOT_EQUAL, f'"{c.label}" must be equal to {arg}')
|
|
333
|
+
elif is_num:
|
|
334
|
+
want = to_number(arg)
|
|
335
|
+
if want is None or float(value) != want:
|
|
336
|
+
c.fail(codes.NOT_EQUAL, f'"{c.label}" must be equal to {arg}')
|
|
337
|
+
elif is_bool:
|
|
338
|
+
if (arg == "true" and value is False) or (arg == "false" and value is True):
|
|
339
|
+
c.fail(codes.NOT_EQUAL, f'"{c.label}" must be equal to {arg}')
|
|
340
|
+
return
|
|
341
|
+
|
|
342
|
+
if kind == "size":
|
|
343
|
+
try:
|
|
344
|
+
size = int(arg)
|
|
345
|
+
except ValueError:
|
|
346
|
+
return
|
|
347
|
+
if is_str and not numeric:
|
|
348
|
+
if len(value) != size:
|
|
349
|
+
c.fail(codes.LENGTH_MISMATCH, f'"{c.label}" must have length {size}')
|
|
350
|
+
elif arr is not None:
|
|
351
|
+
if len(arr) != size:
|
|
352
|
+
c.fail(codes.LENGTH_MISMATCH, f'"{c.label}" must have length {size}')
|
|
353
|
+
elif is_num or (is_str and numeric):
|
|
354
|
+
digits = sum(1 for ch in number_text(value) if ch.isdigit())
|
|
355
|
+
if digits != size:
|
|
356
|
+
c.fail(codes.DIGITS_MISMATCH, f'"{c.label}" must have {size} digits')
|
|
357
|
+
return
|
|
358
|
+
|
|
359
|
+
if kind in ("min", "max"):
|
|
360
|
+
_check_bound(c, kind, arg, value, is_str, is_num, arr, numeric)
|
|
361
|
+
return
|
|
362
|
+
|
|
363
|
+
if kind == "regex":
|
|
364
|
+
try:
|
|
365
|
+
pattern = translate_user_regex(arg)
|
|
366
|
+
except ValueError:
|
|
367
|
+
return
|
|
368
|
+
if not is_str:
|
|
369
|
+
c.fail(codes.NOT_STRING, f'"{c.label}" must be of type string')
|
|
370
|
+
return
|
|
371
|
+
if not pattern.search(value):
|
|
372
|
+
c.fail(codes.REGEX_MISMATCH, f'"{c.label}" is invalid')
|
|
373
|
+
return
|
|
374
|
+
|
|
375
|
+
if kind in ("decimalsize", "decimalmin", "decimalmax"):
|
|
376
|
+
_check_decimals(c, kind, arg, value, is_str, is_num)
|
|
377
|
+
return
|
|
378
|
+
|
|
379
|
+
if kind == "enums":
|
|
380
|
+
options = arg.split(",")
|
|
381
|
+
if is_bool:
|
|
382
|
+
want = "true" if value else "false"
|
|
383
|
+
if want not in options:
|
|
384
|
+
c.fail(codes.ENUM_MISMATCH, f'"{c.label}" is invalid')
|
|
385
|
+
return
|
|
386
|
+
if is_str and not numeric:
|
|
387
|
+
if value not in options:
|
|
388
|
+
c.fail(codes.ENUM_MISMATCH, f'"{c.label}" is invalid')
|
|
389
|
+
return
|
|
390
|
+
if is_num or numeric:
|
|
391
|
+
target = to_number(value)
|
|
392
|
+
allowed = [n for n in (to_number(o) for o in options) if n is not None]
|
|
393
|
+
if target is None or target not in allowed:
|
|
394
|
+
c.fail(codes.ENUM_MISMATCH, f'"{c.label}" is invalid')
|
|
395
|
+
return
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _check_bound(
|
|
399
|
+
c: Checker, kind: str, arg: str, value: Any, is_str: bool, is_num: bool, arr: Any, numeric: bool
|
|
400
|
+
) -> None:
|
|
401
|
+
is_min = kind == "min"
|
|
402
|
+
bound = to_number(arg)
|
|
403
|
+
|
|
404
|
+
if bound is None:
|
|
405
|
+
limit = parse_date(arg)
|
|
406
|
+
if limit is None or not isinstance(value, str):
|
|
407
|
+
return
|
|
408
|
+
actual = parse_date(value)
|
|
409
|
+
if actual is None:
|
|
410
|
+
return
|
|
411
|
+
if is_min and actual < limit:
|
|
412
|
+
c.fail(codes.DATE_TOO_EARLY, f'"{c.label}" must be at least {_iso(limit)}')
|
|
413
|
+
elif not is_min and actual > limit:
|
|
414
|
+
c.fail(codes.DATE_TOO_LATE, f'"{c.label}" must be at most {_iso(limit)}')
|
|
415
|
+
return
|
|
416
|
+
|
|
417
|
+
if is_str and not numeric:
|
|
418
|
+
if is_min and len(value) < bound:
|
|
419
|
+
c.fail(codes.TOO_SHORT, f'"{c.label}" must have length of at least {arg}')
|
|
420
|
+
elif not is_min and len(value) > bound:
|
|
421
|
+
c.fail(codes.TOO_LONG, f'"{c.label}" must have length of at most {arg}')
|
|
422
|
+
return
|
|
423
|
+
|
|
424
|
+
if arr is not None:
|
|
425
|
+
if is_min and len(arr) < bound:
|
|
426
|
+
c.fail(codes.TOO_SHORT, f'"{c.label}" must have length of at least {arg}')
|
|
427
|
+
elif not is_min and len(arr) > bound:
|
|
428
|
+
c.fail(codes.TOO_LONG, f'"{c.label}" must have length of at most {arg}')
|
|
429
|
+
return
|
|
430
|
+
|
|
431
|
+
if is_num or (is_str and numeric):
|
|
432
|
+
actual = to_number(value)
|
|
433
|
+
if actual is None:
|
|
434
|
+
return
|
|
435
|
+
if is_min and actual < bound:
|
|
436
|
+
c.fail(codes.TOO_SMALL, f'"{c.label}" must be at least {arg}')
|
|
437
|
+
elif not is_min and actual > bound:
|
|
438
|
+
c.fail(codes.TOO_LARGE, f'"{c.label}" must be at most {arg}')
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _check_decimals(c: Checker, kind: str, arg: str, value: Any, is_str: bool, is_num: bool) -> None:
|
|
442
|
+
try:
|
|
443
|
+
want = int(arg)
|
|
444
|
+
except ValueError:
|
|
445
|
+
return
|
|
446
|
+
|
|
447
|
+
if is_str:
|
|
448
|
+
if to_number(value) is None:
|
|
449
|
+
c.fail(codes.NOT_NUMERIC_STRING, f'"{c.label}" must be a valid numeric string')
|
|
450
|
+
return
|
|
451
|
+
text = value.strip()
|
|
452
|
+
elif is_num:
|
|
453
|
+
if isinstance(value, float) and math.isnan(value):
|
|
454
|
+
# Wording kept deliberately; the reference reads "a value number".
|
|
455
|
+
c.fail(codes.NOT_A_NUMBER, f'"{c.label}" must be a value number')
|
|
456
|
+
return
|
|
457
|
+
text = number_text(value)
|
|
458
|
+
else:
|
|
459
|
+
return
|
|
460
|
+
|
|
461
|
+
digits, has_point = _decimals(text)
|
|
462
|
+
|
|
463
|
+
if kind == "decimalsize":
|
|
464
|
+
if not has_point:
|
|
465
|
+
if want > 0:
|
|
466
|
+
c.fail(codes.DECIMAL_SIZE_MISMATCH, f'"{c.label}" must have {want} decimal places')
|
|
467
|
+
return
|
|
468
|
+
if digits != want:
|
|
469
|
+
c.fail(codes.DECIMAL_SIZE_MISMATCH, f'"{c.label}" must have {want} decimal places')
|
|
470
|
+
return
|
|
471
|
+
|
|
472
|
+
if kind == "decimalmin":
|
|
473
|
+
if not has_point:
|
|
474
|
+
if want > 0:
|
|
475
|
+
c.fail(codes.DECIMAL_TOO_FEW, f'"{c.label}" must have at least {want} decimal places')
|
|
476
|
+
return
|
|
477
|
+
if digits < want:
|
|
478
|
+
c.fail(codes.DECIMAL_TOO_FEW, f'"{c.label}" must have at least {want} decimal places')
|
|
479
|
+
return
|
|
480
|
+
|
|
481
|
+
if kind == "decimalmax":
|
|
482
|
+
if not has_point:
|
|
483
|
+
return
|
|
484
|
+
if digits > want:
|
|
485
|
+
c.fail(codes.DECIMAL_TOO_MANY, f'"{c.label}" must have at most {want} decimal places')
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def check_array_of(c: Checker, value: Any, present: bool, token: str, previous: list[str]) -> None:
|
|
489
|
+
"""Apply the inner rule to every element, labelled by index."""
|
|
490
|
+
inner = token[len("arrayof:") :]
|
|
491
|
+
|
|
492
|
+
if not is_array(value):
|
|
493
|
+
c.fail(codes.NOT_ARRAY, f'"{c.label}" must be an array')
|
|
494
|
+
return
|
|
495
|
+
|
|
496
|
+
if inner in ("optional", "nullable"):
|
|
497
|
+
return
|
|
498
|
+
|
|
499
|
+
element_nullable = has_token(c.tokens, "arrayof:optional") or has_token(
|
|
500
|
+
c.tokens, "arrayof:nullable"
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
for i, element in enumerate(value):
|
|
504
|
+
missing = element is None
|
|
505
|
+
if element_nullable and missing:
|
|
506
|
+
continue
|
|
507
|
+
|
|
508
|
+
element_key = f"{c.label}[{i}]"
|
|
509
|
+
element_errors: list[Detail] = []
|
|
510
|
+
sub = Checker(element_key, "", c.tokens, element_errors)
|
|
511
|
+
|
|
512
|
+
if inner in DATA_TYPES:
|
|
513
|
+
check_data_type(sub, element, not missing, inner, previous)
|
|
514
|
+
elif inner in STRING_FORMATS:
|
|
515
|
+
check_string_format(sub, element, not missing, inner, previous)
|
|
516
|
+
elif inner in NUMBER_TYPES:
|
|
517
|
+
check_number_type(sub, element, not missing, inner, previous)
|
|
518
|
+
elif inner.startswith("arrayof:"):
|
|
519
|
+
check_array_of(sub, element, not missing, inner, previous)
|
|
520
|
+
else:
|
|
521
|
+
parts = split_arg(inner)
|
|
522
|
+
if parts is not None and parts[0] in ARGUMENT_RULES:
|
|
523
|
+
check_constraint(sub, element, not missing, parts[0], parts[1], previous)
|
|
524
|
+
|
|
525
|
+
c.errors.extend(element_errors)
|