Skip to content

Commit c4f4874

Browse files
committed
Add DataConverter backed by msgspec as the default DataConverter
1 parent 4e16d4a commit c4f4874

File tree

5 files changed

+200
-0
lines changed

5 files changed

+200
-0
lines changed

cadence/data_converter.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
from abc import abstractmethod
2+
from typing import Protocol, List, Type, Any
3+
4+
from cadence.api.v1.common_pb2 import Payload
5+
from json import JSONDecoder
6+
from msgspec import json, convert
7+
8+
9+
class DataConverter(Protocol):
10+
11+
@abstractmethod
12+
async def from_data(self, payload: Payload, type_hints: List[Type]) -> List[Any]:
13+
raise NotImplementedError()
14+
15+
@abstractmethod
16+
async def to_data(self, values: List[Any]) -> Payload:
17+
raise NotImplementedError()
18+
19+
class DefaultDataConverter(DataConverter):
20+
def __init__(self):
21+
self._encoder = json.Encoder()
22+
self._decoder = json.Decoder()
23+
self._fallback_decoder = JSONDecoder(strict=False)
24+
25+
26+
async def from_data(self, payload: Payload, type_hints: List[Type]) -> List[Any]:
27+
if len(type_hints) > 1:
28+
payload_str = payload.data.decode()
29+
# Handle payloads from the Go client, which are a series of json objects rather than a json array
30+
if not payload_str.startswith("["):
31+
return self._decode_whitespace_delimited(payload_str, type_hints)
32+
else:
33+
as_list = self._decoder.decode(payload_str)
34+
return DefaultDataConverter._convert_into(as_list, type_hints)
35+
36+
as_value = self._decoder.decode(payload.data)
37+
return DefaultDataConverter._convert_into([as_value], type_hints)
38+
39+
40+
def _decode_whitespace_delimited(self, payload: str, type_hints: List[Type]) -> List[Any]:
41+
results = []
42+
start, end = 0, len(payload)
43+
while start < end and len(results) < len(type_hints):
44+
remaining = payload[start:end]
45+
(value, value_end) = self._fallback_decoder.raw_decode(remaining)
46+
start += value_end + 1
47+
results.append(value)
48+
49+
return DefaultDataConverter._convert_into(results, type_hints)
50+
51+
@staticmethod
52+
def _convert_into(values: List[Any], type_hints: List[Type]) -> List[Any]:
53+
results = []
54+
for i, type_hint in enumerate(type_hints):
55+
if i < len(values):
56+
value = convert(values[i], type_hint)
57+
else:
58+
value = DefaultDataConverter._get_default(type_hint)
59+
60+
results.append(value)
61+
62+
return results
63+
64+
@staticmethod
65+
def _get_default(type_hint: Type) -> Any:
66+
if type_hint in (int, float):
67+
return 0
68+
if type_hint == bool:
69+
return False
70+
return None
71+
72+
73+
async def to_data(self, values: List[Any]) -> Payload:
74+
data_value = values
75+
# Don't wrap single values in a json array
76+
if len(values) == 1:
77+
data_value = values[0]
78+
79+
return Payload(data=self._encoder.encode(data_value))
80+

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ requires-python = ">=3.11,<3.14"
2828
dependencies = [
2929
"grpcio>=1.50.0",
3030
"grpcio-tools>=1.50.0",
31+
"msgspec>=0.19.0",
3132
"protobuf==5.29.1",
3233
"typing-extensions>=4.0.0",
3334
]

tests/cadence/__init__.py

Whitespace-only changes.

tests/cadence/data_converter_test.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import dataclasses
2+
from typing import Any, Type
3+
4+
import pytest
5+
6+
from cadence.api.v1.common_pb2 import Payload
7+
from cadence.data_converter import DefaultDataConverter
8+
from msgspec import json
9+
10+
@dataclasses.dataclass
11+
class TestDataClass:
12+
foo: str = "foo"
13+
bar: int = -1
14+
baz: 'TestDataClass' = None
15+
16+
@pytest.mark.parametrize(
17+
"json,types,expected",
18+
[
19+
pytest.param(
20+
'"Hello world"', [str], ["Hello world"], id="happy path"
21+
),
22+
pytest.param(
23+
'"Hello" "world"', [str, str], ["Hello", "world"], id="space delimited"
24+
),
25+
pytest.param(
26+
'["Hello", "world"]', [str, str], ["Hello", "world"], id="json array"
27+
),
28+
pytest.param(
29+
"[1]", [int, int], [1, 0], id="ints"
30+
),
31+
pytest.param(
32+
"[1.5]", [float, float], [1.5, 0.0], id="floats"
33+
),
34+
pytest.param(
35+
"[true]", [bool, bool], [True, False], id="bools"
36+
),
37+
pytest.param(
38+
'[{"foo": "hello world", "bar": 42, "baz": {"bar": 43}}]', [TestDataClass, TestDataClass], [TestDataClass("hello world", 42, TestDataClass(bar=43)), None], id="data classes"
39+
),
40+
pytest.param(
41+
'[{"foo": "hello world"}]', [dict, dict], [{"foo": "hello world"}, None], id="dicts"
42+
),
43+
pytest.param(
44+
'[{"foo": 52}]', [dict[str, int], dict], [{"foo": 52}, None], id="generic dicts"
45+
),
46+
pytest.param(
47+
'[["hello"]]', [list[str], list[str]], [["hello"], None], id="lists"
48+
),
49+
pytest.param(
50+
'[["hello"]]', [set[str], set[str]], [{"hello"}, None], id="sets"
51+
),
52+
pytest.param(
53+
'["hello", "world"]', [list[str]], [["hello", "world"]], id="list"
54+
),
55+
pytest.param(
56+
'{"foo": "bar"} {"bar": 100} ["hello"] "world"', [TestDataClass, TestDataClass, list[str], str],
57+
[TestDataClass(foo="bar"), TestDataClass(bar=100), ["hello"], "world"], id="space delimited mix"
58+
),
59+
]
60+
)
61+
@pytest.mark.asyncio
62+
async def test_data_converter_from_data(json: str, types: list[Type], expected: list[Any]):
63+
converter = DefaultDataConverter()
64+
actual = await converter.from_data(Payload(data=json.encode()), types)
65+
assert expected == actual
66+
67+
@pytest.mark.parametrize(
68+
"values,expected",
69+
[
70+
pytest.param(
71+
["hello world"], '"hello world"', id="happy path"
72+
),
73+
pytest.param(
74+
["hello", "world"], '["hello", "world"]', id="multiple values"
75+
),
76+
pytest.param(
77+
[TestDataClass()], '{"foo": "foo", "bar": -1, "baz": null}', id="data classes"
78+
),
79+
]
80+
)
81+
@pytest.mark.asyncio
82+
async def test_data_converter_to_data(values: list[Any], expected: str):
83+
converter = DefaultDataConverter()
84+
actual = await converter.to_data(values)
85+
# Parse both rather than trying to compare strings
86+
actual_parsed = json.decode(actual.data)
87+
expected_parsed = json.decode(expected)
88+
assert expected_parsed == actual_parsed

uv.lock

Lines changed: 31 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)