diff --git a/tests/test_api_client/test_deserializer.py b/tests/test_api_client/test_deserializer.py index 9c851a8a..d924f620 100644 --- a/tests/test_api_client/test_deserializer.py +++ b/tests/test_api_client/test_deserializer.py @@ -327,6 +327,25 @@ def test_deserialize_datetime_ms_error(data): deserialize_datetime_ms(data_type, data, model_finder=None) +def test_deserialize_datetime_ms_pre_epoch(): + # regression for #59: a timestamp before 1970 (negative ms) must convert + # cleanly on every platform instead of leaking OSError (datetime.fromtimestamp + # rejects negative timestamps on Windows). + result = deserialize_datetime_ms( + "datetime[ms-format]", "/Date(-2208988800000)/", model_finder=None + ) + assert result == datetime(1900, 1, 1, tzinfo=tz.UTC) + + +def test_deserialize_datetime_ms_out_of_range_raises_value_error(): + # regression for #59: an out-of-range timestamp must raise the documented + # ValueError, not leak OSError / OverflowError from the platform C library. + with pytest.raises(ValueError): + deserialize_datetime_ms( + "datetime[ms-format]", "/Date(67768036191676800000)/", model_finder=None + ) + + # deserialize_model tests def test_deserialize_model(): # given test model and test data diff --git a/xero_python/api_client/deserializer.py b/xero_python/api_client/deserializer.py index b64e9820..481f9d4d 100644 --- a/xero_python/api_client/deserializer.py +++ b/xero_python/api_client/deserializer.py @@ -250,8 +250,17 @@ def deserialize_datetime_ms(data_type, data, model_finder): tz_info = tz.UTC timestamp_ms = int(match.groupdict()["timestamp"]) - timestamp_s = timestamp_ms / 1000 - return datetime.datetime.fromtimestamp(timestamp_s, tz=tz_info) + # datetime.fromtimestamp is bounded by the platform's C library and + # leaks OSError/OverflowError for out-of-range or (on Windows) + # pre-1970 timestamps. Build the datetime from the epoch instead so + # the conversion is platform independent, and surface the documented + # ValueError for values python's datetime cannot represent. + epoch = datetime.datetime(1970, 1, 1, tzinfo=tz.UTC) + try: + utc_datetime = epoch + datetime.timedelta(milliseconds=timestamp_ms) + except (OverflowError, OSError) as error: + raise ValueError("Invalid datetime value {!r}".format(data)) from error + return utc_datetime.astimezone(tz_info) elif DATE_WITH_NO_DAY_RE.match(str(data)): return datetime.datetime.strptime(data + "-01", "%Y-%m-%d") else: