Skip to content

Commit c8b6d3d

Browse files
sevdogauvipybrowniebroke
authored
DurationField output format (#8532)
* Allow format duration as ISO-8601 * Update tests/test_fields.py Co-authored-by: Bruno Alla <browniebroke@users.noreply.github.com> * Update tests/test_fields.py Co-authored-by: Bruno Alla <browniebroke@users.noreply.github.com> * Add validation for DurationField format, add more tests for it and improve related docs * Add more precise validation check for duration field format and adjust docs * Adjust typo in duration field docs --------- Co-authored-by: Asif Saif Uddin <auvipy@gmail.com> Co-authored-by: Bruno Alla <browniebroke@users.noreply.github.com>
1 parent c73dddf commit c8b6d3d

File tree

6 files changed

+115
-8
lines changed

6 files changed

+115
-8
lines changed

docs/api-guide/fields.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -377,13 +377,16 @@ A Duration representation.
377377
Corresponds to `django.db.models.fields.DurationField`
378378

379379
The `validated_data` for these fields will contain a `datetime.timedelta` instance.
380-
The representation is a string following this format `'[DD] [HH:[MM:]]ss[.uuuuuu]'`.
381380

382-
**Signature:** `DurationField(max_value=None, min_value=None)`
381+
**Signature:** `DurationField(format=api_settings.DURATION_FORMAT, max_value=None, min_value=None)`
383382

383+
* `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.
384384
* `max_value` Validate that the duration provided is no greater than this value.
385385
* `min_value` Validate that the duration provided is no less than this value.
386386

387+
#### `DurationField` formats
388+
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'`).
389+
387390
---
388391

389392
# Choice selection fields

docs/api-guide/settings.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,15 @@ May be a list including the string `'iso-8601'` or Python [strftime format][strf
314314

315315
Default: `['iso-8601']`
316316

317+
318+
#### DURATION_FORMAT
319+
320+
Indicates the default format that should be used for rendering the output of `DurationField` serializer fields. If `None`, then `DurationField` serializer fields will return Python `timedelta` objects, and the duration encoding will be determined by the renderer.
321+
322+
May be any of `None`, `'iso-8601'` or `'django'` (the format accepted by `django.utils.dateparse.parse_duration`).
323+
324+
Default: `'django'`
325+
317326
---
318327

319328
## Encodings

rest_framework/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121

2222
# Default datetime input and output formats
2323
ISO_8601 = 'iso-8601'
24+
DJANGO_DURATION_FORMAT = 'django'
2425

2526

2627
class RemovedInDRF317Warning(PendingDeprecationWarning):

rest_framework/fields.py

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
from django.utils.dateparse import (
2525
parse_date, parse_datetime, parse_duration, parse_time
2626
)
27-
from django.utils.duration import duration_string
27+
from django.utils.duration import duration_iso_string, duration_string
2828
from django.utils.encoding import is_protected_type, smart_str
2929
from django.utils.formats import localize_input, sanitize_separators
3030
from django.utils.ipv6 import clean_ipv6_address
@@ -35,7 +35,7 @@
3535
except ImportError:
3636
pytz = None
3737

38-
from rest_framework import ISO_8601
38+
from rest_framework import DJANGO_DURATION_FORMAT, ISO_8601
3939
from rest_framework.compat import ip_address_validators
4040
from rest_framework.exceptions import ErrorDetail, ValidationError
4141
from rest_framework.settings import api_settings
@@ -1351,9 +1351,22 @@ class DurationField(Field):
13511351
'overflow': _('The number of days must be between {min_days} and {max_days}.'),
13521352
}
13531353

1354-
def __init__(self, **kwargs):
1354+
def __init__(self, *, format=empty, **kwargs):
13551355
self.max_value = kwargs.pop('max_value', None)
13561356
self.min_value = kwargs.pop('min_value', None)
1357+
if format is not empty:
1358+
if format is None or (isinstance(format, str) and format.lower() in (ISO_8601, DJANGO_DURATION_FORMAT)):
1359+
self.format = format
1360+
elif isinstance(format, str):
1361+
raise ValueError(
1362+
f"Unknown duration format provided, got '{format}'"
1363+
" while expecting 'django', 'iso-8601' or `None`."
1364+
)
1365+
else:
1366+
raise TypeError(
1367+
"duration format must be either str or `None`,"
1368+
f" not {type(format).__name__}"
1369+
)
13571370
super().__init__(**kwargs)
13581371
if self.max_value is not None:
13591372
message = lazy_format(self.error_messages['max_value'], max_value=self.max_value)
@@ -1376,7 +1389,26 @@ def to_internal_value(self, value):
13761389
self.fail('invalid', format='[DD] [HH:[MM:]]ss[.uuuuuu]')
13771390

13781391
def to_representation(self, value):
1379-
return duration_string(value)
1392+
output_format = getattr(self, 'format', api_settings.DURATION_FORMAT)
1393+
1394+
if output_format is None:
1395+
return value
1396+
1397+
if isinstance(output_format, str):
1398+
if output_format.lower() == ISO_8601:
1399+
return duration_iso_string(value)
1400+
1401+
if output_format.lower() == DJANGO_DURATION_FORMAT:
1402+
return duration_string(value)
1403+
1404+
raise ValueError(
1405+
f"Unknown duration format provided, got '{output_format}'"
1406+
" while expecting 'django', 'iso-8601' or `None`."
1407+
)
1408+
raise TypeError(
1409+
"duration format must be either str or `None`,"
1410+
f" not {type(output_format).__name__}"
1411+
)
13801412

13811413

13821414
# Choice types...

rest_framework/settings.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
from django.core.signals import setting_changed
2525
from django.utils.module_loading import import_string
2626

27-
from rest_framework import ISO_8601
27+
from rest_framework import DJANGO_DURATION_FORMAT, ISO_8601
2828

2929
DEFAULTS = {
3030
# Base API policies
@@ -109,6 +109,8 @@
109109
'TIME_FORMAT': ISO_8601,
110110
'TIME_INPUT_FORMATS': [ISO_8601],
111111

112+
'DURATION_FORMAT': DJANGO_DURATION_FORMAT,
113+
112114
# Encoding
113115
'UNICODE_JSON': True,
114116
'COMPACT_JSON': True,

tests/test_fields.py

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1770,9 +1770,69 @@ class TestDurationField(FieldValues):
17701770
}
17711771
field = serializers.DurationField()
17721772

1773+
def test_invalid_format(self):
1774+
with pytest.raises(ValueError) as exc_info:
1775+
serializers.DurationField(format='unknown')
1776+
assert str(exc_info.value) == (
1777+
"Unknown duration format provided, got 'unknown'"
1778+
" while expecting 'django', 'iso-8601' or `None`."
1779+
)
1780+
with pytest.raises(TypeError) as exc_info:
1781+
serializers.DurationField(format=123)
1782+
assert str(exc_info.value) == (
1783+
"duration format must be either str or `None`, not int"
1784+
)
17731785

1774-
# Choice types...
1786+
def test_invalid_format_in_config(self):
1787+
field = serializers.DurationField()
1788+
1789+
with override_settings(REST_FRAMEWORK={'DURATION_FORMAT': 'unknown'}):
1790+
with pytest.raises(ValueError) as exc_info:
1791+
field.to_representation(datetime.timedelta(days=1))
1792+
1793+
assert str(exc_info.value) == (
1794+
"Unknown duration format provided, got 'unknown'"
1795+
" while expecting 'django', 'iso-8601' or `None`."
1796+
)
1797+
with override_settings(REST_FRAMEWORK={'DURATION_FORMAT': 123}):
1798+
with pytest.raises(TypeError) as exc_info:
1799+
field.to_representation(datetime.timedelta(days=1))
1800+
assert str(exc_info.value) == (
1801+
"duration format must be either str or `None`, not int"
1802+
)
1803+
1804+
1805+
class TestNoOutputFormatDurationField(FieldValues):
1806+
"""
1807+
Values for `DurationField` with a no output format.
1808+
"""
1809+
valid_inputs = {}
1810+
invalid_inputs = {}
1811+
outputs = {
1812+
datetime.timedelta(1): datetime.timedelta(1)
1813+
}
1814+
field = serializers.DurationField(format=None)
1815+
1816+
1817+
class TestISOOutputFormatDurationField(FieldValues):
1818+
"""
1819+
Values for `DurationField` with a custom output format.
1820+
"""
1821+
valid_inputs = {
1822+
'13': datetime.timedelta(seconds=13),
1823+
'P3DT08H32M01.000123S': datetime.timedelta(days=3, hours=8, minutes=32, seconds=1, microseconds=123),
1824+
'PT8H1M': datetime.timedelta(hours=8, minutes=1),
1825+
'-P999999999D': datetime.timedelta(days=-999999999),
1826+
'P999999999D': datetime.timedelta(days=999999999)
1827+
}
1828+
invalid_inputs = {}
1829+
outputs = {
1830+
datetime.timedelta(days=3, hours=8, minutes=32, seconds=1, microseconds=123): 'P3DT08H32M01.000123S'
1831+
}
1832+
field = serializers.DurationField(format='iso-8601')
17751833

1834+
1835+
# Choice types...
17761836
class TestChoiceField(FieldValues):
17771837
"""
17781838
Valid and invalid values for `ChoiceField`.

0 commit comments

Comments
 (0)