From eb3867f2e2d01973c56c68b0ed5866dcc4761be1 Mon Sep 17 00:00:00 2001 From: Kayvan Zahiri <123409429+Kayvan-Zahiri@users.noreply.github.com> Date: Sat, 17 Oct 2026 20:23:55 -0700 Subject: [PATCH] fix: sanitize datetime and NaN values inside tuple payloads --- customerio/client_base.py | 2 +- tests/test_tuple_payloads.py | 102 +++++++++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 tests/test_tuple_payloads.py diff --git a/customerio/client_base.py b/customerio/client_base.py index 4e63dd7..62cad78 100644 --- a/customerio/client_base.py +++ b/customerio/client_base.py @@ -123,7 +123,7 @@ def _sanitize_value(self, value): return None if isinstance(value, dict): return self._sanitize(value) - if isinstance(value, list): + if isinstance(value, (list, tuple)): return [self._sanitize_value(item) for item in value] return value diff --git a/tests/test_tuple_payloads.py b/tests/test_tuple_payloads.py new file mode 100644 index 0000000..984117e --- /dev/null +++ b/tests/test_tuple_payloads.py @@ -0,0 +1,102 @@ +"""Exercise tuple values through real requests JSON preparation, without networking.""" + +import json +import unittest +from datetime import datetime, timezone +from unittest.mock import patch + +from requests import Response +from requests.adapters import BaseAdapter + +from customerio import APIClient, CustomerIO, SendEmailRequest + + +class RecordingAdapter(BaseAdapter): + def __init__(self): + self.requests = [] + + def send(self, request, **kwargs): + self.requests.append(request) + response = Response() + response.status_code = 200 + response._content = b"{}" + response.request = request + return response + + def close(self): + pass + + +class TestTuplePayloads(unittest.TestCase): + def assert_payloads(self, values, expected): + for operation in ("identify", "track", "batch", "send_email"): + for pooling in (False, True): + with self.subTest(operation=operation, pooling=pooling): + if operation == "send_email": + client = APIClient("test-key", use_connection_pooling=pooling) + else: + client = CustomerIO("test-site", "test-key", use_connection_pooling=pooling) + adapter = RecordingAdapter() + original_build_session = client._build_session + + def build_session( + original_build_session=original_build_session, adapter=adapter + ): + session = original_build_session() + session.mount("https://", adapter) + return session + + with patch.object(client, "_build_session", side_effect=build_session), client: + if operation == "identify": + client.identify("test-person", values=values) + elif operation == "track": + client.track("test-person", "test-event", data={"values": values}) + elif operation == "batch": + client.batch( + [ + { + "type": "person", + "action": "identify", + "identifiers": {"id": "test-person"}, + "attributes": {"values": values}, + } + ] + ) + else: + client.send_email( + SendEmailRequest( + transactional_message_id="test-template", + identifiers={"id": "test-person"}, + message_data={"values": values}, + ) + ) + self.assertEqual(len(adapter.requests), 1) + payload = json.loads(adapter.requests[0].body) + if operation == "track": + payload = payload["data"] + elif operation == "batch": + payload = payload["batch"][0]["attributes"] + elif operation == "send_email": + payload = payload["message_data"] + self.assertEqual(payload["values"], expected) + + def test_datetimes_in_tuples(self): + values = (datetime(2024, 1, 1, tzinfo=timezone.utc), datetime(2024, 1, 2)) + self.assert_payloads(values, [1704067200, 1704153600]) + self.assertIsInstance(values[0], datetime) + + def test_nan_in_tuples(self): + self.assert_payloads((1, float("nan")), [1, None]) + + def test_mixed_nested_containers(self): + values = ([{"nested": (datetime(2024, 1, 1, tzinfo=timezone.utc), float("nan"))}],) + self.assert_payloads(values, [[{"nested": [1704067200, None]}]]) + self.assertIsInstance(values[0][0]["nested"], tuple) + + def test_json_native_tuples(self): + self.assert_payloads((1, "two", False, None, ()), [1, "two", False, None, []]) + + def test_existing_list_conversion(self): + self.assert_payloads( + [datetime(2024, 1, 1, tzinfo=timezone.utc), float("nan")], [1704067200, None] + )