diff --git a/changelog/fixed/tidy-computed-teapot.md b/changelog/fixed/tidy-computed-teapot.md new file mode 100644 index 000000000..6509d06a5 --- /dev/null +++ b/changelog/fixed/tidy-computed-teapot.md @@ -0,0 +1 @@ +Exclude computed fields when the Pydantic payload converter serializes to JSON, so models using computed fields round-trip even with extra="forbid". Requires pydantic>=2.11. diff --git a/pyproject.toml b/pyproject.toml index a87c0790d..034cfb38c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,7 @@ classifiers = [ [project.optional-dependencies] grpc = ["grpcio>=1.48.2,<2"] opentelemetry = ["opentelemetry-api>=1.26,<2", "opentelemetry-sdk>=1.26,<2"] -pydantic = ["pydantic>=2.0.0,<3"] +pydantic = ["pydantic>=2.11.0,<3"] openai-agents = ["temporalio-openai-agents>=1.0.0"] google-adk = ["temporalio-google-adk[mcp]>=0.0.1"] langgraph = ["temporalio-langgraph>=0.1.0"] diff --git a/temporalio/contrib/pydantic.py b/temporalio/contrib/pydantic.py index bb8fe0010..78ef6fc80 100644 --- a/temporalio/contrib/pydantic.py +++ b/temporalio/contrib/pydantic.py @@ -18,7 +18,7 @@ from typing import Any from pydantic import TypeAdapter -from pydantic_core import SchemaSerializer, to_json +from pydantic_core import SchemaSerializer from pydantic_core.core_schema import any_schema import temporalio.api.common.v1 @@ -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. + Serializes ``value`` to JSON with a ``pydantic_core`` schema + serializer configured to match ``pydantic_core.to_json`` defaults. - See - https://docs.pydantic.dev/latest/api/pydantic_core/#pydantic_core.to_json. + Computed fields are excluded so payloads always round-trip, even for + models with ``extra="forbid"`` which would reject them on read. """ 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, + exclude_computed_fields=True, ) if self._to_json_options - else to_json(value) + # by_alias=True matches the pydantic_core.to_json default + else self._schema_serializer.to_json( + value, by_alias=True, exclude_computed_fields=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..8ba6025f7 100644 --- a/tests/contrib/pydantic/test_pydantic.py +++ b/tests/contrib/pydantic/test_pydantic.py @@ -128,6 +128,38 @@ def test_to_json_options_exclude_unset_keeps_aliases(): assert converter.from_payload(payload, AliasModel) == AliasModel(orderId=7) +class ComputedFieldModel(BaseModel): + model_config = pydantic.ConfigDict(extra="forbid") + x: int + + @pydantic.computed_field + @property + def double(self) -> int: + return self.x * 2 + + +@pytest.mark.parametrize( + "converter", + [ + PydanticJSONPlainPayloadConverter(), + PydanticJSONPlainPayloadConverter( + temporalio.contrib.pydantic.ToJsonOptions(exclude_unset=True) + ), + ], +) +def test_computed_fields_excluded_for_round_trip( + converter: PydanticJSONPlainPayloadConverter, +): + # Computed fields are derived on read, so serializing them breaks models + # with extra="forbid" which reject them as unexpected input. + payload = converter.to_payload(ComputedFieldModel(x=21)) + assert payload is not None + assert payload.data == b'{"x":21}' + assert converter.from_payload(payload, ComputedFieldModel) == ComputedFieldModel( + x=21 + ) + + async def test_pydantic_data_converter_pickle(): restored = pickle.loads(pickle.dumps(pydantic_data_converter)) value = {"a": 1}