From 550857495b12e40642c84f4be4a6e522f365042b Mon Sep 17 00:00:00 2001 From: Stephen Young Date: Thu, 21 May 2026 23:35:19 -0400 Subject: [PATCH 1/4] Fix _sanitize_value() to recurse into nested dicts and lists datetime and NaN values nested inside dicts or lists were passed through unsanitized because _sanitize_value() only handled flat values. Add recursion for dict (via _sanitize()) and list types. --- customerio/client_base.py | 4 ++++ tests/test_customerio.py | 43 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/customerio/client_base.py b/customerio/client_base.py index e333b08..80f5732 100644 --- a/customerio/client_base.py +++ b/customerio/client_base.py @@ -120,6 +120,10 @@ def _sanitize_value(self, value): return self._datetime_to_timestamp(value) if isinstance(value, float) and math.isnan(value): return None + if isinstance(value, dict): + return self._sanitize(value) + if isinstance(value, list): + return [self._sanitize_value(item) for item in value] return value def _datetime_to_timestamp(self, dt): diff --git a/tests/test_customerio.py b/tests/test_customerio.py index 77beb98..5cd5ae7 100644 --- a/tests/test_customerio.py +++ b/tests/test_customerio.py @@ -568,6 +568,49 @@ def test_sanitize(self): data_out = self.cio._sanitize(data_in) self.assertEqual(data_out, dict(dt=1234567890)) + def test_sanitize_nested_dict_datetime(self): + from datetime import timezone + + data_in = {"event": {"created_at": datetime(2009, 2, 13, 23, 31, 30, 0, timezone.utc)}} + data_out = self.cio._sanitize(data_in) + self.assertEqual(data_out, {"event": {"created_at": 1234567890}}) + + def test_sanitize_list_datetime(self): + from datetime import timezone + + data_in = {"dates": [datetime(2009, 2, 13, 23, 31, 30, 0, timezone.utc), datetime(2024, 1, 1, 0, 0, 0, 0, timezone.utc)]} + data_out = self.cio._sanitize(data_in) + self.assertEqual(data_out, {"dates": [1234567890, 1704067200]}) + + def test_sanitize_nested_dict_nan(self): + data_in = {"metrics": {"score": float("nan"), "count": 5}} + data_out = self.cio._sanitize(data_in) + self.assertEqual(data_out, {"metrics": {"score": None, "count": 5}}) + + def test_sanitize_deeply_nested(self): + from datetime import timezone + + data_in = { + "outer": { + "items": [ + {"ts": datetime(2009, 2, 13, 23, 31, 30, 0, timezone.utc), "val": float("nan")}, + {"ts": datetime(2024, 1, 1, 0, 0, 0, 0, timezone.utc), "val": 42}, + ], + }, + } + data_out = self.cio._sanitize(data_in) + self.assertEqual( + data_out, + { + "outer": { + "items": [ + {"ts": 1234567890, "val": None}, + {"ts": 1704067200, "val": 42}, + ], + }, + }, + ) + def test_ids_are_encoded_in_url(self): self.cio.http.hooks = dict( response=partial( From 272037558224309a310cc3c92b5b4ffc17ccfbc7 Mon Sep 17 00:00:00 2001 From: Stephen Young Date: Thu, 21 May 2026 23:41:17 -0400 Subject: [PATCH 2/4] Fix formatting in test_sanitize_list_datetime --- tests/test_customerio.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/test_customerio.py b/tests/test_customerio.py index 5cd5ae7..f4cb171 100644 --- a/tests/test_customerio.py +++ b/tests/test_customerio.py @@ -578,7 +578,12 @@ def test_sanitize_nested_dict_datetime(self): def test_sanitize_list_datetime(self): from datetime import timezone - data_in = {"dates": [datetime(2009, 2, 13, 23, 31, 30, 0, timezone.utc), datetime(2024, 1, 1, 0, 0, 0, 0, timezone.utc)]} + data_in = { + "dates": [ + datetime(2009, 2, 13, 23, 31, 30, 0, timezone.utc), + datetime(2024, 1, 1, 0, 0, 0, 0, timezone.utc), + ] + } data_out = self.cio._sanitize(data_in) self.assertEqual(data_out, {"dates": [1234567890, 1704067200]}) From f1ab574f5e4462d6ac774d0d03de5eabc64de0cb Mon Sep 17 00:00:00 2001 From: Stephen Young Date: Fri, 22 May 2026 14:11:45 -0400 Subject: [PATCH 3/4] Fix unsorted import block in tests --- tests/test_customerio.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_customerio.py b/tests/test_customerio.py index be43856..a7aa9bc 100644 --- a/tests/test_customerio.py +++ b/tests/test_customerio.py @@ -5,12 +5,12 @@ from functools import partial import urllib3 -from customerio import CustomerIO, CustomerIOException, Regions -from customerio.client_base import TCP_KEEPALIVE_IDLE_TIMEOUT, TCP_KEEPALIVE_INTERVAL -from customerio.constants import CIOID, EMAIL, ID from requests.auth import _basic_auth_str from urllib3.connection import HTTPConnection +from customerio import CustomerIO, CustomerIOException, Regions +from customerio.client_base import TCP_KEEPALIVE_IDLE_TIMEOUT, TCP_KEEPALIVE_INTERVAL +from customerio.constants import CIOID, EMAIL, ID from tests.server import HTTPSTestCase # test uses a self signed certificate so disable the warning messages From 9d0432488e9c17e298c58575506ae04d78145dcf Mon Sep 17 00:00:00 2001 From: Stephen Young Date: Fri, 22 May 2026 14:46:29 -0400 Subject: [PATCH 4/4] Apply ruff formatting to tests --- tests/test_customerio.py | 48 +++++++++++----------------------------- 1 file changed, 13 insertions(+), 35 deletions(-) diff --git a/tests/test_customerio.py b/tests/test_customerio.py index a7aa9bc..157ac18 100644 --- a/tests/test_customerio.py +++ b/tests/test_customerio.py @@ -53,11 +53,7 @@ def setUp(self): def _check_request(self, resp, rq, *args, **kwargs): request = resp.request - body = ( - request.body.decode("utf-8") - if isinstance(request.body, bytes) - else request.body - ) + body = request.body.decode("utf-8") if isinstance(request.body, bytes) else request.body if rq.get("method", None): self.assertEqual(request.method, rq["method"]) if rq.get("body", None): @@ -67,9 +63,7 @@ def _check_request(self, resp, rq, *args, **kwargs): if rq.get("content_type", None): self.assertEqual(request.headers["Content-Type"], rq["content_type"]) if rq.get("body", None): - self.assertEqual( - int(request.headers["Content-Length"]), len(json.dumps(rq["body"])) - ) + self.assertEqual(int(request.headers["Content-Length"]), len(json.dumps(rq["body"]))) if rq.get("url_suffix", None): self.assertTrue( request.url.endswith(rq["url_suffix"]), @@ -93,21 +87,17 @@ def test_client_setup(self): def test_keepalive_socket_options_are_configured_on_adapter(self): default_socket_options = list(HTTPConnection.default_socket_options) client = CustomerIO(site_id="site_id", api_key="api_key") - socket_options = client.http.adapters[ - "https://" - ].poolmanager.connection_pool_kw["socket_options"] + socket_options = client.http.adapters["https://"].poolmanager.connection_pool_kw[ + "socket_options" + ] tcp_protocol = getattr(socket, "SOL_TCP", socket.IPPROTO_TCP) - tcp_keepidle = getattr( - socket, "TCP_KEEPIDLE", getattr(socket, "TCP_KEEPALIVE", None) - ) + tcp_keepidle = getattr(socket, "TCP_KEEPIDLE", getattr(socket, "TCP_KEEPALIVE", None)) for option in default_socket_options: self.assertIn(option, socket_options) self.assertIn((socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1), socket_options) if tcp_keepidle is not None: - self.assertIn( - (tcp_protocol, tcp_keepidle, TCP_KEEPALIVE_IDLE_TIMEOUT), socket_options - ) + self.assertIn((tcp_protocol, tcp_keepidle, TCP_KEEPALIVE_IDLE_TIMEOUT), socket_options) if hasattr(socket, "TCP_KEEPINTVL"): self.assertIn( (tcp_protocol, socket.TCP_KEEPINTVL, TCP_KEEPALIVE_INTERVAL), @@ -180,9 +170,7 @@ def test_track_with_id(self): ) ) - self.cio.track( - 1, "purchase", {"type": "socks"}, id="01HB4HBDKTFWYZCK01DMRSWRFD" - ) + self.cio.track(1, "purchase", {"type": "socks"}, id="01HB4HBDKTFWYZCK01DMRSWRFD") def test_track_without_id(self): self.cio.http.hooks = dict( @@ -287,9 +275,7 @@ def test_track_anonymous_call(self): ) ) - self.cio.track_anonymous( - anonymous_id=123, name="sign_up", data={"email": "john@test.com"} - ) + self.cio.track_anonymous(anonymous_id=123, name="sign_up", data={"email": "john@test.com"}) def test_track_anonymous_invite_with_data_dict(self): self.cio.http.hooks = dict( @@ -334,9 +320,7 @@ def test_track_anonymous_with_id(self): ) ) - self.cio.track_anonymous( - "anon-123", "purchase", id="01HB4HBDKTFWYZCK01DMRSWRFD" - ) + self.cio.track_anonymous("anon-123", "purchase", id="01HB4HBDKTFWYZCK01DMRSWRFD") def test_track_anonymous_with_timestamp(self): self.cio.http.hooks = dict( @@ -355,9 +339,7 @@ def test_track_anonymous_with_timestamp(self): ) ) - self.cio.track_anonymous( - "anon-123", "purchase", {"type": "socks"}, timestamp=1561231234 - ) + self.cio.track_anonymous("anon-123", "purchase", {"type": "socks"}, timestamp=1561231234) def test_pageview_call(self): self.cio.http.hooks = dict( @@ -419,9 +401,7 @@ def test_backfill_call(self): ) ) - self.cio.backfill( - customer_id=1, name="signup", timestamp=1234567890, email="john@test.com" - ) + self.cio.backfill(customer_id=1, name="signup", timestamp=1234567890, email="john@test.com") with self.assertRaises(TypeError): self.cio.backfill(random_attr="some_value") @@ -601,9 +581,7 @@ def test_sanitize(self): def test_sanitize_nested_dict_datetime(self): from datetime import timezone - data_in = { - "event": {"created_at": datetime(2009, 2, 13, 23, 31, 30, 0, timezone.utc)} - } + data_in = {"event": {"created_at": datetime(2009, 2, 13, 23, 31, 30, 0, timezone.utc)}} data_out = self.cio._sanitize(data_in) self.assertEqual(data_out, {"event": {"created_at": 1234567890}})