caspian-utils 0.0.12__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.
- casp/__init__.py +0 -0
- casp/auth.py +537 -0
- casp/cache_handler.py +180 -0
- casp/caspian_config.py +441 -0
- casp/component_decorator.py +183 -0
- casp/components_compiler.py +293 -0
- casp/html_attrs.py +93 -0
- casp/layout.py +474 -0
- casp/loading.py +25 -0
- casp/rpc.py +230 -0
- casp/scripts_type.py +21 -0
- casp/state_manager.py +134 -0
- casp/string_helpers.py +18 -0
- casp/tw.py +31 -0
- casp/validate.py +747 -0
- caspian_utils-0.0.12.dist-info/METADATA +214 -0
- caspian_utils-0.0.12.dist-info/RECORD +19 -0
- caspian_utils-0.0.12.dist-info/WHEEL +5 -0
- caspian_utils-0.0.12.dist-info/top_level.txt +1 -0
casp/validate.py
ADDED
|
@@ -0,0 +1,747 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import builtins as _builtins
|
|
3
|
+
import html
|
|
4
|
+
import ipaddress
|
|
5
|
+
import json
|
|
6
|
+
import mimetypes
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import uuid as _uuid
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
|
|
12
|
+
from enum import Enum
|
|
13
|
+
from typing import Any, Iterable, Optional, Protocol, TypeVar, Union, runtime_checkable, cast
|
|
14
|
+
import ulid
|
|
15
|
+
|
|
16
|
+
try:
|
|
17
|
+
# Recommended
|
|
18
|
+
from email_validator import validate_email as _validate_email, EmailNotValidError # type: ignore
|
|
19
|
+
except ImportError: # pragma: no cover
|
|
20
|
+
_validate_email = None
|
|
21
|
+
EmailNotValidError = Exception
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
# Optional (strtotime-like parsing)
|
|
25
|
+
from dateutil.parser import parse as _dateutil_parse # type: ignore
|
|
26
|
+
except ImportError: # pragma: no cover
|
|
27
|
+
_dateutil_parse = None
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
# Optional (real MIME sniffing)
|
|
31
|
+
import magic # type: ignore
|
|
32
|
+
except ImportError: # pragma: no cover
|
|
33
|
+
magic = None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# Avoid method-name collisions in type positions inside the class.
|
|
37
|
+
IntT = _builtins.int
|
|
38
|
+
FloatT = _builtins.float
|
|
39
|
+
StrT = _builtins.str
|
|
40
|
+
TEnum = TypeVar("TEnum", bound=Enum)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@runtime_checkable
|
|
44
|
+
class _ReadableSeekable(Protocol):
|
|
45
|
+
def read(self, n: int = ...) -> bytes: ...
|
|
46
|
+
def tell(self) -> int: ...
|
|
47
|
+
def seek(self, offset: int, whence: int = ...) -> int: ...
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Validate:
|
|
51
|
+
# -------------------------
|
|
52
|
+
# Helpers
|
|
53
|
+
# -------------------------
|
|
54
|
+
|
|
55
|
+
@staticmethod
|
|
56
|
+
def _php_to_py_format(fmt: str) -> str:
|
|
57
|
+
"""
|
|
58
|
+
Minimal PHP date format -> Python strptime/strftime mapping.
|
|
59
|
+
"""
|
|
60
|
+
mapping: dict[str, str] = {
|
|
61
|
+
"Y": "%Y",
|
|
62
|
+
"m": "%m",
|
|
63
|
+
"d": "%d",
|
|
64
|
+
"H": "%H",
|
|
65
|
+
"i": "%M",
|
|
66
|
+
"s": "%S",
|
|
67
|
+
"u": "%f",
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
out: list[str] = []
|
|
71
|
+
i = 0
|
|
72
|
+
while i < len(fmt):
|
|
73
|
+
ch = fmt[i]
|
|
74
|
+
out.append(mapping.get(ch, ch))
|
|
75
|
+
i += 1
|
|
76
|
+
return "".join(out)
|
|
77
|
+
|
|
78
|
+
@staticmethod
|
|
79
|
+
def _is_empty_required(value: Any) -> bool:
|
|
80
|
+
if value == "0":
|
|
81
|
+
return False
|
|
82
|
+
if value is None:
|
|
83
|
+
return True
|
|
84
|
+
if isinstance(value, str):
|
|
85
|
+
return len(value.strip()) == 0
|
|
86
|
+
if isinstance(value, (list, tuple, dict, set)):
|
|
87
|
+
return len(value) == 0
|
|
88
|
+
return False
|
|
89
|
+
|
|
90
|
+
@staticmethod
|
|
91
|
+
def _to_str(value: Any) -> str:
|
|
92
|
+
if value is None:
|
|
93
|
+
return ""
|
|
94
|
+
if isinstance(value, datetime):
|
|
95
|
+
return value.strftime("%Y-%m-%d %H:%M:%S")
|
|
96
|
+
return str(value)
|
|
97
|
+
|
|
98
|
+
@staticmethod
|
|
99
|
+
def _parse_datetime_any(value: Any) -> Optional[datetime]:
|
|
100
|
+
if value is None or value == "":
|
|
101
|
+
return None
|
|
102
|
+
if isinstance(value, datetime):
|
|
103
|
+
return value
|
|
104
|
+
s = str(value).strip()
|
|
105
|
+
if not s:
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
if _dateutil_parse:
|
|
109
|
+
try:
|
|
110
|
+
return _dateutil_parse(s)
|
|
111
|
+
except Exception:
|
|
112
|
+
pass
|
|
113
|
+
|
|
114
|
+
# Fallback
|
|
115
|
+
try:
|
|
116
|
+
return datetime.fromisoformat(s)
|
|
117
|
+
except Exception:
|
|
118
|
+
pass
|
|
119
|
+
|
|
120
|
+
for fmt in ("%Y-%m-%d", "%Y-%m-%d %H:%M:%S"):
|
|
121
|
+
try:
|
|
122
|
+
return datetime.strptime(s, fmt)
|
|
123
|
+
except Exception:
|
|
124
|
+
continue
|
|
125
|
+
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
# -------------------------
|
|
129
|
+
# String Validation
|
|
130
|
+
# -------------------------
|
|
131
|
+
|
|
132
|
+
@staticmethod
|
|
133
|
+
def string(value: Any, escape_html: bool = True) -> str:
|
|
134
|
+
s = Validate._to_str(value).strip()
|
|
135
|
+
return html.escape(s, quote=True) if escape_html else s
|
|
136
|
+
|
|
137
|
+
@staticmethod
|
|
138
|
+
def email(value: Any) -> Optional[str]:
|
|
139
|
+
if value is None:
|
|
140
|
+
return None
|
|
141
|
+
s = str(value).strip()
|
|
142
|
+
if not s:
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
if _validate_email:
|
|
146
|
+
try:
|
|
147
|
+
_validate_email(s)
|
|
148
|
+
return s
|
|
149
|
+
except EmailNotValidError:
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
# Basic fallback
|
|
153
|
+
return s if re.fullmatch(r"[^@\s]+@[^@\s]+\.[^@\s]+", s) else None
|
|
154
|
+
|
|
155
|
+
@staticmethod
|
|
156
|
+
def url(value: Any) -> Optional[str]:
|
|
157
|
+
if value is None:
|
|
158
|
+
return None
|
|
159
|
+
s = str(value).strip()
|
|
160
|
+
if not s:
|
|
161
|
+
return None
|
|
162
|
+
return s if re.match(r"^(https?://)([^/\s]+)(/.*)?$", s, re.IGNORECASE) else None
|
|
163
|
+
|
|
164
|
+
@staticmethod
|
|
165
|
+
def ip(value: Any) -> Optional[str]:
|
|
166
|
+
if value is None:
|
|
167
|
+
return None
|
|
168
|
+
s = str(value).strip()
|
|
169
|
+
try:
|
|
170
|
+
ipaddress.ip_address(s)
|
|
171
|
+
return s
|
|
172
|
+
except Exception:
|
|
173
|
+
return None
|
|
174
|
+
|
|
175
|
+
@staticmethod
|
|
176
|
+
def uuid(value: Any) -> Optional[str]:
|
|
177
|
+
if value is None:
|
|
178
|
+
return None
|
|
179
|
+
s = str(value).strip()
|
|
180
|
+
try:
|
|
181
|
+
_uuid.UUID(s)
|
|
182
|
+
return s
|
|
183
|
+
except Exception:
|
|
184
|
+
return None
|
|
185
|
+
|
|
186
|
+
@staticmethod
|
|
187
|
+
def ulid(value: Any) -> Optional[str]:
|
|
188
|
+
if value is None:
|
|
189
|
+
return None
|
|
190
|
+
s = str(value).strip()
|
|
191
|
+
if not s:
|
|
192
|
+
return None
|
|
193
|
+
try:
|
|
194
|
+
ulid.ULID.from_str(s)
|
|
195
|
+
return s
|
|
196
|
+
except Exception:
|
|
197
|
+
return None
|
|
198
|
+
|
|
199
|
+
@staticmethod
|
|
200
|
+
def cuid(value: Any) -> Optional[str]:
|
|
201
|
+
return value if isinstance(value, str) and re.fullmatch(r"^c[0-9a-z]{24}$", value) else None
|
|
202
|
+
|
|
203
|
+
@staticmethod
|
|
204
|
+
def cuid2(value: Any) -> Optional[str]:
|
|
205
|
+
if not isinstance(value, str):
|
|
206
|
+
return None
|
|
207
|
+
# Strict CUID2 check: lowercase a-z and 0-9 only.
|
|
208
|
+
return value if re.fullmatch(r"^[a-z0-9]{20,}$", value) else None
|
|
209
|
+
|
|
210
|
+
@staticmethod
|
|
211
|
+
def nanoid(value: Any, length: int = 21) -> Optional[str]:
|
|
212
|
+
if not isinstance(value, str):
|
|
213
|
+
return None
|
|
214
|
+
regex = f"^[0-9a-zA-Z_-]{{{length}}}$"
|
|
215
|
+
return value if re.fullmatch(regex, value) else None
|
|
216
|
+
|
|
217
|
+
@staticmethod
|
|
218
|
+
def bytes(value: Any) -> Optional[str]:
|
|
219
|
+
if value is None:
|
|
220
|
+
return None
|
|
221
|
+
s = str(value).strip()
|
|
222
|
+
return s if re.fullmatch(r"^[0-9]+[kKmMgGtT]?[bB]?$", s) else None
|
|
223
|
+
|
|
224
|
+
@staticmethod
|
|
225
|
+
def xml(value: Any) -> Optional[str]:
|
|
226
|
+
if value is None:
|
|
227
|
+
return None
|
|
228
|
+
s = str(value)
|
|
229
|
+
return s if re.match(r"^<\?xml", s) else None
|
|
230
|
+
|
|
231
|
+
# -------------------------
|
|
232
|
+
# Number Validation
|
|
233
|
+
# -------------------------
|
|
234
|
+
|
|
235
|
+
@staticmethod
|
|
236
|
+
def int(value: Any) -> Optional[IntT]:
|
|
237
|
+
if value is None or value == "":
|
|
238
|
+
return None
|
|
239
|
+
if isinstance(value, bool):
|
|
240
|
+
return None
|
|
241
|
+
if isinstance(value, _builtins.int):
|
|
242
|
+
return value
|
|
243
|
+
|
|
244
|
+
s = str(value).strip()
|
|
245
|
+
if not re.fullmatch(r"[+-]?\d+", s):
|
|
246
|
+
return None
|
|
247
|
+
try:
|
|
248
|
+
return _builtins.int(s)
|
|
249
|
+
except Exception:
|
|
250
|
+
return None
|
|
251
|
+
|
|
252
|
+
@staticmethod
|
|
253
|
+
def big_int(value: Any) -> Optional[IntT]:
|
|
254
|
+
return Validate.int(value)
|
|
255
|
+
|
|
256
|
+
@staticmethod
|
|
257
|
+
def float(value: Any) -> Optional[FloatT]:
|
|
258
|
+
if value is None or value == "":
|
|
259
|
+
return None
|
|
260
|
+
if isinstance(value, bool):
|
|
261
|
+
return None
|
|
262
|
+
try:
|
|
263
|
+
f = _builtins.float(value)
|
|
264
|
+
if f != f: # NaN
|
|
265
|
+
return None
|
|
266
|
+
return f
|
|
267
|
+
except Exception:
|
|
268
|
+
return None
|
|
269
|
+
|
|
270
|
+
@staticmethod
|
|
271
|
+
def decimal(value: Any, scale: _builtins.int = 30) -> Optional[Decimal]:
|
|
272
|
+
if value is None or value == "":
|
|
273
|
+
return None
|
|
274
|
+
try:
|
|
275
|
+
d = Decimal(str(value))
|
|
276
|
+
quant = Decimal("1").scaleb(-scale)
|
|
277
|
+
return d.quantize(quant, rounding=ROUND_HALF_UP)
|
|
278
|
+
except (InvalidOperation, ValueError):
|
|
279
|
+
return None
|
|
280
|
+
|
|
281
|
+
# -------------------------
|
|
282
|
+
# Date Validation
|
|
283
|
+
# -------------------------
|
|
284
|
+
|
|
285
|
+
@staticmethod
|
|
286
|
+
def date(value: Any, fmt: str = "Y-m-d") -> Optional[str]:
|
|
287
|
+
if value is None or value == "":
|
|
288
|
+
return None
|
|
289
|
+
|
|
290
|
+
if isinstance(value, datetime):
|
|
291
|
+
dt = value
|
|
292
|
+
else:
|
|
293
|
+
s = str(value)
|
|
294
|
+
py_fmt = Validate._php_to_py_format(fmt)
|
|
295
|
+
try:
|
|
296
|
+
dt = datetime.strptime(s, py_fmt)
|
|
297
|
+
except Exception:
|
|
298
|
+
return None
|
|
299
|
+
if dt.strftime(py_fmt) != s:
|
|
300
|
+
return None
|
|
301
|
+
|
|
302
|
+
return dt.strftime(Validate._php_to_py_format(fmt))
|
|
303
|
+
|
|
304
|
+
@staticmethod
|
|
305
|
+
def date_time(value: Any, fmt: str = "Y-m-d H:i:s") -> Optional[str]:
|
|
306
|
+
if value is None or value == "":
|
|
307
|
+
return None
|
|
308
|
+
dt = value if isinstance(
|
|
309
|
+
value, datetime) else Validate._parse_datetime_any(value)
|
|
310
|
+
if dt is None:
|
|
311
|
+
return None
|
|
312
|
+
return dt.strftime(Validate._php_to_py_format(fmt))
|
|
313
|
+
|
|
314
|
+
# -------------------------
|
|
315
|
+
# Boolean Validation
|
|
316
|
+
# -------------------------
|
|
317
|
+
|
|
318
|
+
@staticmethod
|
|
319
|
+
def boolean(value: Any) -> Optional[bool]:
|
|
320
|
+
if value is None:
|
|
321
|
+
return None
|
|
322
|
+
if isinstance(value, bool):
|
|
323
|
+
return value
|
|
324
|
+
s = str(value).strip().lower()
|
|
325
|
+
|
|
326
|
+
true_set = {"1", "true", "on", "yes", "y"}
|
|
327
|
+
false_set = {"0", "false", "off", "no", "n"}
|
|
328
|
+
if s in true_set:
|
|
329
|
+
return True
|
|
330
|
+
if s in false_set:
|
|
331
|
+
return False
|
|
332
|
+
return None
|
|
333
|
+
|
|
334
|
+
# -------------------------
|
|
335
|
+
# Other Validation
|
|
336
|
+
# -------------------------
|
|
337
|
+
|
|
338
|
+
@staticmethod
|
|
339
|
+
def json(value: Any) -> str:
|
|
340
|
+
if isinstance(value, str):
|
|
341
|
+
try:
|
|
342
|
+
json.loads(value)
|
|
343
|
+
return value
|
|
344
|
+
except Exception as e:
|
|
345
|
+
return str(e)
|
|
346
|
+
return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
|
|
347
|
+
|
|
348
|
+
@staticmethod
|
|
349
|
+
def is_json(value: Any) -> bool:
|
|
350
|
+
if not isinstance(value, str):
|
|
351
|
+
return False
|
|
352
|
+
try:
|
|
353
|
+
json.loads(value)
|
|
354
|
+
return True
|
|
355
|
+
except Exception:
|
|
356
|
+
return False
|
|
357
|
+
|
|
358
|
+
@staticmethod
|
|
359
|
+
def enum(value: Any, allowed_values: Iterable[Any]) -> bool:
|
|
360
|
+
return value in list(allowed_values)
|
|
361
|
+
|
|
362
|
+
@staticmethod
|
|
363
|
+
def enum_class(
|
|
364
|
+
value: Union[str, IntT, TEnum, list[Union[str, IntT, TEnum]]],
|
|
365
|
+
enum_cls: type[Any],
|
|
366
|
+
) -> Union[str, IntT, list[Union[str, IntT]], None]:
|
|
367
|
+
if not isinstance(enum_cls, type) or not issubclass(enum_cls, Enum):
|
|
368
|
+
raise ValueError(f"'{enum_cls}' is not an Enum class.")
|
|
369
|
+
|
|
370
|
+
enum_t = cast(type[Enum], enum_cls)
|
|
371
|
+
|
|
372
|
+
def cast_one(v: Any) -> Optional[Union[str, IntT]]:
|
|
373
|
+
if isinstance(v, enum_t):
|
|
374
|
+
return cast(Union[str, IntT], getattr(v, "value", None))
|
|
375
|
+
for member in enum_t:
|
|
376
|
+
if getattr(member, "value", None) == v:
|
|
377
|
+
return cast(Union[str, IntT], member.value)
|
|
378
|
+
return None
|
|
379
|
+
|
|
380
|
+
if isinstance(value, list):
|
|
381
|
+
out: list[Union[str, IntT]] = []
|
|
382
|
+
for item in value:
|
|
383
|
+
c = cast_one(item)
|
|
384
|
+
if c is None:
|
|
385
|
+
return None
|
|
386
|
+
out.append(c)
|
|
387
|
+
return out
|
|
388
|
+
|
|
389
|
+
return cast_one(value)
|
|
390
|
+
|
|
391
|
+
@staticmethod
|
|
392
|
+
def emojis(content: str) -> str:
|
|
393
|
+
emoji_map: dict[str, str] = {
|
|
394
|
+
":)": "😊", ":-)": "😊",
|
|
395
|
+
":(": "☹️", ":-(": "☹️",
|
|
396
|
+
":D": "😄", ":-D": "😄",
|
|
397
|
+
":P": "😛", ":-P": "😛",
|
|
398
|
+
";)": "😉", ";-)": "😉",
|
|
399
|
+
"<3": "❤️", "</3": "💔",
|
|
400
|
+
":wave:": "👋",
|
|
401
|
+
":thumbsup:": "👍",
|
|
402
|
+
":thumbsdown:": "👎",
|
|
403
|
+
":fire:": "🔥",
|
|
404
|
+
":100:": "💯",
|
|
405
|
+
":poop:": "💩",
|
|
406
|
+
}
|
|
407
|
+
for k in sorted(emoji_map.keys(), key=len, reverse=True):
|
|
408
|
+
content = content.replace(k, emoji_map[k])
|
|
409
|
+
return content
|
|
410
|
+
|
|
411
|
+
# -------------------------
|
|
412
|
+
# Rules Engine
|
|
413
|
+
# -------------------------
|
|
414
|
+
|
|
415
|
+
@staticmethod
|
|
416
|
+
def with_rules(
|
|
417
|
+
value: Any,
|
|
418
|
+
rules: Union[str, list[str]],
|
|
419
|
+
confirmation_value: Any = None
|
|
420
|
+
) -> Union[bool, str, None]:
|
|
421
|
+
|
|
422
|
+
if isinstance(rules, list):
|
|
423
|
+
rules_array = rules
|
|
424
|
+
else:
|
|
425
|
+
rules_array = rules.split("|") if rules else []
|
|
426
|
+
|
|
427
|
+
for rule in rules_array:
|
|
428
|
+
if ":" in rule:
|
|
429
|
+
rule_name, parameter = rule.split(":", 1)
|
|
430
|
+
result = Validate.apply_rule(
|
|
431
|
+
rule_name, parameter, value, confirmation_value)
|
|
432
|
+
else:
|
|
433
|
+
result = Validate.apply_rule(
|
|
434
|
+
rule, None, value, confirmation_value)
|
|
435
|
+
|
|
436
|
+
if result is not True:
|
|
437
|
+
return result
|
|
438
|
+
return True
|
|
439
|
+
|
|
440
|
+
@staticmethod
|
|
441
|
+
def apply_rule(rule: str, parameter: Optional[str], value: Any, confirmation_value: Any = None) -> Union[bool, str]:
|
|
442
|
+
s = "" if value is None else str(value)
|
|
443
|
+
|
|
444
|
+
if rule == "required":
|
|
445
|
+
return True if not Validate._is_empty_required(value) else "This field is required."
|
|
446
|
+
|
|
447
|
+
if rule == "min":
|
|
448
|
+
n = _builtins.int(parameter or "0")
|
|
449
|
+
return True if len(s) >= n else f"This field must be at least {n} characters long."
|
|
450
|
+
|
|
451
|
+
if rule == "max":
|
|
452
|
+
n = _builtins.int(parameter or "0")
|
|
453
|
+
return True if len(s) <= n else f"This field must not exceed {n} characters."
|
|
454
|
+
|
|
455
|
+
if rule == "startsWith":
|
|
456
|
+
p = parameter or ""
|
|
457
|
+
return True if s.startswith(p) else f"This field must start with {p}."
|
|
458
|
+
|
|
459
|
+
if rule == "endsWith":
|
|
460
|
+
p = parameter or ""
|
|
461
|
+
return True if s.endswith(p) else f"This field must end with {p}."
|
|
462
|
+
|
|
463
|
+
if rule == "confirmed":
|
|
464
|
+
return True if confirmation_value == value else f"The {rule} confirmation does not match."
|
|
465
|
+
|
|
466
|
+
if rule == "email":
|
|
467
|
+
return True if Validate.email(value) else "This field must be a valid email address."
|
|
468
|
+
|
|
469
|
+
if rule == "url":
|
|
470
|
+
return True if Validate.url(value) else "This field must be a valid URL."
|
|
471
|
+
|
|
472
|
+
if rule == "ip":
|
|
473
|
+
return True if Validate.ip(value) else "This field must be a valid IP address."
|
|
474
|
+
|
|
475
|
+
if rule == "uuid":
|
|
476
|
+
return True if Validate.uuid(value) else "This field must be a valid UUID."
|
|
477
|
+
|
|
478
|
+
if rule == "ulid":
|
|
479
|
+
return True if Validate.ulid(value) else "This field must be a valid ULID."
|
|
480
|
+
|
|
481
|
+
if rule == "cuid":
|
|
482
|
+
return True if Validate.cuid(value) else "This field must be a valid CUID."
|
|
483
|
+
|
|
484
|
+
if rule == "cuid2":
|
|
485
|
+
return True if Validate.cuid2(value) else "This field must be a valid CUID2."
|
|
486
|
+
|
|
487
|
+
if rule == "nanoid":
|
|
488
|
+
return True if Validate.nanoid(value) else "This field must be a valid NanoID."
|
|
489
|
+
|
|
490
|
+
if rule == "int":
|
|
491
|
+
return True if Validate.int(value) is not None else "This field must be an integer."
|
|
492
|
+
|
|
493
|
+
if rule == "float":
|
|
494
|
+
return True if Validate.float(value) is not None else "This field must be a float."
|
|
495
|
+
|
|
496
|
+
if rule == "boolean":
|
|
497
|
+
return True if Validate.boolean(value) is not None else "This field must be a boolean."
|
|
498
|
+
|
|
499
|
+
if rule == "in":
|
|
500
|
+
opts = [x.strip()
|
|
501
|
+
for x in (parameter or "").split(",") if x.strip() != ""]
|
|
502
|
+
return True if s in opts else "The selected value is invalid."
|
|
503
|
+
|
|
504
|
+
if rule == "notIn":
|
|
505
|
+
opts = [x.strip()
|
|
506
|
+
for x in (parameter or "").split(",") if x.strip() != ""]
|
|
507
|
+
return True if s not in opts else "The selected value is invalid."
|
|
508
|
+
|
|
509
|
+
if rule == "size":
|
|
510
|
+
n = _builtins.int(parameter or "0")
|
|
511
|
+
return True if len(s) == n else f"This field must be exactly {n} characters long."
|
|
512
|
+
|
|
513
|
+
if rule == "between":
|
|
514
|
+
parts = (parameter or "").split(",", 1)
|
|
515
|
+
if len(parts) != 2:
|
|
516
|
+
return "This field has an invalid between rule."
|
|
517
|
+
mn, mx = _builtins.int(parts[0]), _builtins.int(parts[1])
|
|
518
|
+
return True if (mn <= len(s) <= mx) else f"This field must be between {mn} and {mx} characters long."
|
|
519
|
+
|
|
520
|
+
if rule == "date":
|
|
521
|
+
fmt = parameter or "Y-m-d"
|
|
522
|
+
return True if Validate.date(value, fmt) else "This field must be a valid date."
|
|
523
|
+
|
|
524
|
+
if rule == "dateFormat":
|
|
525
|
+
if not parameter:
|
|
526
|
+
return "This field has an invalid dateFormat rule."
|
|
527
|
+
py_fmt = Validate._php_to_py_format(parameter)
|
|
528
|
+
try:
|
|
529
|
+
datetime.strptime(s, py_fmt)
|
|
530
|
+
return True
|
|
531
|
+
except Exception:
|
|
532
|
+
return f"This field must match the format {parameter}."
|
|
533
|
+
|
|
534
|
+
if rule == "before":
|
|
535
|
+
left = Validate._parse_datetime_any(value)
|
|
536
|
+
right = Validate._parse_datetime_any(parameter)
|
|
537
|
+
if left is None or right is None:
|
|
538
|
+
return f"This field must be a date before {parameter}."
|
|
539
|
+
return True if left < right else f"This field must be a date before {parameter}."
|
|
540
|
+
|
|
541
|
+
if rule == "after":
|
|
542
|
+
left = Validate._parse_datetime_any(value)
|
|
543
|
+
right = Validate._parse_datetime_any(parameter)
|
|
544
|
+
if left is None or right is None:
|
|
545
|
+
return f"This field must be a date after {parameter}."
|
|
546
|
+
return True if left > right else f"This field must be a date after {parameter}."
|
|
547
|
+
|
|
548
|
+
if rule == "json":
|
|
549
|
+
return True if Validate.is_json(value) else "This field must be a valid JSON string."
|
|
550
|
+
|
|
551
|
+
if rule == "regex":
|
|
552
|
+
if not parameter:
|
|
553
|
+
return "This field format is invalid."
|
|
554
|
+
try:
|
|
555
|
+
return True if re.search(parameter, s) else "This field format is invalid."
|
|
556
|
+
except re.error:
|
|
557
|
+
return "This field format is invalid."
|
|
558
|
+
|
|
559
|
+
if rule == "digits":
|
|
560
|
+
n = _builtins.int(parameter or "0")
|
|
561
|
+
return True if s.isdigit() and len(s) == n else f"This field must be {n} digits."
|
|
562
|
+
|
|
563
|
+
if rule == "digitsBetween":
|
|
564
|
+
parts = (parameter or "").split(",", 1)
|
|
565
|
+
if len(parts) != 2:
|
|
566
|
+
return "This field has an invalid digitsBetween rule."
|
|
567
|
+
mn, mx = _builtins.int(parts[0]), _builtins.int(parts[1])
|
|
568
|
+
return True if s.isdigit() and (mn <= len(s) <= mx) else f"This field must be between {mn} and {mx} digits."
|
|
569
|
+
|
|
570
|
+
if rule == "extensions":
|
|
571
|
+
exts = [x.strip()
|
|
572
|
+
for x in (parameter or "").split(",") if x.strip()]
|
|
573
|
+
return True if Validate.is_extension_allowed(value, exts) else (
|
|
574
|
+
"The file must have one of the following extensions: " +
|
|
575
|
+
", ".join(exts) + "."
|
|
576
|
+
)
|
|
577
|
+
|
|
578
|
+
if rule == "mimes":
|
|
579
|
+
mimes_ = [x.strip()
|
|
580
|
+
for x in (parameter or "").split(",") if x.strip()]
|
|
581
|
+
return True if Validate.is_mime_type_allowed(value, mimes_) else (
|
|
582
|
+
"The file must be of type: " + ", ".join(mimes_) + "."
|
|
583
|
+
)
|
|
584
|
+
|
|
585
|
+
if rule == "file":
|
|
586
|
+
return True if Validate.is_file(value) else "This field must be a valid file."
|
|
587
|
+
|
|
588
|
+
return True
|
|
589
|
+
|
|
590
|
+
# -------------------------
|
|
591
|
+
# File helpers
|
|
592
|
+
# -------------------------
|
|
593
|
+
|
|
594
|
+
@staticmethod
|
|
595
|
+
def is_file(file_value: Any) -> bool:
|
|
596
|
+
if isinstance(file_value, str):
|
|
597
|
+
return os.path.isfile(file_value)
|
|
598
|
+
return isinstance(file_value, _ReadableSeekable) or hasattr(file_value, "read")
|
|
599
|
+
|
|
600
|
+
@staticmethod
|
|
601
|
+
def is_extension_allowed(file_value: Any, allowed_extensions: list[str]) -> bool:
|
|
602
|
+
filename: Optional[str] = None
|
|
603
|
+
|
|
604
|
+
if isinstance(file_value, str):
|
|
605
|
+
filename = file_value
|
|
606
|
+
elif hasattr(file_value, "filename"):
|
|
607
|
+
maybe = getattr(file_value, "filename")
|
|
608
|
+
if isinstance(maybe, str):
|
|
609
|
+
filename = maybe
|
|
610
|
+
|
|
611
|
+
if not filename:
|
|
612
|
+
return False
|
|
613
|
+
|
|
614
|
+
ext = os.path.splitext(filename)[1].lstrip(".").lower()
|
|
615
|
+
allowed = {e.lower().lstrip(".") for e in allowed_extensions}
|
|
616
|
+
return ext in allowed
|
|
617
|
+
|
|
618
|
+
@staticmethod
|
|
619
|
+
def is_mime_type_allowed(file_value: Any, allowed_mime_types: list[str]) -> bool:
|
|
620
|
+
# Real sniffing first
|
|
621
|
+
if magic is not None:
|
|
622
|
+
try:
|
|
623
|
+
if isinstance(file_value, str) and os.path.isfile(file_value):
|
|
624
|
+
mt = magic.from_file(file_value, mime=True)
|
|
625
|
+
return mt in allowed_mime_types
|
|
626
|
+
|
|
627
|
+
if isinstance(file_value, _ReadableSeekable):
|
|
628
|
+
pos = file_value.tell()
|
|
629
|
+
data = file_value.read(2048)
|
|
630
|
+
file_value.seek(pos)
|
|
631
|
+
mt = magic.from_buffer(data, mime=True)
|
|
632
|
+
return mt in allowed_mime_types
|
|
633
|
+
except Exception:
|
|
634
|
+
pass
|
|
635
|
+
|
|
636
|
+
# Fallback: mimetypes guess by filename
|
|
637
|
+
filename: Optional[str] = None
|
|
638
|
+
if isinstance(file_value, str):
|
|
639
|
+
filename = file_value
|
|
640
|
+
elif hasattr(file_value, "filename"):
|
|
641
|
+
maybe = getattr(file_value, "filename")
|
|
642
|
+
if isinstance(maybe, str):
|
|
643
|
+
filename = maybe
|
|
644
|
+
|
|
645
|
+
if not filename:
|
|
646
|
+
return False
|
|
647
|
+
|
|
648
|
+
guessed, _ = mimetypes.guess_type(filename)
|
|
649
|
+
return guessed in allowed_mime_types
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
class Rule:
|
|
653
|
+
"""
|
|
654
|
+
IntelliSense helper for Validation rules.
|
|
655
|
+
Usage: Validate.with_rules(val, [Rule.required, Rule.min(5)])
|
|
656
|
+
"""
|
|
657
|
+
# Simple Rules (Constants)
|
|
658
|
+
REQUIRED = "required"
|
|
659
|
+
EMAIL = "email"
|
|
660
|
+
URL = "url"
|
|
661
|
+
IP = "ip"
|
|
662
|
+
UUID = "uuid"
|
|
663
|
+
ULID = "ulid"
|
|
664
|
+
CUID = "cuid"
|
|
665
|
+
CUID2 = "cuid2"
|
|
666
|
+
NANOID = "nanoid"
|
|
667
|
+
INTEGER = "int"
|
|
668
|
+
FLOAT = "float"
|
|
669
|
+
BOOLEAN = "boolean"
|
|
670
|
+
JSON = "json"
|
|
671
|
+
FILE = "file"
|
|
672
|
+
|
|
673
|
+
# Parameterized Rules (Methods)
|
|
674
|
+
@staticmethod
|
|
675
|
+
def min(value: int) -> str:
|
|
676
|
+
return f"min:{value}"
|
|
677
|
+
|
|
678
|
+
@staticmethod
|
|
679
|
+
def max(value: int) -> str:
|
|
680
|
+
return f"max:{value}"
|
|
681
|
+
|
|
682
|
+
@staticmethod
|
|
683
|
+
def size(value: int) -> str:
|
|
684
|
+
return f"size:{value}"
|
|
685
|
+
|
|
686
|
+
@staticmethod
|
|
687
|
+
def starts_with(prefix: str) -> str:
|
|
688
|
+
return f"startsWith:{prefix}"
|
|
689
|
+
|
|
690
|
+
@staticmethod
|
|
691
|
+
def ends_with(suffix: str) -> str:
|
|
692
|
+
return f"endsWith:{suffix}"
|
|
693
|
+
|
|
694
|
+
@staticmethod
|
|
695
|
+
def confirmed() -> str:
|
|
696
|
+
return "confirmed"
|
|
697
|
+
|
|
698
|
+
@staticmethod
|
|
699
|
+
def in_list(values: list[Any]) -> str:
|
|
700
|
+
# joing values with comma
|
|
701
|
+
val_str = ",".join(str(v) for v in values)
|
|
702
|
+
return f"in:{val_str}"
|
|
703
|
+
|
|
704
|
+
@staticmethod
|
|
705
|
+
def not_in_list(values: list[Any]) -> str:
|
|
706
|
+
val_str = ",".join(str(v) for v in values)
|
|
707
|
+
return f"notIn:{val_str}"
|
|
708
|
+
|
|
709
|
+
@staticmethod
|
|
710
|
+
def between(min_val: int, max_val: int) -> str:
|
|
711
|
+
return f"between:{min_val},{max_val}"
|
|
712
|
+
|
|
713
|
+
@staticmethod
|
|
714
|
+
def digits(count: int) -> str:
|
|
715
|
+
return f"digits:{count}"
|
|
716
|
+
|
|
717
|
+
@staticmethod
|
|
718
|
+
def digits_between(min_val: int, max_val: int) -> str:
|
|
719
|
+
return f"digitsBetween:{min_val},{max_val}"
|
|
720
|
+
|
|
721
|
+
@staticmethod
|
|
722
|
+
def date(fmt: str = "Y-m-d") -> str:
|
|
723
|
+
return f"date:{fmt}"
|
|
724
|
+
|
|
725
|
+
@staticmethod
|
|
726
|
+
def date_format(fmt: str) -> str:
|
|
727
|
+
return f"dateFormat:{fmt}"
|
|
728
|
+
|
|
729
|
+
@staticmethod
|
|
730
|
+
def before(date_str: str) -> str:
|
|
731
|
+
return f"before:{date_str}"
|
|
732
|
+
|
|
733
|
+
@staticmethod
|
|
734
|
+
def after(date_str: str) -> str:
|
|
735
|
+
return f"after:{date_str}"
|
|
736
|
+
|
|
737
|
+
@staticmethod
|
|
738
|
+
def regex(pattern: str) -> str:
|
|
739
|
+
return f"regex:{pattern}"
|
|
740
|
+
|
|
741
|
+
@staticmethod
|
|
742
|
+
def extensions(exts: list[str]) -> str:
|
|
743
|
+
return f"extensions:{','.join(exts)}"
|
|
744
|
+
|
|
745
|
+
@staticmethod
|
|
746
|
+
def mimes(types: list[str]) -> str:
|
|
747
|
+
return f"mimes:{','.join(types)}"
|