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.
validator/rules/files.py CHANGED
@@ -1,7 +1,8 @@
1
- from .base import ValidationRule
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(ValidationRule):
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(ValidationRule):
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
- from PIL import Image
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
- try:
45
- if param.startswith('min:'):
46
- min_width, min_height = map(int, param[4:].split('x'))
47
- elif param.startswith('max:'):
48
- max_width, max_height = map(int, param[4:].split('x'))
49
- else:
50
- exact_width, exact_height = map(int, param.split('x'))
51
- min_width = max_width = exact_width
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
- # Case 3: File path (string)
70
- elif isinstance(value, str) and value.lower().endswith(('.jpg', '.jpeg', '.png', '.gif')):
71
- img = Image.open(value)
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
- if img is None:
74
- return False
75
-
76
- # 3. Validate dimensions
77
- width, height = img.size
78
- return (min_width <= width <= max_width and
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
- except (IOError, OSError, AttributeError) as e:
82
- print(f"Image loading failed: {str(e)}")
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"Unexpected error in dimension validation: {str(e)}")
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
- min_rules = [p for p in params if p.startswith('min:')]
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
- messages = []
95
- if exact_rules:
96
- messages.append(f"exactly {exact_rules[0]}")
97
- if min_rules:
98
- messages.append(f"minimum {min_rules[0][4:]}")
99
- if max_rules:
100
- messages.append(f"maximum {max_rules[0][4:]}")
101
-
102
- return f"Image :attribute dimensions must be {' and '.join(messages)}"
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(ValidationRule):
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(ValidationRule):
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(ValidationRule):
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 ValidationRule
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(ValidationRule):
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
- return bool(re.match(r"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$", value))
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
- return f"The :attribute must be a valid email address."
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(ValidationRule):
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(ValidationRule):
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(ValidationRule):
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(ValidationRule):
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(ValidationRule):
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 TimezoneRule(ValidationRule):
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]}."