diff --git a/src/marshmallow/fields.py b/src/marshmallow/fields.py index 69e8dbb19..042bb18c5 100644 --- a/src/marshmallow/fields.py +++ b/src/marshmallow/fields.py @@ -1341,6 +1341,11 @@ class DateTime(_TemporalField[dt.datetime]): "timestamp_ms": utils.from_timestamp_ms, } + #: Deserialization formats that encode an instant rather than a wall time. + #: :func:`~marshmallow.utils.from_timestamp` returns the UTC wall time for + #: these, so the naive value it produces is known to be UTC. + UTC_FORMATS = frozenset({"timestamp", "timestamp_ms"}) + DEFAULT_FORMAT = "iso" OBJ_TYPE = "datetime" @@ -1417,13 +1422,21 @@ def __init__( def _deserialize(self, value, attr, data, **kwargs) -> dt.datetime: ret = super()._deserialize(value, attr, data, **kwargs) if not utils.is_aware(ret): - if self.default_timezone is None: + if ( + self.format or self.DEFAULT_FORMAT + ) in self.UTC_FORMATS and not isinstance(value, dt.datetime): + # A POSIX timestamp denotes an instant in UTC. There is no + # missing timezone for ``default_timezone`` to supply, so the + # value is returned as UTC. + ret = ret.replace(tzinfo=dt.timezone.utc) + elif self.default_timezone is None: raise self.make_error( "invalid_awareness", awareness=self.AWARENESS, obj_type=self.OBJ_TYPE, ) - ret = ret.replace(tzinfo=self.default_timezone) + else: + ret = ret.replace(tzinfo=self.default_timezone) return ret diff --git a/tests/test_deserialization.py b/tests/test_deserialization.py index b3d21b170..fa00a294c 100644 --- a/tests/test_deserialization.py +++ b/tests/test_deserialization.py @@ -577,18 +577,27 @@ 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. + # A naive field strips the timezone, as before. field = fields.NaiveDateTime(format=fmt) assert field.deserialize(value) == expected + # A timestamp denotes an instant in UTC, so an aware field gets UTC. field = fields.AwareDateTime(format=fmt) - with pytest.raises(ValidationError, match="Not a valid aware datetime."): - field.deserialize(value) + assert field.deserialize(value) == expected.replace(tzinfo=dt.timezone.utc) - # But it can be added by providing a default. + # There is no missing timezone for default_timezone to supply, so it + # does not apply and the instant is still UTC. + field = fields.AwareDateTime(format=fmt, default_timezone=central) + assert field.deserialize(value) == expected.replace(tzinfo=dt.timezone.utc) + + @pytest.mark.parametrize("fmt", ["timestamp", "timestamp_ms"]) + def test_aware_timestamp_deserialization_preserves_instant(self, fmt): + # A timestamp identifies an instant, so deserializing one must not + # move that instant, whichever timezone it is expressed in. + timestamp = 1384043025 + value = timestamp * 1000 if fmt == "timestamp_ms" else timestamp field = fields.AwareDateTime(format=fmt, default_timezone=central) - expected_aware = expected.replace(tzinfo=central) - assert field.deserialize(value) == expected_aware + assert field.deserialize(value).timestamp() == timestamp @pytest.mark.parametrize("fmt", ["timestamp", "timestamp_ms"]) @pytest.mark.parametrize("in_value", [True, False])