From 8ef78b81654ebcf19a1fc241e2b1ede35100096b Mon Sep 17 00:00:00 2001 From: Will Hardy Date: Thu, 26 May 2016 14:48:36 +0200 Subject: [PATCH] Fixed #26656 -- Added duration (timedelta) support to DjangoJSONEncoder. --- django/core/serializers/json.py | 3 +++ django/utils/dateparse.py | 6 +++-- django/utils/duration.py | 23 +++++++++++++++-- docs/releases/1.11.txt | 4 +++ docs/topics/serialization.txt | 9 +++++++ tests/serializers/test_json.py | 13 ++++++++++ tests/utils_tests/test_duration.py | 40 +++++++++++++++++++++++++++++- 7 files changed, 93 insertions(+), 5 deletions(-) diff --git a/django/core/serializers/json.py b/django/core/serializers/json.py index a94a207ad6d9e..b66b3697b3f5f 100644 --- a/django/core/serializers/json.py +++ b/django/core/serializers/json.py @@ -16,6 +16,7 @@ Deserializer as PythonDeserializer, Serializer as PythonSerializer, ) from django.utils import six +from django.utils.duration import duration_iso_string from django.utils.functional import Promise from django.utils.timezone import is_aware @@ -108,6 +109,8 @@ def default(self, o): if o.microsecond: r = r[:12] return r + elif isinstance(o, datetime.timedelta): + return duration_iso_string(o) elif isinstance(o, decimal.Decimal): return str(o) elif isinstance(o, uuid.UUID): diff --git a/django/utils/dateparse.py b/django/utils/dateparse.py index 30a96f818e6a9..c3d7eb06b94c1 100644 --- a/django/utils/dateparse.py +++ b/django/utils/dateparse.py @@ -40,7 +40,8 @@ # Support the sections of ISO 8601 date representation that are accepted by # timedelta iso8601_duration_re = re.compile( - r'^P' + r'^(?P[-+]?)' + r'P' r'(?:(?P\d+(.\d+)?)D)?' r'(?:T' r'(?:(?P\d+(.\d+)?)H)?' @@ -121,7 +122,8 @@ def parse_duration(value): match = iso8601_duration_re.match(value) if match: kw = match.groupdict() + sign = -1 if kw.pop('sign', '+') == '-' else 1 if kw.get('microseconds'): kw['microseconds'] = kw['microseconds'].ljust(6, '0') kw = {k: float(v) for k, v in six.iteritems(kw) if v is not None} - return datetime.timedelta(**kw) + return sign * datetime.timedelta(**kw) diff --git a/django/utils/duration.py b/django/utils/duration.py index c37c885b9123a..53322b51d2344 100644 --- a/django/utils/duration.py +++ b/django/utils/duration.py @@ -1,7 +1,7 @@ -"""Version of str(timedelta) which is not English specific.""" +import datetime -def duration_string(duration): +def _get_duration_components(duration): days = duration.days seconds = duration.seconds microseconds = duration.microseconds @@ -12,6 +12,13 @@ def duration_string(duration): hours = minutes // 60 minutes = minutes % 60 + return days, hours, minutes, seconds, microseconds + + +def duration_string(duration): + """Version of str(timedelta) which is not English specific.""" + days, hours, minutes, seconds, microseconds = _get_duration_components(duration) + string = '{:02d}:{:02d}:{:02d}'.format(hours, minutes, seconds) if days: string = '{} '.format(days) + string @@ -19,3 +26,15 @@ def duration_string(duration): string += '.{:06d}'.format(microseconds) return string + + +def duration_iso_string(duration): + if duration < datetime.timedelta(0): + sign = '-' + duration *= -1 + else: + sign = '' + + days, hours, minutes, seconds, microseconds = _get_duration_components(duration) + ms = '.{:06d}'.format(microseconds) if microseconds else "" + return '{}P{}DT{:02d}H{:02d}M{:02d}{}S'.format(sign, days, hours, minutes, seconds, ms) diff --git a/docs/releases/1.11.txt b/docs/releases/1.11.txt index dc01a824e8851..2056e54921ff6 100644 --- a/docs/releases/1.11.txt +++ b/docs/releases/1.11.txt @@ -223,6 +223,10 @@ Serialization can now be customized by passing a ``cls`` keyword argument to the ``serializers.serialize()`` function. +* :class:`~django.core.serializers.json.DjangoJSONEncoder` now serializes + :class:`~datetime.timedelta` objects (used by + :class:`~django.db.models.DurationField`). + Signals ~~~~~~~ diff --git a/docs/topics/serialization.txt b/docs/topics/serialization.txt index c08ea2797747e..5db5e656ecb05 100644 --- a/docs/topics/serialization.txt +++ b/docs/topics/serialization.txt @@ -297,6 +297,11 @@ The JSON serializer uses ``DjangoJSONEncoder`` for encoding. A subclass of :class:`~datetime.time` A string of the form ``HH:MM:ss.sss`` as defined in `ECMA-262`_. +:class:`~datetime.timedelta` + A string representing a duration as defined in ISO-8601. For example, + ``timedelta(days=1, hours=2, seconds=3.4)`` is represented as + ``'P1DT02H00M03.400000S'``. + :class:`~decimal.Decimal`, ``Promise`` (``django.utils.functional.lazy()`` objects), :class:`~uuid.UUID` A string representation of the object. @@ -304,6 +309,10 @@ The JSON serializer uses ``DjangoJSONEncoder`` for encoding. A subclass of Support for ``Promise`` was added. +.. versionchanged:: 1.11 + + Support for :class:`~datetime.timedelta` was added. + .. _ecma-262: http://www.ecma-international.org/ecma-262/5.1/#sec-15.9.1.15 YAML diff --git a/tests/serializers/test_json.py b/tests/serializers/test_json.py index d0c9656643081..b191ce6644081 100644 --- a/tests/serializers/test_json.py +++ b/tests/serializers/test_json.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals +import datetime import decimal import json import re @@ -302,3 +303,15 @@ def test_lazy_string_encoding(self): json.dumps({'lang': ugettext_lazy("French")}, cls=DjangoJSONEncoder), '{"lang": "Fran\\u00e7ais"}' ) + + def test_timedelta(self): + duration = datetime.timedelta(days=1, hours=2, seconds=3) + self.assertEqual( + json.dumps({'duration': duration}, cls=DjangoJSONEncoder), + '{"duration": "P1DT02H00M03S"}' + ) + duration = datetime.timedelta(0) + self.assertEqual( + json.dumps({'duration': duration}, cls=DjangoJSONEncoder), + '{"duration": "P0DT00H00M00S"}' + ) diff --git a/tests/utils_tests/test_duration.py b/tests/utils_tests/test_duration.py index 559d2ef16f5bc..d0564f396fc95 100644 --- a/tests/utils_tests/test_duration.py +++ b/tests/utils_tests/test_duration.py @@ -2,7 +2,7 @@ import unittest from django.utils.dateparse import parse_duration -from django.utils.duration import duration_string +from django.utils.duration import duration_iso_string, duration_string class TestDurationString(unittest.TestCase): @@ -41,3 +41,41 @@ def test_microseconds(self): def test_negative(self): duration = datetime.timedelta(days=-1, hours=1, minutes=3, seconds=5) self.assertEqual(parse_duration(duration_string(duration)), duration) + + +class TestISODurationString(unittest.TestCase): + + def test_simple(self): + duration = datetime.timedelta(hours=1, minutes=3, seconds=5) + self.assertEqual(duration_iso_string(duration), 'P0DT01H03M05S') + + def test_days(self): + duration = datetime.timedelta(days=1, hours=1, minutes=3, seconds=5) + self.assertEqual(duration_iso_string(duration), 'P1DT01H03M05S') + + def test_microseconds(self): + duration = datetime.timedelta(hours=1, minutes=3, seconds=5, microseconds=12345) + self.assertEqual(duration_iso_string(duration), 'P0DT01H03M05.012345S') + + def test_negative(self): + duration = -1 * datetime.timedelta(days=1, hours=1, minutes=3, seconds=5) + self.assertEqual(duration_iso_string(duration), '-P1DT01H03M05S') + + +class TestParseISODurationRoundtrip(unittest.TestCase): + + def test_simple(self): + duration = datetime.timedelta(hours=1, minutes=3, seconds=5) + self.assertEqual(parse_duration(duration_iso_string(duration)), duration) + + def test_days(self): + duration = datetime.timedelta(days=1, hours=1, minutes=3, seconds=5) + self.assertEqual(parse_duration(duration_iso_string(duration)), duration) + + def test_microseconds(self): + duration = datetime.timedelta(hours=1, minutes=3, seconds=5, microseconds=12345) + self.assertEqual(parse_duration(duration_iso_string(duration)), duration) + + def test_negative(self): + duration = datetime.timedelta(days=-1, hours=1, minutes=3, seconds=5) + self.assertEqual(parse_duration(duration_iso_string(duration)).total_seconds(), duration.total_seconds())