Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog/fixed/tidy-computed-teapot.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
19 changes: 13 additions & 6 deletions temporalio/contrib/pydantic.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
32 changes: 32 additions & 0 deletions tests/contrib/pydantic/test_pydantic.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
Loading