Skip to content
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

Timestamp field added #1009

Closed
wants to merge 9 commits into from
36 changes: 36 additions & 0 deletions marshmallow/fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
30 changes: 29 additions & 1 deletion marshmallow/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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')
Expand Down
27 changes: 27 additions & 0 deletions tests/test_fields.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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
11 changes: 11 additions & 0 deletions tests/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)