Repository: encode/django-rest-framework

Issue:
3.15.0 - bug in rendering `%` characters from `ValidationError`
The new `3.15.0` release introduced a bug in rendering errors from the `ValidationError` exception class.

Given a serializer:
```python
class MySerializer(Serializer):
    departure_datetime = serializers.DateTimeField(
        required=True,
        format="%Y-%m-%d %H:%M:%S",
        error_messages={"invalid": "Expects format %Y-%m-%d %H:%M:%S"},
    )
```

Calling `ValidationError` would raise the exception:
```
  File "/.../.venv/lib/python3.11/site-packages/rest_framework/fields.py", line 603, in fail
    raise ValidationError(message_string, code=key)
          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/.../.venv/lib/python3.11/site-packages/rest_framework/exceptions.py", line 160, in __init__
    detail = [detail % params]
              ~~~~~~~^~~~~~~~
ValueError: unsupported format character 'Y' (0x59) at index 27
```

As expected, this can easily be reproduced in the console:
```python
>>> ValidationError("Expects format %Y-%m-%d %H:%M:%S")
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/.../.venv/lib/python3.11/site-packages/rest_framework/exceptions.py", line 160, in __init__
    detail = [detail % params]
              ~~~~~~~^~~~~~~~
ValueError: unsupported format character 'Y' (0x59) at index 16
```
or
```python
>>> ["Expects format %Y-%m-%d %H:%M:%S" % {}]
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ValueError: unsupported format character 'Y' (0x59) at index 16
```

A solution would be to escape with double `%` characters any occurrence of `%` in the error messages.

```python
class MySerializer(Serializer):
    departure_datetime = serializers.DateTimeField(
        required=True,
        format="%Y-%m-%d %H:%M:%S",
        error_messages={"invalid": "Expects format %%Y-%%m-%%d %%H:%%M:%%S"},
    )
```

Unsure this is a choice by design or not, as this wouldn't be the case on `3.14.x`?


Discussion:
This is coming from https://github.com/encode/django-rest-framework/pull/8863, which is very difficult to live with. The original motivation of that, from https://github.com/encode/django-rest-framework/issues/8077, had a reason of using more standard Django / python exception argument. But if you look at the Django error behavior:

```python
from django.core.exceptions import ValidationError as dValidationError

dValidationError("Expects format %Y-%m-%d %H:%M:%S")
dValidationError("Expects format %Y-%m-%d %H:%M:%S").error_list
```

It does no similar formatting when given such a string.
related fix https://github.com/encode/django-rest-framework/pull/9313

Resolving patch:
diff --git a/rest_framework/exceptions.py b/rest_framework/exceptions.py
--- a/rest_framework/exceptions.py
+++ b/rest_framework/exceptions.py
@@ -144,30 +144,17 @@ class ValidationError(APIException):
     status_code = status.HTTP_400_BAD_REQUEST
     default_detail = _('Invalid input.')
     default_code = 'invalid'
-    default_params = {}
 
-    def __init__(self, detail=None, code=None, params=None):
+    def __init__(self, detail=None, code=None):
         if detail is None:
             detail = self.default_detail
         if code is None:
             code = self.default_code
-        if params is None:
-            params = self.default_params
 
         # For validation failures, we may collect many errors together,
         # so the details should always be coerced to a list if not already.
-        if isinstance(detail, str):
-            detail = [detail % params]
-        elif isinstance(detail, ValidationError):
-            detail = detail.detail
-        elif isinstance(detail, (list, tuple)):
-            final_detail = []
-            for detail_item in detail:
-                if isinstance(detail_item, ValidationError):
-                    final_detail += detail_item.detail
-                else:
-                    final_detail += [detail_item % params if isinstance(detail_item, str) else detail_item]
-            detail = final_detail
+        if isinstance(detail, tuple):
+            detail = list(detail)
         elif not isinstance(detail, dict) and not isinstance(detail, list):
             detail = [detail]
 


Test patch:
diff --git a/tests/test_validation_error.py b/tests/test_validation_error.py
--- a/tests/test_validation_error.py
+++ b/tests/test_validation_error.py
@@ -109,89 +109,3 @@ def test_validation_error_details(self):
         assert len(error.detail) == 2
         assert str(error.detail[0]) == 'message1'
         assert str(error.detail[1]) == 'message2'
-
-
-class TestValidationErrorWithDjangoStyle(TestCase):
-    def test_validation_error_details(self):
-        error = ValidationError('Invalid value: %(value)s', params={'value': '42'})
-        assert str(error.detail[0]) == 'Invalid value: 42'
-
-    def test_validation_error_details_tuple(self):
-        error = ValidationError(
-            detail=('Invalid value: %(value1)s', 'Invalid value: %(value2)s'),
-            params={'value1': '42', 'value2': '43'},
-        )
-        assert isinstance(error.detail, list)
-        assert len(error.detail) == 2
-        assert str(error.detail[0]) == 'Invalid value: 42'
-        assert str(error.detail[1]) == 'Invalid value: 43'
-
-    def test_validation_error_details_list(self):
-        error = ValidationError(
-            detail=['Invalid value: %(value1)s', 'Invalid value: %(value2)s', ],
-            params={'value1': '42', 'value2': '43'}
-        )
-        assert isinstance(error.detail, list)
-        assert len(error.detail) == 2
-        assert str(error.detail[0]) == 'Invalid value: 42'
-        assert str(error.detail[1]) == 'Invalid value: 43'
-
-    def test_validation_error_details_validation_errors(self):
-        error = ValidationError(
-            detail=ValidationError(
-                detail='Invalid value: %(value1)s',
-                params={'value1': '42'},
-            ),
-        )
-        assert isinstance(error.detail, list)
-        assert len(error.detail) == 1
-        assert str(error.detail[0]) == 'Invalid value: 42'
-
-    def test_validation_error_details_validation_errors_list(self):
-        error = ValidationError(
-            detail=[
-                ValidationError(
-                    detail='Invalid value: %(value1)s',
-                    params={'value1': '42'},
-                ),
-                ValidationError(
-                    detail='Invalid value: %(value2)s',
-                    params={'value2': '43'},
-                ),
-                'Invalid value: %(value3)s'
-            ],
-            params={'value3': '44'}
-        )
-        assert isinstance(error.detail, list)
-        assert len(error.detail) == 3
-        assert str(error.detail[0]) == 'Invalid value: 42'
-        assert str(error.detail[1]) == 'Invalid value: 43'
-        assert str(error.detail[2]) == 'Invalid value: 44'
-
-    def test_validation_error_details_validation_errors_nested_list(self):
-        error = ValidationError(
-            detail=[
-                ValidationError(
-                    detail='Invalid value: %(value1)s',
-                    params={'value1': '42'},
-                ),
-                ValidationError(
-                    detail=[
-                        'Invalid value: %(value2)s',
-                        ValidationError(
-                            detail='Invalid value: %(value3)s',
-                            params={'value3': '44'},
-                        )
-                    ],
-                    params={'value2': '43'},
-                ),
-                'Invalid value: %(value4)s'
-            ],
-            params={'value4': '45'}
-        )
-        assert isinstance(error.detail, list)
-        assert len(error.detail) == 4
-        assert str(error.detail[0]) == 'Invalid value: 42'
-        assert str(error.detail[1]) == 'Invalid value: 43'
-        assert str(error.detail[2]) == 'Invalid value: 44'
-        assert str(error.detail[3]) == 'Invalid value: 45'