From a3096b9989d38ff33a8d6f244ab328abb57f3191 Mon Sep 17 00:00:00 2001 From: sevdog Date: Thu, 7 Aug 2025 12:42:39 +0200 Subject: [PATCH] Add validation for DurationField format, add more tests for it and improve related docs --- docs/api-guide/fields.md | 6 +++--- rest_framework/__init__.py | 2 +- rest_framework/fields.py | 28 +++++++++++++++++++++------- rest_framework/settings.py | 4 ++-- tests/test_fields.py | 34 +++++++++++++++++++++++++++++++++- 5 files changed, 60 insertions(+), 14 deletions(-) diff --git a/docs/api-guide/fields.md b/docs/api-guide/fields.md index fc84c5dae..518420444 100644 --- a/docs/api-guide/fields.md +++ b/docs/api-guide/fields.md @@ -380,12 +380,12 @@ The `validated_data` for these fields will contain a `datetime.timedelta` instan **Signature:** `DurationField(format=api_settings.DURATION_FORMAT, max_value=None, min_value=None)` -* `format` - A string representing the output format. If not specified, this defaults to the same value as the `DURATION_FORMAT` settings key, which will be `'standard'` unless set. Setting to a format string indicates that `to_representation` return values should be coerced to string output. Format strings are described below. Setting this value to `None` indicates that Python `timedelta` objects should be returned by `to_representation`. In this case the date encoding will be determined by the renderer. +* `format` - A string representing the output format. If not specified, this defaults to the same value as the `DURATION_FORMAT` settings key, which will be `'django'` unless set. Formats are described below. Setting this value to `None` indicates that Python `timedelta` objects should be returned by `to_representation`. In this case the date encoding will be determined by the renderer. * `max_value` Validate that the duration provided is no greater than this value. * `min_value` Validate that the duration provided is no less than this value. -#### `DurationField` format strings -Format strings may either be the special string `'iso-8601'`, which indicates that [ISO 8601][iso8601] style intervals should be used (eg `'P4DT1H15M20S'`), or the special string `'standard'`, which indicates that Django interval format `'[DD] [HH:[MM:]]ss[.uuuuuu]'` should be used (eg: `'4 1:15:20'`). +#### `DurationField` formats +Format may either be the special string `'iso-8601'`, which indicates that [ISO 8601][iso8601] style intervals should be used (eg `'P4DT1H15M20S'`), or `'django'` which indicates that Django interval format `'[DD] [HH:[MM:]]ss[.uuuuuu]'` should be used (eg: `'4 1:15:20'`). --- diff --git a/rest_framework/__init__.py b/rest_framework/__init__.py index bb1cfe86d..0918ff33d 100644 --- a/rest_framework/__init__.py +++ b/rest_framework/__init__.py @@ -21,7 +21,7 @@ HTTP_HEADER_ENCODING = 'iso-8859-1' # Default datetime input and output formats ISO_8601 = 'iso-8601' -STD_DURATION = 'standard' +DJANGO_DURATION_FORMAT = 'django' class RemovedInDRF317Warning(PendingDeprecationWarning): diff --git a/rest_framework/fields.py b/rest_framework/fields.py index 7d43daddd..84363f97e 100644 --- a/rest_framework/fields.py +++ b/rest_framework/fields.py @@ -35,7 +35,7 @@ try: except ImportError: pytz = None -from rest_framework import ISO_8601 +from rest_framework import DJANGO_DURATION_FORMAT, ISO_8601 from rest_framework.compat import ip_address_validators from rest_framework.exceptions import ErrorDetail, ValidationError from rest_framework.settings import api_settings @@ -1351,11 +1351,17 @@ class DurationField(Field): 'overflow': _('The number of days must be between {min_days} and {max_days}.'), } - def __init__(self, format=empty, **kwargs): + def __init__(self, *, format=empty, **kwargs): self.max_value = kwargs.pop('max_value', None) self.min_value = kwargs.pop('min_value', None) if format is not empty: - self.format = format + if format is None or (isinstance(format, str) and format.lower() in (ISO_8601, DJANGO_DURATION_FORMAT)): + self.format = format + else: + raise ValueError( + f"Unknown duration format provided, got '{format}'" + " while expecting 'django', 'iso-8601' or `None`." + ) super().__init__(**kwargs) if self.max_value is not None: message = lazy_format(self.error_messages['max_value'], max_value=self.max_value) @@ -1380,12 +1386,20 @@ class DurationField(Field): def to_representation(self, value): output_format = getattr(self, 'format', api_settings.DURATION_FORMAT) - if output_format is None or isinstance(value, str): + if output_format is None: return value - if output_format.lower() == ISO_8601: - return duration_iso_string(value) - return duration_string(value) + if isinstance(output_format, str): + if output_format.lower() == ISO_8601: + return duration_iso_string(value) + + if output_format.lower() == DJANGO_DURATION_FORMAT: + return duration_string(value) + + raise ValueError( + f"Unknown duration format provided, got '{output_format}'" + " while expecting 'django', 'iso-8601' or `None`." + ) # Choice types... diff --git a/rest_framework/settings.py b/rest_framework/settings.py index 4b466e044..50e3ad40e 100644 --- a/rest_framework/settings.py +++ b/rest_framework/settings.py @@ -24,7 +24,7 @@ from django.conf import settings from django.core.signals import setting_changed from django.utils.module_loading import import_string -from rest_framework import ISO_8601, STD_DURATION +from rest_framework import DJANGO_DURATION_FORMAT, ISO_8601 DEFAULTS = { # Base API policies @@ -109,7 +109,7 @@ DEFAULTS = { 'TIME_FORMAT': ISO_8601, 'TIME_INPUT_FORMATS': [ISO_8601], - 'DURATION_FORMAT': STD_DURATION, + 'DURATION_FORMAT': DJANGO_DURATION_FORMAT, # Encoding 'UNICODE_JSON': True, diff --git a/tests/test_fields.py b/tests/test_fields.py index 665184394..64130c591 100644 --- a/tests/test_fields.py +++ b/tests/test_fields.py @@ -1772,6 +1772,32 @@ class TestDurationField(FieldValues): } field = serializers.DurationField() + def test_invalid_format(self): + with pytest.raises(ValueError) as exc_info: + serializers.DurationField(format='unknown') + assert str(exc_info.value) == ( + "Unknown duration format provided, got 'unknown'" + " while expecting 'django', 'iso-8601' or `None`." + ) + with pytest.raises(ValueError) as exc_info: + serializers.DurationField(format=123) + assert str(exc_info.value) == ( + "Unknown duration format provided, got '123'" + " while expecting 'django', 'iso-8601' or `None`." + ) + + @override_settings(REST_FRAMEWORK={'DURATION_FORMAT': 'unknown'}) + def test_invalid_format_in_config(self): + field = serializers.DurationField() + + with pytest.raises(ValueError) as exc_info: + field.to_representation(datetime.timedelta(days=1)) + + assert str(exc_info.value) == ( + "Unknown duration format provided, got 'unknown'" + " while expecting 'django', 'iso-8601' or `None`." + ) + class TestNoOutputFormatDurationField(FieldValues): """ @@ -1789,7 +1815,13 @@ class TestISOOutputFormatDurationField(FieldValues): """ Values for `DurationField` with a custom output format. """ - valid_inputs = {} + valid_inputs = { + '13': datetime.timedelta(seconds=13), + 'P3DT08H32M01.000123S': datetime.timedelta(days=3, hours=8, minutes=32, seconds=1, microseconds=123), + 'PT8H1M': datetime.timedelta(hours=8, minutes=1), + '-P999999999D': datetime.timedelta(days=-999999999), + 'P999999999D': datetime.timedelta(days=999999999) + } invalid_inputs = {} outputs = { datetime.timedelta(days=3, hours=8, minutes=32, seconds=1, microseconds=123): 'P3DT08H32M01.000123S'