Skip to content
Draft
46 changes: 46 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
58 changes: 58 additions & 0 deletions temporalio/converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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]]
Expand Down Expand Up @@ -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)
Expand Down
103 changes: 100 additions & 3 deletions tests/worker/test_workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we add a test for @staticmethod too just in case?

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):
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand All @@ -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")
Expand All @@ -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")
Expand Down
Loading