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/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)}"