Coverage for /Users/davegaeddert/Developer/dropseed/plain/plain/plain/validators.py: 47%
294 statements
« prev ^ index » next coverage.py v7.6.9, created at 2024-12-23 11:16 -0600
« prev ^ index » next coverage.py v7.6.9, created at 2024-12-23 11:16 -0600
1import ipaddress
2import math
3import re
4from pathlib import Path
5from urllib.parse import urlsplit, urlunsplit
7from plain.exceptions import ValidationError
8from plain.utils.deconstruct import deconstructible
9from plain.utils.encoding import punycode
10from plain.utils.ipv6 import is_valid_ipv6_address
11from plain.utils.regex_helper import _lazy_re_compile
12from plain.utils.text import pluralize_lazy
14# These values, if given to validate(), will trigger the self.required check.
15EMPTY_VALUES = (None, "", [], (), {})
18@deconstructible
19class RegexValidator:
20 regex = ""
21 message = "Enter a valid value."
22 code = "invalid"
23 inverse_match = False
24 flags = 0
26 def __init__(
27 self, regex=None, message=None, code=None, inverse_match=None, flags=None
28 ):
29 if regex is not None:
30 self.regex = regex
31 if message is not None:
32 self.message = message
33 if code is not None:
34 self.code = code
35 if inverse_match is not None:
36 self.inverse_match = inverse_match
37 if flags is not None:
38 self.flags = flags
39 if self.flags and not isinstance(self.regex, str):
40 raise TypeError(
41 "If the flags are set, regex must be a regular expression string."
42 )
44 self.regex = _lazy_re_compile(self.regex, self.flags)
46 def __call__(self, value):
47 """
48 Validate that the input contains (or does *not* contain, if
49 inverse_match is True) a match for the regular expression.
50 """
51 regex_matches = self.regex.search(str(value))
52 invalid_input = regex_matches if self.inverse_match else not regex_matches
53 if invalid_input:
54 raise ValidationError(self.message, code=self.code, params={"value": value})
56 def __eq__(self, other):
57 return (
58 isinstance(other, RegexValidator)
59 and self.regex.pattern == other.regex.pattern
60 and self.regex.flags == other.regex.flags
61 and (self.message == other.message)
62 and (self.code == other.code)
63 and (self.inverse_match == other.inverse_match)
64 )
67@deconstructible
68class URLValidator(RegexValidator):
69 ul = "\u00a1-\uffff" # Unicode letters range (must not be a raw string).
71 # IP patterns
72 ipv4_re = (
73 r"(?:0|25[0-5]|2[0-4][0-9]|1[0-9]?[0-9]?|[1-9][0-9]?)"
74 r"(?:\.(?:0|25[0-5]|2[0-4][0-9]|1[0-9]?[0-9]?|[1-9][0-9]?)){3}"
75 )
76 ipv6_re = r"\[[0-9a-f:.]+\]" # (simple regex, validated later)
78 # Host patterns
79 hostname_re = (
80 r"[a-z" + ul + r"0-9](?:[a-z" + ul + r"0-9-]{0,61}[a-z" + ul + r"0-9])?"
81 )
82 # Max length for domain name labels is 63 characters per RFC 1034 sec. 3.1
83 domain_re = r"(?:\.(?!-)[a-z" + ul + r"0-9-]{1,63}(?<!-))*"
84 tld_re = (
85 r"\." # dot
86 r"(?!-)" # can't start with a dash
87 r"(?:[a-z" + ul + "-]{2,63}" # domain label
88 r"|xn--[a-z0-9]{1,59})" # or punycode label
89 r"(?<!-)" # can't end with a dash
90 r"\.?" # may have a trailing dot
91 )
92 host_re = "(" + hostname_re + domain_re + tld_re + "|localhost)"
94 regex = _lazy_re_compile(
95 r"^(?:[a-z0-9.+-]*)://" # scheme is validated separately
96 r"(?:[^\s:@/]+(?::[^\s:@/]*)?@)?" # user:pass authentication
97 r"(?:" + ipv4_re + "|" + ipv6_re + "|" + host_re + ")"
98 r"(?::[0-9]{1,5})?" # port
99 r"(?:[/?#][^\s]*)?" # resource path
100 r"\Z",
101 re.IGNORECASE,
102 )
103 message = "Enter a valid URL."
104 schemes = ["http", "https", "ftp", "ftps"]
105 unsafe_chars = frozenset("\t\r\n")
107 def __init__(self, schemes=None, **kwargs):
108 super().__init__(**kwargs)
109 if schemes is not None:
110 self.schemes = schemes
112 def __call__(self, value):
113 if not isinstance(value, str):
114 raise ValidationError(self.message, code=self.code, params={"value": value})
115 if self.unsafe_chars.intersection(value):
116 raise ValidationError(self.message, code=self.code, params={"value": value})
117 # Check if the scheme is valid.
118 scheme = value.split("://")[0].lower()
119 if scheme not in self.schemes:
120 raise ValidationError(self.message, code=self.code, params={"value": value})
122 # Then check full URL
123 try:
124 splitted_url = urlsplit(value)
125 except ValueError:
126 raise ValidationError(self.message, code=self.code, params={"value": value})
127 try:
128 super().__call__(value)
129 except ValidationError as e:
130 # Trivial case failed. Try for possible IDN domain
131 if value:
132 scheme, netloc, path, query, fragment = splitted_url
133 try:
134 netloc = punycode(netloc) # IDN -> ACE
135 except UnicodeError: # invalid domain part
136 raise e
137 url = urlunsplit((scheme, netloc, path, query, fragment))
138 super().__call__(url)
139 else:
140 raise
141 else:
142 # Now verify IPv6 in the netloc part
143 host_match = re.search(r"^\[(.+)\](?::[0-9]{1,5})?$", splitted_url.netloc)
144 if host_match:
145 potential_ip = host_match[1]
146 try:
147 validate_ipv6_address(potential_ip)
148 except ValidationError:
149 raise ValidationError(
150 self.message, code=self.code, params={"value": value}
151 )
153 # The maximum length of a full host name is 253 characters per RFC 1034
154 # section 3.1. It's defined to be 255 bytes or less, but this includes
155 # one byte for the length of the name and one byte for the trailing dot
156 # that's used to indicate absolute names in DNS.
157 if splitted_url.hostname is None or len(splitted_url.hostname) > 253:
158 raise ValidationError(self.message, code=self.code, params={"value": value})
161integer_validator = RegexValidator(
162 _lazy_re_compile(r"^-?\d+\Z"),
163 message="Enter a valid integer.",
164 code="invalid",
165)
168def validate_integer(value):
169 return integer_validator(value)
172@deconstructible
173class EmailValidator:
174 message = "Enter a valid email address."
175 code = "invalid"
176 user_regex = _lazy_re_compile(
177 # dot-atom
178 r"(^[-!#$%&'*+/=?^_`{}|~0-9A-Z]+(\.[-!#$%&'*+/=?^_`{}|~0-9A-Z]+)*\Z"
179 # quoted-string
180 r'|^"([\001-\010\013\014\016-\037!#-\[\]-\177]|\\[\001-\011\013\014\016-\177])'
181 r'*"\Z)',
182 re.IGNORECASE,
183 )
184 domain_regex = _lazy_re_compile(
185 # max length for domain name labels is 63 characters per RFC 1034
186 r"((?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+)(?:[A-Z0-9-]{2,63}(?<!-))\Z",
187 re.IGNORECASE,
188 )
189 literal_regex = _lazy_re_compile(
190 # literal form, ipv4 or ipv6 address (SMTP 4.1.3)
191 r"\[([A-F0-9:.]+)\]\Z",
192 re.IGNORECASE,
193 )
194 domain_allowlist = ["localhost"]
196 def __init__(self, message=None, code=None, allowlist=None):
197 if message is not None:
198 self.message = message
199 if code is not None:
200 self.code = code
201 if allowlist is not None:
202 self.domain_allowlist = allowlist
204 def __call__(self, value):
205 if not value or "@" not in value:
206 raise ValidationError(self.message, code=self.code, params={"value": value})
208 user_part, domain_part = value.rsplit("@", 1)
210 if not self.user_regex.match(user_part):
211 raise ValidationError(self.message, code=self.code, params={"value": value})
213 if domain_part not in self.domain_allowlist and not self.validate_domain_part(
214 domain_part
215 ):
216 # Try for possible IDN domain-part
217 try:
218 domain_part = punycode(domain_part)
219 except UnicodeError:
220 pass
221 else:
222 if self.validate_domain_part(domain_part):
223 return
224 raise ValidationError(self.message, code=self.code, params={"value": value})
226 def validate_domain_part(self, domain_part):
227 if self.domain_regex.match(domain_part):
228 return True
230 literal_match = self.literal_regex.match(domain_part)
231 if literal_match:
232 ip_address = literal_match[1]
233 try:
234 validate_ipv46_address(ip_address)
235 return True
236 except ValidationError:
237 pass
238 return False
240 def __eq__(self, other):
241 return (
242 isinstance(other, EmailValidator)
243 and (self.domain_allowlist == other.domain_allowlist)
244 and (self.message == other.message)
245 and (self.code == other.code)
246 )
249validate_email = EmailValidator()
251slug_re = _lazy_re_compile(r"^[-a-zA-Z0-9_]+\Z")
252validate_slug = RegexValidator(
253 slug_re,
254 # Translators: "letters" means latin letters: a-z and A-Z.
255 "Enter a valid “slug” consisting of letters, numbers, underscores or hyphens.",
256 "invalid",
257)
259slug_unicode_re = _lazy_re_compile(r"^[-\w]+\Z")
260validate_unicode_slug = RegexValidator(
261 slug_unicode_re,
262 "Enter a valid “slug” consisting of Unicode letters, numbers, underscores, or hyphens."
263 "invalid",
264)
267def validate_ipv4_address(value):
268 try:
269 ipaddress.IPv4Address(value)
270 except ValueError:
271 raise ValidationError(
272 "Enter a valid IPv4 address.", code="invalid", params={"value": value}
273 )
276def validate_ipv6_address(value):
277 if not is_valid_ipv6_address(value):
278 raise ValidationError(
279 "Enter a valid IPv6 address.", code="invalid", params={"value": value}
280 )
283def validate_ipv46_address(value):
284 try:
285 validate_ipv4_address(value)
286 except ValidationError:
287 try:
288 validate_ipv6_address(value)
289 except ValidationError:
290 raise ValidationError(
291 "Enter a valid IPv4 or IPv6 address.",
292 code="invalid",
293 params={"value": value},
294 )
297ip_address_validator_map = {
298 "both": ([validate_ipv46_address], "Enter a valid IPv4 or IPv6 address."),
299 "ipv4": ([validate_ipv4_address], "Enter a valid IPv4 address."),
300 "ipv6": ([validate_ipv6_address], "Enter a valid IPv6 address."),
301}
304def ip_address_validators(protocol, unpack_ipv4):
305 """
306 Depending on the given parameters, return the appropriate validators for
307 the GenericIPAddressField.
308 """
309 if protocol != "both" and unpack_ipv4:
310 raise ValueError(
311 "You can only use `unpack_ipv4` if `protocol` is set to 'both'"
312 )
313 try:
314 return ip_address_validator_map[protocol.lower()]
315 except KeyError:
316 raise ValueError(
317 f"The protocol '{protocol}' is unknown. Supported: {list(ip_address_validator_map)}"
318 )
321def int_list_validator(sep=",", message=None, code="invalid", allow_negative=False):
322 regexp = _lazy_re_compile(
323 r"^{neg}\d+(?:{sep}{neg}\d+)*\Z".format(
324 neg="(-)?" if allow_negative else "",
325 sep=re.escape(sep),
326 )
327 )
328 return RegexValidator(regexp, message=message, code=code)
331validate_comma_separated_integer_list = int_list_validator(
332 message="Enter only digits separated by commas.",
333)
336@deconstructible
337class BaseValidator:
338 message = "Ensure this value is %(limit_value)s (it is %(show_value)s)."
339 code = "limit_value"
341 def __init__(self, limit_value, message=None):
342 self.limit_value = limit_value
343 if message:
344 self.message = message
346 def __call__(self, value):
347 cleaned = self.clean(value)
348 limit_value = (
349 self.limit_value() if callable(self.limit_value) else self.limit_value
350 )
351 params = {"limit_value": limit_value, "show_value": cleaned, "value": value}
352 if self.compare(cleaned, limit_value):
353 raise ValidationError(self.message, code=self.code, params=params)
355 def __eq__(self, other):
356 if not isinstance(other, self.__class__):
357 return NotImplemented
358 return (
359 self.limit_value == other.limit_value
360 and self.message == other.message
361 and self.code == other.code
362 )
364 def compare(self, a, b):
365 return a is not b
367 def clean(self, x):
368 return x
371@deconstructible
372class MaxValueValidator(BaseValidator):
373 message = "Ensure this value is less than or equal to %(limit_value)s."
374 code = "max_value"
376 def compare(self, a, b):
377 return a > b
380@deconstructible
381class MinValueValidator(BaseValidator):
382 message = "Ensure this value is greater than or equal to %(limit_value)s."
383 code = "min_value"
385 def compare(self, a, b):
386 return a < b
389@deconstructible
390class StepValueValidator(BaseValidator):
391 message = "Ensure this value is a multiple of step size %(limit_value)s."
392 code = "step_size"
394 def compare(self, a, b):
395 return not math.isclose(math.remainder(a, b), 0, abs_tol=1e-9)
398@deconstructible
399class MinLengthValidator(BaseValidator):
400 message = pluralize_lazy(
401 "Ensure this value has at least %(limit_value)d character (it has "
402 "%(show_value)d).",
403 "Ensure this value has at least %(limit_value)d characters (it has "
404 "%(show_value)d).",
405 "limit_value",
406 )
407 code = "min_length"
409 def compare(self, a, b):
410 return a < b
412 def clean(self, x):
413 return len(x)
416@deconstructible
417class MaxLengthValidator(BaseValidator):
418 message = pluralize_lazy(
419 "Ensure this value has at most %(limit_value)d character (it has "
420 "%(show_value)d).",
421 "Ensure this value has at most %(limit_value)d characters (it has "
422 "%(show_value)d).",
423 "limit_value",
424 )
425 code = "max_length"
427 def compare(self, a, b):
428 return a > b
430 def clean(self, x):
431 return len(x)
434@deconstructible
435class DecimalValidator:
436 """
437 Validate that the input does not exceed the maximum number of digits
438 expected, otherwise raise ValidationError.
439 """
441 messages = {
442 "invalid": "Enter a number.",
443 "max_digits": pluralize_lazy(
444 "Ensure that there are no more than %(max)s digit in total.",
445 "Ensure that there are no more than %(max)s digits in total.",
446 "max",
447 ),
448 "max_decimal_places": pluralize_lazy(
449 "Ensure that there are no more than %(max)s decimal place.",
450 "Ensure that there are no more than %(max)s decimal places.",
451 "max",
452 ),
453 "max_whole_digits": pluralize_lazy(
454 "Ensure that there are no more than %(max)s digit before the decimal "
455 "point.",
456 "Ensure that there are no more than %(max)s digits before the decimal "
457 "point.",
458 "max",
459 ),
460 }
462 def __init__(self, max_digits, decimal_places):
463 self.max_digits = max_digits
464 self.decimal_places = decimal_places
466 def __call__(self, value):
467 digit_tuple, exponent = value.as_tuple()[1:]
468 if exponent in {"F", "n", "N"}:
469 raise ValidationError(
470 self.messages["invalid"], code="invalid", params={"value": value}
471 )
472 if exponent >= 0:
473 digits = len(digit_tuple)
474 if digit_tuple != (0,):
475 # A positive exponent adds that many trailing zeros.
476 digits += exponent
477 decimals = 0
478 else:
479 # If the absolute value of the negative exponent is larger than the
480 # number of digits, then it's the same as the number of digits,
481 # because it'll consume all of the digits in digit_tuple and then
482 # add abs(exponent) - len(digit_tuple) leading zeros after the
483 # decimal point.
484 if abs(exponent) > len(digit_tuple):
485 digits = decimals = abs(exponent)
486 else:
487 digits = len(digit_tuple)
488 decimals = abs(exponent)
489 whole_digits = digits - decimals
491 if self.max_digits is not None and digits > self.max_digits:
492 raise ValidationError(
493 self.messages["max_digits"],
494 code="max_digits",
495 params={"max": self.max_digits, "value": value},
496 )
497 if self.decimal_places is not None and decimals > self.decimal_places:
498 raise ValidationError(
499 self.messages["max_decimal_places"],
500 code="max_decimal_places",
501 params={"max": self.decimal_places, "value": value},
502 )
503 if (
504 self.max_digits is not None
505 and self.decimal_places is not None
506 and whole_digits > (self.max_digits - self.decimal_places)
507 ):
508 raise ValidationError(
509 self.messages["max_whole_digits"],
510 code="max_whole_digits",
511 params={"max": (self.max_digits - self.decimal_places), "value": value},
512 )
514 def __eq__(self, other):
515 return (
516 isinstance(other, self.__class__)
517 and self.max_digits == other.max_digits
518 and self.decimal_places == other.decimal_places
519 )
522@deconstructible
523class FileExtensionValidator:
524 message = "File extension “%(extension)s” is not allowed. Allowed extensions are: %(allowed_extensions)s."
525 code = "invalid_extension"
527 def __init__(self, allowed_extensions=None, message=None, code=None):
528 if allowed_extensions is not None:
529 allowed_extensions = [
530 allowed_extension.lower() for allowed_extension in allowed_extensions
531 ]
532 self.allowed_extensions = allowed_extensions
533 if message is not None:
534 self.message = message
535 if code is not None:
536 self.code = code
538 def __call__(self, value):
539 extension = Path(value.name).suffix[1:].lower()
540 if (
541 self.allowed_extensions is not None
542 and extension not in self.allowed_extensions
543 ):
544 raise ValidationError(
545 self.message,
546 code=self.code,
547 params={
548 "extension": extension,
549 "allowed_extensions": ", ".join(self.allowed_extensions),
550 "value": value,
551 },
552 )
554 def __eq__(self, other):
555 return (
556 isinstance(other, self.__class__)
557 and self.allowed_extensions == other.allowed_extensions
558 and self.message == other.message
559 and self.code == other.code
560 )
563def get_available_image_extensions():
564 try:
565 from PIL import Image
566 except ImportError:
567 return []
568 else:
569 Image.init()
570 return [ext.lower()[1:] for ext in Image.EXTENSION]
573def validate_image_file_extension(value):
574 return FileExtensionValidator(allowed_extensions=get_available_image_extensions())(
575 value
576 )
579@deconstructible
580class ProhibitNullCharactersValidator:
581 """Validate that the string doesn't contain the null character."""
583 message = "Null characters are not allowed."
584 code = "null_characters_not_allowed"
586 def __init__(self, message=None, code=None):
587 if message is not None:
588 self.message = message
589 if code is not None:
590 self.code = code
592 def __call__(self, value):
593 if "\x00" in str(value):
594 raise ValidationError(self.message, code=self.code, params={"value": value})
596 def __eq__(self, other):
597 return (
598 isinstance(other, self.__class__)
599 and self.message == other.message
600 and self.code == other.code
601 )