diff --git a/src/marshmallow/utils.py b/src/marshmallow/utils.py index bda3c06fc..3fd5e6769 100644 --- a/src/marshmallow/utils.py +++ b/src/marshmallow/utils.py @@ -41,17 +41,21 @@ def from_timestamp(value: typing.Any) -> dt.datetime: if value is True or value is False: raise ValueError("Not a valid POSIX timestamp") 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. try: - return dt.datetime.fromtimestamp(value, tz=dt.timezone.utc).replace(tzinfo=None) + ret_dt = dt.datetime.fromtimestamp(value, tz=dt.timezone.utc) except OverflowError as exc: raise ValueError("Timestamp is too large") from exc - except OSError as exc: - raise ValueError("Error converting value to datetime") from exc + except OSError: + try: + ret_dt = dt.datetime(1970, 1, 1, tzinfo=dt.timezone.utc) + dt.timedelta( + seconds=value + ) + except OverflowError as exc: + raise ValueError("Timestamp is too large") from exc + # Then set timezone to None, to let the Field handle adding timezone info. + return ret_dt.replace(tzinfo=None) def from_timestamp_ms(value: typing.Any) -> dt.datetime: diff --git a/tests/test_deserialization.py b/tests/test_deserialization.py index eb7faa9ad..5fcf93c97 100644 --- a/tests/test_deserialization.py +++ b/tests/test_deserialization.py @@ -569,8 +569,10 @@ def test_iso_datetime_field_deserialization(self, fmt, value, expected, aware): dt.datetime(2013, 11, 10, 0, 23, 45, 123456), ), ("timestamp", 1, dt.datetime(1970, 1, 1, 0, 0, 1)), + ("timestamp", -1, dt.datetime(1969, 12, 31, 23, 59, 59)), ("timestamp_ms", 1384043025000, dt.datetime(2013, 11, 10, 0, 23, 45)), ("timestamp_ms", 1000, dt.datetime(1970, 1, 1, 0, 0, 1)), + ("timestamp_ms", -1000, dt.datetime(1969, 12, 31, 23, 59, 59)), ], ) def test_timestamp_field_deserialization(self, fmt, value, expected): @@ -598,16 +600,14 @@ def test_boolean_timestamp_field_deserialization(self, fmt, in_value): field.deserialize(in_value) @pytest.mark.parametrize("fmt", ["timestamp", "timestamp_ms"]) - @pytest.mark.parametrize( - "in_value", - ["", "!@#", -1], - ) + @pytest.mark.parametrize("in_value", ["", "!@#"]) def test_invalid_timestamp_field_deserialization(self, fmt, in_value): field = fields.DateTime(format=fmt) with pytest.raises(ValidationError, match="Not a valid datetime."): field.deserialize(in_value) # Regression test for https://github.com/marshmallow-code/marshmallow/pull/2102 + # fromtimestamp may raise OSError or OverflowError depending on the platform. @pytest.mark.parametrize("fmt", ["timestamp", "timestamp_ms"]) @pytest.mark.parametrize( "mock_fromtimestamp", [MockDateTimeOSError, MockDateTimeOverflowError] @@ -618,6 +618,16 @@ def test_oversized_timestamp_field_deserialization(self, fmt, mock_fromtimestamp with pytest.raises(ValidationError, match="Not a valid datetime."): field.deserialize(99999999999999999) + @pytest.mark.parametrize( + ("fmt", "val"), (("timestamp", -1), ("timestamp_ms", -1000)) + ) + def test_negative_timestamp_field_deserialization(self, fmt, val): + # OSError is raised on Windows for negative timestamps. + # Force it with a mock to test on any platform. + with patch("datetime.datetime", MockDateTimeOSError): + field = fields.DateTime(format=fmt) + assert field.deserialize(val) == dt.datetime(1969, 12, 31, 23, 59, 59) + @pytest.mark.parametrize( ("fmt", "timezone", "value", "expected"), [ diff --git a/tests/test_utils.py b/tests/test_utils.py index 26c12f6a7..f8aac416b 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -108,6 +108,8 @@ def test_is_collection(): [ (1676386740, dt.datetime(2023, 2, 14, 14, 59, 00)), (1676386740.58, dt.datetime(2023, 2, 14, 14, 59, 00, 580000)), + (-627296460, dt.datetime(1950, 2, 14, 14, 59, 00)), + (-627296459.42, dt.datetime(1950, 2, 14, 14, 59, 00, 580000)), ], ) def test_from_timestamp(value, expected): @@ -116,12 +118,6 @@ def test_from_timestamp(value, expected): assert result == expected -def test_from_timestamp_with_negative_value(): - value = -10 - with pytest.raises(ValueError, match=r"Not a valid POSIX timestamp"): - utils.from_timestamp(value) - - def test_from_timestamp_with_overflow_value(): value = 9223372036854775 with pytest.raises(ValueError, match=r"out of range|year must be in 1\.\.9999"):