Skip to content

Commit 486c99e

Browse files
committed
update
1 parent 327de17 commit 486c99e

File tree

3 files changed

+101
-55
lines changed

3 files changed

+101
-55
lines changed

β€ŽCHANGELOG.mdβ€Ž

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
nylas-python Changelog
22
======================
33

4-
Unrelease
4+
Unreleased
55
----------
6-
* Update to use requests' json parameter for proper UTF-8 handling when sending JSON
6+
* Fix UTF-8 encoding for special characters (emoji, accented letters, etc.) by encoding JSON as UTF-8 bytes
7+
* Maintain support for NaN and Infinity float values (using allow_nan=True in JSON serialization)
78

89
v6.14.1
910
----------

β€Žnylas/handler/http_client.pyβ€Ž

Lines changed: 14 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import json
12
import sys
23
from typing import Union, Tuple, Dict
34
from urllib.parse import urlparse, quote
@@ -89,25 +90,20 @@ def _execute(
8990
if overrides and overrides.get("timeout"):
9091
timeout = overrides["timeout"]
9192

92-
# Use requests' json parameter for proper UTF-8 handling when sending JSON
93-
# This avoids Latin-1 encoding errors with special characters (emoji, accented letters, etc.)
93+
# Serialize request_body to JSON with ensure_ascii=False to preserve UTF-8 characters
94+
# and allow_nan=True to support NaN/Infinity values (matching default json.dumps behavior).
95+
# Encode as UTF-8 bytes to avoid Latin-1 encoding errors with special characters.
96+
json_data = None
97+
if request_body is not None and data is None:
98+
json_data = json.dumps(request_body, ensure_ascii=False, allow_nan=True).encode("utf-8")
9499
try:
95-
if request_body is not None and data is None:
96-
response = requests.request(
97-
request["method"],
98-
request["url"],
99-
headers=request["headers"],
100-
json=request_body,
101-
timeout=timeout,
102-
)
103-
else:
104-
response = requests.request(
105-
request["method"],
106-
request["url"],
107-
headers=request["headers"],
108-
data=data,
109-
timeout=timeout,
110-
)
100+
response = requests.request(
101+
request["method"],
102+
request["url"],
103+
headers=request["headers"],
104+
data=json_data if json_data is not None else data,
105+
timeout=timeout,
106+
)
111107
except requests.exceptions.Timeout as exc:
112108
raise NylasSdkTimeoutError(url=request["url"], timeout=timeout) from exc
113109

β€Žtests/handler/test_http_client.pyβ€Ž

Lines changed: 84 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -302,7 +302,7 @@ def test_execute(self, http_client, patched_version_and_sys, patched_request):
302302
"Content-type": "application/json; charset=utf-8",
303303
"test": "header",
304304
},
305-
json={"foo": "bar"},
305+
data=b'{"foo": "bar"}',
306306
timeout=30,
307307
)
308308

@@ -336,7 +336,7 @@ def test_execute_override_timeout(
336336
"Content-type": "application/json; charset=utf-8",
337337
"test": "header",
338338
},
339-
json={"foo": "bar"},
339+
data=b'{"foo": "bar"}',
340340
timeout=60,
341341
)
342342

@@ -426,12 +426,12 @@ def test_execute_with_headers(self, http_client, patched_version_and_sys, patche
426426
"Content-type": "application/json; charset=utf-8",
427427
"test": "header",
428428
},
429-
json={"foo": "bar"},
429+
data=b'{"foo": "bar"}',
430430
timeout=30,
431431
)
432432

433433
def test_execute_with_utf8_characters(self, http_client, patched_version_and_sys, patched_request):
434-
"""Test that UTF-8 characters are safely encoded in JSON requests."""
434+
"""Test that UTF-8 characters are preserved in JSON requests (not escaped)."""
435435
mock_response = Mock()
436436
mock_response.json.return_value = {"success": True}
437437
mock_response.headers = {"X-Test-Header": "test"}
@@ -452,15 +452,19 @@ def test_execute_with_utf8_characters(self, http_client, patched_version_and_sys
452452
)
453453

454454
assert response_json == {"success": True}
455-
# Verify that the json parameter is used with original data
455+
# Verify that the data is sent as UTF-8 encoded bytes
456456
call_kwargs = patched_request.call_args[1]
457-
assert "json" in call_kwargs
458-
sent_json = call_kwargs["json"]
457+
assert "data" in call_kwargs
458+
sent_data = call_kwargs["data"]
459459

460-
# The JSON should contain actual UTF-8 characters
461-
assert sent_json["title"] == "RΓ©union d'Γ©quipe"
462-
assert sent_json["description"] == "De l'idΓ©e Γ  la post-prod, sans friction"
463-
assert sent_json["location"] == "cafΓ©"
460+
# The data should be bytes with actual UTF-8 characters (not escape sequences)
461+
assert isinstance(sent_data, bytes)
462+
decoded_data = sent_data.decode("utf-8")
463+
assert "RΓ©union d'Γ©quipe" in decoded_data
464+
assert "De l'idΓ©e Γ  la post-prod, sans friction" in decoded_data
465+
assert "cafΓ©" in decoded_data
466+
# Should NOT contain unicode escape sequences
467+
assert "\\u" not in decoded_data
464468

465469
def test_execute_with_none_request_body(self, http_client, patched_version_and_sys, patched_request):
466470
"""Test that None request_body is handled correctly."""
@@ -507,7 +511,7 @@ def test_execute_with_none_request_body_and_none_data(self, http_client, patched
507511
assert call_kwargs["data"] is None
508512

509513
def test_execute_with_emoji_and_international_characters(self, http_client, patched_version_and_sys, patched_request):
510-
"""Test that emoji and various international characters are safely encoded."""
514+
"""Test that emoji and various international characters are preserved."""
511515
mock_response = Mock()
512516
mock_response.json.return_value = {"success": True}
513517
mock_response.headers = {"X-Test-Header": "test"}
@@ -531,15 +535,17 @@ def test_execute_with_emoji_and_international_characters(self, http_client, patc
531535

532536
assert response_json == {"success": True}
533537
call_kwargs = patched_request.call_args[1]
534-
sent_json = call_kwargs["json"]
538+
sent_data = call_kwargs["data"]
535539

536-
# All characters should be preserved in the json dict
537-
assert sent_json["emoji"] == "πŸŽ‰ Party time! πŸ₯³"
538-
assert sent_json["japanese"] == "こんにけは"
539-
assert sent_json["chinese"] == "δ½ ε₯½"
540-
assert sent_json["russian"] == "ΠŸΡ€ΠΈΠ²Π΅Ρ‚"
541-
assert sent_json["german"] == "Grâße"
542-
assert sent_json["spanish"] == "ΒΏCΓ³mo estΓ‘s?"
540+
# All characters should be preserved as UTF-8 encoded bytes
541+
assert isinstance(sent_data, bytes)
542+
decoded_data = sent_data.decode("utf-8")
543+
assert "πŸŽ‰ Party time! πŸ₯³" in decoded_data
544+
assert "こんにけは" in decoded_data
545+
assert "δ½ ε₯½" in decoded_data
546+
assert "ΠŸΡ€ΠΈΠ²Π΅Ρ‚" in decoded_data
547+
assert "Grâße" in decoded_data
548+
assert "ΒΏCΓ³mo estΓ‘s?" in decoded_data
543549

544550
def test_execute_with_right_single_quotation_mark(self, http_client, patched_version_and_sys, patched_request):
545551
"""Test that right single quotation mark (\\u2019) is handled correctly.
@@ -567,13 +573,14 @@ def test_execute_with_right_single_quotation_mark(self, http_client, patched_ver
567573

568574
assert response_json == {"success": True}
569575
call_kwargs = patched_request.call_args[1]
570-
sent_json = call_kwargs["json"]
576+
sent_data = call_kwargs["data"]
571577

572-
# The \u2019 character should be preserved
573-
assert "'" in sent_json["subject"] # \u2019 right single quotation mark
574-
assert sent_json["subject"] == "It's a test"
575-
assert "'" in sent_json["body"]
576-
assert "Here's another" in sent_json["body"]
578+
# The data should be UTF-8 encoded bytes with the \u2019 character preserved
579+
assert isinstance(sent_data, bytes)
580+
decoded_data = sent_data.decode("utf-8")
581+
assert "'" in decoded_data # \u2019 right single quotation mark
582+
assert "It's a test" in decoded_data
583+
assert "Here's another" in decoded_data
577584

578585
def test_execute_with_emojis(self, http_client, patched_version_and_sys, patched_request):
579586
"""Test that emojis are handled correctly in request bodies.
@@ -602,16 +609,58 @@ def test_execute_with_emojis(self, http_client, patched_version_and_sys, patched
602609

603610
assert response_json == {"success": True}
604611
call_kwargs = patched_request.call_args[1]
605-
sent_json = call_kwargs["json"]
612+
sent_data = call_kwargs["data"]
613+
614+
# All emojis should be preserved in UTF-8 encoded bytes
615+
assert isinstance(sent_data, bytes)
616+
decoded_data = sent_data.decode("utf-8")
617+
assert "Hello πŸ‘‹ World 🌍" in decoded_data
618+
assert "πŸŽ‰" in decoded_data
619+
assert "πŸ’ͺ" in decoded_data
620+
assert "😊" in decoded_data
621+
assert "πŸ”₯πŸš€βœ¨πŸ’―" in decoded_data
622+
assert "πŸ“…" in decoded_data
623+
assert "⏰" in decoded_data
624+
625+
def test_execute_with_nan_and_infinity(self, http_client, patched_version_and_sys, patched_request):
626+
"""Test that NaN and Infinity float values are handled correctly.
627+
628+
The requests library's json= parameter uses allow_nan=False which raises
629+
ValueError for NaN/Infinity. Our implementation uses json.dumps with
630+
allow_nan=True to maintain backward compatibility.
631+
"""
632+
mock_response = Mock()
633+
mock_response.json.return_value = {"success": True}
634+
mock_response.headers = {"X-Test-Header": "test"}
635+
mock_response.status_code = 200
636+
patched_request.return_value = mock_response
637+
638+
request_body = {
639+
"nan_value": float("nan"),
640+
"infinity": float("inf"),
641+
"neg_infinity": float("-inf"),
642+
"normal": 42.5,
643+
}
644+
645+
# This should NOT raise ValueError
646+
response_json, response_headers = http_client._execute(
647+
method="POST",
648+
path="/data",
649+
request_body=request_body,
650+
)
651+
652+
assert response_json == {"success": True}
653+
call_kwargs = patched_request.call_args[1]
654+
sent_data = call_kwargs["data"]
606655

607-
# All emojis should be preserved exactly
608-
assert sent_json["subject"] == "Hello πŸ‘‹ World 🌍"
609-
assert "πŸŽ‰" in sent_json["body"]
610-
assert "πŸ’ͺ" in sent_json["body"]
611-
assert "😊" in sent_json["body"]
612-
assert sent_json["emoji_only"] == "πŸ”₯πŸš€βœ¨πŸ’―"
613-
assert "πŸ“…" in sent_json["mixed"]
614-
assert "⏰" in sent_json["mixed"]
656+
# The data should be UTF-8 encoded bytes with NaN/Infinity serialized
657+
assert isinstance(sent_data, bytes)
658+
decoded_data = sent_data.decode("utf-8")
659+
# json.dumps with allow_nan=True produces NaN, Infinity, -Infinity (JS-style)
660+
assert "NaN" in decoded_data
661+
assert "Infinity" in decoded_data
662+
assert "-Infinity" in decoded_data
663+
assert "42.5" in decoded_data
615664

616665
def test_execute_with_multipart_data_not_affected(self, http_client, patched_version_and_sys, patched_request):
617666
"""Test that multipart/form-data is not affected by the change."""

0 commit comments

Comments
Β (0)