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

Add timestamp as option to DateTime Fields #2022

Merged
merged 6 commits into from
Nov 11, 2022
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
1 change: 1 addition & 0 deletions AUTHORS.rst
Original file line number Diff line number Diff line change
Expand Up @@ -170,3 +170,4 @@ Contributors (chronological)
- Isira Seneviratne `@Isira-Seneviratne <https://github.com/Isira-Seneviratne>`_
- Karthikeyan Singaravelan `@tirkarthi <https://github.com/tirkarthi>`_
- Marco Satti `@marcosatti <https://github.com/marcosatti>`_
- Ivo Reumkens `@vanHoi <https://github.com/vanHoi>`_
10 changes: 10 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
Changelog
---------

3.19.0 (unreleased)
*******************

Features:

- Add ``timestamp`` and ``timestamp_ms`` formats to `fields.DateTime`
(:issue:`612`).
Thanks :user:`vgavro` for the suggestion and thanks :user:`vanHoi` for
the PR.

3.18.0 (2022-09-15)
*******************

Expand Down
27 changes: 17 additions & 10 deletions src/marshmallow/fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -1218,25 +1218,32 @@ class DateTime(Field):
Example: ``'2014-12-22T03:12:58.019077+00:00'``

:param format: Either ``"rfc"`` (for RFC822), ``"iso"`` (for ISO8601),
or a date format string. If `None`, defaults to "iso".
``"timestamp"``, ``"timestamp_ms"`` (for a POSIX timestamp) or a date format string.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wondering (out loud) about the choices and their naming. I don't use timestamps. Are those two the two common uses?

timestamp is a number of seconds. Should we call it timestamp_s? I don't think so. Why the need for timestamp_ms (and not other units)? Is it also commonly used?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the review! Good question. These are the two common use cases (to my knowledge). JavaScript tends to use timestamps in milliseconds, while Python uses seconds by default.

If `None`, defaults to "iso".
:param kwargs: The same keyword arguments that :class:`Field` receives.

.. versionchanged:: 3.0.0rc9
Does not modify timezone information on (de)serialization.
.. versionchanged:: 3.19
Add timestamp as a format.
"""

SERIALIZATION_FUNCS = {
"iso": utils.isoformat,
"iso8601": utils.isoformat,
"rfc": utils.rfcformat,
"rfc822": utils.rfcformat,
} # type: typing.Dict[str, typing.Callable[[typing.Any], str]]
"timestamp": utils.timestamp,
"timestamp_ms": utils.timestamp_ms,
} # type: typing.Dict[str, typing.Callable[[typing.Any], str | float]]

DESERIALIZATION_FUNCS = {
"iso": utils.from_iso_datetime,
"iso8601": utils.from_iso_datetime,
"rfc": utils.from_rfc,
"rfc822": utils.from_rfc,
"timestamp": utils.from_timestamp,
"timestamp_ms": utils.from_timestamp_ms,
} # type: typing.Dict[str, typing.Callable[[str], typing.Any]]

DEFAULT_FORMAT = "iso"
Expand All @@ -1252,7 +1259,7 @@ class DateTime(Field):
"format": '"{input}" cannot be formatted as a {obj_type}.',
}

def __init__(self, format: str | None = None, **kwargs):
def __init__(self, format: str | None = None, **kwargs) -> None:
super().__init__(**kwargs)
# Allow this to be None. It may be set later in the ``_serialize``
# or ``_deserialize`` methods. This allows a Schema to dynamically set the
Expand All @@ -1267,7 +1274,7 @@ def _bind_to_schema(self, field_name, schema):
or self.DEFAULT_FORMAT
)

def _serialize(self, value, attr, obj, **kwargs):
def _serialize(self, value, attr, obj, **kwargs) -> str | float | None:
if value is None:
return None
data_format = self.format or self.DEFAULT_FORMAT
Expand All @@ -1277,7 +1284,7 @@ def _serialize(self, value, attr, obj, **kwargs):
else:
return value.strftime(data_format)

def _deserialize(self, value, attr, data, **kwargs):
def _deserialize(self, value, attr, data, **kwargs) -> dt.datetime:
if not value: # Falsy values, e.g. '', None, [] are not valid
raise self.make_error("invalid", input=value, obj_type=self.OBJ_TYPE)
data_format = self.format or self.DEFAULT_FORMAT
Expand All @@ -1298,7 +1305,7 @@ def _deserialize(self, value, attr, data, **kwargs):
) from error

@staticmethod
def _make_object_from_format(value, data_format):
def _make_object_from_format(value, data_format) -> dt.datetime:
return dt.datetime.strptime(value, data_format)


Expand All @@ -1323,11 +1330,11 @@ def __init__(
*,
timezone: dt.timezone | None = None,
**kwargs,
):
) -> None:
super().__init__(format=format, **kwargs)
self.timezone = timezone

def _deserialize(self, value, attr, data, **kwargs):
def _deserialize(self, value, attr, data, **kwargs) -> dt.datetime:
ret = super()._deserialize(value, attr, data, **kwargs)
if is_aware(ret):
if self.timezone is None:
Expand Down Expand Up @@ -1360,11 +1367,11 @@ def __init__(
*,
default_timezone: dt.tzinfo | None = None,
**kwargs,
):
) -> None:
super().__init__(format=format, **kwargs)
self.default_timezone = default_timezone

def _deserialize(self, value, attr, data, **kwargs):
def _deserialize(self, value, attr, data, **kwargs) -> dt.datetime:
ret = super()._deserialize(value, attr, data, **kwargs)
if not is_aware(ret):
if self.default_timezone is None:
Expand Down
28 changes: 28 additions & 0 deletions src/marshmallow/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,34 @@ def from_iso_date(value):
return dt.date(**kw)


def from_timestamp(value: typing.Any) -> dt.datetime:
value = float(value)
if value < 0:
raise ValueError("Not a valid POSIX timestamp")

# Load a timestamp with utc as timezone to prevent using system timezone.
# Then set timezone to None, to let the Field handle adding timezone info.
return dt.datetime.fromtimestamp(value, tz=dt.timezone.utc).replace(tzinfo=None)


def from_timestamp_ms(value: typing.Any) -> dt.datetime:
value = float(value)
return from_timestamp(value / 1000)


def timestamp(
value: dt.datetime,
) -> float:
if not is_aware(value):
# When a date is naive, use UTC as zone info to prevent using system timezone.
value = value.replace(tzinfo=dt.timezone.utc)
return value.timestamp()


def timestamp_ms(value: dt.datetime) -> float:
return timestamp(value) * 1000


def isoformat(datetime: dt.datetime) -> str:
"""Return the ISO8601-formatted representation of a datetime object.

Expand Down
44 changes: 44 additions & 0 deletions tests/test_deserialization.py
Original file line number Diff line number Diff line change
Expand Up @@ -526,6 +526,50 @@ def test_iso_datetime_field_deserialization(self, fmt, value, expected, aware):
else:
assert field.deserialize(value) == expected

@pytest.mark.parametrize(
("fmt", "value", "expected"),
[
("timestamp", 1384043025, dt.datetime(2013, 11, 10, 0, 23, 45)),
("timestamp", "1384043025", dt.datetime(2013, 11, 10, 0, 23, 45)),
("timestamp", 1384043025, dt.datetime(2013, 11, 10, 0, 23, 45)),
("timestamp", 1384043025.12, dt.datetime(2013, 11, 10, 0, 23, 45, 120000)),
(
"timestamp",
1384043025.123456,
dt.datetime(2013, 11, 10, 0, 23, 45, 123456),
),
("timestamp", 1, dt.datetime(1970, 1, 1, 0, 0, 1)),
("timestamp_ms", 1384043025000, dt.datetime(2013, 11, 10, 0, 23, 45)),
("timestamp_ms", 1000, dt.datetime(1970, 1, 1, 0, 0, 1)),
],
)
def test_timestamp_field_deserialization(self, fmt, value, expected):
field = fields.DateTime(format=fmt)
assert field.deserialize(value) == expected

# By default, a datetime from a timestamp is never aware.
field = fields.NaiveDateTime(format=fmt)
assert field.deserialize(value) == expected

field = fields.AwareDateTime(format=fmt)
with pytest.raises(ValidationError, match="Not a valid aware datetime."):
field.deserialize(value)

# But it can be added by providing a default.
field = fields.AwareDateTime(format=fmt, default_timezone=central)
expected_aware = expected.replace(tzinfo=central)
assert field.deserialize(value) == expected_aware

@pytest.mark.parametrize("fmt", ["timestamp", "timestamp_ms"])
@pytest.mark.parametrize(
"in_value",
["", "!@#", 0, -1, dt.datetime(2013, 11, 10, 1, 23, 45)],
)
def test_invalid_timestamp_field_deserialization(self, fmt, in_value):
field = fields.DateTime(format="timestamp")
with pytest.raises(ValidationError, match="Not a valid datetime."):
field.deserialize(in_value)

@pytest.mark.parametrize(
("fmt", "timezone", "value", "expected"),
[
Expand Down
32 changes: 32 additions & 0 deletions tests/test_serialization.py
Original file line number Diff line number Diff line change
Expand Up @@ -579,6 +579,38 @@ def test_datetime_field_rfc822(self, fmt, value, expected):
field = fields.DateTime(format=fmt)
assert field.serialize("d", {"d": value}) == expected

@pytest.mark.parametrize(
("fmt", "value", "expected"),
[
("timestamp", dt.datetime(1970, 1, 1), 0),
("timestamp", dt.datetime(2013, 11, 10, 0, 23, 45), 1384043025),
(
"timestamp",
dt.datetime(2013, 11, 10, 0, 23, 45, tzinfo=dt.timezone.utc),
1384043025,
),
(
"timestamp",
central.localize(dt.datetime(2013, 11, 10, 0, 23, 45), is_dst=False),
1384064625,
),
("timestamp_ms", dt.datetime(2013, 11, 10, 0, 23, 45), 1384043025000),
(
"timestamp_ms",
dt.datetime(2013, 11, 10, 0, 23, 45, tzinfo=dt.timezone.utc),
1384043025000,
),
(
"timestamp_ms",
central.localize(dt.datetime(2013, 11, 10, 0, 23, 45), is_dst=False),
1384064625000,
),
],
)
def test_datetime_field_timestamp(self, fmt, value, expected):
field = fields.DateTime(format=fmt)
assert field.serialize("d", {"d": value}) == expected

@pytest.mark.parametrize("fmt", ["iso", "iso8601", None])
@pytest.mark.parametrize(
("value", "expected"),
Expand Down