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.
@@ -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)