Skip to content

Commit a1e09d7

Browse files
authored
Release v3.18.1 (#117)
* Release v3.18.1 * Add comprehensive tests for enum serialization in JSON requests
1 parent d70fc00 commit a1e09d7

7 files changed

Lines changed: 291 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
### v3.18.1 (2026-02-16)
2+
* * *
3+
4+
### Bug Fixes:
5+
- Fixed enum serialization in JSON requests to use actual enum values instead of string representations.
6+
17
### v3.18.0 (2026-02-06)
28
* * *
39
### New Attributes:

VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
3.18.0
1+
3.18.1

chargebee/http_request.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ def request(
4848

4949
retry_config = env.get_retry_config() if hasattr(env, "get_retry_config") else None
5050
url = env.api_url(url, subDomain)
51+
if isJsonRequest:
52+
params = util.convert_to_serializable(params)
5153

5254
match method.lower(), isJsonRequest:
5355
case "get" | "head" | "delete", _:
@@ -58,7 +60,6 @@ def request(
5860
case _, False:
5961
headers["Content-Type"] = "application/x-www-form-urlencoded"
6062
request_args["data"] = params
61-
6263
headers.update(
6364
{
6465
"User-Agent": f"Chargebee-Python-Client v{VERSION}",

chargebee/util.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from collections import OrderedDict
44
from enum import Enum
5+
from typing import Any, Dict
56

67

78
def serialize(value, prefix=None, idx=None, jsonKeys=None, level=0):
@@ -87,3 +88,17 @@ def generate_uuid_v4() -> str:
8788
hex_str = "".join(f"{byte:02x}" for byte in byte_array)
8889

8990
return f"{hex_str[0:8]}-{hex_str[8:12]}-{hex_str[12:16]}-{hex_str[16:20]}-{hex_str[20:32]}"
91+
92+
93+
def convert_to_serializable(obj: Any) -> Any:
94+
"""
95+
Recursively convert TypedDict and enums to JSON-serializable format.
96+
"""
97+
if isinstance(obj, Enum):
98+
return obj.value
99+
elif isinstance(obj, dict):
100+
return {key: convert_to_serializable(value) for key, value in obj.items()}
101+
elif isinstance(obj, (list, tuple)):
102+
return type(obj)(convert_to_serializable(item) for item in obj)
103+
else:
104+
return obj

chargebee/version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
VERSION = "3.18.0"
1+
VERSION = "3.18.1"

tests/test_http_request.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import unittest
33
import asyncio
44
from unittest.mock import patch, Mock, AsyncMock
5+
from enum import Enum
56

67
from chargebee import environment
78
from chargebee.api_error import InvalidRequestError
@@ -276,3 +277,78 @@ def test_subdomain_url(self, mock_client_class):
276277
self.assertEqual(
277278
call_args[1]["url"], "https://test_site.ingest.chargebee.com/api/v2/test?"
278279
)
280+
281+
@patch("httpx.Client")
282+
def test_json_request_with_enum_serialization(self, mock_client_class):
283+
"""Test that enums in JSON requests are converted to their values"""
284+
mock_client, mock_response = make_mock_client(
285+
text=json.dumps({"message": "success"})
286+
)
287+
mock_client.request.return_value = mock_response
288+
mock_client_class.return_value.__enter__.return_value = mock_client
289+
290+
from chargebee.http_request import request
291+
292+
class Status(Enum):
293+
ACTIVE = "active"
294+
INACTIVE = "inactive"
295+
296+
class Priority(Enum):
297+
HIGH = 1
298+
MEDIUM = 2
299+
LOW = 3
300+
301+
# Test with nested structure containing enums
302+
test_data = {
303+
"name": "test_user",
304+
"status": Status.ACTIVE,
305+
"priority": Priority.HIGH,
306+
"nested": {
307+
"status": Status.INACTIVE,
308+
"values": [Priority.LOW, Priority.MEDIUM],
309+
},
310+
"items": (
311+
{"status": Status.ACTIVE, "priority": Priority.HIGH},
312+
{"status": Status.INACTIVE, "priority": Priority.LOW},
313+
),
314+
}
315+
316+
request(
317+
"POST", "/test", MockEnvironment(), params=test_data, isJsonRequest=True
318+
)
319+
320+
# Verify that the request was made with converted enum values
321+
call_args = mock_client.request.call_args
322+
json_data = call_args[1]["json"]
323+
324+
# Check that enums are converted to their values
325+
self.assertEqual(json_data["status"], "active")
326+
self.assertEqual(json_data["priority"], 1)
327+
self.assertEqual(json_data["nested"]["status"], "inactive")
328+
self.assertEqual(json_data["nested"]["values"], [3, 2])
329+
self.assertEqual(json_data["items"][0]["status"], "active")
330+
self.assertEqual(json_data["items"][0]["priority"], 1)
331+
self.assertEqual(json_data["items"][1]["status"], "inactive")
332+
self.assertEqual(json_data["items"][1]["priority"], 3)
333+
334+
@patch("httpx.Client")
335+
def test_form_request_without_enum_conversion(self, mock_client_class):
336+
"""Test that form requests (non-JSON) don't use convert_to_serializable"""
337+
mock_client, mock_response = make_mock_client(
338+
text=json.dumps({"message": "success"})
339+
)
340+
mock_client.request.return_value = mock_response
341+
mock_client_class.return_value.__enter__.return_value = mock_client
342+
343+
from chargebee.http_request import request
344+
345+
test_data = {"key": "value", "number": 42}
346+
request(
347+
"POST", "/test", MockEnvironment(), params=test_data, isJsonRequest=False
348+
)
349+
350+
# Verify that form requests use data parameter
351+
call_args = mock_client.request.call_args
352+
self.assertIn("data", call_args[1])
353+
self.assertNotIn("json", call_args[1])
354+
self.assertEqual(call_args[1]["data"], test_data)

tests/test_util.py

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import unittest
2+
from enum import Enum
23

34
from chargebee import util
45

@@ -42,3 +43,192 @@ def test_serialize(self):
4243
}
4344

4445
self.assertEqual(after, util.serialize(before))
46+
47+
def test_convert_to_serializable_with_enum(self):
48+
"""Test that enums are converted to their values"""
49+
class Status(Enum):
50+
ACTIVE = "active"
51+
INACTIVE = "inactive"
52+
53+
result = util.convert_to_serializable(Status.ACTIVE)
54+
self.assertEqual(result, "active")
55+
56+
def test_convert_to_serializable_with_dict_containing_enum(self):
57+
"""Test that enums in dicts are converted to their values"""
58+
class Status(Enum):
59+
ACTIVE = "active"
60+
INACTIVE = "inactive"
61+
62+
input_dict = {
63+
"name": "test",
64+
"status": Status.ACTIVE,
65+
"count": 42,
66+
}
67+
68+
result = util.convert_to_serializable(input_dict)
69+
expected = {
70+
"name": "test",
71+
"status": "active",
72+
"count": 42,
73+
}
74+
self.assertEqual(result, expected)
75+
76+
def test_convert_to_serializable_with_nested_dict(self):
77+
"""Test that nested dicts with enums are handled correctly"""
78+
class Status(Enum):
79+
ACTIVE = "active"
80+
81+
class Type(Enum):
82+
PREMIUM = "premium"
83+
84+
input_dict = {
85+
"user": {
86+
"name": "John",
87+
"status": Status.ACTIVE,
88+
"subscription": {
89+
"type": Type.PREMIUM,
90+
"active": True,
91+
},
92+
},
93+
"count": 10,
94+
}
95+
96+
result = util.convert_to_serializable(input_dict)
97+
expected = {
98+
"user": {
99+
"name": "John",
100+
"status": "active",
101+
"subscription": {
102+
"type": "premium",
103+
"active": True,
104+
},
105+
},
106+
"count": 10,
107+
}
108+
self.assertEqual(result, expected)
109+
110+
def test_convert_to_serializable_with_list(self):
111+
"""Test that lists with enums are converted correctly"""
112+
class Status(Enum):
113+
ACTIVE = "active"
114+
INACTIVE = "inactive"
115+
116+
input_list = [Status.ACTIVE, "test", 42, Status.INACTIVE]
117+
result = util.convert_to_serializable(input_list)
118+
expected = ["active", "test", 42, "inactive"]
119+
self.assertEqual(result, expected)
120+
121+
def test_convert_to_serializable_with_tuple(self):
122+
"""Test that tuples with enums are converted correctly and remain tuples"""
123+
class Status(Enum):
124+
ACTIVE = "active"
125+
INACTIVE = "inactive"
126+
127+
input_tuple = (Status.ACTIVE, "test", 42, Status.INACTIVE)
128+
result = util.convert_to_serializable(input_tuple)
129+
expected = ("active", "test", 42, "inactive")
130+
self.assertEqual(result, expected)
131+
self.assertIsInstance(result, tuple)
132+
133+
def test_convert_to_serializable_with_list_of_dicts(self):
134+
"""Test that lists of dicts with enums are converted correctly"""
135+
class Status(Enum):
136+
ACTIVE = "active"
137+
INACTIVE = "inactive"
138+
139+
input_list = [
140+
{"name": "user1", "status": Status.ACTIVE},
141+
{"name": "user2", "status": Status.INACTIVE},
142+
]
143+
144+
result = util.convert_to_serializable(input_list)
145+
expected = [
146+
{"name": "user1", "status": "active"},
147+
{"name": "user2", "status": "inactive"},
148+
]
149+
self.assertEqual(result, expected)
150+
151+
def test_convert_to_serializable_with_complex_nested_structure(self):
152+
"""Test complex nested structure with enums at various levels"""
153+
class Status(Enum):
154+
ACTIVE = "active"
155+
156+
class Type(Enum):
157+
BASIC = "basic"
158+
PREMIUM = "premium"
159+
160+
input_data = {
161+
"users": [
162+
{
163+
"name": "John",
164+
"status": Status.ACTIVE,
165+
"subscriptions": [
166+
{"type": Type.PREMIUM, "price": 99.99},
167+
{"type": Type.BASIC, "price": 9.99},
168+
],
169+
},
170+
],
171+
"metadata": {
172+
"default_status": Status.ACTIVE,
173+
"types": (Type.BASIC, Type.PREMIUM),
174+
},
175+
}
176+
177+
result = util.convert_to_serializable(input_data)
178+
expected = {
179+
"users": [
180+
{
181+
"name": "John",
182+
"status": "active",
183+
"subscriptions": [
184+
{"type": "premium", "price": 99.99},
185+
{"type": "basic", "price": 9.99},
186+
],
187+
},
188+
],
189+
"metadata": {
190+
"default_status": "active",
191+
"types": ("basic", "premium"),
192+
},
193+
}
194+
self.assertEqual(result, expected)
195+
196+
def test_convert_to_serializable_with_primitive_types(self):
197+
"""Test that primitive types are passed through unchanged"""
198+
test_cases = [
199+
("string", "string"),
200+
(42, 42),
201+
(3.14, 3.14),
202+
(True, True),
203+
(False, False),
204+
(None, None),
205+
]
206+
207+
for input_val, expected_val in test_cases:
208+
result = util.convert_to_serializable(input_val)
209+
self.assertEqual(result, expected_val)
210+
211+
def test_convert_to_serializable_with_empty_structures(self):
212+
"""Test that empty dicts, lists, and tuples are handled correctly"""
213+
self.assertEqual(util.convert_to_serializable({}), {})
214+
self.assertEqual(util.convert_to_serializable([]), [])
215+
self.assertEqual(util.convert_to_serializable(()), ())
216+
217+
def test_convert_to_serializable_with_integer_enum(self):
218+
"""Test that enums with integer values are converted correctly"""
219+
class Priority(Enum):
220+
LOW = 1
221+
MEDIUM = 2
222+
HIGH = 3
223+
224+
input_dict = {
225+
"task": "Test task",
226+
"priority": Priority.HIGH,
227+
}
228+
229+
result = util.convert_to_serializable(input_dict)
230+
expected = {
231+
"task": "Test task",
232+
"priority": 3,
233+
}
234+
self.assertEqual(result, expected)

0 commit comments

Comments
 (0)