Skip to content

Commit b52e04e

Browse files
authored
Add JSONTypeConverter (#269)
Fixes #264
1 parent 3c8ee0a commit b52e04e

File tree

3 files changed

+213
-5
lines changed

3 files changed

+213
-5
lines changed

README.md

Lines changed: 101 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ event loop. This means task management, sleep, cancellation, etc have all been d
4747
- [Usage](#usage)
4848
- [Client](#client)
4949
- [Data Conversion](#data-conversion)
50+
- [Custom Type Data Conversion](#custom-type-data-conversion)
5051
- [Workers](#workers)
5152
- [Workflows](#workflows)
5253
- [Definition](#definition)
@@ -268,21 +269,118 @@ The default data converter supports converting multiple types including:
268269
* Iterables including ones JSON dump may not support by default, e.g. `set`
269270
* Any class with a `dict()` method and a static `parse_obj()` method, e.g.
270271
[Pydantic models](https://pydantic-docs.helpmanual.io/usage/models)
271-
* Note, this doesn't mean every Pydantic field can be converted, only fields which the data converter supports
272+
* The default data converter is deprecated for Pydantic models and will warn if used since not all fields work.
273+
See [this sample](https://github.com/temporalio/samples-python/tree/main/pydantic_converter) for the recommended
274+
approach.
272275
* [IntEnum, StrEnum](https://docs.python.org/3/library/enum.html) based enumerates
273276
* [UUID](https://docs.python.org/3/library/uuid.html)
274277

275278
This notably doesn't include any `date`, `time`, or `datetime` objects as they may not work across SDKs.
276279

280+
Users are strongly encouraged to use a single `dataclass` for parameter and return types so fields with defaults can be
281+
easily added without breaking compatibility.
282+
277283
Classes with generics may not have the generics properly resolved. The current implementation, similar to Pydantic, does
278284
not have generic type resolution. Users should use concrete types.
279285

286+
##### Custom Type Data Conversion
287+
280288
For converting from JSON, the workflow/activity type hint is taken into account to convert to the proper type. Care has
281289
been taken to support all common typings including `Optional`, `Union`, all forms of iterables and mappings, `NewType`,
282290
etc in addition to the regular JSON values mentioned before.
283291

284-
Users are strongly encouraged to use a single `dataclass` for parameter and return types so fields with defaults can be
285-
easily added without breaking compatibility.
292+
Data converters contain a reference to a payload converter class that is used to convert to/from payloads/values. This
293+
is a class and not an instance because it is instantiated on every workflow run inside the sandbox. The payload
294+
converter is usually a `CompositePayloadConverter` which contains a multiple `EncodingPayloadConverter`s it uses to try
295+
to serialize/deserialize payloads. Upon serialization, each `EncodingPayloadConverter` is tried until one succeeds. The
296+
`EncodingPayloadConverter` provides an "encoding" string serialized onto the payload so that, upon deserialization, the
297+
specific `EncodingPayloadConverter` for the given "encoding" is used.
298+
299+
The default data converter uses the `DefaultPayloadConverter` which is simply a `CompositePayloadConverter` with a known
300+
set of default `EncodingPayloadConverter`s. To implement a custom encoding for a custom type, a new
301+
`EncodingPayloadConverter` can be created for the new type. For example, to support `IPv4Address` types:
302+
303+
```python
304+
class IPv4AddressEncodingPayloadConverter(EncodingPayloadConverter):
305+
@property
306+
def encoding(self) -> str:
307+
return "text/ipv4-address"
308+
309+
def to_payload(self, value: Any) -> Optional[Payload]:
310+
if isinstance(value, ipaddress.IPv4Address):
311+
return Payload(
312+
metadata={"encoding": self.encoding.encode()},
313+
data=str(value).encode(),
314+
)
315+
else:
316+
return None
317+
318+
def from_payload(self, payload: Payload, type_hint: Optional[Type] = None) -> Any:
319+
assert not type_hint or type_hint is ipaddress.IPv4Address
320+
return ipaddress.IPv4Address(payload.data.decode())
321+
322+
class IPv4AddressPayloadConverter(CompositePayloadConverter):
323+
def __init__(self) -> None:
324+
# Just add ours as first before the defaults
325+
super().__init__(
326+
IPv4AddressEncodingPayloadConverter(),
327+
*DefaultPayloadConverter.default_encoding_payload_converters,
328+
)
329+
330+
my_data_converter = dataclasses.replace(
331+
DataConverter.default,
332+
payload_converter_class=IPv4AddressPayloadConverter,
333+
)
334+
```
335+
336+
Imports are left off for brevity.
337+
338+
This is good for many custom types. However, sometimes you want to override the behavior of the just the existing JSON
339+
encoding payload converter to support a new type. It is already the last encoding data converter in the list, so it's
340+
the fall-through behavior for any otherwise unknown type. Customizing the existing JSON converter has the benefit of
341+
making the type work in lists, unions, etc.
342+
343+
The `JSONPlainPayloadConverter` uses the Python [json](https://docs.python.org/3/library/json.html) library with an
344+
advanced JSON encoder by default and a custom value conversion method to turn `json.load`ed values to their type hints.
345+
The conversion can be customized for serialization with a custom `json.JSONEncoder` and deserialization with a custom
346+
`JSONTypeConverter`. For example, to support `IPv4Address` types in existing JSON conversion:
347+
348+
```python
349+
class IPv4AddressJSONEncoder(AdvancedJSONEncoder):
350+
def default(self, o: Any) -> Any:
351+
if isinstance(o, ipaddress.IPv4Address):
352+
return str(o)
353+
return super().default(o)
354+
class IPv4AddressJSONTypeConverter(JSONTypeConverter):
355+
def to_typed_value(
356+
self, hint: Type, value: Any
357+
) -> Union[Optional[Any], _JSONTypeConverterUnhandled]:
358+
if issubclass(hint, ipaddress.IPv4Address):
359+
return ipaddress.IPv4Address(value)
360+
return JSONTypeConverter.Unhandled
361+
362+
class IPv4AddressPayloadConverter(CompositePayloadConverter):
363+
def __init__(self) -> None:
364+
# Replace default JSON plain with our own that has our encoder and type
365+
# converter
366+
json_converter = JSONPlainPayloadConverter(
367+
encoder=IPv4AddressJSONEncoder,
368+
custom_type_converters=[IPv4AddressJSONTypeConverter()],
369+
)
370+
super().__init__(
371+
*[
372+
c if not isinstance(c, JSONPlainPayloadConverter) else json_converter
373+
for c in DefaultPayloadConverter.default_encoding_payload_converters
374+
]
375+
)
376+
377+
my_data_converter = dataclasses.replace(
378+
DataConverter.default,
379+
payload_converter_class=IPv4AddressPayloadConverter,
380+
)
381+
```
382+
383+
Now `IPv4Address` can be used in type hints including collections, optionals, etc.
286384

287385
### Workers
288386

temporalio/converter.py

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
Dict,
2424
List,
2525
Mapping,
26+
NewType,
2627
Optional,
2728
Sequence,
2829
Tuple,
@@ -458,18 +459,22 @@ def __init__(
458459
encoder: Optional[Type[json.JSONEncoder]] = AdvancedJSONEncoder,
459460
decoder: Optional[Type[json.JSONDecoder]] = None,
460461
encoding: str = "json/plain",
462+
custom_type_converters: Sequence[JSONTypeConverter] = [],
461463
) -> None:
462464
"""Initialize a JSON data converter.
463465
464466
Args:
465467
encoder: Custom encoder class object to use.
466468
decoder: Custom decoder class object to use.
467469
encoding: Encoding name to use.
470+
custom_type_converters: Set of custom type converters that are used
471+
when converting from a payload to type-hinted values.
468472
"""
469473
super().__init__()
470474
self._encoder = encoder
471475
self._decoder = decoder
472476
self._encoding = encoding
477+
self._custom_type_converters = custom_type_converters
473478

474479
@property
475480
def encoding(self) -> str:
@@ -500,12 +505,43 @@ def from_payload(
500505
try:
501506
obj = json.loads(payload.data, cls=self._decoder)
502507
if type_hint:
503-
obj = value_to_type(type_hint, obj)
508+
obj = value_to_type(type_hint, obj, self._custom_type_converters)
504509
return obj
505510
except json.JSONDecodeError as err:
506511
raise RuntimeError("Failed parsing") from err
507512

508513

514+
_JSONTypeConverterUnhandled = NewType("_JSONTypeConverterUnhandled", object)
515+
516+
517+
class JSONTypeConverter(ABC):
518+
"""Converter for converting an object from Python :py:func:`json.loads`
519+
result (e.g. scalar, list, or dict) to a known type.
520+
"""
521+
522+
Unhandled = _JSONTypeConverterUnhandled(object())
523+
"""Sentinel value that must be used as the result of
524+
:py:meth:`to_typed_value` to say the given type is not handled by this
525+
converter."""
526+
527+
@abstractmethod
528+
def to_typed_value(
529+
self, hint: Type, value: Any
530+
) -> Union[Optional[Any], _JSONTypeConverterUnhandled]:
531+
"""Convert the given value to a type based on the given hint.
532+
533+
Args:
534+
hint: Type hint to use to help in converting the value.
535+
value: Value as returned by :py:func:`json.loads`. Usually a scalar,
536+
list, or dict.
537+
538+
Returns:
539+
The converted value or :py:attr:`Unhandled` if this converter does
540+
not handle this situation.
541+
"""
542+
raise NotImplementedError
543+
544+
509545
class PayloadCodec(ABC):
510546
"""Codec for encoding/decoding to/from bytes.
511547
@@ -1112,7 +1148,11 @@ def decode_search_attributes(
11121148
return ret
11131149

11141150

1115-
def value_to_type(hint: Type, value: Any) -> Any:
1151+
def value_to_type(
1152+
hint: Type,
1153+
value: Any,
1154+
custom_converters: Sequence[JSONTypeConverter] = [],
1155+
) -> Any:
11161156
"""Convert a given value to the given type hint.
11171157
11181158
This is used internally to convert a raw JSON loaded value to a specific
@@ -1121,13 +1161,23 @@ def value_to_type(hint: Type, value: Any) -> Any:
11211161
Args:
11221162
hint: Type hint to convert the value to.
11231163
value: Raw value (e.g. primitive, dict, or list) to convert from.
1164+
custom_converters: Set of custom converters to try before doing default
1165+
conversion. Converters are tried in order and the first value that
1166+
is not :py:attr:`JSONTypeConverter.Unhandled` will be returned from
1167+
this function instead of doing default behavior.
11241168
11251169
Returns:
11261170
Converted value.
11271171
11281172
Raises:
11291173
TypeError: Unable to convert to the given hint.
11301174
"""
1175+
# Try custom converters
1176+
for conv in custom_converters:
1177+
ret = conv.to_typed_value(hint, value)
1178+
if ret is not JSONTypeConverter.Unhandled:
1179+
return ret
1180+
11311181
# Any or primitives
11321182
if hint is Any:
11331183
return value

tests/test_converter.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import dataclasses
4+
import ipaddress
45
import logging
56
import sys
67
import traceback
@@ -22,6 +23,7 @@
2223
Set,
2324
Text,
2425
Tuple,
26+
Type,
2527
Union,
2628
)
2729
from uuid import UUID, uuid4
@@ -37,11 +39,16 @@
3739
from temporalio.api.common.v1 import Payloads
3840
from temporalio.api.failure.v1 import Failure
3941
from temporalio.converter import (
42+
AdvancedJSONEncoder,
4043
BinaryProtoPayloadConverter,
44+
CompositePayloadConverter,
4145
DataConverter,
4246
DefaultFailureConverterWithEncodedAttributes,
47+
DefaultPayloadConverter,
4348
JSONPlainPayloadConverter,
49+
JSONTypeConverter,
4450
PayloadCodec,
51+
_JSONTypeConverterUnhandled,
4552
decode_search_attributes,
4653
encode_search_attribute_values,
4754
)
@@ -516,3 +523,56 @@ async def test_failure_encoded_attributes():
516523
not in failure.application_failure_info.details.payloads[0].metadata
517524
)
518525
assert failure == orig_failure
526+
527+
528+
class IPv4AddressPayloadConverter(CompositePayloadConverter):
529+
def __init__(self) -> None:
530+
# Replace default JSON plain with our own that has our type converter
531+
json_converter = JSONPlainPayloadConverter(
532+
encoder=IPv4AddressJSONEncoder,
533+
custom_type_converters=[IPv4AddressJSONTypeConverter()],
534+
)
535+
super().__init__(
536+
*[
537+
c if not isinstance(c, JSONPlainPayloadConverter) else json_converter
538+
for c in DefaultPayloadConverter.default_encoding_payload_converters
539+
]
540+
)
541+
542+
543+
class IPv4AddressJSONEncoder(AdvancedJSONEncoder):
544+
def default(self, o: Any) -> Any:
545+
if isinstance(o, ipaddress.IPv4Address):
546+
return str(o)
547+
return super().default(o)
548+
549+
550+
class IPv4AddressJSONTypeConverter(JSONTypeConverter):
551+
def to_typed_value(
552+
self, hint: Type, value: Any
553+
) -> Union[Optional[Any], _JSONTypeConverterUnhandled]:
554+
if issubclass(hint, ipaddress.IPv4Address):
555+
return ipaddress.IPv4Address(value)
556+
return JSONTypeConverter.Unhandled
557+
558+
559+
async def test_json_type_converter():
560+
addr = ipaddress.IPv4Address("1.2.3.4")
561+
custom_conv = dataclasses.replace(
562+
DataConverter.default, payload_converter_class=IPv4AddressPayloadConverter
563+
)
564+
565+
# Fails to encode with default
566+
with pytest.raises(TypeError):
567+
await DataConverter.default.encode([addr])
568+
569+
# But encodes with custom
570+
payload = (await custom_conv.encode([addr]))[0]
571+
assert '"1.2.3.4"' == payload.data.decode()
572+
573+
# Fails to decode with default
574+
with pytest.raises(TypeError):
575+
await DataConverter.default.decode([payload], [ipaddress.IPv4Address])
576+
577+
# But decodes with custom
578+
assert addr == (await custom_conv.decode([payload], [ipaddress.IPv4Address]))[0]

0 commit comments

Comments
 (0)