diff --git a/README.md b/README.md index 4c58f676..c4ab1d43 100644 --- a/README.md +++ b/README.md @@ -446,6 +446,52 @@ my_data_converter = dataclasses.replace( Now `IPv4Address` can be used in type hints including collections, optionals, etc. +When the `JSONPlainPayloadConverter` is used a class can implement `to_temporal_json` and `from_temporal_json` methods to +support custom conversion logic. Custom conversion of generic classes is supported. +These methods should have the following signatures: + +``` + class MyClass: + ... +``` +`from_temporal_json` be either classmethod: +``` + @classmethod + def from_temporal_json(cls, json: Any) -> MyClass: + ... +``` + or static method: + ``` + @staticmethod + def from_temporal_json(json: Any) -> MyClass: + ... +``` +`to_temporal_json` is always an instance method: +``` + def to_temporal_json(self) -> Any: + ... +``` +The to_json should return the same Python JSON types produced by JSONEncoder: +``` + +-------------------+---------------+ + | Python | JSON | + +===================+===============+ + | dict | object | + +-------------------+---------------+ + | list, tuple | array | + +-------------------+---------------+ + | str | string | + +-------------------+---------------+ + | int, float | number | + +-------------------+---------------+ + | True | true | + +-------------------+---------------+ + | False | false | + +-------------------+---------------+ + | None | null | + +-------------------+---------------+ +``` + ### Workers Workers host workflows and/or activities. Here's how to run a worker: diff --git a/temporalio/converter.py b/temporalio/converter.py index 37e7641d..c7143dd4 100644 --- a/temporalio/converter.py +++ b/temporalio/converter.py @@ -499,6 +499,15 @@ def default(self, o: Any) -> Any: See :py:meth:`json.JSONEncoder.default`. """ + # Custom encoding and decoding through to_json and from_json + # to_json should be an instance method with only self argument + to_json = "to_temporal_json" + if hasattr(o, to_json): + attr = getattr(o, to_json) + if not callable(attr): + raise TypeError(f"Type {o.__class__}: {to_json} must be a method") + return attr() + # Dataclass support if dataclasses.is_dataclass(o): return dataclasses.asdict(o) @@ -524,6 +533,44 @@ class JSONPlainPayloadConverter(EncodingPayloadConverter): For decoding, this uses type hints to attempt to rebuild the type from the type hint. + + A class can implement to_json and from_temporal_json methods to support custom conversion logic. + Custom conversion of generic classes is supported. + These methods should have the following signatures: + + .. code-block:: python + + class MyClass: + ... + + @classmethod + def from_temporal_json(cls, json: Any) -> MyClass: + ... + + def to_temporal_json(self) -> Any: + ... + + The to_json should return the same Python JSON types produced by JSONEncoder: + + +-------------------+---------------+ + | Python | JSON | + +===================+===============+ + | dict | object | + +-------------------+---------------+ + | list, tuple | array | + +-------------------+---------------+ + | str | string | + +-------------------+---------------+ + | int, float | number | + +-------------------+---------------+ + | True | true | + +-------------------+---------------+ + | False | false | + +-------------------+---------------+ + | None | null | + +-------------------+---------------+ + + """ _encoder: Optional[Type[json.JSONEncoder]] @@ -1429,6 +1476,17 @@ def value_to_type( raise TypeError(f"Value {value} not in literal values {type_args}") return value + # Has from_json class method (must have to_json as well) + from_json = "from_temporal_json" + if hasattr(hint, from_json): + attr = getattr(hint, from_json) + attr_cls = getattr(attr, "__self__", None) + if not callable(attr) or (attr_cls is not None and attr_cls is not origin): + raise TypeError( + f"Type {hint}: {from_json} must be a staticmethod or classmethod" + ) + return attr(value) + is_union = origin is Union if sys.version_info >= (3, 10): is_union = is_union or isinstance(origin, UnionType) diff --git a/tests/worker/test_workflow.py b/tests/worker/test_workflow.py index f57f4b9f..bad025a8 100644 --- a/tests/worker/test_workflow.py +++ b/tests/worker/test_workflow.py @@ -2243,12 +2243,73 @@ def assert_expected(self) -> None: assert self.field1 == "some value" +T = typing.TypeVar("T") + + +class MyGenericClass(typing.Generic[T]): + """ + Demonstrates custom conversion and that it works even with generic classes. + """ + + def __init__(self, field1: str): + self.field1 = field1 + self.field2 = "foo" + + @classmethod + def from_temporal_json(cls, json_obj: Any) -> MyGenericClass: + return MyGenericClass(str(json_obj) + "_from_json") + + def to_temporal_json(self) -> Any: + return self.field1 + "_to_json" + + def assert_expected(self, value: str) -> None: + # Part of the assertion is that this is the right type, which is + # confirmed just by calling the method. We also check the field. + assert str(self.field1) == value + + +class MyGenericClassWithStatic(typing.Generic[T]): + """ + Demonstrates custom conversion and that it works even with generic classes. + """ + + def __init__(self, field1: str): + self.field1 = field1 + self.field2 = "foo" + + @staticmethod + def from_temporal_json(json_obj: Any) -> MyGenericClass: + return MyGenericClass(str(json_obj) + "_from_json") + + def to_temporal_json(self) -> Any: + return self.field1 + "_to_json" + + def assert_expected(self, value: str) -> None: + # Part of the assertion is that this is the right type, which is + # confirmed just by calling the method. We also check the field. + assert str(self.field1) == value + + @activity.defn async def data_class_typed_activity(param: MyDataClass) -> MyDataClass: param.assert_expected() return param +@activity.defn +async def generic_class_typed_activity( + param: MyGenericClass[str], +) -> MyGenericClass[str]: + return param + + +@activity.defn +async def generic_class_typed_activity_with_static( + param: MyGenericClassWithStatic[str], +) -> MyGenericClassWithStatic[str]: + return param + + @runtime_checkable @workflow.defn(name="DataClassTypedWorkflow") class DataClassTypedWorkflowProto(Protocol): @@ -2306,6 +2367,24 @@ async def run(self, param: MyDataClass) -> MyDataClass: start_to_close_timeout=timedelta(seconds=30), ) param.assert_expected() + generic_param = MyGenericClass[str]("some_value2") + generic_param = await workflow.execute_activity( + generic_class_typed_activity, + generic_param, + start_to_close_timeout=timedelta(seconds=30), + ) + generic_param.assert_expected( + "some_value2_to_json_from_json_to_json_from_json" + ) + generic_param_s = MyGenericClassWithStatic[str]("some_value2") + generic_param_s = await workflow.execute_local_activity( + generic_class_typed_activity_with_static, + generic_param_s, + start_to_close_timeout=timedelta(seconds=30), + ) + generic_param_s.assert_expected( + "some_value2_to_json_from_json_to_json_from_json" + ) child_handle = await workflow.start_child_workflow( DataClassTypedWorkflow.run, param, @@ -2348,7 +2427,13 @@ async def test_workflow_dataclass_typed(client: Client, env: WorkflowEnvironment "Java test server: https://github.com/temporalio/sdk-core/issues/390" ) async with new_worker( - client, DataClassTypedWorkflow, activities=[data_class_typed_activity] + client, + DataClassTypedWorkflow, + activities=[ + data_class_typed_activity, + generic_class_typed_activity, + generic_class_typed_activity_with_static, + ], ) as worker: val = MyDataClass(field1="some value") handle = await client.start_workflow( @@ -2373,7 +2458,13 @@ async def test_workflow_separate_protocol(client: Client): # This test is to confirm that protocols can be used as "interfaces" for # when the workflow impl is absent async with new_worker( - client, DataClassTypedWorkflow, activities=[data_class_typed_activity] + client, + DataClassTypedWorkflow, + activities=[ + data_class_typed_activity, + generic_class_typed_activity, + generic_class_typed_activity_with_static, + ], ) as worker: assert isinstance(DataClassTypedWorkflow(), DataClassTypedWorkflowProto) val = MyDataClass(field1="some value") @@ -2395,7 +2486,13 @@ async def test_workflow_separate_abstract(client: Client): # This test is to confirm that abstract classes can be used as "interfaces" # for when the workflow impl is absent async with new_worker( - client, DataClassTypedWorkflow, activities=[data_class_typed_activity] + client, + DataClassTypedWorkflow, + activities=[ + data_class_typed_activity, + generic_class_typed_activity, + generic_class_typed_activity_with_static, + ], ) as worker: assert issubclass(DataClassTypedWorkflow, DataClassTypedWorkflowAbstract) val = MyDataClass(field1="some value")