From 2608fc968e7f369d117dc1379b67e5811b985bea Mon Sep 17 00:00:00 2001 From: Tim Conley Date: Wed, 16 Sep 2026 11:42:09 -0700 Subject: [PATCH 1/2] Restore pickling of Pydantic data converters --- CHANGELOG.md | 3 ++ temporalio/contrib/pydantic.py | 15 ++++++++++ tests/contrib/pydantic/test_pydantic.py | 38 +++++++++++++++++++++++++ 3 files changed, 56 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e8597d2c2f..87e7e92752 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,9 @@ to include examples, links to docs, or any other relevant information. ### Fixed +- Restore pickling of Pydantic data converters, preserving the type adapter cache + size limit while excluding cached adapters. + ### Security ## [1.33.0] - 2026-09-14 diff --git a/temporalio/contrib/pydantic.py b/temporalio/contrib/pydantic.py index dd5d0e67a6..c6ea2a2da9 100644 --- a/temporalio/contrib/pydantic.py +++ b/temporalio/contrib/pydantic.py @@ -51,6 +51,8 @@ class PydanticJSONPlainPayloadConverter(EncodingPayloadConverter): types from the datetime module, sets, UUID, etc, and custom types composed of any of these. + Pickling preserves the cache size limit but excludes cached type adapters. + See https://docs.pydantic.dev/latest/api/standard_library_types/ """ @@ -77,6 +79,19 @@ def __init__( TypeAdapter ) + def __getstate__(self) -> dict[str, Any]: + """Pickle without the type adapter cache, whose wrapper is not picklable.""" + state = self.__dict__.copy() + state["_type_adapter"] = self._type_adapter.cache_info().maxsize + return state + + def __setstate__(self, state: dict[str, Any]) -> None: + """Restore state with an empty type adapter cache.""" + self.__dict__.update(state) + self._type_adapter = functools.lru_cache(maxsize=state["_type_adapter"])( + TypeAdapter + ) + @property def encoding(self) -> str: """See base class.""" diff --git a/tests/contrib/pydantic/test_pydantic.py b/tests/contrib/pydantic/test_pydantic.py index 7d70dfd19f..cdf8aa203c 100644 --- a/tests/contrib/pydantic/test_pydantic.py +++ b/tests/contrib/pydantic/test_pydantic.py @@ -2,6 +2,7 @@ import datetime import os import pathlib +import pickle import typing import uuid @@ -9,6 +10,7 @@ import pytest from pydantic import BaseModel +import temporalio.contrib.pydantic from temporalio.client import Client from temporalio.contrib.pydantic import ( PydanticJSONPlainPayloadConverter, @@ -55,6 +57,42 @@ ) +@pytest.mark.parametrize("max_cached_type_adapters", [0, 2, None]) +def test_pydantic_json_converter_pickle(max_cached_type_adapters: int | None): + class Model(BaseModel): + value: int + default: int = 0 + + converter = PydanticJSONPlainPayloadConverter( + temporalio.contrib.pydantic.ToJsonOptions(exclude_unset=True), + max_cached_type_adapters=max_cached_type_adapters, + ) + value = Model(value=1) + payload = converter.to_payload(value) + assert payload is not None + assert converter.from_payload(payload, Model) == value + cache_info = converter._type_adapter.cache_info() + + restored = pickle.loads(pickle.dumps(converter)) + + assert converter._type_adapter.cache_info() == cache_info + assert restored._type_adapter.cache_info().maxsize == max_cached_type_adapters + assert restored._type_adapter.cache_info().currsize == 0 + assert restored.to_payload(value) == payload + assert payload.data == b'{"value":1}' + assert restored.from_payload(payload, Model) == value + assert restored.from_payload(payload, Model) == value + assert restored._type_adapter.cache_info().hits == ( + 0 if max_cached_type_adapters == 0 else 1 + ) + + +async def test_pydantic_data_converter_pickle(): + restored = pickle.loads(pickle.dumps(pydantic_data_converter)) + value = {"a": 1} + assert await restored.decode(await restored.encode([value]), [dict]) == [value] + + @pytest.mark.parametrize( ( "converter_kwargs", From 3dceb5ceb5eeecaa460130a730a9e630984cb006 Mon Sep 17 00:00:00 2001 From: Tim Conley Date: Wed, 16 Sep 2026 11:42:44 -0700 Subject: [PATCH 2/2] Focus pickle coverage on empty and populated caches --- tests/contrib/pydantic/test_pydantic.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/tests/contrib/pydantic/test_pydantic.py b/tests/contrib/pydantic/test_pydantic.py index cdf8aa203c..18570b3976 100644 --- a/tests/contrib/pydantic/test_pydantic.py +++ b/tests/contrib/pydantic/test_pydantic.py @@ -57,34 +57,33 @@ ) -@pytest.mark.parametrize("max_cached_type_adapters", [0, 2, None]) -def test_pydantic_json_converter_pickle(max_cached_type_adapters: int | None): +@pytest.mark.parametrize("populate_cache", [False, True]) +def test_pydantic_json_converter_pickle(populate_cache: bool): class Model(BaseModel): value: int default: int = 0 converter = PydanticJSONPlainPayloadConverter( temporalio.contrib.pydantic.ToJsonOptions(exclude_unset=True), - max_cached_type_adapters=max_cached_type_adapters, + max_cached_type_adapters=2, ) value = Model(value=1) payload = converter.to_payload(value) assert payload is not None - assert converter.from_payload(payload, Model) == value + if populate_cache: + assert converter.from_payload(payload, Model) == value cache_info = converter._type_adapter.cache_info() restored = pickle.loads(pickle.dumps(converter)) assert converter._type_adapter.cache_info() == cache_info - assert restored._type_adapter.cache_info().maxsize == max_cached_type_adapters + assert restored._type_adapter.cache_info().maxsize == 2 assert restored._type_adapter.cache_info().currsize == 0 assert restored.to_payload(value) == payload assert payload.data == b'{"value":1}' assert restored.from_payload(payload, Model) == value assert restored.from_payload(payload, Model) == value - assert restored._type_adapter.cache_info().hits == ( - 0 if max_cached_type_adapters == 0 else 1 - ) + assert restored._type_adapter.cache_info().hits == 1 async def test_pydantic_data_converter_pickle():