diff --git a/tests/test_api_client/test_serializer.py b/tests/test_api_client/test_serializer.py index b190bf15..fbb9b3b4 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)/"), @@ -337,6 +338,35 @@ 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) + + +@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", @@ -356,6 +386,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 cf508da9..83e3012a 100644 --- a/xero_python/api_client/serializer.py +++ b/xero_python/api_client/serializer.py @@ -12,6 +12,27 @@ 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 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.""" + 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() def data_type(value, explicit_type=None): @@ -159,7 +180,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 +201,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)