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/recomputed-raccoon.md
Original file line number Diff line number Diff line change
@@ -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.
13 changes: 10 additions & 3 deletions temporalio/contrib/pydantic.py
Original file line number Diff line number Diff line change
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.
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
Expand Down
62 changes: 62 additions & 0 deletions tests/contrib/pydantic/test_pydantic.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
Loading