Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion docs/api-guide/serializers.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,9 @@ When deserializing data, you always need to call `is_valid()` before attempting

Each key in the dictionary will be the field name, and the values will be lists of strings of any error messages corresponding to that field. The `non_field_errors` key may also be present, and will list any general validation errors. The name of the `non_field_errors` key may be customized using the `NON_FIELD_ERRORS_KEY` REST framework setting.

When deserializing a list of items, errors will be returned as a list of dictionaries representing each of the deserialized items.
When deserializing a list of items, errors are returned as a list of dictionaries representing each item. This list-based format is deprecated and will be removed in REST framework 3.20.

To use the dictionary-based format introduced in REST framework 3.18, set `LIST_SERIALIZER_ERRORS_AS_DICT` to `True`. Errors will then be returned as a dictionary keyed by the indexes of invalid items, without entries for valid items.

#### Raising an exception on invalid data

Expand Down
10 changes: 10 additions & 0 deletions docs/api-guide/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -462,6 +462,16 @@ A string representing the key that should be used for serializer errors that do

Default: `'non_field_errors'`

#### LIST_SERIALIZER_ERRORS_AS_DICT

Controls the format of per-item validation errors produced by `ListSerializer`, including serializers instantiated with `many=True`.

When set to `False`, errors are returned as a list with one entry for each input item and an empty dictionary for each valid item. This format is deprecated and will be removed in REST framework 3.20.

When set to `True`, errors are returned as a dictionary keyed by the indexes of invalid items. Valid items are omitted from the dictionary. This format was introduced in REST framework 3.18.0 and is planned to become the default again in REST framework 3.19.

Default: `False`

#### URL_FIELD_NAME

A string representing the key that should be used for the URL fields generated by `HyperlinkedModelSerializer`.
Expand Down
4 changes: 4 additions & 0 deletions rest_framework/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,7 @@
# Default datetime input and output formats
ISO_8601 = 'iso-8601'
DJANGO_DURATION_FORMAT = 'django'


class RemovedInDRF320Warning(PendingDeprecationWarning):
pass
Comment on lines +27 to +28

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To bring this in line with another PR in flight, please move this class to rest_framework/deprecation.py

12 changes: 12 additions & 0 deletions rest_framework/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import copy
import inspect
import traceback
import warnings
from collections import defaultdict
from collections.abc import Mapping

Expand All @@ -26,6 +27,7 @@
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _

from rest_framework import RemovedInDRF320Warning
from rest_framework.compat import postgres_fields
from rest_framework.exceptions import ErrorDetail, ValidationError
from rest_framework.fields import get_error_detail
Expand Down Expand Up @@ -709,6 +711,16 @@ def to_internal_value(self, data):
ret.append(validated)

if errors:
if not api_settings.LIST_SERIALIZER_ERRORS_AS_DICT:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd say to move this logic to a separate in-line method that you could decorate with @deprecated instead, so you don't have to create a new class for it and then have to import it in a lot of places (which can be easy to forget or overlook).

Something like this, maybe?

@deprecated('''The list-based error format for `ListSerializer` is 
               deprecated and will be removed in DRF 3.20. Set
               REST_FRAMEWORK["LIST_SERIALIZER_ERRORS_AS_DICT"] to
               True to use the dictionary-based error format.''', stacklevel=4)
def __legacy_serialize_errors(self, errors, data): list:
    return [errors.get(index, {}) for index in range(len(data))]

def __validate_elements(self, data): tuple[list, dict]:
    ret: list = []
    errors: dict = {}
    for index, item in enumerate(data):
        try:
            validated = self.run_child_validation(item)
        except ValidationError as exc:
            errors[index] = exc.detail
        else:
            ret.append(validated)
     return ret, errors

errors: dict | list = None

ret, errors = __validate_elements(data)

if errors:
    if not api_settings.LIST_SERIALIZER_ERRORS_AS_DICT:
        errors = __legacy_serialize_errors(errors, data)
    raise ValidationError(errors)
return ret

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

warnings.deprecated was added in Python 3.13, while DRF still supports Python 3.10+

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's good to know. Thanks for adding that context!

warnings.warn(
'The list-based error format for `ListSerializer` is '
'deprecated and will be removed in DRF 3.20. Set '
'`REST_FRAMEWORK["LIST_SERIALIZER_ERRORS_AS_DICT"]` to '
'`True` to use the dictionary-based error format.',
RemovedInDRF320Warning,
stacklevel=5
)
errors = [errors.get(index, {}) for index in range(len(data))]
raise ValidationError(errors)

return ret
Expand Down
1 change: 1 addition & 0 deletions rest_framework/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@
# Exception handling
'EXCEPTION_HANDLER': 'rest_framework.views.exception_handler',
'NON_FIELD_ERRORS_KEY': 'non_field_errors',
'LIST_SERIALIZER_ERRORS_AS_DICT': False,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this should now default to True, otherwise that's going to be another breaking change between 3.18.0 and 3.18.1

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That makes sense. I can change that.


# Testing
'TEST_REQUEST_RENDERER_CLASSES': [
Expand Down
8 changes: 6 additions & 2 deletions tests/test_serializer.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@
from django.db import models
from django.test import TestCase

from rest_framework import exceptions, fields, relations, serializers
from rest_framework import (
RemovedInDRF320Warning, exceptions, fields, relations, serializers
)
from rest_framework.fields import Field

from .models import (
Expand Down Expand Up @@ -667,7 +669,9 @@ class ExampleSerializer(serializers.Serializer):

def test_4606_regression(self):
serializer = self.Serializer(data=[{"name": "liz"}], many=True)
with pytest.raises(serializers.ValidationError):
with pytest.warns(RemovedInDRF320Warning), pytest.raises(
serializers.ValidationError
):
serializer.is_valid(raise_exception=True)


Expand Down
22 changes: 15 additions & 7 deletions tests/test_serializer_bulk_update.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"""
from django.test import TestCase

from rest_framework import serializers
from rest_framework import RemovedInDRF320Warning, serializers


class BulkCreateSerializerTests(TestCase):
Expand Down Expand Up @@ -65,12 +65,15 @@ def test_bulk_create_errors(self):
'author': 'Haruki Murakami'
}
]
expected_errors = {
2: {'id': ['A valid integer is required.']}
}
expected_errors = [
{},
{},
{'id': ['A valid integer is required.']}
]

serializer = self.BookSerializer(data=data, many=True)
assert serializer.is_valid() is False
with self.assertWarns(RemovedInDRF320Warning):
assert serializer.is_valid() is False
assert serializer.errors == expected_errors
assert serializer.validated_data == []

Expand All @@ -80,11 +83,16 @@ def test_invalid_list_datatype(self):
"""
data = ['foo', 'bar', 'baz']
serializer = self.BookSerializer(data=data, many=True)
assert serializer.is_valid() is False

message = 'Invalid data. Expected a dictionary, but got str.'
expected_errors = {idx: {'non_field_errors': [message]} for idx in range(len(data))}
expected_errors = [
{'non_field_errors': [message]},
{'non_field_errors': [message]},
{'non_field_errors': [message]}
]

with self.assertWarns(RemovedInDRF320Warning):
assert serializer.is_valid() is False
assert serializer.errors == expected_errors

def test_invalid_single_datatype(self):
Expand Down
55 changes: 48 additions & 7 deletions tests/test_serializer_lists.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import warnings

import pytest
from django.http import QueryDict
from django.test import override_settings
from django.utils.datastructures import MultiValueDict

from rest_framework import serializers
from rest_framework import RemovedInDRF320Warning, serializers
from rest_framework.exceptions import ErrorDetail
from tests.models import (
CustomManagerModel, NullableOneToOneSource, OneToOneTarget
Expand Down Expand Up @@ -885,9 +888,9 @@ def test(self):
assert serializer.data


class TestListSerializerDictErrorBehavior:
class TestListSerializerErrorBehavior:
"""
Tests dict-based error structure for ListSerializer, and consistency with ListField.
Tests both ListSerializer error formats and consistency with ListField.

https://github.com/encode/django-rest-framework/issues/7279
"""
Expand All @@ -908,8 +911,31 @@ class WrapperSerializer(serializers.Serializer):
self.SampleSerializer = SampleSerializer
self.WrapperSerializer = WrapperSerializer

def test_listserializer_dict_error_format(self):
def test_listserializer_list_error_format_by_default(self):
data = [
{"num": "1"},
{"num": "x"},
{"num": "0"},
{"num": "hello"},
]

serializer = self.SampleSerializer(data=data, many=True)
with pytest.warns(
RemovedInDRF320Warning,
match='LIST_SERIALIZER_ERRORS_AS_DICT'
) as warning:
assert not serializer.is_valid()

errors = serializer.errors
assert isinstance(errors, list)
assert errors[0] == {}
assert errors[1] == {"num": [ErrorDetail(string="Must be a valid boolean.", code="invalid")]}
assert errors[2] == {}
assert errors[3] == {"num": [ErrorDetail(string="Must be a valid boolean.", code="invalid")]}
assert warning[0].filename == __file__

@override_settings(REST_FRAMEWORK={'LIST_SERIALIZER_ERRORS_AS_DICT': True})
def test_listserializer_dict_error_format(self):
data = [
{"num": "1"},
{"num": "x"},
Expand All @@ -918,15 +944,30 @@ def test_listserializer_dict_error_format(self):
]

serializer = self.SampleSerializer(data=data, many=True)
serializer.is_valid()
with warnings.catch_warnings():
warnings.simplefilter('error', RemovedInDRF320Warning)
assert not serializer.is_valid()

errors = serializer.errors
assert isinstance(errors, dict)
assert set(errors.keys()) == {1, 3}

assert errors[1] == {"num": [ErrorDetail(string="Must be a valid boolean.", code="invalid")]}
assert errors[3] == {"num": [ErrorDetail(string="Must be a valid boolean.", code="invalid")]}

@override_settings(REST_FRAMEWORK={'LIST_SERIALIZER_ERRORS_AS_DICT': False})
def test_listserializer_explicit_legacy_error_format(self):
data = [{"num": "wrong"}]

serializer = self.SampleSerializer(data=data, many=True)
with pytest.warns(RemovedInDRF320Warning):
assert not serializer.is_valid()

assert isinstance(serializer.errors, list)
assert serializer.errors == [
{"num": [ErrorDetail(string="Must be a valid boolean.", code="invalid")]}
]

@override_settings(REST_FRAMEWORK={'LIST_SERIALIZER_ERRORS_AS_DICT': True})
def test_listserializer_and_listfield_consistency(self):

data = {
Expand All @@ -945,7 +986,7 @@ def test_listserializer_and_listfield_consistency(self):
}

serializer = self.WrapperSerializer(data=data)
serializer.is_valid()
assert not serializer.is_valid()

errors = serializer.errors

Expand Down
Loading