Skip to content

Commit bd82b6b

Browse files
committed
Add 'to_json' and 'from_json' to PlutusData. This helps us to read and write datum when it can only be communicated in json format by some tools, such cardano-db-sync and cardano-cli.
1 parent baf2b57 commit bd82b6b

File tree

3 files changed

+199
-3
lines changed

3 files changed

+199
-3
lines changed

pycardano/plutus.py

Lines changed: 113 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@
33
from __future__ import annotations
44

55
import inspect
6+
import json
67
from dataclasses import dataclass, fields
78
from enum import Enum
8-
from typing import ClassVar, Optional, Type, TypeVar
9+
from typing import ClassVar, Optional, Type, TypeVar, Union
910

1011
import cbor2
1112
from cbor2 import CBORTag
@@ -315,6 +316,117 @@ def hash(self) -> DatumHash:
315316
blake2b(self.to_cbor("bytes"), DATUM_HASH_SIZE, encoder=RawEncoder)
316317
)
317318

319+
def to_json(self, **kwargs) -> str:
320+
"""Convert to a json string
321+
322+
Args:
323+
**kwargs: Extra key word arguments to be passed to `json.dumps()`
324+
325+
Returns:
326+
str: a JSON encoded PlutusData.
327+
"""
328+
329+
def _dfs(obj):
330+
"""
331+
Reference of Haskell's implementation:
332+
https://github.com/input-output-hk/cardano-node/blob/baa9b5e59c5d448d475f94cc88a31a5857c2bda5/cardano-api/
333+
src/Cardano/Api/ScriptData.hs#L449-L474
334+
"""
335+
if isinstance(obj, int):
336+
return {"int": obj}
337+
elif isinstance(obj, bytes):
338+
return {"bytes": obj.hex()}
339+
elif isinstance(obj, list):
340+
return [_dfs(item) for item in obj]
341+
elif isinstance(obj, IndefiniteList):
342+
return {"list": [_dfs(item) for item in obj.items]}
343+
elif isinstance(obj, dict):
344+
return {"map": [{"v": _dfs(v), "k": _dfs(k)} for k, v in obj.items()]}
345+
elif isinstance(obj, PlutusData):
346+
return {
347+
"constructor": obj.CONSTR_ID,
348+
"fields": _dfs([getattr(obj, f.name) for f in fields(obj)]),
349+
}
350+
else:
351+
raise TypeError(f"Unexpected type {type(obj)}")
352+
353+
return json.dumps(_dfs(self), **kwargs)
354+
355+
@classmethod
356+
def from_dict(cls: Type[PData], data: dict) -> PData:
357+
"""Convert a dictionary to PlutusData
358+
359+
Args:
360+
data (dict): A dictionary.
361+
362+
Returns:
363+
PlutusData: Restored PlutusData.
364+
"""
365+
366+
def _dfs(obj):
367+
if isinstance(obj, dict):
368+
if "constructor" in obj:
369+
if obj["constructor"] != cls.CONSTR_ID:
370+
raise DeserializeException(
371+
f"Mismatch between constructors, expect: {cls.CONSTR_ID}, "
372+
f"got: {obj['constructor']} instead."
373+
)
374+
converted_fields = []
375+
for f, f_info in zip(obj["fields"], fields(cls)):
376+
if inspect.isclass(f_info.type) and issubclass(
377+
f_info.type, PlutusData
378+
):
379+
converted_fields.append(f_info.type.from_dict(f))
380+
elif (
381+
hasattr(f_info.type, "__origin__")
382+
and f_info.type.__origin__ is Union
383+
):
384+
t_args = f_info.type.__args__
385+
found_match = False
386+
for t in t_args:
387+
if (
388+
inspect.isclass(t)
389+
and issubclass(t, PlutusData)
390+
and t.CONSTR_ID == f["constructor"]
391+
):
392+
converted_fields.append(t.from_dict(f))
393+
found_match = True
394+
break
395+
if not found_match:
396+
raise DeserializeException(
397+
f"Unexpected data structure: {f}."
398+
)
399+
else:
400+
converted_fields.append(_dfs(f))
401+
return cls(*converted_fields)
402+
elif "map" in obj:
403+
return {_dfs(pair["k"]): _dfs(pair["v"]) for pair in obj["map"]}
404+
elif "int" in obj:
405+
return obj["int"]
406+
elif "bytes" in obj:
407+
return bytes.fromhex(obj["bytes"])
408+
elif "list" in obj:
409+
return IndefiniteList([_dfs(item) for item in obj["list"]])
410+
else:
411+
raise DeserializeException(f"Unexpected data structure: {obj}")
412+
else:
413+
raise TypeError(f"Unexpected data type: {type(obj)}")
414+
415+
return _dfs(data)
416+
417+
@classmethod
418+
def from_json(cls: Type[PData], data: str) -> PData:
419+
"""Restore a json encoded string to a PlutusData.
420+
421+
Args:
422+
data (str): An encoded json string.
423+
424+
Returns:
425+
PlutusData: The restored PlutusData.
426+
"""
427+
obj = json.loads(data)
428+
return cls.from_dict(obj)
429+
318430

319431
class RedeemerTag(CBORSerializable, Enum):
320432
"""

pycardano/serialization.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,17 @@ def to_primitive(self) -> Primitive:
143143
CBOR primitive types.
144144
"""
145145
result = self.to_shallow_primitive()
146-
container_types = (dict, OrderedDict, defaultdict, set, frozenset, tuple, list)
146+
container_types = (
147+
dict,
148+
OrderedDict,
149+
defaultdict,
150+
set,
151+
frozenset,
152+
tuple,
153+
list,
154+
CBORTag,
155+
IndefiniteList,
156+
)
147157

148158
def _helper(value):
149159
if isinstance(value, CBORSerializable):
@@ -169,6 +179,8 @@ def _dfs(value):
169179
return tuple([_helper(k) for k in value])
170180
elif isinstance(value, list):
171181
return [_helper(k) for k in value]
182+
elif isinstance(value, IndefiniteList):
183+
return IndefiniteList([_helper(k) for k in value.items])
172184
elif isinstance(value, CBORTag):
173185
return CBORTag(value.tag, _helper(value.value))
174186
else:

test/pycardano/test_plutus.py

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
from dataclasses import dataclass
22
from test.pycardano.util import check_two_way_cbor
3-
from typing import List, Union
3+
from typing import Union
44

5+
import pytest
6+
7+
from pycardano.exception import DeserializeException, SerializeException
58
from pycardano.plutus import (
69
COST_MODELS,
710
ExecutionUnits,
@@ -67,6 +70,75 @@ def test_plutus_data():
6770
check_two_way_cbor(my_vesting)
6871

6972

73+
def test_plutus_data_json():
74+
key_hash = bytes.fromhex("c2ff616e11299d9094ce0a7eb5b7284b705147a822f4ffbd471f971a")
75+
deadline = 1643235300000
76+
testa = BigTest(MyTest(123, b"1234", IndefiniteList([4, 5, 6]), {1: b"1", 2: b"2"}))
77+
testb = LargestTest()
78+
79+
my_vesting = VestingParam(
80+
beneficiary=key_hash, deadline=deadline, testa=testa, testb=testb
81+
)
82+
83+
encoded_json = my_vesting.to_json(separators=(",", ":"))
84+
85+
assert (
86+
'{"constructor":1,"fields":[{"bytes":"c2ff616e11299d9094ce0a7eb5b7284b705147a822f4ffbd471f971a"},'
87+
'{"int":1643235300000},{"constructor":8,"fields":[{"constructor":130,"fields":[{"int":123},'
88+
'{"bytes":"31323334"},{"list":[{"int":4},{"int":5},{"int":6}]},{"map":[{"v":{"bytes":"31"},'
89+
'"k":{"int":1}},{"v":{"bytes":"32"},"k":{"int":2}}]}]}]},{"constructor":9,"fields":[]}]}'
90+
== encoded_json
91+
)
92+
93+
assert my_vesting == VestingParam.from_json(encoded_json)
94+
95+
96+
def test_plutus_data_to_json_wrong_type():
97+
test = MyTest(123, b"1234", IndefiniteList([4, 5, 6]), {1: b"1", 2: b"2"})
98+
test.a = "123"
99+
with pytest.raises(TypeError):
100+
test.to_json()
101+
102+
103+
def test_plutus_data_from_json_wrong_constructor():
104+
test = (
105+
'{"constructor": 129, "fields": [{"int": 123}, {"bytes": "31323334"}, '
106+
'{"list": [{"int": 4}, {"int": 5}, {"int": 6}]}, {"map": [{"v": {"bytes": "31"}, '
107+
'"k": {"int": 1}}, {"v": {"bytes": "32"}, "k": {"int": 2}}]}]}'
108+
)
109+
with pytest.raises(DeserializeException):
110+
MyTest.from_json(test)
111+
112+
test2 = (
113+
'{"constructor":1,"fields":[{"bytes":"c2ff616e11299d9094ce0a7eb5b7284b705147a822f4ffbd471f971a"},'
114+
'{"int":1643235300000},{"constructor":22,"fields":[{"constructor":130,"fields":[{"int":123},'
115+
'{"bytes":"31323334"},{"list":[{"int":4},{"int":5},{"int":6}]},{"map":[{"v":{"bytes":"31"},'
116+
'"k":{"int":1}},{"v":{"bytes":"32"},"k":{"int":2}}]}]}]},{"constructor":23,"fields":[]}]}'
117+
)
118+
with pytest.raises(DeserializeException):
119+
VestingParam.from_json(test2)
120+
121+
122+
def test_plutus_data_from_json_wrong_data_structure():
123+
test = (
124+
'{"constructor": 130, "fields": [{"int": 123}, {"bytes": "31323334"}, '
125+
'{"wrong_list": [{"int": 4}, {"int": 5}, {"int": 6}]}, {"map": [{"v": {"bytes": "31"}, '
126+
'"k": {"int": 1}}, {"v": {"bytes": "32"}, "k": {"int": 2}}]}]}'
127+
)
128+
with pytest.raises(DeserializeException):
129+
MyTest.from_json(test)
130+
131+
132+
def test_plutus_data_from_json_wrong_data_structure_type():
133+
test = (
134+
'[{"constructor": 130, "fields": [{"int": 123}, {"bytes": "31323334"}, '
135+
'{"list": [{"int": 4}, {"int": 5}, {"int": 6}]}, {"map": [{"v": {"bytes": "31"}, '
136+
'"k": {"int": 1}}, {"v": {"bytes": "32"}, "k": {"int": 2}}]}]}]'
137+
)
138+
with pytest.raises(TypeError):
139+
MyTest.from_json(test)
140+
141+
70142
def test_plutus_data_hash():
71143
assert (
72144
bytes.fromhex(

0 commit comments

Comments
 (0)