diff --git a/stripe/_encode.py b/stripe/_encode.py index 3d7e68ef1..e8cb0866e 100644 --- a/stripe/_encode.py +++ b/stripe/_encode.py @@ -2,7 +2,7 @@ import datetime import time from collections import OrderedDict -from typing import Generator, Tuple, Any +from typing import Any, Dict, Generator, Mapping, Optional, Tuple, Union def _encode_datetime(dttime: datetime.datetime): @@ -27,6 +27,65 @@ def _json_encode_date_callback(value): return value +# Type for a request encoding schema node: either a leaf encoding string +# (e.g. "int64_string") or a nested dict mapping field names to sub-schemas. +_SchemaNode = Union[str, Dict[str, Any]] + + +def _coerce_v2_params( + params: Optional[Mapping[str, Any]], + schema: Dict[str, _SchemaNode], +) -> Optional[Mapping[str, Any]]: + """ + Coerce V2 request params according to the given encoding schema. + + For fields marked as "int64_string", converts int values to str so they + are serialized as JSON strings on the wire. Recurses into nested objects + and arrays. + """ + if params is None: + return None + + result: Dict[str, Any] = {} + for key, value in params.items(): + field_schema = schema.get(key) + if field_schema is not None: + result[key] = _coerce_value(value, field_schema) + else: + result[key] = value + return result + + +def _coerce_value(value: Any, schema: _SchemaNode) -> Any: + """Coerce a single value according to its schema node.""" + if value is None: + return None + + if schema == "int64_string": + # Scalar or array of int64_string + if isinstance(value, list): + return [str(v) if isinstance(v, int) else v for v in value] + if isinstance(value, int): + return str(value) + return value + + if isinstance(schema, dict): + # Nested object schema + if isinstance(value, list): + # Array of objects with int64_string fields + return [ + dict(_coerce_v2_params(v, schema) or {}) + if isinstance(v, dict) + else v + for v in value + ] + if isinstance(value, dict): + return dict(_coerce_v2_params(value, schema) or {}) + return value + + return value + + def _api_encode(data) -> Generator[Tuple[str, Any], None, None]: for key, value in data.items(): if value is None: diff --git a/stripe/_stripe_object.py b/stripe/_stripe_object.py index 6ce504aa6..eea4b8403 100644 --- a/stripe/_stripe_object.py +++ b/stripe/_stripe_object.py @@ -355,6 +355,8 @@ def _refresh_from( self._transient_values = self._transient_values - set(values) for k, v in values.items(): + # Apply field encoding coercion (e.g. int64_string: str → int) + v = self._coerce_field_value(k, v) inner_class = self._get_inner_class_type(k) is_dict = self._get_inner_class_is_beneath_dict(k) if is_dict: @@ -620,6 +622,7 @@ def __deepcopy__(self, memo: Dict[int, Any]) -> "StripeObject": _inner_class_types: ClassVar[Dict[str, Type["StripeObject"]]] = {} _inner_class_dicts: ClassVar[List[str]] = [] + _field_encodings: ClassVar[Dict[str, str]] = {} def _get_inner_class_type( self, field_name: str @@ -628,3 +631,22 @@ def _get_inner_class_type( def _get_inner_class_is_beneath_dict(self, field_name: str): return field_name in self._inner_class_dicts + + def _coerce_field_value(self, field_name: str, value: Any) -> Any: + """ + Apply field encoding coercion based on _field_encodings metadata. + + For int64_string fields, converts string values from the API response + to native Python ints. + """ + encoding = self._field_encodings.get(field_name) + if encoding is None or value is None: + return value + + if encoding == "int64_string": + if isinstance(value, str): + return int(value) + if isinstance(value, list): + return [int(v) if isinstance(v, str) else v for v in value] + + return value diff --git a/stripe/v2/billing/_cadence.py b/stripe/v2/billing/_cadence.py index 9d50a4332..635fc6304 100644 --- a/stripe/v2/billing/_cadence.py +++ b/stripe/v2/billing/_cadence.py @@ -359,6 +359,7 @@ class MandateOptions(StripeObject): """ A description of the mandate that is meant to be displayed to the customer. """ + _field_encodings = {"amount": "int64_string"} mandate_options: Optional[MandateOptions] """ diff --git a/stripe/v2/billing/_collection_setting.py b/stripe/v2/billing/_collection_setting.py index 3a9a369f7..6f5c66f4b 100644 --- a/stripe/v2/billing/_collection_setting.py +++ b/stripe/v2/billing/_collection_setting.py @@ -71,6 +71,7 @@ class MandateOptions(StripeObject): """ A description of the mandate that is meant to be displayed to the customer. """ + _field_encodings = {"amount": "int64_string"} mandate_options: Optional[MandateOptions] """ diff --git a/stripe/v2/billing/_collection_setting_service.py b/stripe/v2/billing/_collection_setting_service.py index 96ff1556a..e546c5651 100644 --- a/stripe/v2/billing/_collection_setting_service.py +++ b/stripe/v2/billing/_collection_setting_service.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- # File generated from our OpenAPI spec +from stripe._encode import _coerce_v2_params from stripe._stripe_service import StripeService from stripe._util import sanitize_id from typing import Optional, cast @@ -108,7 +109,16 @@ def create( "post", "/v2/billing/collection_settings", base_address="api", - params=params, + params=_coerce_v2_params( + params, + { + "payment_method_options": { + "card": { + "mandate_options": {"amount": "int64_string"}, + }, + }, + }, + ), options=options, ), ) @@ -127,7 +137,16 @@ async def create_async( "post", "/v2/billing/collection_settings", base_address="api", - params=params, + params=_coerce_v2_params( + params, + { + "payment_method_options": { + "card": { + "mandate_options": {"amount": "int64_string"}, + }, + }, + }, + ), options=options, ), ) @@ -193,7 +212,16 @@ def update( id=sanitize_id(id), ), base_address="api", - params=params, + params=_coerce_v2_params( + params, + { + "payment_method_options": { + "card": { + "mandate_options": {"amount": "int64_string"}, + }, + }, + }, + ), options=options, ), ) @@ -215,7 +243,16 @@ async def update_async( id=sanitize_id(id), ), base_address="api", - params=params, + params=_coerce_v2_params( + params, + { + "payment_method_options": { + "card": { + "mandate_options": {"amount": "int64_string"}, + }, + }, + }, + ), options=options, ), ) diff --git a/stripe/v2/billing/_collection_setting_version.py b/stripe/v2/billing/_collection_setting_version.py index cfa05ea80..62c58c2e1 100644 --- a/stripe/v2/billing/_collection_setting_version.py +++ b/stripe/v2/billing/_collection_setting_version.py @@ -71,6 +71,7 @@ class MandateOptions(StripeObject): """ A description of the mandate that is meant to be displayed to the customer. """ + _field_encodings = {"amount": "int64_string"} mandate_options: Optional[MandateOptions] """ diff --git a/stripe/v2/billing/_license_fee.py b/stripe/v2/billing/_license_fee.py index b5d1d9a39..17e38500b 100644 --- a/stripe/v2/billing/_license_fee.py +++ b/stripe/v2/billing/_license_fee.py @@ -48,6 +48,7 @@ class TransformQuantity(StripeObject): """ After division, round the result up or down. """ + _field_encodings = {"divide_by": "int64_string"} active: bool """ diff --git a/stripe/v2/billing/_license_fee_service.py b/stripe/v2/billing/_license_fee_service.py index fa5049035..5b617f579 100644 --- a/stripe/v2/billing/_license_fee_service.py +++ b/stripe/v2/billing/_license_fee_service.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- # File generated from our OpenAPI spec +from stripe._encode import _coerce_v2_params from stripe._stripe_service import StripeService from stripe._util import sanitize_id from typing import Optional, cast @@ -106,7 +107,10 @@ def create( "post", "/v2/billing/license_fees", base_address="api", - params=params, + params=_coerce_v2_params( + params, + {"transform_quantity": {"divide_by": "int64_string"}}, + ), options=options, ), ) @@ -125,7 +129,10 @@ async def create_async( "post", "/v2/billing/license_fees", base_address="api", - params=params, + params=_coerce_v2_params( + params, + {"transform_quantity": {"divide_by": "int64_string"}}, + ), options=options, ), ) @@ -185,7 +192,10 @@ def update( "post", "/v2/billing/license_fees/{id}".format(id=sanitize_id(id)), base_address="api", - params=params, + params=_coerce_v2_params( + params, + {"transform_quantity": {"divide_by": "int64_string"}}, + ), options=options, ), ) @@ -205,7 +215,10 @@ async def update_async( "post", "/v2/billing/license_fees/{id}".format(id=sanitize_id(id)), base_address="api", - params=params, + params=_coerce_v2_params( + params, + {"transform_quantity": {"divide_by": "int64_string"}}, + ), options=options, ), ) diff --git a/stripe/v2/billing/_license_fee_version.py b/stripe/v2/billing/_license_fee_version.py index 8fd8b37d9..886b768ec 100644 --- a/stripe/v2/billing/_license_fee_version.py +++ b/stripe/v2/billing/_license_fee_version.py @@ -45,6 +45,7 @@ class TransformQuantity(StripeObject): """ After division, round the result up or down. """ + _field_encodings = {"divide_by": "int64_string"} created: str """ diff --git a/stripe/v2/billing/_rate_card_rate.py b/stripe/v2/billing/_rate_card_rate.py index 682d3abcb..1e172fea1 100644 --- a/stripe/v2/billing/_rate_card_rate.py +++ b/stripe/v2/billing/_rate_card_rate.py @@ -63,6 +63,7 @@ class TransformQuantity(StripeObject): """ After division, round the result up or down. """ + _field_encodings = {"divide_by": "int64_string"} created: str """ diff --git a/stripe/v2/billing/rate_cards/_rate_service.py b/stripe/v2/billing/rate_cards/_rate_service.py index 1fb0060f8..3f163f386 100644 --- a/stripe/v2/billing/rate_cards/_rate_service.py +++ b/stripe/v2/billing/rate_cards/_rate_service.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- # File generated from our OpenAPI spec +from stripe._encode import _coerce_v2_params from stripe._stripe_service import StripeService from stripe._util import sanitize_id from typing import Optional, cast @@ -87,7 +88,10 @@ def create( rate_card_id=sanitize_id(rate_card_id), ), base_address="api", - params=params, + params=_coerce_v2_params( + params, + {"transform_quantity": {"divide_by": "int64_string"}}, + ), options=options, ), ) @@ -110,7 +114,10 @@ async def create_async( rate_card_id=sanitize_id(rate_card_id), ), base_address="api", - params=params, + params=_coerce_v2_params( + params, + {"transform_quantity": {"divide_by": "int64_string"}}, + ), options=options, ), ) diff --git a/stripe/v2/reporting/_report_run.py b/stripe/v2/reporting/_report_run.py index 9a604d9c0..e7945fdf2 100644 --- a/stripe/v2/reporting/_report_run.py +++ b/stripe/v2/reporting/_report_run.py @@ -41,6 +41,7 @@ class DownloadUrl(StripeObject): The total size of the file in bytes. """ _inner_class_types = {"download_url": DownloadUrl} + _field_encodings = {"size": "int64_string"} file: File """ diff --git a/tests/test_int64_string.py b/tests/test_int64_string.py new file mode 100644 index 000000000..96c189aaa --- /dev/null +++ b/tests/test_int64_string.py @@ -0,0 +1,240 @@ +""" +Tests for V2 int64_string field encoding and decoding. + +V2 API int64 fields are encoded as strings on the wire but exposed as +native Python ints. These tests verify both directions: +- Request serialization: int → JSON string +- Response hydration: JSON string → int +""" + +from stripe._encode import _coerce_v2_params +from stripe._stripe_object import StripeObject + + +class TestCoerceV2Params: + """Tests for outbound request coercion (int → str).""" + + def test_top_level_int64_string(self): + params = {"amount": 12345} + schema = {"amount": "int64_string"} + result = _coerce_v2_params(params, schema) + assert result == {"amount": "12345"} + + def test_nested_int64_string(self): + params = {"nested": {"count": 42}} + schema = {"nested": {"count": "int64_string"}} + result = _coerce_v2_params(params, schema) + assert result == {"nested": {"count": "42"}} + + def test_array_of_int64_string(self): + params = {"amounts": [100, 200, 300]} + schema = {"amounts": "int64_string"} + result = _coerce_v2_params(params, schema) + assert result == {"amounts": ["100", "200", "300"]} + + def test_array_of_objects_with_int64_string(self): + params = {"items": [{"amount": 100}, {"amount": 200}]} + schema = {"items": {"amount": "int64_string"}} + result = _coerce_v2_params(params, schema) + assert result == {"items": [{"amount": "100"}, {"amount": "200"}]} + + def test_unrelated_fields_unchanged(self): + params = {"name": "test", "count": 42, "amount": 100} + schema = {"amount": "int64_string"} + result = _coerce_v2_params(params, schema) + assert result == {"name": "test", "count": 42, "amount": "100"} + + def test_none_params_returns_none(self): + schema = {"amount": "int64_string"} + result = _coerce_v2_params(None, schema) + assert result is None + + def test_none_value_preserved(self): + params = {"amount": None} + schema = {"amount": "int64_string"} + result = _coerce_v2_params(params, schema) + assert result == {"amount": None} + + def test_deeply_nested_int64_string(self): + params = {"level1": {"level2": {"value": 999}}} + schema = {"level1": {"level2": {"value": "int64_string"}}} + result = _coerce_v2_params(params, schema) + assert result == {"level1": {"level2": {"value": "999"}}} + + def test_mixed_fields_only_int64_coerced(self): + params = { + "name": "test", + "amount": 100, + "metadata": {"key": "val"}, + "count": 5, + } + schema = {"amount": "int64_string", "count": "int64_string"} + result = _coerce_v2_params(params, schema) + assert result == { + "name": "test", + "amount": "100", + "metadata": {"key": "val"}, + "count": "5", + } + + def test_empty_schema_no_coercion(self): + params = {"amount": 100} + schema = {} + result = _coerce_v2_params(params, schema) + assert result == {"amount": 100} + + def test_large_int64_value(self): + params = {"amount": 9223372036854775807} # max int64 + schema = {"amount": "int64_string"} + result = _coerce_v2_params(params, schema) + assert result == {"amount": "9223372036854775807"} + + def test_zero_value(self): + params = {"amount": 0} + schema = {"amount": "int64_string"} + result = _coerce_v2_params(params, schema) + assert result == {"amount": "0"} + + def test_negative_value(self): + params = {"amount": -100} + schema = {"amount": "int64_string"} + result = _coerce_v2_params(params, schema) + assert result == {"amount": "-100"} + + +class TestResponseFieldCoercion: + """Tests for inbound response coercion (str → int) via _field_encodings.""" + + def _make_v2_class(self, field_encodings): + """Create a StripeObject subclass with given field encodings.""" + + class V2Resource(StripeObject): + _field_encodings = field_encodings + + return V2Resource + + def test_top_level_int64_string_response(self): + cls = self._make_v2_class({"amount": "int64_string"}) + obj = cls.construct_from( + {"id": "test", "amount": "12345"}, + key="sk_test", + api_mode="V2", + ) + assert obj["amount"] == 12345 + assert isinstance(obj["amount"], int) + + def test_nested_class_int64_string_response(self): + """Nested StripeObject classes coerce their own fields.""" + + class InnerObj(StripeObject): + _field_encodings = {"count": "int64_string"} + + class OuterObj(StripeObject): + _inner_class_types = {"inner": InnerObj} + + obj = OuterObj.construct_from( + {"id": "test", "inner": {"count": "42"}}, + key="sk_test", + api_mode="V2", + ) + assert obj["inner"]["count"] == 42 + assert isinstance(obj["inner"]["count"], int) + + def test_array_of_int64_string_response(self): + cls = self._make_v2_class({"amounts": "int64_string"}) + obj = cls.construct_from( + {"id": "test", "amounts": ["100", "200", "300"]}, + key="sk_test", + api_mode="V2", + ) + assert obj["amounts"] == [100, 200, 300] + assert all(isinstance(v, int) for v in obj["amounts"]) + + def test_unrelated_fields_unchanged_response(self): + cls = self._make_v2_class({"amount": "int64_string"}) + obj = cls.construct_from( + {"id": "test", "name": "hello", "amount": "100"}, + key="sk_test", + api_mode="V2", + ) + assert obj["name"] == "hello" + assert isinstance(obj["name"], str) + assert obj["amount"] == 100 + + def test_none_value_preserved_response(self): + cls = self._make_v2_class({"amount": "int64_string"}) + obj = cls.construct_from( + {"id": "test", "amount": None}, + key="sk_test", + api_mode="V2", + ) + assert obj["amount"] is None + + def test_no_field_encodings_unchanged(self): + """StripeObject without _field_encodings leaves strings as strings.""" + obj = StripeObject.construct_from( + {"id": "test", "amount": "12345"}, + key="sk_test", + api_mode="V2", + ) + assert obj["amount"] == "12345" + assert isinstance(obj["amount"], str) + + def test_large_int64_response(self): + cls = self._make_v2_class({"amount": "int64_string"}) + obj = cls.construct_from( + {"id": "test", "amount": "9223372036854775807"}, + key="sk_test", + api_mode="V2", + ) + assert obj["amount"] == 9223372036854775807 + + def test_zero_response(self): + cls = self._make_v2_class({"amount": "int64_string"}) + obj = cls.construct_from( + {"id": "test", "amount": "0"}, + key="sk_test", + api_mode="V2", + ) + assert obj["amount"] == 0 + + def test_negative_response(self): + cls = self._make_v2_class({"amount": "int64_string"}) + obj = cls.construct_from( + {"id": "test", "amount": "-100"}, + key="sk_test", + api_mode="V2", + ) + assert obj["amount"] == -100 + + +class TestV1Unchanged: + """Regression tests: V1 behavior should not be affected.""" + + def test_v1_integer_fields_stay_integers(self): + """V1 objects don't have _field_encodings, so ints stay ints.""" + obj = StripeObject.construct_from( + {"id": "test", "amount": 100}, + key="sk_test", + api_mode="V1", + ) + assert obj["amount"] == 100 + assert isinstance(obj["amount"], int) + + def test_v1_string_fields_stay_strings(self): + obj = StripeObject.construct_from( + {"id": "test", "name": "hello"}, + key="sk_test", + api_mode="V1", + ) + assert obj["name"] == "hello" + assert isinstance(obj["name"], str) + + def test_object_without_field_encodings_unchanged(self): + """Objects without _field_encodings are unaffected.""" + obj = StripeObject.construct_from( + {"id": "test", "amount": "12345", "count": 42}, + key="sk_test", + ) + assert obj["amount"] == "12345" + assert obj["count"] == 42