From 766c9b7dad487ebacd75556b47cd380c1f729561 Mon Sep 17 00:00:00 2001 From: Sohom Chakraborty <16609933+sohom-cs@users.noreply.github.com> Date: Fri, 30 Oct 2026 05:55:59 -0400 Subject: [PATCH] Leave computed fields out of Pydantic payloads so extra="forbid" models decode Serialize with round_trip=True on both the default path and the ToJsonOptions path, so that from_payload can validate what to_payload wrote: computed fields are left out (validation recomputes them) and Json[...] fields are written as JSON strings. --- changelog/fixed/recomputed-raccoon.md | 1 + temporalio/contrib/pydantic.py | 13 ++++-- tests/contrib/pydantic/test_pydantic.py | 62 +++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 3 deletions(-) create mode 100644 changelog/fixed/recomputed-raccoon.md diff --git a/changelog/fixed/recomputed-raccoon.md b/changelog/fixed/recomputed-raccoon.md new file mode 100644 index 000000000..cece7f41d --- /dev/null +++ b/changelog/fixed/recomputed-raccoon.md @@ -0,0 +1 @@ +`temporalio.contrib.pydantic`: the Pydantic payload converter now serializes with `round_trip=True`, so models with computed fields and `extra="forbid"`, and models with `Json[...]` fields, can be decoded from its payloads; computed fields are no longer written to payloads. diff --git a/temporalio/contrib/pydantic.py b/temporalio/contrib/pydantic.py index bb8fe0010..2ee4d4aac 100644 --- a/temporalio/contrib/pydantic.py +++ b/temporalio/contrib/pydantic.py @@ -104,17 +104,24 @@ def encoding(self) -> str: def to_payload(self, value: Any) -> temporalio.api.common.v1.Payload | None: """See base class. - Uses ``pydantic_core.to_json`` to serialize ``value`` to JSON. + Uses ``pydantic_core.to_json`` to serialize ``value`` to JSON, with + ``round_trip=True`` so that ``from_payload`` can validate the output + back into the same value: computed fields are left out, since + validation recomputes them, and ``Json[...]`` fields are written as + JSON strings. See https://docs.pydantic.dev/latest/api/pydantic_core/#pydantic_core.to_json. """ data = ( self._schema_serializer.to_json( - value, by_alias=True, exclude_unset=self._to_json_options.exclude_unset + value, + by_alias=True, + exclude_unset=self._to_json_options.exclude_unset, + round_trip=True, ) if self._to_json_options - else to_json(value) + else to_json(value, round_trip=True) ) return temporalio.api.common.v1.Payload( metadata={"encoding": self.encoding.encode()}, data=data diff --git a/tests/contrib/pydantic/test_pydantic.py b/tests/contrib/pydantic/test_pydantic.py index c3ab95f18..1b565258d 100644 --- a/tests/contrib/pydantic/test_pydantic.py +++ b/tests/contrib/pydantic/test_pydantic.py @@ -128,6 +128,68 @@ def test_to_json_options_exclude_unset_keeps_aliases(): assert converter.from_payload(payload, AliasModel) == AliasModel(orderId=7) +class ForbidComputedModel(BaseModel): + model_config = pydantic.ConfigDict(extra="forbid") + text: str + + @pydantic.computed_field # type: ignore[prop-decorator] + @property + def length(self) -> int: + return len(self.text) + + +class AllowComputedModel(BaseModel): + model_config = pydantic.ConfigDict(extra="allow") + text: str + + @pydantic.computed_field # type: ignore[prop-decorator] + @property + def length(self) -> int: + return len(self.text) + + +class JsonFieldModel(BaseModel): + raw: pydantic.Json[dict[str, int]] + + +@pytest.mark.parametrize( + "options", + [ + None, + temporalio.contrib.pydantic.ToJsonOptions(), + temporalio.contrib.pydantic.ToJsonOptions(exclude_unset=True), + ], +) +@pytest.mark.parametrize( + ("value", "type_hint", "data"), + [ + (ForbidComputedModel(text="hi"), ForbidComputedModel, b'{"text":"hi"}'), + (AllowComputedModel(text="hi"), AllowComputedModel, b'{"text":"hi"}'), + ( + [ForbidComputedModel(text="hi")], + list[ForbidComputedModel], + b'[{"text":"hi"}]', + ), + ( + JsonFieldModel.model_validate({"raw": '{"a":1}'}), + JsonFieldModel, + b'{"raw":"{\\"a\\":1}"}', + ), + ], +) +def test_payload_validates_back_to_value( + options: temporalio.contrib.pydantic.ToJsonOptions | None, + value: object, + type_hint: type, + data: bytes, +): + converter = PydanticJSONPlainPayloadConverter(options) + payload = converter.to_payload(value) + assert payload is not None + assert converter.from_payload(payload, type_hint) == value + assert payload.data == data + + async def test_pydantic_data_converter_pickle(): restored = pickle.loads(pickle.dumps(pydantic_data_converter)) value = {"a": 1}