safeshield 1.3.2__py3-none-any.whl → 1.4.2__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.
- {safeshield-1.3.2.dist-info → safeshield-1.4.2.dist-info}/METADATA +6 -1
- safeshield-1.4.2.dist-info/RECORD +31 -0
- validator/core/validator.py +7 -7
- validator/factory.py +4 -4
- validator/rules/__init__.py +6 -8
- validator/rules/array.py +23 -30
- validator/rules/base.py +32 -8
- validator/rules/basic.py +64 -165
- validator/rules/boolean.py +157 -0
- validator/rules/comparison.py +121 -31
- validator/rules/date.py +30 -14
- validator/rules/files.py +176 -64
- validator/rules/format.py +116 -25
- validator/rules/numeric.py +188 -0
- validator/rules/string.py +68 -9
- validator/rules/utilities.py +209 -34
- validator/services/rule_conflict.py +2 -2
- validator/services/rule_error_handler.py +1 -1
- validator/services/rule_preparer.py +12 -25
- safeshield-1.3.2.dist-info/RECORD +0 -31
- validator/rules/conditional.py +0 -165
- validator/rules/type.py +0 -49
- {safeshield-1.3.2.dist-info → safeshield-1.4.2.dist-info}/LICENSE +0 -0
- {safeshield-1.3.2.dist-info → safeshield-1.4.2.dist-info}/WHEEL +0 -0
- {safeshield-1.3.2.dist-info → safeshield-1.4.2.dist-info}/top_level.txt +0 -0
validator/rules/files.py
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
from .base import
|
|
1
|
+
from .base import Rule
|
|
2
2
|
from typing import Any, Dict, List, Optional, Set, Union, Tuple, Type
|
|
3
|
+
import mimetypes
|
|
3
4
|
|
|
4
|
-
class FileRule(
|
|
5
|
+
class FileRule(Rule):
|
|
5
6
|
def validate(self, field: str, value: Any, params: List[str]) -> bool:
|
|
6
7
|
# 1. Cek file object framework (Flask/Werkzeug/FastAPI)
|
|
7
8
|
if (hasattr(value, 'filename') and
|
|
@@ -30,78 +31,171 @@ class FileRule(ValidationRule):
|
|
|
30
31
|
def message(self, field: str, params: List[str]) -> str:
|
|
31
32
|
return f"The :attribute must be a valid file"
|
|
32
33
|
|
|
33
|
-
class DimensionsRule(
|
|
34
|
+
class DimensionsRule(Rule):
|
|
35
|
+
def __init__(self, *params):
|
|
36
|
+
super().__init__(*params)
|
|
37
|
+
|
|
38
|
+
self._constraints = {
|
|
39
|
+
'min_width': 0,
|
|
40
|
+
'min_height': 0,
|
|
41
|
+
'max_width': float('inf'),
|
|
42
|
+
'max_height': float('inf'),
|
|
43
|
+
'width': None,
|
|
44
|
+
'height': None,
|
|
45
|
+
'ratio': None
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
def maxWidth(self, value: int) -> 'DimensionsRule':
|
|
49
|
+
self._constraints['width'] = None
|
|
50
|
+
self._constraints['max_width'] = value
|
|
51
|
+
return self
|
|
52
|
+
|
|
53
|
+
def maxHeight(self, value: int) -> 'DimensionsRule':
|
|
54
|
+
self._constraints['height'] = None
|
|
55
|
+
self._constraints['max_height'] = value
|
|
56
|
+
return self
|
|
57
|
+
|
|
58
|
+
def ratio(self, value: float) -> 'DimensionsRule':
|
|
59
|
+
self._constraints['ratio'] = value
|
|
60
|
+
return self
|
|
61
|
+
|
|
62
|
+
def minWidth(self, value: int) -> 'DimensionsRule':
|
|
63
|
+
self._constraints['width'] = None
|
|
64
|
+
self._constraints['min_width'] = value
|
|
65
|
+
return self
|
|
66
|
+
|
|
67
|
+
def minHeight(self, value: int) -> 'DimensionsRule':
|
|
68
|
+
self._constraints['height'] = None
|
|
69
|
+
self._constraints['min_height'] = value
|
|
70
|
+
return self
|
|
71
|
+
|
|
72
|
+
def _validate_constraint_logic(self):
|
|
73
|
+
if self._constraints['width'] is not None:
|
|
74
|
+
if (self._constraints['min_width'] > 0 or
|
|
75
|
+
self._constraints['max_width'] < float('inf')):
|
|
76
|
+
raise ValueError("Cannot specify both exact width and min/max width")
|
|
77
|
+
|
|
78
|
+
if self._constraints['max_width'] < self._constraints['min_width']:
|
|
79
|
+
raise ValueError(f"max_width ({self._constraints['max_width']}) "
|
|
80
|
+
f"cannot be less than min_width ({self._constraints['min_width']})")
|
|
81
|
+
|
|
82
|
+
if self._constraints['height'] is not None:
|
|
83
|
+
if (self._constraints['min_height'] > 0 or
|
|
84
|
+
self._constraints['max_height'] < float('inf')):
|
|
85
|
+
raise ValueError("Cannot specify both exact height and min/max height")
|
|
86
|
+
|
|
87
|
+
if self._constraints['max_height'] < self._constraints['min_height']:
|
|
88
|
+
raise ValueError(f"max_height ({self._constraints['max_height']}) "
|
|
89
|
+
f"cannot be less than min_height ({self._constraints['min_height']})")
|
|
90
|
+
|
|
34
91
|
def validate(self, field: str, value: Any, params: List[str]) -> bool:
|
|
92
|
+
self._validate_constraint_logic()
|
|
35
93
|
try:
|
|
36
|
-
|
|
37
|
-
import io
|
|
38
|
-
|
|
39
|
-
# 1. Parse dimension rules
|
|
40
|
-
min_width = min_height = 0
|
|
41
|
-
max_width = max_height = float('inf')
|
|
42
|
-
|
|
94
|
+
# 2. Parse parameters
|
|
43
95
|
for param in params:
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
min_height = max_height = exact_height
|
|
53
|
-
except (ValueError, IndexError):
|
|
54
|
-
continue
|
|
55
|
-
|
|
56
|
-
# 2. Load image with proper handling
|
|
57
|
-
img = None
|
|
58
|
-
try:
|
|
59
|
-
# Case 1: File-like object (Flask/Werkzeug/FastAPI)
|
|
60
|
-
if hasattr(value, 'read'):
|
|
61
|
-
value.seek(0) # Important for rewinding
|
|
62
|
-
img = Image.open(value)
|
|
63
|
-
value.seek(0) # Reset after reading
|
|
64
|
-
|
|
65
|
-
# Case 2: Bytes data
|
|
66
|
-
elif isinstance(value, bytes):
|
|
67
|
-
img = Image.open(io.BytesIO(value))
|
|
96
|
+
# Format Laravel: min_width=100
|
|
97
|
+
if '=' in param:
|
|
98
|
+
key, val = param.split('=', 1)
|
|
99
|
+
if key in self._constraints:
|
|
100
|
+
try:
|
|
101
|
+
self._constraints[key] = float(val)
|
|
102
|
+
except ValueError:
|
|
103
|
+
continue
|
|
68
104
|
|
|
69
|
-
#
|
|
70
|
-
elif
|
|
71
|
-
|
|
105
|
+
# Format legacy: 100x200
|
|
106
|
+
elif 'x' in param:
|
|
107
|
+
try:
|
|
108
|
+
width, height = map(int, param.split('x'))
|
|
109
|
+
self._constraints['width'] = width
|
|
110
|
+
self._constraints['height'] = height
|
|
111
|
+
except ValueError:
|
|
112
|
+
continue
|
|
72
113
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
min_height <= height <= max_height)
|
|
114
|
+
# Format ratio: 3/2
|
|
115
|
+
elif '/' in param:
|
|
116
|
+
try:
|
|
117
|
+
self._constraints['ratio'] = param
|
|
118
|
+
except ValueError:
|
|
119
|
+
continue
|
|
80
120
|
|
|
81
|
-
|
|
82
|
-
|
|
121
|
+
# 3. Load image
|
|
122
|
+
img = self._load_image(value)
|
|
123
|
+
if not img:
|
|
83
124
|
return False
|
|
84
125
|
|
|
126
|
+
width, height = img.size
|
|
127
|
+
actual_ratio = round(width / height, 2)
|
|
128
|
+
|
|
129
|
+
# 4. Validate _constraints
|
|
130
|
+
checks = [
|
|
131
|
+
(self._constraints['width'] is None or width == self._constraints['width']),
|
|
132
|
+
(self._constraints['height'] is None or height == self._constraints['height']),
|
|
133
|
+
width >= self._constraints['min_width'],
|
|
134
|
+
height >= self._constraints['min_height'],
|
|
135
|
+
width <= self._constraints['max_width'],
|
|
136
|
+
height <= self._constraints['max_height'],
|
|
137
|
+
self._check_ratio(self._constraints['ratio'], actual_ratio)
|
|
138
|
+
]
|
|
139
|
+
|
|
140
|
+
return all(checks)
|
|
141
|
+
|
|
85
142
|
except Exception as e:
|
|
86
|
-
print(f"
|
|
143
|
+
print(f"Dimension validation error: {str(e)}")
|
|
87
144
|
return False
|
|
88
|
-
|
|
145
|
+
|
|
146
|
+
def _load_image(self, value):
|
|
147
|
+
"""Helper to load image from different sources"""
|
|
148
|
+
try:
|
|
149
|
+
from PIL import Image
|
|
150
|
+
import io
|
|
151
|
+
|
|
152
|
+
if hasattr(value, 'read'): # File-like object
|
|
153
|
+
value.seek(0)
|
|
154
|
+
img = Image.open(value)
|
|
155
|
+
value.seek(0)
|
|
156
|
+
return img
|
|
157
|
+
elif isinstance(value, bytes): # Bytes
|
|
158
|
+
return Image.open(io.BytesIO(value))
|
|
159
|
+
elif isinstance(value, str): # File path
|
|
160
|
+
if value.lower().endswith(('.jpg', '.jpeg', '.png', '.gif')):
|
|
161
|
+
return Image.open(value)
|
|
162
|
+
return None
|
|
163
|
+
except:
|
|
164
|
+
return None
|
|
165
|
+
|
|
166
|
+
def _check_ratio(self, ratio_constraint, actual_ratio):
|
|
167
|
+
"""Validate aspect ratio"""
|
|
168
|
+
if not ratio_constraint:
|
|
169
|
+
return True
|
|
170
|
+
|
|
171
|
+
try:
|
|
172
|
+
numerator, denominator = map(float, ratio_constraint.split('/'))
|
|
173
|
+
expected_ratio = round(numerator / denominator, 2)
|
|
174
|
+
return actual_ratio == expected_ratio
|
|
175
|
+
except:
|
|
176
|
+
return False
|
|
177
|
+
|
|
89
178
|
def message(self, field: str, params: List[str]) -> str:
|
|
90
|
-
|
|
91
|
-
max_rules = [p for p in params if p.startswith('max:')]
|
|
92
|
-
exact_rules = [p for p in params if ':' not in p]
|
|
179
|
+
constraints = []
|
|
93
180
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
181
|
+
if self._constraints['width'] is not None:
|
|
182
|
+
constraints.append(f"width={self._constraints['width']}")
|
|
183
|
+
if self._constraints['height'] is not None:
|
|
184
|
+
constraints.append(f"height={self._constraints['height']}")
|
|
185
|
+
if self._constraints['min_width'] > 0:
|
|
186
|
+
constraints.append(f"min_width={self._constraints['min_width']}")
|
|
187
|
+
if self._constraints['max_width'] < float('inf'):
|
|
188
|
+
constraints.append(f"max_width={self._constraints['max_width']}")
|
|
189
|
+
if self._constraints['min_height'] > 0:
|
|
190
|
+
constraints.append(f"min_height={self._constraints['min_height']}")
|
|
191
|
+
if self._constraints['max_height'] < float('inf'):
|
|
192
|
+
constraints.append(f"max_height={self._constraints['max_height']}")
|
|
193
|
+
if self._constraints['ratio'] is not None:
|
|
194
|
+
constraints.append(f"ratio={self._constraints['ratio']:.2f}")
|
|
195
|
+
|
|
196
|
+
return f"The :attribute image must satisfy: {', '.join(constraints)}"
|
|
103
197
|
|
|
104
|
-
class ExtensionsRule(
|
|
198
|
+
class ExtensionsRule(Rule):
|
|
105
199
|
def validate(self, field: str, value: Any, params: List[str]) -> bool:
|
|
106
200
|
if not params:
|
|
107
201
|
return False
|
|
@@ -120,7 +214,7 @@ class ExtensionsRule(ValidationRule):
|
|
|
120
214
|
def message(self, field: str, params: List[str]) -> str:
|
|
121
215
|
return f"File :attribute must have one of these extensions: {', '.join(params)}"
|
|
122
216
|
|
|
123
|
-
class ImageRule(
|
|
217
|
+
class ImageRule(Rule):
|
|
124
218
|
VALID_EXTENSIONS = {'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'}
|
|
125
219
|
VALID_MIME_TYPES = {
|
|
126
220
|
'image/jpeg', 'image/png',
|
|
@@ -148,7 +242,7 @@ class ImageRule(ValidationRule):
|
|
|
148
242
|
def message(self, field: str, params: List[str]) -> str:
|
|
149
243
|
return f"The :attribute must be a valid image file (JPEG, PNG, GIF, BMP, or WebP)"
|
|
150
244
|
|
|
151
|
-
class MimeTypesRule(
|
|
245
|
+
class MimeTypesRule(Rule):
|
|
152
246
|
def validate(self, field: str, value: Any, params: List[str]) -> bool:
|
|
153
247
|
if not params:
|
|
154
248
|
return False
|
|
@@ -164,4 +258,22 @@ class MimeTypesRule(ValidationRule):
|
|
|
164
258
|
return False
|
|
165
259
|
|
|
166
260
|
def message(self, field: str, params: List[str]) -> str:
|
|
167
|
-
return f"File :attribute must be one of these types: {', '.join(params)}"
|
|
261
|
+
return f"File :attribute must be one of these types: {', '.join(params)}"
|
|
262
|
+
|
|
263
|
+
class MimeTypeByExtensionRule(Rule):
|
|
264
|
+
_name = 'mimes'
|
|
265
|
+
|
|
266
|
+
def validate(self, field: str, value: Any, params: List[str]) -> bool:
|
|
267
|
+
if value is None or not hasattr(value, 'filename'):
|
|
268
|
+
return False
|
|
269
|
+
|
|
270
|
+
mimetypes.init()
|
|
271
|
+
|
|
272
|
+
extension = value.filename.split('.')[-1].lower()
|
|
273
|
+
|
|
274
|
+
mime_type = mimetypes.guess_type(f"file.{extension}")[0]
|
|
275
|
+
|
|
276
|
+
return mime_type in params if mime_type else False
|
|
277
|
+
|
|
278
|
+
def message(self, field: str, params: List[str]) -> str:
|
|
279
|
+
return f"The :attribute must be one of these types: {', '.join(params)}"
|
validator/rules/format.py
CHANGED
|
@@ -1,25 +1,129 @@
|
|
|
1
|
-
from .base import
|
|
1
|
+
from .base import Rule
|
|
2
2
|
from typing import Any, Dict, List, Optional, Set, Union, Tuple, Type
|
|
3
3
|
import re
|
|
4
|
-
import zoneinfo
|
|
5
4
|
import ipaddress
|
|
6
5
|
import json
|
|
7
6
|
import uuid
|
|
7
|
+
import dns.resolver
|
|
8
|
+
import idna
|
|
8
9
|
|
|
9
10
|
# =============================================
|
|
10
11
|
# FORMAT VALIDATION RULES
|
|
11
12
|
# =============================================
|
|
12
13
|
|
|
13
|
-
class EmailRule(
|
|
14
|
+
class EmailRule(Rule):
|
|
14
15
|
def validate(self, field: str, value: Any, params: List[str]) -> bool:
|
|
16
|
+
"""
|
|
17
|
+
Available params:
|
|
18
|
+
- rfc: RFCValidation (default)
|
|
19
|
+
- strict: NoRFCWarningsValidation
|
|
20
|
+
- dns: DNSCheckValidation
|
|
21
|
+
- spoof: SpoofCheckValidation
|
|
22
|
+
- filter: FilterEmailValidation
|
|
23
|
+
- filter_unicode: FilterEmailValidation::unicode()
|
|
24
|
+
"""
|
|
25
|
+
|
|
15
26
|
if not isinstance(value, str):
|
|
16
27
|
return False
|
|
17
|
-
|
|
18
|
-
|
|
28
|
+
|
|
29
|
+
# Default to RFC validation if no params specified
|
|
30
|
+
validation_types = params if params else ['rfc']
|
|
31
|
+
|
|
32
|
+
# Apply all requested validations
|
|
33
|
+
for validation in validation_types:
|
|
34
|
+
if validation == 'rfc' and not self._validate_rfc(value):
|
|
35
|
+
return False
|
|
36
|
+
elif validation == 'strict' and not self._validate_strict(value):
|
|
37
|
+
return False
|
|
38
|
+
elif validation == 'dns' and not self._validate_dns(value):
|
|
39
|
+
return False
|
|
40
|
+
elif validation == 'spoof' and not self._validate_spoof(value):
|
|
41
|
+
return False
|
|
42
|
+
elif validation == 'filter' and not self._validate_filter(value):
|
|
43
|
+
return False
|
|
44
|
+
elif validation == 'filter_unicode' and not self._validate_filter_unicode(value):
|
|
45
|
+
return False
|
|
46
|
+
|
|
47
|
+
return True
|
|
48
|
+
|
|
49
|
+
def _validate_rfc(self, email: str) -> bool:
|
|
50
|
+
"""Strict RFC-compliant email validation (single-line version)"""
|
|
51
|
+
return bool(re.match(
|
|
52
|
+
r"^(?!(\.|\.\.))[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@(?=.{1,255}$)[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)*\.[a-zA-Z]{2,}$",
|
|
53
|
+
email
|
|
54
|
+
))
|
|
55
|
+
|
|
56
|
+
def _validate_strict(self, email: str) -> bool:
|
|
57
|
+
"""Strict RFC validation (no warnings)"""
|
|
58
|
+
if not self._validate_rfc(email):
|
|
59
|
+
return False
|
|
60
|
+
|
|
61
|
+
# No leading/trailing dots
|
|
62
|
+
if email.startswith('.') or email.endswith('.'):
|
|
63
|
+
return False
|
|
64
|
+
|
|
65
|
+
# No consecutive dots
|
|
66
|
+
if '..' in email.split('@')[0]:
|
|
67
|
+
return False
|
|
68
|
+
|
|
69
|
+
return True
|
|
70
|
+
|
|
71
|
+
def _validate_dns(self, email: str) -> bool:
|
|
72
|
+
"""DNS MX record validation"""
|
|
73
|
+
try:
|
|
74
|
+
domain = email.split('@')[-1]
|
|
75
|
+
return bool(dns.resolver.resolve(domain, 'MX'))
|
|
76
|
+
except (dns.resolver.NoAnswer, dns.resolver.NXDOMAIN,
|
|
77
|
+
dns.resolver.NoNameservers, dns.exception.DNSException):
|
|
78
|
+
return False
|
|
79
|
+
|
|
80
|
+
def _validate_spoof(self, email: str) -> bool:
|
|
81
|
+
"""Spoof/homograph detection"""
|
|
82
|
+
try:
|
|
83
|
+
domain_part = email.split('@')[-1]
|
|
84
|
+
ascii_domain = idna.encode(domain_part).decode('ascii')
|
|
85
|
+
|
|
86
|
+
# Check for homographs
|
|
87
|
+
if domain_part != ascii_domain:
|
|
88
|
+
return False
|
|
89
|
+
|
|
90
|
+
# Check for deceptive characters
|
|
91
|
+
return not any(ord(char) > 127 for char in email)
|
|
92
|
+
except idna.IDNAError:
|
|
93
|
+
return False
|
|
94
|
+
|
|
95
|
+
def _validate_filter(self, email: str) -> bool:
|
|
96
|
+
"""PHP filter_var compatible validation"""
|
|
97
|
+
return bool(re.match(
|
|
98
|
+
r"^(?!(\.|\.\.))[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$",
|
|
99
|
+
email
|
|
100
|
+
))
|
|
101
|
+
|
|
102
|
+
def _validate_filter_unicode(self, email: str) -> bool:
|
|
103
|
+
"""PHP filter_var with Unicode support"""
|
|
104
|
+
return bool(re.match(
|
|
105
|
+
r"^(?!(\.|\.\.))[\w.!#$%&'*+/=?^_`{|}~-]+@[\w-]+(?:\.[\w-]+)*$",
|
|
106
|
+
email,
|
|
107
|
+
re.UNICODE
|
|
108
|
+
))
|
|
109
|
+
|
|
19
110
|
def message(self, field: str, params: List[str]) -> str:
|
|
20
|
-
|
|
111
|
+
base_msg = "The :attribute must be a valid email address"
|
|
112
|
+
|
|
113
|
+
if 'strict' in params:
|
|
114
|
+
base_msg += " (strict RFC compliance)"
|
|
115
|
+
if 'dns' in params:
|
|
116
|
+
base_msg += " with valid DNS records"
|
|
117
|
+
if 'spoof' in params:
|
|
118
|
+
base_msg += " without spoofed characters"
|
|
119
|
+
if 'filter' in params:
|
|
120
|
+
base_msg += " (PHP filter compatible)"
|
|
121
|
+
if 'filter_unicode' in params:
|
|
122
|
+
base_msg += " (Unicode allowed)"
|
|
123
|
+
|
|
124
|
+
return f"{base_msg}."
|
|
21
125
|
|
|
22
|
-
class UrlRule(
|
|
126
|
+
class UrlRule(Rule):
|
|
23
127
|
def validate(self, field: str, value: Any, params: List[str]) -> bool:
|
|
24
128
|
if not isinstance(value, str):
|
|
25
129
|
return False
|
|
@@ -34,7 +138,7 @@ class UrlRule(ValidationRule):
|
|
|
34
138
|
def message(self, field: str, params: List[str]) -> str:
|
|
35
139
|
return f"The :attribute must be a valid URL."
|
|
36
140
|
|
|
37
|
-
class JsonRule(
|
|
141
|
+
class JsonRule(Rule):
|
|
38
142
|
def validate(self, field: str, value: Any, params: List[str]) -> bool:
|
|
39
143
|
if not isinstance(value, str):
|
|
40
144
|
return False
|
|
@@ -47,7 +151,7 @@ class JsonRule(ValidationRule):
|
|
|
47
151
|
def message(self, field: str, params: List[str]) -> str:
|
|
48
152
|
return f"The :attribute must be a valid JSON string."
|
|
49
153
|
|
|
50
|
-
class UuidRule(
|
|
154
|
+
class UuidRule(Rule):
|
|
51
155
|
def validate(self, field: str, value: Any, params: List[str]) -> bool:
|
|
52
156
|
if not isinstance(value, str):
|
|
53
157
|
return False
|
|
@@ -60,7 +164,7 @@ class UuidRule(ValidationRule):
|
|
|
60
164
|
def message(self, field: str, params: List[str]) -> str:
|
|
61
165
|
return f"The :attribute must be a valid UUID."
|
|
62
166
|
|
|
63
|
-
class UlidRule(
|
|
167
|
+
class UlidRule(Rule):
|
|
64
168
|
def validate(self, field: str, value: Any, params: List[str]) -> bool:
|
|
65
169
|
if not isinstance(value, str):
|
|
66
170
|
return False
|
|
@@ -69,7 +173,7 @@ class UlidRule(ValidationRule):
|
|
|
69
173
|
def message(self, field: str, params: List[str]) -> str:
|
|
70
174
|
return f"The :attribute must be a valid ULID."
|
|
71
175
|
|
|
72
|
-
class IpRule(
|
|
176
|
+
class IpRule(Rule):
|
|
73
177
|
def validate(self, field: str, value: Any, params: List[str]) -> bool:
|
|
74
178
|
if not isinstance(value, str):
|
|
75
179
|
return False
|
|
@@ -82,20 +186,7 @@ class IpRule(ValidationRule):
|
|
|
82
186
|
def message(self, field: str, params: List[str]) -> str:
|
|
83
187
|
return f"The :attribute must be a valid IP address."
|
|
84
188
|
|
|
85
|
-
class
|
|
86
|
-
def validate(self, field: str, value: Any, params: List[str]) -> bool:
|
|
87
|
-
if not isinstance(value, str):
|
|
88
|
-
return False
|
|
89
|
-
try:
|
|
90
|
-
zoneinfo.ZoneInfo(value)
|
|
91
|
-
return True
|
|
92
|
-
except zoneinfo.ZoneInfoNotFoundError:
|
|
93
|
-
return False
|
|
94
|
-
|
|
95
|
-
def message(self, field: str, params: List[str]) -> str:
|
|
96
|
-
return f"The :attribute must be a valid timezone."
|
|
97
|
-
|
|
98
|
-
class HexRule(ValidationRule):
|
|
189
|
+
class HexRule(Rule):
|
|
99
190
|
def validate(self, field: str, value: Any, params: List[str]) -> bool:
|
|
100
191
|
if not isinstance(value, str):
|
|
101
192
|
return False
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
from .base import Rule
|
|
2
|
+
from typing import Any, Dict, List, Optional, Set, Union, Tuple, Type
|
|
3
|
+
import decimal
|
|
4
|
+
|
|
5
|
+
class NumericRule(Rule):
|
|
6
|
+
def validate(self, field: str, value: Any, params: List[str]) -> bool:
|
|
7
|
+
if isinstance(value, (int, float)):
|
|
8
|
+
return True
|
|
9
|
+
if not isinstance(value, str):
|
|
10
|
+
return False
|
|
11
|
+
return value.replace('.', '', 1).isdigit()
|
|
12
|
+
|
|
13
|
+
def message(self, field: str, params: List[str]) -> str:
|
|
14
|
+
return f"The :attribute must be a number."
|
|
15
|
+
|
|
16
|
+
class IntegerRule(Rule):
|
|
17
|
+
def validate(self, field: str, value: Any, params: List[str]) -> bool:
|
|
18
|
+
if isinstance(value, int):
|
|
19
|
+
return True
|
|
20
|
+
if not isinstance(value, str):
|
|
21
|
+
return False
|
|
22
|
+
return value.isdigit()
|
|
23
|
+
|
|
24
|
+
def message(self, field: str, params: List[str]) -> str:
|
|
25
|
+
return f"The :attribute must be an integer."
|
|
26
|
+
|
|
27
|
+
class DigitsRule(Rule):
|
|
28
|
+
def validate(self, field: str, value: Any, params: List[str]) -> bool:
|
|
29
|
+
if not params or not isinstance(value, str):
|
|
30
|
+
return False
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
digits = int(params[0])
|
|
34
|
+
except ValueError:
|
|
35
|
+
return False
|
|
36
|
+
|
|
37
|
+
return value.isdigit() and len(value) == digits
|
|
38
|
+
|
|
39
|
+
def message(self, field: str, params: List[str]) -> str:
|
|
40
|
+
return f"The :attribute must be {params[0]} digits."
|
|
41
|
+
|
|
42
|
+
class DigitsBetweenRule(Rule):
|
|
43
|
+
def validate(self, field: str, value: Any, params: List[str]) -> bool:
|
|
44
|
+
if len(params) < 2 or not isinstance(value, str):
|
|
45
|
+
return False
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
min_digits = int(params[0])
|
|
49
|
+
max_digits = int(params[1])
|
|
50
|
+
except ValueError:
|
|
51
|
+
return False
|
|
52
|
+
|
|
53
|
+
return value.isdigit() and min_digits <= len(value) <= max_digits
|
|
54
|
+
|
|
55
|
+
def message(self, field: str, params: List[str]) -> str:
|
|
56
|
+
return f"The :attribute must be between {params[0]} and {params[1]} digits."
|
|
57
|
+
|
|
58
|
+
class DecimalRule(Rule):
|
|
59
|
+
def validate(self, field: str, value: Any, params: List[str]) -> bool:
|
|
60
|
+
try:
|
|
61
|
+
decimal.Decimal(str(value))
|
|
62
|
+
return True
|
|
63
|
+
except (decimal.InvalidOperation, TypeError, ValueError):
|
|
64
|
+
return False
|
|
65
|
+
|
|
66
|
+
def message(self, field: str, params: List[str]) -> str:
|
|
67
|
+
return "The :attribute must be a decimal number."
|
|
68
|
+
|
|
69
|
+
class GreaterThanRule(Rule):
|
|
70
|
+
_name = 'gt'
|
|
71
|
+
|
|
72
|
+
def validate(self, field: str, value: Any, params: List[str]) -> bool:
|
|
73
|
+
if len(params) < 1:
|
|
74
|
+
return False
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
threshold = decimal.Decimal(params[0])
|
|
78
|
+
numeric_value = decimal.Decimal(str(value))
|
|
79
|
+
return numeric_value > threshold
|
|
80
|
+
except (decimal.InvalidOperation, TypeError, ValueError):
|
|
81
|
+
return False
|
|
82
|
+
|
|
83
|
+
def message(self, field: str, params: List[str]) -> str:
|
|
84
|
+
return f"The :attribute must be greater than {params[0]}."
|
|
85
|
+
|
|
86
|
+
class GreaterThanOrEqualRule(Rule):
|
|
87
|
+
_name = 'gte'
|
|
88
|
+
|
|
89
|
+
def validate(self, field: str, value: Any, params: List[str]) -> bool:
|
|
90
|
+
if len(params) < 1:
|
|
91
|
+
return False
|
|
92
|
+
|
|
93
|
+
try:
|
|
94
|
+
threshold = decimal.Decimal(params[0])
|
|
95
|
+
numeric_value = decimal.Decimal(str(value))
|
|
96
|
+
return numeric_value >= threshold
|
|
97
|
+
except (decimal.InvalidOperation, TypeError, ValueError):
|
|
98
|
+
return False
|
|
99
|
+
|
|
100
|
+
def message(self, field: str, params: List[str]) -> str:
|
|
101
|
+
return f"The :attribute must be greater than or equal to {params[0]}."
|
|
102
|
+
|
|
103
|
+
class LessThanRule(Rule):
|
|
104
|
+
_name = 'lt'
|
|
105
|
+
|
|
106
|
+
def validate(self, field: str, value: Any, params: List[str]) -> bool:
|
|
107
|
+
if len(params) < 1:
|
|
108
|
+
return False
|
|
109
|
+
|
|
110
|
+
try:
|
|
111
|
+
threshold = decimal.Decimal(params[0])
|
|
112
|
+
numeric_value = decimal.Decimal(str(value))
|
|
113
|
+
return numeric_value < threshold
|
|
114
|
+
except (decimal.InvalidOperation, TypeError, ValueError):
|
|
115
|
+
return False
|
|
116
|
+
|
|
117
|
+
def message(self, field: str, params: List[str]) -> str:
|
|
118
|
+
return f"The :attribute must be less than {params[0]}."
|
|
119
|
+
|
|
120
|
+
class LessThanOrEqualRule(Rule):
|
|
121
|
+
_name = 'lte'
|
|
122
|
+
|
|
123
|
+
def validate(self, field: str, value: Any, params: List[str]) -> bool:
|
|
124
|
+
if len(params) < 1:
|
|
125
|
+
return False
|
|
126
|
+
|
|
127
|
+
try:
|
|
128
|
+
threshold = decimal.Decimal(params[0])
|
|
129
|
+
numeric_value = decimal.Decimal(str(value))
|
|
130
|
+
return numeric_value <= threshold
|
|
131
|
+
except (decimal.InvalidOperation, TypeError, ValueError):
|
|
132
|
+
return False
|
|
133
|
+
|
|
134
|
+
def message(self, field: str, params: List[str]) -> str:
|
|
135
|
+
return f"The :attribute must be less than or equal to {params[0]}."
|
|
136
|
+
|
|
137
|
+
class MaxDigitsRule(Rule):
|
|
138
|
+
def validate(self, field: str, value: Any, params: List[str]) -> bool:
|
|
139
|
+
if len(params) < 1:
|
|
140
|
+
return False
|
|
141
|
+
|
|
142
|
+
try:
|
|
143
|
+
max_digits = int(params[0])
|
|
144
|
+
numeric_value = decimal.Decimal(str(value))
|
|
145
|
+
str_value = str(numeric_value).replace("-", "")
|
|
146
|
+
if '.' in str_value:
|
|
147
|
+
str_value = str_value.replace(".", "")
|
|
148
|
+
return len(str_value) <= max_digits
|
|
149
|
+
except (decimal.InvalidOperation, TypeError, ValueError):
|
|
150
|
+
return False
|
|
151
|
+
|
|
152
|
+
def message(self, field: str, params: List[str]) -> str:
|
|
153
|
+
return f"The :attribute must not exceed {params[0]} digits."
|
|
154
|
+
|
|
155
|
+
class MinDigitsRule(Rule):
|
|
156
|
+
def validate(self, field: str, value: Any, params: List[str]) -> bool:
|
|
157
|
+
if len(params) < 1:
|
|
158
|
+
return False
|
|
159
|
+
|
|
160
|
+
try:
|
|
161
|
+
min_digits = int(params[0])
|
|
162
|
+
numeric_value = decimal.Decimal(str(value))
|
|
163
|
+
str_value = str(numeric_value).replace("-", "")
|
|
164
|
+
if '.' in str_value:
|
|
165
|
+
str_value = str_value.replace(".", "")
|
|
166
|
+
return len(str_value) >= min_digits
|
|
167
|
+
except (decimal.InvalidOperation, TypeError, ValueError):
|
|
168
|
+
return False
|
|
169
|
+
|
|
170
|
+
def message(self, field: str, params: List[str]) -> str:
|
|
171
|
+
return f"The :attribute must have at least {params[0]} digits."
|
|
172
|
+
|
|
173
|
+
class MultipleOfRule(Rule):
|
|
174
|
+
def validate(self, field: str, value: Any, params: List[str]) -> bool:
|
|
175
|
+
if not params:
|
|
176
|
+
return False
|
|
177
|
+
|
|
178
|
+
try:
|
|
179
|
+
divisor = float(params[0])
|
|
180
|
+
if divisor == 0:
|
|
181
|
+
return False
|
|
182
|
+
num = float(value)
|
|
183
|
+
return num % divisor == 0
|
|
184
|
+
except (ValueError, TypeError):
|
|
185
|
+
return False
|
|
186
|
+
|
|
187
|
+
def message(self, field: str, params: List[str]) -> str:
|
|
188
|
+
return f"The :attribute must be a multiple of {params[0]}."
|