Skip to content

Commit 1dc4caa

Browse files
committed
Avoid Django's lazy() when creating validators because it is too slow
As a fix for issue #3354, commit 607e4ed made the evaluation of some validation error messages lazy. To achieve that, Django's django.utils.functional.lazy() function was used. However, that function is extremely heavy and slow, and slows down string validation significantly (lazy() is evaluated each time for each validator for each field which has one). We noticed this in our production system. Use a custom lazy_format() object instead which does the formatting lazily with less overhead. Using the benchmark attached to the PR (snipped to tottime>100ms): Before, model serializer: 9225123 function calls (9200068 primitive calls) in 8.337 seconds Ordered by: internal time ncalls tottime percall cumtime percall filename:lineno(function) 25000 1.299 0.000 3.534 0.000 functional.py:125(__prepare_class__) 2415001 0.954 0.000 0.954 0.000 {built-in method builtins.hasattr} 1350000 0.901 0.000 0.901 0.000 functional.py:145(__promise__) 1550001 0.521 0.000 0.521 0.000 {built-in method builtins.setattr} 25000 0.494 0.000 0.735 0.000 {built-in method builtins.__build_class__} 30000 0.298 0.000 0.385 0.000 fields.py:297(__init__) 25000 0.289 0.000 5.895 0.000 fields.py:740(__init__) 25000 0.264 0.000 0.802 0.000 field_mapping.py:66(get_field_kwargs) 25000 0.241 0.000 0.241 0.000 functional.py:100(__proxy__) 670003/670001 0.203 0.000 0.211 0.000 {built-in method builtins.getattr} 5000 0.189 0.000 7.722 0.002 serializers.py:990(get_fields) 25000 0.186 0.000 0.400 0.000 functools.py:186(total_ordering) 25000 0.158 0.000 0.299 0.000 functional.py:234(wrapper) 5000 0.129 0.000 0.136 0.000 serializers.py:1066(get_field_names) 25000 0.104 0.000 1.002 0.000 serializers.py:1195(build_standard_field) After, model serializer: 3265096 function calls (3240059 primitive calls) in 2.645 seconds Ordered by: internal time ncalls tottime percall cumtime percall filename:lineno(function) 30000 0.237 0.000 0.315 0.000 fields.py:295(__init__) 25000 0.218 0.000 0.639 0.000 field_mapping.py:66(get_field_kwargs) 25000 0.214 0.000 0.665 0.000 fields.py:743(__init__) 5000 0.156 0.000 2.086 0.000 serializers.py:988(get_fields) 25000 0.107 0.000 0.210 0.000 functional.py:234(wrapper) Before, regular serializer: 8060003 function calls (7960003 primitive calls) in 7.123 seconds Ordered by: internal time ncalls tottime percall cumtime percall filename:lineno(function) 25000 1.569 0.000 3.897 0.000 functional.py:125(__prepare_class__) 1350000 1.013 0.000 1.013 0.000 functional.py:145(__promise__) 2365000 0.925 0.000 0.925 0.000 {built-in method builtins.hasattr} 1550000 0.512 0.000 0.512 0.000 {built-in method builtins.setattr} 25000 0.378 0.000 0.550 0.000 {built-in method builtins.__build_class__} 25000 0.307 0.000 5.946 0.000 fields.py:740(__init__) 30000 0.277 0.000 0.360 0.000 fields.py:297(__init__) 80000/5000 0.202 0.000 6.526 0.001 copy.py:132(deepcopy) 25000 0.172 0.000 0.172 0.000 functional.py:100(__proxy__) 540000 0.152 0.000 0.152 0.000 {built-in method builtins.getattr} 25000 0.119 0.000 6.199 0.000 fields.py:604(__deepcopy__) After, regular serializer: 2150003 function calls (2050003 primitive calls) in 1.609 seconds Ordered by: internal time ncalls tottime percall cumtime percall filename:lineno(function) 30000 0.224 0.000 0.293 0.000 fields.py:295(__init__) 25000 0.181 0.000 0.607 0.000 fields.py:743(__init__) 80000/5000 0.151 0.000 1.074 0.000 copy.py:132(deepcopy) 25000 0.102 0.000 0.819 0.000 fields.py:607(__deepcopy__)
1 parent 19ca86d commit 1dc4caa

File tree

4 files changed

+62
-64
lines changed

4 files changed

+62
-64
lines changed

rest_framework/compat.py

Lines changed: 0 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
import sys
66

77
from django.conf import settings
8-
from django.core import validators
98
from django.views.generic import View
109

1110
try:
@@ -238,34 +237,5 @@ def md_filter_add_syntax_highlight(md):
238237
INDENT_SEPARATORS = (',', ': ')
239238

240239

241-
class CustomValidatorMessage:
242-
"""
243-
We need to avoid evaluation of `lazy` translated `message` in `django.core.validators.BaseValidator.__init__`.
244-
https://github.com/django/django/blob/75ed5900321d170debef4ac452b8b3cf8a1c2384/django/core/validators.py#L297
245-
246-
Ref: https://github.com/encode/django-rest-framework/pull/5452
247-
"""
248-
249-
def __init__(self, *args, **kwargs):
250-
self.message = kwargs.pop('message', self.message)
251-
super().__init__(*args, **kwargs)
252-
253-
254-
class MinValueValidator(CustomValidatorMessage, validators.MinValueValidator):
255-
pass
256-
257-
258-
class MaxValueValidator(CustomValidatorMessage, validators.MaxValueValidator):
259-
pass
260-
261-
262-
class MinLengthValidator(CustomValidatorMessage, validators.MinLengthValidator):
263-
pass
264-
265-
266-
class MaxLengthValidator(CustomValidatorMessage, validators.MaxLengthValidator):
267-
pass
268-
269-
270240
# Version Constants.
271241
PY36 = sys.version_info >= (3, 6)

rest_framework/fields.py

Lines changed: 17 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@
1212
from django.core.exceptions import ObjectDoesNotExist
1313
from django.core.exceptions import ValidationError as DjangoValidationError
1414
from django.core.validators import (
15-
EmailValidator, RegexValidator, URLValidator, ip_address_validators
15+
EmailValidator, MaxLengthValidator, MaxValueValidator, MinLengthValidator,
16+
MinValueValidator, RegexValidator, URLValidator, ip_address_validators
1617
)
1718
from django.forms import FilePathField as DjangoFilePathField
1819
from django.forms import ImageField as DjangoImageField
@@ -23,20 +24,17 @@
2324
from django.utils.duration import duration_string
2425
from django.utils.encoding import is_protected_type, smart_text
2526
from django.utils.formats import localize_input, sanitize_separators
26-
from django.utils.functional import lazy
2727
from django.utils.ipv6 import clean_ipv6_address
2828
from django.utils.timezone import utc
2929
from django.utils.translation import gettext_lazy as _
3030
from pytz.exceptions import InvalidTimeError
3131

3232
from rest_framework import ISO_8601
33-
from rest_framework.compat import (
34-
MaxLengthValidator, MaxValueValidator, MinLengthValidator,
35-
MinValueValidator, ProhibitNullCharactersValidator
36-
)
33+
from rest_framework.compat import ProhibitNullCharactersValidator
3734
from rest_framework.exceptions import ErrorDetail, ValidationError
3835
from rest_framework.settings import api_settings
3936
from rest_framework.utils import html, humanize_datetime, json, representation
37+
from rest_framework.utils.formatting import lazy_format
4038

4139

4240
class empty:
@@ -749,12 +747,11 @@ def __init__(self, **kwargs):
749747
self.min_length = kwargs.pop('min_length', None)
750748
super().__init__(**kwargs)
751749
if self.max_length is not None:
752-
message = lazy(self.error_messages['max_length'].format, str)(max_length=self.max_length)
750+
message = lazy_format(self.error_messages['max_length'], max_length=self.max_length)
753751
self.validators.append(
754752
MaxLengthValidator(self.max_length, message=message))
755753
if self.min_length is not None:
756-
message = lazy(
757-
self.error_messages['min_length'].format, str)(min_length=self.min_length)
754+
message = lazy_format(self.error_messages['min_length'], min_length=self.min_length)
758755
self.validators.append(
759756
MinLengthValidator(self.min_length, message=message))
760757

@@ -915,13 +912,11 @@ def __init__(self, **kwargs):
915912
self.min_value = kwargs.pop('min_value', None)
916913
super().__init__(**kwargs)
917914
if self.max_value is not None:
918-
message = lazy(
919-
self.error_messages['max_value'].format, str)(max_value=self.max_value)
915+
message = lazy_format(self.error_messages['max_value'], max_value=self.max_value)
920916
self.validators.append(
921917
MaxValueValidator(self.max_value, message=message))
922918
if self.min_value is not None:
923-
message = lazy(
924-
self.error_messages['min_value'].format, str)(min_value=self.min_value)
919+
message = lazy_format(self.error_messages['min_value'], min_value=self.min_value)
925920
self.validators.append(
926921
MinValueValidator(self.min_value, message=message))
927922

@@ -953,15 +948,11 @@ def __init__(self, **kwargs):
953948
self.min_value = kwargs.pop('min_value', None)
954949
super().__init__(**kwargs)
955950
if self.max_value is not None:
956-
message = lazy(
957-
self.error_messages['max_value'].format,
958-
str)(max_value=self.max_value)
951+
message = lazy_format(self.error_messages['max_value'], max_value=self.max_value)
959952
self.validators.append(
960953
MaxValueValidator(self.max_value, message=message))
961954
if self.min_value is not None:
962-
message = lazy(
963-
self.error_messages['min_value'].format,
964-
str)(min_value=self.min_value)
955+
message = lazy_format(self.error_messages['min_value'], min_value=self.min_value)
965956
self.validators.append(
966957
MinValueValidator(self.min_value, message=message))
967958

@@ -1012,14 +1003,11 @@ def __init__(self, max_digits, decimal_places, coerce_to_string=None, max_value=
10121003
super().__init__(**kwargs)
10131004

10141005
if self.max_value is not None:
1015-
message = lazy(
1016-
self.error_messages['max_value'].format,
1017-
str)(max_value=self.max_value)
1006+
message = lazy_format(self.error_messages['max_value'], max_value=self.max_value)
10181007
self.validators.append(
10191008
MaxValueValidator(self.max_value, message=message))
10201009
if self.min_value is not None:
1021-
message = lazy(
1022-
self.error_messages['min_value'].format, str)(min_value=self.min_value)
1010+
message = lazy_format(self.error_messages['min_value'], min_value=self.min_value)
10231011
self.validators.append(
10241012
MinValueValidator(self.min_value, message=message))
10251013

@@ -1357,15 +1345,11 @@ def __init__(self, **kwargs):
13571345
self.min_value = kwargs.pop('min_value', None)
13581346
super().__init__(**kwargs)
13591347
if self.max_value is not None:
1360-
message = lazy(
1361-
self.error_messages['max_value'].format,
1362-
str)(max_value=self.max_value)
1348+
message = lazy_format(self.error_messages['max_value'], max_value=self.max_value)
13631349
self.validators.append(
13641350
MaxValueValidator(self.max_value, message=message))
13651351
if self.min_value is not None:
1366-
message = lazy(
1367-
self.error_messages['min_value'].format,
1368-
str)(min_value=self.min_value)
1352+
message = lazy_format(self.error_messages['min_value'], min_value=self.min_value)
13691353
self.validators.append(
13701354
MinValueValidator(self.min_value, message=message))
13711355

@@ -1610,10 +1594,10 @@ def __init__(self, *args, **kwargs):
16101594
super().__init__(*args, **kwargs)
16111595
self.child.bind(field_name='', parent=self)
16121596
if self.max_length is not None:
1613-
message = lazy(self.error_messages['max_length'].format, str)(max_length=self.max_length)
1597+
message = lazy_format(self.error_messages['max_length'], max_length=self.max_length)
16141598
self.validators.append(MaxLengthValidator(self.max_length, message=message))
16151599
if self.min_length is not None:
1616-
message = lazy(self.error_messages['min_length'].format, str)(min_length=self.min_length)
1600+
message = lazy_format(self.error_messages['min_length'], min_length=self.min_length)
16171601
self.validators.append(MinLengthValidator(self.min_length, message=message))
16181602

16191603
def get_value(self, dictionary):
@@ -1886,8 +1870,7 @@ def __init__(self, model_field, **kwargs):
18861870
max_length = kwargs.pop('max_length', None)
18871871
super().__init__(**kwargs)
18881872
if max_length is not None:
1889-
message = lazy(
1890-
self.error_messages['max_length'].format, str)(max_length=self.max_length)
1873+
message = lazy_format(self.error_messages['max_length'], max_length=self.max_length)
18911874
self.validators.append(
18921875
MaxLengthValidator(self.max_length, message=message))
18931876

rest_framework/utils/formatting.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,3 +65,29 @@ def markup_description(description):
6565
description = escape(description).replace('\n', '<br />')
6666
description = '<p>' + description + '</p>'
6767
return mark_safe(description)
68+
69+
70+
class lazy_format:
71+
"""
72+
Delay formatting until it's actually needed.
73+
74+
Useful when the format string or one of the arguments is lazy.
75+
76+
Not using Django's lazy because it is too slow.
77+
"""
78+
__slots__ = ('format_string', 'args', 'kwargs', 'result')
79+
80+
def __init__(self, format_string, *args, **kwargs):
81+
self.result = None
82+
self.format_string = format_string
83+
self.args = args
84+
self.kwargs = kwargs
85+
86+
def __str__(self):
87+
if self.result is None:
88+
self.result = self.format_string.format(*self.args, **self.kwargs)
89+
self.format_string, self.args, self.kwargs = None, None, None
90+
return self.result
91+
92+
def __mod__(self, value):
93+
return str(self) % value

tests/test_utils.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from unittest import mock
2+
13
from django.conf.urls import url
24
from django.test import TestCase, override_settings
35

@@ -6,6 +8,7 @@
68
from rest_framework.serializers import ModelSerializer
79
from rest_framework.utils import json
810
from rest_framework.utils.breadcrumbs import get_breadcrumbs
11+
from rest_framework.utils.formatting import lazy_format
912
from rest_framework.utils.urls import remove_query_param, replace_query_param
1013
from rest_framework.views import APIView
1114
from rest_framework.viewsets import ModelViewSet
@@ -257,3 +260,19 @@ def test_invalid_unicode(self):
257260
removed_key = 'page'
258261

259262
assert key in remove_query_param(q, removed_key)
263+
264+
265+
class LazyFormatTests(TestCase):
266+
def test_it_formats_correctly(self):
267+
formatted = lazy_format('Does {} work? {answer}: %s', 'it', answer='Yes')
268+
assert str(formatted) == 'Does it work? Yes: %s'
269+
assert formatted % 'it does' == 'Does it work? Yes: it does'
270+
271+
def test_it_formats_lazily(self):
272+
message = mock.Mock(wraps='message')
273+
formatted = lazy_format(message)
274+
assert message.format.call_count == 0
275+
str(formatted)
276+
assert message.format.call_count == 1
277+
str(formatted)
278+
assert message.format.call_count == 1

0 commit comments

Comments
 (0)