diff --git a/changelog/fixed/aliased-armadillo.md b/changelog/fixed/aliased-armadillo.md new file mode 100644 index 0000000000..685653ccba --- /dev/null +++ b/changelog/fixed/aliased-armadillo.md @@ -0,0 +1 @@ +`temporalio.contrib.pydantic`: setting `ToJsonOptions` on the Pydantic payload converter no longer drops field aliases or serializes `inf`/`nan` as `null`; the JSON output now matches the default converter apart from the options themselves. diff --git a/temporalio/contrib/pydantic.py b/temporalio/contrib/pydantic.py index 94e4363db0..bb8fe0010a 100644 --- a/temporalio/contrib/pydantic.py +++ b/temporalio/contrib/pydantic.py @@ -73,7 +73,11 @@ def __init__( """ if max_cached_type_adapters is not None and max_cached_type_adapters < 0: raise ValueError("max_cached_type_adapters cannot be negative") - self._schema_serializer = SchemaSerializer(any_schema()) + # Match the defaults of pydantic_core.to_json so that the options only + # change what they control + self._schema_serializer = SchemaSerializer( + any_schema(), config={"ser_json_inf_nan": "constants"} + ) self._to_json_options = to_json_options self._type_adapter: functools._lru_cache_wrapper[TypeAdapter[Any]] = ( functools.lru_cache(maxsize=max_cached_type_adapters)(TypeAdapter) @@ -107,7 +111,7 @@ def to_payload(self, value: Any) -> temporalio.api.common.v1.Payload | None: """ data = ( self._schema_serializer.to_json( - value, exclude_unset=self._to_json_options.exclude_unset + value, by_alias=True, exclude_unset=self._to_json_options.exclude_unset ) if self._to_json_options else to_json(value) diff --git a/tests/contrib/pydantic/test_pydantic.py b/tests/contrib/pydantic/test_pydantic.py index 18570b3976..c3ab95f181 100644 --- a/tests/contrib/pydantic/test_pydantic.py +++ b/tests/contrib/pydantic/test_pydantic.py @@ -86,6 +86,48 @@ class Model(BaseModel): assert restored._type_adapter.cache_info().hits == 1 +class AliasModel(BaseModel): + order_id: int = pydantic.Field(default=-1, alias="orderId") + note: str = "" + ratio: float = 1.0 + wait: datetime.timedelta = datetime.timedelta(seconds=1) + data: bytes = b"data" + + +@pytest.mark.parametrize( + "value", + [ + AliasModel(orderId=7), + float("inf"), + float("-inf"), + float("nan"), + [float("inf"), 1.5, float("nan")], + {"ratio": float("inf")}, + {"wait": datetime.timedelta(minutes=1), "data": b"data"}, + ], +) +def test_to_json_options_wire_format_matches_default(value: object): + default_payload = PydanticJSONPlainPayloadConverter().to_payload(value) + for options in ( + temporalio.contrib.pydantic.ToJsonOptions(), + temporalio.contrib.pydantic.ToJsonOptions(exclude_unset=False), + ): + options_payload = PydanticJSONPlainPayloadConverter(options).to_payload(value) + assert default_payload is not None + assert options_payload is not None + assert options_payload.data == default_payload.data + + +def test_to_json_options_exclude_unset_keeps_aliases(): + converter = PydanticJSONPlainPayloadConverter( + temporalio.contrib.pydantic.ToJsonOptions(exclude_unset=True) + ) + payload = converter.to_payload(AliasModel(orderId=7)) + assert payload is not None + assert payload.data == b'{"orderId":7}' + assert converter.from_payload(payload, AliasModel) == AliasModel(orderId=7) + + async def test_pydantic_data_converter_pickle(): restored = pickle.loads(pickle.dumps(pydantic_data_converter)) value = {"a": 1}