From df8f2d00ac8bfdb8ea4b6af9e5579f40ea520ee4 Mon Sep 17 00:00:00 2001 From: cb-alish Date: Mon, 16 Feb 2026 12:39:21 +0530 Subject: [PATCH 1/2] Release v3.18.1 --- CHANGELOG.md | 6 ++++++ VERSION | 2 +- chargebee/http_request.py | 3 ++- chargebee/util.py | 15 +++++++++++++++ chargebee/version.py | 2 +- 5 files changed, 25 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c07298..074d29e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +### v3.18.1 (2026-02-16) +* * * + +### Bug Fixes: +- Fixed enum serialization in JSON requests to use actual enum values instead of string representations. + ### v3.18.0 (2026-02-06) * * * ### New Attributes: diff --git a/VERSION b/VERSION index c5b45eb..d21858b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.18.0 +3.18.1 diff --git a/chargebee/http_request.py b/chargebee/http_request.py index 3420155..a85d898 100644 --- a/chargebee/http_request.py +++ b/chargebee/http_request.py @@ -48,6 +48,8 @@ def request( retry_config = env.get_retry_config() if hasattr(env, "get_retry_config") else None url = env.api_url(url, subDomain) + if isJsonRequest: + params = util.convert_to_serializable(params) match method.lower(), isJsonRequest: case "get" | "head" | "delete", _: @@ -58,7 +60,6 @@ def request( case _, False: headers["Content-Type"] = "application/x-www-form-urlencoded" request_args["data"] = params - headers.update( { "User-Agent": f"Chargebee-Python-Client v{VERSION}", diff --git a/chargebee/util.py b/chargebee/util.py index 42920c6..7af9c0c 100644 --- a/chargebee/util.py +++ b/chargebee/util.py @@ -2,6 +2,7 @@ from collections import OrderedDict from enum import Enum +from typing import Any, Dict def serialize(value, prefix=None, idx=None, jsonKeys=None, level=0): @@ -87,3 +88,17 @@ def generate_uuid_v4() -> str: hex_str = "".join(f"{byte:02x}" for byte in byte_array) return f"{hex_str[0:8]}-{hex_str[8:12]}-{hex_str[12:16]}-{hex_str[16:20]}-{hex_str[20:32]}" + + +def convert_to_serializable(obj: Any) -> Any: + """ + Recursively convert TypedDict and enums to JSON-serializable format. + """ + if isinstance(obj, Enum): + return obj.value + elif isinstance(obj, dict): + return {key: convert_to_serializable(value) for key, value in obj.items()} + elif isinstance(obj, (list, tuple)): + return type(obj)(convert_to_serializable(item) for item in obj) + else: + return obj diff --git a/chargebee/version.py b/chargebee/version.py index ed2ab66..f890bc7 100644 --- a/chargebee/version.py +++ b/chargebee/version.py @@ -1 +1 @@ -VERSION = "3.18.0" +VERSION = "3.18.1" From b769d584267739a24d0cfae4cbeaef72d49c39d7 Mon Sep 17 00:00:00 2001 From: cb-alish Date: Mon, 16 Feb 2026 12:46:09 +0530 Subject: [PATCH 2/2] Add comprehensive tests for enum serialization in JSON requests --- tests/test_http_request.py | 76 +++++++++++++++ tests/test_util.py | 190 +++++++++++++++++++++++++++++++++++++ 2 files changed, 266 insertions(+) diff --git a/tests/test_http_request.py b/tests/test_http_request.py index fc1664a..ea3ec3b 100644 --- a/tests/test_http_request.py +++ b/tests/test_http_request.py @@ -2,6 +2,7 @@ import unittest import asyncio from unittest.mock import patch, Mock, AsyncMock +from enum import Enum from chargebee import environment from chargebee.api_error import InvalidRequestError @@ -276,3 +277,78 @@ def test_subdomain_url(self, mock_client_class): self.assertEqual( call_args[1]["url"], "https://test_site.ingest.chargebee.com/api/v2/test?" ) + + @patch("httpx.Client") + def test_json_request_with_enum_serialization(self, mock_client_class): + """Test that enums in JSON requests are converted to their values""" + mock_client, mock_response = make_mock_client( + text=json.dumps({"message": "success"}) + ) + mock_client.request.return_value = mock_response + mock_client_class.return_value.__enter__.return_value = mock_client + + from chargebee.http_request import request + + class Status(Enum): + ACTIVE = "active" + INACTIVE = "inactive" + + class Priority(Enum): + HIGH = 1 + MEDIUM = 2 + LOW = 3 + + # Test with nested structure containing enums + test_data = { + "name": "test_user", + "status": Status.ACTIVE, + "priority": Priority.HIGH, + "nested": { + "status": Status.INACTIVE, + "values": [Priority.LOW, Priority.MEDIUM], + }, + "items": ( + {"status": Status.ACTIVE, "priority": Priority.HIGH}, + {"status": Status.INACTIVE, "priority": Priority.LOW}, + ), + } + + request( + "POST", "/test", MockEnvironment(), params=test_data, isJsonRequest=True + ) + + # Verify that the request was made with converted enum values + call_args = mock_client.request.call_args + json_data = call_args[1]["json"] + + # Check that enums are converted to their values + self.assertEqual(json_data["status"], "active") + self.assertEqual(json_data["priority"], 1) + self.assertEqual(json_data["nested"]["status"], "inactive") + self.assertEqual(json_data["nested"]["values"], [3, 2]) + self.assertEqual(json_data["items"][0]["status"], "active") + self.assertEqual(json_data["items"][0]["priority"], 1) + self.assertEqual(json_data["items"][1]["status"], "inactive") + self.assertEqual(json_data["items"][1]["priority"], 3) + + @patch("httpx.Client") + def test_form_request_without_enum_conversion(self, mock_client_class): + """Test that form requests (non-JSON) don't use convert_to_serializable""" + mock_client, mock_response = make_mock_client( + text=json.dumps({"message": "success"}) + ) + mock_client.request.return_value = mock_response + mock_client_class.return_value.__enter__.return_value = mock_client + + from chargebee.http_request import request + + test_data = {"key": "value", "number": 42} + request( + "POST", "/test", MockEnvironment(), params=test_data, isJsonRequest=False + ) + + # Verify that form requests use data parameter + call_args = mock_client.request.call_args + self.assertIn("data", call_args[1]) + self.assertNotIn("json", call_args[1]) + self.assertEqual(call_args[1]["data"], test_data) diff --git a/tests/test_util.py b/tests/test_util.py index 29c98a9..9b97781 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -1,4 +1,5 @@ import unittest +from enum import Enum from chargebee import util @@ -42,3 +43,192 @@ def test_serialize(self): } self.assertEqual(after, util.serialize(before)) + + def test_convert_to_serializable_with_enum(self): + """Test that enums are converted to their values""" + class Status(Enum): + ACTIVE = "active" + INACTIVE = "inactive" + + result = util.convert_to_serializable(Status.ACTIVE) + self.assertEqual(result, "active") + + def test_convert_to_serializable_with_dict_containing_enum(self): + """Test that enums in dicts are converted to their values""" + class Status(Enum): + ACTIVE = "active" + INACTIVE = "inactive" + + input_dict = { + "name": "test", + "status": Status.ACTIVE, + "count": 42, + } + + result = util.convert_to_serializable(input_dict) + expected = { + "name": "test", + "status": "active", + "count": 42, + } + self.assertEqual(result, expected) + + def test_convert_to_serializable_with_nested_dict(self): + """Test that nested dicts with enums are handled correctly""" + class Status(Enum): + ACTIVE = "active" + + class Type(Enum): + PREMIUM = "premium" + + input_dict = { + "user": { + "name": "John", + "status": Status.ACTIVE, + "subscription": { + "type": Type.PREMIUM, + "active": True, + }, + }, + "count": 10, + } + + result = util.convert_to_serializable(input_dict) + expected = { + "user": { + "name": "John", + "status": "active", + "subscription": { + "type": "premium", + "active": True, + }, + }, + "count": 10, + } + self.assertEqual(result, expected) + + def test_convert_to_serializable_with_list(self): + """Test that lists with enums are converted correctly""" + class Status(Enum): + ACTIVE = "active" + INACTIVE = "inactive" + + input_list = [Status.ACTIVE, "test", 42, Status.INACTIVE] + result = util.convert_to_serializable(input_list) + expected = ["active", "test", 42, "inactive"] + self.assertEqual(result, expected) + + def test_convert_to_serializable_with_tuple(self): + """Test that tuples with enums are converted correctly and remain tuples""" + class Status(Enum): + ACTIVE = "active" + INACTIVE = "inactive" + + input_tuple = (Status.ACTIVE, "test", 42, Status.INACTIVE) + result = util.convert_to_serializable(input_tuple) + expected = ("active", "test", 42, "inactive") + self.assertEqual(result, expected) + self.assertIsInstance(result, tuple) + + def test_convert_to_serializable_with_list_of_dicts(self): + """Test that lists of dicts with enums are converted correctly""" + class Status(Enum): + ACTIVE = "active" + INACTIVE = "inactive" + + input_list = [ + {"name": "user1", "status": Status.ACTIVE}, + {"name": "user2", "status": Status.INACTIVE}, + ] + + result = util.convert_to_serializable(input_list) + expected = [ + {"name": "user1", "status": "active"}, + {"name": "user2", "status": "inactive"}, + ] + self.assertEqual(result, expected) + + def test_convert_to_serializable_with_complex_nested_structure(self): + """Test complex nested structure with enums at various levels""" + class Status(Enum): + ACTIVE = "active" + + class Type(Enum): + BASIC = "basic" + PREMIUM = "premium" + + input_data = { + "users": [ + { + "name": "John", + "status": Status.ACTIVE, + "subscriptions": [ + {"type": Type.PREMIUM, "price": 99.99}, + {"type": Type.BASIC, "price": 9.99}, + ], + }, + ], + "metadata": { + "default_status": Status.ACTIVE, + "types": (Type.BASIC, Type.PREMIUM), + }, + } + + result = util.convert_to_serializable(input_data) + expected = { + "users": [ + { + "name": "John", + "status": "active", + "subscriptions": [ + {"type": "premium", "price": 99.99}, + {"type": "basic", "price": 9.99}, + ], + }, + ], + "metadata": { + "default_status": "active", + "types": ("basic", "premium"), + }, + } + self.assertEqual(result, expected) + + def test_convert_to_serializable_with_primitive_types(self): + """Test that primitive types are passed through unchanged""" + test_cases = [ + ("string", "string"), + (42, 42), + (3.14, 3.14), + (True, True), + (False, False), + (None, None), + ] + + for input_val, expected_val in test_cases: + result = util.convert_to_serializable(input_val) + self.assertEqual(result, expected_val) + + def test_convert_to_serializable_with_empty_structures(self): + """Test that empty dicts, lists, and tuples are handled correctly""" + self.assertEqual(util.convert_to_serializable({}), {}) + self.assertEqual(util.convert_to_serializable([]), []) + self.assertEqual(util.convert_to_serializable(()), ()) + + def test_convert_to_serializable_with_integer_enum(self): + """Test that enums with integer values are converted correctly""" + class Priority(Enum): + LOW = 1 + MEDIUM = 2 + HIGH = 3 + + input_dict = { + "task": "Test task", + "priority": Priority.HIGH, + } + + result = util.convert_to_serializable(input_dict) + expected = { + "task": "Test task", + "priority": 3, + } + self.assertEqual(result, expected)