Skip to content

Added support for class specific custom conversion through to_temporal_json/from_temporal_json methods #819

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 13 commits into
base: main
Choose a base branch
from
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