Ticket #37277: validators.py

File validators.py, 21.9 KB (added by sepehrnorouzi, 101 minutes ago)

I have the tests and the doc changes.

Line 
1import ipaddress
2import math
3import re
4from pathlib import Path
5from urllib.parse import urlsplit
6
7from django.core.exceptions import ValidationError
8from django.utils.deconstruct import deconstructible
9from django.utils.http import MAX_URL_LENGTH
10from django.utils.ipv6 import is_valid_ipv6_address
11from django.utils.regex_helper import _lazy_re_compile
12from django.utils.translation import gettext_lazy as _
13from django.utils.translation import ngettext_lazy
14
15# These values, if given to validate(), will trigger the self.required check.
16EMPTY_VALUES = (None, "", [], (), {})
17
18
19@deconstructible
20class RegexValidator:
21 regex = ""
22 message = _("Enter a valid value.")
23 code = "invalid"
24 inverse_match = False
25 flags = 0
26
27 def __init__(
28 self, regex=None, message=None, code=None, inverse_match=None, flags=None
29 ):
30 if regex is not None:
31 self.regex = regex
32 if message is not None:
33 self.message = message
34 if code is not None:
35 self.code = code
36 if inverse_match is not None:
37 self.inverse_match = inverse_match
38 if flags is not None:
39 self.flags = flags
40 if self.flags and not isinstance(self.regex, str):
41 raise TypeError(
42 "If the flags are set, regex must be a regular expression string."
43 )
44
45 self.regex = _lazy_re_compile(self.regex, self.flags)
46
47 def __call__(self, value):
48 """
49 Validate that the input contains (or does *not* contain, if
50 inverse_match is True) a match for the regular expression.
51 """
52 regex_matches = self.regex.search(str(value))
53 invalid_input = regex_matches if self.inverse_match else not regex_matches
54 if invalid_input:
55 raise ValidationError(self.message, code=self.code, params={"value": value})
56
57 def __eq__(self, other):
58 return (
59 isinstance(other, RegexValidator)
60 and self.regex.pattern == other.regex.pattern
61 and self.regex.flags == other.regex.flags
62 and (self.message == other.message)
63 and (self.code == other.code)
64 and (self.inverse_match == other.inverse_match)
65 )
66
67
68@deconstructible
69class DomainNameValidator(RegexValidator):
70 message = _("Enter a valid domain name.")
71 ul = "\u00a1-\uffff" # Unicode letters range (must not be a raw string).
72 # Host patterns.
73 hostname_re = (
74 r"[a-z" + ul + r"0-9](?:[a-z" + ul + r"0-9-]{0,61}[a-z" + ul + r"0-9])?"
75 )
76 # Max length for domain name labels is 63 characters per RFC 1034 sec. 3.1.
77 domain_re = r"(?:\.(?!-)[a-z" + ul + r"0-9-]{1,63}(?<!-))*"
78 # Top-level domain.
79 tld_no_fqdn_re = (
80 r"\." # dot
81 r"(?!-)" # can't start with a dash
82 r"(?:[a-z" + ul + "-]{2,63}" # domain label
83 r"|xn--[a-z0-9]{1,59})" # or punycode label
84 r"(?<!-)" # can't end with a dash
85 )
86 tld_re = tld_no_fqdn_re + r"\.?"
87 ascii_only_hostname_re = r"[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?"
88 ascii_only_domain_re = r"(?:\.(?!-)[a-zA-Z0-9-]{1,63}(?<!-))*"
89 ascii_only_tld_re = (
90 r"\." # dot
91 r"(?!-)" # can't start with a dash
92 r"(?:[a-zA-Z0-9-]{2,63})" # domain label
93 r"(?<!-)" # can't end with a dash
94 r"\.?" # may have a trailing dot
95 )
96
97 max_length = 255
98
99 def __init__(self, **kwargs):
100 self.accept_idna = kwargs.pop("accept_idna", True)
101
102 regex_parts = [
103 "^",
104 *(
105 (self.hostname_re, self.domain_re, self.tld_re)
106 if self.accept_idna
107 else (
108 self.ascii_only_hostname_re,
109 self.ascii_only_domain_re,
110 self.ascii_only_tld_re,
111 )
112 ),
113 r"\Z",
114 ]
115 self.regex = _lazy_re_compile("".join(regex_parts), re.IGNORECASE)
116 super().__init__(**kwargs)
117
118 def __call__(self, value):
119 if not isinstance(value, str) or len(value) > self.max_length:
120 raise ValidationError(self.message, code=self.code, params={"value": value})
121 if not self.accept_idna and not value.isascii():
122 raise ValidationError(self.message, code=self.code, params={"value": value})
123 super().__call__(value)
124
125
126validate_domain_name = DomainNameValidator()
127
128
129@deconstructible
130class URLValidator(RegexValidator):
131 # IP patterns
132 ipv4_re = (
133 r"(?:0|25[0-5]|2[0-4][0-9]|1[0-9]?[0-9]?|[1-9][0-9]?)"
134 r"(?:\.(?:0|25[0-5]|2[0-4][0-9]|1[0-9]?[0-9]?|[1-9][0-9]?)){3}"
135 )
136 ipv6_re = r"\[[0-9a-f:.]+\]" # (simple regex, validated later)
137
138 hostname_re = DomainNameValidator.hostname_re
139 domain_re = DomainNameValidator.domain_re
140 tld_re = DomainNameValidator.tld_re
141
142 host_re = "(" + hostname_re + domain_re + tld_re + "|localhost)"
143
144 regex = _lazy_re_compile(
145 r"^(?:[a-z0-9.+-]*)://" # scheme is validated separately
146 r"(?:[^\s:@/]+(?::[^\s:@/]*)?@)?" # user:pass authentication
147 r"(?:" + ipv4_re + "|" + ipv6_re + "|" + host_re + ")"
148 r"(?::[0-9]{1,5})?" # port
149 r"(?:[/?#][^\s]*)?" # resource path
150 r"\Z",
151 re.IGNORECASE,
152 )
153 message = _("Enter a valid URL.")
154 schemes = ["http", "https", "ftp", "ftps", "ws", "wss"]
155 unsafe_chars = frozenset("\t\r\n")
156 max_length = MAX_URL_LENGTH
157
158 def __init__(self, schemes=None, **kwargs):
159 super().__init__(**kwargs)
160 if schemes is not None:
161 self.schemes = schemes
162
163 def __call__(self, value):
164 if not isinstance(value, str) or len(value) > self.max_length:
165 raise ValidationError(self.message, code=self.code, params={"value": value})
166 if self.unsafe_chars.intersection(value):
167 raise ValidationError(self.message, code=self.code, params={"value": value})
168 # Check if the scheme is valid.
169 scheme = value.split("://")[0].lower()
170 if scheme not in self.schemes:
171 raise ValidationError(self.message, code=self.code, params={"value": value})
172
173 # Then check full URL
174 try:
175 splitted_url = urlsplit(value)
176 except ValueError:
177 raise ValidationError(self.message, code=self.code, params={"value": value})
178 super().__call__(value)
179 # Now verify IPv6 in the netloc part
180 host_match = re.search(r"^\[(.+)\](?::[0-9]{1,5})?$", splitted_url.netloc)
181 if host_match:
182 potential_ip = host_match[1]
183 try:
184 validate_ipv6_address(potential_ip)
185 except ValidationError:
186 raise ValidationError(
187 self.message, code=self.code, params={"value": value}
188 )
189
190 # The maximum length of a full host name is 253 characters per RFC 1034
191 # section 3.1. It's defined to be 255 bytes or less, but this includes
192 # one byte for the length of the name and one byte for the trailing dot
193 # that's used to indicate absolute names in DNS.
194 if splitted_url.hostname is None or len(splitted_url.hostname) > 253:
195 raise ValidationError(self.message, code=self.code, params={"value": value})
196
197
198integer_validator = RegexValidator(
199 _lazy_re_compile(r"^-?\d+\Z"),
200 message=_("Enter a valid integer."),
201 code="invalid",
202)
203
204
205def validate_integer(value):
206 return integer_validator(value)
207
208
209@deconstructible
210class EmailValidator:
211 message = _("Enter a valid email address.")
212 code = "invalid"
213 hostname_re = DomainNameValidator.hostname_re
214 domain_re = DomainNameValidator.domain_re
215 tld_no_fqdn_re = DomainNameValidator.tld_no_fqdn_re
216
217 user_regex = _lazy_re_compile(
218 # dot-atom
219 r"(^[-!#$%&'*+/=?^_`{}|~0-9A-Z]+(\.[-!#$%&'*+/=?^_`{}|~0-9A-Z]+)*\Z"
220 # quoted-string
221 r'|^"([\001-\010\013\014\016-\037!#-\[\]-\177]|\\[\001-\011\013\014\016-\177])'
222 r'*"\Z)',
223 re.IGNORECASE,
224 )
225 domain_regex = _lazy_re_compile(
226 r"^" + hostname_re + domain_re + tld_no_fqdn_re + r"\Z",
227 re.IGNORECASE,
228 )
229 literal_regex = _lazy_re_compile(
230 # literal form, ipv4 or ipv6 address (SMTP 4.1.3)
231 r"\[([A-F0-9:.]+)\]\Z",
232 re.IGNORECASE,
233 )
234 domain_allowlist = ["localhost"]
235
236 def __init__(self, message=None, code=None, allowlist=None):
237 if message is not None:
238 self.message = message
239 if code is not None:
240 self.code = code
241 if allowlist is not None:
242 self.domain_allowlist = allowlist
243
244 def __call__(self, value):
245 # The maximum length of an email is 320 characters per RFC 3696
246 # section 3.
247 if not value or "@" not in value or len(value) > 320:
248 raise ValidationError(self.message, code=self.code, params={"value": value})
249
250 user_part, domain_part = value.rsplit("@", 1)
251
252 if not self.user_regex.match(user_part):
253 raise ValidationError(self.message, code=self.code, params={"value": value})
254
255 if domain_part not in self.domain_allowlist and not self.validate_domain_part(
256 domain_part
257 ):
258 raise ValidationError(self.message, code=self.code, params={"value": value})
259
260 def validate_domain_part(self, domain_part):
261 if self.domain_regex.match(domain_part):
262 return True
263
264 literal_match = self.literal_regex.match(domain_part)
265 if literal_match:
266 ip_address = literal_match[1]
267 try:
268 validate_ipv46_address(ip_address)
269 return True
270 except ValidationError:
271 pass
272 return False
273
274 def __eq__(self, other):
275 return (
276 isinstance(other, EmailValidator)
277 and (set(self.domain_allowlist) == set(other.domain_allowlist))
278 and (self.message == other.message)
279 and (self.code == other.code)
280 )
281
282
283validate_email = EmailValidator()
284
285slug_re = _lazy_re_compile(r"^[-a-zA-Z0-9_]+\Z")
286validate_slug = RegexValidator(
287 slug_re,
288 # Translators: "letters" means latin letters: a-z and A-Z.
289 _("Enter a valid “slug” consisting of letters, numbers, underscores or hyphens."),
290 "invalid",
291)
292
293slug_unicode_re = _lazy_re_compile(r"^[-\w]+\Z")
294validate_unicode_slug = RegexValidator(
295 slug_unicode_re,
296 _(
297 "Enter a valid “slug” consisting of Unicode letters, numbers, underscores, or "
298 "hyphens."
299 ),
300 "invalid",
301)
302
303
304def validate_ipv4_address(value):
305 try:
306 ipaddress.IPv4Address(value)
307 except ValueError:
308 raise ValidationError(
309 _("Enter a valid %(protocol)s address."),
310 code="invalid",
311 params={"protocol": _("IPv4"), "value": value},
312 )
313
314
315def validate_ipv6_address(value):
316 if not is_valid_ipv6_address(value):
317 raise ValidationError(
318 _("Enter a valid %(protocol)s address."),
319 code="invalid",
320 params={"protocol": _("IPv6"), "value": value},
321 )
322
323
324def validate_ipv46_address(value):
325 try:
326 validate_ipv4_address(value)
327 except ValidationError:
328 try:
329 validate_ipv6_address(value)
330 except ValidationError:
331 raise ValidationError(
332 _("Enter a valid %(protocol)s address."),
333 code="invalid",
334 params={"protocol": _("IPv4 or IPv6"), "value": value},
335 )
336
337
338ip_address_validator_map = {
339 "both": [validate_ipv46_address],
340 "ipv4": [validate_ipv4_address],
341 "ipv6": [validate_ipv6_address],
342}
343
344
345def ip_address_validators(protocol, unpack_ipv4):
346 """
347 Depending on the given parameters, return the appropriate validators for
348 the GenericIPAddressField.
349 """
350 if protocol != "both" and unpack_ipv4:
351 raise ValueError(
352 "You can only use `unpack_ipv4` if `protocol` is set to 'both'"
353 )
354 try:
355 return ip_address_validator_map[protocol.lower()]
356 except KeyError:
357 raise ValueError(
358 "The protocol '%s' is unknown. Supported: %s"
359 % (protocol, list(ip_address_validator_map))
360 )
361
362
363def int_list_validator(sep=",", message=None, code="invalid", allow_negative=False):
364 regexp = _lazy_re_compile(
365 r"^%(neg)s\d+(?:%(sep)s%(neg)s\d+)*\Z"
366 % {
367 "neg": "(-)?" if allow_negative else "",
368 "sep": re.escape(sep),
369 }
370 )
371 return RegexValidator(regexp, message=message, code=code)
372
373
374validate_comma_separated_integer_list = int_list_validator(
375 message=_("Enter only digits separated by commas."),
376)
377
378
379@deconstructible
380class BaseValidator:
381 message = _("Ensure this value is %(limit_value)s (it is %(show_value)s).")
382 code = "limit_value"
383
384 def __init__(self, limit_value, message=None):
385 self.limit_value = limit_value
386 if message:
387 self.message = message
388
389 def __call__(self, value):
390 cleaned = self.clean(value)
391 limit_value = (
392 self.limit_value() if callable(self.limit_value) else self.limit_value
393 )
394 params = {"limit_value": limit_value, "show_value": cleaned, "value": value}
395 if self.compare(cleaned, limit_value):
396 raise ValidationError(self.message, code=self.code, params=params)
397
398 def __eq__(self, other):
399 if not isinstance(other, self.__class__):
400 return NotImplemented
401 return (
402 self.limit_value == other.limit_value
403 and self.message == other.message
404 and self.code == other.code
405 )
406
407 def compare(self, a, b):
408 return a is not b
409
410 def clean(self, x):
411 return x
412
413
414@deconstructible
415class MaxValueValidator(BaseValidator):
416 message = _("Ensure this value is less than or equal to %(limit_value)s.")
417 code = "max_value"
418
419 def compare(self, a, b):
420 return a > b
421
422
423@deconstructible
424class MinValueValidator(BaseValidator):
425 message = _("Ensure this value is greater than or equal to %(limit_value)s.")
426 code = "min_value"
427
428 def compare(self, a, b):
429 return a < b
430
431
432@deconstructible
433class StepValueValidator(BaseValidator):
434 message = _("Ensure this value is a multiple of step size %(limit_value)s.")
435 code = "step_size"
436
437 def __init__(self, limit_value, message=None, offset=None):
438 super().__init__(limit_value, message)
439 if offset is not None:
440 self.message = _(
441 "Ensure this value is a multiple of step size %(limit_value)s, "
442 "starting from %(offset)s, e.g. %(offset)s, %(valid_value1)s, "
443 "%(valid_value2)s, and so on."
444 )
445 self.offset = offset
446
447 def __call__(self, value):
448 if self.offset is None:
449 super().__call__(value)
450 else:
451 cleaned = self.clean(value)
452 limit_value = (
453 self.limit_value() if callable(self.limit_value) else self.limit_value
454 )
455 if self.compare(cleaned, limit_value):
456 offset = cleaned.__class__(self.offset)
457 params = {
458 "limit_value": limit_value,
459 "offset": offset,
460 "valid_value1": offset + limit_value,
461 "valid_value2": offset + 2 * limit_value,
462 }
463 raise ValidationError(self.message, code=self.code, params=params)
464
465 def compare(self, a, b):
466 offset = 0 if self.offset is None else self.offset
467 return not math.isclose(math.remainder(a - offset, b), 0, abs_tol=1e-9)
468
469
470@deconstructible
471class MinLengthValidator(BaseValidator):
472 message = ngettext_lazy(
473 "Ensure this value has at least %(limit_value)d character (it has "
474 "%(show_value)d).",
475 "Ensure this value has at least %(limit_value)d characters (it has "
476 "%(show_value)d).",
477 "limit_value",
478 )
479 code = "min_length"
480
481 def compare(self, a, b):
482 return a < b
483
484 def clean(self, x):
485 return len(x)
486
487
488@deconstructible
489class MaxLengthValidator(BaseValidator):
490 message = ngettext_lazy(
491 "Ensure this value has at most %(limit_value)d character (it has "
492 "%(show_value)d).",
493 "Ensure this value has at most %(limit_value)d characters (it has "
494 "%(show_value)d).",
495 "limit_value",
496 )
497 code = "max_length"
498
499 def compare(self, a, b):
500 return a > b
501
502 def clean(self, x):
503 return len(x)
504
505
506@deconstructible
507class DecimalValidator:
508 """
509 Validate that the input does not exceed the maximum number of digits
510 expected, otherwise raise ValidationError.
511 """
512
513 messages = {
514 "invalid": _("Enter a number."),
515 "max_digits": ngettext_lazy(
516 "Ensure that there is no more than %(max)s digit in total.",
517 "Ensure that there are no more than %(max)s digits in total.",
518 "max",
519 ),
520 "max_decimal_places": ngettext_lazy(
521 "Ensure that there is no more than %(max)s decimal place.",
522 "Ensure that there are no more than %(max)s decimal places.",
523 "max",
524 ),
525 "max_whole_digits": ngettext_lazy(
526 "Ensure that there is no more than %(max)s digit before the decimal "
527 "point.",
528 "Ensure that there are no more than %(max)s digits before the decimal "
529 "point.",
530 "max",
531 ),
532 }
533
534 def __init__(self, max_digits, decimal_places):
535 self.max_digits = max_digits
536 self.decimal_places = decimal_places
537
538 def __call__(self, value):
539 digit_tuple, exponent = value.as_tuple()[1:]
540 if exponent in {"F", "n", "N"}:
541 raise ValidationError(
542 self.messages["invalid"], code="invalid", params={"value": value}
543 )
544 if exponent >= 0:
545 digits = len(digit_tuple)
546 if digit_tuple != (0,):
547 # A positive exponent adds that many trailing zeros.
548 digits += exponent
549 decimals = 0
550 else:
551 # If the absolute value of the negative exponent is larger than the
552 # number of digits, then it's the same as the number of digits,
553 # because it'll consume all of the digits in digit_tuple and then
554 # add abs(exponent) - len(digit_tuple) leading zeros after the
555 # decimal point.
556 if abs(exponent) > len(digit_tuple):
557 digits = decimals = abs(exponent)
558 else:
559 digits = len(digit_tuple)
560 decimals = abs(exponent)
561 whole_digits = digits - decimals
562
563 if self.max_digits is not None and digits > self.max_digits:
564 raise ValidationError(
565 self.messages["max_digits"],
566 code="max_digits",
567 params={"max": self.max_digits, "value": value},
568 )
569 if self.decimal_places is not None and decimals > self.decimal_places:
570 raise ValidationError(
571 self.messages["max_decimal_places"],
572 code="max_decimal_places",
573 params={"max": self.decimal_places, "value": value},
574 )
575 if (
576 self.max_digits is not None
577 and self.decimal_places is not None
578 and whole_digits > (self.max_digits - self.decimal_places)
579 ):
580 raise ValidationError(
581 self.messages["max_whole_digits"],
582 code="max_whole_digits",
583 params={"max": (self.max_digits - self.decimal_places), "value": value},
584 )
585
586 def __eq__(self, other):
587 return (
588 isinstance(other, self.__class__)
589 and self.max_digits == other.max_digits
590 and self.decimal_places == other.decimal_places
591 )
592
593
594@deconstructible
595class FileExtensionValidator:
596 message = _(
597 "File extension “%(extension)s” is not allowed. "
598 "Allowed extensions are: %(allowed_extensions)s."
599 )
600 code = "invalid_extension"
601
602 def __init__(self, allowed_extensions=None, message=None, code=None):
603 if allowed_extensions is not None:
604 allowed_extensions = [
605 allowed_extension.lower() for allowed_extension in allowed_extensions
606 ]
607 self.allowed_extensions = allowed_extensions
608 if message is not None:
609 self.message = message
610 if code is not None:
611 self.code = code
612
613 def __call__(self, value):
614 extension = Path(value.name).suffix[1:].lower()
615 if (
616 self.allowed_extensions is not None
617 and extension not in self.allowed_extensions
618 ):
619 raise ValidationError(
620 self.message,
621 code=self.code,
622 params={
623 "extension": extension,
624 "allowed_extensions": ", ".join(self.allowed_extensions),
625 "value": value,
626 },
627 )
628
629 def __eq__(self, other):
630 return (
631 isinstance(other, self.__class__)
632 and set(self.allowed_extensions or [])
633 == set(other.allowed_extensions or [])
634 and self.message == other.message
635 and self.code == other.code
636 )
637
638
639def get_available_image_extensions():
640 try:
641 from PIL import Image
642 except ImportError:
643 return []
644 else:
645 Image.init()
646 return [ext.lower()[1:] for ext in Image.EXTENSION]
647
648
649def validate_image_file_extension(value):
650 return FileExtensionValidator(allowed_extensions=get_available_image_extensions())(
651 value
652 )
653
654
655@deconstructible
656class ProhibitNullCharactersValidator:
657 """Validate that the string doesn't contain the null character."""
658
659 message = _("Null characters are not allowed.")
660 code = "null_characters_not_allowed"
661
662 def __init__(self, message=None, code=None):
663 if message is not None:
664 self.message = message
665 if code is not None:
666 self.code = code
667
668 def __call__(self, value):
669 if "\x00" in str(value):
670 raise ValidationError(self.message, code=self.code, params={"value": value})
671
672 def __eq__(self, other):
673 return (
674 isinstance(other, self.__class__)
675 and self.message == other.message
676 and self.code == other.code
677 )
Back to Top