From 4f74a461e96655786d4e6b5a66e079aa55862c28 Mon Sep 17 00:00:00 2001 From: Ryan Duguid <152749594+ryanduguid@users.noreply.github.com> Date: Thu, 13 Aug 2026 02:50:13 +1000 Subject: [PATCH 1/3] fix: serialize epoch datetimes on Windows --- tests/test_api_client/test_serializer.py | 1 + xero_python/api_client/serializer.py | 12 ++++++++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/tests/test_api_client/test_serializer.py b/tests/test_api_client/test_serializer.py index b190bf15..61ce9ab4 100644 --- a/tests/test_api_client/test_serializer.py +++ b/tests/test_api_client/test_serializer.py @@ -306,6 +306,7 @@ def test_serialize_datetime(value, expected): "value,expected", [ (datetime.fromtimestamp(0.0), "/Date(0)/"), + (datetime(1960, 1, 1, tzinfo=tz.UTC), "/Date(-315619200000+0000)/"), (datetime.fromtimestamp(1439424000.0), "/Date(1439424000000)/"), (datetime.fromtimestamp(1439434356.790), "/Date(1439434356790)/"), (datetime(2015, 8, 13, tzinfo=tz.UTC), "/Date(1439424000000+0000)/"), diff --git a/xero_python/api_client/serializer.py b/xero_python/api_client/serializer.py index cf508da9..7570a645 100644 --- a/xero_python/api_client/serializer.py +++ b/xero_python/api_client/serializer.py @@ -12,6 +12,14 @@ DICT_DATA_TYPE = re.compile(r"^dict(?:\[(.*)\])?$") LIST_DATA_TYPE = re.compile(r"^list(?:\[(.*)\])?$") TUPLE_DATA_TYPE = re.compile(r"^tuple(?:\[(.*)\])?$") +UNIX_EPOCH = datetime(1970, 1, 1, tzinfo=tz.UTC) + + +def datetime_timestamp(value): + """Return seconds from the Unix epoch without platform timestamp limits.""" + if value.tzinfo is None: + value = value.replace(tzinfo=tz.tzlocal()) + return (value.astimezone(tz.UTC) - UNIX_EPOCH).total_seconds() def data_type(value, explicit_type=None): @@ -159,7 +167,7 @@ def serialize_datetime_ms(value, explicit_type=None): :return: serialized object """ tz_str = value.strftime("%z") - timestamp_s = value.timestamp() + timestamp_s = datetime_timestamp(value) timestamp_ms = int(timestamp_s * 1000) return "/Date({}{})/".format(timestamp_ms, tz_str) @@ -180,7 +188,7 @@ def serialize_date_ms(value, explicit_type=None): else: raise ValueError("Can't serialize {!r} into Microsoft date json format") - timestamp_s = datetime_value.timestamp() + timestamp_s = datetime_timestamp(datetime_value) timestamp_ms = int(timestamp_s * 1000) return "/Date({})/".format(timestamp_ms) From d8379405cf89db6bb2b850d8fd2c4e368887d90d Mon Sep 17 00:00:00 2001 From: Ryan Duguid <152749594+ryanduguid@users.noreply.github.com> Date: Thu, 13 Aug 2026 03:08:42 +1000 Subject: [PATCH 2/3] test: cover pre-epoch local and date values --- tests/test_api_client/test_serializer.py | 21 +++++++++++++++++++++ xero_python/api_client/serializer.py | 10 +++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/tests/test_api_client/test_serializer.py b/tests/test_api_client/test_serializer.py index 61ce9ab4..db944237 100644 --- a/tests/test_api_client/test_serializer.py +++ b/tests/test_api_client/test_serializer.py @@ -338,6 +338,18 @@ def test_serialize_datetime_ms(value, expected): assert result == expected +def test_serialize_naive_pre_epoch_datetime_ms_uses_local_timezone(): + value = datetime(1960, 1, 1, 12, 30) + windows_local = getattr(tz, "tzwinlocal", None) + local_tz = windows_local() if windows_local is not None else tz.tzlocal() + local_offset = local_tz.utcoffset(value) or timedelta() + utc_value = (value - local_offset).replace(tzinfo=tz.UTC) + epoch = datetime(1970, 1, 1, tzinfo=tz.UTC) + expected_ms = int((utc_value - epoch).total_seconds() * 1000) + + assert serialize_datetime_ms(value) == "/Date({})/".format(expected_ms) + + # serialize_date_ms tests @pytest.mark.parametrize( "value,expected", @@ -357,6 +369,15 @@ def test_serialize_date_ms(value, expected): assert result == expected +def test_serialize_pre_epoch_date_ms_uses_utc_midnight(): + value = date(1960, 1, 1) + utc_value = datetime.combine(value, datetime.min.time()).replace(tzinfo=tz.UTC) + epoch = datetime(1970, 1, 1, tzinfo=tz.UTC) + expected_ms = int((utc_value - epoch).total_seconds() * 1000) + + assert serialize_date_ms(value) == "/Date({})/".format(expected_ms) + + # serialize_base_model tests def test_serialize_base_model(): # given test model diff --git a/xero_python/api_client/serializer.py b/xero_python/api_client/serializer.py index 7570a645..68ac22c5 100644 --- a/xero_python/api_client/serializer.py +++ b/xero_python/api_client/serializer.py @@ -15,10 +15,18 @@ UNIX_EPOCH = datetime(1970, 1, 1, tzinfo=tz.UTC) +def local_timezone(): + """Return a local timezone that supports pre-epoch dates on Windows.""" + windows_local = getattr(tz, "tzwinlocal", None) + if windows_local is not None: + return windows_local() + return tz.tzlocal() + + def datetime_timestamp(value): """Return seconds from the Unix epoch without platform timestamp limits.""" if value.tzinfo is None: - value = value.replace(tzinfo=tz.tzlocal()) + value = value.replace(tzinfo=local_timezone()) return (value.astimezone(tz.UTC) - UNIX_EPOCH).total_seconds() From 93d24fcbbfbc423cd8b4e956259442dc50e397d1 Mon Sep 17 00:00:00 2001 From: Ryan Duguid <152749594+ryanduguid@users.noreply.github.com> Date: Thu, 13 Aug 2026 03:38:06 +1000 Subject: [PATCH 3/3] Preserve native local datetime semantics --- tests/test_api_client/test_serializer.py | 17 +++++++++++++++++ xero_python/api_client/serializer.py | 5 +++++ 2 files changed, 22 insertions(+) diff --git a/tests/test_api_client/test_serializer.py b/tests/test_api_client/test_serializer.py index db944237..fbb9b3b4 100644 --- a/tests/test_api_client/test_serializer.py +++ b/tests/test_api_client/test_serializer.py @@ -350,6 +350,23 @@ def test_serialize_naive_pre_epoch_datetime_ms_uses_local_timezone(): assert serialize_datetime_ms(value) == "/Date({})/".format(expected_ms) +@pytest.mark.parametrize( + "value", + [ + datetime(1971, 1, 1, 12, 30), + datetime(2015, 8, 13, 12, 30), + datetime(2024, 4, 7, 2, 30, fold=0), + datetime(2024, 4, 7, 2, 30, fold=1), + datetime(2024, 10, 6, 2, 30, fold=0), + datetime(2024, 10, 6, 2, 30, fold=1), + ], +) +def test_serialize_naive_datetime_ms_preserves_platform_timestamp_semantics(value): + expected_ms = int(value.timestamp() * 1000) + + assert serialize_datetime_ms(value) == "/Date({})/".format(expected_ms) + + # serialize_date_ms tests @pytest.mark.parametrize( "value,expected", diff --git a/xero_python/api_client/serializer.py b/xero_python/api_client/serializer.py index 68ac22c5..83e3012a 100644 --- a/xero_python/api_client/serializer.py +++ b/xero_python/api_client/serializer.py @@ -25,6 +25,11 @@ def local_timezone(): def datetime_timestamp(value): """Return seconds from the Unix epoch without platform timestamp limits.""" + try: + # Preserve the platform's existing naive-local DST and fold semantics. + return value.timestamp() + except (OSError, OverflowError): + pass if value.tzinfo is None: value = value.replace(tzinfo=local_timezone()) return (value.astimezone(tz.UTC) - UNIX_EPOCH).total_seconds()