diff --git a/marshmallow/fields.py b/marshmallow/fields.py index 3a1fef299..1929a984d 100644 --- a/marshmallow/fields.py +++ b/marshmallow/fields.py @@ -1149,6 +1149,42 @@ def _deserialize(self, value, attr, data): self.fail('invalid') +class Timestamp(Field): + """Timestamp field, converts to datetime. + + :param timezone: Timezone of timestamp (defaults to UTC). + Timezone-aware datetimes will be converted to this before serialization, + timezone-naive datetimes will be serialized as is (in timestamp timezone). + :param bool ms: Milliseconds instead of seconds, defaults to `False`. For javascript + compatibility. + :param bool naive: Should deserialize to timezone-naive or timezone-aware datetime. + Defaults to `False`, so all datetimes will be timezone-aware with `timezone`. + :param bool as_int: If `True`, timestamp will be serialized to int instead of float, + so datetime microseconds precision can be lost. Note that this affects milliseconds also, + because 1 millisecond is 1000 microseconds. Defaults to `False`. + :param kwargs: The same keyword arguments that :class:`Field` receives. + """ + def __init__(self, timezone=utils.UTC, ms=False, naive=False, as_int=False, **kwargs): + self.timezone = utils.get_tzinfo(timezone) + self.ms = ms + self.naive = naive + self.as_int = as_int + super(Timestamp, self).__init__(**kwargs) + + def _serialize(self, value, attr, obj): + if value is None: + return None + value = utils.to_timestamp(value, self.timezone, self.ms) + return int(value) if self.as_int else value + + def _deserialize(self, value, attr, data): + try: + return utils.from_timestamp(value, None if self.naive else self.timezone, self.ms) + except (ValueError, OverflowError, OSError): + # Timestamp exceeds limits, ValueError needed for Python < 3.3 + self.fail('invalid') + + class Dict(Field): """A dict field. Supports dicts and dict-like objects. Optionally composed with another `Field` class or instance. diff --git a/marshmallow/utils.py b/marshmallow/utils.py index 168a2f169..fc43a7c9c 100644 --- a/marshmallow/utils.py +++ b/marshmallow/utils.py @@ -24,7 +24,7 @@ dateutil_available = False try: - from dateutil import parser + from dateutil import parser, tz dateutil_available = True except ImportError: dateutil_available = False @@ -317,6 +317,34 @@ def to_iso_date(date, *args, **kwargs): return datetime.date.isoformat(date) +_EPOCH = datetime.datetime(1970, 1, 1) + + +def from_timestamp(value, tzinfo=UTC, ms=False): + value = float(value) + return (datetime.datetime.utcfromtimestamp((value / 1000) if ms else value) + .replace(tzinfo=tzinfo)) + + +def to_timestamp(dt, tzinfo=UTC, ms=False): + if dt.tzinfo is not None: + dt = dt.astimezone(tzinfo).replace(tzinfo=None) + return (dt - _EPOCH).total_seconds() * (1000 if ms else 1) + + +def get_tzinfo(value, use_dateutil=True): + if isinstance(value, datetime.tzinfo): + return value + elif value == 'UTC': + return UTC + elif dateutil_available and use_dateutil: + tzinfo = tz.gettz(value) + if tzinfo is None: + raise ValueError('Unknown timezone', value) + return tzinfo + raise ValueError('Unknown timezone and dateutil not available') + + def ensure_text_type(val): if isinstance(val, binary_type): val = val.decode('utf-8') diff --git a/tests/test_fields.py b/tests/test_fields.py index de5ced4d4..6a6967105 100644 --- a/tests/test_fields.py +++ b/tests/test_fields.py @@ -1,9 +1,12 @@ # -*- coding: utf-8 -*- +import datetime as dt + import pytest from marshmallow import fields, Schema, ValidationError, EXCLUDE, INCLUDE, RAISE from marshmallow.marshalling import missing from marshmallow.exceptions import StringNotCollectionError +from marshmallow.utils import UTC from tests.base import ALL_FIELDS @@ -240,3 +243,27 @@ class MySchema(Schema): elif field_unknown == RAISE or (schema_unknown == RAISE and not field_unknown): with pytest.raises(ValidationError): MySchema().load({'nested': {'x': 1}}) + + +class TestTimestamp: + class UTC_plus_3(dt.tzinfo): + def utcoffset(self, dt_): + return dt.timedelta(hours=3) + def dst(self, dt_): + return dt.timedelta(0) + UTC_plus_3 = UTC_plus_3() + + @pytest.mark.parametrize( + 'timestamp,datetime,kwargs', ( + (-1.5, dt.datetime(1969, 12, 31, 23, 59, 58, 500000, tzinfo=UTC), {}), + (-1500, dt.datetime(1969, 12, 31, 23, 59, 58, 500000, tzinfo=UTC), {'ms': True}), + (0, dt.datetime(1970, 1, 1, tzinfo=UTC), {'timezone': 'UTC', 'ms': True}), + (0, dt.datetime(1970, 1, 1, tzinfo=None), {'naive': True}), + (0, dt.datetime(1969, 12, 31, 21, tzinfo=UTC), {'timezone': UTC_plus_3}), + (0, dt.datetime(1970, 1, 1, 3, tzinfo=UTC_plus_3), {'timezone': UTC}), + ), + ) + def test_load_dump(self, timestamp, datetime, kwargs): + field = fields.Timestamp(**kwargs) + assert not (field.deserialize(timestamp) - datetime).total_seconds() + assert field._serialize(datetime, '', object()) == timestamp diff --git a/tests/test_utils.py b/tests/test_utils.py index bf10a1bb3..bf2eb6200 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -237,3 +237,14 @@ def __call__(self, foo, bar): for func in [f1, f2, f3]: assert utils.get_func_args(func) == ['foo', 'bar'] + +@pytest.mark.parametrize('use_dateutil', [True, False]) +def test_get_tzinfo(use_dateutil): + if use_dateutil: + assert utils.get_tzinfo('Europe/Kiev', use_dateutil) + with pytest.raises(ValueError): + utils.get_tzinfo('Europe/Maaaskva', use_dateutil) + else: + assert utils.get_tzinfo('UTC', use_dateutil) + with pytest.raises(ValueError): + utils.get_tzinfo('Europe/Kiev', use_dateutil)