Skip to content

[pull] master from encode:master #24

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Aug 12, 2025
Merged
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
7 changes: 5 additions & 2 deletions docs/api-guide/fields.md
Original file line number Diff line number Diff line change
Expand Up @@ -377,13 +377,16 @@ A Duration representation.
Corresponds to `django.db.models.fields.DurationField`

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

**Signature:** `DurationField(max_value=None, min_value=None)`
**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 `'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` 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'`).

---

# Choice selection fields
Expand Down
9 changes: 9 additions & 0 deletions docs/api-guide/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,15 @@ May be a list including the string `'iso-8601'` or Python [strftime format][strf

Default: `['iso-8601']`


#### DURATION_FORMAT

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.

May be any of `None`, `'iso-8601'` or `'django'` (the format accepted by `django.utils.dateparse.parse_duration`).

Default: `'django'`

---

## Encodings
Expand Down
1 change: 1 addition & 0 deletions rest_framework/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@

# Default datetime input and output formats
ISO_8601 = 'iso-8601'
DJANGO_DURATION_FORMAT = 'django'


class RemovedInDRF317Warning(PendingDeprecationWarning):
Expand Down
40 changes: 36 additions & 4 deletions rest_framework/fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
from django.utils.dateparse import (
parse_date, parse_datetime, parse_duration, parse_time
)
from django.utils.duration import duration_string
from django.utils.duration import duration_iso_string, duration_string
from django.utils.encoding import is_protected_type, smart_str
from django.utils.formats import localize_input, sanitize_separators
from django.utils.ipv6 import clean_ipv6_address
Expand All @@ -35,7 +35,7 @@
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
Expand Down Expand Up @@ -1351,9 +1351,22 @@ class DurationField(Field):
'overflow': _('The number of days must be between {min_days} and {max_days}.'),
}

def __init__(self, **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:
if format is None or (isinstance(format, str) and format.lower() in (ISO_8601, DJANGO_DURATION_FORMAT)):
self.format = format
elif isinstance(format, str):
raise ValueError(
f"Unknown duration format provided, got '{format}'"
" while expecting 'django', 'iso-8601' or `None`."
)
else:
raise TypeError(
"duration format must be either str or `None`,"
f" not {type(format).__name__}"
)
super().__init__(**kwargs)
if self.max_value is not None:
message = lazy_format(self.error_messages['max_value'], max_value=self.max_value)
Expand All @@ -1376,7 +1389,26 @@ def to_internal_value(self, value):
self.fail('invalid', format='[DD] [HH:[MM:]]ss[.uuuuuu]')

def to_representation(self, value):
return duration_string(value)
output_format = getattr(self, 'format', api_settings.DURATION_FORMAT)

if output_format is None:
return 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`."
)
raise TypeError(
"duration format must be either str or `None`,"
f" not {type(output_format).__name__}"
)


# Choice types...
Expand Down
4 changes: 3 additions & 1 deletion rest_framework/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
from django.core.signals import setting_changed
from django.utils.module_loading import import_string

from rest_framework import ISO_8601
from rest_framework import DJANGO_DURATION_FORMAT, ISO_8601

DEFAULTS = {
# Base API policies
Expand Down Expand Up @@ -109,6 +109,8 @@
'TIME_FORMAT': ISO_8601,
'TIME_INPUT_FORMATS': [ISO_8601],

'DURATION_FORMAT': DJANGO_DURATION_FORMAT,

# Encoding
'UNICODE_JSON': True,
'COMPACT_JSON': True,
Expand Down
62 changes: 61 additions & 1 deletion tests/test_fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -1770,9 +1770,69 @@ 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(TypeError) as exc_info:
serializers.DurationField(format=123)
assert str(exc_info.value) == (
"duration format must be either str or `None`, not int"
)

# Choice types...
def test_invalid_format_in_config(self):
field = serializers.DurationField()

with override_settings(REST_FRAMEWORK={'DURATION_FORMAT': 'unknown'}):
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`."
)
with override_settings(REST_FRAMEWORK={'DURATION_FORMAT': 123}):
with pytest.raises(TypeError) as exc_info:
field.to_representation(datetime.timedelta(days=1))
assert str(exc_info.value) == (
"duration format must be either str or `None`, not int"
)


class TestNoOutputFormatDurationField(FieldValues):
"""
Values for `DurationField` with a no output format.
"""
valid_inputs = {}
invalid_inputs = {}
outputs = {
datetime.timedelta(1): datetime.timedelta(1)
}
field = serializers.DurationField(format=None)


class TestISOOutputFormatDurationField(FieldValues):
"""
Values for `DurationField` with a custom output format.
"""
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'
}
field = serializers.DurationField(format='iso-8601')


# Choice types...
class TestChoiceField(FieldValues):
"""
Valid and invalid values for `ChoiceField`.
Expand Down