diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2407e5b55..f59e56c82 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,7 @@ on: branches: - master - beta + - private-preview - sdk-release/** - feature/** tags: diff --git a/CODEGEN_VERSION b/CODEGEN_VERSION index 308945cbf..df4cd29dc 100644 --- a/CODEGEN_VERSION +++ b/CODEGEN_VERSION @@ -1 +1 @@ -baff58c9d515cdd5f5c3231d101989d588788c6f \ No newline at end of file +668589296b587cb6b843b19716fb0990f1f2208f \ No newline at end of file diff --git a/OPENAPI_VERSION b/OPENAPI_VERSION index 28d67d2bf..c67d247fb 100644 --- a/OPENAPI_VERSION +++ b/OPENAPI_VERSION @@ -1 +1 @@ -v2442 \ No newline at end of file +v2455 \ No newline at end of file diff --git a/justfile b/justfile index a56d62314..c9a7a6800 100644 --- a/justfile +++ b/justfile @@ -35,11 +35,11 @@ typecheck: install-test-deps install-dev-deps # ⭐ format all code format: install-dev-deps - ruff format . --quiet + ruff format . > /dev/null # verify formatting, but don't modify files format-check: install-dev-deps - ruff format . --check --quiet + ruff format . --check > /dev/null # remove venv & build artifacts clean: diff --git a/stripe/_stripe_object.py b/stripe/_stripe_object.py index f29bbce42..b37075ab1 100644 --- a/stripe/_stripe_object.py +++ b/stripe/_stripe_object.py @@ -413,7 +413,9 @@ def _refresh_from( 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) + inner_class = self._get_union_variant_class( + k, v + ) or self._get_inner_class_type(k) is_dict = self._get_inner_class_is_beneath_dict(k) if is_dict: obj = { @@ -682,11 +684,41 @@ def __deepcopy__(self, memo: Dict[int, Any]) -> "StripeObject": _inner_class_dicts: ClassVar[List[str]] = [] _field_encodings: ClassVar[Dict[str, str]] = {} + # Maps a discriminated-union field to (discriminator, {value: class}). Generated + # subclasses override this; every other object keeps the empty default so the + # lookup in _update_attributes stays cheap. + _inner_class_union_variant_types: ClassVar[ + Dict[str, Tuple[str, Dict[str, Type["StripeObject"]]]] + ] = {} + def _get_inner_class_type( self, field_name: str ) -> Optional[Type["StripeObject"]]: return self._inner_class_types.get(field_name) + def _get_union_variant_class( + self, field_name: str, value: Any + ) -> Optional[Type["StripeObject"]]: + """ + Returns the variant class that a discriminated union field's value should + become, based on the discriminator carried in the value itself. + + Returns None rather than raising when the discriminator is absent, is not a + string, or names a variant this version of the SDK does not know about. The + caller then converts without a class, so a variant the API adds after this + release still deserializes instead of blowing up. + """ + union = self._inner_class_union_variant_types.get(field_name) + if union is None or not isinstance(value, dict): + return None + + discriminator, variants = union + discriminator_value = cast(Dict[str, Any], value).get(discriminator) + if not isinstance(discriminator_value, str): + return None + + return variants.get(discriminator_value) + def _get_inner_class_is_beneath_dict(self, field_name: str): return field_name in self._inner_class_dicts diff --git a/tests/test_discriminated_unions.py b/tests/test_discriminated_unions.py new file mode 100644 index 000000000..ff20cc1a6 --- /dev/null +++ b/tests/test_discriminated_unions.py @@ -0,0 +1,309 @@ +""" +Tests for discriminated union runtime behavior. + +A discriminated union field arrives as a plain JSON object, and the SDK has to +pick the variant class out of the discriminator carried inside that object. The +fixtures below mirror what codegen emits for the fake spec's `test.llama` +resource, including the part that makes dispatch *observable*: the two color +variants declare different `_field_encodings`, so identical wire bytes hydrate +differently based only on the discriminator. Without dispatch the value becomes +a bare StripeObject carrying no encodings, and every coercion assertion here +fails. + +Static type narrowing (Literal discriminators, Union resolution) is checked by +pyright, not here. +""" + +from decimal import Decimal +from typing import Any, Dict, Optional, Union + +from typing_extensions import Literal + +from stripe._encode import _coerce_v2_params +from stripe._stripe_object import StripeObject + + +# --------------------------------------------------------------------------- +# Fixtures — shaped the way codegen emits them +# --------------------------------------------------------------------------- + + +class RgbColor(StripeObject): + luminance: Optional[int] + model: Literal["rgb"] + _field_encodings = {"luminance": "int64_string"} + + +class HsvColor(StripeObject): + model: Literal["hsv"] + saturation_precision: Optional[Decimal] + _field_encodings = {"saturation_precision": "decimal_string"} + + +class HslColor(StripeObject): + model: Literal["hsl"] + + +class MagicLlama(StripeObject): + mana_cost: Optional[int] + _field_encodings = {"mana_cost": "int64_string"} + + +class Llama(StripeObject): + """ + Carries both union shapes the generator produces: a standalone `color` + union whose variants are separate classes, and an inline `magic_llama` + union whose discriminator lives on the parent and whose payload is a + plain inner class. + """ + + color: Union[RgbColor, HsvColor, HslColor] + magic_llama: Optional[MagicLlama] + name: str + type: Literal["earth_llama", "magic_llama"] + _inner_class_types = {"magic_llama": MagicLlama} + _inner_class_union_variant_types = { + "color": ( + "model", + {"rgb": RgbColor, "hsv": HsvColor, "hsl": HslColor}, + ), + } + + +def _llama(**values: Any) -> Llama: + return Llama.construct_from( + {"name": "kuzco", **values}, key="sk_test", api_mode="V2" + ) + + +# Copied from the generated `LlamaService.create` call site. The generator +# flattens every variant's fields into one map keyed by field name, so this +# single schema covers both `luminance` (rgb) and `saturation_precision` (hsv). +_COLOR_REQUEST_SCHEMA: Dict[str, Any] = { + "color": { + "luminance": "int64_string", + "saturation_precision": "decimal_string", + }, +} + + +# --------------------------------------------------------------------------- +# Response side — variant dispatch +# --------------------------------------------------------------------------- + + +class TestVariantDispatch: + """The discriminator selects the variant class, not the base.""" + + def test_dispatches_to_the_rgb_variant(self): + llama = _llama(color={"model": "rgb", "luminance": "1500"}) + assert isinstance(llama.color, RgbColor) + + def test_dispatches_to_the_hsv_variant(self): + llama = _llama(color={"model": "hsv", "saturation_precision": "0.125"}) + assert isinstance(llama.color, HsvColor) + + def test_dispatches_to_a_variant_with_no_payload_fields(self): + llama = _llama(color={"model": "hsl"}) + assert isinstance(llama.color, HslColor) + assert llama.color.model == "hsl" + + def test_the_variants_int64_encoding_applies(self): + llama = _llama(color={"model": "rgb", "luminance": "1500"}) + assert llama.color.luminance == 1500 + assert isinstance(llama.color.luminance, int) + + def test_the_variants_decimal_encoding_applies(self): + llama = _llama(color={"model": "hsv", "saturation_precision": "0.125"}) + assert llama.color.saturation_precision == Decimal("0.125") + assert isinstance(llama.color.saturation_precision, Decimal) + + def test_only_the_discriminator_decides_which_field_coerces(self): + """ + The sharpest statement of what dispatch buys: two payloads differing + in nothing but the discriminator coerce different fields, because each + variant class knows only its own encodings. + """ + payload = {"luminance": "1500", "saturation_precision": "0.125"} + + as_rgb = _llama(color={"model": "rgb", **payload}).color + assert as_rgb.luminance == 1500 + assert as_rgb.saturation_precision == "0.125" + + as_hsv = _llama(color={"model": "hsv", **payload}).color + assert as_hsv.luminance == "1500" + assert as_hsv.saturation_precision == Decimal("0.125") + + def test_the_discriminator_itself_is_readable_on_the_variant(self): + llama = _llama(color={"model": "rgb", "luminance": "1"}) + assert llama.color.model == "rgb" + assert llama.color["model"] == "rgb" + + +# --------------------------------------------------------------------------- +# Response side — fallback +# --------------------------------------------------------------------------- + + +class TestUnknownVariantFallback: + """ + A variant the API adds after this release must still deserialize. The + fallback is a plain StripeObject: readable, but with no encodings, since + the SDK has no idea what the new variant's fields mean. + """ + + def test_an_unknown_discriminator_falls_back(self): + llama = _llama(color={"model": "cmyk", "cyan": "1"}) + assert type(llama.color) is StripeObject + assert llama.color.model == "cmyk" + assert llama.color.cyan == "1" + + def test_an_absent_discriminator_falls_back(self): + llama = _llama(color={"luminance": "1500"}) + assert type(llama.color) is StripeObject + assert llama.color.luminance == "1500" + + def test_a_non_string_discriminator_falls_back(self): + llama = _llama(color={"model": 7}) + assert type(llama.color) is StripeObject + + def test_a_null_union_value_stays_none(self): + assert _llama(color=None).color is None + + def test_a_non_object_union_value_passes_through(self): + """ + Not a shape the API produces, but the lookup must not raise on it — + the union field is read before anything has validated its type. + """ + assert _llama(color="rgb").color == "rgb" + + +# --------------------------------------------------------------------------- +# Response side — inline unions are unaffected +# --------------------------------------------------------------------------- + + +class TestInlineUnionsUseInnerClassTypes: + """ + Inline union variants are namespaced by field name, so they need no + discriminator lookup and keep going through `_inner_class_types`. These + pin that the union lookup did not displace it. + """ + + def test_the_inline_variant_gets_its_inner_class(self): + llama = _llama(type="magic_llama", magic_llama={"mana_cost": "42"}) + assert isinstance(llama.magic_llama, MagicLlama) + + def test_the_inline_variants_encoding_applies(self): + llama = _llama(type="magic_llama", magic_llama={"mana_cost": "42"}) + assert llama.magic_llama.mana_cost == 42 + assert isinstance(llama.magic_llama.mana_cost, int) + + def test_the_non_selected_variant_is_not_fabricated(self): + llama = _llama(type="earth_llama") + assert llama.type == "earth_llama" + # `__getattr__` raises for a key absent from `_data`, so this is a + # real statement that nothing was materialized for the other variant. + assert not hasattr(llama, "magic_llama") + + +# --------------------------------------------------------------------------- +# Response side — serialization back out +# --------------------------------------------------------------------------- + + +class TestUnionValueSerialization: + def test_to_dict_recurses_into_the_variant(self): + llama = _llama(color={"model": "rgb", "luminance": "1500"}) + assert llama.to_dict()["color"] == { + "model": "rgb", + "luminance": 1500, + } + + def test_to_dict_for_json_restringifies_the_decimal(self): + """ + The variant hydrates `saturation_precision` to a Decimal, which is not + JSON-serializable, so `for_json` has to put the string back. + """ + llama = _llama(color={"model": "hsv", "saturation_precision": "0.125"}) + + plain = llama.to_dict()["color"]["saturation_precision"] + assert isinstance(plain, Decimal) + + for_json = llama.to_dict(for_json=True)["color"] + assert for_json["saturation_precision"] == "0.125" + assert isinstance(for_json["saturation_precision"], str) + + def test_to_dict_preserves_an_unknown_variant_verbatim(self): + llama = _llama(color={"model": "cmyk", "cyan": "1"}) + assert llama.to_dict()["color"] == {"model": "cmyk", "cyan": "1"} + + +# --------------------------------------------------------------------------- +# Request side +# --------------------------------------------------------------------------- + + +class TestUnionRequestCoercion: + """ + Outbound coercion runs off the method-level schema, which is keyed by + field name only — there is no discriminator in it. + """ + + def test_the_rgb_variants_int64_field_is_stringified(self): + result = _coerce_v2_params( + {"color": {"model": "rgb", "luminance": 1500}}, + _COLOR_REQUEST_SCHEMA, + ) + assert result == {"color": {"model": "rgb", "luminance": "1500"}} + + def test_the_hsv_variants_decimal_field_is_stringified(self): + result = _coerce_v2_params( + { + "color": { + "model": "hsv", + "saturation_precision": Decimal("0.125"), + } + }, + _COLOR_REQUEST_SCHEMA, + ) + assert result == { + "color": {"model": "hsv", "saturation_precision": "0.125"} + } + + def test_a_payload_free_variant_passes_through_untouched(self): + result = _coerce_v2_params( + {"color": {"model": "hsl"}}, _COLOR_REQUEST_SCHEMA + ) + assert result == {"color": {"model": "hsl"}} + + def test_coercion_is_by_field_name_not_by_variant(self): + """ + Pins the generator's flattening decision: every variant's fields land + in one map, so a field is coerced whenever it appears, whatever the + discriminator says. Safe while variants do not share a field name with + conflicting encodings. + """ + result = _coerce_v2_params( + { + "color": { + "model": "rgb", + "saturation_precision": Decimal("0.5"), + } + }, + _COLOR_REQUEST_SCHEMA, + ) + assert result == { + "color": {"model": "rgb", "saturation_precision": "0.5"} + } + + def test_unknown_variant_fields_pass_through(self): + result = _coerce_v2_params( + {"color": {"model": "cmyk", "cyan": 1}}, + _COLOR_REQUEST_SCHEMA, + ) + assert result == {"color": {"model": "cmyk", "cyan": 1}} + + def test_a_null_union_is_not_coerced(self): + result = _coerce_v2_params({"color": None}, _COLOR_REQUEST_SCHEMA) + assert result == {"color": None}