Skip to content

Commit 36483dc

Browse files
Added IndefiniteDecoder for round trip plutusdata serialization (#431)
* Added IndefiniteDecoder for round trip plutusdata serialization * Fixed qa issues * Force non-binary installation of cbor2 * Ensure pure cbor2 in CI * Added IndefiniteList to ArrayCBORSerializable primitives * Pass QA checks --------- Co-authored-by: Jerry <[email protected]>
1 parent a312c08 commit 36483dc

File tree

8 files changed

+104
-14
lines changed

8 files changed

+104
-14
lines changed

.github/workflows/main.yml

+3
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ jobs:
2828
- name: Install dependencies
2929
run: |
3030
poetry install
31+
- name: Ensure pure cbor2 is installed
32+
run: |
33+
make ensure-pure-cbor2
3134
- name: Run unit tests
3235
run: |
3336
poetry run pytest --doctest-modules --ignore=examples --cov=pycardano --cov-config=.coveragerc --cov-report=xml

.github/workflows/publish.yml

+3
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ jobs:
2424
- name: Install dependencies
2525
run: |
2626
poetry install
27+
- name: Ensure pure cbor2 is installed
28+
run: |
29+
make ensure-pure-cbor2
2730
- name: Lint with flake8
2831
run: |
2932
poetry run flake8 pycardano

.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ docs/build
55
dist
66
.mypy_cache
77
coverage.xml
8+
.cbor2_version
89

910
# IDE
1011
.idea

Makefile

+19-4
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,25 @@ export PRINT_HELP_PYSCRIPT
2323

2424
BROWSER := poetry run python -c "$$BROWSER_PYSCRIPT"
2525

26+
ensure-pure-cbor2: ## ensures cbor2 is installed with pure Python implementation
27+
@poetry run python -c "from importlib.metadata import version; \
28+
print(version('cbor2'))" > .cbor2_version
29+
@poetry run python -c "import cbor2, inspect; \
30+
print('Checking cbor2 implementation...'); \
31+
decoder_path = inspect.getfile(cbor2.CBORDecoder); \
32+
using_c_ext = decoder_path.endswith('.so'); \
33+
print(f'Implementation path: {decoder_path}'); \
34+
print(f'Using C extension: {using_c_ext}'); \
35+
exit(1 if using_c_ext else 0)" || \
36+
(echo "Reinstalling cbor2 with pure Python implementation..." && \
37+
poetry run pip uninstall -y cbor2 && \
38+
CBOR2_BUILD_C_EXTENSION=0 poetry run pip install --no-binary cbor2 "cbor2==$$(cat .cbor2_version)" --force-reinstall && \
39+
rm .cbor2_version)
40+
2641
help:
2742
@python -c "$$PRINT_HELP_PYSCRIPT" < $(MAKEFILE_LIST)
2843

29-
cov: ## check code coverage
44+
cov: ensure-pure-cbor2 ## check code coverage
3045
poetry run pytest -n 4 --cov pycardano
3146

3247
cov-html: cov ## check code coverage and generate an html report
@@ -54,7 +69,7 @@ clean-test: ## remove test and coverage artifacts
5469
rm -fr cov_html/
5570
rm -fr .pytest_cache
5671

57-
test: ## runs tests
72+
test: ensure-pure-cbor2 ## runs tests
5873
poetry run pytest -vv -n 4
5974

6075
test-integration: ## runs integration tests
@@ -63,7 +78,7 @@ test-integration: ## runs integration tests
6378
test-single: ## runs tests with "single" markers
6479
poetry run pytest -s -vv -m single
6580

66-
qa: ## runs static analyses
81+
qa: ensure-pure-cbor2 ## runs static analyses
6782
poetry run flake8 pycardano
6883
poetry run mypy --install-types --non-interactive pycardano
6984
poetry run black --check .
@@ -78,6 +93,6 @@ docs: ## build the documentation
7893
poetry run sphinx-build docs/source docs/build/html
7994
$(BROWSER) docs/build/html/index.html
8095

81-
release: clean qa test format ## build dist version and release to pypi
96+
release: clean qa test format ensure-pure-cbor2 ## build dist version and release to pypi
8297
poetry build
8398
poetry publish

integration-test/run_tests.sh

+1
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ set -o pipefail
66
ROOT=$(pwd)
77

88
poetry install -C ..
9+
make ensure-pure-cbor2 -f ../Makefile
910
#poetry run pip install ogmios
1011

1112
##########

pycardano/serialization.py

+35-9
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
Iterable,
2222
List,
2323
Optional,
24+
Sequence,
2425
Set,
2526
Type,
2627
TypeVar,
@@ -46,7 +47,6 @@
4647
CBORTag,
4748
FrozenDict,
4849
dumps,
49-
loads,
5050
undefined,
5151
)
5252
from frozenlist import FrozenList
@@ -199,6 +199,22 @@ def wrapper(cls, value: Primitive):
199199
CBORBase = TypeVar("CBORBase", bound="CBORSerializable")
200200

201201

202+
def decode_array(self, subtype: int) -> Sequence[Any]:
203+
# Major tag 4
204+
length = self._decode_length(subtype, allow_indefinite=True)
205+
206+
if length is None:
207+
return IndefiniteList(cast(Primitive, self.decode_array(subtype=subtype)))
208+
else:
209+
return self.decode_array(subtype=subtype)
210+
211+
212+
try:
213+
cbor2._decoder.major_decoders[4] = decode_array
214+
except Exception as e:
215+
logger.warning("Failed to replace major decoder for indefinite array", e)
216+
217+
202218
def default_encoder(
203219
encoder: CBOREncoder, value: Union[CBORSerializable, IndefiniteList]
204220
):
@@ -265,7 +281,7 @@ class CBORSerializable:
265281
does not refer to itself, which could cause infinite loops.
266282
"""
267283

268-
def to_shallow_primitive(self) -> Primitive:
284+
def to_shallow_primitive(self) -> Union[Primitive, CBORSerializable]:
269285
"""
270286
Convert the instance to a CBOR primitive. If the primitive is a container, e.g. list, dict, the type of
271287
its elements could be either a Primitive or a CBORSerializable.
@@ -516,7 +532,11 @@ def from_cbor(cls, payload: Union[str, bytes]) -> CBORSerializable:
516532
"""
517533
if type(payload) is str:
518534
payload = bytes.fromhex(payload)
519-
value = loads(payload) # type: ignore
535+
536+
assert isinstance(payload, bytes)
537+
538+
value = cbor2.loads(payload)
539+
520540
return cls.from_primitive(value)
521541

522542
def __repr__(self):
@@ -538,7 +558,7 @@ def _restore_dataclass_field(
538558

539559
if "object_hook" in f.metadata:
540560
return f.metadata["object_hook"](v)
541-
return _restore_typed_primitive(f.type, v)
561+
return _restore_typed_primitive(cast(Any, f.type), v)
542562

543563

544564
def _restore_typed_primitive(
@@ -580,10 +600,14 @@ def _restore_typed_primitive(
580600
raise DeserializeException(
581601
f"List types need exactly one type argument, but got {t_args}"
582602
)
583-
t = t_args[0]
584-
if not isinstance(v, list):
603+
t_subtype = t_args[0]
604+
if not isinstance(v, (list, IndefiniteList)):
585605
raise DeserializeException(f"Expected type list but got {type(v)}")
586-
return IndefiniteList([_restore_typed_primitive(t, w) for w in v])
606+
v_list = [_restore_typed_primitive(t_subtype, w) for w in v]
607+
if t == IndefiniteList:
608+
return IndefiniteList(v_list)
609+
else:
610+
return v_list
587611
elif isclass(t) and t == ByteString:
588612
if not isinstance(v, bytes):
589613
raise DeserializeException(f"Expected type bytes but got {type(v)}")
@@ -712,8 +736,10 @@ def to_shallow_primitive(self) -> Primitive:
712736
return primitives
713737

714738
@classmethod
715-
@limit_primitive_type(list, tuple)
716-
def from_primitive(cls: Type[ArrayBase], values: Union[list, tuple]) -> ArrayBase:
739+
@limit_primitive_type(list, tuple, IndefiniteList)
740+
def from_primitive(
741+
cls: Type[ArrayBase], values: Union[list, tuple, IndefiniteList]
742+
) -> ArrayBase:
717743
"""Restore a primitive value to its original class type.
718744
719745
Args:

test/pycardano/test_serialization.py

+25-1
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
VerificationKeyWitness,
3131
)
3232
from pycardano.exception import DeserializeException, SerializeException
33-
from pycardano.plutus import PlutusV1Script, PlutusV2Script
33+
from pycardano.plutus import PlutusData, PlutusV1Script, PlutusV2Script
3434
from pycardano.serialization import (
3535
ArrayCBORSerializable,
3636
ByteString,
@@ -368,6 +368,30 @@ class Test1(CBORSerializable):
368368
obj.validate()
369369

370370

371+
def test_datum_raw_round_trip():
372+
@dataclass
373+
class TestDatum(PlutusData):
374+
CONSTR_ID = 0
375+
a: int
376+
b: List[bytes]
377+
378+
datum = TestDatum(a=1, b=[b"test", b"datum"])
379+
restored = RawPlutusData.from_cbor(datum.to_cbor())
380+
assert datum.to_cbor_hex() == restored.to_cbor_hex()
381+
382+
383+
def test_datum_round_trip():
384+
@dataclass
385+
class TestDatum(PlutusData):
386+
CONSTR_ID = 0
387+
a: int
388+
b: List[bytes]
389+
390+
datum = TestDatum(a=1, b=[b"test", b"datum"])
391+
restored = TestDatum.from_cbor(datum.to_cbor())
392+
assert datum.to_cbor_hex() == restored.to_cbor_hex()
393+
394+
371395
def test_wrong_primitive_type():
372396
@dataclass
373397
class Test1(MapCBORSerializable):

test/pycardano/test_transaction.py

+17
Original file line numberDiff line numberDiff line change
@@ -417,6 +417,23 @@ def test_multi_asset_comparison():
417417
a <= 1
418418

419419

420+
def test_datum_witness():
421+
@dataclass
422+
class TestDatum(PlutusData):
423+
CONSTR_ID = 0
424+
a: int
425+
b: bytes
426+
427+
tx_body = make_transaction_body()
428+
signed_tx = Transaction(
429+
tx_body,
430+
TransactionWitnessSet(vkey_witnesses=None, plutus_data=[TestDatum(1, b"test")]),
431+
)
432+
restored_tx = Transaction.from_cbor(signed_tx.to_cbor())
433+
434+
assert signed_tx.to_cbor_hex() == restored_tx.to_cbor_hex()
435+
436+
420437
def test_values():
421438
a = Value.from_primitive(
422439
[1, {b"1" * SCRIPT_HASH_SIZE: {b"Token1": 1, b"Token2": 2}}]

0 commit comments

Comments
 (0)