From 9f2d648a86474a7249ef3795e832eff07c69a48a Mon Sep 17 00:00:00 2001 From: Cedric Conday Date: Tue, 30 Jun 2026 03:07:05 +0000 Subject: [PATCH] Convert MS-format datetimes without platform-bound fromtimestamp (#59) deserialize_datetime_ms used datetime.fromtimestamp, which is bounded by the platform C library: it raises OSError on Windows for pre-1970 (negative) timestamps and leaks OSError/OverflowError for out-of-range values. Some Xero organisations return such timestamps, turning a normal API call (e.g. get_employees) into an uncaught OSError. Build the datetime from the Unix epoch with timedelta instead, which is platform independent, and surface the ValueError the function already documents for values python's datetime cannot represent. Closes #59. --- tests/test_api_client/test_deserializer.py | 19 +++++++++++++++++++ xero_python/api_client/deserializer.py | 13 +++++++++++-- 2 files changed, 30 insertions(+), 2 deletions(-) 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: